@telorun/kernel 0.81.0 → 0.82.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/controllers/logging/console-sink-controller.d.ts.map +1 -1
- package/dist/controllers/logging/console-sink-controller.js +19 -9
- package/dist/controllers/logging/console-sink-controller.js.map +1 -1
- package/dist/controllers/logging/file-sink-controller.d.ts.map +1 -1
- package/dist/controllers/logging/file-sink-controller.js +17 -6
- package/dist/controllers/logging/file-sink-controller.js.map +1 -1
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +36 -15
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-template-controller.js +7 -5
- package/dist/controllers/resource-definition/resource-template-controller.js.map +1 -1
- package/dist/effect-scope.d.ts +132 -0
- package/dist/effect-scope.d.ts.map +1 -0
- package/dist/effect-scope.js +253 -0
- package/dist/effect-scope.js.map +1 -0
- package/dist/evaluation-context.d.ts +26 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +156 -12
- package/dist/evaluation-context.js.map +1 -1
- package/dist/init-failure-diagnostics.d.ts +10 -0
- package/dist/init-failure-diagnostics.d.ts.map +1 -1
- package/dist/init-failure-diagnostics.js +7 -1
- package/dist/init-failure-diagnostics.js.map +1 -1
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +17 -12
- package/dist/kernel.js.map +1 -1
- package/dist/manifest-schemas.d.ts +4 -1
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +4 -10
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-context.d.ts +10 -0
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +15 -0
- package/dist/module-context.js.map +1 -1
- package/dist/observed-state.d.ts.map +1 -1
- package/dist/observed-state.js +2 -4
- package/dist/observed-state.js.map +1 -1
- package/dist/resource-context.d.ts +18 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +27 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/schema-compiled-values.d.ts +2 -1
- package/dist/schema-compiled-values.d.ts.map +1 -1
- package/dist/schema-compiled-values.js +23 -20
- package/dist/schema-compiled-values.js.map +1 -1
- package/package.json +3 -3
- package/src/controllers/logging/console-sink-controller.ts +19 -9
- package/src/controllers/logging/file-sink-controller.ts +17 -6
- package/src/controllers/module/import-controller.ts +50 -27
- package/src/controllers/resource-definition/resource-template-controller.ts +14 -11
- package/src/effect-scope.ts +340 -0
- package/src/evaluation-context.ts +165 -10
- package/src/init-failure-diagnostics.ts +8 -1
- package/src/kernel.ts +20 -11
- package/src/manifest-schemas.ts +4 -9
- package/src/module-context.ts +16 -0
- package/src/observed-state.ts +2 -4
- package/src/resource-context.ts +32 -0
- package/src/schema-compiled-values.ts +35 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/kernel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.82.0",
|
|
4
4
|
"description": "Telo Runtime - A lightweight, polyglot execution host.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -61,7 +61,7 @@
|
|
|
61
61
|
"dependencies": {
|
|
62
62
|
"@marcbachmann/cel-js": "^7.6.1",
|
|
63
63
|
"@sinclair/typebox": "^0.34.48",
|
|
64
|
-
"@telorun/analyzer": "0.
|
|
64
|
+
"@telorun/analyzer": "0.65.0",
|
|
65
65
|
"@telorun/glob": "0.2.0",
|
|
66
66
|
"@telorun/templating": "0.16.0",
|
|
67
67
|
"ajv": "^8.17.1",
|
|
@@ -75,7 +75,7 @@
|
|
|
75
75
|
"@types/tar-stream": "^3.1.3",
|
|
76
76
|
"typescript": "^5.0.0",
|
|
77
77
|
"vitest": "^2.1.8",
|
|
78
|
-
"@telorun/sdk": "0.
|
|
78
|
+
"@telorun/sdk": "0.82.0"
|
|
79
79
|
},
|
|
80
80
|
"optionalDependencies": {
|
|
81
81
|
"esbuild": "^0.25.12"
|
|
@@ -29,18 +29,28 @@ export async function create(
|
|
|
29
29
|
stderr: ctx.stderr,
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
// Attaching is the effect, and detaching-and-closing is its inverse — as one
|
|
33
|
+
// pair, performed HERE rather than returned from `init()`, because a sink must
|
|
34
|
+
// be receiving records from the moment it is constructed: everything logged
|
|
35
|
+
// while the rest of the graph creates and initializes would otherwise reach no
|
|
36
|
+
// destination. Sinks are pinned to unwind after every other resource, so
|
|
37
|
+
// anything logged during its own shutdown still reaches a live sink.
|
|
38
|
+
await ctx
|
|
39
|
+
.effect("log sink", async () => {
|
|
40
|
+
ctx.logging.attach(sink);
|
|
41
|
+
return {
|
|
42
|
+
result: sink,
|
|
43
|
+
inverse: async () => {
|
|
44
|
+
await sink.flush();
|
|
45
|
+
ctx.logging.detach(sink);
|
|
46
|
+
await sink.close();
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
})
|
|
50
|
+
.perform();
|
|
33
51
|
|
|
34
52
|
return {
|
|
35
53
|
sink,
|
|
36
54
|
teardownPriority: TEARDOWN_LAST,
|
|
37
|
-
// Sinks are ordinary resources, so the final flush is their own teardown —
|
|
38
|
-
// they are simply pinned to run after every other resource, so anything
|
|
39
|
-
// logging during its own shutdown still reaches a live destination.
|
|
40
|
-
teardown: async () => {
|
|
41
|
-
await sink.flush();
|
|
42
|
-
ctx.logging.detach(sink);
|
|
43
|
-
await sink.close();
|
|
44
|
-
},
|
|
45
55
|
} as unknown as ResourceInstance;
|
|
46
56
|
}
|
|
@@ -26,15 +26,26 @@ export async function create(
|
|
|
26
26
|
onDrop: () => ctx.logging.recordDrop(sinkId, "buffer_full"),
|
|
27
27
|
});
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// Attach and its inverse as one pair, performed here rather than returned from
|
|
30
|
+
// `init()`: a sink must receive records from construction on, or everything
|
|
31
|
+
// logged while the rest of the graph initializes reaches no destination. The
|
|
32
|
+
// flush is what makes a clean shutdown lose nothing that was buffered.
|
|
33
|
+
await ctx
|
|
34
|
+
.effect("log sink", async () => {
|
|
35
|
+
ctx.logging.attach(sink);
|
|
36
|
+
return {
|
|
37
|
+
result: sink,
|
|
38
|
+
inverse: async () => {
|
|
39
|
+
await sink.flush();
|
|
40
|
+
ctx.logging.detach(sink);
|
|
41
|
+
await sink.close();
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
})
|
|
45
|
+
.perform();
|
|
30
46
|
|
|
31
47
|
return {
|
|
32
48
|
sink,
|
|
33
49
|
teardownPriority: TEARDOWN_LAST,
|
|
34
|
-
teardown: async () => {
|
|
35
|
-
await sink.flush();
|
|
36
|
-
ctx.logging.detach(sink);
|
|
37
|
-
await sink.close();
|
|
38
|
-
},
|
|
39
50
|
} as unknown as ResourceInstance;
|
|
40
51
|
}
|
|
@@ -297,23 +297,44 @@ export async function create(
|
|
|
297
297
|
);
|
|
298
298
|
}
|
|
299
299
|
}
|
|
300
|
-
|
|
300
|
+
// The alias registrations are an EFFECT on the create frame, not bare calls:
|
|
301
|
+
// an import whose `init()` fails is discarded and re-created on the next pass,
|
|
302
|
+
// so an alias left registered would be re-registered against a module context
|
|
303
|
+
// that already has it — and the abandoned child context would linger with no
|
|
304
|
+
// owner. One effect, because the four registrations are one act: an alias
|
|
305
|
+
// resolving kinds through a module whose instances are gone is not a state
|
|
306
|
+
// this controller should be able to produce.
|
|
307
|
+
await ctx
|
|
308
|
+
.effect(`import alias ${alias}`, async () => {
|
|
309
|
+
ctx.registerModuleImport(alias, targetModule, exportedKindSuffixes);
|
|
301
310
|
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
311
|
+
// Publish the child's exported instances to the parent so cross-module `!ref Alias.name`
|
|
312
|
+
// (Phase 5 injection / boot targets) and `${{ resources.Alias.name }}` (CEL value-flow)
|
|
313
|
+
// resolve. The gate is `exports.resources`; the child's terminal getter is read lazily —
|
|
314
|
+
// it exists after this import's init() built the child's export table. Handing the parent
|
|
315
|
+
// the child's TERMINAL getter (not a wrapper) keeps resolution O(1) across re-export hops.
|
|
316
|
+
(ctx.moduleContext as ModuleContext).registerImportedScope(
|
|
317
|
+
alias,
|
|
318
|
+
exportedResourceNames,
|
|
319
|
+
(name) => childCtx.getTerminalExport(name),
|
|
320
|
+
);
|
|
321
|
+
// Same for kinds: `kind: Alias.Kind` resolves through the child's exported-kind table,
|
|
322
|
+
// covering both locally-defined and transitively re-exported kinds in O(1).
|
|
323
|
+
(ctx.moduleContext as ModuleContext).registerImportedKindScope(alias, (suffix) =>
|
|
324
|
+
childCtx.getExportedKind(suffix),
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
return {
|
|
328
|
+
result: undefined,
|
|
329
|
+
inverse: () => {
|
|
330
|
+
(ctx.moduleContext as ModuleContext).unregisterImport(alias);
|
|
331
|
+
// The child context goes with the alias: it was spawned for this
|
|
332
|
+
// import and nothing else can reach it once the alias is gone.
|
|
333
|
+
ctx.moduleContext.detachChild(child);
|
|
334
|
+
},
|
|
335
|
+
};
|
|
336
|
+
})
|
|
337
|
+
.perform();
|
|
317
338
|
|
|
318
339
|
// Return a ResourceInstance whose snapshot() surfaces the exported values under
|
|
319
340
|
// resources.<alias>: the import's variables/secrets plus each exported instance's own
|
|
@@ -342,17 +363,19 @@ export async function create(
|
|
|
342
363
|
...exported,
|
|
343
364
|
};
|
|
344
365
|
},
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
366
|
+
// The library's resources ARE this import's allocation, and tearing the
|
|
367
|
+
// child context down is what undoes it — so the two are one effect rather
|
|
368
|
+
// than an init/teardown pair the kernel had to trust were inverses.
|
|
369
|
+
init: (importCtx) =>
|
|
370
|
+
importCtx.effect("library resources", async () => {
|
|
371
|
+
await child.initializeResources();
|
|
372
|
+
// Build this import's flattened export tables now that its own imports are
|
|
373
|
+
// registered (leaves-first), so a re-export (`!ref Alias.name` / `Alias.Kind`)
|
|
374
|
+
// copies the source import's terminal getter / canonical kind by reference —
|
|
375
|
+
// O(1) resolution at any depth.
|
|
376
|
+
childCtx.buildExportTable(exportEntries, kindEntries, targetModule);
|
|
377
|
+
return { result: undefined, inverse: () => child.teardownResources() };
|
|
378
|
+
}),
|
|
356
379
|
};
|
|
357
380
|
}
|
|
358
381
|
|
|
@@ -205,15 +205,21 @@ export function createTemplateController(definition: {
|
|
|
205
205
|
let registered = false;
|
|
206
206
|
|
|
207
207
|
return {
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
208
|
+
// The template's own resources are its allocation, and tearing the child
|
|
209
|
+
// context down is the inverse. `init()` still resumes rather than
|
|
210
|
+
// restarting, because a deferral (a child ref naming a sibling that has
|
|
211
|
+
// not initialized) keeps the instance: only a real failure discards it.
|
|
212
|
+
init: (templateCtx) =>
|
|
213
|
+
templateCtx.effect("template resources", async () => {
|
|
214
|
+
if (!registered) {
|
|
215
|
+
for (const template of definition.resources ?? []) {
|
|
216
|
+
childContext.registerManifest(expandSelf(template));
|
|
217
|
+
}
|
|
218
|
+
registered = true;
|
|
212
219
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
},
|
|
220
|
+
await childContext.initializeResources();
|
|
221
|
+
return { result: undefined, inverse: () => childContext.teardownResources() };
|
|
222
|
+
}),
|
|
217
223
|
|
|
218
224
|
...(invokeTarget && {
|
|
219
225
|
invoke: async (inputs: any) => {
|
|
@@ -267,9 +273,6 @@ export function createTemplateController(definition: {
|
|
|
267
273
|
},
|
|
268
274
|
}),
|
|
269
275
|
|
|
270
|
-
teardown: async () => {
|
|
271
|
-
await childContext.teardownResources();
|
|
272
|
-
},
|
|
273
276
|
};
|
|
274
277
|
},
|
|
275
278
|
};
|
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
EffectBody,
|
|
3
|
+
EffectChain,
|
|
4
|
+
EffectOutcome,
|
|
5
|
+
EffectResult,
|
|
6
|
+
Inverse,
|
|
7
|
+
ResourceInstance,
|
|
8
|
+
} from "@telorun/sdk";
|
|
9
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The inverse accumulator behind the effect chain.
|
|
13
|
+
*
|
|
14
|
+
* A stack of FRAMES, one per lifecycle entry. The kernel opens a frame at
|
|
15
|
+
* `create()` and at each `init()` / `run()`; a failure unwinds only the frame
|
|
16
|
+
* that failed, so a failed `init()` cannot revert what `create()` did and then
|
|
17
|
+
* retry against a resource whose construction was rolled back. Teardown unwinds
|
|
18
|
+
* every open frame, newest first; within a frame, last-in-first-out.
|
|
19
|
+
*
|
|
20
|
+
* Normative contract: `kernel/specs/revertible-effects.md`.
|
|
21
|
+
*/
|
|
22
|
+
export type FrameLabel = "create" | "init" | "run";
|
|
23
|
+
|
|
24
|
+
interface Registration {
|
|
25
|
+
readonly reason: string;
|
|
26
|
+
readonly inverse: Inverse;
|
|
27
|
+
disposed: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface Frame {
|
|
31
|
+
readonly label: FrameLabel;
|
|
32
|
+
readonly entries: Registration[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** One inverse that refused, kept with the reason its author gave the effect. */
|
|
36
|
+
export interface RecoveryFailure {
|
|
37
|
+
readonly reason: string;
|
|
38
|
+
readonly error: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One link of a chain: what to run, and what the author called it. */
|
|
42
|
+
interface Step {
|
|
43
|
+
readonly reason: string;
|
|
44
|
+
readonly body: EffectBody<unknown, unknown>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** One line for an inverse's refusal, quoted into a recovery aggregate. */
|
|
48
|
+
function errorText(err: unknown): string {
|
|
49
|
+
return err instanceof Error ? err.message : String(err);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function isAsyncGenerator(value: unknown): value is AsyncGenerator<Inverse, unknown, void> {
|
|
53
|
+
return (
|
|
54
|
+
typeof value === "object" &&
|
|
55
|
+
value !== null &&
|
|
56
|
+
typeof (value as AsyncGenerator).next === "function" &&
|
|
57
|
+
Symbol.asyncIterator in (value as object)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A lazy chain of steps against one scope.
|
|
63
|
+
*
|
|
64
|
+
* Immutable and value-like, so `chain.effect(...)` in a branch or a loop builds
|
|
65
|
+
* a new description rather than mutating a shared one. Deliberately NOT a
|
|
66
|
+
* thenable: an `async init()` would unwrap it and hand the kernel its last
|
|
67
|
+
* result instead of the chain.
|
|
68
|
+
*/
|
|
69
|
+
class Chain<T> implements EffectChain<T> {
|
|
70
|
+
constructor(
|
|
71
|
+
private readonly scope: EffectScope,
|
|
72
|
+
private readonly steps: readonly Step[],
|
|
73
|
+
) {}
|
|
74
|
+
|
|
75
|
+
effect<TNext>(reason: string, body: EffectBody<T, TNext>): EffectChain<TNext> {
|
|
76
|
+
return new Chain<TNext>(this.scope, [
|
|
77
|
+
...this.steps,
|
|
78
|
+
{ reason, body: body as EffectBody<unknown, unknown> },
|
|
79
|
+
]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
perform(): Promise<EffectResult<T>> {
|
|
83
|
+
return this.scope.execute(this.steps) as Promise<EffectResult<T>>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The steps, for the kernel executing a chain a controller returned. */
|
|
87
|
+
plan(): readonly Step[] {
|
|
88
|
+
return this.steps;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A chain produced by this kernel, as opposed to any other object a controller
|
|
93
|
+
* might return from `init()`. */
|
|
94
|
+
export function isEffectChain(value: unknown): value is Chain<unknown> {
|
|
95
|
+
return value instanceof Chain;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class EffectScope {
|
|
99
|
+
/** Innermost last. `create` is opened with the scope, so there is always a
|
|
100
|
+
* frame to register onto. */
|
|
101
|
+
private readonly frames: Frame[] = [];
|
|
102
|
+
|
|
103
|
+
/** Set while this scope is unwinding. A generator body checks it at each step
|
|
104
|
+
* boundary and stops rather than allocating into a scope that is going away —
|
|
105
|
+
* the mid-boot SIGINT case. */
|
|
106
|
+
private unwinding = false;
|
|
107
|
+
|
|
108
|
+
/** Terminal: set by {@link unwindAll}. A closed scope has no frames and takes
|
|
109
|
+
* no new effects. */
|
|
110
|
+
private closed = false;
|
|
111
|
+
|
|
112
|
+
constructor(private readonly label: string) {
|
|
113
|
+
this.openFrame("create");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Refuse work against a scope that has already unwound. Raised BEFORE a
|
|
117
|
+
* forward body runs, so a late effect cannot allocate and then find it has
|
|
118
|
+
* nowhere to record the inverse. */
|
|
119
|
+
private assertOpen(what: string): void {
|
|
120
|
+
if (!this.closed) return;
|
|
121
|
+
throw new RuntimeError(
|
|
122
|
+
"ERR_EFFECT_SCOPE_CLOSED",
|
|
123
|
+
`${this.label}: '${what}' cannot run — this resource has been torn down. ` +
|
|
124
|
+
`An effect registered now would record an inverse nothing will ever run.`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
openFrame(label: FrameLabel): void {
|
|
129
|
+
this.frames.push({ label, entries: [] });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Start a chain. Nothing runs until it is executed. */
|
|
133
|
+
chain<T>(reason: string, body: EffectBody<void, T>): EffectChain<T> {
|
|
134
|
+
return new Chain<never>(this, []).effect(reason, body as EffectBody<never, T>);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Register an inverse for work performed elsewhere, without a forward body.
|
|
139
|
+
*
|
|
140
|
+
* The synchronous door onto the same accumulator, for a primitive that
|
|
141
|
+
* already returns its own inverse: `acquireHold` hands back a release
|
|
142
|
+
* closure, and its public signature is synchronous, so it cannot go through a
|
|
143
|
+
* chain.
|
|
144
|
+
*/
|
|
145
|
+
register(reason: string, inverse: Inverse): () => Promise<void> {
|
|
146
|
+
this.assertOpen(reason);
|
|
147
|
+
const entry: Registration = { reason, inverse, disposed: false };
|
|
148
|
+
this.current().entries.push(entry);
|
|
149
|
+
return () => this.disposeEntries([entry]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Run a chain's steps in order against the frame open NOW, threading each
|
|
154
|
+
* step's result into the next.
|
|
155
|
+
*
|
|
156
|
+
* A step that throws leaves every inverse produced so far on the frame — this
|
|
157
|
+
* does not unwind, because whether a partial `init()` is recovered-and-retried
|
|
158
|
+
* or torn down is the caller's decision, not this function's.
|
|
159
|
+
*/
|
|
160
|
+
async execute(steps: readonly Step[]): Promise<EffectResult<unknown>> {
|
|
161
|
+
const registered: Registration[] = [];
|
|
162
|
+
const push = (reason: string, inverse: Inverse): void => {
|
|
163
|
+
const entry: Registration = { reason, inverse, disposed: false };
|
|
164
|
+
this.current().entries.push(entry);
|
|
165
|
+
registered.push(entry);
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
let value: unknown = undefined;
|
|
169
|
+
for (const step of steps) {
|
|
170
|
+
this.assertOpen(step.reason);
|
|
171
|
+
if (this.unwinding) {
|
|
172
|
+
throw new RuntimeError(
|
|
173
|
+
"ERR_EFFECT_SCOPE_CLOSING",
|
|
174
|
+
`${this.label}: '${step.reason}' not started because the resource is being torn down`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const produced = (step.body as (input: unknown) => unknown)(value);
|
|
178
|
+
if (isAsyncGenerator(produced)) {
|
|
179
|
+
const iterator = produced as AsyncGenerator<Inverse, unknown, void>;
|
|
180
|
+
for (;;) {
|
|
181
|
+
if (this.unwinding) {
|
|
182
|
+
// Stop at the step boundary and let the generator run its own
|
|
183
|
+
// `finally`. What already yielded stays on the frame and is
|
|
184
|
+
// recovered by the unwind in progress.
|
|
185
|
+
await iterator.return?.(undefined as never);
|
|
186
|
+
throw new RuntimeError(
|
|
187
|
+
"ERR_EFFECT_SCOPE_CLOSING",
|
|
188
|
+
`${this.label}: '${step.reason}' stopped because the resource is being torn down`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const next = await iterator.next();
|
|
192
|
+
if (next.done) {
|
|
193
|
+
value = next.value;
|
|
194
|
+
break;
|
|
195
|
+
}
|
|
196
|
+
push(step.reason, next.value);
|
|
197
|
+
}
|
|
198
|
+
} else {
|
|
199
|
+
const outcome = (await produced) as EffectOutcome<unknown>;
|
|
200
|
+
// No inverse means the step allocated nothing that outlives a failure —
|
|
201
|
+
// a chain is the sequencing structure for lifecycle work as well as the
|
|
202
|
+
// record of what to undo, so a step with nothing to undo registers
|
|
203
|
+
// nothing rather than a no-op that would read as an oversight.
|
|
204
|
+
if (outcome.inverse) push(step.reason, outcome.inverse);
|
|
205
|
+
value = outcome.result;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { result: value, dispose: () => this.disposeEntries(registered) };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Unwind the innermost frame and close it, returning what refused.
|
|
214
|
+
*
|
|
215
|
+
* Failures are returned rather than thrown: the caller decides what a refusal
|
|
216
|
+
* means. Pre-retry recovery withholds the resource (retrying from a state that
|
|
217
|
+
* could not be rolled back is worse than not retrying); teardown aggregates
|
|
218
|
+
* and keeps going, so one throwing resource cannot strand the log sinks pinned
|
|
219
|
+
* to outlive it.
|
|
220
|
+
*/
|
|
221
|
+
async unwindFrame(): Promise<RecoveryFailure[]> {
|
|
222
|
+
const frame = this.frames.pop();
|
|
223
|
+
if (!frame) return [];
|
|
224
|
+
return this.runInverses(frame.entries);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Unwind every open frame, innermost first, and CLOSE the scope.
|
|
229
|
+
*
|
|
230
|
+
* Terminal, because both callers are: teardown, and the discard of a resource
|
|
231
|
+
* whose `init()` failed (its replacement is built with a fresh context, so a
|
|
232
|
+
* fresh scope). A closed scope refuses new effects rather than accepting them
|
|
233
|
+
* onto a frame nothing will ever unwind — recording an inverse that will never
|
|
234
|
+
* run is the silent leak this whole mechanism exists to remove, and it is
|
|
235
|
+
* exactly the shape a detached task still settling after teardown produces.
|
|
236
|
+
*/
|
|
237
|
+
async unwindAll(): Promise<RecoveryFailure[]> {
|
|
238
|
+
const failures: RecoveryFailure[] = [];
|
|
239
|
+
while (this.frames.length > 0) failures.push(...(await this.unwindFrame()));
|
|
240
|
+
this.closed = true;
|
|
241
|
+
return failures;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private async runInverses(entries: Registration[]): Promise<RecoveryFailure[]> {
|
|
245
|
+
const wasUnwinding = this.unwinding;
|
|
246
|
+
this.unwinding = true;
|
|
247
|
+
const failures: RecoveryFailure[] = [];
|
|
248
|
+
for (const entry of [...entries].reverse()) {
|
|
249
|
+
if (entry.disposed) continue;
|
|
250
|
+
entry.disposed = true;
|
|
251
|
+
try {
|
|
252
|
+
await entry.inverse();
|
|
253
|
+
} catch (error) {
|
|
254
|
+
failures.push({ reason: entry.reason, error });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
this.unwinding = wasUnwinding;
|
|
258
|
+
return failures;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private async disposeEntries(entries: Registration[]): Promise<void> {
|
|
262
|
+
const failures = await this.runInverses(entries);
|
|
263
|
+
if (failures.length === 0) return;
|
|
264
|
+
// An explicit dispose HAS a caller, unlike an unwind — so it throws rather
|
|
265
|
+
// than being collected into someone else's aggregate. Every refusal travels,
|
|
266
|
+
// matching the init and teardown paths: a dispose covers a chain, so
|
|
267
|
+
// reporting the first and dropping the rest would hide the others exactly
|
|
268
|
+
// where more than one thing failed to roll back.
|
|
269
|
+
throw new RuntimeError(
|
|
270
|
+
"ERR_EFFECT_RECOVERY_FAILED",
|
|
271
|
+
`${this.label}: ${failures.length} inverse(s) refused: ` +
|
|
272
|
+
failures.map((f) => `'${f.reason}' (${errorText(f.error)})`).join(", "),
|
|
273
|
+
failures.map((f) => ({
|
|
274
|
+
severity: "error" as const,
|
|
275
|
+
message: `inverse '${f.reason}' refused: ${errorText(f.error)}`,
|
|
276
|
+
})),
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private current(): Frame {
|
|
281
|
+
this.assertOpen("effect");
|
|
282
|
+
const frame = this.frames[this.frames.length - 1];
|
|
283
|
+
if (!frame) throw new RuntimeError("ERR_EFFECT_NO_FRAME", `${this.label}: no open effect frame`);
|
|
284
|
+
return frame;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Execute whatever a lifecycle method returned.
|
|
290
|
+
*
|
|
291
|
+
* A controller that allocates nothing returns nothing, so a non-chain return is
|
|
292
|
+
* not an error — but it is also not an inverse, which is why `init()` returning
|
|
293
|
+
* a chain it forgot to hand back fails loudly and immediately: nothing was
|
|
294
|
+
* allocated at all.
|
|
295
|
+
*/
|
|
296
|
+
export async function executeReturnedChain(returned: unknown, scope?: EffectScope): Promise<void> {
|
|
297
|
+
if (!isEffectChain(returned)) return;
|
|
298
|
+
if (!scope) {
|
|
299
|
+
throw new RuntimeError(
|
|
300
|
+
"ERR_EFFECT_NO_SCOPE",
|
|
301
|
+
"a controller returned an effect chain from a resource with no effect scope",
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
await scope.execute(returned.plan());
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* What the teardown cascade needs from a resource's context: its inverses, and
|
|
309
|
+
* its detached-task drain.
|
|
310
|
+
*
|
|
311
|
+
* Structural rather than the context class itself, so the accumulator does not
|
|
312
|
+
* pull the whole `ResourceContextImpl` into every consumer — and so the drain
|
|
313
|
+
* stays visibly a *second* thing, since it waits for in-flight work rather than
|
|
314
|
+
* undoing anything.
|
|
315
|
+
*/
|
|
316
|
+
export interface EffectOwner {
|
|
317
|
+
readonly effects: EffectScope;
|
|
318
|
+
drainDetached(): Promise<void>;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* An instance's effect owner, recorded at the kernel's single
|
|
323
|
+
* instance-production site — the anchor that already carries handle minting and
|
|
324
|
+
* contract binding, so an instance is never observable without one.
|
|
325
|
+
*
|
|
326
|
+
* A WeakMap rather than a field on the resource-instances map: teardown holds
|
|
327
|
+
* the instance and nothing else, and every reader of that map would otherwise
|
|
328
|
+
* have to learn about effects. First bind wins, mirroring handle minting: a
|
|
329
|
+
* `base:` child IS the parent instance returned verbatim, and re-binding would
|
|
330
|
+
* give one object two accumulators.
|
|
331
|
+
*/
|
|
332
|
+
const owners = new WeakMap<ResourceInstance, EffectOwner>();
|
|
333
|
+
|
|
334
|
+
export function bindEffectOwner(instance: ResourceInstance, owner: EffectOwner): void {
|
|
335
|
+
if (!owners.has(instance)) owners.set(instance, owner);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
export function effectOwnerOf(instance: ResourceInstance): EffectOwner | undefined {
|
|
339
|
+
return owners.get(instance);
|
|
340
|
+
}
|