@aui.io/apollo 0.1.49 → 0.1.52

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 (42) hide show
  1. package/node_modules/@aui.io/apollo-cli/dist/src/commands/bundles.js +8 -0
  2. package/node_modules/@aui.io/apollo-cli/dist/src/commands/bundles.js.map +1 -1
  3. package/node_modules/@aui.io/apollo-cli/dist/src/commands/mockdb.js +132 -21
  4. package/node_modules/@aui.io/apollo-cli/dist/src/commands/mockdb.js.map +1 -1
  5. package/node_modules/@aui.io/apollo-cli/package.json +3 -3
  6. package/node_modules/@aui.io/apollo-core/dist/src/api/kbm.d.ts +14 -9
  7. package/node_modules/@aui.io/apollo-core/dist/src/api/kbm.js +10 -8
  8. package/node_modules/@aui.io/apollo-core/dist/src/api/kbm.js.map +1 -1
  9. package/node_modules/@aui.io/apollo-core/dist/src/api/mock-db.d.ts +104 -3
  10. package/node_modules/@aui.io/apollo-core/dist/src/api/mock-db.js +137 -6
  11. package/node_modules/@aui.io/apollo-core/dist/src/api/mock-db.js.map +1 -1
  12. package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.d.ts +10 -1
  13. package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js +9 -2
  14. package/node_modules/@aui.io/apollo-core/dist/src/api/runtime-api.js.map +1 -1
  15. package/node_modules/@aui.io/apollo-core/dist/src/api/transport.d.ts +3 -0
  16. package/node_modules/@aui.io/apollo-core/dist/src/api/transport.js +6 -2
  17. package/node_modules/@aui.io/apollo-core/dist/src/api/transport.js.map +1 -1
  18. package/node_modules/@aui.io/apollo-core/dist/src/index.d.ts +2 -2
  19. package/node_modules/@aui.io/apollo-core/dist/src/index.js +2 -2
  20. package/node_modules/@aui.io/apollo-core/dist/src/index.js.map +1 -1
  21. package/node_modules/@aui.io/apollo-core/dist/src/services/bundles.d.ts +16 -1
  22. package/node_modules/@aui.io/apollo-core/dist/src/services/bundles.js +38 -4
  23. package/node_modules/@aui.io/apollo-core/dist/src/services/bundles.js.map +1 -1
  24. package/node_modules/@aui.io/apollo-core/dist/src/services/knowledge.js +7 -2
  25. package/node_modules/@aui.io/apollo-core/dist/src/services/knowledge.js.map +1 -1
  26. package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js +3 -1
  27. package/node_modules/@aui.io/apollo-core/dist/src/services/messaging.js.map +1 -1
  28. package/node_modules/@aui.io/apollo-core/dist/src/services/mockdb.d.ts +86 -4
  29. package/node_modules/@aui.io/apollo-core/dist/src/services/mockdb.js +752 -47
  30. package/node_modules/@aui.io/apollo-core/dist/src/services/mockdb.js.map +1 -1
  31. package/node_modules/@aui.io/apollo-core/package.json +1 -1
  32. package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js +8 -0
  33. package/node_modules/@aui.io/apollo-tui/dist/src/actions/registry.js.map +1 -1
  34. package/node_modules/@aui.io/apollo-tui/dist/src/author/resources.d.ts +5 -0
  35. package/node_modules/@aui.io/apollo-tui/dist/src/author/resources.js +29 -3
  36. package/node_modules/@aui.io/apollo-tui/dist/src/author/resources.js.map +1 -1
  37. package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js +58 -9
  38. package/node_modules/@aui.io/apollo-tui/dist/src/lib/actions.js.map +1 -1
  39. package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js +28 -0
  40. package/node_modules/@aui.io/apollo-tui/dist/src/lib/commands.js.map +1 -1
  41. package/node_modules/@aui.io/apollo-tui/package.json +2 -2
  42. package/package.json +4 -4
@@ -1,9 +1,38 @@
1
+ /**
2
+ * Mock DB service layer.
3
+ *
4
+ * Drives the `mock-db-service` management + runtime planes on behalf of the
5
+ * `apollo mockdb` commands and the TUI. Pure logic — no terminal, no
6
+ * process.exit.
7
+ *
8
+ * ─── Identity & auth ────────────────────────────────────────────────────
9
+ *
10
+ * The agent comes from the checkout (`.apollorc`) or `--agent`; the Apollo
11
+ * token from the active profile. The token is sent to the mock-db service
12
+ * FIRST-PARTY and only where the service demands it (provision / status /
13
+ * delete / minting a management key). Every other management call prefers
14
+ * the per-agent `managementKey` (`X-Mgmt-Key`) — no Apollo token transmitted
15
+ * — and runtime calls use the per-agent `runtimeKey`. Keys are persisted
16
+ * under `~/.apollo/mockdb/<agentId>.json` (outside the checkout).
17
+ *
18
+ * ─── Resilience ─────────────────────────────────────────────────────────
19
+ *
20
+ * The shared backend occasionally answers a 2xx with a BLANK body under
21
+ * load. Reads retry that with bounded backoff and then fail loudly (never a
22
+ * silent `{}` that reads as an empty DB); writes surface it as a verify-first
23
+ * error (a repeat could duplicate rows); key rotation retries until a key
24
+ * actually comes back. Runtime calls also ride out read-after-write replica
25
+ * lag (422 no_results / transient 5xx) where a retry can never duplicate a
26
+ * write.
27
+ */
1
28
  import { mkdir, readFile, writeFile } from "node:fs/promises";
2
29
  import { homedir } from "node:os";
3
30
  import path from "node:path";
4
- import { MockDbClient, } from "../api/mock-db.js";
31
+ import { isMockDbTransportError, MockDbClient, mockDbErrorCode, } from "../api/mock-db.js";
5
32
  import { clearMockDbKeys, keyPrefixes, loadMockDbKeys, saveMockDbKeys, } from "../config/mockdb-keys.js";
