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

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 (38) hide show
  1. package/assets/skill/skills/primitive-platform/SKILL.md +105 -675
  2. package/dist/src/commands/collections.js +61 -0
  3. package/dist/src/commands/collections.js.map +1 -1
  4. package/dist/src/commands/databases.js +10 -2
  5. package/dist/src/commands/databases.js.map +1 -1
  6. package/dist/src/commands/documents.d.ts +38 -0
  7. package/dist/src/commands/documents.js +235 -53
  8. package/dist/src/commands/documents.js.map +1 -1
  9. package/dist/src/commands/functions.js +1 -1
  10. package/dist/src/commands/functions.js.map +1 -1
  11. package/dist/src/lib/api-client.d.ts +28 -0
  12. package/dist/src/lib/api-client.js +40 -11
  13. package/dist/src/lib/api-client.js.map +1 -1
  14. package/dist/src/lib/db-codegen/dbTypeIR.js +8 -0
  15. package/dist/src/lib/db-codegen/dbTypeIR.js.map +1 -1
  16. package/dist/src/lib/document-ingest-rows.d.ts +137 -0
  17. package/dist/src/lib/document-ingest-rows.js +181 -0
  18. package/dist/src/lib/document-ingest-rows.js.map +1 -0
  19. package/dist/src/lib/document-ingest.d.ts +25 -2
  20. package/dist/src/lib/document-ingest.js +184 -31
  21. package/dist/src/lib/document-ingest.js.map +1 -1
  22. package/dist/src/lib/function-db-types.d.ts +1 -1
  23. package/dist/src/lib/function-db-types.js +1 -1
  24. package/dist/src/lib/function-document-types.js +23 -2
  25. package/dist/src/lib/function-document-types.js.map +1 -1
  26. package/dist/src/lib/generated-sdk-types.d.ts +1 -1
  27. package/dist/src/lib/generated-sdk-types.js +1 -1
  28. package/dist/src/lib/generated-sdk-types.js.map +1 -1
  29. package/dist/src/lib/resolve-owner.d.ts +19 -0
  30. package/dist/src/lib/resolve-owner.js +20 -0
  31. package/dist/src/lib/resolve-owner.js.map +1 -0
  32. package/dist/src/lib/skill-installer.d.ts +49 -3
  33. package/dist/src/lib/skill-installer.js +275 -100
  34. package/dist/src/lib/skill-installer.js.map +1 -1
  35. package/dist/src/lib/snapshot-build.d.ts +50 -0
  36. package/dist/src/lib/snapshot-build.js +111 -0
  37. package/dist/src/lib/snapshot-build.js.map +1 -0
  38. package/package.json +2 -2
@@ -1,8 +1,11 @@
1
1
  import { snapshotBuildRows } from "../lib/snapshot-build-rows.js";
2
+ import { driveSnapshotBuild } from "../lib/snapshot-build.js";
3
+ import { ingestClientRows, ingestSessionRows, } from "../lib/document-ingest-rows.js";
2
4
  import { ApiClient } from "../lib/api-client.js";
3
5
  import { resolveAppId } from "../lib/config.js";
4
6
  import { success, error, info, formatTable, formatId, formatDate, json, keyValue, result as printResult, warn, flushOutput, } from "../lib/output.js";
5
7
  import { confirmPrompt } from "../lib/confirm-prompt.js";
8
+ import { resolveOwnerUserId } from "../lib/resolve-owner.js";
6
9
  import { parseDataOption } from "../lib/data-input.js";
7
10
  import { parseFilterOptions } from "../lib/record-filter.js";
8
11
  import { buildPermissionsExport } from "../lib/document-export-permissions.js";
@@ -18,37 +21,16 @@ import { ulid } from "ulid";
18
21
  import * as fs from "fs";
19
22
  import * as path from "path";
20
23
  /**
21
- * One bulk-load session as rows (#3434, #3435).
24
+ * One bulk-load session as rows (#3434, #3435, #3598).
22
25
  *
23
26
  * 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.
27
+ * report, so an operator reads the same thing however they arrived at it. The
28
+ * rows themselves are decided in `document-ingest-rows`, where they can be
29
+ * checked without a live server holding a session in the right state.
25
30
  */
26
31
  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
- }
32
+ for (const [label, value] of ingestSessionRows(session)) {
33
+ printResult(label, value);
52
34
  }
