@agentic-kit/db-tools 0.11.5 → 0.12.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 +40 -1
- package/app-workspace.d.ts +29 -2
- package/app-workspace.js +99 -8
- package/blueprint-construction.d.ts +60 -0
- package/blueprint-construction.js +161 -0
- package/context.d.ts +5 -1
- package/context.js +30 -5
- package/db-probe.d.ts +25 -17
- package/db-probe.js +97 -37
- package/esm/app-workspace.d.ts +29 -2
- package/esm/app-workspace.js +96 -8
- package/esm/blueprint-construction.d.ts +60 -0
- package/esm/blueprint-construction.js +154 -0
- package/esm/context.d.ts +5 -1
- package/esm/context.js +31 -6
- package/esm/db-probe.d.ts +25 -17
- package/esm/db-probe.js +96 -37
- package/esm/graphql-http.d.ts +35 -0
- package/esm/graphql-http.js +134 -0
- package/esm/host.d.ts +46 -0
- package/esm/host.js +9 -0
- package/esm/index.d.ts +11 -2
- package/esm/index.js +6 -0
- package/esm/provision-database/env-file.d.ts +23 -2
- package/esm/provision-database/env-file.js +86 -27
- package/esm/provision-database/pending-ticket.d.ts +33 -0
- package/esm/provision-database/pending-ticket.js +63 -0
- package/esm/provision-database/provision.d.ts +71 -0
- package/esm/provision-database/provision.js +557 -0
- package/esm/provision-database/request-database.d.ts +55 -3
- package/esm/provision-database/request-database.js +118 -17
- package/esm/schema-model/normalize.d.ts +21 -0
- package/esm/schema-model/normalize.js +244 -0
- package/esm/schema-model.d.ts +110 -0
- package/esm/schema-model.js +303 -0
- package/esm/tools/add-policies.d.ts +1 -1
- package/esm/tools/add-records.d.ts +1 -0
- package/esm/tools/add-records.js +18 -6
- package/esm/tools/add-relation.js +2 -3
- package/esm/tools/create-api-key.d.ts +2 -0
- package/esm/tools/create-api-key.js +27 -12
- package/esm/tools/manage-entity-types.d.ts +1 -1
- package/esm/tools/provision-blueprint.d.ts +6 -0
- package/esm/tools/provision-blueprint.js +11 -4
- package/esm/tools/provision-database.d.ts +8 -11
- package/esm/tools/provision-database.js +23 -243
- package/esm/tools/run-codegen.d.ts +19 -2
- package/esm/tools/run-codegen.js +184 -97
- package/esm/tools/templates.js +3 -15
- package/graphql-http.d.ts +35 -0
- package/graphql-http.js +140 -0
- package/host.d.ts +46 -0
- package/host.js +10 -0
- package/index.d.ts +11 -2
- package/index.js +26 -1
- package/package.json +4 -4
- package/provision-database/env-file.d.ts +23 -2
- package/provision-database/env-file.js +89 -28
- package/provision-database/pending-ticket.d.ts +33 -0
- package/provision-database/pending-ticket.js +72 -0
- package/provision-database/provision.d.ts +71 -0
- package/provision-database/provision.js +565 -0
- package/provision-database/request-database.d.ts +55 -3
- package/provision-database/request-database.js +124 -18
- package/schema-model/normalize.d.ts +21 -0
- package/schema-model/normalize.js +250 -0
- package/schema-model.d.ts +110 -0
- package/schema-model.js +309 -0
- package/tools/add-policies.d.ts +1 -1
- package/tools/add-records.d.ts +1 -0
- package/tools/add-records.js +18 -6
- package/tools/add-relation.js +2 -3
- package/tools/create-api-key.d.ts +2 -0
- package/tools/create-api-key.js +27 -12
- package/tools/manage-entity-types.d.ts +1 -1
- package/tools/provision-blueprint.d.ts +6 -0
- package/tools/provision-blueprint.js +11 -4
- package/tools/provision-database.d.ts +8 -11
- package/tools/provision-database.js +23 -245
- package/tools/run-codegen.d.ts +19 -2
- package/tools/run-codegen.js +184 -96
- package/tools/templates.js +3 -15
|
@@ -1,35 +1,6 @@
|
|
|
1
|
-
import { writeFile } from 'node:fs/promises';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
3
|
-
import path from 'node:path';
|
|
4
1
|
import { z } from 'zod';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import { getHost } from '../host';
|
|
8
|
-
import { selectProvisionCredential } from '../provision-database/credential';
|
|
9
|
-
import { archiveBindingKeys, ARCHIVED_BINDING_KEYS, mergeEnv, provisionEnvVars, } from '../provision-database/env-file';
|
|
10
|
-
import { loadProvisionManifest } from '../provision-database/manifest';
|
|
11
|
-
import { selectProvisionRequest } from '../provision-database/preset-match';
|
|
12
|
-
import { requestDatabaseProvision } from '../provision-database/request-database';
|
|
13
|
-
import { resolveProvisionModules } from '../provision-database/resolve';
|
|
14
|
-
const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
|
|
15
|
-
const DEFAULT_MODULES_ENDPOINT = 'http://modules.localhost:3000/graphql';
|
|
16
|
-
// The per-DB endpoints are subdomain.<domain>; the provisioning domain is the api
|
|
17
|
-
// endpoint's host minus its first label (api.localhost -> localhost,
|
|
18
|
-
// api.launchql.dev -> launchql.dev), so dev and devnet both provision correctly.
|
|
19
|
-
function provisionDomain(apiEndpoint) {
|
|
20
|
-
try {
|
|
21
|
-
const { hostname } = new URL(apiEndpoint);
|
|
22
|
-
// A bare IP host has no domain hierarchy to strip: dropping its first label
|
|
23
|
-
// ('192.168.1.10' -> '168.1.10') would yield a garbage domain. Use it verbatim.
|
|
24
|
-
if (hostname.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname))
|
|
25
|
-
return hostname;
|
|
26
|
-
const parts = hostname.split('.');
|
|
27
|
-
return parts.length > 1 ? parts.slice(1).join('.') : parts.join('.');
|
|
28
|
-
}
|
|
29
|
-
catch {
|
|
30
|
-
return 'localhost';
|
|
31
|
-
}
|
|
32
|
-
}
|
|
2
|
+
import { run } from '../app-workspace';
|
|
3
|
+
import { boundDatabaseId, provisionFromTool, } from '../provision-database/provision';
|
|
33
4
|
const ProvisionDatabaseZod = z.object({
|
|
34
5
|
database_name: z
|
|
35
6
|
.string()
|
|
@@ -39,216 +10,25 @@ const ProvisionDatabaseZod = z.object({
|
|
|
39
10
|
.describe('Mint a fresh database even though the project already carries a binding — use when the bound database no longer exists on this backend (refreshed backend, deleted database), when it belongs to a different account than the one signed in, or when the user asks for a clean rebuild. The old .env keys are archived as comments and the old database is never deleted server-side. The schema must be rebuilt afterwards (provision_blueprint + run_codegen); records do not carry over.')
|
|
40
11
|
.optional(),
|
|
41
12
|
});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
13
|
+
// `exec` runs the prewarm's child processes (clone, install); tests inject it.
|
|
14
|
+
export function createProvisionDatabaseTool(exec = run) {
|
|
15
|
+
return {
|
|
16
|
+
name: 'provision_database',
|
|
17
|
+
label: 'Provision database',
|
|
18
|
+
description: 'Bootstrap a new Constructive database for the project under your account: provision the standard module set and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards). If a call returns while the database is still provisioning, call it again with the same database_name to keep waiting; a different name starts a second database.',
|
|
19
|
+
promptSnippet: 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.',
|
|
20
|
+
parameters: ProvisionDatabaseZod,
|
|
21
|
+
async execute(params, ctx) {
|
|
22
|
+
// reprovision: true replaces the binding the project has right now; one
|
|
23
|
+
// that changes while this call waits for the project lock is not replaced.
|
|
24
|
+
const replace = params.reprovision === true;
|
|
25
|
+
const details = await provisionFromTool({
|
|
26
|
+
databaseName: params.database_name,
|
|
27
|
+
mode: replace ? 'replace' : 'ensure',
|
|
28
|
+
replaces: replace ? ((await boundDatabaseId(ctx.cwd)) ?? null) : undefined,
|
|
29
|
+
}, ctx, exec);
|
|
30
|
+
return { content: [{ type: 'text', text: details.message }], details };
|
|
31
|
+
},
|
|
32
|
+
};
|
|
58
33
|
}
|
|
59
|
-
export const provisionDatabaseTool =
|
|
60
|
-
name: 'provision_database',
|
|
61
|
-
label: 'Provision database',
|
|
62
|
-
description: 'Bootstrap a new Constructive database for the project under your account: provision the standard module set and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards).',
|
|
63
|
-
promptSnippet: 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.',
|
|
64
|
-
parameters: ProvisionDatabaseZod,
|
|
65
|
-
async execute(params, ctx) {
|
|
66
|
-
const databaseName = params.database_name.trim();
|
|
67
|
-
if (!databaseName)
|
|
68
|
-
return fail('database_name is required.');
|
|
69
|
-
const envPath = path.join(ctx.cwd, '.env');
|
|
70
|
-
let existing = '';
|
|
71
|
-
try {
|
|
72
|
-
existing = await readFile(envPath, 'utf8');
|
|
73
|
-
}
|
|
74
|
-
catch {
|
|
75
|
-
/* no existing .env — fine */
|
|
76
|
-
}
|
|
77
|
-
// Unify endpoints onto the app's backend-config store (environment-aware:
|
|
78
|
-
// *.localhost:3000 in dev, *.launchql.dev packaged). A process.env override
|
|
79
|
-
// still wins for ad-hoc dev; the hardcoded default is a last resort if the
|
|
80
|
-
// store isn't ready.
|
|
81
|
-
const host = getHost();
|
|
82
|
-
const backend = host.backendConfig();
|
|
83
|
-
const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
|
|
84
|
-
const modulesEndpoint = process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT;
|
|
85
|
-
// Every new project provisions UNDER the account (requestDatabase owns the
|
|
86
|
-
// database to the JWT user), so it is account-owned and enumerable. The
|
|
87
|
-
// account bearer is the single credential: it authenticates the provision
|
|
88
|
-
// mutation, the idempotency
|
|
89
|
-
// probe below, and is the ACCESS_TOKEN written to .env for project-local
|
|
90
|
-
// scripts. It expires with the login session — a relogin plus any provision
|
|
91
|
-
// run (including the skip path) refreshes the .env copy. A signed-in session
|
|
92
|
-
// with no usable bearer errors out here rather than silently minting a
|
|
93
|
-
// throwaway owner. Gate BEFORE prewarm so an error return doesn't leak a
|
|
94
|
-
// detached background scaffold/install.
|
|
95
|
-
const credential = selectProvisionCredential(host.account(), host.signInHint);
|
|
96
|
-
if (credential.mode === 'error') {
|
|
97
|
-
return fail(`Cannot provision a database: ${credential.reason}`);
|
|
98
|
-
}
|
|
99
|
-
const ownerId = credential.ownerId;
|
|
100
|
-
// Idempotency keyed on binding liveness AND ownership: a binding is done only
|
|
101
|
-
// when its database still answers and belongs to the signed-in account
|
|
102
|
-
// (schemas are account-scoped). A dead binding (backend refreshed, database
|
|
103
|
-
// deleted, key revoked) or a live one under another account points at the
|
|
104
|
-
// reprovision path; an unreachable backend is a retry, never a reprovision.
|
|
105
|
-
const existingEnv = parseEnvKeys(existing);
|
|
106
|
-
const hasBinding = Boolean(existingEnv.DATABASE_ID && existingEnv.ACCESS_TOKEN);
|
|
107
|
-
if (hasBinding && !params.reprovision) {
|
|
108
|
-
// The probe carries the account bearer, so it only ever targets the
|
|
109
|
-
// app-configured backend — never a .env-pinned endpoint, which an
|
|
110
|
-
// untrusted cloned project controls.
|
|
111
|
-
const probe = await probeDatabase({
|
|
112
|
-
endpoint: apiEndpoint,
|
|
113
|
-
bearer: credential.bearer,
|
|
114
|
-
databaseId: existingEnv.DATABASE_ID,
|
|
115
|
-
signInHint: host.signInHint,
|
|
116
|
-
});
|
|
117
|
-
if (probe.outcome === 'unreachable') {
|
|
118
|
-
return fail(`Cannot verify the existing database binding: backend unreachable (${probe.detail}). Check the Constructive backend is running, then retry — do not reprovision on an unreachable backend.`);
|
|
119
|
-
}
|
|
120
|
-
if (probe.outcome === 'missing') {
|
|
121
|
-
return fail(`Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database no longer exists on this backend. Re-run with reprovision: true to mint a fresh database (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`);
|
|
122
|
-
}
|
|
123
|
-
const sessionUserId = host.account()?.userId;
|
|
124
|
-
if (probe.ownerId && sessionUserId && probe.ownerId !== sessionUserId) {
|
|
125
|
-
return fail(`Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database was provisioned under a different account than the one signed in. Re-run with reprovision: true to mint a fresh database under this account (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`);
|
|
126
|
-
}
|
|
127
|
-
// Heal .env on the skip path: a stale token (relogin) or missing/stale
|
|
128
|
-
// endpoint pins (project provisioned before pins existed, or backend
|
|
129
|
-
// config changed) refresh to the current session + backend.
|
|
130
|
-
const refresh = {};
|
|
131
|
-
if (existingEnv.ACCESS_TOKEN !== credential.bearer)
|
|
132
|
-
refresh.ACCESS_TOKEN = credential.bearer;
|
|
133
|
-
if (existingEnv.API_ENDPOINT !== apiEndpoint)
|
|
134
|
-
refresh.API_ENDPOINT = apiEndpoint;
|
|
135
|
-
if (existingEnv.MODULES_ENDPOINT !== modulesEndpoint) {
|
|
136
|
-
refresh.MODULES_ENDPOINT = modulesEndpoint;
|
|
137
|
-
}
|
|
138
|
-
let tokenNote = '';
|
|
139
|
-
if (Object.keys(refresh).length > 0) {
|
|
140
|
-
const keys = Object.keys(refresh).join(', ');
|
|
141
|
-
try {
|
|
142
|
-
await writeFile(envPath, mergeEnv(existing, refresh));
|
|
143
|
-
tokenNote = ` Refreshed in .env from the signed-in session: ${keys}.`;
|
|
144
|
-
}
|
|
145
|
-
catch (err) {
|
|
146
|
-
tokenNote = ` Could not refresh ${keys} in .env: ${err instanceof Error ? err.message : String(err)}.`;
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
const message = `Project already provisioned (DATABASE_ID=${existingEnv.DATABASE_ID}) and the database is live under this account. Skipping — pass reprovision: true only for an explicit clean rebuild.${tokenNote}`;
|
|
150
|
-
return {
|
|
151
|
-
content: [{ type: 'text', text: message }],
|
|
152
|
-
details: {
|
|
153
|
-
success: true,
|
|
154
|
-
message,
|
|
155
|
-
databaseId: existingEnv.DATABASE_ID,
|
|
156
|
-
databaseName: existingEnv.DATABASE_NAME,
|
|
157
|
-
ownerId: existingEnv.OWNER_ID,
|
|
158
|
-
skipped: true,
|
|
159
|
-
},
|
|
160
|
-
};
|
|
161
|
-
}
|
|
162
|
-
if (hasBinding && params.reprovision) {
|
|
163
|
-
existing = archiveBindingKeys(existing, ARCHIVED_BINDING_KEYS, new Date().toISOString().slice(0, 10));
|
|
164
|
-
}
|
|
165
|
-
// The packages/app scaffold + pnpm install are database-INDEPENDENT, so kick
|
|
166
|
-
// them off now to run concurrently with the provisioning request below
|
|
167
|
-
// (near-instant on a warm-pool hit, up to minutes on the cold path).
|
|
168
|
-
// This keeps clone + install off run_codegen's critical path. Best-effort:
|
|
169
|
-
// run_codegen re-runs the same idempotent steps, so a prewarm failure is
|
|
170
|
-
// harmless. Never let it reject (we await it before returning).
|
|
171
|
-
const prewarm = prewarmAppWorkspace(ctx.cwd).catch((err) => ({
|
|
172
|
-
ok: false,
|
|
173
|
-
out: err instanceof Error ? err.message : String(err),
|
|
174
|
-
}));
|
|
175
|
-
// Resolve the module set: pinned base preset from node-type-registry, then
|
|
176
|
-
// ordered overlays (host default < project-local provision.json). No list is
|
|
177
|
-
// hand-copied here; overlays are pure data. A bad preset/manifest fails the
|
|
178
|
-
// run rather than provisioning a wrong module set.
|
|
179
|
-
let modules;
|
|
180
|
-
try {
|
|
181
|
-
const layers = [];
|
|
182
|
-
const hostOverlay = await host.provisionOverlay?.();
|
|
183
|
-
if (hostOverlay)
|
|
184
|
-
layers.push(hostOverlay);
|
|
185
|
-
const projectOverlay = await loadProvisionManifest(ctx.cwd);
|
|
186
|
-
if (projectOverlay)
|
|
187
|
-
layers.push(projectOverlay);
|
|
188
|
-
modules = resolveProvisionModules(layers);
|
|
189
|
-
}
|
|
190
|
-
catch (err) {
|
|
191
|
-
return fail(`Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`);
|
|
192
|
-
}
|
|
193
|
-
// Provision on the API endpoint: requestDatabase claims a warm-pool
|
|
194
|
-
// database when the resolved module set matches a cataloged preset
|
|
195
|
-
// (near-instant) and cold-provisions asynchronously otherwise; the ticket
|
|
196
|
-
// is polled on the modules endpoint until the database AND its deferred
|
|
197
|
-
// owner bootstrap complete. No retry wrapper: a pending ticket is the
|
|
198
|
-
// normal first response, and transient poll failures are absorbed inside
|
|
199
|
-
// the poll loop. The domain still derives from the api endpoint's host
|
|
200
|
-
// (per-DB endpoints live under it), and an explicit subdomain
|
|
201
|
-
// (= databaseName) keeps them deterministic (SUBDOMAIN-001).
|
|
202
|
-
let databaseId;
|
|
203
|
-
try {
|
|
204
|
-
({ databaseId } = await requestDatabaseProvision({
|
|
205
|
-
apiEndpoint,
|
|
206
|
-
modulesEndpoint,
|
|
207
|
-
bearer: credential.bearer,
|
|
208
|
-
databaseName,
|
|
209
|
-
domain: provisionDomain(apiEndpoint),
|
|
210
|
-
request: selectProvisionRequest(modules),
|
|
211
|
-
}));
|
|
212
|
-
}
|
|
213
|
-
catch (err) {
|
|
214
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
215
|
-
const nameTakenHint = /already exists|already taken|already in use|duplicate/i.test(detail)
|
|
216
|
-
? ' The database name is already taken on the backend — re-run with a different database_name.'
|
|
217
|
-
: '';
|
|
218
|
-
return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`);
|
|
219
|
-
}
|
|
220
|
-
// Persist the binding to the project .env (upsert, preserving other keys).
|
|
221
|
-
const merged = mergeEnv(existing, provisionEnvVars({
|
|
222
|
-
databaseId,
|
|
223
|
-
databaseName,
|
|
224
|
-
ownerId,
|
|
225
|
-
accessToken: credential.bearer,
|
|
226
|
-
apiEndpoint,
|
|
227
|
-
modulesEndpoint,
|
|
228
|
-
}));
|
|
229
|
-
try {
|
|
230
|
-
await writeFile(envPath, merged);
|
|
231
|
-
}
|
|
232
|
-
catch (err) {
|
|
233
|
-
return fail(`Database provisioned (ID: ${databaseId}) but writing .env failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
234
|
-
}
|
|
235
|
-
// Settle the concurrent prewarm so the scaffold + install are in place before
|
|
236
|
-
// run_codegen runs. A failure here is non-fatal — run_codegen redoes the work.
|
|
237
|
-
const prewarmResult = await prewarm;
|
|
238
|
-
const prewarmNote = prewarmResult.ok ? ' packages/app prewarmed.' : '';
|
|
239
|
-
const reprovisionNote = hasBinding && params.reprovision
|
|
240
|
-
? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.'
|
|
241
|
-
: '';
|
|
242
|
-
const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${prewarmNote}${reprovisionNote}`;
|
|
243
|
-
return {
|
|
244
|
-
content: [{ type: 'text', text: message }],
|
|
245
|
-
details: {
|
|
246
|
-
success: true,
|
|
247
|
-
message,
|
|
248
|
-
databaseId,
|
|
249
|
-
databaseName,
|
|
250
|
-
ownerId,
|
|
251
|
-
},
|
|
252
|
-
};
|
|
253
|
-
},
|
|
254
|
-
};
|
|
34
|
+
export const provisionDatabaseTool = createProvisionDatabaseTool();
|
|
@@ -1,12 +1,29 @@
|
|
|
1
1
|
import type { HarnessTool } from '@agentic-kit/harness';
|
|
2
2
|
import { z } from 'zod';
|
|
3
|
-
import { prewarmAppWorkspace } from '../app-workspace';
|
|
3
|
+
import { prewarmAppWorkspace, type RunCommand } from '../app-workspace';
|
|
4
4
|
declare const RunCodegenZod: z.ZodObject<{}, z.core.$strip>;
|
|
5
|
+
/** Wall-clock milliseconds per step; a step that didn't run reports 0. */
|
|
6
|
+
export type RunCodegenTimings = {
|
|
7
|
+
scaffoldMs: number;
|
|
8
|
+
installMs: number;
|
|
9
|
+
codegenMs: number;
|
|
10
|
+
barrelsMs: number;
|
|
11
|
+
totalMs: number;
|
|
12
|
+
};
|
|
5
13
|
export type RunCodegenDetails = {
|
|
6
14
|
success: boolean;
|
|
7
15
|
message: string;
|
|
8
16
|
barrelsRewritten?: number;
|
|
9
17
|
generatedSubmodules?: string[];
|
|
18
|
+
/** `composed` when packages/app carries .constructive/compose.json. */
|
|
19
|
+
appKind?: 'composed' | 'template';
|
|
20
|
+
/** False when a composed app's node_modules was already there. */
|
|
21
|
+
installed?: boolean;
|
|
22
|
+
codegenAttempts?: number;
|
|
23
|
+
timings?: RunCodegenTimings;
|
|
24
|
+
/** The app API answered ACCESS_SUSPENDED: the database is deleted or suspended. */
|
|
25
|
+
code?: 'database-unavailable';
|
|
10
26
|
};
|
|
11
|
-
export declare
|
|
27
|
+
export declare function createRunCodegenTool(exec?: RunCommand): HarnessTool<typeof RunCodegenZod, RunCodegenDetails>;
|
|
28
|
+
export declare const runCodegenTool: HarnessTool<z.ZodObject<{}, z.core.$strip>, RunCodegenDetails>;
|
|
12
29
|
export { prewarmAppWorkspace };
|
package/esm/tools/run-codegen.js
CHANGED
|
@@ -2,136 +2,223 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { readFile, rm, writeFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { z } from 'zod';
|
|
5
|
-
import { ensureAppScaffold, ensureWorkspaceConfig, IGNORED_BUILDS_RE, pinCodegenLatest, prewarmAppWorkspace, run, } from '../app-workspace';
|
|
5
|
+
import { detectAppKind, ensureAppScaffold, ensureComposedInstall, ensureWorkspaceConfig, IGNORED_BUILDS_RE, pinCodegenLatest, prewarmAppWorkspace, run, settlePrewarm, } from '../app-workspace';
|
|
6
|
+
import { classifyBackendErrors, postGraphQL } from '../graphql-http';
|
|
6
7
|
import { getHost } from '../host';
|
|
8
|
+
import { readEnv } from '../provision-database/env-file';
|
|
7
9
|
import { normalizeSdkBarrels } from '../run-codegen/barrels';
|
|
8
10
|
import { codegenEndpointEnv, derivePlaneEndpoints, envLocalContent } from '../run-codegen/endpoints';
|
|
9
11
|
const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
|
|
10
12
|
const RunCodegenZod = z.object({});
|
|
11
|
-
function fail(message) {
|
|
12
|
-
return {
|
|
13
|
+
function fail(message, extra) {
|
|
14
|
+
return {
|
|
15
|
+
content: [{ type: 'text', text: message }],
|
|
16
|
+
details: { ...extra, success: false, message },
|
|
17
|
+
};
|
|
13
18
|
}
|
|
14
19
|
const FETCH_RESET_RE = /Failed to fetch schema|Connection reset|ECONNRESET|socket hang up/i;
|
|
20
|
+
// A refusal is an answer, not a dropped connection: retrying it only waits.
|
|
21
|
+
const REFUSED_RE = /\b403\b|Forbidden|ACCESS_SUSPENDED/;
|
|
15
22
|
// Read the binding provision_database wrote to the project .env. The
|
|
16
23
|
// API_ENDPOINT pin identifies which backend owns the binding — it wins over
|
|
17
24
|
// the app's current backend config, matching context.ts data-plane precedence.
|
|
18
25
|
async function readProjectEnv(cwd) {
|
|
19
26
|
try {
|
|
20
|
-
const env = await readFile(path.join(cwd, '.env'), 'utf8');
|
|
21
|
-
|
|
22
|
-
|
|
27
|
+
const env = readEnv(await readFile(path.join(cwd, '.env'), 'utf8'));
|
|
28
|
+
return {
|
|
29
|
+
databaseName: env.DATABASE_NAME?.trim() || undefined,
|
|
30
|
+
apiEndpoint: env.API_ENDPOINT?.trim() || undefined,
|
|
31
|
+
};
|
|
23
32
|
}
|
|
24
33
|
catch {
|
|
25
34
|
return {};
|
|
26
35
|
}
|
|
27
36
|
}
|
|
28
37
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
38
|
+
// Ask the app API itself; a 403 from codegen alone could be anything.
|
|
39
|
+
async function isDatabaseUnavailable(appEndpoint) {
|
|
40
|
+
const answer = await postGraphQL({ endpoint: appEndpoint, query: '{ __typename }' });
|
|
41
|
+
return classifyBackendErrors(answer.errors) === 'suspended';
|
|
42
|
+
}
|
|
43
|
+
const CODEGEN_ARGS = ['generate', '--config', './graphql-codegen.config.ts'];
|
|
44
|
+
// The template flow resolves codegen through npx. A composed app runs the codegen
|
|
45
|
+
// its lockfile pinned: the linked bin, or the app's own `codegen` script when the
|
|
46
|
+
// bin isn't linked, so nothing is resolved over the network.
|
|
47
|
+
function codegenCommand(appDir, composed) {
|
|
48
|
+
if (!composed)
|
|
49
|
+
return { cmd: 'npx', args: ['@constructive-io/graphql-codegen', ...CODEGEN_ARGS] };
|
|
50
|
+
const bin = path.join(appDir, 'node_modules', '.bin', 'graphql-codegen');
|
|
51
|
+
return existsSync(bin) ? { cmd: bin, args: CODEGEN_ARGS } : { cmd: 'pnpm', args: ['run', 'codegen'] };
|
|
52
|
+
}
|
|
29
53
|
// The codegen CLI introspects three live vhosts (admin/auth/app). Under load the
|
|
30
54
|
// Constructive backend intermittently resets a connection mid-introspection
|
|
31
55
|
// ("Failed to fetch schema: Connection reset"), which is transient — a retry
|
|
32
56
|
// almost always clears it. Re-run the whole generate (it rebuilds all targets)
|
|
33
57
|
// up to `attempts` times, only on a fetch/reset failure; any other failure is
|
|
34
58
|
// real and returned immediately (CODEGEN-RETRY-001).
|
|
35
|
-
async function runCodegenWithRetry(appDir, env, attempts = 4) {
|
|
59
|
+
async function runCodegenWithRetry(appDir, env, exec, composed, attempts = 4) {
|
|
36
60
|
const sdk = path.join(appDir, 'src', 'graphql', 'sdk');
|
|
61
|
+
const { cmd, args } = codegenCommand(appDir, composed);
|
|
37
62
|
let last = { ok: false, out: 'codegen never ran' };
|
|
38
63
|
for (let i = 1; i <= attempts; i++) {
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
// A composed app ships SDK stubs so it builds before codegen; codegen prunes
|
|
65
|
+
// its own stale files, so the stubs stay until a run replaces them.
|
|
66
|
+
if (!composed) {
|
|
67
|
+
for (const sub of ['admin', 'auth', 'app']) {
|
|
68
|
+
await rm(path.join(sdk, sub), { recursive: true, force: true });
|
|
69
|
+
}
|
|
41
70
|
}
|
|
42
|
-
last = await
|
|
43
|
-
if (last.ok)
|
|
44
|
-
return last;
|
|
45
|
-
if (!FETCH_RESET_RE.test(last.out))
|
|
46
|
-
return last;
|
|
71
|
+
last = await exec(cmd, args, appDir, env);
|
|
72
|
+
if (last.ok || !FETCH_RESET_RE.test(last.out) || REFUSED_RE.test(last.out))
|
|
73
|
+
return { ...last, attempts: i };
|
|
47
74
|
if (i < attempts)
|
|
48
75
|
await sleep(2000 * i);
|
|
49
76
|
}
|
|
50
|
-
return last;
|
|
77
|
+
return { ...last, attempts };
|
|
51
78
|
}
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
const backend = getHost().backendConfig();
|
|
73
|
-
const apiEndpoint = envApiEndpoint || backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
|
|
74
|
-
const planes = derivePlaneEndpoints(apiEndpoint, dbName);
|
|
75
|
-
// The dev server reads .env.local (NEXT_PUBLIC_DB_NAME + per-DB endpoint
|
|
76
|
-
// overrides so it doesn't fall back to localhost vhosts on a remote
|
|
77
|
-
// backend); the codegen config reads process.env (passed directly below).
|
|
78
|
-
try {
|
|
79
|
-
await writeFile(path.join(appDir, '.env.local'), envLocalContent(dbName, planes));
|
|
80
|
-
}
|
|
81
|
-
catch (err) {
|
|
82
|
-
return fail(`Failed to write packages/app/.env.local: ${err instanceof Error ? err.message : String(err)}`);
|
|
83
|
-
}
|
|
84
|
-
// Hoist the graphql override to the root workspace (PNPM-GRAPHQL-OVERRIDE-001)
|
|
85
|
-
// and pin codegen to latest (TS-001) before the single clean install. When
|
|
86
|
-
// prewarmed by provision_database these are idempotent and the install is a
|
|
87
|
-
// fast no-op; otherwise this is the first install (PNPM-BUILDS-001).
|
|
88
|
-
await ensureWorkspaceConfig(cwd);
|
|
89
|
-
await pinCodegenLatest(appDir);
|
|
90
|
-
const install = await run('pnpm', ['install', '--no-frozen-lockfile'], cwd);
|
|
91
|
-
if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) {
|
|
92
|
-
return fail(`pnpm install failed:\n${install.out}`);
|
|
93
|
-
}
|
|
94
|
-
const codegen = await runCodegenWithRetry(appDir, {
|
|
95
|
-
NEXT_PUBLIC_DB_NAME: dbName,
|
|
96
|
-
...(planes ? codegenEndpointEnv(planes) : {}),
|
|
97
|
-
});
|
|
98
|
-
if (!codegen.ok) {
|
|
99
|
-
// A reset that survives every internal retry is a backend OUTAGE, not a code
|
|
100
|
-
// or install problem — re-running run_codegen cannot fix it. Say so explicitly
|
|
101
|
-
// so the agent marks sdk-codegen `blocked` instead of looping the tool to the
|
|
102
|
-
// time cap (CODEGEN-OUTAGE-001 / THRASH-001).
|
|
103
|
-
if (FETCH_RESET_RE.test(codegen.out)) {
|
|
104
|
-
return fail('codegen failed: BACKEND OUTAGE — the Constructive backend reset the connection ' +
|
|
105
|
-
'during schema introspection, and it stayed down across every internal retry. ' +
|
|
106
|
-
'This is an EXTERNAL outage, not a code or install problem; re-running run_codegen ' +
|
|
107
|
-
'will NOT fix it. Call run_preflight once — if the backend is down, mark ' +
|
|
108
|
-
'sdk-codegen `blocked` (SERVER-001, BLOCKED-PROCEED-001) and hand back to ' +
|
|
109
|
-
'build-orchestrator. Do NOT loop on run_codegen (THRASH-001).\n\n' +
|
|
110
|
-
codegen.out);
|
|
79
|
+
// `exec` runs every child process (clone, install, codegen); tests inject it.
|
|
80
|
+
export function createRunCodegenTool(exec = run) {
|
|
81
|
+
return {
|
|
82
|
+
name: 'run_codegen',
|
|
83
|
+
label: 'Run codegen',
|
|
84
|
+
description: "Generate the typed GraphQL SDK for the project's Next.js app (packages/app). Runs the exact deterministic sequence — scaffold packages/app from the constructive-app template if it doesn't exist yet, hoist the graphql override to the root workspace so a single grafast resolves, pin codegen to latest, do one clean install, run codegen (retrying transient backend connection resets), and normalize SDK barrels for Turbopack. A composed app (packages/app/.constructive/compose.json) skips the scaffold and those fixups: it installs from its own lockfile only when node_modules is missing and runs its local codegen. Run AFTER provisioning the database and creating schema; it scaffolds packages/app itself, so no separate `pgpm init` step is needed. The scaffold + install are usually prewarmed during provision_database, so this is fast.",
|
|
85
|
+
promptSnippet: 'run_codegen: scaffold packages/app (if missing) + regenerate the typed SDK (workspace-config + clean install + codegen-with-retry + barrel-normalize; a composed app keeps its lockfile and local codegen). Run after schema changes. Gated.',
|
|
86
|
+
parameters: RunCodegenZod,
|
|
87
|
+
async execute(_params, ctx) {
|
|
88
|
+
const started = Date.now();
|
|
89
|
+
const since = (t) => Date.now() - t;
|
|
90
|
+
const cwd = ctx.cwd;
|
|
91
|
+
const appDir = path.join(cwd, 'packages', 'app');
|
|
92
|
+
// A prewarm provision_database started may still be installing; let it
|
|
93
|
+
// finish so this run's install never races it.
|
|
94
|
+
await settlePrewarm(cwd);
|
|
95
|
+
const app = detectAppKind(appDir);
|
|
96
|
+
if (app.kind === 'invalid') {
|
|
97
|
+
return fail(`${app.reason} Recompose packages/app, or remove the marker to use the template flow.`);
|
|
111
98
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
99
|
+
const composed = app.kind === 'composed';
|
|
100
|
+
const appKind = composed ? 'composed' : 'template';
|
|
101
|
+
const timings = {
|
|
102
|
+
scaffoldMs: 0,
|
|
103
|
+
installMs: 0,
|
|
104
|
+
codegenMs: 0,
|
|
105
|
+
barrelsMs: 0,
|
|
106
|
+
totalMs: 0,
|
|
107
|
+
};
|
|
108
|
+
let installed;
|
|
109
|
+
let codegenAttempts;
|
|
110
|
+
const stepDetails = () => ({
|
|
111
|
+
appKind,
|
|
112
|
+
installed,
|
|
113
|
+
codegenAttempts,
|
|
114
|
+
timings: { ...timings, totalMs: since(started) },
|
|
115
|
+
});
|
|
116
|
+
// Materialize packages/app deterministically if it isn't there yet — this
|
|
117
|
+
// replaces the flaky `pgpm init` scaffold step (PGPM-001). When
|
|
118
|
+
// provision_database prewarmed it, this no-ops.
|
|
119
|
+
if (!composed) {
|
|
120
|
+
const t = Date.now();
|
|
121
|
+
const scaffold = await ensureAppScaffold(cwd, exec);
|
|
122
|
+
timings.scaffoldMs = since(t);
|
|
123
|
+
if (!scaffold.ok) {
|
|
124
|
+
return fail(scaffold.out, stepDetails());
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const { databaseName: dbName, apiEndpoint: envApiEndpoint } = await readProjectEnv(cwd);
|
|
128
|
+
if (!dbName) {
|
|
129
|
+
return fail('DATABASE_NAME missing from .env — provision the database first, then re-run.');
|
|
130
|
+
}
|
|
131
|
+
const backend = getHost().backendConfig();
|
|
132
|
+
const apiEndpoint = envApiEndpoint || backend?.apiEndpoint || DEFAULT_API_ENDPOINT;
|
|
133
|
+
const planes = derivePlaneEndpoints(apiEndpoint, dbName);
|
|
134
|
+
// The dev server reads .env.local (NEXT_PUBLIC_DB_NAME + per-DB endpoint
|
|
135
|
+
// overrides so it doesn't fall back to localhost vhosts on a remote
|
|
136
|
+
// backend); the codegen config reads process.env (passed directly below).
|
|
137
|
+
try {
|
|
138
|
+
await writeFile(path.join(appDir, '.env.local'), envLocalContent(dbName, planes));
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return fail(`Failed to write packages/app/.env.local: ${err instanceof Error ? err.message : String(err)}`);
|
|
142
|
+
}
|
|
143
|
+
const tInstall = Date.now();
|
|
144
|
+
if (composed) {
|
|
145
|
+
const install = await ensureComposedInstall(cwd, exec);
|
|
146
|
+
timings.installMs = since(tInstall);
|
|
147
|
+
installed = install.installed;
|
|
148
|
+
if (!install.ok) {
|
|
149
|
+
return fail(install.out, stepDetails());
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
else {
|
|
153
|
+
// Hoist the graphql override to the root workspace (PNPM-GRAPHQL-OVERRIDE-001)
|
|
154
|
+
// and pin codegen to latest (TS-001) before the single clean install. When
|
|
155
|
+
// prewarmed by provision_database these are idempotent and the install is a
|
|
156
|
+
// fast no-op; otherwise this is the first install (PNPM-BUILDS-001).
|
|
157
|
+
await ensureWorkspaceConfig(cwd);
|
|
158
|
+
await pinCodegenLatest(appDir);
|
|
159
|
+
const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], cwd);
|
|
160
|
+
timings.installMs = since(tInstall);
|
|
161
|
+
installed = true;
|
|
162
|
+
if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) {
|
|
163
|
+
return fail(`pnpm install failed:\n${install.out}`, stepDetails());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const tCodegen = Date.now();
|
|
167
|
+
const codegen = await runCodegenWithRetry(appDir, {
|
|
168
|
+
NEXT_PUBLIC_DB_NAME: dbName,
|
|
169
|
+
...(planes ? codegenEndpointEnv(planes) : {}),
|
|
170
|
+
}, exec, composed);
|
|
171
|
+
timings.codegenMs = since(tCodegen);
|
|
172
|
+
codegenAttempts = codegen.attempts;
|
|
173
|
+
if (!codegen.ok) {
|
|
174
|
+
// A refused introspection may be the database itself: deleted or
|
|
175
|
+
// suspended databases answer ACCESS_SUSPENDED on every app API.
|
|
176
|
+
if (REFUSED_RE.test(codegen.out) && planes && (await isDatabaseUnavailable(planes.app))) {
|
|
177
|
+
return fail(`codegen failed: the app database "${dbName}" is unavailable (the backend answered ACCESS_SUSPENDED: it was deleted or is suspended). Re-running run_codegen won't help until it is restored.`, { ...stepDetails(), code: 'database-unavailable' });
|
|
178
|
+
}
|
|
179
|
+
// A reset that survives every internal retry is a backend OUTAGE, not a code
|
|
180
|
+
// or install problem — re-running run_codegen cannot fix it. Say so explicitly
|
|
181
|
+
// so the agent marks sdk-codegen `blocked` instead of looping the tool to the
|
|
182
|
+
// time cap (CODEGEN-OUTAGE-001 / THRASH-001).
|
|
183
|
+
if (FETCH_RESET_RE.test(codegen.out)) {
|
|
184
|
+
return fail('codegen failed: BACKEND OUTAGE — the Constructive backend reset the connection ' +
|
|
185
|
+
'during schema introspection, and it stayed down across every internal retry. ' +
|
|
186
|
+
'This is an EXTERNAL outage, not a code or install problem; re-running run_codegen ' +
|
|
187
|
+
'will NOT fix it. Call run_preflight once — if the backend is down, mark ' +
|
|
188
|
+
'sdk-codegen `blocked` (SERVER-001, BLOCKED-PROCEED-001) and hand back to ' +
|
|
189
|
+
'build-orchestrator. Do NOT loop on run_codegen (THRASH-001).\n\n' +
|
|
190
|
+
codegen.out, stepDetails());
|
|
191
|
+
}
|
|
192
|
+
return fail(`codegen failed:\n${codegen.out}`, stepDetails());
|
|
193
|
+
}
|
|
194
|
+
// Normalize directory barrels so Turbopack can resolve them (TURBOPACK-BARREL-001).
|
|
195
|
+
// Only directories that exist are rewritten, so a config without an admin
|
|
196
|
+
// target leaves nothing pointing at one.
|
|
197
|
+
const tBarrels = Date.now();
|
|
198
|
+
const sdkRoot = path.join(appDir, 'src', 'graphql', 'sdk');
|
|
199
|
+
const barrelsRewritten = normalizeSdkBarrels(sdkRoot);
|
|
200
|
+
timings.barrelsMs = since(tBarrels);
|
|
201
|
+
// Report which app submodules actually generated — a bare scaffold with only
|
|
202
|
+
// index.ts means codegen had no schema to generate against.
|
|
203
|
+
const appSdk = path.join(sdkRoot, 'app');
|
|
204
|
+
const generatedSubmodules = ['hooks', 'orm', 'types'].filter((sub) => existsSync(path.join(appSdk, sub)));
|
|
205
|
+
const message = generatedSubmodules.length > 0
|
|
206
|
+
? `Codegen complete. Generated app submodules: ${generatedSubmodules.join(', ')}. Normalized ${barrelsRewritten} barrel file(s).`
|
|
207
|
+
: `Codegen ran, but no app submodules (hooks/orm/types) were generated — the database may have no entity tables yet. Normalized ${barrelsRewritten} barrel file(s).`;
|
|
208
|
+
return {
|
|
209
|
+
content: [{ type: 'text', text: message }],
|
|
210
|
+
details: {
|
|
211
|
+
success: generatedSubmodules.length > 0,
|
|
212
|
+
message,
|
|
213
|
+
barrelsRewritten,
|
|
214
|
+
generatedSubmodules,
|
|
215
|
+
...stepDetails(),
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
export const runCodegenTool = createRunCodegenTool();
|
|
135
222
|
// Re-exported so provision_database can prewarm the same deterministic scaffold +
|
|
136
223
|
// install concurrently with its backend mutation (kept off run_codegen's path).
|
|
137
224
|
export { prewarmAppWorkspace };
|