6
- import { AuthError, ConfigError, ValidationError } from "../errors/index.js";
33
+ import { ApiError, AuthError, CliError, ConfigError, ValidationError, } from "../errors/index.js";
34
+ /** Env var the wired `connections.yaml` reads the runtime key from by default. */
35
+ export const DEFAULT_MOCKDB_TOKEN_ENV = "MOCKDB_TOKEN";
7
36
  function accountIdFromToken(token) {
8
37
  try {
9
38
  const payload = token.split(".")[1];
@@ -64,111 +93,584 @@ export function resolveMockDb(context, options = {}) {
64
93
  client,
65
94
  agentId,
66
95
  ...(context.agentRoot ? { agentRoot: context.agentRoot } : {}),
96
+ accountId,
67
97
  tokenAuth,
68
98
  homeDir,
99
+ tokenEnv: options.tokenEnv?.trim() || DEFAULT_MOCKDB_TOKEN_ENV,
69
100
  };
70
101
  }
71
- async function persistProvisionKeys(agentId, baseUrl, resp, homeDir) {
102
+ // ─── Blank-body guards (no silent empty-success) ────────────────────────
103
+ /** A read result is "empty" only when the HTTP body was blank → `request()`
104
+ * returned a zero-key `{}`. A real read (even of an empty DB) returns a keyed
105
+ * object like `{ collections: [] }`, so this never flags a legitimately-empty
106
+ * DB — only a genuinely blank body. */
107
+ function isEmptyReadObject(resp) {
108
+ return (resp != null &&
109
+ typeof resp === "object" &&
110
+ !Array.isArray(resp) &&
111
+ Object.keys(resp).length === 0);
112
+ }
113
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
114
+ /** True inside the unit-test harness (skip real backoff sleeps). */
115
+ function inTestEnv() {
116
+ return !!process.env.VITEST || process.env.NODE_ENV === "test";
117
+ }
118
+ /** Bounded exponential backoff (+ jitter) between blank-body retries. */
119
+ const BLANK_RETRY_ATTEMPTS = 5;
120
+ function backoffMs(attempt) {
121
+ const base = Math.min(250 * 2 ** (attempt - 1), 2000);
122
+ return base + Math.floor(Math.random() * 100);
123
+ }
124
+ /**
125
+ * Re-run `fn` while `retry(result)` is true, up to `BLANK_RETRY_ATTEMPTS`,
126
+ * with bounded backoff. Only ever wrap calls that are SAFE to repeat
127
+ * (idempotent reads, or key rotation which mints fresh each time); never
128
+ * naive writes (insert/seed) where a repeat could duplicate.
129
+ */
130
+ async function retryWhile(retry, fn) {
131
+ const inTest = inTestEnv();
132
+ let result = await fn();
133
+ for (let i = 1; i < BLANK_RETRY_ATTEMPTS && retry(result); i++) {
134
+ if (!inTest)
135
+ await sleep(backoffMs(i));
136
+ result = await fn();
137
+ }
138
+ return result;
139
+ }
140
+ /**
141
+ * Throw a precise, non-destructive error for a persistently-blank 2xx
142
+ * response. Worded per operation kind so the caller takes the SAFE next step
143
+ * (never "just retry" a write that may have landed; never "delete the DB" on
144
+ * a transient blip).
145
+ */
146
+ function blankBodyError(resolved, op, kind) {
147
+ const head = `mockdb ${op}: the mock-db service (${resolved.context.baseUrls.mockDb}) returned HTTP 2xx but a blank body for agent ${resolved.agentId}` +
148
+ (kind === "write"
149
+ ? ". The write may have already landed — NOT auto-retried, to avoid duplicate rows."
150
+ : ", even after retries.");
151
+ const common = "This is a TRANSIENT transport blank on the shared backend under load (an empty 2xx body), not data loss. ";
152
+ let tail;
153
+ if (kind === "read") {
154
+ tail =
155
+ "Your data is most likely intact — wait a moment and retry. Do NOT delete or re-provision the mock DB based on this alone.";
156
+ }
157
+ else if (kind === "mint") {
158
+ tail =
159
+ "No key was stored, so nothing changed and it is safe to re-run. If it persists, the backend key-mint path is failing — check `apollo mockdb status` and report it.";
160
+ }
161
+ else {
162
+ tail =
163
+ "The write MAY have already landed server-side, so do NOT blindly retry (re-running an insert/seed can DUPLICATE rows). Verify first — `apollo mockdb describe` / `collections list` for base writes, `apollo mockdb session get --key <session>` for runtime writes — then retry only what's actually missing.";
164
+ }
165
+ throw new CliError(head, { code: "API_SERVER_ERROR", suggestion: common + tail });
166
+ }
167
+ /** Run an idempotent read, retrying a blank body, then failing loudly if it stays blank. */
168
+ async function readWithRetry(resolved, op, fn) {
169
+ const result = await retryWhile(isEmptyReadObject, fn);
170
+ if (isEmptyReadObject(result))
171
+ blankBodyError(resolved, op, "read");
172
+ return result;
173
+ }
174
+ function isAuthRejection(error) {
175
+ return error instanceof ApiError && (error.status === 401 || error.status === 403);
176
+ }
177
+ // ─── Key persistence ────────────────────────────────────────────────────
178
+ async function persistReturnedKeys(resolved, resp) {
72
179
  const patch = {
73
- mockDbUrl: baseUrl,
180
+ mockDbUrl: resolved.context.baseUrls.mockDb,
74
181
  backend: "aui-managed",
75
182
  };
76
- if (typeof resp.managementKey === "string")
183
+ if (typeof resp.managementKey === "string" && resp.managementKey) {
77
184
  patch.managementKey = resp.managementKey;
78
- if (typeof resp.runtimeKey === "string")
185
+ }
186
+ if (typeof resp.runtimeKey === "string" && resp.runtimeKey) {
79
187
  patch.runtimeKey = resp.runtimeKey;
188
+ }
80
189
  if (patch.managementKey || patch.runtimeKey) {
81
- await saveMockDbKeys(agentId, patch, homeDir);
190
+ await saveMockDbKeys(resolved.agentId, patch, resolved.homeDir);
191
+ }
192
+ }
193
+ function responseHasKey(resp, field) {
194
+ return typeof resp[field] === "string" && resp[field].length > 0;
195
+ }
196
+ /**
197
+ * Mint-and-retry for key rotation. Under load the service can drop the body
198
+ * AFTER minting; returning `{}` then persists nothing — a false success that
199
+ * strands the caller with no usable key. Rotation is safe to repeat (each
200
+ * call mints fresh; the last one returned wins), so retry until the expected
201
+ * key comes back and persist it, else fail LOUDLY.
202
+ */
203
+ async function rotateWithRetry(resolved, field, op, fn) {
204
+ const resp = await retryWhile((r) => !responseHasKey(r, field), fn);
205
+ if (!responseHasKey(resp, field))
206
+ blankBodyError(resolved, op, "mint");
207
+ await persistReturnedKeys(resolved, resp);
208
+ return resp;
209
+ }
210
+ // ─── Management auth: mgmt key first, token as the safety net ───────────
211
+ /**
212
+ * Mint + cache a fresh management key via the token-auth `rotate-mgmt-key`
213
+ * path. Returns the minted key, or `undefined` when minting isn't possible
214
+ * (agent not provisioned, no account, backend mint path failing) so callers
215
+ * fall back to token auth. Never throws.
216
+ */
217
+ async function tryMintManagementKey(resolved) {
218
+ try {
219
+ const resp = await rotateWithRetry(resolved, "managementKey", "rotate mgmt", () => resolved.client.rotateMgmtKey(resolved.agentId, resolved.tokenAuth));
220
+ return typeof resp.managementKey === "string" ? resp.managementKey : undefined;
221
+ }
222
+ catch {
223
+ return undefined;
224
+ }
225
+ }
226
+ /**
227
+ * Run a management call. The management key is sourced from the cache first,
228
+ * and a fresh one is MINTED (+cached) when the cache has none or the stored
229
+ * one is rejected (401/403). The Apollo token is the safety net under every
230
+ * branch, so a missing/dead management key never fails a command.
231
+ */
232
+ async function withManage(resolved, fn) {
233
+ let managementKey = (await loadMockDbKeys(resolved.agentId, resolved.homeDir))
234
+ .managementKey;
235
+ if (!managementKey) {
236
+ managementKey = await tryMintManagementKey(resolved);
237
+ if (!managementKey)
238
+ return fn(resolved.tokenAuth);
239
+ }
240
+ try {
241
+ return await fn({ mode: "mgmt", managementKey });
242
+ }
243
+ catch (error) {
244
+ if (!isAuthRejection(error))
245
+ throw error;
246
+ const fresh = await tryMintManagementKey(resolved);
247
+ if (fresh) {
248
+ try {
249
+ return await fn({ mode: "mgmt", managementKey: fresh });
250
+ }
251
+ catch (retryError) {
252
+ if (isAuthRejection(retryError))
253
+ return fn(resolved.tokenAuth);
254
+ throw retryError;
255
+ }
256
+ }
257
+ return fn(resolved.tokenAuth);
82
258
  }
83
259
  }
84
260
  export async function provisionMockDb(context, options = {}) {
85
261
  const resolved = resolveMockDb(context, options);
86
- const resp = await resolved.client.provision(resolved.agentId, resolved.tokenAuth);
87
- await persistProvisionKeys(resolved.agentId, context.baseUrls.mockDb, resp, resolved.homeDir);
88
- const stored = keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir));
89
- return { ...resp, stored };
262
+ // Retry ONLY a pure transport blank. A real `created:false` (the DB already
263
+ // exists keys not re-shown) is a legitimate, keyed response and must NOT
264
+ // be retried. Provision is idempotent, so retrying a blank is safe.
265
+ const resp = await retryWhile(isEmptyReadObject, () => resolved.client.provision(resolved.agentId, resolved.tokenAuth));
266
+ if (isEmptyReadObject(resp)) {
267
+ throw new CliError(`mockdb provision: the mock-db service (${context.baseUrls.mockDb}) returned HTTP 2xx but a blank body for agent ${resolved.agentId}, even after retries.`, {
268
+ code: "API_SERVER_ERROR",
269
+ suggestion: "Transient backend overload (an empty 2xx body). The DB may or may not have been created — wait a moment, then run `apollo mockdb status`; if it exists, mint keys with `apollo mockdb rotate runtime` / `rotate mgmt`. Do NOT blindly re-provision.",
270
+ });
271
+ }
272
+ await persistReturnedKeys(resolved, resp);
273
+ const keys = await loadMockDbKeys(resolved.agentId, resolved.homeDir);
274
+ const stored = keyPrefixes(keys);
275
+ // Loud-fail the silent "provisioned but no usable keys" dead-end: when the
276
+ // DB already existed the service returns created:false with NO keys.
277
+ // Without a runtime key, `execute` (and the deployed agent) can't call it.
278
+ if (!keys.runtimeKey) {
279
+ return {
280
+ ...resp,
281
+ stored,
282
+ keysAvailable: false,
283
+ keysHint: "No runtime key is stored for this agent (provision returned none — the DB already existed). Run `apollo mockdb rotate runtime` to mint one, then `apollo mockdb wire` and update the connection's token env. `execute` self-heals by minting on demand.",
284
+ };
285
+ }
286
+ return { ...resp, stored, keysAvailable: true };
90
287
  }
