@gaganref/confect-test 10.3.1-fork.92 → 10.3.1-fork.93

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 (36) hide show
  1. package/dist/TestConfect.d.ts +26 -0
  2. package/dist/TestConfect.d.ts.map +1 -1
  3. package/dist/TestConfect.js +9 -1
  4. package/dist/TestConfect.js.map +1 -1
  5. package/dist/internal/ConvexTest.d.ts +26 -1
  6. package/dist/internal/ConvexTest.d.ts.map +1 -1
  7. package/dist/internal/ConvexTest.js +55 -27
  8. package/dist/internal/ConvexTest.js.map +1 -1
  9. package/dist/internal/Execution.d.ts +138 -1
  10. package/dist/internal/Execution.d.ts.map +1 -1
  11. package/dist/internal/Execution.js +595 -182
  12. package/dist/internal/Execution.js.map +1 -1
  13. package/dist/internal/KernelRefusal.d.ts +134 -0
  14. package/dist/internal/KernelRefusal.d.ts.map +1 -1
  15. package/dist/internal/KernelRefusal.js +134 -1
  16. package/dist/internal/KernelRefusal.js.map +1 -1
  17. package/dist/internal/Redaction.d.ts +142 -0
  18. package/dist/internal/Redaction.d.ts.map +1 -0
  19. package/dist/internal/Redaction.js +448 -0
  20. package/dist/internal/Redaction.js.map +1 -0
  21. package/dist/internal/Snapshot.d.ts +298 -0
  22. package/dist/internal/Snapshot.d.ts.map +1 -0
  23. package/dist/internal/Snapshot.js +532 -0
  24. package/dist/internal/Snapshot.js.map +1 -0
  25. package/dist/internal/Subscriptions.d.ts +13 -0
  26. package/dist/internal/Subscriptions.d.ts.map +1 -1
  27. package/dist/internal/Subscriptions.js +27 -3
  28. package/dist/internal/Subscriptions.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/TestConfect.ts +63 -4
  31. package/src/internal/ConvexTest.ts +135 -38
  32. package/src/internal/Execution.ts +886 -287
  33. package/src/internal/KernelRefusal.ts +160 -0
  34. package/src/internal/Redaction.ts +730 -0
  35. package/src/internal/Snapshot.ts +945 -0
  36. package/src/internal/Subscriptions.ts +34 -4
