@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/README.md +1472 -240
- package/dist/audit.d.ts +21 -5
- package/dist/audit.js +32 -8
- package/dist/jobs.d.ts +7 -0
- package/dist/jobs.js +56 -2
- package/package.json +2 -2
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
|
-
|
|
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
|
-
]
|
|
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
|
|
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
|
|
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, {
|
|
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
|
-
|
|
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 () =>
|
|
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.
|
|
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.
|
|
193
|
+
"@forgezero/access": "^0.1.6"
|
|
194
194
|
},
|
|
195
195
|
"peerDependencies": {
|
|
196
196
|
"@noble/ciphers": "^2.2.0",
|