@nimbus-sh/fabric 0.1.0 → 0.3.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/README.md +208 -293
- package/dist/bindings.js +5 -5
- package/dist/budgets.d.ts +132 -0
- package/dist/budgets.d.ts.map +1 -0
- package/dist/budgets.js +248 -0
- package/dist/composition.d.ts +3 -0
- package/dist/composition.d.ts.map +1 -0
- package/dist/composition.js +2 -0
- package/dist/connections.d.ts +81 -0
- package/dist/connections.d.ts.map +1 -0
- package/dist/connections.js +114 -0
- package/dist/derived.d.ts +65 -0
- package/dist/derived.d.ts.map +1 -0
- package/dist/derived.js +95 -0
- package/dist/do-calls.d.ts +94 -0
- package/dist/do-calls.d.ts.map +1 -0
- package/dist/do-calls.js +111 -0
- package/dist/facet-pool.d.ts +90 -0
- package/dist/facet-pool.d.ts.map +1 -0
- package/dist/facet-pool.js +113 -0
- package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
- package/dist/fanout.d.ts.map +1 -0
- package/dist/{fanout-pool.js → fanout.js} +20 -20
- package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
- package/dist/fenced-work.d.ts.map +1 -0
- package/dist/fenced-work.js +241 -0
- package/dist/generation.d.ts +69 -0
- package/dist/generation.d.ts.map +1 -0
- package/dist/generation.js +118 -0
- package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
- package/dist/image-store.d.ts.map +1 -0
- package/dist/{facet-image-store.js → image-store.js} +4 -4
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -8
- package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
- package/dist/isolate-pool.d.ts.map +1 -0
- package/dist/{loader-pool.js → isolate-pool.js} +20 -20
- package/dist/journal.d.ts +111 -0
- package/dist/journal.d.ts.map +1 -0
- package/dist/journal.js +177 -0
- package/dist/outbox.d.ts +249 -0
- package/dist/outbox.d.ts.map +1 -0
- package/dist/outbox.js +355 -0
- package/dist/process-fabric.d.ts +33 -15
- package/dist/process-fabric.d.ts.map +1 -1
- package/dist/process-fabric.js +25 -15
- package/dist/process-host.d.ts +1 -1
- package/dist/process-host.d.ts.map +1 -1
- package/dist/process-host.js +19 -11
- package/dist/sealed.d.ts +78 -0
- package/dist/sealed.d.ts.map +1 -0
- package/dist/sealed.js +145 -0
- package/dist/timers.d.ts +138 -0
- package/dist/timers.d.ts.map +1 -0
- package/dist/timers.js +231 -0
- package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
- package/dist/turn-budget.d.ts.map +1 -0
- package/dist/{launch-pacer.js → turn-budget.js} +24 -12
- package/dist/workerd-facet-host.d.ts +67 -70
- package/dist/workerd-facet-host.d.ts.map +1 -1
- package/dist/workerd-facet-host.js +129 -181
- package/examples/agent-core-adapter.ts +191 -0
- package/package.json +4 -2
- package/src/bindings.ts +6 -6
- package/src/budgets.ts +308 -0
- package/src/composition.ts +16 -0
- package/src/connections.ts +140 -0
- package/src/derived.ts +135 -0
- package/src/do-calls.ts +156 -0
- package/src/facet-pool.ts +157 -0
- package/src/{fanout-pool.ts → fanout.ts} +35 -35
- package/src/{launch-journal.ts → fenced-work.ts} +129 -42
- package/src/generation.ts +144 -0
- package/src/{facet-image-store.ts → image-store.ts} +9 -9
- package/src/index.ts +16 -8
- package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
- package/src/journal.ts +242 -0
- package/src/node-async-hooks.d.ts +14 -0
- package/src/outbox.ts +520 -0
- package/src/process-fabric.ts +43 -34
- package/src/process-host.ts +22 -20
- package/src/sealed.ts +150 -0
- package/src/timers.ts +294 -0
- package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
- package/src/workerd-facet-host.ts +159 -208
- package/dist/alarms.d.ts +0 -134
- package/dist/alarms.d.ts.map +0 -1
- package/dist/alarms.js +0 -214
- package/dist/ctx-exports.d.ts +0 -47
- package/dist/ctx-exports.d.ts.map +0 -1
- package/dist/ctx-exports.js +0 -54
- package/dist/facet-image-store.d.ts.map +0 -1
- package/dist/fanout-pool.d.ts.map +0 -1
- package/dist/launch-journal.d.ts.map +0 -1
- package/dist/launch-journal.js +0 -154
- package/dist/launch-pacer.d.ts.map +0 -1
- package/dist/loader-ledger.d.ts +0 -57
- package/dist/loader-ledger.d.ts.map +0 -1
- package/dist/loader-ledger.js +0 -91
- package/dist/loader-pool.d.ts.map +0 -1
- package/src/alarms.ts +0 -275
- package/src/ctx-exports.ts +0 -77
- package/src/loader-ledger.ts +0 -112
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* isolate-pool.ts — Nimbus loader-isolate pool based on cloudflare-parallel.
|
|
3
3
|
*
|
|
4
4
|
* Adds Nimbus-specific behavior to the upstream pool design:
|
|
5
5
|
* 1. **Stable-slot isolate reuse**. Upstream's #counter++ gives every
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* reach https://registry.npmjs.org without a proxy binding).
|
|
15
15
|
* 3. **Supervisor autoinjection**. The pool grabs the embedder's
|
|
16
16
|
* registered supervisor entrypoint stub (see `supervisorEntrypoint` in
|
|
17
|
-
*
|
|
17
|
+
* composition.ts) and forwards it as `env.SUPERVISOR` to every facet,
|
|
18
18
|
* same pattern as git-network-facet.ts. Callers can add more bindings
|
|
19
19
|
* via `extraBindings`.
|
|
20
20
|
* 4. **Fail-loud defaults**: timeout 60s, retries 0, onError 'throw'.
|
|
@@ -25,13 +25,13 @@
|
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
import { CF_COMPAT_DATE } from '@nimbus-sh/core/constants.js';
|
|
28
|
-
import { supervisorEntrypoint } from './
|
|
29
|
-
import { disposeRpcResource } from '@nimbus-sh/
|
|
28
|
+
import { supervisorEntrypoint } from './composition.js';
|
|
29
|
+
import { disposeRpcResource } from '@nimbus-sh/platform/rpc-dispose.js';
|
|
30
30
|
import { serializeFunction, hashSource } from './vendor/serialize.js';
|
|
31
|
-
import { beginLoaderFetch, recordLoaderId, withDynamicWorkerCapNamed } from './
|
|
32
|
-
import { assertModuleMapWithinCodeLimit } from './
|
|
33
|
-
import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/
|
|
34
|
-
import { classifyError } from '@nimbus-sh/
|
|
31
|
+
import { beginLoaderFetch, recordLoaderId, withDynamicWorkerCapNamed } from './budgets.js';
|
|
32
|
+
import { assertModuleMapWithinCodeLimit } from './budgets.js';
|
|
33
|
+
import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/platform/oom-discriminator.js';
|
|
34
|
+
import { classifyError } from '@nimbus-sh/platform/oom-classify.js';
|
|
35
35
|
import {
|
|
36
36
|
BindingError,
|
|
37
37
|
ExecutionError,
|
|
@@ -55,12 +55,12 @@ export type FacetTaskFn<A, R> = {
|
|
|
55
55
|
}['task'];
|
|
56
56
|
|
|
57
57
|
/** The one binding a pool needs off whichever env its host hands it. */
|
|
58
|
-
export interface
|
|
58
|
+
export interface IsolatePoolEnv {
|
|
59
59
|
LOADER?: WorkerLoader;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
-
/** Options handed to
|
|
63
|
-
export interface
|
|
62
|
+
/** Options handed to IsolatePool's constructor. */
|
|
63
|
+
export interface IsolatePoolOptions {
|
|
64
64
|
/** Maximum concurrent in-flight facets. Default 4. */
|
|
65
65
|
concurrency?: number;
|
|
66
66
|
/** Per-task timeout in ms. Default 60_000. */
|
|
@@ -97,8 +97,8 @@ export interface LoaderPoolOptions {
|
|
|
97
97
|
* Override the `doId` baked into the auto-injected SUPERVISOR binding.
|
|
98
98
|
* Default: `ctx.id.toString()` (the DO that constructs the pool).
|
|
99
99
|
*
|
|
100
|
-
* Used by
|
|
101
|
-
* construct their per-task
|
|
100
|
+
* Used by Fanout's peer-DO branch (peer-DO fanout): peer DOs
|
|
101
|
+
* construct their per-task IsolatePool from inside
|
|
102
102
|
* `_rpcFanoutExecute`, where `ctx` is the PEER DO's ctx. Without this
|
|
103
103
|
* override the peer's auto-injected SUPERVISOR routes back to the
|
|
104
104
|
* peer DO itself — so writes (e.g. install-batch-facet's
|
|
@@ -170,7 +170,7 @@ export interface LoaderPoolOptions {
|
|
|
170
170
|
}
|
|
171
171
|
|
|
172
172
|
/** Per-call override (merged with pool defaults). */
|
|
173
|
-
export interface
|
|
173
|
+
export interface IsolateCallOptions {
|
|
174
174
|
timeoutMs?: number;
|
|
175
175
|
retries?: number;
|
|
176
176
|
/**
|
|
@@ -206,7 +206,7 @@ export interface LoaderCallOptions {
|
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
/** Per-map override. Adds onError strategy for partial failures. */
|
|
209
|
-
export interface
|
|
209
|
+
export interface IsolateMapOptions extends IsolateCallOptions {
|
|
210
210
|
/** Concurrency override for this call. Defaults to pool's concurrency. */
|
|
211
211
|
concurrency?: number;
|
|
212
212
|
/**
|
|
@@ -327,7 +327,7 @@ export function assembleLoaderWorkerModuleSource(
|
|
|
327
327
|
*
|
|
328
328
|
* Typical use:
|
|
329
329
|
*
|
|
330
|
-
* const pool = new
|
|
330
|
+
* const pool = new IsolatePool(env, ctx, {
|
|
331
331
|
* concurrency: 4,
|
|
332
332
|
* tag: 'npm-install',
|
|
333
333
|
* });
|
|
@@ -336,9 +336,9 @@ export function assembleLoaderWorkerModuleSource(
|
|
|
336
336
|
* toFetch,
|
|
337
337
|
* );
|
|
338
338
|
*/
|
|
339
|
-
export class
|
|
339
|
+
export class IsolatePool {
|
|
340
340
|
private readonly loader: WorkerLoader;
|
|
341
|
-
/** The hosting actor, as the loader
|
|
341
|
+
/** The hosting actor, as the loader budget ledger's per-DO key. */
|
|
342
342
|
private readonly ctx: DurableObjectState;
|
|
343
343
|
private readonly concurrency: number;
|
|
344
344
|
private readonly defaultTimeoutMs: number;
|
|
@@ -351,7 +351,7 @@ export class LoaderPool {
|
|
|
351
351
|
private readonly preambleHash: string;
|
|
352
352
|
/**
|
|
353
353
|
* WASM modules to ship in the LOADER `modules` map. See
|
|
354
|
-
*
|
|
354
|
+
* IsolatePoolOptions.wasmModules for the rationale. Stored in
|
|
355
355
|
* insertion order so the per-import preamble we generate matches
|
|
356
356
|
* across pool dispatches (cache-key stability).
|
|
357
357
|
*/
|
|
@@ -384,14 +384,14 @@ export class LoaderPool {
|
|
|
384
384
|
constructor(
|
|
385
385
|
env: unknown,
|
|
386
386
|
ctx: DurableObjectState,
|
|
387
|
-
opts?:
|
|
387
|
+
opts?: IsolatePoolOptions,
|
|
388
388
|
) {
|
|
389
389
|
// A host hands its whole env over; the binding is claimed here and the
|
|
390
390
|
// claim is checked on the next line.
|
|
391
|
-
const loader = (env as
|
|
391
|
+
const loader = (env as IsolatePoolEnv | null | undefined)?.LOADER;
|
|
392
392
|
if (!loader || typeof loader.get !== 'function') {
|
|
393
393
|
throw new BindingError(
|
|
394
|
-
'
|
|
394
|
+
'IsolatePool: env.LOADER binding missing or invalid. ' +
|
|
395
395
|
'Add a [[worker_loaders]] entry to wrangler.jsonc.',
|
|
396
396
|
);
|
|
397
397
|
}
|
|
@@ -423,14 +423,14 @@ export class LoaderPool {
|
|
|
423
423
|
// value is whatever it really was rather than the ArrayBuffer here.
|
|
424
424
|
const got = (bytes as { constructor?: { name?: string } } | null | undefined)?.constructor?.name;
|
|
425
425
|
throw new BindingError(
|
|
426
|
-
`
|
|
426
|
+
`IsolatePool: wasmModules['${name}'] must be ArrayBuffer ` +
|
|
427
427
|
`(got ${got || typeof bytes}).`,
|
|
428
428
|
);
|
|
429
429
|
}
|
|
430
430
|
const id = name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^[^A-Za-z_]/, '_');
|
|
431
431
|
if (seenIds.has(id)) {
|
|
432
432
|
throw new BindingError(
|
|
433
|
-
`
|
|
433
|
+
`IsolatePool: wasmModules key '${name}' collides with another after ` +
|
|
434
434
|
`identifier-sanitisation (id='${id}'). Pick distinct module names.`,
|
|
435
435
|
);
|
|
436
436
|
}
|
|
@@ -488,7 +488,7 @@ export class LoaderPool {
|
|
|
488
488
|
return this.concurrency;
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
-
#resolve(opts?:
|
|
491
|
+
#resolve(opts?: IsolateCallOptions): ResolvedResilience {
|
|
492
492
|
return {
|
|
493
493
|
timeoutMs: Math.max(0, opts?.timeoutMs ?? this.defaultTimeoutMs),
|
|
494
494
|
retries: Math.max(0, opts?.retries ?? this.defaultRetries),
|
|
@@ -515,21 +515,21 @@ export class LoaderPool {
|
|
|
515
515
|
if (!(bytes instanceof ArrayBuffer)) {
|
|
516
516
|
const got = (bytes as { constructor?: { name?: string } } | null | undefined)?.constructor?.name;
|
|
517
517
|
throw new BindingError(
|
|
518
|
-
`
|
|
518
|
+
`IsolatePool: per-call wasmModules['${name}'] must be ` +
|
|
519
519
|
`ArrayBuffer (got ${got || typeof bytes}).`,
|
|
520
520
|
);
|
|
521
521
|
}
|
|
522
522
|
const id = name.replace(/[^A-Za-z0-9_]/g, '_').replace(/^[^A-Za-z_]/, '_');
|
|
523
523
|
if (ctorIds.has(id)) {
|
|
524
524
|
throw new BindingError(
|
|
525
|
-
`
|
|
525
|
+
`IsolatePool: per-call wasmModules key '${name}' (sanitised ` +
|
|
526
526
|
`id='${id}') collides with a constructor-time wasm module. ` +
|
|
527
527
|
`Per-call modules cannot shadow pool-defaults. Pick a distinct name.`,
|
|
528
528
|
);
|
|
529
529
|
}
|
|
530
530
|
if (seen.has(id)) {
|
|
531
531
|
throw new BindingError(
|
|
532
|
-
`
|
|
532
|
+
`IsolatePool: per-call wasmModules key '${name}' (sanitised ` +
|
|
533
533
|
`id='${id}') collides with another per-call key. Pick distinct names.`,
|
|
534
534
|
);
|
|
535
535
|
}
|
|
@@ -620,7 +620,7 @@ export class LoaderPool {
|
|
|
620
620
|
// re-import (the user fn is serialized via fn.toString and doesn't
|
|
621
621
|
// carry import statements).
|
|
622
622
|
//
|
|
623
|
-
// Per-call entries (passed via
|
|
623
|
+
// Per-call entries (passed via IsolateCallOptions.wasmModules
|
|
624
624
|
// — used by the wasm-runner shell command) are appended to the same
|
|
625
625
|
// table. Naming collision with constructor entries is rejected
|
|
626
626
|
// upstream in #materialisePerCallWasm so the import block here
|
|
@@ -843,7 +843,7 @@ export class LoaderPool {
|
|
|
843
843
|
async submit<T, R>(
|
|
844
844
|
fn: FacetTaskFn<T, R>,
|
|
845
845
|
arg: T,
|
|
846
|
-
opts?:
|
|
846
|
+
opts?: IsolateCallOptions,
|
|
847
847
|
): Promise<Awaited<R>> {
|
|
848
848
|
const { fnSource, fnHash } = this.#prepare(fn);
|
|
849
849
|
const resilience = this.#resolve(opts);
|
|
@@ -866,7 +866,7 @@ export class LoaderPool {
|
|
|
866
866
|
async map<T, R>(
|
|
867
867
|
fn: FacetTaskFn<T, R>,
|
|
868
868
|
items: T[],
|
|
869
|
-
opts?:
|
|
869
|
+
opts?: IsolateMapOptions,
|
|
870
870
|
): Promise<Array<Awaited<R> | null>> {
|
|
871
871
|
if (items.length === 0) return [];
|
|
872
872
|
|
|
@@ -877,7 +877,7 @@ export class LoaderPool {
|
|
|
877
877
|
/**
|
|
878
878
|
* Same shape as `map`, but accepts a pre-serialized function source
|
|
879
879
|
* string instead of a live function reference. Used by
|
|
880
|
-
* `
|
|
880
|
+
* `Fanout`'s peer-DO leg, where the function was already
|
|
881
881
|
* serialized on the coordinator side and forwarded over RPC.
|
|
882
882
|
*
|
|
883
883
|
* The fnSource MUST be the output of `serializeFunction(fn)`
|
|
@@ -894,7 +894,7 @@ export class LoaderPool {
|
|
|
894
894
|
async mapSource<T, R>(
|
|
895
895
|
fnSource: string,
|
|
896
896
|
items: T[],
|
|
897
|
-
opts?:
|
|
897
|
+
opts?: IsolateMapOptions,
|
|
898
898
|
): Promise<Array<Awaited<R> | null>> {
|
|
899
899
|
if (items.length === 0) return [];
|
|
900
900
|
const fnHash = hashSource(fnSource);
|
|
@@ -905,7 +905,7 @@ export class LoaderPool {
|
|
|
905
905
|
fnSource: string,
|
|
906
906
|
fnHash: string,
|
|
907
907
|
items: T[],
|
|
908
|
-
opts?:
|
|
908
|
+
opts?: IsolateMapOptions,
|
|
909
909
|
): Promise<Array<Awaited<R> | null>> {
|
|
910
910
|
const resilience = this.#resolve(opts);
|
|
911
911
|
const concurrency = Math.max(
|
package/src/journal.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* journal.ts — an append-only event journal with dedupe and delivery leases,
|
|
3
|
+
* over a Durable Object's own SQLite.
|
|
4
|
+
*
|
|
5
|
+
* Modeled on Proteus's EventLog (`core/src/events/hub/log.ts`), which proved
|
|
6
|
+
* the contract this keeps:
|
|
7
|
+
*
|
|
8
|
+
* - `publish` returns `{ id, admitted }` — a dedupe hit returns the
|
|
9
|
+
* EXISTING id with `admitted: false` (log.ts:53-58), and the dedupe is
|
|
10
|
+
* storage-level: a unique partial index over non-null keys, because its
|
|
11
|
+
* schema comment calls the indexes "mandatory — without them recovery
|
|
12
|
+
* scans regress to table-scans on the hot path" (hub/schema.ts:6-7).
|
|
13
|
+
* - pending reads are priority-ordered: higher priority first, arrival
|
|
14
|
+
* order within a priority.
|
|
15
|
+
*
|
|
16
|
+
* Where this deliberately differs: Proteus binds a delivery by writing
|
|
17
|
+
* `consumed_at` and then needs a cold-start sweep (`unbindStale`, 10 minutes,
|
|
18
|
+
* orchestrator.ts:196) to recover rows a dead activation left bound. Here a
|
|
19
|
+
* claim takes a LEASE that expires on its own — expiry alone re-pends the
|
|
20
|
+
* row, so recovery needs no sweep and no cold-start hook. A lease that
|
|
21
|
+
* expired and was re-claimed is fenced by a per-claim nonce: the dead
|
|
22
|
+
* holder's `done`/`defer`/`dismiss` returns false instead of clobbering the
|
|
23
|
+
* new claimant.
|
|
24
|
+
*
|
|
25
|
+
* Everything here is synchronous — DO SQLite is — so the journal is safe to
|
|
26
|
+
* touch from a constructor without awaiting on the init gate.
|
|
27
|
+
*
|
|
28
|
+
* The index set is derived from this module's own query set, not copied from
|
|
29
|
+
* the consumer's nine: the claim scan gets a partial index over pending rows
|
|
30
|
+
* in claim order, and the dedupe probe gets the unique partial index. No
|
|
31
|
+
* other query here repeats on a hot path.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { z } from 'zod/v4';
|
|
35
|
+
|
|
36
|
+
/** Synchronous DO SQLite, as the journal uses it. */
|
|
37
|
+
export interface JournalSqlExec {
|
|
38
|
+
exec(query: string, ...bindings: Array<string | number | null>): Iterable<unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface JournalContext {
|
|
42
|
+
storage: { sql: JournalSqlExec };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface JournalPublishResult {
|
|
46
|
+
/** The event id — the existing one when the publish was a duplicate. */
|
|
47
|
+
id: string;
|
|
48
|
+
/** False when the dedupe key already existed; nothing was written. */
|
|
49
|
+
admitted: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** One claimed delivery: the record, and the lease that settles it. */
|
|
53
|
+
export interface JournalClaim<P> {
|
|
54
|
+
id: string;
|
|
55
|
+
payload: P;
|
|
56
|
+
priority: number;
|
|
57
|
+
receivedAt: number;
|
|
58
|
+
lease: JournalLease;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* One delivery lease. Every settle returns whether THIS lease still held the
|
|
63
|
+
* row — false means the lease expired and another claim owns it now, and
|
|
64
|
+
* nothing was written.
|
|
65
|
+
*/
|
|
66
|
+
export interface JournalLease {
|
|
67
|
+
/** The event is handled; it never delivers again. */
|
|
68
|
+
done(): boolean;
|
|
69
|
+
/** Release the lease and hide the event until `at`. */
|
|
70
|
+
defer(at: number): boolean;
|
|
71
|
+
/** The event is refused for good; it never delivers again. */
|
|
72
|
+
dismiss(reason: string): boolean;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const ClaimRowSchema = z.object({
|
|
76
|
+
id: z.string(),
|
|
77
|
+
payload: z.string(),
|
|
78
|
+
priority: z.number(),
|
|
79
|
+
received_at: z.number(),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const NAME_PATTERN = /^[a-z][a-z0-9_]{0,40}$/;
|
|
83
|
+
|
|
84
|
+
/** One named journal on one hosting actor. Cheap accessor, like `timers()`. */
|
|
85
|
+
export function journal<P>(ctx: JournalContext, name: string): Journal<P> {
|
|
86
|
+
return new Journal(ctx, name);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export class Journal<P> {
|
|
90
|
+
private readonly table: string;
|
|
91
|
+
private schemaReady = false;
|
|
92
|
+
private lastId = '';
|
|
93
|
+
private seq = 0;
|
|
94
|
+
private leaseSeq = 0;
|
|
95
|
+
|
|
96
|
+
constructor(private readonly ctx: JournalContext, name: string) {
|
|
97
|
+
if (!NAME_PATTERN.test(name)) {
|
|
98
|
+
throw new Error(`fabric: journal name '${name}' must match ${NAME_PATTERN}`);
|
|
99
|
+
}
|
|
100
|
+
this.table = `journal_${name}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private ensureSchema(): void {
|
|
104
|
+
if (this.schemaReady) return;
|
|
105
|
+
const sql = this.ctx.storage.sql;
|
|
106
|
+
sql.exec(`CREATE TABLE IF NOT EXISTS ${this.table} (
|
|
107
|
+
id TEXT PRIMARY KEY,
|
|
108
|
+
payload TEXT NOT NULL,
|
|
109
|
+
dedupe_key TEXT,
|
|
110
|
+
priority INTEGER NOT NULL DEFAULT 0,
|
|
111
|
+
state TEXT NOT NULL DEFAULT 'pending'
|
|
112
|
+
CHECK (state IN ('pending', 'done', 'dismissed')),
|
|
113
|
+
received_at INTEGER NOT NULL,
|
|
114
|
+
not_before INTEGER NOT NULL DEFAULT 0,
|
|
115
|
+
lease_id TEXT,
|
|
116
|
+
lease_until INTEGER,
|
|
117
|
+
done_at INTEGER,
|
|
118
|
+
dismiss_reason TEXT
|
|
119
|
+
)`);
|
|
120
|
+
sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_${this.table}_dedupe
|
|
121
|
+
ON ${this.table} (dedupe_key) WHERE dedupe_key IS NOT NULL`);
|
|
122
|
+
// The claim scan in its own order, over pending rows only — done history
|
|
123
|
+
// grows without bound and must never be what recovery reads through.
|
|
124
|
+
sql.exec(`CREATE INDEX IF NOT EXISTS idx_${this.table}_pending
|
|
125
|
+
ON ${this.table} (priority DESC, id) WHERE state = 'pending'`);
|
|
126
|
+
const rows = [...sql.exec(`SELECT MAX(id) AS id FROM ${this.table}`)] as Array<{ id: string | null }>;
|
|
127
|
+
this.lastId = rows[0]?.id ?? '';
|
|
128
|
+
this.schemaReady = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Same shape as the outbox's: time-ordered, forced above every stored id. */
|
|
132
|
+
private mintId(now: number): string {
|
|
133
|
+
let id = `${now.toString(36).padStart(9, '0')}-${(this.seq++).toString(36).padStart(6, '0')}`;
|
|
134
|
+
if (this.lastId !== '' && id <= this.lastId) id = `${this.lastId}0`;
|
|
135
|
+
this.lastId = id;
|
|
136
|
+
return id;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Append one event. A dedupe key that already exists — pending, done, or
|
|
141
|
+
* dismissed — refuses the append and names the existing id.
|
|
142
|
+
*/
|
|
143
|
+
publish(payload: P, opts: { dedupeKey?: string; priority?: number; now?: number } = {}): JournalPublishResult {
|
|
144
|
+
this.ensureSchema();
|
|
145
|
+
const now = opts.now ?? Date.now();
|
|
146
|
+
const sql = this.ctx.storage.sql;
|
|
147
|
+
if (opts.dedupeKey !== undefined) {
|
|
148
|
+
const existing = [...sql.exec(
|
|
149
|
+
`SELECT id FROM ${this.table} WHERE dedupe_key = ?`, opts.dedupeKey,
|
|
150
|
+
)] as Array<{ id: string }>;
|
|
151
|
+
if (existing.length > 0) return { id: existing[0].id, admitted: false };
|
|
152
|
+
}
|
|
153
|
+
const id = this.mintId(now);
|
|
154
|
+
sql.exec(
|
|
155
|
+
`INSERT INTO ${this.table} (id, payload, dedupe_key, priority, received_at, not_before)
|
|
156
|
+
VALUES (?, ?, ?, ?, ?, 0)`,
|
|
157
|
+
id, JSON.stringify(payload), opts.dedupeKey ?? null, opts.priority ?? 0, now,
|
|
158
|
+
);
|
|
159
|
+
return { id, admitted: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Claim deliverable events under a lease: pending, at or past their
|
|
164
|
+
* revisit time, and not held by a live lease. Higher priority first,
|
|
165
|
+
* arrival order within a priority. Expiry alone re-pends a row a dead
|
|
166
|
+
* holder left leased — that is the whole recovery path.
|
|
167
|
+
*/
|
|
168
|
+
claim(opts: { leaseMs: number; minPriority?: number; limit?: number; now?: number }): Array<JournalClaim<P>> {
|
|
169
|
+
this.ensureSchema();
|
|
170
|
+
const now = opts.now ?? Date.now();
|
|
171
|
+
const sql = this.ctx.storage.sql;
|
|
172
|
+
const rows = [...sql.exec(
|
|
173
|
+
`SELECT id, payload, priority, received_at FROM ${this.table}
|
|
174
|
+
WHERE state = 'pending'
|
|
175
|
+
AND priority >= ?
|
|
176
|
+
AND not_before <= ?
|
|
177
|
+
AND (lease_until IS NULL OR lease_until <= ?)
|
|
178
|
+
ORDER BY priority DESC, id
|
|
179
|
+
LIMIT ?`,
|
|
180
|
+
opts.minPriority ?? -2147483648, now, now, opts.limit ?? 50,
|
|
181
|
+
)].map((row) => ClaimRowSchema.parse(row));
|
|
182
|
+
const claims: Array<JournalClaim<P>> = [];
|
|
183
|
+
for (const row of rows) {
|
|
184
|
+
const leaseId = `${now.toString(36)}-${(this.leaseSeq++).toString(36)}`;
|
|
185
|
+
sql.exec(
|
|
186
|
+
`UPDATE ${this.table} SET lease_id = ?, lease_until = ? WHERE id = ?`,
|
|
187
|
+
leaseId, now + opts.leaseMs, row.id,
|
|
188
|
+
);
|
|
189
|
+
claims.push({
|
|
190
|
+
id: row.id,
|
|
191
|
+
payload: JSON.parse(row.payload) as P,
|
|
192
|
+
priority: row.priority,
|
|
193
|
+
receivedAt: row.received_at,
|
|
194
|
+
lease: this.lease(row.id, leaseId),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return claims;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The settle ops all share one fence: they write only while the row is
|
|
202
|
+
* still pending under THIS lease's nonce. An expired-and-reclaimed row has
|
|
203
|
+
* a different nonce, so the dead holder's settle is a refused no-op —
|
|
204
|
+
* synchronous SQL makes the check-then-write atomic on the actor thread.
|
|
205
|
+
*/
|
|
206
|
+
private lease(id: string, leaseId: string): JournalLease {
|
|
207
|
+
const sql = this.ctx.storage.sql;
|
|
208
|
+
const holds = (): boolean => {
|
|
209
|
+
const rows = [...sql.exec(
|
|
210
|
+
`SELECT 1 AS held FROM ${this.table} WHERE id = ? AND state = 'pending' AND lease_id = ?`,
|
|
211
|
+
id, leaseId,
|
|
212
|
+
)];
|
|
213
|
+
return rows.length > 0;
|
|
214
|
+
};
|
|
215
|
+
return {
|
|
216
|
+
done: (): boolean => {
|
|
217
|
+
if (!holds()) return false;
|
|
218
|
+
sql.exec(
|
|
219
|
+
`UPDATE ${this.table} SET state = 'done', done_at = ?, lease_id = NULL, lease_until = NULL WHERE id = ?`,
|
|
220
|
+
Date.now(), id,
|
|
221
|
+
);
|
|
222
|
+
return true;
|
|
223
|
+
},
|
|
224
|
+
defer: (at: number): boolean => {
|
|
225
|
+
if (!holds()) return false;
|
|
226
|
+
sql.exec(
|
|
227
|
+
`UPDATE ${this.table} SET not_before = ?, lease_id = NULL, lease_until = NULL WHERE id = ?`,
|
|
228
|
+
at, id,
|
|
229
|
+
);
|
|
230
|
+
return true;
|
|
231
|
+
},
|
|
232
|
+
dismiss: (reason: string): boolean => {
|
|
233
|
+
if (!holds()) return false;
|
|
234
|
+
sql.exec(
|
|
235
|
+
`UPDATE ${this.table} SET state = 'dismissed', dismiss_reason = ?, lease_id = NULL, lease_until = NULL WHERE id = ?`,
|
|
236
|
+
reason, id,
|
|
237
|
+
);
|
|
238
|
+
return true;
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal typing for the one node builtin fabric uses. The repo compiles
|
|
3
|
+
* against workers-types only (no @types/node), and pulling all of
|
|
4
|
+
* @types/node for one class would let untyped node surface leak into
|
|
5
|
+
* workerd code. workerd ships AsyncLocalStorage under `nodejs_compat`
|
|
6
|
+
* (probe-verified 2026-05-04, see core's real-node-imports.ts matrix);
|
|
7
|
+
* bun and node ship it natively.
|
|
8
|
+
*/
|
|
9
|
+
declare module 'node:async_hooks' {
|
|
10
|
+
export class AsyncLocalStorage<T> {
|
|
11
|
+
run<R>(store: T, callback: () => R): R;
|
|
12
|
+
getStore(): T | undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|