Skip to content

Commit

Permalink
📝 document scope, spawn
Browse files Browse the repository at this point in the history
Spiff up the scope documentation. The `ScopeInternal` class was moved
to its own file so that it can be imported internally, but is not
exported from the main module.
  • Loading branch information
cowboyd committed Jan 9, 2025
1 parent 428bdde commit 049c77b
Show file tree
Hide file tree
Showing 5 changed files with 184 additions and 101 deletions.
86 changes: 86 additions & 0 deletions lib/scope-internal.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { Children, Generation } from "./contexts.ts";
import { Err, Ok, unbox } from "./result.ts";
import { createTask } from "./task.ts";
import type { Context, Operation, Scope, Task } from "./types.ts";

export function createScopeInternal(
parent?: Scope,
): [ScopeInternal, () => Operation<void>] {
let destructors = new Set<() => Operation<void>>();

let contexts: Record<string, unknown> = Object.create(
parent ? (parent as ScopeInternal).contexts : null,
);
let scope: ScopeInternal = Object.create({
[Symbol.toStringTag]: "Scope",
contexts,
get<T>(context: Context<T>): T | undefined {
return (contexts[context.name] ?? context.defaultValue) as T | undefined;
},
set<T>(context: Context<T>, value: T): T {
return contexts[context.name] = value;
},
expect<T>(context: Context<T>): T {
let value = scope.get(context);
if (typeof value === "undefined") {
let error = new Error(context.name);
error.name = `MissingContextError`;
throw error;
}
return value;
},
delete<T>(context: Context<T>): boolean {
return delete contexts[context.name];
},
hasOwn<T>(context: Context<T>): boolean {
return !!Reflect.getOwnPropertyDescriptor(contexts, context.name);
},
run<T>(operation: () => Operation<T>): Task<T> {
let { task, start } = createTask({ operation, owner: scope });
start();
return task;
},
spawn<T>(operation: () => Operation<T>): Operation<Task<T>> {
return {
*[Symbol.iterator]() {
let { task, start } = createTask({ operation, owner: scope });
start();
return task;
},
};
},

ensure(op: () => Operation<void>): () => void {
destructors.add(op);
return () => destructors.delete(op);
},
});

scope.set(Generation, scope.expect(Generation) + 1);
scope.set(Children, new Set());
parent?.expect(Children).add(scope);

let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {};

function* destroy(): Operation<void> {
parent?.expect(Children).delete(scope);
unbind();
let outcome = Ok();
for (let destructor of [...destructors].reverse()) {
try {
destructors.delete(destructor);
yield* destructor();
} catch (error) {
outcome = Err(error as Error);
}
}
unbox(outcome);
}

return [scope, destroy];
}

export interface ScopeInternal extends Scope {
contexts: Record<string, unknown>;
ensure(op: () => Operation<void>): () => void;
}
144 changes: 46 additions & 98 deletions lib/scope.ts
Original file line number Diff line number Diff line change
@@ -1,108 +1,56 @@
import { Children, Generation } from "./contexts.ts";
import type {
Context,
Effect,
Future,
Operation,
Scope,
Task,
} from "./types.ts";
import { Err, Ok, unbox } from "./result.ts";
import { createTask } from "./task.ts";

const scope: [ScopeInternal, () => Operation<void>] = createScopeInternal();

export const global = scope[0];

import type { Effect, Future, Operation, Scope } from "./types.ts";
import { Ok } from "./result.ts";
import { createScopeInternal } from "./scope-internal.ts";

/**
* The root of all Effection Scopes.
*/
export const [global] = createScopeInternal();

/**
* Create a new {@link Scope} as a child of `parent`, inheriting all its contexts.
* along with a method to destroy the scope. Whenever the scope is destroyd, all
* tasks and resources it contains will be halted.
*
* This function is used mostly by frameworks as an intergration point to enter
* Effection.
*
* @example
* ```js
* import { createScope, sleep, suspend } from "effection";
*
* let [scope, destroy] = createScope();
*
* let delay = scope.run(function*() {
* yield* sleep(1000);
* });
* scope.run(function*() {
* try {
* yield* suspend();
* } finally {
* console.log('done!')
* }
* });
* await delay;
* await destroy(); // prints "done!";
* ```
*
* @param parent scope. If no parent is specified it will derive directly from {@link global}
* @returns a tuple containing the freshly created scope, along with a function to
* destroy it.
*/
export function createScope(
parent: Scope = global,
): [Scope, () => Future<void>] {
let [scope, destroy] = createScopeInternal(parent);
return [scope, () => parent.run(destroy)];
}

