@agentic-kit/pi 0.10.6 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -38,6 +38,12 @@ const dbTools = createDbTools({
38
38
  pi.use(dbTools);
39
39
  ```
40
40
 
41
+ ## Provisioning
42
+
43
+ `provision_database` requests a database through the `requestDatabase` mutation on the api endpoint. When the requested module set matches a cataloged preset, the backend claims a warm pre-baked database in seconds. Otherwise a background job provisions the database cold. The tool polls the provision ticket on the modules endpoint until the database and its owner bootstrap are complete. Then it writes the credentials to the project `.env` and returns.
44
+
45
+ For local development, the cold path and the warm pool depend on the backend's jobs worker. Start the worker in `constructive-db` with `pnpm dev:fn`. If the worker is not running, cold tickets stay `pending` and the tool times out after 240 seconds.
46
+
41
47
  ## Host contract
42
48
 
43
49
  `PiToolsHost` is the only integration surface:
@@ -0,0 +1,13 @@
1
+ import { type ProvisionModule } from './presets';
2
+ import type { ProvisionRequest } from './request-database';
3
+ /**
4
+ * The slug of the registry preset whose module set equals `resolved` under
5
+ * the backend's hash semantics, or undefined when no preset matches.
6
+ */
7
+ export declare function matchPresetSlug(resolved: ProvisionModule[]): string | undefined;
8
+ /**
9
+ * Build the requestDatabase input branch: presetSlug when the resolved set
10
+ * equals a registry preset (warm-pool eligible), else the explicit modules
11
+ * array — never both.
12
+ */
13
+ export declare function selectProvisionRequest(resolved: ProvisionModule[]): ProvisionRequest;
@@ -0,0 +1,66 @@
1
+ // Client-side mirror of the backend's modules_hash normalization
2
+ // (constructive-db: metaschema_private.modules_hash), used to decide whether a
3
+ // resolved module set can be requested by preset slug — the warm-pool-eligible
4
+ // path — or must be sent as an explicit modules array:
5
+ // 1. A bare module name normalizes to [name, {}]; a missing/null options
6
+ // slot normalizes to {}.
7
+ // 2. Options are compared verbatim after canonicalization (object keys
8
+ // sorted at every level) — no default-value normalization.
9
+ // 3. Entries are sorted so the caller's ordering never affects the match.
10
+ // 4. Duplicate normalized entries never match (the backend rejects them).
11
+ // Only equality within this process matters, so string sort order does not
12
+ // need to replicate PostgreSQL collation.
13
+ //
14
+ // The registry presets are the canonical definitions; the backend's
15
+ // db_presets catalog is synced from them. A slug match assumes that sync —
16
+ // preset drift would provision the backend's version of the set.
17
+ import { allModulePresets } from './presets';
18
+ function canonicalize(value) {
19
+ if (Array.isArray(value))
20
+ return value.map(canonicalize);
21
+ if (value && typeof value === 'object') {
22
+ const sorted = {};
23
+ for (const key of Object.keys(value).sort()) {
24
+ sorted[key] = canonicalize(value[key]);
25
+ }
26
+ return sorted;
27
+ }
28
+ return value;
29
+ }
30
+ function normalizedEntries(list) {
31
+ const entries = list.map((m) => {
32
+ const name = Array.isArray(m) ? m[0] : m;
33
+ const options = Array.isArray(m) ? (m[1] ?? {}) : {};
34
+ return JSON.stringify([name, canonicalize(options)]);
35
+ });
36
+ entries.sort();
37
+ for (let i = 1; i < entries.length; i++) {
38
+ if (entries[i] === entries[i - 1])
39
+ return null;
40
+ }
41
+ return entries;
42
+ }
43
+ /**
44
+ * The slug of the registry preset whose module set equals `resolved` under
45
+ * the backend's hash semantics, or undefined when no preset matches.
46
+ */
47
+ export function matchPresetSlug(resolved) {
48
+ const target = normalizedEntries(resolved)?.join('\n');
49
+ if (!target)
50
+ return undefined;
51
+ for (const preset of allModulePresets) {
52
+ if (normalizedEntries(preset.modules)?.join('\n') === target) {
53
+ return preset.name;
54
+ }
55
+ }
56
+ return undefined;
57
+ }
58
+ /**
59
+ * Build the requestDatabase input branch: presetSlug when the resolved set
60
+ * equals a registry preset (warm-pool eligible), else the explicit modules
61
+ * array — never both.
62
+ */
63
+ export function selectProvisionRequest(resolved) {
64
+ const presetSlug = matchPresetSlug(resolved);
65
+ return presetSlug ? { presetSlug } : { modules: resolved };
66
+ }
@@ -0,0 +1,61 @@
1
+ import type { ProvisionModule } from './presets';
2
+ /**
3
+ * Exactly one of presetSlug or modules — requestDatabase rejects both/neither
4
+ * (REQUEST_DATABASE_INVALID_INPUT). `selectProvisionRequest` (preset-match.ts)
5
+ * builds this from the resolved module set.
6
+ */
7
+ export type ProvisionRequest = {
8
+ presetSlug: string;
9
+ } | {
10
+ modules: ProvisionModule[];
11
+ };
12
+ export type ProvisionTicket = {
13
+ id?: string | null;
14
+ status?: string | null;
15
+ bootstrapStatus?: string | null;
16
+ databaseId?: string | null;
17
+ errorMessage?: string | null;
18
+ bootstrapError?: string | null;
19
+ };
20
+ /**
21
+ * Request + poll seam, injectable for tests (pattern: db-probe's
22
+ * ProbeExecutor). The default executor drives the SDK clients.
23
+ */
24
+ export type ProvisionRequestExecutor = {
25
+ request(input: {
26
+ databaseName: string;
27
+ subdomain: string;
28
+ domain: string;
29
+ request: ProvisionRequest;
30
+ }): Promise<ProvisionTicket>;
31
+ poll(ticketId: string): Promise<ProvisionTicket | null>;
32
+ };
33
+ export declare const POLL_INTERVAL_MS = 2000;
34
+ /** Stays under airpage's 5-minute per-tool timeout. */
35
+ export declare const POLL_DEADLINE_MS = 240000;
36
+ /**
37
+ * Consecutive unreadable polls tolerated before giving up. Provisioning keeps
38
+ * running server-side, so a dropped request or a momentary 502 must not abort
39
+ * a creation that is about to succeed.
40
+ */
41
+ export declare const MAX_CONSECUTIVE_POLL_FAILURES = 3;
42
+ /**
43
+ * Request a database and resolve only when it is fully usable: ticket
44
+ * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation
45
+ * returns is checked before the first sleep, so a warm claim with bootstrap
46
+ * already done never waits. `status: 'failed'` surfaces `errorMessage`;
47
+ * `bootstrapStatus: 'failed'` surfaces `bootstrapError`.
48
+ */
49
+ export declare function requestDatabaseProvision(args: {
50
+ apiEndpoint: string;
51
+ modulesEndpoint: string;
52
+ bearer: string;
53
+ databaseName: string;
54
+ domain: string;
55
+ request: ProvisionRequest;
56
+ executor?: ProvisionRequestExecutor;
57
+ pollIntervalMs?: number;
58
+ pollDeadlineMs?: number;
59
+ }): Promise<{
60
+ databaseId: string;
61
+ }>;
@@ -0,0 +1,130 @@
1
+ // Provision a database through the api endpoint's requestDatabase mutation —
2
+ // the pool-aware entry point. The mutation returns a ticket (a
3
+ // database_provision_module row) immediately: a warm-pool claim arrives
4
+ // 'completed' with the deferred owner bootstrap still pending; a cold request
5
+ // arrives 'pending' and is fulfilled asynchronously by the backend's jobs
6
+ // worker. The ticket is polled on the MODULES endpoint (the api endpoint has
7
+ // no ticket query) until BOTH status and bootstrapStatus are 'completed': pi
8
+ // hands credentials to the agent as soon as this resolves, and until owner
9
+ // bootstrap completes the new database has zero users, so its per-DB API
10
+ // would reject the owner token under RLS. requestDatabase owns the database
11
+ // to the JWT user — there is no owner input. The generated ORM's default
12
+ // adapter is plain fetch, so the SDK's FetchAdapter is injected to keep
13
+ // *.localhost DNS/Host routing working in Node.
14
+ import { api, modules } from '@constructive-io/sdk';
15
+ export const POLL_INTERVAL_MS = 2_000;
16
+ /** Stays under airpage's 5-minute per-tool timeout. */
17
+ export const POLL_DEADLINE_MS = 240_000;
18
+ /**
19
+ * Consecutive unreadable polls tolerated before giving up. Provisioning keeps
20
+ * running server-side, so a dropped request or a momentary 502 must not abort
21
+ * a creation that is about to succeed.
22
+ */
23
+ export const MAX_CONSECUTIVE_POLL_FAILURES = 3;
24
+ const TICKET_SELECT = {
25
+ id: true,
26
+ status: true,
27
+ bootstrapStatus: true,
28
+ databaseId: true,
29
+ errorMessage: true,
30
+ bootstrapError: true,
31
+ };
32
+ function createSdkExecutor(args) {
33
+ const headers = { Authorization: `Bearer ${args.bearer}` };
34
+ const apiClient = api.createClient({ adapter: new api.FetchAdapter(args.apiEndpoint, headers) });
35
+ const modulesClient = modules.createClient({
36
+ adapter: new api.FetchAdapter(args.modulesEndpoint, headers),
37
+ });
38
+ return {
39
+ async request(input) {
40
+ const requestInput = {
41
+ databaseName: input.databaseName,
42
+ subdomain: input.subdomain,
43
+ domain: input.domain,
44
+ ...('presetSlug' in input.request
45
+ ? { presetSlug: input.request.presetSlug }
46
+ : { modules: input.request.modules }),
47
+ };
48
+ const result = await apiClient.mutation
49
+ .requestDatabase({ input: requestInput }, { select: { result: { select: TICKET_SELECT } } })
50
+ .unwrap();
51
+ const ticket = result.requestDatabase?.result;
52
+ if (!ticket)
53
+ throw new Error('requestDatabase returned no provision ticket.');
54
+ return ticket;
55
+ },
56
+ async poll(ticketId) {
57
+ const result = await modulesClient.databaseProvisionModule
58
+ .findOne({ id: ticketId, select: TICKET_SELECT })
59
+ .unwrap();
60
+ return result.databaseProvisionModule ?? null;
61
+ },
62
+ };
63
+ }
64
+ function sleep(ms) {
65
+ return new Promise((resolve) => setTimeout(resolve, ms));
66
+ }
67
+ /**
68
+ * Request a database and resolve only when it is fully usable: ticket
69
+ * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation
70
+ * returns is checked before the first sleep, so a warm claim with bootstrap
71
+ * already done never waits. `status: 'failed'` surfaces `errorMessage`;
72
+ * `bootstrapStatus: 'failed'` surfaces `bootstrapError`.
73
+ */
74
+ export async function requestDatabaseProvision(args) {
75
+ const executor = args.executor ??
76
+ createSdkExecutor({
77
+ apiEndpoint: args.apiEndpoint,
78
+ modulesEndpoint: args.modulesEndpoint,
79
+ bearer: args.bearer,
80
+ });
81
+ const intervalMs = args.pollIntervalMs ?? POLL_INTERVAL_MS;
82
+ const deadlineMs = args.pollDeadlineMs ?? POLL_DEADLINE_MS;
83
+ let ticket = await executor.request({
84
+ databaseName: args.databaseName,
85
+ subdomain: args.databaseName,
86
+ domain: args.domain,
87
+ request: args.request,
88
+ });
89
+ const ticketId = ticket.id;
90
+ if (!ticketId)
91
+ throw new Error('requestDatabase returned a provision ticket without an id.');
92
+ const deadline = Date.now() + deadlineMs;
93
+ let failures = 0;
94
+ for (;;) {
95
+ if (ticket.status === 'failed') {
96
+ throw new Error(ticket.errorMessage ?? 'provisioning failed');
97
+ }
98
+ if (ticket.bootstrapStatus === 'failed') {
99
+ throw new Error(ticket.bootstrapError ?? 'owner bootstrap failed');
100
+ }
101
+ if (ticket.status === 'completed' && ticket.bootstrapStatus === 'completed') {
102
+ if (!ticket.databaseId)
103
+ throw new Error('provisioning completed but returned no databaseId.');
104
+ return { databaseId: ticket.databaseId };
105
+ }
106
+ if (Date.now() >= deadline) {
107
+ throw new Error(`provisioning timed out after ${Math.round(deadlineMs / 1000)}s (status: ${ticket.status ?? 'unknown'}, bootstrapStatus: ${ticket.bootstrapStatus ?? 'unknown'})`);
108
+ }
109
+ await sleep(intervalMs);
110
+ // A poll that throws (network blip, 502) or comes back empty is retried a
111
+ // few times rather than failing a provision that is still running.
112
+ let polled;
113
+ try {
114
+ polled = await executor.poll(ticketId);
115
+ }
116
+ catch (err) {
117
+ if (++failures > MAX_CONSECUTIVE_POLL_FAILURES)
118
+ throw err;
119
+ continue;
120
+ }
121
+ if (!polled) {
122
+ if (++failures > MAX_CONSECUTIVE_POLL_FAILURES) {
123
+ throw new Error(`provision ticket ${ticketId} could not be read back.`);
124
+ }
125
+ continue;
126
+ }
127
+ failures = 0;
128
+ ticket = polled;
129
+ }
130
+ }
@@ -5,11 +5,12 @@ import { z } from 'zod';
5
5
  import { prewarmAppWorkspace } from '../app-workspace';
6
6
  import { probeDatabase } from '../db-probe';
7
7
  import { getHost } from '../host';
8
- import { createDatabaseProvision } from '../provision-database/create-database-provision';
9
8
  import { selectProvisionCredential } from '../provision-database/credential';
10
9
  import { archiveBindingKeys, ARCHIVED_BINDING_KEYS, mergeEnv, provisionEnvVars, } from '../provision-database/env-file';
11
10
  import { loadProvisionManifest } from '../provision-database/manifest';
12
11
  import { applySqlFixups } from '../provision-database/pg-fixups';
12
+ import { selectProvisionRequest } from '../provision-database/preset-match';
13
+ import { requestDatabaseProvision } from '../provision-database/request-database';
13
14
  import { resolveProvisionModules } from '../provision-database/resolve';
14
15
  import { toolSchema } from '../tool-schema';
15
16
  const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
@@ -58,22 +59,6 @@ function parseEnvKeys(source) {
58
59
  }
59
60
  return env;
60
61
  }
61
- async function withRetry(fn, maxRetries = 5, delayMs = 2000) {
62
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
63
- try {
64
- return await fn();
65
- }
66
- catch (err) {
67
- const msg = err instanceof Error ? err.message : String(err);
68
- if (msg.includes('already exists') || msg.includes('exists'))
69
- throw err;
70
- if (attempt === maxRetries)
71
- throw err;
72
- await new Promise((r) => setTimeout(r, delayMs));
73
- }
74
- }
75
- throw new Error('unreachable');
76
- }
77
62
  export const provisionDatabaseTool = {
78
63
  name: 'provision_database',
79
64
  label: 'Provision database',
@@ -100,9 +85,10 @@ export const provisionDatabaseTool = {
100
85
  const backend = host.backendConfig();
101
86
  const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
102
87
  const modulesEndpoint = process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT;
103
- // Every new project provisions UNDER the account (owner_id = the account user),
104
- // so the database is account-owned and enumerable. The account bearer is the
105
- // single credential: it authenticates the provision mutation, the idempotency
88
+ // Every new project provisions UNDER the account (requestDatabase owns the
89
+ // database to the JWT user), so it is account-owned and enumerable. The
90
+ // account bearer is the single credential: it authenticates the provision
91
+ // mutation, the idempotency
106
92
  // probe below, and is the ACCESS_TOKEN written to .env for project-local
107
93
  // scripts. It expires with the login session — a relogin plus any provision
108
94
  // run (including the skip path) refreshes the .env copy. A signed-in session
@@ -180,7 +166,8 @@ export const provisionDatabaseTool = {
180
166
  existing = archiveBindingKeys(existing, ARCHIVED_BINDING_KEYS, new Date().toISOString().slice(0, 10));
181
167
  }
182
168
  // The packages/app scaffold + pnpm install are database-INDEPENDENT, so kick
183
- // them off now to run concurrently with the ~90s provisioning mutation below.
169
+ // them off now to run concurrently with the provisioning request below
170
+ // (near-instant on a warm-pool hit, up to minutes on the cold path).
184
171
  // This keeps clone + install off run_codegen's critical path. Best-effort:
185
172
  // run_codegen re-runs the same idempotent steps, so a prewarm failure is
186
173
  // harmless. Never let it reject (we await it before returning).
@@ -207,21 +194,25 @@ export const provisionDatabaseTool = {
207
194
  return fail(`Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`);
208
195
  }
209
196
  const physicalDb = process.env.CONSTRUCTIVE_DB || 'constructive';
210
- // Provision on the MODULES endpoint: createDatabaseProvisionModule creates
211
- // the database owned by ownerId, provisions the module set, and bootstraps
212
- // the owner into it before the row returns. The domain still derives from
213
- // the api endpoint's host (per-DB endpoints live under it), and an explicit
214
- // subdomain (= databaseName) keeps them deterministic (SUBDOMAIN-001).
197
+ // Provision on the API endpoint: requestDatabase claims a warm-pool
198
+ // database when the resolved module set matches a cataloged preset
199
+ // (near-instant) and cold-provisions asynchronously otherwise; the ticket
200
+ // is polled on the modules endpoint until the database AND its deferred
201
+ // owner bootstrap complete. No retry wrapper: a pending ticket is the
202
+ // normal first response, and transient poll failures are absorbed inside
203
+ // the poll loop. The domain still derives from the api endpoint's host
204
+ // (per-DB endpoints live under it), and an explicit subdomain
205
+ // (= databaseName) keeps them deterministic (SUBDOMAIN-001).
215
206
  let databaseId;
216
207
  try {
217
- ({ databaseId } = await withRetry(() => createDatabaseProvision({
218
- endpoint: modulesEndpoint,
208
+ ({ databaseId } = await requestDatabaseProvision({
209
+ apiEndpoint,
210
+ modulesEndpoint,
219
211
  bearer: credential.bearer,
220
212
  databaseName,
221
213
  domain: provisionDomain(apiEndpoint),
222
- ownerId,
223
- modules,
224
- })));
214
+ request: selectProvisionRequest(modules),
215
+ }));
225
216
  }
226
217
  catch (err) {
227
218
  const detail = err instanceof Error ? err.message : String(err);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentic-kit/pi",
3
- "version": "0.10.6",
3
+ "version": "0.11.1",
4
4
  "author": "Dan Lynch <pyramation@gmail.com>",
5
5
  "description": "Pi adapter for agentic-kit — the Constructive typed db tools (provision, blueprint, codegen, records, policies, templates) and confirm gate as a pi coding-agent extension",
6
6
  "main": "index.js",
@@ -30,9 +30,9 @@
30
30
  },
31
31
  "dependencies": {
32
32
  "@agentic-kit/harness": "^0.10.1",
33
- "@constructive-io/graphql-query": "^4.12.9",
34
- "@constructive-io/sdk": "^1.11.9",
35
- "node-type-registry": "^1.14.3",
33
+ "@constructive-io/graphql-query": "^4.12.10",
34
+ "@constructive-io/sdk": "^1.11.11",
35
+ "node-type-registry": "^1.15.0",
36
36
  "zod": "^4.4.3"
37
37
  },
38
38
  "peerDependencies": {
@@ -48,5 +48,5 @@
48
48
  "coding-agent",
49
49
  "constructive"
50
50
  ],
51
- "gitHead": "44f56ee80a348f649a22e5bd32b35f2822726509"
51
+ "gitHead": "a9dd614c27e520cad2a04231605444cdb9fa4aed"
52
52
  }
@@ -0,0 +1,13 @@
1
+ import { type ProvisionModule } from './presets';
2
+ import type { ProvisionRequest } from './request-database';
3
+ /**
4
+ * The slug of the registry preset whose module set equals `resolved` under
5
+ * the backend's hash semantics, or undefined when no preset matches.
6
+ */
7
+ export declare function matchPresetSlug(resolved: ProvisionModule[]): string | undefined;
8
+ /**
9
+ * Build the requestDatabase input branch: presetSlug when the resolved set
10
+ * equals a registry preset (warm-pool eligible), else the explicit modules
11
+ * array — never both.
12
+ */
13
+ export declare function selectProvisionRequest(resolved: ProvisionModule[]): ProvisionRequest;
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ // Client-side mirror of the backend's modules_hash normalization
3
+ // (constructive-db: metaschema_private.modules_hash), used to decide whether a
4
+ // resolved module set can be requested by preset slug — the warm-pool-eligible
5
+ // path — or must be sent as an explicit modules array:
6
+ // 1. A bare module name normalizes to [name, {}]; a missing/null options
7
+ // slot normalizes to {}.
8
+ // 2. Options are compared verbatim after canonicalization (object keys
9
+ // sorted at every level) — no default-value normalization.
10
+ // 3. Entries are sorted so the caller's ordering never affects the match.
11
+ // 4. Duplicate normalized entries never match (the backend rejects them).
12
+ // Only equality within this process matters, so string sort order does not
13
+ // need to replicate PostgreSQL collation.
14
+ //
15
+ // The registry presets are the canonical definitions; the backend's
16
+ // db_presets catalog is synced from them. A slug match assumes that sync —
17
+ // preset drift would provision the backend's version of the set.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.matchPresetSlug = matchPresetSlug;
20
+ exports.selectProvisionRequest = selectProvisionRequest;
21
+ const presets_1 = require("./presets");
22
+ function canonicalize(value) {
23
+ if (Array.isArray(value))
24
+ return value.map(canonicalize);
25
+ if (value && typeof value === 'object') {
26
+ const sorted = {};
27
+ for (const key of Object.keys(value).sort()) {
28
+ sorted[key] = canonicalize(value[key]);
29
+ }
30
+ return sorted;
31
+ }
32
+ return value;
33
+ }
34
+ function normalizedEntries(list) {
35
+ const entries = list.map((m) => {
36
+ const name = Array.isArray(m) ? m[0] : m;
37
+ const options = Array.isArray(m) ? (m[1] ?? {}) : {};
38
+ return JSON.stringify([name, canonicalize(options)]);
39
+ });
40
+ entries.sort();
41
+ for (let i = 1; i < entries.length; i++) {
42
+ if (entries[i] === entries[i - 1])
43
+ return null;
44
+ }
45
+ return entries;
46
+ }
47
+ /**
48
+ * The slug of the registry preset whose module set equals `resolved` under
49
+ * the backend's hash semantics, or undefined when no preset matches.
50
+ */
51
+ function matchPresetSlug(resolved) {
52
+ const target = normalizedEntries(resolved)?.join('\n');
53
+ if (!target)
54
+ return undefined;
55
+ for (const preset of presets_1.allModulePresets) {
56
+ if (normalizedEntries(preset.modules)?.join('\n') === target) {
57
+ return preset.name;
58
+ }
59
+ }
60
+ return undefined;
61
+ }
62
+ /**
63
+ * Build the requestDatabase input branch: presetSlug when the resolved set
64
+ * equals a registry preset (warm-pool eligible), else the explicit modules
65
+ * array — never both.
66
+ */
67
+ function selectProvisionRequest(resolved) {
68
+ const presetSlug = matchPresetSlug(resolved);
69
+ return presetSlug ? { presetSlug } : { modules: resolved };
70
+ }
@@ -0,0 +1,61 @@
1
+ import type { ProvisionModule } from './presets';
2
+ /**
3
+ * Exactly one of presetSlug or modules — requestDatabase rejects both/neither
4
+ * (REQUEST_DATABASE_INVALID_INPUT). `selectProvisionRequest` (preset-match.ts)
5
+ * builds this from the resolved module set.
6
+ */
7
+ export type ProvisionRequest = {
8
+ presetSlug: string;
9
+ } | {
10
+ modules: ProvisionModule[];
11
+ };
12
+ export type ProvisionTicket = {
13
+ id?: string | null;
14
+ status?: string | null;
15
+ bootstrapStatus?: string | null;
16
+ databaseId?: string | null;
17
+ errorMessage?: string | null;
18
+ bootstrapError?: string | null;
19
+ };
20
+ /**
21
+ * Request + poll seam, injectable for tests (pattern: db-probe's
22
+ * ProbeExecutor). The default executor drives the SDK clients.
23
+ */
24
+ export type ProvisionRequestExecutor = {
25
+ request(input: {
26
+ databaseName: string;
27
+ subdomain: string;
28
+ domain: string;
29
+ request: ProvisionRequest;
30
+ }): Promise<ProvisionTicket>;
31
+ poll(ticketId: string): Promise<ProvisionTicket | null>;
32
+ };
33
+ export declare const POLL_INTERVAL_MS = 2000;
34
+ /** Stays under airpage's 5-minute per-tool timeout. */
35
+ export declare const POLL_DEADLINE_MS = 240000;
36
+ /**
37
+ * Consecutive unreadable polls tolerated before giving up. Provisioning keeps
38
+ * running server-side, so a dropped request or a momentary 502 must not abort
39
+ * a creation that is about to succeed.
40
+ */
41
+ export declare const MAX_CONSECUTIVE_POLL_FAILURES = 3;
42
+ /**
43
+ * Request a database and resolve only when it is fully usable: ticket
44
+ * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation
45
+ * returns is checked before the first sleep, so a warm claim with bootstrap
46
+ * already done never waits. `status: 'failed'` surfaces `errorMessage`;
47
+ * `bootstrapStatus: 'failed'` surfaces `bootstrapError`.
48
+ */
49
+ export declare function requestDatabaseProvision(args: {
50
+ apiEndpoint: string;
51
+ modulesEndpoint: string;
52
+ bearer: string;
53
+ databaseName: string;
54
+ domain: string;
55
+ request: ProvisionRequest;
56
+ executor?: ProvisionRequestExecutor;
57
+ pollIntervalMs?: number;
58
+ pollDeadlineMs?: number;
59
+ }): Promise<{
60
+ databaseId: string;
61
+ }>;
@@ -0,0 +1,134 @@
1
+ "use strict";
2
+ // Provision a database through the api endpoint's requestDatabase mutation —
3
+ // the pool-aware entry point. The mutation returns a ticket (a
4
+ // database_provision_module row) immediately: a warm-pool claim arrives
5
+ // 'completed' with the deferred owner bootstrap still pending; a cold request
6
+ // arrives 'pending' and is fulfilled asynchronously by the backend's jobs
7
+ // worker. The ticket is polled on the MODULES endpoint (the api endpoint has
8
+ // no ticket query) until BOTH status and bootstrapStatus are 'completed': pi
9
+ // hands credentials to the agent as soon as this resolves, and until owner
10
+ // bootstrap completes the new database has zero users, so its per-DB API
11
+ // would reject the owner token under RLS. requestDatabase owns the database
12
+ // to the JWT user — there is no owner input. The generated ORM's default
13
+ // adapter is plain fetch, so the SDK's FetchAdapter is injected to keep
14
+ // *.localhost DNS/Host routing working in Node.
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.MAX_CONSECUTIVE_POLL_FAILURES = exports.POLL_DEADLINE_MS = exports.POLL_INTERVAL_MS = void 0;
17
+ exports.requestDatabaseProvision = requestDatabaseProvision;
18
+ const sdk_1 = require("@constructive-io/sdk");
19
+ exports.POLL_INTERVAL_MS = 2_000;
20
+ /** Stays under airpage's 5-minute per-tool timeout. */
21
+ exports.POLL_DEADLINE_MS = 240_000;
22
+ /**
23
+ * Consecutive unreadable polls tolerated before giving up. Provisioning keeps
24
+ * running server-side, so a dropped request or a momentary 502 must not abort
25
+ * a creation that is about to succeed.
26
+ */
27
+ exports.MAX_CONSECUTIVE_POLL_FAILURES = 3;
28
+ const TICKET_SELECT = {
29
+ id: true,
30
+ status: true,
31
+ bootstrapStatus: true,
32
+ databaseId: true,
33
+ errorMessage: true,
34
+ bootstrapError: true,
35
+ };
36
+ function createSdkExecutor(args) {
37
+ const headers = { Authorization: `Bearer ${args.bearer}` };
38
+ const apiClient = sdk_1.api.createClient({ adapter: new sdk_1.api.FetchAdapter(args.apiEndpoint, headers) });
39
+ const modulesClient = sdk_1.modules.createClient({
40
+ adapter: new sdk_1.api.FetchAdapter(args.modulesEndpoint, headers),
41
+ });
42
+ return {
43
+ async request(input) {
44
+ const requestInput = {
45
+ databaseName: input.databaseName,
46
+ subdomain: input.subdomain,
47
+ domain: input.domain,
48
+ ...('presetSlug' in input.request
49
+ ? { presetSlug: input.request.presetSlug }
50
+ : { modules: input.request.modules }),
51
+ };
52
+ const result = await apiClient.mutation
53
+ .requestDatabase({ input: requestInput }, { select: { result: { select: TICKET_SELECT } } })
54
+ .unwrap();
55
+ const ticket = result.requestDatabase?.result;
56
+ if (!ticket)
57
+ throw new Error('requestDatabase returned no provision ticket.');
58
+ return ticket;
59
+ },
60
+ async poll(ticketId) {
61
+ const result = await modulesClient.databaseProvisionModule
62
+ .findOne({ id: ticketId, select: TICKET_SELECT })
63
+ .unwrap();
64
+ return result.databaseProvisionModule ?? null;
65
+ },
66
+ };
67
+ }
68
+ function sleep(ms) {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+ /**
72
+ * Request a database and resolve only when it is fully usable: ticket
73
+ * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation
74
+ * returns is checked before the first sleep, so a warm claim with bootstrap
75
+ * already done never waits. `status: 'failed'` surfaces `errorMessage`;
76
+ * `bootstrapStatus: 'failed'` surfaces `bootstrapError`.
77
+ */
78
+ async function requestDatabaseProvision(args) {
79
+ const executor = args.executor ??
80
+ createSdkExecutor({
81
+ apiEndpoint: args.apiEndpoint,
82
+ modulesEndpoint: args.modulesEndpoint,
83
+ bearer: args.bearer,
84
+ });
85
+ const intervalMs = args.pollIntervalMs ?? exports.POLL_INTERVAL_MS;
86
+ const deadlineMs = args.pollDeadlineMs ?? exports.POLL_DEADLINE_MS;
87
+ let ticket = await executor.request({
88
+ databaseName: args.databaseName,
89
+ subdomain: args.databaseName,
90
+ domain: args.domain,
91
+ request: args.request,
92
+ });
93
+ const ticketId = ticket.id;
94
+ if (!ticketId)
95
+ throw new Error('requestDatabase returned a provision ticket without an id.');
96
+ const deadline = Date.now() + deadlineMs;
97
+ let failures = 0;
98
+ for (;;) {
99
+ if (ticket.status === 'failed') {
100
+ throw new Error(ticket.errorMessage ?? 'provisioning failed');
101
+ }
102
+ if (ticket.bootstrapStatus === 'failed') {
103
+ throw new Error(ticket.bootstrapError ?? 'owner bootstrap failed');
104
+ }
105
+ if (ticket.status === 'completed' && ticket.bootstrapStatus === 'completed') {
106
+ if (!ticket.databaseId)
107
+ throw new Error('provisioning completed but returned no databaseId.');
108
+ return { databaseId: ticket.databaseId };
109
+ }
110
+ if (Date.now() >= deadline) {
111
+ throw new Error(`provisioning timed out after ${Math.round(deadlineMs / 1000)}s (status: ${ticket.status ?? 'unknown'}, bootstrapStatus: ${ticket.bootstrapStatus ?? 'unknown'})`);
112
+ }
113
+ await sleep(intervalMs);
114
+ // A poll that throws (network blip, 502) or comes back empty is retried a
115
+ // few times rather than failing a provision that is still running.
116
+ let polled;
117
+ try {
118
+ polled = await executor.poll(ticketId);
119
+ }
120
+ catch (err) {
121
+ if (++failures > exports.MAX_CONSECUTIVE_POLL_FAILURES)
122
+ throw err;
123
+ continue;
124
+ }
125
+ if (!polled) {
126
+ if (++failures > exports.MAX_CONSECUTIVE_POLL_FAILURES) {
127
+ throw new Error(`provision ticket ${ticketId} could not be read back.`);
128
+ }
129
+ continue;
130
+ }
131
+ failures = 0;
132
+ ticket = polled;
133
+ }
134
+ }
@@ -11,11 +11,12 @@ const zod_1 = require("zod");
11
11
  const app_workspace_1 = require("../app-workspace");
12
12
  const db_probe_1 = require("../db-probe");
13
13
  const host_1 = require("../host");
14
- const create_database_provision_1 = require("../provision-database/create-database-provision");
15
14
  const credential_1 = require("../provision-database/credential");
16
15
  const env_file_1 = require("../provision-database/env-file");
17
16
  const manifest_1 = require("../provision-database/manifest");
18
17
  const pg_fixups_1 = require("../provision-database/pg-fixups");
18
+ const preset_match_1 = require("../provision-database/preset-match");
19
+ const request_database_1 = require("../provision-database/request-database");
19
20
  const resolve_1 = require("../provision-database/resolve");
20
21
  const tool_schema_1 = require("../tool-schema");
21
22
  const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
@@ -64,22 +65,6 @@ function parseEnvKeys(source) {
64
65
  }
65
66
  return env;
66
67
  }
67
- async function withRetry(fn, maxRetries = 5, delayMs = 2000) {
68
- for (let attempt = 1; attempt <= maxRetries; attempt++) {
69
- try {
70
- return await fn();
71
- }
72
- catch (err) {
73
- const msg = err instanceof Error ? err.message : String(err);
74
- if (msg.includes('already exists') || msg.includes('exists'))
75
- throw err;
76
- if (attempt === maxRetries)
77
- throw err;
78
- await new Promise((r) => setTimeout(r, delayMs));
79
- }
80
- }
81
- throw new Error('unreachable');
82
- }
83
68
  exports.provisionDatabaseTool = {
84
69
  name: 'provision_database',
85
70
  label: 'Provision database',
@@ -106,9 +91,10 @@ exports.provisionDatabaseTool = {
106
91
  const backend = host.backendConfig();
107
92
  const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
108
93
  const modulesEndpoint = process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT;
109
- // Every new project provisions UNDER the account (owner_id = the account user),
110
- // so the database is account-owned and enumerable. The account bearer is the
111
- // single credential: it authenticates the provision mutation, the idempotency
94
+ // Every new project provisions UNDER the account (requestDatabase owns the
95
+ // database to the JWT user), so it is account-owned and enumerable. The
96
+ // account bearer is the single credential: it authenticates the provision
97
+ // mutation, the idempotency
112
98
  // probe below, and is the ACCESS_TOKEN written to .env for project-local
113
99
  // scripts. It expires with the login session — a relogin plus any provision
114
100
  // run (including the skip path) refreshes the .env copy. A signed-in session
@@ -186,7 +172,8 @@ exports.provisionDatabaseTool = {
186
172
  existing = (0, env_file_1.archiveBindingKeys)(existing, env_file_1.ARCHIVED_BINDING_KEYS, new Date().toISOString().slice(0, 10));
187
173
  }
188
174
  // The packages/app scaffold + pnpm install are database-INDEPENDENT, so kick
189
- // them off now to run concurrently with the ~90s provisioning mutation below.
175
+ // them off now to run concurrently with the provisioning request below
176
+ // (near-instant on a warm-pool hit, up to minutes on the cold path).
190
177
  // This keeps clone + install off run_codegen's critical path. Best-effort:
191
178
  // run_codegen re-runs the same idempotent steps, so a prewarm failure is
192
179
  // harmless. Never let it reject (we await it before returning).
@@ -213,21 +200,25 @@ exports.provisionDatabaseTool = {
213
200
  return fail(`Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`);
214
201
  }
215
202
  const physicalDb = process.env.CONSTRUCTIVE_DB || 'constructive';
216
- // Provision on the MODULES endpoint: createDatabaseProvisionModule creates
217
- // the database owned by ownerId, provisions the module set, and bootstraps
218
- // the owner into it before the row returns. The domain still derives from
219
- // the api endpoint's host (per-DB endpoints live under it), and an explicit
220
- // subdomain (= databaseName) keeps them deterministic (SUBDOMAIN-001).
203
+ // Provision on the API endpoint: requestDatabase claims a warm-pool
204
+ // database when the resolved module set matches a cataloged preset
205
+ // (near-instant) and cold-provisions asynchronously otherwise; the ticket
206
+ // is polled on the modules endpoint until the database AND its deferred
207
+ // owner bootstrap complete. No retry wrapper: a pending ticket is the
208
+ // normal first response, and transient poll failures are absorbed inside
209
+ // the poll loop. The domain still derives from the api endpoint's host
210
+ // (per-DB endpoints live under it), and an explicit subdomain
211
+ // (= databaseName) keeps them deterministic (SUBDOMAIN-001).
221
212
  let databaseId;
222
213
  try {
223
- ({ databaseId } = await withRetry(() => (0, create_database_provision_1.createDatabaseProvision)({
224
- endpoint: modulesEndpoint,
214
+ ({ databaseId } = await (0, request_database_1.requestDatabaseProvision)({
215
+ apiEndpoint,
216
+ modulesEndpoint,
225
217
  bearer: credential.bearer,
226
218
  databaseName,
227
219
  domain: provisionDomain(apiEndpoint),
228
- ownerId,
229
- modules,
230
- })));
220
+ request: (0, preset_match_1.selectProvisionRequest)(modules),
221
+ }));
231
222
  }
232
223
  catch (err) {
233
224
  const detail = err instanceof Error ? err.message : String(err);
@@ -1,11 +0,0 @@
1
- import type { ProvisionModule } from './presets';
2
- export declare function createDatabaseProvision(args: {
3
- endpoint: string;
4
- bearer: string;
5
- databaseName: string;
6
- domain: string;
7
- ownerId: string;
8
- modules: ProvisionModule[];
9
- }): Promise<{
10
- databaseId: string;
11
- }>;
@@ -1,48 +0,0 @@
1
- // Provision a database through the modules endpoint's createDatabaseProvisionModule
2
- // mutation — an insert into database_provision_modules whose BEFORE INSERT trigger
3
- // creates the database, domain, API, and module set before the row returns (the
4
- // same contract constructive-client's admin app uses). The retired requestDatabase
5
- // mutation only ever existed on dev builds of the api endpoint; production
6
- // deployments (modules.launchql.dev) expose this one. bootstrapUser: true copies
7
- // the owner into the new database so its per-DB API recognizes the account user.
8
- // The generated ORM's default adapter is plain fetch, so the SDK's FetchAdapter is
9
- // injected to keep *.localhost DNS/Host routing working in Node.
10
- import { api, modules } from '@constructive-io/sdk';
11
- export async function createDatabaseProvision(args) {
12
- const db = modules.createClient({
13
- adapter: new api.FetchAdapter(args.endpoint, {
14
- Authorization: `Bearer ${args.bearer}`,
15
- }),
16
- });
17
- const result = await db.databaseProvisionModule
18
- .create({
19
- data: {
20
- databaseName: args.databaseName,
21
- subdomain: args.databaseName,
22
- domain: args.domain,
23
- ownerId: args.ownerId,
24
- modules: args.modules,
25
- bootstrapUser: true,
26
- },
27
- select: {
28
- id: true,
29
- databaseId: true,
30
- status: true,
31
- errorMessage: true,
32
- completedAt: true,
33
- },
34
- })
35
- .unwrap();
36
- const record = result.createDatabaseProvisionModule?.databaseProvisionModule;
37
- if (!record)
38
- throw new Error('createDatabaseProvisionModule returned no provision record.');
39
- if (record.status === 'failed') {
40
- throw new Error(record.errorMessage ?? 'provisioning failed');
41
- }
42
- if (record.status !== 'completed') {
43
- throw new Error(record.errorMessage ?? `provisioning did not complete (status: ${record.status ?? 'unknown'})`);
44
- }
45
- if (!record.databaseId)
46
- throw new Error('provisioning completed but returned no databaseId.');
47
- return { databaseId: record.databaseId };
48
- }
@@ -1,11 +0,0 @@
1
- import type { ProvisionModule } from './presets';
2
- export declare function createDatabaseProvision(args: {
3
- endpoint: string;
4
- bearer: string;
5
- databaseName: string;
6
- domain: string;
7
- ownerId: string;
8
- modules: ProvisionModule[];
9
- }): Promise<{
10
- databaseId: string;
11
- }>;
@@ -1,51 +0,0 @@
1
- "use strict";
2
- // Provision a database through the modules endpoint's createDatabaseProvisionModule
3
- // mutation — an insert into database_provision_modules whose BEFORE INSERT trigger
4
- // creates the database, domain, API, and module set before the row returns (the
5
- // same contract constructive-client's admin app uses). The retired requestDatabase
6
- // mutation only ever existed on dev builds of the api endpoint; production
7
- // deployments (modules.launchql.dev) expose this one. bootstrapUser: true copies
8
- // the owner into the new database so its per-DB API recognizes the account user.
9
- // The generated ORM's default adapter is plain fetch, so the SDK's FetchAdapter is
10
- // injected to keep *.localhost DNS/Host routing working in Node.
11
- Object.defineProperty(exports, "__esModule", { value: true });
12
- exports.createDatabaseProvision = createDatabaseProvision;
13
- const sdk_1 = require("@constructive-io/sdk");
14
- async function createDatabaseProvision(args) {
15
- const db = sdk_1.modules.createClient({
16
- adapter: new sdk_1.api.FetchAdapter(args.endpoint, {
17
- Authorization: `Bearer ${args.bearer}`,
18
- }),
19
- });
20
- const result = await db.databaseProvisionModule
21
- .create({
22
- data: {
23
- databaseName: args.databaseName,
24
- subdomain: args.databaseName,
25
- domain: args.domain,
26
- ownerId: args.ownerId,
27
- modules: args.modules,
28
- bootstrapUser: true,
29
- },
30
- select: {
31
- id: true,
32
- databaseId: true,
33
- status: true,
34
- errorMessage: true,
35
- completedAt: true,
36
- },
37
- })
38
- .unwrap();
39
- const record = result.createDatabaseProvisionModule?.databaseProvisionModule;
40
- if (!record)
41
- throw new Error('createDatabaseProvisionModule returned no provision record.');
42
- if (record.status === 'failed') {
43
- throw new Error(record.errorMessage ?? 'provisioning failed');
44
- }
45
- if (record.status !== 'completed') {
46
- throw new Error(record.errorMessage ?? `provisioning did not complete (status: ${record.status ?? 'unknown'})`);
47
- }
48
- if (!record.databaseId)
49
- throw new Error('provisioning completed but returned no databaseId.');
50
- return { databaseId: record.databaseId };
51
- }