53
35
  }
54
36
  /**
@@ -66,22 +48,85 @@ function printDocumentFormat(doc) {
66
48
  }
67
49
  }
68
50
  /**
69
- * Resolve a `--owner` value to a userId. A value containing "@" is looked up
70
- * by exact email through the member-accessible `users/lookup` endpoint
71
- * (issue #2763); anything else is already a user id and is passed through.
72
- * Failures throw with the server's own message — a permission problem must
73
- * not read as "no such user", which is how the old admin-only lookup
74
- * reported it.
51
+ * What `documents records bulk` reports when the write is refused (#3619).
52
+ *
53
+ * The command used to catch every failure with `error(err.message)` while
54
+ * `ApiError` carried the code and the status on separate fields, so the stable
55
+ * code the server answers never reached the output and `--json` handled
56
+ * successes only. An operator driving a multi-hour load had to read human text
57
+ * to tell "ask again" from "somebody look at the log" (finding 3619-SO-04).
58
+ *
59
+ * The status is read from `statusCode` FIRST: that is where
60
+ * `cli/src/lib/api-client.ts`'s `ApiError` keeps it, and a reader that asks for
61
+ * `status` alone sees `undefined` on every error the client raises and prints
62
+ * nothing at all — #3597 paid for that once already. `status` is the fallback,
63
+ * for an error that came from somewhere else.
64
+ *
65
+ * Its own function so both surfaces are composed once and can be graded
66
+ * without spawning a process: the human line and the `--json` envelope must
67
+ * agree about which failure happened.
75
68
  */
