primitive-admin 1.1.0-alpha.81 → 1.1.0-alpha.83

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 (109) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +12 -0
  2. package/dist/src/commands/documents.js +455 -22
  3. package/dist/src/commands/documents.js.map +1 -1
  4. package/dist/src/commands/functions.js +889 -76
  5. package/dist/src/commands/functions.js.map +1 -1
  6. package/dist/src/commands/locks.js +15 -1
  7. package/dist/src/commands/locks.js.map +1 -1
  8. package/dist/src/commands/prompts.js +19 -2
  9. package/dist/src/commands/prompts.js.map +1 -1
  10. package/dist/src/commands/sync.d.ts +50 -0
  11. package/dist/src/commands/sync.js +587 -316
  12. package/dist/src/commands/sync.js.map +1 -1
  13. package/dist/src/commands/workflows.d.ts +1 -29
  14. package/dist/src/commands/workflows.js +8 -109
  15. package/dist/src/commands/workflows.js.map +1 -1
  16. package/dist/src/lib/api-client.d.ts +191 -2
  17. package/dist/src/lib/api-client.js +283 -7
  18. package/dist/src/lib/api-client.js.map +1 -1
  19. package/dist/src/lib/config-object-descriptor.js +12 -4
  20. package/dist/src/lib/config-object-descriptor.js.map +1 -1
  21. package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
  22. package/dist/src/lib/document-ingest-artifact.js +505 -0
  23. package/dist/src/lib/document-ingest-artifact.js.map +1 -0
  24. package/dist/src/lib/document-ingest-input.d.ts +51 -0
  25. package/dist/src/lib/document-ingest-input.js +132 -0
  26. package/dist/src/lib/document-ingest-input.js.map +1 -0
  27. package/dist/src/lib/document-ingest.d.ts +78 -0
  28. package/dist/src/lib/document-ingest.js +231 -0
  29. package/dist/src/lib/document-ingest.js.map +1 -0
  30. package/dist/src/lib/env-resolver-core.d.ts +2 -2
  31. package/dist/src/lib/env-resolver-core.js +2 -2
  32. package/dist/src/lib/function-bundle.d.ts +6 -0
  33. package/dist/src/lib/function-bundle.js +8 -0
  34. package/dist/src/lib/function-bundle.js.map +1 -1
  35. package/dist/src/lib/function-collect.d.ts +1 -1
  36. package/dist/src/lib/function-collect.js +16 -0
  37. package/dist/src/lib/function-collect.js.map +1 -1
  38. package/dist/src/lib/function-db-types.d.ts +4 -0
  39. package/dist/src/lib/function-db-types.js +46 -2
  40. package/dist/src/lib/function-db-types.js.map +1 -1
  41. package/dist/src/lib/function-document-types.d.ts +83 -0
  42. package/dist/src/lib/function-document-types.js +137 -0
  43. package/dist/src/lib/function-document-types.js.map +1 -1
  44. package/dist/src/lib/function-grants-preflight.js +5 -0
  45. package/dist/src/lib/function-grants-preflight.js.map +1 -1
  46. package/dist/src/lib/function-log-lines.d.ts +76 -0
  47. package/dist/src/lib/function-log-lines.js +160 -0
  48. package/dist/src/lib/function-log-lines.js.map +1 -0
  49. package/dist/src/lib/function-log-row.d.ts +29 -0
  50. package/dist/src/lib/function-log-row.js +73 -0
  51. package/dist/src/lib/function-log-row.js.map +1 -0
  52. package/dist/src/lib/function-log-tail.d.ts +51 -2
  53. package/dist/src/lib/function-log-tail.js +160 -13
  54. package/dist/src/lib/function-log-tail.js.map +1 -1
  55. package/dist/src/lib/function-run.d.ts +289 -0
  56. package/dist/src/lib/function-run.js +389 -0
  57. package/dist/src/lib/function-run.js.map +1 -0
  58. package/dist/src/lib/function-schema-codegen.d.ts +17 -9
  59. package/dist/src/lib/function-schema-codegen.js +30 -29
  60. package/dist/src/lib/function-schema-codegen.js.map +1 -1
  61. package/dist/src/lib/function-sync.d.ts +226 -30
  62. package/dist/src/lib/function-sync.js +731 -77
  63. package/dist/src/lib/function-sync.js.map +1 -1
  64. package/dist/src/lib/function-versions.d.ts +122 -0
  65. package/dist/src/lib/function-versions.js +182 -0
  66. package/dist/src/lib/function-versions.js.map +1 -0
  67. package/dist/src/lib/generated-config-surfaces.d.ts +561 -91
  68. package/dist/src/lib/generated-config-surfaces.js +1858 -287
  69. package/dist/src/lib/generated-config-surfaces.js.map +1 -1
  70. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  71. package/dist/src/lib/generated-sdk-types.js +1 -1
  72. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  73. package/dist/src/lib/log-inspection.d.ts +2 -0
  74. package/dist/src/lib/log-inspection.js +4 -0
  75. package/dist/src/lib/log-inspection.js.map +1 -1
  76. package/dist/src/lib/output.d.ts +15 -0
  77. package/dist/src/lib/output.js +28 -0
  78. package/dist/src/lib/output.js.map +1 -1
  79. package/dist/src/lib/prompt-schema-codegen.d.ts +94 -0
  80. package/dist/src/lib/prompt-schema-codegen.js +212 -0
  81. package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
  82. package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
  83. package/dist/src/lib/snapshot-audit-source.js +58 -0
  84. package/dist/src/lib/snapshot-audit-source.js.map +1 -0
  85. package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
  86. package/dist/src/lib/snapshot-audit-store.js +196 -0
  87. package/dist/src/lib/snapshot-audit-store.js.map +1 -0
  88. package/dist/src/lib/snapshot-audit.d.ts +207 -0
  89. package/dist/src/lib/snapshot-audit.js +431 -0
  90. package/dist/src/lib/snapshot-audit.js.map +1 -0
  91. package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
  92. package/dist/src/lib/snapshot-build-rows.js +87 -0
  93. package/dist/src/lib/snapshot-build-rows.js.map +1 -0
  94. package/dist/src/lib/snapshot-manifest-layout.d.ts +61 -0
  95. package/dist/src/lib/snapshot-manifest-layout.js +70 -0
  96. package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
  97. package/dist/src/lib/step-run-table.d.ts +43 -0
  98. package/dist/src/lib/step-run-table.js +129 -0
  99. package/dist/src/lib/step-run-table.js.map +1 -0
  100. package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
  101. package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
  102. package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
  103. package/dist/src/lib/sync-selectors.d.ts +45 -2
  104. package/dist/src/lib/sync-selectors.js +66 -5
  105. package/dist/src/lib/sync-selectors.js.map +1 -1
  106. package/dist/src/lib/workflow-toml-validator.d.ts +9 -5
  107. package/dist/src/lib/workflow-toml-validator.js +18 -10
  108. package/dist/src/lib/workflow-toml-validator.js.map +1 -1
  109. package/package.json +2 -2