export function createScopeInternal(
parent?: Scope,
): [ScopeInternal, () => Operation<void>] {
let destructors = new Set<() => Operation<void>>();

let contexts: Record<string, unknown> = Object.create(
parent ? (parent as ScopeInternal).contexts : null,
);
let scope: ScopeInternal = Object.create({
[Symbol.toStringTag]: "Scope",
contexts,
get<T>(context: Context<T>): T | undefined {
return (contexts[context.name] ?? context.defaultValue) as T | undefined;
},
set<T>(context: Context<T>, value: T): T {
return contexts[context.name] = value;
},
expect<T>(context: Context<T>): T {
let value = scope.get(context);
if (typeof value === "undefined") {
let error = new Error(context.name);
error.name = `MissingContextError`;
throw error;
}
return value;
},
delete<T>(context: Context<T>): boolean {
return delete contexts[context.name];
},
hasOwn<T>(context: Context<T>): boolean {
return !!Reflect.getOwnPropertyDescriptor(contexts, context.name);
},
run<T>(operation: () => Operation<T>): Task<T> {
let { task, start } = createTask({ operation, owner: scope });
start();
return task;
},
spawn<T>(operation: () => Operation<T>): Operation<Task<T>> {
return {
*[Symbol.iterator]() {
let { task, start } = createTask({ operation, owner: scope });
start();
return task;
},
};
},

ensure(op: () => Operation<void>): () => void {
destructors.add(op);
return () => destructors.delete(op);
},
});

scope.set(Generation, scope.expect(Generation) + 1);
scope.set(Children, new Set());
parent?.expect(Children).add(scope);

let unbind = parent ? (parent as ScopeInternal).ensure(destroy) : () => {};

function* destroy(): Operation<void> {
parent?.expect(Children).delete(scope);
unbind();
let outcome = Ok();
for (let destructor of [...destructors].reverse()) {
try {
destructors.delete(destructor);
yield* destructor();
} catch (error) {
outcome = Err(error as Error);
}
}
unbox(outcome);
}

return [scope, destroy];
}

export interface ScopeInternal extends Scope {
contexts: Record<string, unknown>;
ensure(op: () => Operation<void>): () => void;
}

/**
* Get the scope of the currently running {@link Operation}.
*
* @returns an operation yielding the current scope
*/
export function* useScope(): Operation<Scope> {
return (yield {
description: `useScope()`,
Expand Down
2 changes: 1 addition & 1 deletion lib/scoped.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Operation } from "./types.ts";
import { trap } from "./task.ts";
import { createScopeInternal } from "./scope.ts";
import { useCoroutine } from "./coroutine.ts";
import { createScopeInternal } from "./scope-internal.ts";

/**
* Encapsulate an operation so that no effects will persist outside of
Expand Down
51 changes: 50 additions & 1 deletion lib/spawn.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,57 @@
import { Ok } from "./result.ts";
import type { ScopeInternal } from "./scope.ts";
import type { ScopeInternal } from "./scope-internal.ts";
import { createTask, type NewTask } from "./task.ts";
import type { Effect, Operation, Task } from "./types.ts";

/**
* Run another operation concurrently as a child of the current one.
*
* The spawned operation will begin executing immediately and control will
* return to the caller when it reaches its first suspend
*
* @example
* ```js
* import { main, sleep, suspend, spawn } from 'effection';
*
* await main(function*() {
* yield* spawn(function*() {
* yield* sleep(1000);
* console.log("hello");
* });
* yield* spawn(function*() {
* yield* sleep(2000);
* console.log("world");
* });
* yield* suspend();
* });
* ```
*
* You should prefer using the spawn operation over calling
* {@link Scope.run} from within Effection code. The reason being that a
* synchronous failure in the spawned operation will not be caught
* until the next yield point when using `run`, which results in lines
* being executed that should not.
*
* @example
* ```js
* import { main, suspend, spawn, useScope } from 'effection';
*
* await main(function*() {
* yield* useScope();
*
* scope.run(function*() {
* throw new Error('boom!');
* });
*
* console.log('this code will run and probably should not');
*
* yield* suspend(); // <- error is thrown after this.
* });
* ```
* @param operation the operation to run as a child of the current task
* @typeParam T the type that the spawned task evaluates to
* @returns a {@link Task} representing a handle to the running operation
*/
export function* spawn<T>(op: () => Operation<T>): Operation<Task<T>> {
let { task, start } = (yield Spawn(op)) as NewTask<T>;
start();
Expand Down
2 changes: 1 addition & 1 deletion lib/task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { drain } from "./drain.ts";
import { lazyPromise, lazyPromiseWithResolvers } from "./lazy-promise.ts";
import { Just, type Maybe, Nothing } from "./maybe.ts";
import { Err, Ok, type Result, unbox } from "./result.ts";
import { createScopeInternal, type ScopeInternal } from "./scope.ts";
import { createScopeInternal, type ScopeInternal } from "./scope-internal.ts";
import type { Coroutine, Operation, Resolve, Scope, Task } from "./types.ts";
import { withResolvers } from "./with-resolvers.ts";

Expand Down

0 comments on commit 049c77b

Please sign in to comment.