@agentic-kit/db-tools 0.11.5 → 0.12.0

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 (82) hide show
  1. package/README.md +40 -1
  2. package/app-workspace.d.ts +29 -2
  3. package/app-workspace.js +99 -8
  4. package/blueprint-construction.d.ts +60 -0
  5. package/blueprint-construction.js +161 -0
  6. package/context.d.ts +5 -1
  7. package/context.js +30 -5
  8. package/db-probe.d.ts +25 -17
  9. package/db-probe.js +97 -37
  10. package/esm/app-workspace.d.ts +29 -2
  11. package/esm/app-workspace.js +96 -8
  12. package/esm/blueprint-construction.d.ts +60 -0
  13. package/esm/blueprint-construction.js +154 -0
  14. package/esm/context.d.ts +5 -1
  15. package/esm/context.js +31 -6
  16. package/esm/db-probe.d.ts +25 -17
  17. package/esm/db-probe.js +96 -37
  18. package/esm/graphql-http.d.ts +35 -0
  19. package/esm/graphql-http.js +134 -0
  20. package/esm/host.d.ts +46 -0
  21. package/esm/host.js +9 -0
  22. package/esm/index.d.ts +11 -2
  23. package/esm/index.js +6 -0
  24. package/esm/provision-database/env-file.d.ts +23 -2
  25. package/esm/provision-database/env-file.js +86 -27
  26. package/esm/provision-database/pending-ticket.d.ts +33 -0
  27. package/esm/provision-database/pending-ticket.js +63 -0
  28. package/esm/provision-database/provision.d.ts +71 -0
  29. package/esm/provision-database/provision.js +557 -0
  30. package/esm/provision-database/request-database.d.ts +55 -3
  31. package/esm/provision-database/request-database.js +118 -17
  32. package/esm/schema-model/normalize.d.ts +21 -0
  33. package/esm/schema-model/normalize.js +244 -0
  34. package/esm/schema-model.d.ts +110 -0
  35. package/esm/schema-model.js +303 -0
  36. package/esm/tools/add-policies.d.ts +1 -1
  37. package/esm/tools/add-records.d.ts +1 -0
  38. package/esm/tools/add-records.js +18 -6
  39. package/esm/tools/add-relation.js +2 -3
  40. package/esm/tools/create-api-key.d.ts +2 -0
  41. package/esm/tools/create-api-key.js +27 -12
  42. package/esm/tools/manage-entity-types.d.ts +1 -1
  43. package/esm/tools/provision-blueprint.d.ts +6 -0
  44. package/esm/tools/provision-blueprint.js +11 -4
  45. package/esm/tools/provision-database.d.ts +8 -11
  46. package/esm/tools/provision-database.js +23 -243
  47. package/esm/tools/run-codegen.d.ts +19 -2
  48. package/esm/tools/run-codegen.js +184 -97
  49. package/esm/tools/templates.js +3 -15
  50. package/graphql-http.d.ts +35 -0
  51. package/graphql-http.js +140 -0
  52. package/host.d.ts +46 -0
  53. package/host.js +10 -0
  54. package/index.d.ts +11 -2
  55. package/index.js +26 -1
  56. package/package.json +4 -4
  57. package/provision-database/env-file.d.ts +23 -2
  58. package/provision-database/env-file.js +89 -28
  59. package/provision-database/pending-ticket.d.ts +33 -0
  60. package/provision-database/pending-ticket.js +72 -0
  61. package/provision-database/provision.d.ts +71 -0
  62. package/provision-database/provision.js +565 -0
  63. package/provision-database/request-database.d.ts +55 -3
  64. package/provision-database/request-database.js +124 -18
  65. package/schema-model/normalize.d.ts +21 -0
  66. package/schema-model/normalize.js +250 -0
  67. package/schema-model.d.ts +110 -0
  68. package/schema-model.js +309 -0
  69. package/tools/add-policies.d.ts +1 -1
  70. package/tools/add-records.d.ts +1 -0
  71. package/tools/add-records.js +18 -6
  72. package/tools/add-relation.js +2 -3
  73. package/tools/create-api-key.d.ts +2 -0
  74. package/tools/create-api-key.js +27 -12
  75. package/tools/manage-entity-types.d.ts +1 -1
  76. package/tools/provision-blueprint.d.ts +6 -0
  77. package/tools/provision-blueprint.js +11 -4
  78. package/tools/provision-database.d.ts +8 -11
  79. package/tools/provision-database.js +23 -245
  80. package/tools/run-codegen.d.ts +19 -2
  81. package/tools/run-codegen.js +184 -96
  82. package/tools/templates.js +3 -15