91
288
  export async function describeMockDb(context, options = {}) {
92
289
  const resolved = resolveMockDb(context, options);
93
- return resolved.client.describe(resolved.agentId, resolved.tokenAuth);
290
+ return readWithRetry(resolved, "describe", () => withManage(resolved, (auth) => resolved.client.describe(resolved.agentId, auth)));
94
291
  }
95
292
  export async function statusMockDb(context, options = {}) {
96
293
  const resolved = resolveMockDb(context, options);
97
- const remote = await resolved.client.status(resolved.agentId, resolved.tokenAuth);
98
- const keys = await loadMockDbKeys(resolved.agentId, resolved.homeDir);
294
+ const keys = keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir));
295
+ const hasLocalKeys = !!(keys.managementKey || keys.runtimeKey);
296
+ let remote = null;
297
+ let error;
298
+ try {
299
+ const resp = await retryWhile(isEmptyReadObject, () => resolved.client.status(resolved.agentId, resolved.tokenAuth));
300
+ // A blank `{}` (transport blank under load) is NOT "the service doesn't
301
+ // own this agent" — treat it as unreachable → `unknown`, never `new`.
302
+ if (isEmptyReadObject(resp)) {
303
+ throw new Error("/status returned a blank body (transport blank under load)");
304
+ }
305
+ remote = resp;
306
+ }
307
+ catch (probeError) {
308
+ error = probeError instanceof Error ? probeError.message : String(probeError);
309
+ }
310
+ let verdict;
311
+ let message;
312
+ if (!remote) {
313
+ verdict = "unknown";
314
+ message =
315
+ "The mock-db /status check was unreachable — nothing was changed. Retry, or run `apollo mockdb describe` to confirm.";
316
+ }
317
+ else if (remote.exists && remote.hasData !== false) {
318
+ verdict = "ok";
319
+ message =
320
+ "The mock-db service owns this agent's mock DB" +
321
+ (remote.hasData ? " and it has data." : ".");
322
+ }
323
+ else if (remote.exists) {
324
+ verdict = "empty";
325
+ message =
326
+ "The mock-db service owns this agent's record but it is EMPTY (no collections or endpoints). Seed it with `apollo mockdb collections create` / `seed` / `endpoint create`.";
327
+ }
328
+ else {
329
+ verdict = "new";
330
+ message = hasLocalKeys
331
+ ? "The mock-db service does NOT own this agent (no mock DB), yet local keys exist — they belong to a DB that was deleted or lives on another deployment. Run `apollo mockdb provision` to create one here; the stale keys are replaced on provision."
332
+ : "No mock DB for this agent yet. Run `apollo mockdb provision` to create one.";
333
+ }
99
334
  return {
100
335
  agentId: resolved.agentId,
336
+ backend: "aui-managed",
101
337
  baseUrl: context.baseUrls.mockDb,
102
338
  remote,
103
- keys: keyPrefixes(keys),
339
+ keys,
340
+ verdict,
341
+ message,
342
+ ...(error === undefined ? {} : { error }),
104
343
  };
105
344
  }
106
345
  export async function createCollection(context, name, schema, options = {}) {
107
346
  const resolved = resolveMockDb(context, options);
108
- return resolved.client.createCollection(resolved.agentId, resolved.tokenAuth, {
109
- name,
110
- schema,
111
- });
347
+ // No auto-retry on a write: a blank 2xx may mean the create already landed.
348
+ const resp = await withManage(resolved, (auth) => resolved.client.createCollection(resolved.agentId, auth, { name, schema }));
349
+ if (isEmptyReadObject(resp))
350
+ blankBodyError(resolved, "collections create", "write");
351
+ return resp;
112
352
  }
113
353
  export async function listCollections(context, options = {}) {
114
354
  const resolved = resolveMockDb(context, options);
115
- return resolved.client.listCollections(resolved.agentId, resolved.tokenAuth);
355
+ return readWithRetry(resolved, "collections list", () => withManage(resolved, (auth) => resolved.client.listCollections(resolved.agentId, auth)));
116
356
  }
