@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.
Files changed (104) hide show
  1. package/README.md +208 -293
  2. package/dist/bindings.js +5 -5
  3. package/dist/budgets.d.ts +132 -0
  4. package/dist/budgets.d.ts.map +1 -0
  5. package/dist/budgets.js +248 -0
  6. package/dist/composition.d.ts +3 -0
  7. package/dist/composition.d.ts.map +1 -0
  8. package/dist/composition.js +2 -0
  9. package/dist/connections.d.ts +81 -0
  10. package/dist/connections.d.ts.map +1 -0
  11. package/dist/connections.js +114 -0
  12. package/dist/derived.d.ts +65 -0
  13. package/dist/derived.d.ts.map +1 -0
  14. package/dist/derived.js +95 -0
  15. package/dist/do-calls.d.ts +94 -0
  16. package/dist/do-calls.d.ts.map +1 -0
  17. package/dist/do-calls.js +111 -0
  18. package/dist/facet-pool.d.ts +90 -0
  19. package/dist/facet-pool.d.ts.map +1 -0
  20. package/dist/facet-pool.js +113 -0
  21. package/dist/{fanout-pool.d.ts → fanout.d.ts} +20 -20
  22. package/dist/fanout.d.ts.map +1 -0
  23. package/dist/{fanout-pool.js → fanout.js} +20 -20
  24. package/dist/{launch-journal.d.ts → fenced-work.d.ts} +58 -17
  25. package/dist/fenced-work.d.ts.map +1 -0
  26. package/dist/fenced-work.js +241 -0
  27. package/dist/generation.d.ts +69 -0
  28. package/dist/generation.d.ts.map +1 -0
  29. package/dist/generation.js +118 -0
  30. package/dist/{facet-image-store.d.ts → image-store.d.ts} +8 -8
  31. package/dist/image-store.d.ts.map +1 -0
  32. package/dist/{facet-image-store.js → image-store.js} +4 -4
  33. package/dist/index.d.ts +16 -8
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +16 -8
  36. package/dist/{loader-pool.d.ts → isolate-pool.d.ts} +19 -19
  37. package/dist/isolate-pool.d.ts.map +1 -0
  38. package/dist/{loader-pool.js → isolate-pool.js} +20 -20
  39. package/dist/journal.d.ts +111 -0
  40. package/dist/journal.d.ts.map +1 -0
  41. package/dist/journal.js +177 -0
  42. package/dist/outbox.d.ts +249 -0
  43. package/dist/outbox.d.ts.map +1 -0
  44. package/dist/outbox.js +355 -0
  45. package/dist/process-fabric.d.ts +33 -15
  46. package/dist/process-fabric.d.ts.map +1 -1
  47. package/dist/process-fabric.js +25 -15
  48. package/dist/process-host.d.ts +1 -1
  49. package/dist/process-host.d.ts.map +1 -1
  50. package/dist/process-host.js +19 -11
  51. package/dist/sealed.d.ts +78 -0
  52. package/dist/sealed.d.ts.map +1 -0
  53. package/dist/sealed.js +145 -0
  54. package/dist/timers.d.ts +138 -0
  55. package/dist/timers.d.ts.map +1 -0
  56. package/dist/timers.js +231 -0
  57. package/dist/{launch-pacer.d.ts → turn-budget.d.ts} +24 -21
  58. package/dist/turn-budget.d.ts.map +1 -0
  59. package/dist/{launch-pacer.js → turn-budget.js} +24 -12
  60. package/dist/workerd-facet-host.d.ts +67 -70
  61. package/dist/workerd-facet-host.d.ts.map +1 -1
  62. package/dist/workerd-facet-host.js +129 -181
  63. package/examples/agent-core-adapter.ts +191 -0
  64. package/package.json +4 -2
  65. package/src/bindings.ts +6 -6
  66. package/src/budgets.ts +308 -0
  67. package/src/composition.ts +16 -0
  68. package/src/connections.ts +140 -0
  69. package/src/derived.ts +135 -0
  70. package/src/do-calls.ts +156 -0
  71. package/src/facet-pool.ts +157 -0
  72. package/src/{fanout-pool.ts → fanout.ts} +35 -35
  73. package/src/{launch-journal.ts → fenced-work.ts} +129 -42
  74. package/src/generation.ts +144 -0
  75. package/src/{facet-image-store.ts → image-store.ts} +9 -9
  76. package/src/index.ts +16 -8
  77. package/src/{loader-pool.ts → isolate-pool.ts} +34 -34
  78. package/src/journal.ts +242 -0
  79. package/src/node-async-hooks.d.ts +14 -0
  80. package/src/outbox.ts +520 -0
  81. package/src/process-fabric.ts +43 -34
  82. package/src/process-host.ts +22 -20
  83. package/src/sealed.ts +150 -0
  84. package/src/timers.ts +294 -0
  85. package/src/{launch-pacer.ts → turn-budget.ts} +34 -27
  86. package/src/workerd-facet-host.ts +159 -208
  87. package/dist/alarms.d.ts +0 -134
  88. package/dist/alarms.d.ts.map +0 -1
  89. package/dist/alarms.js +0 -214
  90. package/dist/ctx-exports.d.ts +0 -47
  91. package/dist/ctx-exports.d.ts.map +0 -1
  92. package/dist/ctx-exports.js +0 -54
  93. package/dist/facet-image-store.d.ts.map +0 -1
  94. package/dist/fanout-pool.d.ts.map +0 -1
  95. package/dist/launch-journal.d.ts.map +0 -1
  96. package/dist/launch-journal.js +0 -154
  97. package/dist/launch-pacer.d.ts.map +0 -1
  98. package/dist/loader-ledger.d.ts +0 -57
  99. package/dist/loader-ledger.d.ts.map +0 -1
  100. package/dist/loader-ledger.js +0 -91
  101. package/dist/loader-pool.d.ts.map +0 -1
  102. package/src/alarms.ts +0 -275
  103. package/src/ctx-exports.ts +0 -77
  104. package/src/loader-ledger.ts +0 -112