76
- async function resolveOwnerUserId(client, appId, owner) {
77
- if (!owner.includes("@"))
78
- return owner;
79
- const lookup = await client.lookupUserByEmail(appId, owner);
80
- if (!lookup?.exists || !lookup.user) {
81
- throw new Error(`No app user found with email "${owner}" in app ${appId}.`);
82
- }
83
- info(`Resolved owner email to userId: ${lookup.user.userId}`);
84
- return lookup.user.userId;
69
+ export function describeBulkFailure(err) {
70
+ const message = (typeof err?.message === "string" && err.message) || String(err);
71
+ const code = typeof err?.code === "string" && err.code ? err.code : undefined;
72
+ const raw = err?.statusCode ?? err?.status;
73
+ const status = Number.isFinite(Number(raw)) ? Number(raw) : undefined;
74
+ return {
75
+ line: `${message}${code ? ` [${code}]` : ""}${status !== undefined ? ` (${status})` : ""}`,
76
+ envelope: {
77
+ ok: false,
78
+ ...(code ? { code } : {}),
79
+ ...(status !== undefined ? { status } : {}),
80
+ error: message,
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * A document's tags as a list, from whatever the wire handed back (#3644).
86
+ *
87
+ * `tags` is absent when the document has none — the server's presence rule,
88
+ * shared by the app API's list shape and the admin inventory — so every
89
+ * reader here has to cope with `undefined`. A stray non-array (an older
90
+ * server, a hand-edited fixture) is read as "no tags" rather than printed
91
+ * raw.
92
+ */
93
+ function documentTagList(tags) {
94
+ return Array.isArray(tags)
95
+ ? tags.filter((tag) => typeof tag === "string" && tag !== "")
96
+ : [];
97
+ }
98
+ /**
99
+ * Tag text a terminal prints rather than obeys (#3644).
100
+ *
101
+ * A tag is app-user text. The write-side validator
102
+ * (`validateTag` in `src/app-api/controllers/documents-controller.ts`) trims
103
+ * it, caps it at 48 characters and refuses dynamo-bao's key separator, but
104
+ * every other byte survives — including the escape character. Printed raw
105
+ * into an operator's terminal, a 15-character tag such as
106
+ * `\x1b[2J\x1b[Hrestored` clears the screen and homes the cursor while
107
+ * the listing is being written, so a member with write access to one document
108
+ * could hide or falsify what `documents list` and `documents get` appear to
109
+ * report about the rest. The C0 range, DEL and the C1 range are therefore
110
+ * rendered as their `\uXXXX` source escapes: readable, one line and one cell
111
+ * per tag, and inert. Ordinary text — accents, CJK, emoji — is untouched.
112
+ */
113
+ function escapeForTerminal(tag) {
114
+ return tag.replace(/[\x00-\x1f\x7f-\x9f]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
115
+ }
116
+ /**
117
+ * A document's tags as printable text, in the order the wire reported them.
118
+ *
119
+ * Only the plain-text surfaces come through here: `--json` on both commands
120
+ * returns the server's object untouched, so a migration diffing tags against
121
+ * what it wrote still reads the stored bytes.
122
+ */
123
+ export function displayTags(tags) {
124
+ return documentTagList(tags).map(escapeForTerminal);
125
+ }
126
+ /** The tags cell of a documents table: comma-joined, or `-` when there are none. */
127
+ export function formatTags(tags) {
128
+ const list = displayTags(tags);
129
+ return list.length > 0 ? list.join(", ") : "-";
85
130
  }
86
131
  export function registerDocumentsCommands(program) {
87
132
  const documents = program
@@ -136,6 +181,10 @@ Examples:
136
181
  console.log(formatTable(list, [
137
182
  { header: "DOCUMENT_ID", key: "documentId", format: formatId },
138
183
  { header: "TITLE", key: "title", flex: true },
184
+ // #3644 — the tags the route now reports. An app migration that
185
+ // regenerates tags on import verifies them from here; a document
186
+ // with none shows `-` rather than an empty cell.
187
+ { header: "TAGS", key: "tags", format: formatTags },
139
188
  { header: "PERMISSION", key: "permission" },
140
189
  { header: "GRANTED", key: "grantedAt", format: formatDate },
141
190
  ]));
@@ -167,6 +216,15 @@ Examples:
167
216
  keyValue("Created", formatDate(doc.createdAt));
168
217
  keyValue("Modified", formatDate(doc.modifiedAt ?? doc.lastModified));
169
218
  printDocumentFormat(doc);
219
+ // #3644 — tags, when the document has any. The endpoint has returned
220
+ // them since #3096 but only `--json` showed them, so a migration
221
+ // verifying restored tags had to parse JSON to see its own work.
222
+ // Printed only when present, by the same rule the response uses: an
223
+ // untagged document's output is unchanged, and escaped the same way
224
+ // the table's cell is so a crafted tag cannot drive the terminal.
225
+ const tags = displayTags(doc.tags);
226
+ if (tags.length > 0)
227
+ keyValue("Tags", tags.join(", "));
170
228
  // `GET documents/:id` reports the caller's own access, not the
171
229
  // document's grant/alias/collection inventory — printing counts for
172
230
  // fields the endpoint never returns read as a definitive "0" for
@@ -868,7 +926,11 @@ Permissions (enforced by the server, not the CLI):
868
926
  `${result.added.length} added, ${result.updated.length} updated, ${result.deleted} deleted.`);
869
927
  }
870
928
  catch (err) {
871
- error(err.message);
929
+ const report = describeBulkFailure(err);
930
+ if (options.json)
931
+ json(report.envelope);
932
+ else
933
+ error(report.line);
872
934
  process.exit(1);
873
935
  }
874
936
  });
@@ -983,7 +1045,13 @@ Permissions (enforced by the server, not the CLI):
983
1045
  for (const ignored of input.ignored) {
984
1046
  info(`Ignoring ${ignored}: a bulk load reads the snapshot only.`);
985
1047
  }
1048
+ // #3598 — reading, checking, sorting, cutting and gzipping is this
1049
+ // side's biggest phase on a large input, and it used to be invisible
1050
+ // inside one wall-clock number that also covered the platform's work.
1051
+ const buildStartedAt = Date.now();
986
1052
  plan = await buildIngestArtifact({ input, schema });
1053
+ const buildMs = Date.now() - buildStartedAt;
1054
+ info(`Built artifact in ${(buildMs / 1000).toFixed(1)}s.`);
987
1055
  for (const line of summariseIngestPlan(plan))
988
1056
  info(line);
989
1057
  assertIngestConfirmable({
@@ -1028,14 +1096,27 @@ Permissions (enforced by the server, not the CLI):
1028
1096
  }
1029
1097
  if (outcome.message)
1030
1098
  error(outcome.message);
1099
+ // #3598 — the two sides, side by side. The session's own `timings`
1100
+ // say where the platform's share went; `client` says what this
1101
+ // process spent before and around it, so a slow load can be blamed on
1102
+ // the side that was actually slow.
1103
+ const clientTimings = { buildMs, ...outcome.timings };
1031
1104
  if (options.json) {
1032
- json(outcome.session ?? { sessionId: outcome.sessionId });
1033
- }
1034
- else if (outcome.session) {
1035
- printIngestSession(outcome.session);
1105
+ json({
1106
+ ...(outcome.session ?? { sessionId: outcome.sessionId }),
1107
+ client: clientTimings,
1108
+ });
1036
1109
  }
1037
- else if (outcome.sessionId) {
1038
- printResult("Session", outcome.sessionId);
1110
+ else {
1111
+ if (outcome.session) {
1112
+ printIngestSession(outcome.session);
1113
+ }
1114
+ else if (outcome.sessionId) {
1115
+ printResult("Session", outcome.sessionId);
1116
+ }
1117
+ for (const [label, value] of ingestClientRows(clientTimings)) {
1118
+ printResult(label, value);
1119
+ }
1039
1120
  }
1040
1121
  return outcome.exitCode;
1041
1122
  };
@@ -1216,6 +1297,96 @@ Permissions (enforced by the server, not the CLI):
1216
1297
  process.exit(1);
1217
1298
  }
1218
1299
  });