@@ -0,0 +1,565 @@
1
+ "use strict";
2
+ // Provisioning a project's database without ever creating a second one by
3
+ // accident. Every step runs under a per-project lock, and:
4
+ // - a binding is DATABASE_ID alone: it is probed, then kept (and healed) or
5
+ // refused, never provisioned over;
6
+ // - replace mode swaps a binding only if it is still the one the caller saw,
7
+ // and never one that is live (host path) or suspended;
8
+ // - a recorded ticket that reads back empty, or a call that lost its ticket,
9
+ // finds an in-flight provision of the same name before anything new is
10
+ // requested, and a completed one is only reported (binding it is `adopt`);
11
+ // - the host saves the binding before `.env` is written.
12
+ var __importDefault = (this && this.__importDefault) || function (mod) {
13
+ return (mod && mod.__esModule) ? mod : { "default": mod };
14
+ };
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.boundDatabaseId = boundDatabaseId;
17
+ exports.provisionDatabase = provisionDatabase;
18
+ exports.provisionFromTool = provisionFromTool;
19
+ const promises_1 = require("node:fs/promises");
20
+ const node_path_1 = __importDefault(require("node:path"));
21
+ const app_workspace_1 = require("../app-workspace");
22
+ const db_probe_1 = require("../db-probe");
23
+ const host_1 = require("../host");
24
+ const credential_1 = require("./credential");
25
+ const env_file_1 = require("./env-file");
26
+ const manifest_1 = require("./manifest");
27
+ const pending_ticket_1 = require("./pending-ticket");
28
+ const preset_match_1 = require("./preset-match");
29
+ const request_database_1 = require("./request-database");
30
+ const resolve_1 = require("./resolve");
31
+ const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
32
+ const DEFAULT_MODULES_ENDPOINT = 'http://modules.localhost:3000/graphql';
33
+ function failure(code, message, extra = {}) {
34
+ return { ...extra, success: false, message, code };
35
+ }
36
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
37
+ const today = () => new Date().toISOString().slice(0, 10);
38
+ const noop = () => undefined;
39
+ async function readText(file) {
40
+ try {
41
+ return await (0, promises_1.readFile)(file, 'utf8');
42
+ }
43
+ catch {
44
+ return '';
45
+ }
46
+ }
47
+ // Edit .env from its current content, read right before the write, so changes
48
+ // made while this call waited on the backend (by another tool or the user)
49
+ // aren't overwritten with what the file held when the call started.
50
+ async function updateEnvFile(envPath, edit) {
51
+ await (0, promises_1.writeFile)(envPath, edit(await readText(envPath)));
52
+ }
53
+ /** The DATABASE_ID in a project's `.env`, if any. */
54
+ async function boundDatabaseId(cwd) {
55
+ return (0, env_file_1.readEnv)(await readText(node_path_1.default.join(cwd, '.env'))).DATABASE_ID || undefined;
56
+ }
57
+ // The per-DB endpoints are subdomain.<domain>; the provisioning domain is the api
58
+ // endpoint's host minus its first label (api.localhost -> localhost,
59
+ // api.launchql.dev -> launchql.dev), so dev and devnet both provision correctly.
60
+ function provisionDomain(apiEndpoint) {
61
+ try {
62
+ const { hostname } = new URL(apiEndpoint);
63
+ // A bare IP host has no domain hierarchy to strip: dropping its first label
64
+ // ('192.168.1.10' -> '168.1.10') would yield a garbage domain. Use it verbatim.
65
+ if (hostname.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname))
66
+ return hostname;
67
+ const parts = hostname.split('.');
68
+ return parts.length > 1 ? parts.slice(1).join('.') : parts.join('.');
69
+ }
70
+ catch {
71
+ return 'localhost';
72
+ }
73
+ }
74
+ // The host's poll deadline when it's a positive number, else the default.
75
+ function pollDeadline(ms) {
76
+ return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : undefined;
77
+ }
78
+ // ---- per-project lock ----------------------------------------------------
79
+ const projectLocks = new Map();
80
+ // One provision per project at a time: a second call waits, then sees what
81
+ // the first one left (a binding, a recorded ticket) instead of racing it.
82
+ async function withProjectLock(cwd, fn) {
83
+ const key = await (0, promises_1.realpath)(cwd).catch(() => node_path_1.default.resolve(cwd));
84
+ const previous = projectLocks.get(key) ?? Promise.resolve();
85
+ let release = noop;
86
+ const done = new Promise((resolve) => (release = resolve));
87
+ const tail = previous.then(() => done);
88
+ projectLocks.set(key, tail);
89
+ await previous;
90
+ try {
91
+ return await fn();
92
+ }
93
+ finally {
94
+ release();
95
+ if (projectLocks.get(key) === tail)
96
+ projectLocks.delete(key);
97
+ }
98
+ }
99
+ /**
100
+ * Provision the project's database, or keep the one it is bound to. The host
101
+ * entry point: replace mode refuses a live binding here, and `abandonPending`
102
+ * and `adopt` are honoured.
103
+ */
104
+ function provisionDatabase(input, ctx, exec = app_workspace_1.run) {
105
+ return withProjectLock(ctx.cwd, () => provision(input, ctx.cwd, exec, false));
106
+ }
107
+ /**
108
+ * The provision_database tool's entry point: its `reprovision: true` may
109
+ * replace a live binding (a clean rebuild), unless the host turned tool
110
+ * reprovisioning off.
111
+ */
112
+ function provisionFromTool(input, ctx, exec = app_workspace_1.run) {
113
+ return withProjectLock(ctx.cwd, () => provision(input, ctx.cwd, exec, true));
114
+ }
115
+ async function provision(input, cwd, exec, viaTool) {
116
+ const name = input.databaseName?.trim();
117
+ if (!name)
118
+ return failure('failed', 'database_name is required.');
119
+ // Unify endpoints onto the app's backend-config store (environment-aware:
120
+ // *.localhost:3000 in dev, *.launchql.dev packaged). A process.env override
121
+ // still wins for ad-hoc dev; the hardcoded default is a last resort if the
122
+ // store isn't ready.
123
+ const host = (0, host_1.getHost)();
124
+ const backend = host.backendConfig();
125
+ const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
126
+ const modulesEndpoint = process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT;
127
+ // Every project provisions UNDER the account (requestDatabase owns the
128
+ // database to the JWT user). The account bearer authenticates the mutation
129
+ // and the probes, and is the ACCESS_TOKEN written to .env; it expires with
130
+ // the login session, and any provision run (the skip path too) refreshes it.
131
+ const credential = (0, credential_1.selectProvisionCredential)(host.account(), host.signInHint);
132
+ if (credential.mode === 'error') {
133
+ return failure('signed-out', `Cannot provision a database: ${credential.reason}`);
134
+ }
135
+ const envPath = node_path_1.default.join(cwd, '.env');
136
+ const env = (0, env_file_1.readEnv)(await readText(envPath));
137
+ const bound = env.DATABASE_ID || undefined;
138
+ const r = {
139
+ host,
140
+ cwd,
141
+ envPath,
142
+ name,
143
+ mode: input.mode ?? 'ensure',
144
+ apiEndpoint,
145
+ modulesEndpoint,
146
+ bearer: credential.bearer,
147
+ ownerId: credential.ownerId,
148
+ viaTool,
149
+ executor: (0, request_database_1.createProvisionExecutor)({ apiEndpoint, modulesEndpoint, bearer: credential.bearer }),
150
+ // The probe carries the account bearer, so it only ever targets the
151
+ // app-configured backend, never a .env-pinned endpoint.
152
+ probe: (databaseId) => (0, db_probe_1.probeDatabase)({ endpoint: apiEndpoint, bearer: credential.bearer, databaseId, signInHint: host.signInHint }),
153
+ };
154
+ let replaced;
155
+ if (r.mode === 'replace') {
156
+ const refusal = await checkReplace(r, input, bound);
157
+ if (refusal)
158
+ return refusal;
159
+ replaced = bound;
160
+ }
161
+ else if (bound) {
162
+ return keepBinding(r, env, bound, input.adopt);
163
+ }
164
+ if (input.adopt) {
165
+ const prewarm = startPrewarm(r, exec);
166
+ return bindDatabase(r, { databaseId: input.adopt, replaced, adopted: true }, prewarm);
167
+ }
168
+ return requestAndBind(r, input, env, replaced, exec);
169
+ }
170
+ // ---- ensure: keep the binding --------------------------------------------
171
+ async function keepBinding(r, env, bound, adopt) {
172
+ const probe = await r.probe(bound);
173
+ if (probe.status === 'unreachable') {
174
+ return failure('backend-unreachable', `Cannot verify the existing database binding (DATABASE_ID=${bound}): ${probe.detail}. Retry once the backend answers; don't reprovision on an unreachable backend.`, { databaseId: bound });
175
+ }
176
+ if (probe.status === 'missing') {
177
+ return failure('db-missing', `Project carries a binding (DATABASE_ID=${bound}) but that database no longer exists on this backend, or this account lost access to it. ${(0, host_1.reprovisionAdvice)(r.host)}`, { databaseId: bound });
178
+ }
179
+ if (probe.ownerId && probe.ownerId !== r.ownerId) {
180
+ return failure('account-mismatch', `Project carries a binding (DATABASE_ID=${bound}) but that database was provisioned under a different account than the one signed in. ${(0, host_1.reprovisionAdvice)(r.host)}`, { databaseId: bound });
181
+ }
182
+ if (probe.status === 'suspended') {
183
+ return failure('db-suspended', suspendedText(bound, probe), { databaseId: bound });
184
+ }
185
+ if (adopt && adopt !== bound) {
186
+ return failure('binding-live', `The project is bound to a live database (DATABASE_ID=${bound}); adopt binds only an unbound project. Use replace mode to swap it.`, { databaseId: bound });
187
+ }
188
+ // Heal .env on the skip path: a stale token (relogin), missing or stale
189
+ // endpoint pins, and the binding keys a partial .env lacks.
190
+ const databaseName = env.DATABASE_NAME || probe.name || '';
191
+ const ownerId = probe.ownerId ?? r.ownerId;
192
+ const refresh = {};
193
+ if (env.ACCESS_TOKEN !== r.bearer)
194
+ refresh.ACCESS_TOKEN = r.bearer;
195
+ if (env.API_ENDPOINT !== r.apiEndpoint)
196
+ refresh.API_ENDPOINT = r.apiEndpoint;
197
+ if (env.MODULES_ENDPOINT !== r.modulesEndpoint)
198
+ refresh.MODULES_ENDPOINT = r.modulesEndpoint;
199
+ if (!env.DATABASE_NAME && databaseName)
200
+ refresh.DATABASE_NAME = databaseName;
201
+ if (!env.PGDATABASE && databaseName)
202
+ refresh.PGDATABASE = databaseName;
203
+ if (!env.OWNER_ID)
204
+ refresh.OWNER_ID = ownerId;
205
+ const saved = await saveBinding(r, {
206
+ cwd: r.cwd,
207
+ databaseId: bound,
208
+ databaseName,
209
+ ownerId,
210
+ apiEndpoint: r.apiEndpoint,
211
+ modulesEndpoint: r.modulesEndpoint,
212
+ skipped: true,
213
+ });
214
+ if (saved)
215
+ return saved;
216
+ let note = '';
217
+ if (Object.keys(refresh).length > 0) {
218
+ const keys = Object.keys(refresh).join(', ');
219
+ try {
220
+ await updateEnvFile(r.envPath, (content) => (0, env_file_1.mergeEnv)(content, refresh));
221
+ note = ` Refreshed in .env: ${keys}.`;
222
+ }
223
+ catch (err) {
224
+ note = ` Could not refresh ${keys} in .env: ${errorText(err)}.`;
225
+ }
226
+ }
227
+ const advice = r.host.toolReprovision === false ? '' : ' Pass reprovision: true only for an explicit clean rebuild.';
228
+ return {
229
+ success: true,
230
+ message: `Project already provisioned (DATABASE_ID=${bound}) and the database is live under this account. Skipping.${advice}${note}`,
231
+ databaseId: bound,
232
+ databaseName,
233
+ ownerId,
234
+ skipped: true,
235
+ };
236
+ }
237
+ function suspendedText(databaseId, probe) {
238
+ const why = probe.suspendedReason ? ` (${probe.suspendedReason})` : '';
239
+ return `The bound database (DATABASE_ID=${databaseId}) is suspended${why} since ${probe.suspendedAt}. Its data is kept, so it isn't replaced; nothing can read or change it until the suspension is lifted.`;
240
+ }
241
+ // ---- replace: compare and swap -------------------------------------------
242
+ async function checkReplace(r, input, bound) {
243
+ if (r.viaTool && r.host.toolReprovision === false) {
244
+ return failure('reprovision-not-allowed', `provision_database can't replace this project's database: the app replaces a database only after the user approves it. ${(0, host_1.reprovisionAdvice)(r.host)}`);
245
+ }
246
+ const expected = input.replaces === undefined ? bound : input.replaces || undefined;
247
+ if ((expected ?? '') !== (bound ?? '')) {
248
+ return failure('binding-changed', `The project's binding changed (DATABASE_ID is ${bound ?? 'unset'}, expected ${expected ?? 'unset'}), so nothing was replaced.`, { databaseId: bound });
249
+ }
250
+ if (!bound)
251
+ return null;
252
+ const probe = await r.probe(bound);
253
+ if (probe.status === 'unreachable') {
254
+ return failure('backend-unreachable', `Cannot verify the binding being replaced (DATABASE_ID=${bound}): ${probe.detail}. Retry once the backend answers.`, { databaseId: bound });
255
+ }
256
+ if (probe.status === 'suspended')
257
+ return failure('db-suspended', suspendedText(bound, probe), { databaseId: bound });
258
+ // A missing or foreign-owned binding is replaced; a live one of this account
259
+ // only through the tool's explicit rebuild.
260
+ if (probe.status === 'found' && (!probe.ownerId || probe.ownerId === r.ownerId) && !r.viaTool) {
261
+ return failure('binding-live', `The bound database (DATABASE_ID=${bound}) is live under this account, so it wasn't replaced.`, { databaseId: bound });
262
+ }
263
+ return null;
264
+ }
265
+ // The account's in-flight provisions of this name, newest first. A lookup that
266
+ // can't reach the backend refuses (nothing may be requested blind); one that
267
+ // gets another error leaves the caller to decide.
268
+ async function findInFlight(r, skip) {
269
+ try {
270
+ const tickets = await r.executor.listOwn({ databaseName: r.name, ownerId: r.ownerId });
271
+ return { inFlight: tickets.filter((t) => t.id && !skip.has(t.id) && (0, request_database_1.isTicketInFlight)(t)) };
272
+ }
273
+ catch (err) {
274
+ if ((0, request_database_1.isTransientProvisionError)(err)) {
275
+ return {
276
+ inFlight: [],
277
+ refusal: failure('backend-unreachable', `Could not check the backend for a provision of "${r.name}" already in progress (${errorText(err)}). Nothing was requested; retry once the backend answers.`),
278
+ };
279
+ }
280
+ return { inFlight: [], problem: errorText(err) };
281
+ }
282
+ }
283
+ const ticketOf = (r, ticket) => ({
284
+ ticketId: ticket.id,
285
+ databaseName: r.name,
286
+ apiEndpoint: r.apiEndpoint,
287
+ userId: r.ownerId,
288
+ attempts: 0,
289
+ });
290
+ function startPrewarm(r, exec) {
291
+ // The packages/app scaffold + install are database-INDEPENDENT, so they run
292
+ // alongside the provision. Best-effort: run_codegen re-runs the same
293
+ // idempotent steps. A call that returns early leaves it running; a retry
294
+ // joins that same run rather than starting a second install.
295
+ return (0, app_workspace_1.prewarmAppWorkspace)(r.cwd, exec).catch((err) => ({ ok: false, out: errorText(err) }));
296
+ }
297
+ async function requestAndBind(r, input, env, replaced, exec) {
298
+ const abandoned = (0, pending_ticket_1.abandonedTickets)(env);
299
+ const abandon = async (ids) => {
300
+ for (const id of ids)
301
+ abandoned.add(id);
302
+ await updateEnvFile(r.envPath, (content) => (0, pending_ticket_1.recordAbandoned)((0, pending_ticket_1.clearPendingTicket)(content), ids)).catch(noop);
303
+ };
304
+ // A ticket an earlier call stopped waiting on.
305
+ let current;
306
+ let recovered = false;
307
+ const recorded = (0, pending_ticket_1.recordedTicket)(env);
308
+ if (recorded) {
309
+ if (recorded.apiEndpoint !== r.apiEndpoint || recorded.userId !== r.ownerId) {
310
+ // Requested on another backend or by another account: not ours to follow.
311
+ await updateEnvFile(r.envPath, (content) => (0, pending_ticket_1.archivePendingTicket)(content, today())).catch(noop);
312
+ }
313
+ else if (input.abandonPending) {
314
+ await abandon([recorded.ticketId]);
315
+ }
316
+ else if (recorded.databaseName !== r.name) {
317
+ return failure('pending-other', `A provision of "${recorded.databaseName}" (ticket ${recorded.ticketId}) is still recorded for this project. Call provision_database with database_name "${recorded.databaseName}" to finish it; another name would start a second database.`, { ticketId: recorded.ticketId });
318
+ }
319
+ else {
320
+ current = recorded;
321
+ }
322
+ }
323
+ // No ticket of ours is recorded, but an earlier call may have lost track of
324
+ // one (its record failed to write, or the call was cut off): look it up by
325
+ // name. Only an in-flight one is resumed.
326
+ if (!current) {
327
+ const lookup = await findInFlight(r, abandoned);
328
+ if (lookup.refusal)
329
+ return lookup.refusal;
330
+ if (lookup.inFlight.length > 0 && input.abandonPending) {
331
+ await abandon(lookup.inFlight.map((t) => t.id));
332
+ }
333
+ else if (lookup.inFlight.length > 0) {
334
+ current = ticketOf(r, lookup.inFlight[0]);
335
+ recovered = true;
336
+ }
337
+ }
338
+ const prewarm = startPrewarm(r, exec);
339
+ // Resolve the module set: pinned base preset from node-type-registry, then
340
+ // ordered overlays (host default < project-local provision.json). A bad
341
+ // preset/manifest fails the run rather than provisioning a wrong module set.
342
+ let modules;
343
+ try {
344
+ const layers = [];
345
+ const hostOverlay = await r.host.provisionOverlay?.();
346
+ if (hostOverlay)
347
+ layers.push(hostOverlay);
348
+ const projectOverlay = await (0, manifest_1.loadProvisionManifest)(r.cwd);
349
+ if (projectOverlay)
350
+ layers.push(projectOverlay);
351
+ modules = (0, resolve_1.resolveProvisionModules)(layers);
352
+ }
353
+ catch (err) {
354
+ return failure('failed', `Cannot resolve provision modules: ${errorText(err)}`);
355
+ }
356
+ // requestDatabase claims a warm-pool database when the module set matches a
357
+ // cataloged preset and cold-provisions otherwise; the ticket is polled on the
358
+ // modules endpoint until the database AND its owner bootstrap complete. An
359
+ // explicit subdomain (= name) keeps the per-DB endpoints deterministic.
360
+ const ticketFor = (ticketId) => ({
361
+ ticketId,
362
+ databaseName: r.name,
363
+ apiEndpoint: r.apiEndpoint,
364
+ userId: r.ownerId,
365
+ attempts: (current?.ticketId === ticketId ? current.attempts : 0) + 1,
366
+ });
367
+ const lost = new Set();
368
+ let provisioned;
369
+ for (;;) {
370
+ try {
371
+ provisioned = await (0, request_database_1.requestDatabaseProvision)({
372
+ apiEndpoint: r.apiEndpoint,
373
+ modulesEndpoint: r.modulesEndpoint,
374
+ bearer: r.bearer,
375
+ databaseName: r.name,
376
+ domain: provisionDomain(r.apiEndpoint),
377
+ request: (0, preset_match_1.selectProvisionRequest)(modules),
378
+ executor: r.executor,
379
+ // Past the attempt cap one read says whether it finished; no more waiting.
380
+ pollDeadlineMs: current && current.attempts >= pending_ticket_1.MAX_PENDING_ATTEMPTS ? 0 : pollDeadline(r.host.provisionPollDeadlineMs),
381
+ resumeTicketId: current?.ticketId,
382
+ });
383
+ break;
384
+ }
385
+ catch (err) {
386
+ if (err instanceof request_database_1.ProvisionPendingError) {
387
+ return pendingResult(r, ticketFor(err.ticketId), err.message, recovered);
388
+ }
389
+ if (err instanceof request_database_1.ProvisionTicketLostError) {
390
+ // The ticket can't be read back, which doesn't mean the provision
391
+ // stopped. Follow another in-flight one of this name, or stop without
392
+ // requesting anything.
393
+ lost.add(err.ticketId);
394
+ const lookup = await findInFlight(r, new Set([...abandoned, ...lost]));
395
+ if (lookup.refusal)
396
+ return lookup.refusal;
397
+ if (lookup.inFlight.length > 0) {
398
+ current = ticketOf(r, lookup.inFlight[0]);
399
+ recovered = true;
400
+ continue;
401
+ }
402
+ await updateEnvFile(r.envPath, pending_ticket_1.clearPendingTicket).catch(noop);
403
+ const checked = lookup.problem
404
+ ? `the backend couldn't be asked for another one (${lookup.problem})`
405
+ : 'no other provision of that name is in progress';
406
+ return failure('ticket-lost', `The provision of "${r.name}" (ticket ${err.ticketId}) can no longer be found on this backend, and ${checked}. Nothing was created, and the record is cleared, so calling provision_database again requests a new database.`, { ticketId: err.ticketId });
407
+ }
408
+ // A definitive failure ends the recorded request, so forget it.
409
+ if (current)
410
+ await updateEnvFile(r.envPath, pending_ticket_1.clearPendingTicket).catch(noop);
411
+ return requestFailure(r, err);
412
+ }
413
+ }
414
+ return bindDatabase(r, {
415
+ databaseId: provisioned.databaseId,
416
+ ticket: ticketFor(provisioned.ticketId),
417
+ resumed: provisioned.resumed === true,
418
+ recovered,
419
+ replaced,
420
+ }, prewarm);
421
+ }
422
+ // The provision is still running: record its ticket so the next call with the
423
+ // same name resumes it. Past MAX_PENDING_ATTEMPTS the ticket stays recorded
424
+ // and the call fails `provision-stuck`: nothing new is requested while it is
425
+ // in flight, until the host abandons it.
426
+ async function pendingResult(r, ticket, reason, recovered) {
427
+ const { ticketId } = ticket;
428
+ let recordedOk = true;
429
+ try {
430
+ await updateEnvFile(r.envPath, (content) => (0, pending_ticket_1.recordPendingTicket)(content, ticket));
431
+ }
432
+ catch {
433
+ recordedOk = false;
434
+ }
435
+ if (ticket.attempts > pending_ticket_1.MAX_PENDING_ATTEMPTS) {
436
+ return failure('provision-stuck', `Database "${r.name}" is still not ready after ${pending_ticket_1.MAX_PENDING_ATTEMPTS} waits (${reason}). Its request (ticket ${ticketId}) stays recorded, and no other database is requested while it is in progress. Check the backend's provisioning jobs.`, { ticketId });
437
+ }
438
+ const again = `call provision_database again with database_name "${r.name}"${r.mode === 'replace' && r.viaTool ? ' and reprovision: true' : ''}`;
439
+ const message = recordedOk
440
+ ? `Database "${r.name}" is still provisioning (${reason}). The request is recorded in .env; ${again} to keep waiting on it. Don't change the name: that starts a second database.`
441
+ : `Database "${r.name}" is still provisioning (${reason}). Recording its ticket in .env failed, but the backend still has it: ${again} to keep waiting on it. Don't change the name: that starts a second database.`;
442
+ return { success: false, message, pending: true, ticketId, ...(recovered ? { recovered: true } : {}) };
443
+ }
444
+ const NAME_TAKEN_RE = /already exists|already taken|already in use|duplicate/i;
445
+ async function requestFailure(r, err) {
446
+ const detail = errorText(err);
447
+ if (!NAME_TAKEN_RE.test(detail))
448
+ return failure('failed', `Database provisioning failed: ${detail}.`);
449
+ const heldBy = await ownHolder(r);
450
+ if (heldBy) {
451
+ const when = heldBy.createdAt ? `, requested ${heldBy.createdAt}` : '';
452
+ return failure('name-taken', `Database provisioning failed: ${detail}. The name "${r.name}" belongs to a database this account provisioned earlier (ID ${heldBy.databaseId}${when}) that this project isn't bound to, so it was not bound. Re-run with a different database_name.`, { heldBy });
453
+ }
454
+ return failure('name-taken', `Database provisioning failed: ${detail}. The database name is already taken on the backend; re-run with a different database_name.`);
455
+ }
456
+ // A completed provision of this name by this account whose database still
457
+ // exists under it: reported with name-taken so the host can adopt it.
458
+ async function ownHolder(r) {
459
+ let tickets;
460
+ try {
461
+ tickets = await r.executor.listOwn({ databaseName: r.name, ownerId: r.ownerId });
462
+ }
463
+ catch {
464
+ return undefined;
465
+ }
466
+ const done = tickets.find((t) => t.status === 'completed' && t.databaseId && !(0, request_database_1.isTicketInFlight)(t));
467
+ if (!done)
468
+ return undefined;
469
+ const probe = await r.probe(done.databaseId);
470
+ if (probe.status !== 'found' && probe.status !== 'suspended')
471
+ return undefined;
472
+ if (probe.ownerId && probe.ownerId !== r.ownerId)
473
+ return undefined;
474
+ return { databaseId: done.databaseId, yours: true, ...(done.createdAt ? { createdAt: done.createdAt } : {}) };
475
+ }
476
+ // ---- binding ---------------------------------------------------------------
477
+ async function saveBinding(r, binding, ticket) {
478
+ if (!r.host.persistBinding)
479
+ return null;
480
+ try {
481
+ await r.host.persistBinding(binding);
482
+ return null;
483
+ }
484
+ catch (err) {
485
+ // Keep the ticket recorded, so the next call resumes it and binds it.
486
+ if (ticket)
487
+ await updateEnvFile(r.envPath, (content) => (0, pending_ticket_1.recordPendingTicket)(content, ticket)).catch(noop);
488
+ return failure('binding-not-saved', `Database "${binding.databaseName}" (ID ${binding.databaseId}) is ready, but the app couldn't save the binding (${errorText(err)}), so .env was left unchanged.${ticket ? ' Calling provision_database again with the same name binds it.' : ''}`, { databaseId: binding.databaseId, ...(ticket ? { ticketId: ticket.ticketId } : {}) });
489
+ }
490
+ }
491
+ async function bindDatabase(r, bind, prewarm) {
492
+ const { databaseId, ticket } = bind;
493
+ // Bind only a database this account owns. An unreachable backend keeps the
494
+ // ticket for the next call to check again.
495
+ const owner = await r.probe(databaseId);
496
+ if (owner.status === 'unreachable') {
497
+ if (ticket) {
498
+ return pendingResult(r, ticket, `provisioned, but its owner couldn't be confirmed: ${owner.detail}`, bind.recovered);
499
+ }
500
+ return failure('backend-unreachable', `Cannot verify database ${databaseId}: ${owner.detail}. Retry once the backend answers.`);
501
+ }
502
+ const visible = owner.status !== 'missing';
503
+ if (!visible || owner.ownerId !== r.ownerId) {
504
+ if (ticket)
505
+ await updateEnvFile(r.envPath, pending_ticket_1.clearPendingTicket).catch(noop);
506
+ const why = visible ? "it isn't owned by" : "it isn't visible to";
507
+ if (bind.adopted) {
508
+ return failure(visible ? 'account-mismatch' : 'db-missing', `Database ${databaseId} was not bound: ${why} the signed-in account.`);
509
+ }
510
+ return failure('failed', `Database provisioning returned database ${databaseId}, but ${why} the signed-in account, so it was not bound. Call provision_database again to request a new database.`);
511
+ }
512
+ if (bind.adopted && owner.status === 'suspended') {
513
+ return failure('db-suspended', suspendedText(databaseId, owner));
514
+ }
515
+ const saved = await saveBinding(r, {
516
+ cwd: r.cwd,
517
+ databaseId,
518
+ databaseName: r.name,
519
+ ownerId: r.ownerId,
520
+ apiEndpoint: r.apiEndpoint,
521
+ modulesEndpoint: r.modulesEndpoint,
522
+ ...(bind.replaced ? { replaces: bind.replaced } : {}),
523
+ }, ticket);
524
+ if (saved)
525
+ return saved;
526
+ // Write the binding into .env (upsert, preserving other keys), dropping any
527
+ // recorded pending request: the project is bound now.
528
+ try {
529
+ await updateEnvFile(r.envPath, (content) => (0, env_file_1.mergeEnv)((0, pending_ticket_1.clearPendingTicket)(bind.replaced ? (0, env_file_1.archiveBindingKeys)(content, env_file_1.ARCHIVED_BINDING_KEYS, today()) : content), (0, env_file_1.provisionEnvVars)({
530
+ databaseId,
531
+ databaseName: r.name,
532
+ ownerId: r.ownerId,
533
+ accessToken: r.bearer,
534
+ apiEndpoint: r.apiEndpoint,
535
+ modulesEndpoint: r.modulesEndpoint,
536
+ })));
537
+ }
538
+ catch (err) {
539
+ return failure('env-write-failed', `Database provisioned (ID: ${databaseId}) but writing .env failed: ${errorText(err)}`, { databaseId });
540
+ }
541
+ // Settle the concurrent prewarm so the scaffold + install are in place before
542
+ // run_codegen runs. A failure here is non-fatal: run_codegen redoes the work.
543
+ const prewarmed = (await prewarm).ok ? ' packages/app prewarmed.' : '';
544
+ const notes = [
545
+ bind.resumed ? ' Finished the provision an earlier call had started.' : '',
546
+ bind.recovered ? ' Found it on the backend by name: its ticket was no longer recorded.' : '',
547
+ prewarmed,
548
+ bind.replaced
549
+ ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.'
550
+ : '',
551
+ ].join('');
552
+ const message = bind.adopted
553
+ ? `Bound database "${r.name}" (ID: ${databaseId}), which this account provisioned earlier. Credentials written to .env.${notes}`
554
+ : `Provisioned database "${r.name}" (ID: ${databaseId}). Credentials written to .env.${notes}`;
555
+ return {
556
+ success: true,
557
+ message,
558
+ databaseId,
559
+ databaseName: r.name,
560
+ ownerId: r.ownerId,
561
+ ...(bind.resumed ? { resumed: true } : {}),
562
+ ...(bind.recovered ? { recovered: true } : {}),
563
+ ...(bind.replaced ? { replaced: bind.replaced } : {}),
564
+ };
565
+ }
@@ -16,10 +16,12 @@ export type ProvisionTicket = {
16
16
  databaseId?: string | null;
17
17
  errorMessage?: string | null;
18
18
  bootstrapError?: string | null;
19
+ databaseName?: string | null;
20
+ createdAt?: string | null;
19
21
  };
