@agentic-kit/pi 0.10.6 → 0.11.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.
- package/README.md +6 -0
- package/esm/provision-database/preset-match.d.ts +13 -0
- package/esm/provision-database/preset-match.js +66 -0
- package/esm/provision-database/request-database.d.ts +61 -0
- package/esm/provision-database/request-database.js +130 -0
- package/esm/tools/provision-database.js +22 -31
- package/package.json +5 -5
- package/provision-database/preset-match.d.ts +13 -0
- package/provision-database/preset-match.js +70 -0
- package/provision-database/request-database.d.ts +61 -0
- package/provision-database/request-database.js +134 -0
- package/tools/provision-database.js +22 -31
- package/esm/provision-database/create-database-provision.d.ts +0 -11
- package/esm/provision-database/create-database-provision.js +0 -48
- package/provision-database/create-database-provision.d.ts +0 -11
- package/provision-database/create-database-provision.js +0 -51
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 (
|
|
104
|
-
//
|
|
105
|
-
// single credential: it authenticates the provision
|
|
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
|
|
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
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
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
|
|
218
|
-
|
|
208
|
+
({ databaseId } = await requestDatabaseProvision({
|
|
209
|
+
apiEndpoint,
|
|
210
|
+
modulesEndpoint,
|
|
219
211
|
bearer: credential.bearer,
|
|
220
212
|
databaseName,
|
|
221
213
|
domain: provisionDomain(apiEndpoint),
|
|
222
|
-
|
|
223
|
-
|
|
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.
|
|
3
|
+
"version": "0.11.0",
|
|
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.
|
|
34
|
-
"@constructive-io/sdk": "^1.11.
|
|
35
|
-
"node-type-registry": "^1.
|
|
33
|
+
"@constructive-io/graphql-query": "^4.12.10",
|
|
34
|
+
"@constructive-io/sdk": "^1.11.10",
|
|
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": "
|
|
51
|
+
"gitHead": "d0d796b7eb0d16c8c19405a49c0ca62ce3df117b"
|
|
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 (
|
|
110
|
-
//
|
|
111
|
-
// single credential: it authenticates the provision
|
|
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
|
|
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
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
//
|
|
220
|
-
//
|
|
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
|
|
224
|
-
|
|
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
|
-
|
|
229
|
-
|
|
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
|
-
}
|