1300
+ // #3666, criterion 7 — the one ACTION verb in this group: snapshot the
1301
+ // document now instead of waiting for the size or age trigger. Its exit
1302
+ // codes are `documents ingest`'s, as `docs/cli-design.md` records them, and
1303
+ // for the same reason: giving up on WATCHING a build is not the build
1304
+ // failing.
1305
+ //
1306
+ // Not destructive, so it does not confirm: a seal rotates the overlay the
1307
+ // room is writing to and loses nothing, which is what the size trigger does
1308
+ // on its own several times a day.
1309
+ snapshots
1310
+ .command("build")
1311
+ .description("Snapshot a large document now: seal the open epoch and build a base")
1312
+ .argument("<document-id>", "Document ID")
1313
+ .option("--app <app-id>", "App ID")
1314
+ .option("--wait", "Poll until the build has verified or failed")
1315
+ .option("--timeout <seconds>", "Give up waiting after this many seconds")
1316
+ .option("--json", "Output as JSON")
1317
+ .action(async (documentId, options) => {
1318
+ const resolvedAppId = resolveAppId(undefined, options);
1319
+ const client = new ApiClient();
1320
+ const run = async () => {
1321
+ // Ctrl-C while polling leaves the build running — by then the room is
1322
+ // already building it and giving up on watching is not giving up on
1323
+ // the build — so the signal is handed to the driver rather than
1324
+ // killing the process.
1325
+ const interrupt = new AbortController();
1326
+ const onSignal = () => interrupt.abort();
1327
+ process.on("SIGINT", onSignal);
1328
+ let outcome;
1329
+ try {
1330
+ outcome = await driveSnapshotBuild({
1331
+ client: {
1332
+ requestSnapshot: (id) => client.requestDocumentSnapshot(resolvedAppId, id),
1333
+ getSnapshot: (id, buildId) => client.getDocumentSnapshot(resolvedAppId, id, buildId),
1334
+ },
1335
+ documentId,
1336
+ wait: Boolean(options.wait),
1337
+ timeoutMs: options.timeout ? Number(options.timeout) * 1000 : null,
1338
+ signal: interrupt.signal,
1339
+ onProgress: (line) => info(line),
1340
+ });
1341
+ }
1342
+ finally {
1343
+ process.off("SIGINT", onSignal);
1344
+ }
1345
+ if (outcome.message)
1346
+ error(outcome.message);
1347
+ if (options.json) {
1348
+ // The route's answer as received, with the build the wait settled
1349
+ // on beside it when there was one — so a script branches on the
1350
+ // verdict rather than on an exit code alone.
1351
+ json(outcome.build ? { ...outcome.response, build: outcome.build } : outcome.response);
1352
+ return outcome.exitCode;
1353
+ }
1354
+ if (outcome.response?.sealed === false) {
1355
+ printResult("Sealed", "no — the open epoch carried nothing");
1356
+ printResult("Reason", outcome.response.reason);
1357
+ printResult("Covering build", outcome.response.coveringBuildId ?? "—");
1358
+ return outcome.exitCode;
1359
+ }
1360
+ printResult("Sealed epoch", outcome.response.sealedEpoch);
1361
+ printResult("Open epoch", outcome.response.nextEpoch);
1362
+ printResult("Build", outcome.response.buildId);
1363
+ if (outcome.build) {
1364
+ for (const [label, value] of snapshotBuildRows(outcome.build)) {
1365
+ printResult(label, value);
1366
+ }
1367
+ }
1368
+ return outcome.exitCode;
1369
+ };
1370
+ let code = 1;
1371
+ try {
1372
+ code = await run();
1373
+ }
1374
+ catch (err) {
1375
+ // The server's own sentence, plus the code a script branches on
1376
+ // (#3403) — a refused loop that only says "too young to seal" leaves
1377
+ // a caller parsing prose to tell it from a permission refusal.
1378
+ error(err.code ? `${err.message} (${err.code})` : err.message);
1379
+ const retryAfterMs = err.details?.retryAfterMs;
1380
+ if (typeof retryAfterMs === "number") {
1381
+ error(`Ask again in ${Math.ceil(retryAfterMs / 1000)}s.`);
1382
+ }
1383
+ code = 1;
1384
+ }
1385
+ // The result has to reach the pipe before the process ends: stdout to a
1386
+ // pipe is asynchronous and `process.exit` does not wait for it.
1387
+ await flushOutput();
1388
+ process.exit(code);
1389
+ });
1219
1390
  // #3433, criterion 10 — the operator audit: `snapshot ⊕ overlays == table`,