20
22
  /**
21
- * Request + poll seam, injectable for tests (pattern: db-probe's
22
- * ProbeExecutor). The default executor drives the SDK clients.
23
+ * Request, poll and lookup seam, injectable for tests. The default executor
24
+ * drives the SDK clients.
23
25
  */
24
26
  export type ProvisionRequestExecutor = {
25
27
  request(input: {
@@ -29,6 +31,11 @@ export type ProvisionRequestExecutor = {
29
31
  request: ProvisionRequest;
30
32
  }): Promise<ProvisionTicket>;
31
33
  poll(ticketId: string): Promise<ProvisionTicket | null>;
34
+ /** The account's newest tickets for a database name, newest first. */
35
+ listOwn(filter: {
36
+ databaseName: string;
37
+ ownerId: string;
38
+ }): Promise<ProvisionTicket[]>;
32
39
  };
33
40
  export declare const POLL_INTERVAL_MS = 2000;
34
41
  /** Stays under airpage's 5-minute per-tool timeout. */
@@ -39,12 +46,54 @@ export declare const POLL_DEADLINE_MS = 240000;
39
46
  * a creation that is about to succeed.
40
47
  */
41
48
  export declare const MAX_CONSECUTIVE_POLL_FAILURES = 3;
49
+ /**
50
+ * The provision is still running server-side, but this call stopped waiting:
51
+ * the deadline passed, or the backend couldn't be reached. A caller should keep
52
+ * `ticketId` and pass it back as `resumeTicketId`, because requesting the same
53
+ * name again starts a second provision rather than resuming this one.
54
+ */
55
+ export declare class ProvisionPendingError extends Error {
56
+ readonly ticketId: string;
57
+ constructor(message: string, ticketId: string, options?: {
58
+ cause?: unknown;
59
+ });
60
+ }
61
+ /**
62
+ * The recorded ticket can't be read back: it is gone, or this account can't
63
+ * see it. Nothing says the provision stopped, so a caller must not request
64
+ * the same database again on this alone.
65
+ */
66
+ export declare class ProvisionTicketLostError extends Error {
67
+ readonly ticketId: string;
68
+ constructor(message: string, ticketId: string);
69
+ }
70
+ export declare function createProvisionExecutor(args: {
71
+ apiEndpoint: string;
72
+ modulesEndpoint: string;
73
+ bearer: string;
74
+ }): ProvisionRequestExecutor;
75
+ /** A ticket whose provision or owner bootstrap is still running. */
76
+ export declare function isTicketInFlight(ticket: ProvisionTicket): boolean;
77
+ /**
78
+ * Whether an error reading or requesting a ticket says only that the backend
79
+ * couldn't be reached or failed: a network error thrown before any response,
80
+ * or an HTTP 5xx/429 the adapter reports as the only error. A GraphQL error
81
+ * (validation, permission, an expired or rejected token) is an answer, so the
82
+ * ticket can't be followed any further.
83
+ */
84
+ export declare function isTransientProvisionError(err: unknown): boolean;
42
85
  /**
43
86
  * Request a database and resolve only when it is fully usable: ticket
44
87
  * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation
45
88
  * returns is checked before the first sleep, so a warm claim with bootstrap
46
89
  * already done never waits. `status: 'failed'` surfaces `errorMessage`;
47
- * `bootstrapStatus: 'failed'` surfaces `bootstrapError`.
90
+ * `bootstrapStatus: 'failed'` surfaces `bootstrapError`. Stopping short of
91
+ * either outcome throws `ProvisionPendingError`.
92
+ *
93
+ * With `resumeTicketId` the ticket of an earlier call is polled instead of
94
+ * requesting again, and `resumed` is true. A ticket that reads back empty
95
+ * throws `ProvisionTicketLostError`, never a new request: the provision may
96
+ * still be running where this account can't see it.
48
97
  */
49
98
  export declare function requestDatabaseProvision(args: {
50
99
  apiEndpoint: string;
@@ -56,6 +105,9 @@ export declare function requestDatabaseProvision(args: {
56
105
  executor?: ProvisionRequestExecutor;
57
106
  pollIntervalMs?: number;
58
107
  pollDeadlineMs?: number;
108
+ resumeTicketId?: string;
59
109
  }): Promise<{
60
110
  databaseId: string;
111
+ ticketId: string;
112
+ resumed?: true;
61
113
  }>;