117
357
  export async function updateCollection(context, name, addColumns, options = {}) {
118
358
  const resolved = resolveMockDb(context, options);
119
- return resolved.client.updateCollection(resolved.agentId, resolved.tokenAuth, name, {
120
- addColumns,
121
- });
359
+ const resp = await withManage(resolved, (auth) => resolved.client.updateCollection(resolved.agentId, auth, name, { addColumns }));
360
+ if (isEmptyReadObject(resp))
361
+ blankBodyError(resolved, "collections update", "write");
362
+ return resp;
122
363
  }
123
364
  export async function seedRows(context, collection, rows, options = {}) {
124
365
  const resolved = resolveMockDb(context, options);
125
- return resolved.client.seedRows(resolved.agentId, resolved.tokenAuth, collection, rows);
366
+ // Seeding APPENDS rows — never auto-retry a blank (a repeat could duplicate).
367
+ const resp = await withManage(resolved, (auth) => resolved.client.seedRows(resolved.agentId, auth, collection, rows));
368
+ if (isEmptyReadObject(resp))
369
+ blankBodyError(resolved, "seed", "write");
370
+ return resp;
126
371
  }
127
372
  export async function createEndpoint(context, body, options = {}) {
128
373
  const resolved = resolveMockDb(context, options);
129
- return resolved.client.createEndpoint(resolved.agentId, resolved.tokenAuth, body);
374
+ const resp = await withManage(resolved, (auth) => resolved.client.createEndpoint(resolved.agentId, auth, body));
375
+ if (isEmptyReadObject(resp))
376
+ blankBodyError(resolved, "endpoint create", "write");
377
+ return resp;
130
378
  }
131
379
  export async function listEndpoints(context, options = {}) {
132
380
  const resolved = resolveMockDb(context, options);
133
- return resolved.client.listEndpoints(resolved.agentId, resolved.tokenAuth);
381
+ return readWithRetry(resolved, "endpoint list", () => withManage(resolved, (auth) => resolved.client.listEndpoints(resolved.agentId, auth)));
134
382
  }
135
383
  export async function getEndpoint(context, slug, options = {}) {
136
384
  const resolved = resolveMockDb(context, options);
137
- return resolved.client.getEndpoint(resolved.agentId, resolved.tokenAuth, slug);
385
+ return readWithRetry(resolved, `endpoint get ${slug}`, () => withManage(resolved, (auth) => resolved.client.getEndpoint(resolved.agentId, auth, slug)));
138
386
  }
139
387
  export async function updateEndpoint(context, slug, body, options = {}) {
140
388
  const resolved = resolveMockDb(context, options);
141
- return resolved.client.updateEndpoint(resolved.agentId, resolved.tokenAuth, slug, body);
389
+ const resp = await withManage(resolved, (auth) => resolved.client.updateEndpoint(resolved.agentId, auth, slug, body));
390
+ if (isEmptyReadObject(resp))
391
+ blankBodyError(resolved, `endpoint update ${slug}`, "write");
392
+ return resp;
142
393
  }
143
394
  export async function deleteEndpoint(context, slug, options = {}) {
144
395
  const resolved = resolveMockDb(context, options);
145
- return resolved.client.deleteEndpoint(resolved.agentId, resolved.tokenAuth, slug);
396
+ const resp = await withManage(resolved, (auth) => resolved.client.deleteEndpoint(resolved.agentId, auth, slug));
397
+ if (isEmptyReadObject(resp))
398
+ blankBodyError(resolved, `endpoint delete ${slug}`, "write");
399
+ return resp;
400
+ }
401
+ /**
402
+ * Ordered runtime keys to try. The wired key (`$MOCKDB_TOKEN`, or the
403
+ * connection's `token_env`) is what a local `chat --local` run resolves, so
404
+ * it goes first; the cache holds the last key the console minted/stored and
405
+ * is the fallback. Duplicates collapse. `executeEndpoint` tries these in
406
+ * order, falls through on a 401/403, and mints only when none authenticate.
407
+ */
408
+ async function runtimeKeyCandidates(resolved) {
409
+ const envKey = process.env[resolved.tokenEnv]?.trim();
410
+ const cacheKey = (await loadMockDbKeys(resolved.agentId, resolved.homeDir)).runtimeKey;
411
+ const candidates = [];
412
+ if (envKey)
413
+ candidates.push({ key: envKey, source: "env" });
414
+ if (cacheKey && cacheKey !== envKey)
415
+ candidates.push({ key: cacheKey, source: "cache" });
416
+ return candidates;
417
+ }
418
+ /**
419
+ * When `execute` had to mint a fresh key — or the wired env key was rejected
420
+ * and a cached one worked — the key the deployed agent / local runtime uses
421
+ * is stale. The console doesn't edit secrets itself, so it attaches a loud,
422
+ * actionable `runtimeKeyResync` block carrying the working key + the exact
423
+ * re-wire instruction. The command layer echoes it; tooling reads the JSON.
424
+ */
425
+ function attachRuntimeKeyResync(resolved, resp, source, runtimeKey) {
426
+ const reason = source === "cache"
427
+ ? `the runtime key in $${resolved.tokenEnv} was rejected (HTTP 401); a working key was recovered from the local cache`
428
+ : "no valid runtime key was wired or cached; a fresh runtime key was minted (any previously wired key is now invalid)";
429
+ if (resp && typeof resp === "object" && !Array.isArray(resp)) {
430
+ const block = {
431
+ required: true,
432
+ source,
433
+ reason,
434
+ runtimeKey,
435
+ action: `Export ${resolved.tokenEnv}=${runtimeKey} (and update the vault secret / secrets.yaml the connection resolves it from), ` +
436
+ "then re-run `apollo mockdb wire` if connections.yaml changed and `apollo push`. Until then the DEPLOYED agent keeps getting HTTP 401 on its runtime calls.",
437
+ };
438
+ resp.runtimeKeyResync = block;
439
+ }
440
+ return resp;
441
+ }
442
+ // Read-after-write lag: a write commits, but the next read can be served by a
443
+ // not-yet-caught-up replica — a 422 no_results (row not visible yet) or a
444
+ // transient 5xx. Retried with bounded backoff where a retry can NEVER
445
+ // duplicate a write:
446
+ // • 422 no_results ⇒ the service matched 0 rows ⇒ NOTHING was mutated
447
+ // (an insert always returns count 1, so it never 422s). Always safe.
448
+ // • a transient 5xx / dropped connection is ambiguous for a write, so it is
449
+ // retried ONLY for non-insert endpoints (read/update/delete are idempotent);
450
+ // an insert — or an endpoint whose kind can't be confirmed — surfaces at once.
451
+ const NO_RESULTS_MAX_RETRIES = 2;
452
+ const TRANSIENT_MAX_RETRIES = 4;
453
+ const LAG_BACKOFF_MS = [300, 800, 1800, 3000];
454
+ function lagBackoffMs(attempt) {
455
+ const base = LAG_BACKOFF_MS[Math.min(attempt, LAG_BACKOFF_MS.length) - 1] ?? 3000;
456
+ return base + Math.floor(Math.random() * 150);
457
+ }
458
+ const endpointKindCache = new Map();
459
+ /** Best-effort endpoint kind via the management plane (cached per process).
460
+ * Unknown ⇒ callers MUST treat as "not safe to retry a 5xx". */
461
+ async function resolveEndpointKind(resolved, slug) {
462
+ const cacheKey = `${resolved.agentId}:${slug}`;
463
+ const cached = endpointKindCache.get(cacheKey);
464
+ if (cached)
465
+ return cached;
466
+ try {
467
+ const resp = await withManage(resolved, (auth) => resolved.client.getEndpoint(resolved.agentId, auth, slug));
468
+ const kind = resp?.kind;
469
+ if (kind === "read" || kind === "insert" || kind === "update" || kind === "delete") {
470
+ endpointKindCache.set(cacheKey, kind);
471
+ return kind;
472
+ }
473
+ }
474
+ catch {
475
+ // unknown → unsafe for a 5xx retry (the 422 retry is still allowed)
476
+ }
477
+ return undefined;
478
+ }
479
+ function isNoResultsError(error) {
480
+ return (error instanceof ApiError && error.status === 422 && mockDbErrorCode(error) === "no_results");
481
+ }
482
+ function isTransientServerError(error) {
483
+ if (error instanceof ApiError)
484
+ return error.status >= 500 && error.status <= 599;
485
+ return isMockDbTransportError(error);
486
+ }
487
+ /** Mint + cache a fresh runtime key (loud-fails on a keyless mint). */
488
+ async function mintRuntimeKey(resolved) {
489
+ const resp = await rotateWithRetry(resolved, "runtimeKey", "rotate runtime", () => withManage(resolved, (auth) => resolved.client.rotateRuntimeKey(resolved.agentId, auth)));
490
+ // rotateWithRetry loud-fails unless the key came back, so it is present here.
491
+ return resp.runtimeKey;
146
492
  }
