@forgezero/runtime 0.1.8 → 0.1.10

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/audit.d.ts CHANGED
@@ -74,6 +74,8 @@ export interface AuditRecord extends AuditEntry {
74
74
  /** The previous record's `digest`, or `GENESIS_DIGEST` for the first. */
75
75
  previousDigest: string;
76
76
  digest: string;
77
+ /** Explicit because one realm may span locked (plain) and unlocked (sealed) periods. */
78
+ digestMode?: 'sha256' | 'hmac-sha256-v1';
77
79
  }
78
80
  /**
79
81
  * The bytes that get hashed.
@@ -111,6 +113,7 @@ export interface AuditStore {
111
113
  }
112
114
  export interface AppendOptions {
113
115
  digester?: Digester;
116
+ digestMode?: AuditRecord['digestMode'];
114
117
  now?: () => number;
115
118
  }
116
119
  /**
@@ -137,6 +140,12 @@ export interface ChainVerdict {
137
140
  reason?: AuditChainError['code'];
138
141
  message?: string;
139
142
  }
143
+ export type AuditDigesterResolver = (record: AuditRecord) => Digester | Promise<Digester>;
144
+ export interface AuditVerifyOptions {
145
+ digester?: Digester;
146
+ digesterForRecord?: AuditDigesterResolver;
147
+ expectGenesis?: boolean;
148
+ }
140
149
  /**
141
150
  * Walk a chain and report the FIRST break.
142
151
  *
@@ -150,10 +159,7 @@ export interface ChainVerdict {
150
159
  * predecessor; a full chain that does not start at genesis has been truncated
151
160
  * from the front, which is exactly the deletion this is meant to catch.
152
161
  */
153
- export declare function verifyChain(records: readonly AuditRecord[], options?: {
154
- digester?: Digester;
155
- expectGenesis?: boolean;
156
- }): Promise<ChainVerdict>;
162
+ export declare function verifyChain(records: readonly AuditRecord[], options?: AuditVerifyOptions): Promise<ChainVerdict>;
157
163
  export interface AuditExport {
158
164
  realm?: string;
159
165
  fromSequence: number;
@@ -181,7 +187,7 @@ export declare function exportRange(store: AuditStore, args: {
181
187
  realm?: string;
182
188
  fromSequence: number;
183
189
  toSequence: number;
184
- }, options?: AppendOptions): Promise<AuditExport>;
190
+ }, options?: AppendOptions & Pick<AuditVerifyOptions, 'digesterForRecord'>): Promise<AuditExport>;
185
191
  /**
186
192
  * Check an export against the anchor somebody kept.
187
193
  *
@@ -192,6 +198,7 @@ export declare function exportRange(store: AuditStore, args: {
192
198
  */
193
199
  export declare function verifyExport(slice: AuditExport, options?: {
194
200
  digester?: Digester;
201
+ digesterForRecord?: AuditDigesterResolver;
195
202
  expectSealDigest?: string;
196
203
  }): Promise<ChainVerdict>;
197
204
  /**
@@ -215,6 +222,15 @@ export interface EffectAuditRecord {
215
222
  }
216
223
  export interface AuditChainOptions extends AppendOptions {
217
224
  store: AuditStore;
225
+ /** Selects a digest at append time, allowing locked/plain and unlocked/sealed records in one chain. */
226
+ digesterForEntry?: (entry: AuditEntry) => Promise<Required<Pick<AppendOptions, 'digester' | 'digestMode'>>>;
227
+ /** Selects the verifier from the record's authenticated mode marker. */
228
+ digesterForRecord?: AuditDigesterResolver;
229
+ /** Retry only a store-declared optimistic sequence collision, never arbitrary failures. */
230
+ retryAppend?: {
231
+ attempts: number;
232
+ conflict(error: unknown): boolean;
233
+ };
218
234
  /** Called when an append fails. Silence here is how a trail dies unnoticed. */
219
235
  onError?: (error: unknown, entry: AuditEntry) => void;
220
236
  }