@@ -1,5 +1,5 @@
1
1
  /**
2
- * loader-pool.ts — Nimbus loader-isolate pool based on cloudflare-parallel.
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
- * ctx-exports.ts) and forwards it as `env.SUPERVISOR` to every facet,
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 './ctx-exports.js';
29
- import { disposeRpcResource } from '@nimbus-sh/core/_shared/rpc-dispose.js';
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 './loader-ledger.js';
32
- import { assertModuleMapWithinCodeLimit } from './workerd-facet-host.js';
33
- import { recordFailure, setLastFacetId, getLastRpcFrame } from '@nimbus-sh/core/observability/oom-discriminator.js';
34
- import { classifyError } from '@nimbus-sh/core/observability/oom-classify.js';
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 LoaderPoolEnv {
58
+ export interface IsolatePoolEnv {
59
59
  LOADER?: WorkerLoader;
60
60
  }
61
61
 
62
- /** Options handed to LoaderPool's constructor. */
63
- export interface LoaderPoolOptions {
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 FanoutPool's peer-DO branch (peer-DO fanout): peer DOs
101
- * construct their per-task LoaderPool from inside
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 LoaderCallOptions {
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 LoaderMapOptions extends LoaderCallOptions {
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 LoaderPool(env, ctx, {
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 LoaderPool {
339
+ export class IsolatePool {
340
340
  private readonly loader: WorkerLoader;
341
- /** The hosting actor, as the loader-ledger's per-DO key. */
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
- * LoaderPoolOptions.wasmModules for the rationale. Stored in
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?: LoaderPoolOptions,
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 LoaderPoolEnv | null | undefined)?.LOADER;
391
+ const loader = (env as IsolatePoolEnv | null | undefined)?.LOADER;
392
392
  if (!loader || typeof loader.get !== 'function') {
393
393
  throw new BindingError(
394
- 'LoaderPool: env.LOADER binding missing or invalid. ' +
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
- `LoaderPool: wasmModules['${name}'] must be ArrayBuffer ` +
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
- `LoaderPool: wasmModules key '${name}' collides with another after ` +
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?: LoaderCallOptions): ResolvedResilience {
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
- `LoaderPool: per-call wasmModules['${name}'] must be ` +
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
- `LoaderPool: per-call wasmModules key '${name}' (sanitised ` +
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
- `LoaderPool: per-call wasmModules key '${name}' (sanitised ` +
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 LoaderCallOptions.wasmModules
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?: LoaderCallOptions,
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?: LoaderMapOptions,
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
- * `FanoutPool`'s peer-DO leg, where the function was already
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?: LoaderMapOptions,
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?: LoaderMapOptions,
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
+ }