147
493
  export async function executeEndpoint(context, slug, input = {}, options = {}) {
148
494
  const resolved = resolveMockDb(context, options);
149
- const keys = await loadMockDbKeys(resolved.agentId, resolved.homeDir);
150
- if (!keys.runtimeKey) {
151
- throw new ConfigError("No runtime key stored for this agent.", "Run `apollo mockdb provision` or `apollo mockdb rotate runtime`, then `apollo mockdb wire`.");
152
- }
153
- const auth = {
154
- mode: "runtime",
155
- runtimeKey: keys.runtimeKey,
156
- ...(input.session ? { sessionKey: input.session } : {}),
495
+ const sessionKey = input.session?.trim() || undefined;
496
+ const candidates = await runtimeKeyCandidates(resolved);
497
+ const runOnce = (key) => {
498
+ const auth = {
499
+ mode: "runtime",
500
+ runtimeKey: key,
501
+ ...(sessionKey ? { sessionKey } : {}),
502
+ };
503
+ return resolved.client.execute(resolved.agentId, auth, slug, input.body);
504
+ };
505
+ // A WRITE always requires a session (the service rejects a write with no
506
+ // session key), so an execute WITHOUT a session is guaranteed a read and is
507
+ // safe to retry on a transient blank / 5xx; WITH a session the kind gates it.
508
+ let transientRetrySafe;
509
+ const transientIsSafe = async () => {
510
+ if (sessionKey === undefined)
511
+ return true;
512
+ if (transientRetrySafe === undefined) {
513
+ const kind = await resolveEndpointKind(resolved, slug);
514
+ transientRetrySafe = kind !== undefined && kind !== "insert";
515
+ }
516
+ return transientRetrySafe;
157
517
  };
158
- return resolved.client.execute(resolved.agentId, auth, slug, input.body);
518
+ const inTest = inTestEnv();
519
+ const runWithRetries = async (key) => {
520
+ const attempt = () => sessionKey === undefined
521
+ ? retryWhile(isEmptyReadObject, () => runOnce(key))
522
+ : runOnce(key);
523
+ let noResultsTries = 0;
524
+ let transientTries = 0;
525
+ for (;;) {
526
+ try {
527
+ return await attempt();
528
+ }
529
+ catch (error) {
530
+ if (isAuthRejection(error))
531
+ throw error; // next key source / mint
532
+ if (isNoResultsError(error) && noResultsTries < NO_RESULTS_MAX_RETRIES) {
533
+ if (!inTest)
534
+ await sleep(lagBackoffMs(++noResultsTries));
535
+ continue;
536
+ }
537
+ if (isTransientServerError(error) &&
538
+ transientTries < TRANSIENT_MAX_RETRIES &&
539
+ (await transientIsSafe())) {
540
+ if (!inTest)
541
+ await sleep(lagBackoffMs(++transientTries));
542
+ continue;
543
+ }
544
+ throw error;
545
+ }
546
+ }
547
+ };
548
+ let resp;
549
+ let usedSource;
550
+ let usedKey;
551
+ let envRejected = false;
552
+ for (const candidate of candidates) {
553
+ try {
554
+ resp = await runWithRetries(candidate.key);
555
+ usedSource = candidate.source;
556
+ usedKey = candidate.key;
557
+ break;
558
+ }
559
+ catch (error) {
560
+ if (isAuthRejection(error)) {
561
+ if (candidate.source === "env")
562
+ envRejected = true;
563
+ continue;
564
+ }
565
+ throw error;
566
+ }
567
+ }
568
+ if (usedSource === undefined) {
569
+ const minted = await mintRuntimeKey(resolved);
570
+ try {
571
+ resp = await runWithRetries(minted);
572
+ usedSource = "minted";
573
+ usedKey = minted;
574
+ }
575
+ catch (error) {
576
+ if (isAuthRejection(error)) {
577
+ throw new CliError(`mockdb execute ${slug}: the runtime key is still invalid for agent ${resolved.agentId} even after minting a fresh one (HTTP ${error.status}).`, {
578
+ code: "API_CLIENT_ERROR",
579
+ suggestion: "Confirm the agent is provisioned with `apollo mockdb status`; the backend may be rejecting freshly-minted keys (report it).",
580
+ });
581
+ }
582
+ throw error;
583
+ }
584
+ }
585
+ if (isEmptyReadObject(resp)) {
586
+ blankBodyError(resolved, `execute ${slug}`, sessionKey === undefined ? "read" : "write");
587
+ }
588
+ // A minted key, or a cached key that had to replace a rejected wired key,
589
+ // means whatever the runtime resolves is stale ⇒ surface the re-wire step.
590
+ if (usedKey && (usedSource === "minted" || (usedSource === "cache" && envRejected))) {
591
+ resp = attachRuntimeKeyResync(resolved, resp, usedSource, usedKey);
592
+ }
593
+ return resp;
594
+ }
595
+ // ─── Identities (persona sample data for the playground simulator) ──────
596
+ export async function getIdentities(context, options = {}) {
597
+ const resolved = resolveMockDb(context, options);
598
+ // An agent with no personas returns a keyed `{ sample_data: { personas: [] } }`
599
+ // (the feature is simply off), never `{}` — so a blank body really is a
600
+ // transport blank and is retried like every other read.
601
+ return readWithRetry(resolved, "identities get", () => withManage(resolved, (auth) => resolved.client.getIdentities(resolved.agentId, auth)));
602
+ }
603
+ export async function setIdentities(context, sampleData, options = {}) {
604
+ const resolved = resolveMockDb(context, options);
605
+ const resp = await withManage(resolved, (auth) => resolved.client.setIdentities(resolved.agentId, auth, sampleData));
606
+ if (isEmptyReadObject(resp))
607
+ blankBodyError(resolved, "identities set", "write");
608
+ return resp;
609
+ }
610
+ /**
611
+ * Normalize a persona payload into the `sample_data` object the service
612
+ * stores. Accepts the bare object (`{ personas: [...] }`) or the full request
613
+ * body (`{ sample_data: { personas: [...] } }`), so a payload copied straight
614
+ * from the API docs works as-is. Only the outer shape is checked here — the
615
+ * field-level rules belong to the service, which reports them as precise
616
+ * 400s naming the offending `personas[i].fields[j]`.
617
+ */
618
+ export function normalizeSampleData(raw) {
619
+ const wrapped = raw && typeof raw === "object" && !Array.isArray(raw)
620
+ ? raw.sample_data
621
+ : undefined;
622
+ const payload = wrapped !== undefined ? wrapped : raw;
623
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
624
+ throw new ValidationError("The persona payload must be a JSON object (optionally wrapped in `sample_data`).", 'e.g. --sample-data \'{"personas":[{"label":"Jane Doe (verified)","state":"verified","default":true,"fields":[]}]}\'');
625
+ }
626
+ if (!Array.isArray(payload.personas)) {
627
+ throw new ValidationError("The persona payload needs a `personas` array (pass an empty array to turn the persona picker off).", 'Each persona needs `label`, `state`, and `fields`; exactly one must have `default: true`. e.g. \'{"personas":[{"label":"Jane Doe (verified)","state":"verified","default":true,"fields":[{"key":"user_id","label":"User ID","value":"U1001","group":"who_you_are"}]}]}\'');
628
+ }
629
+ return payload;
630
+ }
631
+ /**
632
+ * Read the persona payload from an inline JSON string or a file. A persona
633
+ * payload is the largest JSON any mockdb command takes and its values
634
+ * routinely contain apostrophes, so the file path avoids shell quoting.
635
+ */
636
+ export async function readSampleDataInput(input) {
637
+ const inline = input.sampleData?.trim();
638
+ const file = input.file?.trim();
639
+ if (inline && file) {
640
+ throw new ValidationError("Pass either --sample-data or --file, not both.", "Use --file <path.json> for a large payload, --sample-data '<json>' for a small inline one.");
641
+ }
642
+ if (file) {
643
+ const resolvedPath = path.resolve(file);
644
+ let raw;
645
+ try {
646
+ raw = await readFile(resolvedPath, "utf8");
647
+ }
648
+ catch (error) {
649
+ throw new ValidationError(`--file could not be read: ${error instanceof Error ? error.message : String(error)}`, `Check that ${resolvedPath} exists and is readable JSON.`);
650
+ }
651
+ return parseJsonFlag(`--file (${resolvedPath})`, raw);
652
+ }
653
+ if (!inline) {
654
+ throw new ValidationError("Missing required option: --sample-data.", "Pass --sample-data '{\"personas\":[...]}' (or --file <path.json>).");
655
+ }
656
+ return parseJsonFlag("--sample-data", inline);
159
657
  }