package/dist/audit.js CHANGED
@@ -323,7 +323,7 @@ class AuditChainError extends Error {
323
323
  var GENESIS_DIGEST = "0".repeat(64);
324
324
  function canonicalise(record) {
325
325
  const detail = record.detail ? Object.keys(record.detail).sort().map((key) => `${key}=${String(record.detail[key])}`).join("\x1F") : "";
326
- return [
326
+ const fields = [
327
327
  record.sequence,
328
328
  record.previousDigest,
329
329
  record.atMs,
@@ -335,7 +335,10 @@ function canonicalise(record) {
335
335
  record.targetKey ?? "",
336
336
  record.reason ?? "",
337
337
  detail
338
- ].join("\x1E");
338
+ ];
339
+ if (record.digestMode !== undefined)
340
+ fields.push(record.digestMode);
341
+ return fields.join("\x1E");
339
342
  }
340
343
  var hashDigester = (input) => sha256(input);
341
344
  var sealedDigester = (key) => {
@@ -351,7 +354,8 @@ async function appendRecord(store, entry, options = {}) {
351
354
  ...entry,
352
355
  sequence: (previous?.sequence ?? 0) + 1,
353
356
  atMs: (options.now ?? Date.now)(),
354
- previousDigest: previous?.digest ?? GENESIS_DIGEST
357
+ previousDigest: previous?.digest ?? GENESIS_DIGEST,
358
+ ...options.digestMode ? { digestMode: options.digestMode } : {}
355
359
  };
356
360
  const record = { ...unsigned, digest: await digester(canonicalise(unsigned)) };
357
361
  await store.append(record);
@@ -393,7 +397,8 @@ async function verifyChain(records, options = {}) {
393
397
  };
394
398
  }
395
399
  }
396
- const expected = await digester(canonicalise(record));
400
+ const recordDigester = options.digesterForRecord ? await options.digesterForRecord(record) : digester;
401
+ const expected = await recordDigester(canonicalise(record));
397
402
  if (!timingSafeEqual(expected, record.digest)) {
398
403
  return {
399
404
  ok: false,
@@ -418,6 +423,7 @@ async function exportRange(store, args, options = {}) {
418
423
  }
419
424
  const verdict = await verifyChain(records, {
420
425
  digester,
426
+ digesterForRecord: options.digesterForRecord,
421
427
  expectGenesis: args.fromSequence <= 1
422
428
  });
423
429
  if (!verdict.ok) {
@@ -437,6 +443,7 @@ async function verifyExport(slice, options = {}) {
437
443
  const digester = options.digester ?? hashDigester;
438
444
  const verdict = await verifyChain(slice.records, {
439
445
  digester,
446
+ digesterForRecord: options.digesterForRecord,
440
447
  expectGenesis: slice.anchorDigest === GENESIS_DIGEST
441
448
  });
442
449
  if (!verdict.ok)
@@ -475,13 +482,30 @@ function createAuditChain(options) {
475
482
  const digester = options.digester ?? hashDigester;
476
483
  const queue = createQueue();
477
484
  const enqueue = (realm, run) => queue.run(realm ?? "\x00platform", run).result;
485
+ const append = (entry) => enqueue(entry.realm, async () => {
486
+ const attempts = Math.max(1, Math.min(options.retryAppend?.attempts ?? 1, 32));
487
+ for (let attempt = 0;attempt < attempts; attempt += 1) {
488
+ const selected = options.digesterForEntry ? await options.digesterForEntry(entry) : { digester, digestMode: options.digestMode };
489
+ try {
490
+ return await appendRecord(options.store, entry, { ...options, ...selected });
491
+ } catch (error) {
492
+ if (attempt + 1 >= attempts || !options.retryAppend?.conflict(error))
493
+ throw error;
494
+ }
495
+ }
496
+ throw new Error("audit: append retry exhausted");
497
+ });
478
498
  return {
479
- append: (entry) => enqueue(entry.realm, () => appendRecord(options.store, entry, { ...options, digester })),
499
+ append,
480
500
  verify: async (realm, fromSequence = 1, toSequence = Number.MAX_SAFE_INTEGER) => {
481
501
  const records = await options.store.range(realm, fromSequence, toSequence);
482
- return verifyChain(records, { digester, expectGenesis: fromSequence <= 1 });
502
+ return verifyChain(records, {
503
+ digester,
504
+ digesterForRecord: options.digesterForRecord,
505
+ expectGenesis: fromSequence <= 1
506
+ });
483
507
  },
484
- export: (args) => exportRange(options.store, args, { ...options, digester }),
508
+ export: (args) => exportRange(options.store, args, { ...options, digester, digesterForRecord: options.digesterForRecord }),
485
509
  sink: {
486
510
  write(record) {
487
511
  const entry = {
@@ -498,7 +522,7 @@ function createAuditChain(options) {
498
522
  },
499
523
  ...record.outcome === "denied" && record.code ? { reason: record.code } : {}
500
524
  };
501
- enqueue(record.realm, () => appendRecord(options.store, entry, { ...options, digester })).catch((error) => options.onError?.(error, entry));
525
+ append(entry).catch((error) => options.onError?.(error, entry));
502
526
  }
503
527
  }
504
528
  };
package/dist/jobs.d.ts CHANGED
@@ -85,6 +85,13 @@ export interface JobContext {
85
85
  holdsLock(): Promise<boolean>;
86
86
  log(message: string, detail?: Record<string, unknown>): void;
87
87
  }
88
+ export declare class JobFenceLostError extends Error {
89
+ constructor();
90
+ }
91
+ /** Check/renew the distributed fence immediately before one mutation. */
92
+ export declare function withJobFence<T>(context: JobContext, mutate: () => Promise<T>): Promise<T>;
93
+ /** Sequential mutation helper; a lost fence stops before the next item is touched. */
94
+ export declare function forEachFenced<T>(context: JobContext, items: Iterable<T>, mutate: (item: T, index: number) => Promise<void>): Promise<void>;
88
95
  export interface JobResult {
89
96
  /** A resolved run may still report an operational failure without throwing. */
90
97
  ok?: boolean;
package/dist/jobs.js CHANGED
@@ -410,6 +410,27 @@ function storeLock(store, clock = systemClock) {
410
410
  release: (key, fence) => store.clear(key, fence)
411
411
  };
412
412
  }
413
+
414
+ class JobFenceLostError extends Error {
415
+ constructor() {
416
+ super("job: distributed lease was lost; refusing further mutation");
417
+ this.name = "JobFenceLostError";
418
+ }
419
+ }
420
+ async function withJobFence(context, mutate) {
421
+ if (context.signal.aborted)
422
+ throw context.signal.reason ?? new Error("job: aborted");
423
+ if (!await context.holdsLock())
424
+ throw new JobFenceLostError;
425
+ return mutate();
426
+ }
427
+ async function forEachFenced(context, items, mutate) {
428
+ let index = 0;
429
+ for (const item of items) {
430
+ await withJobFence(context, () => mutate(item, index));
431
+ index += 1;
432
+ }
433
+ }
413
434
  function defineJob(spec) {
414
435
  if (!spec.key.trim())
415
436
  throw new Error("A job needs a key — it is the lock key and the report key.");
@@ -460,12 +481,29 @@ function createScheduler(options) {
460
481
  const report = reports.get(job.key);
461
482
  const leaseMs = job.leaseMs ?? (job.every ? everyMs(job.every) * 4 : 60000);
462
483
  let lease;
484
+ let leaseHeld = true;
485
+ let renewTimer;
463
486
  if (!job.unlocked) {
464
487
  lease = await lock.acquire(job.key, leaseMs);
465
488
  if (!lease) {
466
489
  report.skippedLocked += 1;
467
490
  return;
468
491
  }
492
+ const renewEveryMs = Math.max(1, Math.floor(leaseMs / 3));
493
+ const scheduleRenewal = () => {
494
+ renewTimer = setTimeout(async () => {
495
+ if (!lease || !leaseHeld)
496
+ return;
497
+ try {
498
+ leaseHeld = await lock.renew(job.key, lease.fence, leaseMs);
499
+ } catch {
500
+ leaseHeld = false;
501
+ }
502
+ if (leaseHeld)
503
+ scheduleRenewal();
504
+ }, renewEveryMs);
505
+ };
506
+ scheduleRenewal();
469
507
  }
470
508
  report.state = "running";
471
509
  report.lastStartedAtMs = clock.now();
@@ -473,7 +511,18 @@ function createScheduler(options) {
473
511
  try {
474
512
  const result = await job.run({
475
513
  signal: controller.signal,
476
- holdsLock: async () => lease ? lock.renew(job.key, lease.fence, leaseMs) : true,
514
+ holdsLock: async () => {
515
+ if (!lease)
516
+ return true;
517
+ if (!leaseHeld)
518
+ return false;
519
+ try {
520
+ leaseHeld = await lock.renew(job.key, lease.fence, leaseMs);
521
+ } catch {
522
+ leaseHeld = false;
523
+ }
524
+ return leaseHeld;
525
+ },
477
526
  log: (message, detail) => options.onLog?.(job.key, message, detail)
478
527
  });
479
528
  report.lastResult = result ?? undefined;
@@ -491,6 +540,8 @@ function createScheduler(options) {
491
540
  report.consecutiveFailures += 1;
492
541
  options.onError?.(job.key, error);
493
542
  } finally {
543
+ if (renewTimer)
544
+ clearTimeout(renewTimer);
494
545
  report.runs += 1;
495
546
  report.lastFinishedAtMs = clock.now();
496
547
  report.lastDurationMs = clock.now() - startedAt;
@@ -644,13 +695,16 @@ function cursorJob(spec) {
644
695
  }
645
696
  var VERSION = "0.1.0";
646
697
  export {
698
+ withJobFence,
647
699
  systemClock,
648
700
  storeLock,
649
701
  nextWallClockAt,
650
702
  memoryLock,
703
+ forEachFenced,
651
704
  everyMs,
652
705
  defineJob,
653
706
  cursorJob,
654
707
  createScheduler,
655
- VERSION
708
+ VERSION,
709
+ JobFenceLostError
656
710
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forgezero/runtime",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "type": "module",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -190,7 +190,7 @@
190
190
  "prepublishOnly": "bun ../tools/package-task.ts prepublish runtime"
191
191
  },
192
192
  "dependencies": {
193
- "@forgezero/access": "^0.1.4"
193
+ "@forgezero/access": "^0.1.6"
194
194
  },
195
195
  "peerDependencies": {
196
196
  "@noble/ciphers": "^2.2.0",