1220
1391
  // recomputed on this side with the CLIENT's loader and fold and compared
1221
1392
  // with the authoritative table page by page. The one check that can catch a
@@ -1288,9 +1459,16 @@ Permissions (enforced by the server, not the CLI):
1288
1459
  documents
1289
1460
  .command("transfer-owner")
1290
1461
  .description("Transfer document ownership to another user")
1462
+ // Every positional is declared optional and the action decides which is
1463
+ // which (#3645). A required positional AFTER an optional one makes the
1464
+ // optional one mandatory: commander binds the first value it sees to
1465
+ // `[app-id]` and then reports the required argument missing, so
1466
+ // `transfer-owner <document-id> <new-owner-id>` never reached the action.
1467
+ // What each slot actually requires is enforced below, where the shift is
1468
+ // already known.
1291
1469
  .argument("[app-id]", "App ID (uses current app if not specified)")
1292
- .argument("<document-id>", "Document ID")
1293
- .argument("<new-owner-id>", "User ID of the new owner")
1470
+ .argument("[document-id]", "Document ID (required)")
1471
+ .argument("[new-owner-id]", "User ID of the new owner (required)")
1294
1472
  .option("--app <app-id>", "App ID")
1295
1473
  .option("-y, --yes", "Skip confirmation prompt")
1296
1474
  .option("--json", "Output as JSON")
@@ -1492,8 +1670,11 @@ Permissions (enforced by the server, not the CLI):
1492
1670
  documents
1493
1671
  .command("export")
1494
1672
  .description("Export a document (Yjs state, blobs, permissions, aliases)")
1673
+ // Optional in commander, required in the action (#3645) — see
1674
+ // `transfer-owner` above for why a `<...>` here would make `[app-id]`
1675
+ // mandatory.
1495
1676
  .argument("[app-id]", "App ID (uses current app if not specified)")
1496
- .argument("<document-id>", "Document ID to export")
1677
+ .argument("[document-id]", "Document ID to export (required)")
1497
1678
  .option("--app <app-id>", "App ID")
1498
1679
  .option("--output <dir>", "Output directory", "./primitive-export")
1499
1680
  .option("--no-blobs", "Skip blob data")
@@ -1604,8 +1785,9 @@ Permissions (enforced by the server, not the CLI):
1604
1785
  documents
1605
1786
  .command("import")
1606
1787
  .description("Import documents from an export directory")
1788
+ // Optional in commander, required in the action (#3645).
1607
1789
  .argument("[app-id]", "App ID (uses current app if not specified)")
1608
- .argument("<path>", "Path to export directory or single document directory")
1790
+ .argument("[path]", "Path to export directory or single document directory (required)")
1609
1791
  .option("--app <app-id>", "App ID")
1610
1792
  .option("--aliases <mode>", "Alias conflict handling: overwrite or skip", "skip")
1611
1793
  // `--overwrite` has never replaced a document: the import path issues no