658
+ // ─── Sessions / keys / export / delete ──────────────────────────────────
160
659
  export async function inspectSession(context, sessionKey, options = {}) {
161
660
  const resolved = resolveMockDb(context, options);
162
- return resolved.client.inspectSession(resolved.agentId, resolved.tokenAuth, sessionKey);
661
+ // A real empty overlay returns a keyed `{ sessionKey, totalRecords:0, … }`,
662
+ // never `{}` — a blank is a transport blip, not an empty session.
663
+ return readWithRetry(resolved, `session get ${sessionKey}`, () => withManage(resolved, (auth) => resolved.client.inspectSession(resolved.agentId, auth, sessionKey)));
163
664
  }
164
665
  export async function resetSession(context, sessionKey, options = {}) {
165
666
  const resolved = resolveMockDb(context, options);
166
- return resolved.client.resetSession(resolved.agentId, resolved.tokenAuth, sessionKey);
667
+ return withManage(resolved, (auth) => resolved.client.resetSession(resolved.agentId, auth, sessionKey));
167
668
  }
168
669
  export async function rotateMgmtKey(context, options = {}) {
169
670
  const resolved = resolveMockDb(context, options);
170
- const resp = await resolved.client.rotateMgmtKey(resolved.agentId, resolved.tokenAuth);
171
- await persistProvisionKeys(resolved.agentId, context.baseUrls.mockDb, resp, resolved.homeDir);
671
+ // The one rotation that always rides the Apollo token — it is also how a
672
+ // pre-existing agent obtains its FIRST management key.
673
+ const resp = await rotateWithRetry(resolved, "managementKey", "rotate mgmt", () => resolved.client.rotateMgmtKey(resolved.agentId, resolved.tokenAuth));
172
674
  return {
173
675
  ...resp,
174
676
  stored: keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
@@ -176,8 +678,7 @@ export async function rotateMgmtKey(context, options = {}) {
176
678
  }
177
679
  export async function rotateRuntimeKey(context, options = {}) {
178
680
  const resolved = resolveMockDb(context, options);
179
- const resp = await resolved.client.rotateRuntimeKey(resolved.agentId, resolved.tokenAuth);
180
- await persistProvisionKeys(resolved.agentId, context.baseUrls.mockDb, resp, resolved.homeDir);
681
+ const resp = await rotateWithRetry(resolved, "runtimeKey", "rotate runtime", () => withManage(resolved, (auth) => resolved.client.rotateRuntimeKey(resolved.agentId, auth)));
181
682
  return {
182
683
  ...resp,
183
684
  stored: keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
@@ -186,10 +687,11 @@ export async function rotateRuntimeKey(context, options = {}) {
186
687
  }
187
688
  export async function exportMockDb(context, options = {}) {
188
689
  const resolved = resolveMockDb(context, options);
189
- return resolved.client.exportAgent(resolved.agentId, resolved.tokenAuth);
690
+ return readWithRetry(resolved, "export", () => withManage(resolved, (auth) => resolved.client.exportAgent(resolved.agentId, auth)));
190
691
  }
191
692
  export async function deleteMockDb(context, options = {}) {
192
693
  const resolved = resolveMockDb(context, options);
694
+ // DELETE is strict on the service — always the Apollo token, never a mgmt key.
193
695
  const resp = await resolved.client.deleteAgent(resolved.agentId, resolved.tokenAuth);
194
696
  await clearMockDbKeys(resolved.agentId, resolved.homeDir);
195
697
  return resp;
@@ -201,6 +703,32 @@ export async function storedKeys(context, options = {}) {
201
703
  ...keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
202
704
  };
203
705
  }
706
+ /**
707
+ * Clone another agent's mock DB (collections + base rows + endpoints +
708
+ * personas) into THIS agent. The target must be empty — the service refuses
709
+ * (400) to overwrite an existing mock DB. Both agents are authorized on the
710
+ * account plane (the caller must be a member of the account owning each).
711
+ * Keys come back only when the target was provisioned by this call; they are
712
+ * persisted like a provision.
713
+ */
714
+ export async function cloneMockDb(context, options) {
715
+ const resolved = resolveMockDb(context, options);
716
+ const from = options.from.trim();
717
+ if (!from) {
718
+ throw new ValidationError("Missing clone source.", "Pass --from <sourceAgentId>.");
719
+ }
720
+ if (from === resolved.agentId) {
721
+ throw new ValidationError("The clone source and target are the same agent.", "Pass --from <a different agent id> (e.g. the template agent this one was created from).");
722
+ }
723
+ const resp = await retryWhile(isEmptyReadObject, () => resolved.client.cloneAgent(resolved.agentId, from, resolved.tokenAuth));
724
+ if (isEmptyReadObject(resp))
725
+ blankBodyError(resolved, `clone from ${from}`, "write");
726
+ await persistReturnedKeys(resolved, resp);
727
+ return {
728
+ ...resp,
729
+ stored: keyPrefixes(await loadMockDbKeys(resolved.agentId, resolved.homeDir)),
730
+ };
731
+ }
204
732
  /**
205
733
  * Ensure the bundle's `connections.yaml` has a mock-db connection pointing at
206
734
  * this agent's execute base, reading the runtime key from MOCKDB_TOKEN (vault
@@ -220,10 +748,10 @@ export async function wireMockDbConnection(context, options = {}) {
220
748
  }
221
749
  const keys = await loadMockDbKeys(resolved.agentId, resolved.homeDir);
222
750
  if (!keys.runtimeKey) {
223
- throw new ConfigError("No runtime key to wire.", "Run `apollo mockdb provision` first.");
751
+ throw new ConfigError("No runtime key to wire.", "Run `apollo mockdb provision` first (or `apollo mockdb rotate runtime` for an existing DB).");
224
752
  }
225
753
  const connectionId = options.connectionId ?? "mock-db";
226
- const tokenEnv = options.tokenEnv ?? "MOCKDB_TOKEN";
754
+ const tokenEnv = options.tokenEnv ?? DEFAULT_MOCKDB_TOKEN_ENV;
227
755
  const baseUrl = `${context.baseUrls.mockDb.replace(/\/+$/, "")}/api/agents/${resolved.agentId}/apis`;
228
756
  const sourceRoot = path.join(agentRoot, "bundle", "src");
229
757
  const file = path.join(sourceRoot, "connections.yaml");
@@ -287,6 +815,7 @@ export async function wireMockDbConnection(context, options = {}) {
287
815
  notes,
288
816
  };
289
817
  }
818
+ // ─── Flag parsing helpers (shared by the CLI and the TUI) ───────────────
290
819
  export function parseJsonFlag(flag, raw) {
291
820
  try {
292
821
  return JSON.parse(raw);
@@ -303,4 +832,180 @@ export function normalizeSchema(raw) {
303
832
  }
304
833
  throw new ValidationError("--schema must be a JSON array of columns, or { \"columns\": [...] }.");
305
834
  }
835
+ /**
836
+ * Normalize the `--add-columns` value into a column list. Accepts a bare array
837
+ * of column defs, or a `{ columns: [...] }` / `{ addColumns: [...] }` wrapper
838
+ * so the shape matches `collections create --schema`.
839
+ */
840
+ export function normalizeAddColumns(raw) {
841
+ const arr = Array.isArray(raw)
842
+ ? raw
843
+ : raw && typeof raw === "object"
844
+ ? (raw.addColumns ??
845
+ raw.columns)
846
+ : undefined;
847
+ if (!Array.isArray(arr) || arr.length === 0) {
848
+ throw new ValidationError("--add-columns must be a non-empty JSON array of column definitions (or an object with a `columns` array).", 'e.g. --add-columns \'[{"name":"status","type":"text"}]\'. Added columns are nullable; primary keys cannot be added to an existing collection.');
849
+ }
850
+ return arr;
851
+ }
852
+ // ─── Guide ──────────────────────────────────────────────────────────────
853
+ /** The text emitted by `apollo mockdb guide` — the how-to for driving the mock DB. */
854
+ export const MOCKDB_GUIDE = `# Mock DB — Apollo CLI Guide
855
+
856
+ A per-agent **mock database** for building and testing agents that have no
857
+ real backend (or no test environment) yet. You define collections (tables),
858
+ seed realistic data, and declare named read/write endpoints; the agent's
859
+ program sources then call those endpoints at runtime through a
860
+ \`connections.yaml\` connection. It supports relative date/time fields
861
+ (\`now-30d\`) that stay fresh, and per-conversation copy-on-write keyed by the
862
+ session id, so test runs are isolated and resettable.
863
+
864
+ All \`apollo mockdb\` commands are **non-interactive**. Pass the global
865
+ \`--json\` for the machine envelope (\`{ "success": true, "data": ... }\`).
866
+
867
+ ## How auth works (and why it's safe)
868
+
869
+ - The agent comes from the checkout you are standing in (\`.apollorc\`) or
870
+ \`--agent <id>\`; the account from the active project (\`--account <id>\` or
871
+ \`APOLLO_ACCOUNT_ID\` to override).
872
+ - Your Apollo token is sent **first-party from the CLI** only where the
873
+ service demands it: \`provision\`, \`status\`, \`delete\`, and minting a
874
+ management key. The service verifies it against Apollo (you must be a
875
+ member of the agent's account).
876
+ - Provision returns two per-agent keys, stored under \`~/.apollo/mockdb/\`:
877
+ - **managementKey** — used for ALL other management calls (\`X-Mgmt-Key\`;
878
+ no Apollo token transmitted). Minted on demand if missing/rejected.
879
+ - **runtimeKey** — what the agent's connection uses at runtime; also what
880
+ \`apollo mockdb execute\` uses to test endpoints.
881
+
882
+ ## Typical workflow
883
+
884
+ \`\`\`bash
885
+ # 1. Provision (stores the keys) and wire bundle/src/connections.yaml
886
+ apollo mockdb provision --wire
887
+
888
+ # 2. Create collections (parents before children — FKs need the parent first)
889
+ apollo mockdb collections create --name users \\
890
+ --schema '{"columns":[{"name":"id","type":"text","primaryKey":true},{"name":"name","type":"text"}]}'
891
+ apollo mockdb collections create --name accounts \\
892
+ --schema '{"columns":[{"name":"id","type":"text","primaryKey":true},{"name":"user_id","type":"text","references":"users.id"},{"name":"balance","type":"number"},{"name":"opened_at","type":"reldate"}]}'
893
+
894
+ # 3. Seed data (relative date tokens allowed)
895
+ apollo mockdb seed --collection users --rows '[{"id":"U1","name":"Ada Lovelace"}]'
896
+ apollo mockdb seed --collection accounts --rows '[{"id":"A1","user_id":"U1","balance":4200.5,"opened_at":"now-400d"}]'
897
+
898
+ # 4. Declare the endpoints the agent will call
899
+ apollo mockdb endpoint create --slug get-accounts \\
900
+ --param-schema '{"user_id":{"type":"string","required":true}}' \\
901
+ --spec '{"kind":"read","collection":"accounts","where":[{"column":"user_id","op":"eq","param":"user_id"}]}'
902
+
903
+ # 5. Test an endpoint under a session (runtime key + session = a conversation)
904
+ apollo mockdb execute --slug get-accounts --session test-1 --body '{"user_id":"U1"}'
905
+
906
+ # 6. Inspect / reset that session's overlay
907
+ apollo mockdb session get --key test-1
908
+ apollo mockdb session reset --key test-1
909
+ \`\`\`
910
+
911
+ Then map each program source that names \`connection: mock-db\` to an endpoint
912
+ under \`operations\` in \`bundle/src/connections.yaml\`, export the runtime key as
913
+ \`MOCKDB_TOKEN\` (or the connection's \`token_env\`), and \`apollo validate &&
914
+ apollo push\`.
915
+
916
+ ## Personas for the playground simulator (\`identities\`)
917
+
918
+ Once the mock DB has data, the playground can offer a **persona picker** before
919
+ the first message so a tester speaks as a real seeded user. The payload is
920
+ authored here — the schema and seed values are already in hand:
921
+
922
+ \`\`\`bash
923
+ # Read what's stored (no personas ⇒ { "personas": [] } — the picker is off).
924
+ apollo mockdb identities get
925
+
926
+ # Upsert the payload (large payloads are easier from a file).
927
+ apollo mockdb identities set --file personas.json
928
+ apollo mockdb identities set --sample-data '{
929
+ "instructions": "Pick a persona to simulate as.",
930
+ "personas": [
931
+ {
932
+ "label": "Jane Doe (verified)",
933
+ "state": "verified",
934
+ "default": true,
935
+ "fields": [
936
+ { "key": "user_id", "label": "User ID", "value": "U1001", "group": "who_you_are" },
937
+ { "key": "pin", "label": "PIN", "value": "4321", "group": "verify", "role": "secret" }
938
+ ]
939
+ }
940
+ ]
941
+ }'
942
+ \`\`\`
943
+
944
+ Rules the service enforces (a 400 names the exact \`personas[i].fields[j]\`):
945
+
946
+ - Each persona needs a non-empty \`label\` and \`state\`; each field needs
947
+ \`key\`, \`label\`, \`value\`, and a \`group\` of \`who_you_are\` | \`verify\` |
948
+ \`good_to_know\` | \`records\`.
949
+ - Exactly **one** persona carries \`default: true\` (when there is at least one).
950
+ - Optional per-field extras: \`role\` (\`key\` | \`secret\`), \`audience\`
951
+ (\`user\` | \`simulator\` | \`both\`), \`required_to_start\`, \`match\`, \`maps_to\`.
952
+ - Optional per-persona \`intents\` (\`{ title, message }\`) seed suggested openers.
953
+
954
+ \`set\` is a full **REPLACE**, not a merge — \`{"personas":[]}\` turns the picker
955
+ off. Seed the values first: every persona value must exist in the mock DB.
956
+ Personas travel with the DB — \`clone\` and \`export\` carry them.
957
+
958
+ ## Cloning another agent's mock DB
959
+
960
+ \`\`\`bash
961
+ # Copy a template agent's collections + rows + endpoints + personas into THIS
962
+ # agent (the target must be empty; your account must own both agents):
963
+ apollo mockdb clone --from <templateAgentId>
964
+ \`\`\`
965
+
966
+ ## Commands
967
+
968
+ | Command | What it does |
969
+ |---|---|
970
+ | \`mockdb provision [--wire]\` | Create the agent's mock DB; store runtime + management keys |
971
+ | \`mockdb status\` | Ownership + local key prefixes + verdict (ok / empty / new / unknown) |
972
+ | \`mockdb describe\` | Schema, relationships, relative-field flags, endpoints, row counts |
973
+ | \`mockdb collections create --name <n> --schema <json>\` | Create a collection (table) |
974
+ | \`mockdb collections update --name <n> --add-columns <json>\` | Add nullable columns (no rename/type-change/drop) |
975
+ | \`mockdb collections list\` | List collections |
976
+ | \`mockdb seed --collection <n> --rows <json-array>\` | Seed base rows |
977
+ | \`mockdb endpoint create --slug <s> --spec <json> [--param-schema <json>] [--description <t>]\` | Declare a read/insert/update/delete endpoint |
978
+ | \`mockdb endpoint list\` / \`get <slug>\` | List / inspect endpoints |
979
+ | \`mockdb endpoint update <slug> [--spec] [--param-schema] [--description]\` | PATCH-merge an endpoint |
980
+ | \`mockdb endpoint delete <slug>\` | Delete an endpoint (re-map operations + push after) |
981
+ | \`mockdb execute --slug <s> [--session <key>] [--body <json>]\` | Run an endpoint with the runtime key |
982
+ | \`mockdb identities get\` / \`set --sample-data <json> | --file <path>\` | Read / replace the persona payload |
983
+ | \`mockdb session get --key <key>\` / \`reset --key <key>\` | Inspect / reset a session overlay |
984
+ | \`mockdb rotate mgmt\` / \`rotate runtime\` | Mint fresh keys (re-wire after a runtime rotation) |
985
+ | \`mockdb clone --from <id>\` | Clone another agent's mock DB into this one (target must be empty) |
986
+ | \`mockdb export\` | Export schema + base data + endpoints + personas |
987
+ | \`mockdb keys\` | Show locally stored key prefixes (no secrets) |
988
+ | \`mockdb wire [--connection-id] [--token-env]\` | Write bundle/src/connections.yaml for the runtime key |
989
+ | \`mockdb delete --force\` | Permanently delete the mock DB (Apollo token only) |
990
+ | \`mockdb guide [--output <file>]\` | Write this guide to a file (default GUIDE.md) |
991
+
992
+ Common flags: \`--agent <id>\`, \`--account <id>\`, and the global \`--json\`.
993
+
994
+ ## Notes
995
+
996
+ - Create collections **parent-first** (a \`references\` FK needs the parent).
997
+ - **Writes need \`--session\`**; reads may omit it to peek at pristine base.
998
+ - Endpoint \`where\`/value params must be declared in \`--param-schema\`.
999
+ - \`execute\` resolves the runtime key from \`$MOCKDB_TOKEN\` → the local cache →
1000
+ a fresh mint (then reports \`runtimeKeyResync\` so you re-wire).
1001
+ - A blank 2xx from the service is a transient transport blip: reads retry it,
1002
+ writes stop and tell you to verify before retrying (never duplicate rows).
1003
+ - Override the service URL with \`APOLLO_MOCKDB_URL\`.
1004
+ `;
1005
+ /** Write the guide to `outputPath` (default `GUIDE.md` in the cwd). */
1006
+ export async function writeMockDbGuide(outputPath) {
1007
+ const resolvedPath = path.resolve(outputPath?.trim() || "GUIDE.md");
1008
+ await writeFile(resolvedPath, MOCKDB_GUIDE, "utf8");
1009
+ return { written: true, path: resolvedPath, bytes: Buffer.byteLength(MOCKDB_GUIDE) };
1010
+ }
306
1011
  //# sourceMappingURL=mockdb.js.map