@@ -0,0 +1,945 @@
1
+ // oxlint-disable-next-line effecttsgo/node-builtin-import -- Export is synchronous, and Effect 4.0's digests are asynchronous.
2
+ import { createHash } from "node:crypto";
3
+ import {
4
+ convexToJson,
5
+ jsonToConvex,
6
+ type JSONValue,
7
+ type Value,
8
+ } from "convex/values";
9
+ import * as Result from "effect/Result";
10
+ import * as Schema from "effect/Schema";
11
+ import type * as Kernel from "./Kernel.js";
12
+ import * as KernelRefusal from "./KernelRefusal.js";
13
+ import type * as Scheduling from "./Scheduling.js";
14
+ import {
15
+ tableNameFromId,
16
+ type DocumentId,
17
+ type FunctionPath,
18
+ type StoredDocument,
19
+ } from "./KernelValues.js";
20
+
21
+ /**
22
+ * The counters the kernel allocates identities from: the next document ID, the
23
+ * latest creation time handed out, the next held attempt's ID, and the identity
24
+ * epoch, which every subscriber's `authenticate` advances. A restored world
25
+ * continues each where its source stopped, so the same calls mint the same
26
+ * identities and the same identity versions.
27
+ */
28
+ export interface Identity {
29
+ readonly nextDocumentId: number;
30
+ readonly lastCreationTime: number;
31
+ readonly nextAttemptId: number;
32
+ readonly identityEpoch: number;
33
+ }
34
+
35
+ /**
36
+ * What the kernel keeps beside a job's row, with the job's test world named
37
+ * rather than held. `null` is a job scheduled with no invocation bound.
38
+ */
39
+ export interface JobTarget {
40
+ readonly functionPath: FunctionPath;
41
+ readonly standing: boolean;
42
+ readonly world: string | null;
43
+ }
44
+
45
+ /**
46
+ * The kernel state a snapshot carries: committed rows of every component with
47
+ * the version that wrote each, the write log, the latest commit timestamp,
48
+ * every job's target, the clock's floor and the identity counters.
49
+ */
50
+ export interface Image {
51
+ readonly components: ReadonlyArray<string>;
52
+ readonly state: Kernel.State;
53
+ /**
54
+ * The latest deadline the clock was advanced to. `-Infinity` before any.
55
+ */
56
+ readonly floor: number;
57
+ readonly identity: Identity;
58
+ /**
59
+ * The target of every job whose row is committed, by job ID.
60
+ */
61
+ readonly jobs: ReadonlyMap<DocumentId, JobTarget>;
62
+ }
63
+
64
+ /**
65
+ * Whether a piece of kernel state is in a snapshot. `exported` names the
66
+ * snapshot field that carries it. `excluded` says why a restore starts without
67
+ * it.
68
+ */
69
+ export type Participation =
70
+ | { readonly exported: string }
71
+ | { readonly excluded: string };
72
+
73
+ /**
74
+ * The snapshot's contract with the kernel's state. Every field of the committed
75
+ * state, of a job's target and of a `World` is listed here as exported or
76
+ * excluded with its reason. The first two are checked by type: a field added to
77
+ * `Kernel.State` or `Scheduling.Target` without an entry fails to compile. A
78
+ * `World`'s fields are private, which no type can enumerate, so
79
+ * `Snapshot.test.ts` compares a fresh world's own fields with `world` below,
80
+ * and a field added without an entry fails that test.
81
+ *
82
+ * That test sees only the own fields a fresh world has. It cannot see state
83
+ * created lazily after construction, held under a symbol or a `#private` name,
84
+ * or kept in a closure or another object, as subscribers are. So every live
85
+ * registry, state that exists only while something runs or a client is
86
+ * attached, is also listed in `live`. `World.liveCounts` reports each one,
87
+ * typed against `live`, and a restore refuses while any of them is non-empty,
88
+ * so a piece of live state the snapshot does not carry fails loudly rather than
89
+ * surviving a restore silently. A new live registry adds itself there.
90
+ *
91
+ * Owed by later rows, which add their state here when they land on `main`: cron
92
+ * state (#133), stored file bytes, storage ID mappings, `nextFile` and
93
+ * upload-token counters (#138), and component init markers. A restore never
94
+ * redeclares a cron and never draws a fresh random value: it installs the
95
+ * declared state as the snapshot holds it, so a row whose state comes from a
96
+ * declaration or a random draw exports what was declared or drawn.
97
+ */
98
+ export const contract = {
99
+ state: {
100
+ tables: { exported: "tables" },
101
+ version: { exported: "version" },
102
+ commitTs: { exported: "commitTs" },
103
+ log: { exported: "log" },
104
+ } satisfies { readonly [K in keyof Kernel.State]-?: Participation },
105
+ target: {
106
+ functionPath: { exported: "jobs[].target" },
107
+ standing: { exported: "jobs[].standing" },
108
+ world: { exported: "jobs[].world, by name" },
109
+ } satisfies { readonly [K in keyof Scheduling.Target]-?: Participation },
110
+ world: {
111
+ components: {
112
+ excluded:
113
+ "Registered code, not state: the restoring world registers the same components, which `components` checks.",
114
+ },
115
+ state: { exported: "the committed state above" },
116
+ opened: {
117
+ excluded: "Whether modules loaded. A restore opens its world first.",
118
+ },
119
+ nextDocId: { exported: "identity.nextDocumentId" },
120
+ lastCreationTime: { exported: "identity.lastCreationTime" },
121
+ nextQueryId: {
122
+ excluded:
123
+ "Ids of query streams, which live only inside a running function, and a snapshot is never taken while one runs.",
124
+ },
125
+ queries: {
126
+ excluded:
127
+ "Open query streams, which live only inside a running function.",
128
+ },
129
+ holding: {
130
+ excluded:
131
+ "Held attempts are running closures. A world holding one refuses to export.",
132
+ },
133
+ nextAttemptId: { exported: "identity.nextAttemptId" },
134
+ floor: { exported: "floor" },
135
+ targets: { exported: "jobs" },
136
+ live: {
137
+ excluded:
138
+ "Live registries, listed under `live` below. A world refuses to restore while any is non-empty.",
139
+ },
140
+ identityEpoch: { exported: "identity.identityEpoch" },
141
+ runtime: {
142
+ excluded:
143
+ "The driver's clock and commit timestamps. The restoring driver supplies its own.",
144
+ },
145
+ redaction: {
146
+ excluded:
147
+ "The secret policy is configuration: the restoring world is built with its own.",
148
+ },
149
+ secrets: {
150
+ excluded:
151
+ "Raw secrets are replay state and never reach an artifact. A snapshot writes markers, and a restore takes the raw values from the source's `secrets()`.",
152
+ },
153
+ } satisfies Readonly<Record<string, Participation>>,
154
+ /**
155
+ * Live registries: what blocks a snapshot while it is non-empty, and why.
156
+ */
157
+ live: {
158
+ running: {
159
+ blocks: "export and restore",
160
+ reason:
161
+ "Calls from the test in flight, whose effects a snapshot would split.",
162
+ },
163
+ walks: {
164
+ blocks: "export and restore",
165
+ reason: "Scheduler walks in flight, which would leave a job running.",
166
+ },
167
+ functions: {
168
+ blocks: "export and restore",
169
+ reason:
170
+ "Function executions, nested and detached ones included, which may still write. The binary lets detached work finish within its function's lifetime, so a snapshot waits for it rather than cancel it.",
171
+ },
172
+ held: {
173
+ blocks: "export and restore",
174
+ reason: "Held attempts are running closures a snapshot does not carry.",
175
+ },
176
+ subscribers: {
177
+ blocks: "restore",
178
+ reason:
179
+ "Live client state. A restore would rewind the identity versions an existing subscriber holds, so it needs a world with none.",
180
+ },
181
+ } satisfies Readonly<
182
+ Record<
183
+ string,
184
+ {
185
+ readonly blocks: "export and restore" | "restore";
186
+ readonly reason: string;
187
+ }
188
+ >
189
+ >,
190
+ /**
191
+ * State outside the kernel's world, live per client or per process.
192
+ */
193
+ outside: {
194
+ subscriptions: {
195
+ excluded:
196
+ "Live client state: subscribers, their callers and runs. A restore requires a world with no subscribers (`live.subscribers`) and starts with none. Identity versions continue through `identityEpoch`.",
197
+ },
198
+ requestIds: {
199
+ excluded:
200
+ "Request IDs, a process-wide counter, reach no committed state.",
201
+ },
202
+ } satisfies Readonly<Record<string, Participation>>,
203
+ } as const;
204
+
205
+ const header = "confect-kernel-snapshot 1 sha256:";
206
+
207
+ const FunctionPathJson = Schema.Struct({
208
+ componentPath: Schema.String,
209
+ udfPath: Schema.String,
210
+ });
211
+
212
+ const Body = Schema.Struct({
213
+ components: Schema.Array(Schema.String),
214
+ version: Schema.Int,
215
+ commitTs: Schema.String,
216
+ floor: Schema.NullOr(Schema.Finite),
217
+ identity: Schema.Struct({
218
+ nextDocumentId: Schema.Int,
219
+ lastCreationTime: Schema.Finite,
220
+ nextAttemptId: Schema.Int,
221
+ identityEpoch: Schema.Int,
222
+ }),
223
+ tables: Schema.Array(
224
+ Schema.Struct({
225
+ componentPath: Schema.String,
226
+ table: Schema.String,
227
+ rows: Schema.Array(
228
+ Schema.Struct({ version: Schema.Int, doc: Schema.Unknown }),
229
+ ),
230
+ }),
231
+ ),
232
+ jobs: Schema.Array(
233
+ Schema.Struct({
234
+ id: Schema.String,
235
+ target: FunctionPathJson,
236
+ standing: Schema.Boolean,
237
+ world: Schema.NullOr(Schema.String),
238
+ }),
239
+ ),
240
+ log: Schema.Array(
241
+ Schema.Struct({
242
+ version: Schema.Int,
243
+ commitTs: Schema.String,
244
+ changes: Schema.Array(
245
+ Schema.Struct({
246
+ componentPath: Schema.String,
247
+ id: Schema.String,
248
+ before: Schema.Unknown,
249
+ after: Schema.Unknown,
250
+ }),
251
+ ),
252
+ }),
253
+ ),
254
+ });
255
+
256
+ type Body = typeof Body.Type;
257
+
258
+ const byString = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0);
259
+
260
+ const sorted = <A>(items: Iterable<A>, key: (item: A) => string): Array<A> =>
261
+ Array.from(items).sort((a, b) => byString(key(a), key(b)));
262
+
263
+ /**
264
+ * What an export writes for one document, before it reaches the bytes: the
265
+ * kernel marks secrets here. `table` is the document's table.
266
+ */
267
+ export type Redact = (
268
+ componentPath: string,
269
+ table: string,
270
+ json: JSONValue,
271
+ ) => JSONValue;
272
+
273
+ /**
274
+ * What a restore reads back for one document before decoding it: the kernel
275
+ * puts marked secrets back here.
276
+ */
277
+ export type Unredact = (
278
+ json: JSONValue,
279
+ ) => Result.Result<JSONValue, KernelRefusal.SnapshotSecretMissing>;
280
+
281
+ const asIs: Redact = (_componentPath, _table, json) => json;
282
+
283
+ const digestOf = (bytes: Uint8Array): string =>
284
+ createHash("sha256").update(bytes).digest("hex");
285
+
286
+ /**
287
+ * Encode `image` as snapshot bytes: a header line naming the format and the
288
+ * SHA-256 digest of the body, then the body as JSON. Components, tables, rows
289
+ * and jobs are sorted, and every document keeps its own field order, so the
290
+ * same state always encodes to the same bytes.
291
+ */
292
+ export const encode = (image: Image, redact: Redact = asIs): Uint8Array => {
293
+ const { state } = image;
294
+ const encodeDocument = (
295
+ componentPath: string,
296
+ doc: StoredDocument | null,
297
+ ): JSONValue | null =>
298
+ doc === null
299
+ ? null
300
+ : redact(
301
+ componentPath,
302
+ tableNameFromId(doc._id) ?? "",
303
+ convexToJson(doc as Value),
304
+ );
305
+ const tables: Array<Body["tables"][number]> = [];
306
+ for (const [componentPath, byTable] of sorted(
307
+ state.tables,
308
+ ([path]) => path,
309
+ )) {
310
+ for (const [table, rows] of sorted(byTable, ([name]) => name)) {
311
+ if (rows.size === 0) continue;
312
+ tables.push({
313
+ componentPath,
314
+ table,
315
+ rows: sorted(rows, ([id]) => id).map(([, row]) => ({
316
+ version: row.version,
317
+ doc: encodeDocument(componentPath, row.doc),
318
+ })),
319
+ });
320
+ }
321
+ }
322
+ const body: Body = {
323
+ components: sorted(image.components, (path) => path),
324
+ version: state.version,
325
+ commitTs: state.commitTs.toString(),
326
+ floor: Number.isFinite(image.floor) ? image.floor : null,
327
+ identity: {
328
+ nextDocumentId: image.identity.nextDocumentId,
329
+ lastCreationTime: image.identity.lastCreationTime,
330
+ nextAttemptId: image.identity.nextAttemptId,
331
+ identityEpoch: image.identity.identityEpoch,
332
+ },
333
+ tables,
334
+ jobs: sorted(image.jobs, ([id]) => id).map(([id, target]) => ({
335
+ id,
336
+ target: {
337
+ componentPath: target.functionPath.componentPath,
338
+ udfPath: target.functionPath.udfPath,
339
+ },
340
+ standing: target.standing,
341
+ world: target.world,
342
+ })),
343
+ log: state.log.map((commit) => ({
344
+ version: commit.version,
345
+ commitTs: commit.commitTs.toString(),
346
+ changes: commit.changes.map((change) => ({
347
+ componentPath: change.componentPath,
348
+ id: change.id,
349
+ before: encodeDocument(change.componentPath, change.before),
350
+ after: encodeDocument(change.componentPath, change.after),
351
+ })),
352
+ })),
353
+ };
354
+ const bodyBytes = new TextEncoder().encode(JSON.stringify(body));
355
+ const head = new TextEncoder().encode(`${header}${digestOf(bodyBytes)}\n`);
356
+ const bytes = new Uint8Array(head.length + bodyBytes.length);
357
+ bytes.set(head);
358
+ bytes.set(bodyBytes, head.length);
359
+ return bytes;
360
+ };
361
+
362
+ const malformed = (reason: string, detail: string) =>
363
+ new KernelRefusal.SnapshotMalformed({
364
+ reason,
365
+ message: `These bytes are not a test kernel snapshot this kernel reads (${reason}): ${detail}`,
366
+ });
367
+
368
+ const decodeDocument = (
369
+ json: unknown,
370
+ unredact: Unredact,
371
+ ): StoredDocument | null => {
372
+ if (json === null) return null;
373
+ const unredacted = unredact(json as JSONValue);
374
+ if (Result.isFailure(unredacted)) throw unredacted.failure;
375
+ const doc = jsonToConvex(unredacted.success);
376
+ if (
377
+ typeof doc !== "object" ||
378
+ doc === null ||
379
+ Array.isArray(doc) ||
380
+ typeof (doc as { _id?: unknown })._id !== "string"
381
+ ) {
382
+ throw new Error("a document is not an object with an `_id`");
383
+ }
384
+ return doc as StoredDocument;
385
+ };
386
+
387
+ const idOf = (doc: unknown): string => {
388
+ const id = (doc as { readonly _id?: unknown } | null)?._id;
389
+ return typeof id === "string" ? id : "";
390
+ };
391
+
392
+ /**
393
+ * Why a document's system fields are not ones the kernel writes, or nothing: an
394
+ * `_id` string and a finite, non-negative `_creationTime`.
395
+ */
396
+ const systemFieldsProblem = (doc: unknown): string | undefined => {
397
+ if (typeof doc !== "object" || doc === null || Array.isArray(doc))
398
+ return "is not an object";
399
+ const { _id, _creationTime } = doc as {
400
+ readonly _id?: unknown;
401
+ readonly _creationTime?: unknown;
402
+ };
403
+ if (typeof _id !== "string") return "has no string `_id`";
404
+ if (
405
+ typeof _creationTime !== "number" ||
406
+ !Number.isFinite(_creationTime) ||
407
+ _creationTime < 0
408
+ )
409
+ return "has no finite, non-negative `_creationTime`";
410
+ return undefined;
411
+ };
412
+
413
+ /**
414
+ * Check the decoded body before any map is built from it, so its order cannot
415
+ * change the result: components, tables, rows and jobs each appear once, and
416
+ * every document carries the system fields the kernel writes.
417
+ */
418
+ export const checkBody = (
419
+ body: Body,
420
+ ): Result.Result<
421
+ void,
422
+ | KernelRefusal.SnapshotMalformed
423
+ | KernelRefusal.SnapshotRowInconsistent
424
+ | KernelRefusal.SnapshotJobInconsistent
425
+ > => {
426
+ const once = <A>(items: ReadonlyArray<A>, key: (item: A) => string) => {
427
+ const seen = new Set<string>();
428
+ for (const item of items) {
429
+ const k = key(item);
430
+ if (seen.has(k)) return item;
431
+ seen.add(k);
432
+ }
433
+ return undefined;
434
+ };
435
+ const component = once(body.components, (path) => path);
436
+ if (component !== undefined)
437
+ return Result.fail(
438
+ malformed("duplicate", `component "${component}" appears twice`),
439
+ );
440
+ const twice = once(
441
+ body.tables,
442
+ (entry) => `${entry.componentPath}\u0000${entry.table}`,
443
+ );
444
+ if (twice !== undefined)
445
+ return Result.fail(
446
+ rowRefusal(
447
+ twice.componentPath,
448
+ twice.table,
449
+ "duplicate",
450
+ `names table "${twice.table}" twice`,
451
+ ),
452
+ );
453
+ for (const { componentPath, rows } of body.tables) {
454
+ for (const { doc } of rows) {
455
+ const problem = systemFieldsProblem(doc);
456
+ if (problem !== undefined)
457
+ return Result.fail(
458
+ rowRefusal(componentPath, idOf(doc), "systemFields", problem),
459
+ );
460
+ }
461
+ }
462
+ const byComponent = new Map<string, Array<string>>();
463
+ for (const { componentPath, rows } of body.tables)
464
+ byComponent.set(componentPath, [
465
+ ...(byComponent.get(componentPath) ?? []),
466
+ ...rows.map(({ doc }) => (doc as { _id: string })._id),
467
+ ]);
468
+ for (const [componentPath, ids] of byComponent) {
469
+ const id = once(ids, (i) => i);
470
+ if (id !== undefined)
471
+ return Result.fail(
472
+ rowRefusal(componentPath, id, "duplicate", "is stored twice"),
473
+ );
474
+ }
475
+ for (const { changes } of body.log) {
476
+ for (const { componentPath, id, before, after } of changes) {
477
+ for (const doc of [before, after]) {
478
+ if (doc === null) continue;
479
+ const problem =
480
+ systemFieldsProblem(doc) ??
481
+ ((doc as { _id: unknown })._id === id
482
+ ? undefined
483
+ : "is logged under another ID");
484
+ if (problem !== undefined)
485
+ return Result.fail(
486
+ rowRefusal(componentPath, id, "systemFields", problem),
487
+ );
488
+ }
489
+ }
490
+ }
491
+ const job = once(body.jobs, ({ id }) => id);
492
+ if (job !== undefined)
493
+ return Result.fail(
494
+ new KernelRefusal.SnapshotJobInconsistent({
495
+ id: job.id,
496
+ reason: "duplicate",
497
+ message: `The snapshot's job ${job.id} appears twice.`,
498
+ }),
499
+ );
500
+ return Result.succeed(undefined);
501
+ };
502
+
503
+ /**
504
+ * `body` with every document's marked secrets put back, each document checked
505
+ * to decode as a Convex value. No map is built, so the body checks that follow
506
+ * see the documents as the kernel stored them, in the order they were written.
507
+ */
508
+ const unredactBody = (
509
+ body: Body,
510
+ unredact: Unredact,
511
+ ): Result.Result<
512
+ Body,
513
+ KernelRefusal.SnapshotSecretMissing | KernelRefusal.SnapshotMalformed
514
+ > => {
515
+ let refusal:
516
+ | KernelRefusal.SnapshotSecretMissing
517
+ | KernelRefusal.SnapshotMalformed
518
+ | undefined;
519
+ const document = (json: unknown): unknown => {
520
+ if (json === null || refusal !== undefined) return json;
521
+ const unredacted = unredact(json as JSONValue);
522
+ if (Result.isFailure(unredacted)) {
523
+ refusal = unredacted.failure;
524
+ return json;
525
+ }
526
+ try {
527
+ jsonToConvex(unredacted.success);
528
+ } catch (error) {
529
+ refusal = malformed(
530
+ "value",
531
+ error instanceof Error ? error.message : String(error),
532
+ );
533
+ }
534
+ return unredacted.success;
535
+ };
536
+ const unredacted: Body = {
537
+ ...body,
538
+ tables: body.tables.map((entry) => ({
539
+ ...entry,
540
+ rows: entry.rows.map((row) => ({ ...row, doc: document(row.doc) })),
541
+ })),
542
+ log: body.log.map((commit) => ({
543
+ ...commit,
544
+ changes: commit.changes.map((change) => ({
545
+ ...change,
546
+ before: document(change.before),
547
+ after: document(change.after),
548
+ })),
549
+ })),
550
+ };
551
+ return refusal === undefined
552
+ ? Result.succeed(unredacted)
553
+ : Result.fail(refusal);
554
+ };
555
+
556
+ const imageOf = (body: Body, unredact: Unredact): Image => {
557
+ const tables = new Map<string, Map<string, Map<DocumentId, Kernel.Row>>>();
558
+ for (const { componentPath, table, rows } of body.tables) {
559
+ const byTable = tables.get(componentPath) ?? new Map();
560
+ tables.set(componentPath, byTable);
561
+ const byId = new Map<DocumentId, Kernel.Row>();
562
+ byTable.set(table, byId);
563
+ for (const { version, doc } of rows) {
564
+ const decoded = decodeDocument(doc, unredact)!;
565
+ byId.set(decoded._id, { doc: decoded, version });
566
+ }
567
+ }
568
+ const log = body.log.map((commit): Kernel.Commit => ({
569
+ version: commit.version,
570
+ commitTs: BigInt(commit.commitTs),
571
+ changes: commit.changes.map((change) => ({
572
+ componentPath: change.componentPath,
573
+ id: change.id as DocumentId,
574
+ before: decodeDocument(change.before, unredact),
575
+ after: decodeDocument(change.after, unredact),
576
+ })),
577
+ }));
578
+ if (log.length !== body.version || log.some((c, i) => c.version !== i + 1)) {
579
+ throw new Error(
580
+ `the write log holds ${log.length} commits for version ${body.version}`,
581
+ );
582
+ }
583
+ return {
584
+ components: body.components,
585
+ state: {
586
+ tables,
587
+ version: body.version,
588
+ commitTs: BigInt(body.commitTs),
589
+ log,
590
+ },
591
+ floor: body.floor ?? Number.NEGATIVE_INFINITY,
592
+ identity: body.identity,
593
+ jobs: new Map(
594
+ body.jobs.map((job) => [
595
+ job.id as DocumentId,
596
+ {
597
+ functionPath: job.target,
598
+ standing: job.standing,
599
+ world: job.world,
600
+ },
601
+ ]),
602
+ ),
603
+ };
604
+ };
605
+
606
+ /**
607
+ * Why a decoded snapshot is not state the kernel could have reached.
608
+ */
609
+ export type Inconsistency =
610
+ | KernelRefusal.SnapshotAllocatorBehind
611
+ | KernelRefusal.SnapshotRowInconsistent
612
+ | KernelRefusal.SnapshotTimestampInconsistent
613
+ | KernelRefusal.SnapshotJobInconsistent;
614
+
615
+ const documentText = (doc: StoredDocument | null): string =>
616
+ doc === null ? "null" : JSON.stringify(convexToJson(doc as Value));
617
+
618
+ /**
619
+ * The counter a kernel-minted document ID carries, or `undefined` for an ID the
620
+ * kernel never mints.
621
+ */
622
+ const counterOf = (id: string): number | undefined => {
623
+ const table = tableNameFromId(id);
624
+ const digits = table === null ? "" : id.slice(0, id.length - table.length);
625
+ return /^[0-9]+$/.test(digits) ? Number(digits) : undefined;
626
+ };
627
+
628
+ const rowRefusal = (
629
+ componentPath: string,
630
+ id: string,
631
+ reason: KernelRefusal.SnapshotRowInconsistent["reason"],
632
+ detail: string,
633
+ ) =>
634
+ new KernelRefusal.SnapshotRowInconsistent({
635
+ componentPath,
636
+ id,
637
+ reason,
638
+ message: `The snapshot's document ${id} in "${componentPath}" ${detail}.`,
639
+ });
640
+
641
+ /**
642
+ * Rows match the write log: replaying every commit from the empty world, each
643
+ * change replacing the value the log last published, gives exactly the
644
+ * snapshot's rows, each at the version that last wrote it, in a registered
645
+ * component and under its ID's table.
646
+ */
647
+ export const checkRows = (
648
+ image: Image,
649
+ ): Result.Result<void, KernelRefusal.SnapshotRowInconsistent> => {
650
+ const components = new Set(image.components);
651
+ const replayed = new Map<string, { doc: StoredDocument; version: number }>();
652
+ const key = (componentPath: string, id: string) =>
653
+ `${componentPath}\u0000${id}`;
654
+ for (const commit of image.state.log) {
655
+ for (const change of commit.changes) {
656
+ const current = replayed.get(key(change.componentPath, change.id));
657
+ if (documentText(current?.doc ?? null) !== documentText(change.before)) {
658
+ return Result.fail(
659
+ rowRefusal(
660
+ change.componentPath,
661
+ change.id,
662
+ "log",
663
+ `changes at version ${commit.version} from a value the log never published`,
664
+ ),
665
+ );
666
+ }
667
+ if (change.after === null)
668
+ replayed.delete(key(change.componentPath, change.id));
669
+ else
670
+ replayed.set(key(change.componentPath, change.id), {
671
+ doc: change.after,
672
+ version: commit.version,
673
+ });
674
+ }
675
+ }
676
+ const filed = new Set<string>();
677
+ for (const [componentPath, byTable] of image.state.tables) {
678
+ for (const [table, byId] of byTable) {
679
+ for (const [id, row] of byId) {
680
+ filed.add(key(componentPath, id));
681
+ if (counterOf(id) === undefined || tableNameFromId(id) !== table) {
682
+ return Result.fail(
683
+ rowRefusal(componentPath, id, "id", `is filed under "${table}"`),
684
+ );
685
+ }
686
+ if (!components.has(componentPath)) {
687
+ return Result.fail(
688
+ rowRefusal(
689
+ componentPath,
690
+ id,
691
+ "component",
692
+ "belongs to no component the snapshot registers",
693
+ ),
694
+ );
695
+ }
696
+ const published = replayed.get(key(componentPath, id));
697
+ if (
698
+ published === undefined ||
699
+ published.version !== row.version ||
700
+ documentText(published.doc) !== documentText(row.doc)
701
+ ) {
702
+ return Result.fail(
703
+ rowRefusal(
704
+ componentPath,
705
+ id,
706
+ "row",
707
+ "is not the value at the version the write log last published for it",
708
+ ),
709
+ );
710
+ }
711
+ }
712
+ }
713
+ }
714
+ for (const published of replayed.keys()) {
715
+ if (filed.has(published)) continue;
716
+ const [componentPath, id] = published.split("\u0000") as [string, string];
717
+ return Result.fail(
718
+ rowRefusal(
719
+ componentPath,
720
+ id,
721
+ "row",
722
+ "is published by the log but missing",
723
+ ),
724
+ );
725
+ }
726
+ return Result.succeed(undefined);
727
+ };
728
+
729
+ /**
730
+ * Identity counters run ahead of every identity the state holds: the next
731
+ * document ID past every ID's counter, the latest creation time at or past
732
+ * every document's, a positive next attempt ID and a non-negative epoch.
733
+ */
734
+ export const checkAllocators = (
735
+ image: Image,
736
+ ): Result.Result<void, KernelRefusal.SnapshotAllocatorBehind> => {
737
+ let maxCounter = -1;
738
+ let maxCreation = 0;
739
+ const see = (doc: StoredDocument | null) => {
740
+ if (doc === null) return;
741
+ maxCounter = Math.max(maxCounter, counterOf(doc._id) ?? -1);
742
+ maxCreation = Math.max(maxCreation, doc._creationTime);
743
+ };
744
+ for (const byTable of image.state.tables.values())
745
+ for (const byId of byTable.values())
746
+ for (const { doc } of byId.values()) see(doc);
747
+ for (const { changes } of image.state.log)
748
+ for (const { before, after } of changes) {
749
+ see(before);
750
+ see(after);
751
+ }
752
+ for (const id of image.jobs.keys())
753
+ maxCounter = Math.max(maxCounter, counterOf(id) ?? -1);
754
+ const { identity } = image;
755
+ const behind = (
756
+ allocator: KernelRefusal.SnapshotAllocatorBehind["allocator"],
757
+ value: number,
758
+ required: number,
759
+ ) =>
760
+ Result.fail(
761
+ new KernelRefusal.SnapshotAllocatorBehind({
762
+ allocator,
763
+ value,
764
+ required,
765
+ message: `The snapshot's ${allocator} is ${value}, but its state needs at least ${required}, or the next allocation would reuse an identity it already holds.`,
766
+ }),
767
+ );
768
+ if (identity.nextDocumentId <= maxCounter)
769
+ return behind("nextDocumentId", identity.nextDocumentId, maxCounter + 1);
770
+ if (identity.lastCreationTime < maxCreation)
771
+ return behind("lastCreationTime", identity.lastCreationTime, maxCreation);
772
+ if (identity.nextAttemptId < 1)
773
+ return behind("nextAttemptId", identity.nextAttemptId, 1);
774
+ if (identity.identityEpoch < 0)
775
+ return behind("identityEpoch", identity.identityEpoch, 0);
776
+ return Result.succeed(undefined);
777
+ };
778
+
779
+ /**
780
+ * Commit timestamps strictly increase through the write log, and the latest
781
+ * commit timestamp is the log's last, or zero before any commit.
782
+ */
783
+ export const checkTimestamps = (
784
+ image: Image,
785
+ ): Result.Result<void, KernelRefusal.SnapshotTimestampInconsistent> => {
786
+ let previous = 0n;
787
+ const refuse = (version: number, commitTs: bigint, detail: string) =>
788
+ Result.fail(
789
+ new KernelRefusal.SnapshotTimestampInconsistent({
790
+ version,
791
+ commitTs: commitTs.toString(),
792
+ message: `The snapshot's commit timestamp ${commitTs} at version ${version} ${detail}.`,
793
+ }),
794
+ );
795
+ for (const { version, commitTs } of image.state.log) {
796
+ if (commitTs <= previous)
797
+ return refuse(version, commitTs, `is not past the previous ${previous}`);
798
+ previous = commitTs;
799
+ }
800
+ if (image.state.commitTs !== previous)
801
+ return refuse(
802
+ image.state.version,
803
+ image.state.commitTs,
804
+ `is not the write log's last, ${previous}`,
805
+ );
806
+ return Result.succeed(undefined);
807
+ };
808
+
809
+ /**
810
+ * Every job names a committed job row and a registered component, every job row
811
+ * has its job, and no job is left running: a snapshot is never taken while a
812
+ * job runs.
813
+ */
814
+ export const checkJobs = (
815
+ image: Image,
816
+ table: string,
817
+ ): Result.Result<void, KernelRefusal.SnapshotJobInconsistent> => {
818
+ const refuse = (
819
+ id: string,
820
+ reason: KernelRefusal.SnapshotJobInconsistent["reason"],
821
+ detail: string,
822
+ ) =>
823
+ Result.fail(
824
+ new KernelRefusal.SnapshotJobInconsistent({
825
+ id,
826
+ reason,
827
+ message: `The snapshot's job ${id} ${detail}.`,
828
+ }),
829
+ );
830
+ const rows = new Map<string, StoredDocument>();
831
+ for (const byTable of image.state.tables.values())
832
+ for (const { doc } of byTable.get(table)?.values() ?? [])
833
+ rows.set(doc._id, doc);
834
+ const components = new Set(image.components);
835
+ for (const [id, target] of image.jobs) {
836
+ if (!rows.has(id)) return refuse(id, "row", "has no committed job row");
837
+ if (!components.has(target.functionPath.componentPath))
838
+ return refuse(
839
+ id,
840
+ "component",
841
+ `targets "${target.functionPath.componentPath}", which the snapshot does not register`,
842
+ );
843
+ }
844
+ for (const [id, doc] of rows) {
845
+ if (!image.jobs.has(id as DocumentId))
846
+ return refuse(id, "untargeted", "has a row but no target");
847
+ if ((doc["state"] as { kind?: unknown } | undefined)?.kind === "inProgress")
848
+ return refuse(id, "inProgress", "is recorded as running");
849
+ }
850
+ return Result.succeed(undefined);
851
+ };
852
+
853
+ /**
854
+ * Check a decoded image against every rule the kernel's own exports keep,
855
+ * before any of it is installed.
856
+ */
857
+ export const check = (
858
+ image: Image,
859
+ jobTable: string,
860
+ ): Result.Result<void, Inconsistency> => {
861
+ const checks: ReadonlyArray<() => Result.Result<void, Inconsistency>> = [
862
+ () => checkRows(image),
863
+ () => checkAllocators(image),
864
+ () => checkTimestamps(image),
865
+ () => checkJobs(image, jobTable),
866
+ ];
867
+ for (const run of checks) {
868
+ const checked = run();
869
+ if (Result.isFailure(checked)) return checked;
870
+ }
871
+ return Result.succeed(undefined);
872
+ };
873
+
874
+ /**
875
+ * Read snapshot bytes back into the state they encode. Bytes whose body no
876
+ * longer hashes to their header's digest refuse with `SnapshotDigestMismatch`
877
+ * before any of the body is read.
878
+ */
879
+ export const decode = (
880
+ bytes: Uint8Array,
881
+ unredact: Unredact = Result.succeed,
882
+ ): Result.Result<
883
+ Image,
884
+ | KernelRefusal.SnapshotMalformed
885
+ | KernelRefusal.SnapshotDigestMismatch
886
+ | KernelRefusal.SnapshotSecretMissing
887
+ | KernelRefusal.SnapshotRowInconsistent
888
+ | KernelRefusal.SnapshotJobInconsistent
889
+ > => {
890
+ const newline = bytes.indexOf(0x0a);
891
+ const head =
892
+ newline === -1
893
+ ? ""
894
+ : new TextDecoder("utf-8", { fatal: false }).decode(
895
+ bytes.subarray(0, newline),
896
+ );
897
+ const expected = head.startsWith(header) ? head.slice(header.length) : "";
898
+ if (!/^[0-9a-f]{64}$/.test(expected)) {
899
+ return Result.fail(
900
+ malformed("header", `the first line is not \`${header}<sha256>\``),
901
+ );
902
+ }
903
+ const bodyBytes = bytes.subarray(newline + 1);
904
+ const actual = digestOf(bodyBytes);
905
+ if (actual !== expected) {
906
+ return Result.fail(
907
+ new KernelRefusal.SnapshotDigestMismatch({
908
+ expected,
909
+ actual,
910
+ message: `The snapshot's body hashes to sha256:${actual}, not the sha256:${expected} its header names. Its bytes changed after export.`,
911
+ }),
912
+ );
913
+ }
914
+ let json: unknown;
915
+ try {
916
+ json = JSON.parse(
917
+ new TextDecoder("utf-8", { fatal: true }).decode(bodyBytes),
918
+ );
919
+ } catch (error) {
920
+ return Result.fail(malformed("json", String(error)));
921
+ }
922
+ const body = Schema.decodeUnknownResult(Body)(json);
923
+ if (Result.isFailure(body)) {
924
+ return Result.fail(malformed("shape", body.failure.message));
925
+ }
926
+ // Secrets go back first, so a document's system fields and IDs are checked as
927
+ // the kernel stored them, and every check runs before any map is built.
928
+ const unredacted = unredactBody(body.success, unredact);
929
+ if (Result.isFailure(unredacted)) return Result.fail(unredacted.failure);
930
+ const checkedBody = checkBody(unredacted.success);
931
+ if (Result.isFailure(checkedBody)) return Result.fail(checkedBody.failure);
932
+ try {
933
+ return Result.succeed(imageOf(unredacted.success, Result.succeed));
934
+ } catch (error) {
935
+ if (Schema.is(KernelRefusal.SnapshotSecretMissing)(error)) {
936
+ return Result.fail(error);
937
+ }
938
+ return Result.fail(
939
+ malformed(
940
+ "value",
941
+ error instanceof Error ? error.message : String(error),
942
+ ),
943
+ );
944
+ }
945
+ };