primitive-admin 1.1.0-alpha.82 → 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.
- package/dist/src/commands/documents.js +333 -42
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/functions.js +302 -120
- package/dist/src/commands/functions.js.map +1 -1
- package/dist/src/commands/locks.js +15 -1
- package/dist/src/commands/locks.js.map +1 -1
- package/dist/src/commands/sync.d.ts +50 -0
- package/dist/src/commands/sync.js +560 -312
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +75 -2
- package/dist/src/lib/api-client.js +122 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +5 -5
- package/dist/src/lib/config-object-descriptor.js.map +1 -1
- package/dist/src/lib/document-ingest-artifact.d.ts +120 -0
- package/dist/src/lib/document-ingest-artifact.js +505 -0
- package/dist/src/lib/document-ingest-artifact.js.map +1 -0
- package/dist/src/lib/document-ingest-input.d.ts +51 -0
- package/dist/src/lib/document-ingest-input.js +132 -0
- package/dist/src/lib/document-ingest-input.js.map +1 -0
- package/dist/src/lib/document-ingest.d.ts +78 -0
- package/dist/src/lib/document-ingest.js +231 -0
- package/dist/src/lib/document-ingest.js.map +1 -0
- package/dist/src/lib/function-bundle.d.ts +6 -0
- package/dist/src/lib/function-bundle.js +8 -0
- package/dist/src/lib/function-bundle.js.map +1 -1
- package/dist/src/lib/function-collect.d.ts +1 -1
- package/dist/src/lib/function-collect.js +16 -0
- package/dist/src/lib/function-collect.js.map +1 -1
- package/dist/src/lib/function-db-types.d.ts +4 -0
- package/dist/src/lib/function-db-types.js +31 -2
- package/dist/src/lib/function-db-types.js.map +1 -1
- package/dist/src/lib/function-document-types.d.ts +83 -0
- package/dist/src/lib/function-document-types.js +133 -0
- package/dist/src/lib/function-document-types.js.map +1 -1
- package/dist/src/lib/function-grants-preflight.js +5 -0
- package/dist/src/lib/function-grants-preflight.js.map +1 -1
- package/dist/src/lib/function-log-lines.d.ts +76 -0
- package/dist/src/lib/function-log-lines.js +160 -0
- package/dist/src/lib/function-log-lines.js.map +1 -0
- package/dist/src/lib/function-log-row.d.ts +29 -0
- package/dist/src/lib/function-log-row.js +73 -0
- package/dist/src/lib/function-log-row.js.map +1 -0
- package/dist/src/lib/function-run.d.ts +53 -35
- package/dist/src/lib/function-run.js +55 -44
- package/dist/src/lib/function-run.js.map +1 -1
- package/dist/src/lib/function-schema-codegen.d.ts +6 -9
- package/dist/src/lib/function-schema-codegen.js +22 -35
- package/dist/src/lib/function-schema-codegen.js.map +1 -1
- package/dist/src/lib/function-sync.d.ts +226 -35
- package/dist/src/lib/function-sync.js +731 -99
- package/dist/src/lib/function-sync.js.map +1 -1
- package/dist/src/lib/function-versions.d.ts +122 -0
- package/dist/src/lib/function-versions.js +182 -0
- package/dist/src/lib/function-versions.js.map +1 -0
- package/dist/src/lib/generated-config-surfaces.d.ts +390 -146
- package/dist/src/lib/generated-config-surfaces.js +1207 -401
- package/dist/src/lib/generated-config-surfaces.js.map +1 -1
- package/dist/src/lib/generated-sdk-types.d.ts +1 -1
- package/dist/src/lib/generated-sdk-types.js +1 -1
- package/dist/src/lib/generated-sdk-types.js.map +1 -1
- package/dist/src/lib/log-inspection.d.ts +2 -0
- package/dist/src/lib/log-inspection.js +4 -0
- package/dist/src/lib/log-inspection.js.map +1 -1
- package/dist/src/lib/output.d.ts +15 -0
- package/dist/src/lib/output.js +28 -0
- package/dist/src/lib/output.js.map +1 -1
- package/dist/src/lib/snapshot-audit-source.d.ts +45 -0
- package/dist/src/lib/snapshot-audit-source.js +58 -0
- package/dist/src/lib/snapshot-audit-source.js.map +1 -0
- package/dist/src/lib/snapshot-audit-store.d.ts +52 -0
- package/dist/src/lib/snapshot-audit-store.js +196 -0
- package/dist/src/lib/snapshot-audit-store.js.map +1 -0
- package/dist/src/lib/snapshot-audit.d.ts +207 -0
- package/dist/src/lib/snapshot-audit.js +431 -0
- package/dist/src/lib/snapshot-audit.js.map +1 -0
- package/dist/src/lib/snapshot-build-rows.d.ts +60 -0
- package/dist/src/lib/snapshot-build-rows.js +87 -0
- package/dist/src/lib/snapshot-build-rows.js.map +1 -0
- package/dist/src/lib/swift-codegen/functionGenerator.d.ts +23 -16
- package/dist/src/lib/swift-codegen/functionGenerator.js +34 -33
- package/dist/src/lib/swift-codegen/functionGenerator.js.map +1 -1
- package/dist/src/lib/sync-selectors.d.ts +45 -2
- package/dist/src/lib/sync-selectors.js +66 -5
- package/dist/src/lib/sync-selectors.js.map +1 -1
- package/package.json +2 -2
|
@@ -1,15 +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";
|
|
8
9
|
import { chunkAddressOf, renumberManifestForExport, } from "../lib/snapshot-manifest-layout.js";
|
|
9
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";
|
|
10
17
|
import { ulid } from "ulid";
|
|
11
18
|
import * as fs from "fs";
|
|
12
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
|
+
}
|
|
13
54
|
/**
|
|
14
55
|
* Report a document's format (#2816) — but only when it is a large document.
|
|
15
56
|
*
|
|
@@ -900,6 +941,195 @@ Permissions (enforced by the server, not the CLI):
|
|
|
900
941
|
process.exit(1);
|
|
901
942
|
}
|
|
902
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
|
+
});
|
|
903
1133
|
// Snapshot builds of a large document (#3432, criterion 9): a data noun
|
|
904
1134
|
// with `list` and `get` readers, per docs/cli-design.md, over the app API's
|
|
905
1135
|
// read-only inspection routes.
|
|
@@ -974,47 +1204,11 @@ Permissions (enforced by the server, not the CLI):
|
|
|
974
1204
|
json(build);
|
|
975
1205
|
return;
|
|
976
1206
|
}
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
printResult("Started", formatDate(build.startedAt));
|
|
983
|
-
printResult("Completed", build.completedAt ? formatDate(build.completedAt) : "—");
|
|
984
|
-
printResult("Rows", build.rows);
|
|
985
|
-
printResult("Bytes", build.bytes);
|
|
986
|
-
printResult("Chunks", build.chunks);
|
|
987
|
-
const verification = build.verification;
|
|
988
|
-
printResult("Verification", verification ? verification.state : "not started");
|
|
989
|
-
if (verification) {
|
|
990
|
-
printResult("Checked at", formatDate(verification.checkedAt));
|
|
991
|
-
printResult("Chunks checked", verification.chunksChecked);
|
|
992
|
-
if (verification.code)
|
|
993
|
-
printResult("Code", verification.code);
|
|
994
|
-
if (verification.reason)
|
|
995
|
-
printResult("Reason", verification.reason);
|
|
996
|
-
}
|
|
997
|
-
if (build.failure) {
|
|
998
|
-
printResult("Failed at", formatDate(build.failure.at));
|
|
999
|
-
if (build.failure.chunk) {
|
|
1000
|
-
printResult("Failed on", `${build.failure.chunk.model} chunk ${build.failure.chunk.ordinal}`);
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
const timings = build.timings ?? { ticks: 0, totals: {} };
|
|
1004
|
-
printResult("Ticks", timings.ticks);
|
|
1005
|
-
const stages = [
|
|
1006
|
-
["Read (ms)", "readMs"],
|
|
1007
|
-
["Encode (ms)", "encodeMs"],
|
|
1008
|
-
["Compress (ms)", "compressMs"],
|
|
1009
|
-
["Hash (ms)", "hashMs"],
|
|
1010
|
-
["Upload (ms)", "uploadMs"],
|
|
1011
|
-
["Manifest (ms)", "manifestMs"],
|
|
1012
|
-
["Verify read (ms)", "verifyReadMs"],
|
|
1013
|
-
["Verify digest (ms)", "verifyDigestMs"],
|
|
1014
|
-
["Verify coverage (ms)", "verifyCoverageMs"],
|
|
1015
|
-
];
|
|
1016
|
-
for (const [label, key] of stages) {
|
|
1017
|
-
printResult(label, timings.totals?.[key] ?? 0);
|
|
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);
|
|
1018
1212
|
}
|
|
1019
1213
|
}
|
|
1020
1214
|
catch (err) {
|
|
@@ -1022,6 +1216,74 @@ Permissions (enforced by the server, not the CLI):
|
|
|
1022
1216
|
process.exit(1);
|
|
1023
1217
|
}
|
|
1024
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
|
+
});
|
|
1025
1287
|
// Transfer document ownership
|
|
1026
1288
|
documents
|
|
1027
1289
|
.command("transfer-owner")
|
|
@@ -2360,4 +2622,33 @@ async function importRootDocument(client, appId, docDir, metadata, plan, overwri
|
|
|
2360
2622
|
`root document ${targetRootDocId}`);
|
|
2361
2623
|
}
|
|
2362
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
|
+
}
|
|
2363
2654
|
//# sourceMappingURL=documents.js.map
|