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.
- package/assets/skill/skills/primitive-platform/SKILL.md +12 -0
- package/dist/src/commands/documents.js +455 -22
- package/dist/src/commands/documents.js.map +1 -1
- package/dist/src/commands/functions.js +889 -76
- 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/prompts.js +19 -2
- package/dist/src/commands/prompts.js.map +1 -1
- package/dist/src/commands/sync.d.ts +50 -0
- package/dist/src/commands/sync.js +587 -316
- package/dist/src/commands/sync.js.map +1 -1
- package/dist/src/commands/workflows.d.ts +1 -29
- package/dist/src/commands/workflows.js +8 -109
- package/dist/src/commands/workflows.js.map +1 -1
- package/dist/src/lib/api-client.d.ts +191 -2
- package/dist/src/lib/api-client.js +283 -7
- package/dist/src/lib/api-client.js.map +1 -1
- package/dist/src/lib/config-object-descriptor.js +12 -4
- 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/env-resolver-core.d.ts +2 -2
- package/dist/src/lib/env-resolver-core.js +2 -2
- 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 +46 -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 +137 -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-log-tail.d.ts +51 -2
- package/dist/src/lib/function-log-tail.js +160 -13
- package/dist/src/lib/function-log-tail.js.map +1 -1
- package/dist/src/lib/function-run.d.ts +289 -0
- package/dist/src/lib/function-run.js +389 -0
- package/dist/src/lib/function-run.js.map +1 -0
- package/dist/src/lib/function-schema-codegen.d.ts +17 -9
- package/dist/src/lib/function-schema-codegen.js +30 -29
- package/dist/src/lib/function-schema-codegen.js.map +1 -1
- package/dist/src/lib/function-sync.d.ts +226 -30
- package/dist/src/lib/function-sync.js +731 -77
- 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 +561 -91
- package/dist/src/lib/generated-config-surfaces.js +1858 -287
- 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/prompt-schema-codegen.d.ts +94 -0
- package/dist/src/lib/prompt-schema-codegen.js +212 -0
- package/dist/src/lib/prompt-schema-codegen.js.map +1 -0
- 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/snapshot-manifest-layout.d.ts +61 -0
- package/dist/src/lib/snapshot-manifest-layout.js +70 -0
- package/dist/src/lib/snapshot-manifest-layout.js.map +1 -0
- package/dist/src/lib/step-run-table.d.ts +43 -0
- package/dist/src/lib/step-run-table.js +129 -0
- package/dist/src/lib/step-run-table.js.map +1 -0
- package/dist/src/lib/swift-codegen/functionGenerator.d.ts +26 -13
- package/dist/src/lib/swift-codegen/functionGenerator.js +38 -27
- 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/dist/src/lib/workflow-toml-validator.d.ts +9 -5
- package/dist/src/lib/workflow-toml-validator.js +18 -10
- package/dist/src/lib/workflow-toml-validator.js.map +1 -1
- 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
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
/**
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
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 =
|
|
1687
|
-
require(path.join(docDir, "snapshot",
|
|
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
|
-
|
|
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
|
|
2134
|
+
model,
|
|
1731
2135
|
index,
|
|
1732
|
-
}, fs.readFileSync(path.join(docDir, "snapshot",
|
|
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
|