@@ -279,6 +279,18 @@ primitive integrations archive <id> # the ID column of `integrations list`
279
279
  primitive prompts archive <id> # the ID column of `prompts list`
280
280
  primitive functions archive <id> # the ID column of `functions list`
281
281
 
282
+ # Running a server function (#3448)
283
+ primitive functions invoke <key> --input '{"n":1}' # request mode: run it, print the result
284
+ primitive functions start <key> --wait # task mode: start a run and wait for it
285
+ primitive functions runs wait <function-id> <run-id> # wait for a run already started
286
+ primitive functions invoke <key> --user <user-id> # as that app user (admin/owner; token revoked after)
287
+ primitive functions invoke <key> --as system # no caller: ctx.user null, a manual trigger
288
+ primitive functions logs <function-id> --invocation <id> # the record an invoke's id names
289
+ primitive functions logs <function-id> --run <run-id> # one task run's records
290
+ # invoke/start take the KEY; runs, wait, steps, terminate and logs take the ID.
291
+ # Exit codes: 0 completed, 1 failed or refused, 124 the wait gave up, 130 Ctrl-C.
292
+ # The wrong verb is REFUSED, never converted — on both sides.
293
+
282
294
  # Common operations
283
295
  primitive apps list # List apps on the active env's server
284
296
  primitive apps create "Name" # Create an app (does NOT auto-bind to an env;
@@ -1,13 +1,56 @@
1
+ import { snapshotBuildRows } from "../lib/snapshot-build-rows.js";
1
2
  import { ApiClient } from "../lib/api-client.js";
2
3
  import { resolveAppId } from "../lib/config.js";
3
- import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, } from "../lib/output.js";
4
+ import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, flushOutput, } from "../lib/output.js";
4
5
  import { confirmPrompt } from "../lib/confirm-prompt.js";
5
6
  import { parseDataOption } from "../lib/data-input.js";
6
7
  import { parseFilterOptions } from "../lib/record-filter.js";
7
8
  import { buildPermissionsExport } from "../lib/document-export-permissions.js";
9
+ import { chunkAddressOf, renumberManifestForExport, } from "../lib/snapshot-manifest-layout.js";
10
+ import { validateSnapshotManifest } from "js-bao";
11
+ import { auditSnapshotChain, } from "../lib/snapshot-audit.js";
12
+ import { createAuditApiSource } from "../lib/snapshot-audit-source.js";
13
+ import { createAuditLocalStore, createChainFingerprintReader, } from "../lib/snapshot-audit-store.js";
14
+ import { discoverIngestInput, } from "../lib/document-ingest-input.js";
15
+ import { buildIngestArtifact, ingestSchemaFromIntrospection, } from "../lib/document-ingest-artifact.js";
16
+ import { driveDocumentIngest, summariseIngestPlan, assertIngestConfirmable, } from "../lib/document-ingest.js";
8
17
  import { ulid } from "ulid";
9
18
  import * as fs from "fs";
10
19
  import * as path from "path";
20
+ /**
21
+ * One bulk-load session as rows (#3434, #3435).
22
+ *
23
+ * Shared by `documents ingests get` and by `documents ingest`'s own terminal
24
+ * report, so an operator reads the same thing however they arrived at it.
25
+ */
26
+ function printIngestSession(session) {
27
+ printResult("Session", session.sessionId);
28
+ printResult("Document", session.documentId);
29
+ printResult("State", session.state);
30
+ printResult("Created by", session.createdBy);
31
+ printResult("Created", formatDate(session.createdAt));
32
+ printResult("Updated", formatDate(session.updatedAt));
33
+ printResult("Completed", session.completedAt ? formatDate(session.completedAt) : "—");
34
+ printResult("Chunks", session.chunks);
35
+ printResult("Rows", session.rows);
36
+ printResult("Bytes", session.bytes);
37
+ const progress = session.progress ?? {};
38
+ printResult("Progress", `copied ${progress.rowsCopied ?? 0} rows, applied ` +
39
+ `${progress.chunksApplied ?? 0} chunks / ${progress.rowsApplied ?? 0} rows, ` +
40
+ `reconciled ${progress.recordsReconciled ?? 0} records`);
41
+ printResult("Copy point", session.copyStartedAt ? formatDate(session.copyStartedAt) : "—");
42
+ printResult("Discontinuity epoch", session.discontinuityEpoch ?? "—");
43
+ printResult("Build", session.buildId ?? "—");
44
+ printResult("Expires", formatDate(session.expiresAt));
45
+ // Why it stopped is the reason an operator opened this at all.
46
+ if (session.failure) {
47
+ printResult("Failure", session.failure.code);
48
+ printResult("Reason", session.failure.reason);
49
+ for (const row of session.failure.rows ?? []) {
50
+ printResult(" Row", typeof row === "string" ? row : `${row.model} ${row.id}`);
51
+ }
52
+ }
53
+ }
11
54
  /**
12
55
  * Report a document's format (#2816) — but only when it is a large document.
13
56
  *
@@ -898,6 +941,349 @@ Permissions (enforced by the server, not the CLI):
898
941
  process.exit(1);
899
942
  }
900
943
  });
944
+ // The action verb (#3435, criterion 9): a directory of records becomes a
945
+ // chunked, digested, uploaded artifact and one atomic swap of the
946
+ // document's tables. `documents ingests list|get` below is how an operator
947
+ // watches one afterwards.
948
+ //
949
+ // Destructive by nature — a bulk load deletes and overwrites records in a
950
+ // live document with no undo — so it confirms unless `-y`, which is what
951
+ // `docs/cli-design.md` reserves the destructive default for.
952
+ documents
953
+ .command("ingest")
954
+ .description("Bulk-load records into a large document from a directory")
955
+ .argument("<document-id>", "Document ID")
956
+ .requiredOption("--input <dir>", "Directory of <model>.ndjson[.gz] files, or a `documents export` output")
957
+ .option("--app <app-id>", "App ID")
958
+ .option("-y, --yes", "Skip the confirmation prompt")
959
+ .option("--no-wait", "Exit once the session is committed")
960
+ .option("--timeout <seconds>", "Give up waiting after this many seconds")
961
+ .option("--json", "Output as JSON")
962
+ .action(async (documentId, options) => {
963
+ const resolvedAppId = resolveAppId(undefined, options);
964
+ const client = new ApiClient();
965
+ let plan = null;
966
+ // The exit code is carried out of here rather than taken inside: a
967
+ // `process.exit` does not unwind, so ending the process in the try block
968
+ // would skip the cleanup in the finally and leave the compressed
969
+ // artifact — up to the whole input, gzipped — in the OS temp directory
970
+ // on every run, successful or not (finding 3435-FROZEN-05).
971
+ const run = async () => {
972
+ // The preflight, in this order and before a byte is read from disk:
973
+ // the schema read says whether this caller may see the document's
974
+ // declarations at all and hands over the ones the lines are checked
975
+ // against, and the session reader says whether this is a large
976
+ // document. Both are READS on the arm the ingest routes admit, so a
977
+ // caller who would be refused halfway is refused at once, and neither
978
+ // opens a session.
979
+ const introspection = await client.getDocumentSchema(resolvedAppId, documentId);
980
+ await client.listDocumentIngests(resolvedAppId, documentId, { limit: 1 });
981
+ const schema = ingestSchemaFromIntrospection(introspection);
982
+ const input = discoverIngestInput(options.input, documentId);
983
+ for (const ignored of input.ignored) {
984
+ info(`Ignoring ${ignored}: a bulk load reads the snapshot only.`);
985
+ }
986
+ plan = await buildIngestArtifact({ input, schema });
987
+ for (const line of summariseIngestPlan(plan))
988
+ info(line);
989
+ assertIngestConfirmable({
990
+ yes: Boolean(options.yes),
991
+ isTTY: Boolean(process.stdin.isTTY),
992
+ });
993
+ if (!options.yes) {
994
+ const confirmed = await confirmPrompt(`Bulk-load ${plan.rows} records into ${documentId}? ` +
995
+ "This replaces records in a live document and cannot be undone.");
996
+ if (!confirmed) {
997
+ info("Nothing was uploaded.");
998
+ return 1;
999
+ }
1000
+ }
1001
+ // Ctrl-C is a decision the driver makes differently depending on
1002
+ // where it lands, so it is handed the signal rather than killing the
1003
+ // process: mid-upload the session is aborted, mid-poll it is left
1004
+ // running, because by then the document is already being changed.
1005
+ const interrupt = new AbortController();
1006
+ const onSignal = () => interrupt.abort();
1007
+ process.on("SIGINT", onSignal);
1008
+ let outcome;
1009
+ try {
1010
+ outcome = await driveDocumentIngest({
1011
+ client: {
1012
+ createIngest: (id) => client.createDocumentIngest(resolvedAppId, id),
1013
+ uploadIngestChunk: (id, sessionId, chunk) => client.uploadDocumentIngestChunk(resolvedAppId, id, sessionId, chunk),
1014
+ commitIngest: (id, sessionId) => client.commitDocumentIngest(resolvedAppId, id, sessionId),
1015
+ abortIngest: (id, sessionId) => client.abortDocumentIngest(resolvedAppId, id, sessionId),
1016
+ getIngest: (id, sessionId) => client.getDocumentIngest(resolvedAppId, id, sessionId),
1017
+ },
1018
+ documentId,
1019
+ plan,
1020
+ wait: options.wait !== false,
1021
+ timeoutMs: options.timeout ? Number(options.timeout) * 1000 : null,
1022
+ signal: interrupt.signal,
1023
+ onProgress: (line) => info(line),
1024
+ });
1025
+ }
1026
+ finally {
1027
+ process.off("SIGINT", onSignal);
1028
+ }
1029
+ if (outcome.message)
1030
+ error(outcome.message);
1031
+ if (options.json) {
1032
+ json(outcome.session ?? { sessionId: outcome.sessionId });
1033
+ }
1034
+ else if (outcome.session) {
1035
+ printIngestSession(outcome.session);
1036
+ }
1037
+ else if (outcome.sessionId) {
1038
+ printResult("Session", outcome.sessionId);
1039
+ }
1040
+ return outcome.exitCode;
1041
+ };
1042
+ let code = 1;
1043
+ try {
1044
+ code = await run();
1045
+ }
1046
+ catch (err) {
1047
+ error(err.message);
1048
+ code = 1;
1049
+ }
1050
+ finally {
1051
+ await plan?.cleanup().catch(() => undefined);
1052
+ }
1053
+ // The result has to reach the pipe before the process ends: stdout to a
1054
+ // pipe is asynchronous and `process.exit` does not wait for it.
1055
+ await flushOutput();
1056
+ process.exit(code);
1057
+ });
1058
+ // Bulk-load sessions of a large document (#3434, criterion 9): a data noun
1059
+ // with `list` and `get` readers, per docs/cli-design.md, beside `snapshots`
1060
+ // below. The ACTION verb `documents ingest` is #3435's; these two are how an
1061
+ // operator sees what a session did and why one stopped.
1062
+ const ingests = documents
1063
+ .command("ingests")
1064
+ .description("Inspect a large document's bulk-load sessions");
1065
+ ingests
1066
+ .command("list")
1067
+ .description("List a large document's bulk-load sessions, newest first")
1068
+ .argument("<document-id>", "Document ID")
1069
+ .option("--app <app-id>", "App ID")
1070
+ .option("--limit <n>", "Page size (1–100)", "20")
1071
+ .option("--cursor <cursor>", "Continue from a previous page's nextCursor")
1072
+ .option("--json", "Output as JSON")
1073
+ .action(async (documentId, options) => {
1074
+ const resolvedAppId = resolveAppId(undefined, options);
1075
+ const client = new ApiClient();
1076
+ try {
1077
+ const page = await client.listDocumentIngests(resolvedAppId, documentId, {
1078
+ limit: Number(options.limit),
1079
+ ...(options.cursor ? { cursor: options.cursor } : {}),
1080
+ });
1081
+ if (options.json) {
1082
+ json(page);
1083
+ return;
1084
+ }
1085
+ if (page.items.length === 0) {
1086
+ info("No ingest sessions yet.");
1087
+ return;
1088
+ }
1089
+ console.log(formatTable(page.items, [
1090
+ { header: "SESSION", key: "sessionId" },
1091
+ { header: "STATE", key: "state" },
1092
+ { header: "CREATED", key: "createdAt", format: formatDate },
1093
+ { header: "CHUNKS", key: "chunks" },
1094
+ { header: "ROWS", key: "rows" },
1095
+ {
1096
+ header: "COMPLETED",
1097
+ key: "completedAt",
1098
+ format: (at) => (at ? formatDate(at) : "—"),
1099
+ },
1100
+ ]));
1101
+ if (page.hasMore && page.nextCursor) {
1102
+ info(`More sessions: --cursor ${page.nextCursor}`);
1103
+ }
1104
+ }
1105
+ catch (err) {
1106
+ error(err.message);
1107
+ process.exit(1);
1108
+ }
1109
+ });
1110
+ ingests
1111
+ .command("get")
1112
+ .description("Show one bulk-load session, its progress and why it stopped")
1113
+ .argument("<document-id>", "Document ID")
1114
+ .argument("<session-id>", "Session ID")
1115
+ .option("--app <app-id>", "App ID")
1116
+ .option("--json", "Output as JSON")
1117
+ .action(async (documentId, sessionId, options) => {
1118
+ const resolvedAppId = resolveAppId(undefined, options);
1119
+ const client = new ApiClient();
1120
+ try {
1121
+ const session = await client.getDocumentIngest(resolvedAppId, documentId, sessionId);
1122
+ if (options.json) {
1123
+ json(session);
1124
+ return;
1125
+ }
1126
+ printIngestSession(session);
1127
+ }
1128
+ catch (err) {
1129
+ error(err.message);
1130
+ process.exit(1);
1131
+ }
1132
+ });
1133
+ // Snapshot builds of a large document (#3432, criterion 9): a data noun
1134
+ // with `list` and `get` readers, per docs/cli-design.md, over the app API's
1135
+ // read-only inspection routes.
1136
+ const snapshots = documents
1137
+ .command("snapshots")
1138
+ .description("Inspect a large document's snapshot builds and their verification");
1139
+ snapshots
1140
+ .command("list")
1141
+ .description("List a large document's snapshot builds, newest first")
1142
+ .argument("<document-id>", "Document ID")
1143
+ .option("--app <app-id>", "App ID")
1144
+ .option("--limit <n>", "Page size (1–100)", "20")
1145
+ .option("--cursor <cursor>", "Continue from a previous page's nextCursor")
1146
+ .option("--json", "Output as JSON")
1147
+ .action(async (documentId, options) => {
1148
+ const resolvedAppId = resolveAppId(undefined, options);
1149
+ const client = new ApiClient();
1150
+ try {
1151
+ const page = await client.listDocumentSnapshots(resolvedAppId, documentId, {
1152
+ limit: Number(options.limit),
1153
+ ...(options.cursor ? { cursor: options.cursor } : {}),
1154
+ });
1155
+ if (options.json) {
1156
+ json(page);
1157
+ return;
1158
+ }
1159
+ if (page.items.length === 0) {
1160
+ info("No snapshot builds yet.");
1161
+ return;
1162
+ }
1163
+ console.log(formatTable(page.items, [
1164
+ { header: "BUILD", key: "buildId" },
1165
+ { header: "EPOCH", key: "epoch" },
1166
+ { header: "STATE", key: "state" },
1167
+ { header: "SOURCE", key: "source" },
1168
+ {
1169
+ header: "VERIFICATION",
1170
+ key: "verification",
1171
+ format: (verification) => verification
1172
+ ? verification.code
1173
+ ? `${verification.state} (${verification.code})`
1174
+ : verification.state
1175
+ : "-",
1176
+ },
1177
+ { header: "ROWS", key: "rows" },
1178
+ { header: "CHUNKS", key: "chunks" },
1179
+ { header: "STARTED", key: "startedAt", format: formatDate },
1180
+ { header: "COMPLETED", key: "completedAt", format: formatDate },
1181
+ ]));
1182
+ if (page.hasMore && page.nextCursor) {
1183
+ info(`More builds: --cursor ${page.nextCursor}`);
1184
+ }
1185
+ }
1186
+ catch (err) {
1187
+ error(err.message);
1188
+ process.exit(1);
1189
+ }
1190
+ });
1191
+ snapshots
1192
+ .command("get")
1193
+ .description("Show one snapshot build, its verification result and where its time went")
1194
+ .argument("<document-id>", "Document ID")
1195
+ .argument("<build-id>", "Build ID")
1196
+ .option("--app <app-id>", "App ID")
1197
+ .option("--json", "Output as JSON")
1198
+ .action(async (documentId, buildId, options) => {
1199
+ const resolvedAppId = resolveAppId(undefined, options);
1200
+ const client = new ApiClient();
1201
+ try {
1202
+ const build = await client.getDocumentSnapshot(resolvedAppId, documentId, buildId);
1203
+ if (options.json) {
1204
+ json(build);
1205
+ return;
1206
+ }
1207
+ // The rows are composed by a pure function (#3435), so what a
1208
+ // terminal shows is checkable without spawning one; the command still
1209
+ // owns the printing.
1210
+ for (const [label, value] of snapshotBuildRows(build)) {
1211
+ printResult(label, value);
1212
+ }
1213
+ }
1214
+ catch (err) {
1215
+ error(err.message);
1216
+ process.exit(1);
1217
+ }
1218
+ });
1219
+ // #3433, criterion 10 — the operator audit: `snapshot ⊕ overlays == table`,
1220
+ // recomputed on this side with the CLIENT's loader and fold and compared
1221
+ // with the authoritative table page by page. The one check that can catch a
1222
+ // DO/client fold divergence, which per-build verification runs on one side
1223
+ // of and therefore cannot see.
1224
+ snapshots
1225
+ .command("audit")
1226
+ .description("Recompute a large document's snapshot chain locally and compare it with the table")
1227
+ .argument("<document-id>", "Document ID")
1228
+ .option("--app <app-id>", "App ID")
1229
+ .option("--retries <n>", "Attempts before giving up on a document that keeps changing", "3")
1230
+ .option("--json", "Output as JSON")
1231
+ .action(async (documentId, options) => {
1232
+ const resolvedAppId = resolveAppId(undefined, options);
1233
+ const client = new ApiClient();
1234
+ const report = await auditDocumentChain(client, resolvedAppId, documentId, Number(options.retries));
1235
+ if (options.json) {
1236
+ json(report);
1237
+ }
1238
+ else if (report.verdict === "none") {
1239
+ // No verdict is not a clean bill of health, so nothing that could be
1240
+ // read as one is printed: the reason, and nothing else.
1241
+ error(`No verdict: ${report.reason}`);
1242
+ }
1243
+ else {
1244
+ printResult("Document", documentId);
1245
+ printResult("Base build", report.buildId);
1246
+ printResult("Base epoch", report.epoch);
1247
+ printResult("Attempts", report.attempts);
1248
+ printResult("Verdict", report.verdict === "match"
1249
+ ? "match — the table is the chain"
1250
+ : "DIVERGENCE");
1251
+ console.log(formatTable(report.models, [
1252
+ { header: "MODEL", key: "model" },
1253
+ { header: "COMPARED", key: "compared" },
1254
+ {
1255
+ header: "ONLY IN CHAIN",
1256
+ key: "onlyRecomputed",
1257
+ format: (ids) => (ids.length ? ids.join(", ") : "-"),
1258
+ },
1259
+ {
1260
+ header: "ONLY IN TABLE",
1261
+ key: "onlyTable",
1262
+ format: (ids) => (ids.length ? ids.join(", ") : "-"),
1263
+ },
1264
+ {
1265
+ header: "DIFFERING",
1266
+ key: "differing",
1267
+ format: (rows) => rows.length
1268
+ ? rows.map((row) => `${row.id} (${row.fields.join(", ")})`).join("; ")
1269
+ : "-",
1270
+ },
1271
+ ]));
1272
+ if (report.models.some((model) => model.truncated)) {
1273
+ info("Some lists were truncated; re-run with --json for the full report.");
1274
+ }
1275
+ if (report.verdict === "match")
1276
+ success("snapshot ⊕ overlays == table");
1277
+ }
1278
+ // The report has to reach the pipe before the process ends: stdout to a
1279
+ // pipe is asynchronous and `process.exit` does not wait for it, so a
1280
+ // large report piped into anything that is not reading as fast as this
1281
+ // writes used to arrive cut off mid-document (3433-C12).
1282
+ await flushOutput();
1283
+ // 0 match, 1 divergence, 2 no verdict — so the validation child can
1284
+ // script on "no verdict" without reading the text.
1285
+ process.exit(report.verdict === "match" ? 0 : report.verdict === "divergence" ? 1 : 2);
1286
+ });
901
1287
  // Transfer document ownership
902
1288
  documents
903
1289
  .command("transfer-owner")
@@ -1429,17 +1815,21 @@ async function exportLargeDocumentChain(client, appId, documentId, docDir) {
1429
1815
  const snapshotDir = path.join(docDir, "snapshot");
1430
1816
  fs.mkdirSync(snapshotDir, { recursive: true });
1431
1817
  const manifestBytes = await client.downloadDocumentArtifact(appId, chain.base.download.path);
1432
- fs.writeFileSync(path.join(snapshotDir, "manifest.json"), manifestBytes);
1433
- const manifest = JSON.parse(manifestBytes.toString("utf-8"));
1434
- for (const chunk of manifest.chunks ?? []) {
1435
- // Addressed within the granted build — `{model}/{n}` — never by key.
1436
- const index = chunkIndexFromKey(chunk.key);
1437
- const body = await client.downloadDocumentArtifact(appId, `${chain.base.download.path}/${chunk.model}/${index}`);
1438
- const modelDir = path.join(snapshotDir, chunk.model);
1439
- fs.mkdirSync(modelDir, { recursive: true });
1440
- fs.writeFileSync(path.join(modelDir, `${index}.ndjson.gz`), body);
1818
+ const downloaded = JSON.parse(manifestBytes.toString("utf-8"));
1819
+ // Addressed within the granted build by each entry's `path` — the
1820
+ // own-build `{model}/{n}` or the `{epoch}-{buildId}/{model}/{n}` of a
1821
+ // chunk reused from an earlier build — never by key (#3432). On disk the
1822
+ // chunks are renumbered per model into the own-build form and the stored
1823
+ // manifest says so, which is the one layout `documents import` reads.
1824
+ const { manifest, files } = renumberManifestForExport(downloaded);
1825
+ for (const file of files) {
1826
+ const body = await client.downloadDocumentArtifact(appId, `${chain.base.download.path}/${file.download}`);
1827
+ const target = path.join(snapshotDir, file.file);
1828
+ fs.mkdirSync(path.dirname(target), { recursive: true });
1829
+ fs.writeFileSync(target, body);
1441
1830
  bytes += body.byteLength;
1442
1831
  }
1832
+ fs.writeFileSync(path.join(snapshotDir, "manifest.json"), JSON.stringify(manifest, null, 2));
1443
1833
  base = {
1444
1834
  epoch: chain.base.epoch,
1445
1835
  buildId: chain.base.buildId,
@@ -1459,13 +1849,16 @@ async function exportLargeDocumentChain(client, appId, documentId, docDir) {
1459
1849
  }, null, 2));
1460
1850
  return bytes;
1461
1851
  }
1462
- /** The chunk number out of a manifest entry's key (`…/{model}/{n}.ndjson.gz`). */
1463
- function chunkIndexFromKey(key) {
1464
- const match = /\/(\d+)\.ndjson\.gz$/.exec(String(key ?? ""));
1465
- if (!match) {
1466
- throw new Error(`Snapshot manifest names a chunk this CLI cannot address: ${key}`);
1467
- }
1468
- return Number(match[1]);
1852
+ /**
1853
+ * Where a stored manifest entry's chunk is on disk: `snapshot/{model}/{n}`.
1854
+ *
1855
+ * An exported manifest is in the own-build form (renumbered per model), so
1856
+ * the entry's `path` — or, for an artifact written before the field existed,
1857
+ * its key — names the file directly.
1858
+ */
1859
+ function chunkFileOf(chunk) {
1860
+ const address = chunkAddressOf(chunk);
1861
+ return { model: address.model, index: address.index };
1469
1862
  }
1470
1863
  async function exportSingleDocument(client, appId, documentId, outputDir, includeBlobs, jsonOutput) {
1471
1864
  const docDir = path.join(outputDir, "documents", documentId);
@@ -1682,9 +2075,19 @@ function verifyChainArtifact(docDir, documentId, chain) {
1682
2075
  refuse(`has a base snapshot manifest that is not readable ` +
1683
2076
  `(${parseError.message})`);
1684
2077
  }
2078
+ // Validated where it enters (#3432): the fields the server keys on — an
2079
+ // entry's ordinal, its path, its range — fail silently rather than loudly
2080
+ // if they are wrong, and the server refuses the same manifest later with
2081
+ // the same message.
2082
+ try {
2083
+ validateSnapshotManifest(manifest);
2084
+ }
2085
+ catch (invalid) {
2086
+ refuse(`has a base snapshot manifest that is not valid (${invalid.message})`);
2087
+ }
1685
2088
  for (const chunk of manifest.chunks ?? []) {
1686
- const index = chunkIndexFromKey(String(chunk.key));
1687
- require(path.join(docDir, "snapshot", String(chunk.model), `${index}.ndjson.gz`), `chunk ${index} of ${chunk.model}`);
2089
+ const { model, index } = chunkFileOf(chunk);
2090
+ require(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`), `chunk ${index} of ${model}`);
1688
2091
  }
1689
2092
  }
1690
2093
  for (const overlay of chain.overlays) {
@@ -1722,14 +2125,15 @@ async function importLargeDocumentChain(client, appId, docDir, documentId) {
1722
2125
  }, manifestBytes);
1723
2126
  const manifest = JSON.parse(manifestBytes.toString("utf-8"));
1724
2127
  for (const chunk of manifest.chunks ?? []) {
1725
- const index = chunkIndexFromKey(chunk.key);
2128
+ // Addressed by the entry's `path` (#3432); the server installs by it too.
2129
+ const { model, index } = chunkFileOf(chunk);
1726
2130
  await client.uploadDocumentChainArtifact(appId, documentId, {
1727
2131
  kind: "chunk",
1728
2132
  epoch: chain.base.epoch,
1729
2133
  buildId: chain.base.buildId,
1730
- model: chunk.model,
2134
+ model,
1731
2135
  index,
1732
- }, fs.readFileSync(path.join(docDir, "snapshot", chunk.model, `${index}.ndjson.gz`)));
2136
+ }, fs.readFileSync(path.join(docDir, "snapshot", model, `${index}.ndjson.gz`)));
1733
2137
  }
1734
2138
  }
1735
2139
  // The sealed overlays, oldest first, then the open epoch's — which travels
@@ -2218,4 +2622,33 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
2218
2622
  `root document ${targetRootDocId}`);
2219
2623
  }
2220
2624
  }
2625
+ /**
2626
+ * Wire the audit to the app API (#3433, criterion 10).
2627
+ *
2628
+ * Two permissions, not one (3433-C08): the chain export takes the app-admin
2629
+ * arm, and the schema and records reads it is compared against take the
2630
+ * console arm or a read grant on the document — the documents surface grants
2631
+ * no app-role bypass on a read (#3279, D3279-002). The schema is read first,
2632
+ * so a caller holding only the first is refused before anything is exported.
2633
+ */
2634
+ async function auditDocumentChain(client, appId, documentId, retries) {
2635
+ // One page's worth of records is the audit's whole working set on the
2636
+ // server side; everything else lives in the local store.
2637
+ const AUDIT_PAGE_SIZE = 200;
2638
+ const fingerprint = await createChainFingerprintReader();
2639
+ // Nothing the server answers is carried from one attempt to the next: the
2640
+ // attempt a moved document discards is exactly the one whose schema has
2641
+ // changed under it (3433-C09).
2642
+ const source = createAuditApiSource(client, {
2643
+ appId,
2644
+ documentId,
2645
+ pageSize: AUDIT_PAGE_SIZE,
2646
+ });
2647
+ return await auditSnapshotChain(source, {
2648
+ store: () => createAuditLocalStore(documentId),
2649
+ fingerprint,
2650
+ retries,
2651
+ pageSize: AUDIT_PAGE_SIZE,
2652
+ });
2653
+ }
2221
2654
  //# sourceMappingURL=documents.js.map