@agentic-kit/db-tools 0.11.4 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
package/db-probe.js
CHANGED
|
@@ -1,50 +1,110 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.probeDatabase = probeDatabase;
|
|
4
|
-
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
//
|
|
10
|
-
|
|
4
|
+
exports.probeBoundDatabase = probeBoundDatabase;
|
|
5
|
+
const graphql_http_1 = require("./graphql-http");
|
|
6
|
+
const host_1 = require("./host");
|
|
7
|
+
const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
|
|
8
|
+
const FIELDS = 'id name ownerId hash suspendedAt suspendedReason';
|
|
9
|
+
// A backend from before `hash` and the suspension columns.
|
|
10
|
+
const BASIC_FIELDS = 'id name ownerId';
|
|
11
|
+
const NEWER_FIELD_RE = /\b(hash|suspended_?at|suspended_?reason)\b/i;
|
|
12
|
+
const document = (databaseId, fields) => `
|
|
13
|
+
query ProbeDatabase {
|
|
14
|
+
databases(where: { id: { equalTo: ${JSON.stringify(databaseId)} } }, first: 1) {
|
|
15
|
+
nodes { ${fields} }
|
|
16
|
+
}
|
|
17
|
+
}`;
|
|
18
|
+
// Liveness probe for a bound database, on the platform api. Only 'missing'
|
|
19
|
+
// means the binding is dead: the backend answered and the account can't see
|
|
20
|
+
// the database (deleted, or access to it lost). 'suspended' means it exists
|
|
21
|
+
// and is held, so its data must not be replaced. 'unreachable' is any answer
|
|
22
|
+
// that says nothing about the database (no response, a failing backend, a
|
|
23
|
+
// rejected account credential, an error this probe doesn't know); recovery is
|
|
24
|
+
// a retry or a sign-in, never a reprovision.
|
|
11
25
|
//
|
|
12
26
|
// The probe authenticates with the ACCOUNT bearer, not the project's .env key:
|
|
13
27
|
// the platform api rejects per-database keys, and the singular `database(id:)`
|
|
14
28
|
// field is gone from the current contract, so the lookup goes through the
|
|
15
|
-
// `databases` collection filter
|
|
29
|
+
// `databases` collection filter.
|
|
16
30
|
async function probeDatabase(args) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
const post = args.post ?? graphql_http_1.postGraphQL;
|
|
32
|
+
const { databaseId } = args;
|
|
33
|
+
const ask = (fields) => post({
|
|
34
|
+
endpoint: args.endpoint,
|
|
35
|
+
headers: { Authorization: `Bearer ${args.bearer}` },
|
|
36
|
+
query: document(databaseId, fields),
|
|
37
|
+
});
|
|
38
|
+
let result = await ask(FIELDS);
|
|
39
|
+
if (result.errors.length > 0 && result.errors.some((e) => NEWER_FIELD_RE.test(e?.message ?? ''))) {
|
|
40
|
+
result = await ask(BASIC_FIELDS);
|
|
41
|
+
}
|
|
42
|
+
if (result.errors.length > 0)
|
|
43
|
+
return unreachable(databaseId, result.errors, args.signInHint);
|
|
44
|
+
const nodes = result.data?.databases?.nodes;
|
|
45
|
+
if (!Array.isArray(nodes)) {
|
|
46
|
+
return { status: 'unreachable', databaseId, cause: 'error', detail: 'the backend answered without a databases list' };
|
|
47
|
+
}
|
|
48
|
+
const node = nodes[0];
|
|
49
|
+
if (!node)
|
|
50
|
+
return { status: 'missing', databaseId };
|
|
51
|
+
const found = {
|
|
52
|
+
databaseId,
|
|
53
|
+
name: node.name ?? undefined,
|
|
54
|
+
ownerId: node.ownerId ?? undefined,
|
|
55
|
+
hash: node.hash ?? undefined,
|
|
56
|
+
};
|
|
57
|
+
if (node.suspendedAt) {
|
|
58
|
+
return {
|
|
59
|
+
status: 'suspended',
|
|
60
|
+
...found,
|
|
61
|
+
suspendedAt: node.suspendedAt,
|
|
62
|
+
suspendedReason: node.suspendedReason ?? undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { status: 'found', ...found };
|
|
66
|
+
}
|
|
67
|
+
function unreachable(databaseId, errors, signInHint) {
|
|
68
|
+
const detail = (0, graphql_http_1.errorsText)(errors);
|
|
69
|
+
switch ((0, graphql_http_1.classifyBackendErrors)(errors)) {
|
|
70
|
+
case 'network':
|
|
71
|
+
return { status: 'unreachable', databaseId, cause: 'network', detail };
|
|
72
|
+
case 'server':
|
|
73
|
+
return { status: 'unreachable', databaseId, cause: 'server', detail };
|
|
74
|
+
case 'auth':
|
|
75
|
+
case 'forbidden':
|
|
76
|
+
// A rejected ACCOUNT credential is never evidence the binding is dead.
|
|
40
77
|
return {
|
|
41
|
-
|
|
42
|
-
|
|
78
|
+
status: 'unreachable',
|
|
79
|
+
databaseId,
|
|
80
|
+
cause: 'auth',
|
|
81
|
+
detail: `${detail}: the account credential was rejected. ${signInHint ?? 'Sign in again, then retry.'}`,
|
|
43
82
|
};
|
|
44
|
-
|
|
45
|
-
|
|
83
|
+
default:
|
|
84
|
+
return { status: 'unreachable', databaseId, cause: 'error', detail };
|
|
46
85
|
}
|
|
47
|
-
|
|
48
|
-
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Probe a database with the host's account bearer on its configured control
|
|
89
|
+
* endpoint (never a project-pinned one). Without a bearer the answer is
|
|
90
|
+
* unreachable with cause 'auth'.
|
|
91
|
+
*/
|
|
92
|
+
async function probeBoundDatabase(databaseId) {
|
|
93
|
+
const host = (0, host_1.getHost)();
|
|
94
|
+
const account = host.account();
|
|
95
|
+
const bearer = account?.apiKey ?? account?.accessToken;
|
|
96
|
+
if (!bearer) {
|
|
97
|
+
return {
|
|
98
|
+
status: 'unreachable',
|
|
99
|
+
databaseId,
|
|
100
|
+
cause: 'auth',
|
|
101
|
+
detail: `No usable account credential. ${host.signInHint ?? 'Sign in to the app, then retry.'}`,
|
|
102
|
+
};
|
|
49
103
|
}
|
|
104
|
+
return probeDatabase({
|
|
105
|
+
endpoint: host.backendConfig()?.apiEndpoint || DEFAULT_API_ENDPOINT,
|
|
106
|
+
bearer,
|
|
107
|
+
databaseId,
|
|
108
|
+
signInHint: host.signInHint,
|
|
109
|
+
});
|
|
50
110
|
}
|
package/esm/app-workspace.d.ts
CHANGED
|
@@ -1,16 +1,43 @@
|
|
|
1
1
|
export declare const NATIVE_BUILD_DEPS: string[];
|
|
2
2
|
export declare const IGNORED_BUILDS_RE: RegExp;
|
|
3
|
+
export declare const COMPOSE_MARKER: string;
|
|
4
|
+
export type AppKind = {
|
|
5
|
+
kind: 'composed';
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'template';
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'invalid';
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Which flow packages/app takes. Composed needs the marker to be a JSON object
|
|
14
|
+
* and the app's own lockfile beside it. A marker that fails either check is
|
|
15
|
+
* neither: running the template fixups would rewrite a composed app, so
|
|
16
|
+
* callers stop and report the reason instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectAppKind(appDir: string): AppKind;
|
|
19
|
+
export type RunCommand = (cmd: string, args: string[], cwd: string, env?: Record<string, string>) => Promise<{
|
|
20
|
+
ok: boolean;
|
|
21
|
+
out: string;
|
|
22
|
+
}>;
|
|
3
23
|
export declare function run(cmd: string, args: string[], cwd: string, env?: Record<string, string>): Promise<{
|
|
4
24
|
ok: boolean;
|
|
5
25
|
out: string;
|
|
6
26
|
}>;
|
|
7
27
|
export declare function ensureWorkspaceConfig(cwd: string): Promise<void>;
|
|
8
28
|
export declare function pinCodegenLatest(appDir: string): Promise<void>;
|
|
9
|
-
export declare function ensureAppScaffold(cwd: string): Promise<{
|
|
29
|
+
export declare function ensureAppScaffold(cwd: string, exec?: RunCommand): Promise<{
|
|
30
|
+
ok: boolean;
|
|
31
|
+
out: string;
|
|
32
|
+
}>;
|
|
33
|
+
export declare function ensureComposedInstall(cwd: string, exec?: RunCommand): Promise<{
|
|
10
34
|
ok: boolean;
|
|
11
35
|
out: string;
|
|
36
|
+
installed: boolean;
|
|
12
37
|
}>;
|
|
13
|
-
export declare function prewarmAppWorkspace(cwd: string): Promise<{
|
|
38
|
+
export declare function prewarmAppWorkspace(cwd: string, exec?: RunCommand): Promise<{
|
|
14
39
|
ok: boolean;
|
|
15
40
|
out: string;
|
|
16
41
|
}>;
|
|
42
|
+
/** Wait for a prewarm still running for `cwd`; its outcome doesn't matter here. */
|
|
43
|
+
export declare function settlePrewarm(cwd: string): Promise<void>;
|
package/esm/app-workspace.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
@@ -22,6 +22,38 @@ export const IGNORED_BUILDS_RE = /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/
|
|
|
22
22
|
const DEFAULT_GRAPHQL_OVERRIDE = '16.14.0';
|
|
23
23
|
const TEMPLATE_REPO = 'https://github.com/constructive-io/sandbox-templates';
|
|
24
24
|
const TEMPLATE_SUBDIR = path.join('nextjs', 'constructive-app');
|
|
25
|
+
// A composed app carries this marker, written when its template was composed.
|
|
26
|
+
// It ships its own pinned package.json and pnpm-lock.yaml, so none of the
|
|
27
|
+
// constructive-app fixups below apply to it.
|
|
28
|
+
export const COMPOSE_MARKER = path.join('.constructive', 'compose.json');
|
|
29
|
+
/**
|
|
30
|
+
* Which flow packages/app takes. Composed needs the marker to be a JSON object
|
|
31
|
+
* and the app's own lockfile beside it. A marker that fails either check is
|
|
32
|
+
* neither: running the template fixups would rewrite a composed app, so
|
|
33
|
+
* callers stop and report the reason instead.
|
|
34
|
+
*/
|
|
35
|
+
export function detectAppKind(appDir) {
|
|
36
|
+
const marker = path.join(appDir, COMPOSE_MARKER);
|
|
37
|
+
if (!existsSync(marker))
|
|
38
|
+
return { kind: 'template' };
|
|
39
|
+
let parsed;
|
|
40
|
+
try {
|
|
41
|
+
parsed = JSON.parse(readFileSync(marker, 'utf8'));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return { kind: 'invalid', reason: `packages/app/${COMPOSE_MARKER} is not valid JSON.` };
|
|
45
|
+
}
|
|
46
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
47
|
+
return { kind: 'invalid', reason: `packages/app/${COMPOSE_MARKER} is not a JSON object.` };
|
|
48
|
+
}
|
|
49
|
+
if (!existsSync(path.join(appDir, 'pnpm-lock.yaml'))) {
|
|
50
|
+
return {
|
|
51
|
+
kind: 'invalid',
|
|
52
|
+
reason: `packages/app has ${COMPOSE_MARKER} but no pnpm-lock.yaml, so it can't be installed as composed.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { kind: 'composed' };
|
|
56
|
+
}
|
|
25
57
|
export async function run(cmd, args, cwd, env) {
|
|
26
58
|
try {
|
|
27
59
|
const { stdout, stderr } = await execFileAsync(cmd, args, {
|
|
@@ -81,14 +113,14 @@ export async function pinCodegenLatest(appDir) {
|
|
|
81
113
|
// its CLI is unreliable in the non-TTY agent shell, which made the agent thrash
|
|
82
114
|
// for minutes. Materialize `packages/app` deterministically by shallow-cloning
|
|
83
115
|
// the template instead (PGPM-001).
|
|
84
|
-
export async function ensureAppScaffold(cwd) {
|
|
116
|
+
export async function ensureAppScaffold(cwd, exec = run) {
|
|
85
117
|
const appDir = path.join(cwd, 'packages', 'app');
|
|
86
118
|
if (existsSync(path.join(appDir, 'package.json'))) {
|
|
87
119
|
return { ok: true, out: 'packages/app already scaffolded' };
|
|
88
120
|
}
|
|
89
121
|
const tmp = path.join(os.tmpdir(), `airpage-app-template-${process.pid}`);
|
|
90
122
|
await rm(tmp, { recursive: true, force: true });
|
|
91
|
-
const clone = await
|
|
123
|
+
const clone = await exec('git', ['clone', '--depth', '1', TEMPLATE_REPO, tmp], cwd);
|
|
92
124
|
if (!clone.ok) {
|
|
93
125
|
await rm(tmp, { recursive: true, force: true });
|
|
94
126
|
return { ok: false, out: `Template clone failed:\n${clone.out}` };
|
|
@@ -112,21 +144,77 @@ export async function ensureAppScaffold(cwd) {
|
|
|
112
144
|
await rm(tmp, { recursive: true, force: true });
|
|
113
145
|
return { ok: true, out: 'Scaffolded packages/app from the constructive-app template.' };
|
|
114
146
|
}
|
|
147
|
+
// pnpm writes node_modules/.modules.yaml once an install finishes linking, so a
|
|
148
|
+
// partial install (interrupted, or failed mid-way) lacks it. Codegen also
|
|
149
|
+
// needs its bin, so both must be there to skip the install.
|
|
150
|
+
function composedInstallComplete(appDir) {
|
|
151
|
+
const modules = path.join(appDir, 'node_modules');
|
|
152
|
+
return (existsSync(path.join(modules, '.modules.yaml')) &&
|
|
153
|
+
existsSync(path.join(modules, '.bin', 'graphql-codegen')));
|
|
154
|
+
}
|
|
155
|
+
// Install a composed app exactly as its lockfile pins it, unless a finished
|
|
156
|
+
// install is already there. The install runs in packages/app against its own
|
|
157
|
+
// lockfile, from the store where it can. A nested pnpm-workspace.yaml makes the
|
|
158
|
+
// app its own workspace root, so the config is left alone; without one, a root
|
|
159
|
+
// pnpm-workspace.yaml would pull the install up to the project root, where no
|
|
160
|
+
// lockfile exists, so pnpm is told to ignore it instead of rewriting it.
|
|
161
|
+
export async function ensureComposedInstall(cwd, exec = run) {
|
|
162
|
+
const appDir = path.join(cwd, 'packages', 'app');
|
|
163
|
+
if (composedInstallComplete(appDir)) {
|
|
164
|
+
return { ok: true, out: 'packages/app dependencies already installed.', installed: false };
|
|
165
|
+
}
|
|
166
|
+
const args = ['install', '--frozen-lockfile', '--prefer-offline'];
|
|
167
|
+
const ownsWorkspace = existsSync(path.join(appDir, 'pnpm-workspace.yaml'));
|
|
168
|
+
if (!ownsWorkspace && existsSync(path.join(cwd, 'pnpm-workspace.yaml'))) {
|
|
169
|
+
args.push('--ignore-workspace');
|
|
170
|
+
}
|
|
171
|
+
const install = await exec('pnpm', args, appDir);
|
|
172
|
+
if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) {
|
|
173
|
+
return { ok: false, out: `pnpm install failed:\n${install.out}`, installed: true };
|
|
174
|
+
}
|
|
175
|
+
return { ok: true, out: 'Installed packages/app from its lockfile.', installed: true };
|
|
176
|
+
}
|
|
115
177
|
// Scaffold packages/app, hoist the workspace config, pin codegen, and run the one
|
|
116
178
|
// clean install. All of this is database-INDEPENDENT, so it can be prewarmed
|
|
117
179
|
// concurrently with the ~90s provision_database backend mutation to keep clone +
|
|
118
180
|
// install off run_codegen's critical path. Best-effort and fully idempotent:
|
|
119
181
|
// run_codegen re-runs the same steps, finding the scaffold present and the install
|
|
120
182
|
// already satisfied (a fast no-op). Errors are swallowed by callers that prewarm —
|
|
121
|
-
// run_codegen surfaces any real failure later.
|
|
122
|
-
|
|
123
|
-
|
|
183
|
+
// run_codegen surfaces any real failure later. A composed app only gets its
|
|
184
|
+
// frozen install, and an invalid marker leaves the app untouched.
|
|
185
|
+
//
|
|
186
|
+
// One prewarm runs per project at a time: a call while one is in flight gets
|
|
187
|
+
// that same run, so a retried provision_database (or run_codegen, via
|
|
188
|
+
// settlePrewarm) never starts a second install beside it.
|
|
189
|
+
const prewarmsInFlight = new Map();
|
|
190
|
+
export function prewarmAppWorkspace(cwd, exec = run) {
|
|
191
|
+
const key = path.resolve(cwd);
|
|
192
|
+
const inFlight = prewarmsInFlight.get(key);
|
|
193
|
+
if (inFlight)
|
|
194
|
+
return inFlight;
|
|
195
|
+
const started = prewarm(cwd, exec).finally(() => prewarmsInFlight.delete(key));
|
|
196
|
+
prewarmsInFlight.set(key, started);
|
|
197
|
+
return started;
|
|
198
|
+
}
|
|
199
|
+
/** Wait for a prewarm still running for `cwd`; its outcome doesn't matter here. */
|
|
200
|
+
export async function settlePrewarm(cwd) {
|
|
201
|
+
await prewarmsInFlight.get(path.resolve(cwd))?.catch(() => undefined);
|
|
202
|
+
}
|
|
203
|
+
async function prewarm(cwd, exec) {
|
|
204
|
+
const appDir = path.join(cwd, 'packages', 'app');
|
|
205
|
+
const app = detectAppKind(appDir);
|
|
206
|
+
if (app.kind === 'invalid')
|
|
207
|
+
return { ok: false, out: app.reason };
|
|
208
|
+
if (app.kind === 'composed') {
|
|
209
|
+
const install = await ensureComposedInstall(cwd, exec);
|
|
210
|
+
return install.ok ? { ok: true, out: 'Prewarmed packages/app install.' } : install;
|
|
211
|
+
}
|
|
212
|
+
const scaffold = await ensureAppScaffold(cwd, exec);
|
|
124
213
|
if (!scaffold.ok)
|
|
125
214
|
return scaffold;
|
|
126
|
-
const appDir = path.join(cwd, 'packages', 'app');
|
|
127
215
|
await ensureWorkspaceConfig(cwd);
|
|
128
216
|
await pinCodegenLatest(appDir);
|
|
129
|
-
const install = await
|
|
217
|
+
const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], cwd);
|
|
130
218
|
if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) {
|
|
131
219
|
return { ok: false, out: `pnpm install failed:\n${install.out}` };
|
|
132
220
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { ModulesClient } from './context';
|
|
2
|
+
/**
|
|
3
|
+
* How long a tool waits for its build: the build's own 120 s statement
|
|
4
|
+
* timeout, plus as long again for the builds queued ahead of it (a cold build
|
|
5
|
+
* of a curated blueprint takes 15-40 s). It stays under the 5-minute limit
|
|
6
|
+
* hosts put on the long database tools, so the tool reports the wait itself
|
|
7
|
+
* instead of being cut off.
|
|
8
|
+
*/
|
|
9
|
+
export declare const CONSTRUCTION_DEADLINE_MS = 240000;
|
|
10
|
+
export declare const CONSTRUCTION_POLL_MS = 2000;
|
|
11
|
+
/**
|
|
12
|
+
* Unreadable statuses in a row tolerated before giving up. The build goes on
|
|
13
|
+
* whether or not a read gets through, so a dropped request or a momentary 502
|
|
14
|
+
* must not end the wait.
|
|
15
|
+
*/
|
|
16
|
+
export declare const MAX_CONSECUTIVE_READ_FAILURES = 3;
|
|
17
|
+
/** Table name → id, for the tables a construction created. */
|
|
18
|
+
export type ConstructionTableMap = Record<string, string>;
|
|
19
|
+
export type WaitForConstructionOptions = {
|
|
20
|
+
signal?: AbortSignal;
|
|
21
|
+
deadlineMs?: number;
|
|
22
|
+
pollMs?: number;
|
|
23
|
+
now?: () => number;
|
|
24
|
+
sleep?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Why a construction failed, from its `error` column. That column came with
|
|
28
|
+
* the queued build, and the modules SDK predates it, so it is read on its own
|
|
29
|
+
* after a failure: the status reads stay valid on a backend without it. The
|
|
30
|
+
* SDK's select type doesn't list `error`, but the ORM writes each select key
|
|
31
|
+
* as the field name.
|
|
32
|
+
*/
|
|
33
|
+
export declare function readConstructionFailure(modules: ModulesClient, constructionId: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Wait for a construction to finish. Resolves with its table map once it is
|
|
36
|
+
* constructed, and throws with the backend's coded error once it failed, or
|
|
37
|
+
* when the deadline passes, the signal aborts, or its status can't be read.
|
|
38
|
+
*/
|
|
39
|
+
export declare function waitForConstruction(modules: ModulesClient, constructionId: string, options?: WaitForConstructionOptions): Promise<ConstructionTableMap>;
|
|
40
|
+
/**
|
|
41
|
+
* A build that failed after the backend queued it, or that the wait gave up
|
|
42
|
+
* on. The construction carries on without the caller, so constructing again
|
|
43
|
+
* would queue the same tables a second time.
|
|
44
|
+
*/
|
|
45
|
+
export declare class BlueprintBuildError extends Error {
|
|
46
|
+
readonly constructionId: string;
|
|
47
|
+
constructor(message: string, constructionId: string);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Construct a blueprint into a schema and wait for the build. Resolves with
|
|
51
|
+
* the construction and the created tables. Once the backend has queued the
|
|
52
|
+
* build, every failure is a BlueprintBuildError with its construction id.
|
|
53
|
+
*/
|
|
54
|
+
export declare function buildBlueprint(modules: ModulesClient, input: {
|
|
55
|
+
blueprintId: string;
|
|
56
|
+
schemaId: string;
|
|
57
|
+
}, options?: WaitForConstructionOptions): Promise<{
|
|
58
|
+
constructionId: string;
|
|
59
|
+
tables: ConstructionTableMap;
|
|
60
|
+
}>;
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Building a blueprint: constructBlueprint enqueues a `blueprint:construct`
|
|
2
|
+
// job and returns the construction id at once (constructive-db#4156). A worker
|
|
3
|
+
// builds one blueprint at a time, each under a 120 s statement timeout, and
|
|
4
|
+
// moves the construction row pending → constructing → constructed | failed,
|
|
5
|
+
// with a failed build's coded error in `error`. A backend from before that
|
|
6
|
+
// builds inside the mutation and raises the build's error from it, so the
|
|
7
|
+
// first read already says constructed, and the same wait serves both.
|
|
8
|
+
import { isTransientProvisionError } from './provision-database/request-database';
|
|
9
|
+
/**
|
|
10
|
+
* How long a tool waits for its build: the build's own 120 s statement
|
|
11
|
+
* timeout, plus as long again for the builds queued ahead of it (a cold build
|
|
12
|
+
* of a curated blueprint takes 15-40 s). It stays under the 5-minute limit
|
|
13
|
+
* hosts put on the long database tools, so the tool reports the wait itself
|
|
14
|
+
* instead of being cut off.
|
|
15
|
+
*/
|
|
16
|
+
export const CONSTRUCTION_DEADLINE_MS = 240_000;
|
|
17
|
+
export const CONSTRUCTION_POLL_MS = 2_000;
|
|
18
|
+
/**
|
|
19
|
+
* Unreadable statuses in a row tolerated before giving up. The build goes on
|
|
20
|
+
* whether or not a read gets through, so a dropped request or a momentary 502
|
|
21
|
+
* must not end the wait.
|
|
22
|
+
*/
|
|
23
|
+
export const MAX_CONSECUTIVE_READ_FAILURES = 3;
|
|
24
|
+
const MAY_STILL_FINISH = 'The build may still finish on the backend: read the schema before you call again.';
|
|
25
|
+
const errorText = (err) => (err instanceof Error ? err.message : String(err));
|
|
26
|
+
function stopped() {
|
|
27
|
+
return new Error(`Stopped waiting for the blueprint build. ${MAY_STILL_FINISH}`);
|
|
28
|
+
}
|
|
29
|
+
function abortableSleep(ms, signal) {
|
|
30
|
+
return new Promise((resolve, reject) => {
|
|
31
|
+
if (signal?.aborted)
|
|
32
|
+
return reject(stopped());
|
|
33
|
+
const onAbort = () => {
|
|
34
|
+
clearTimeout(timer);
|
|
35
|
+
reject(stopped());
|
|
36
|
+
};
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
signal?.removeEventListener('abort', onAbort);
|
|
39
|
+
resolve();
|
|
40
|
+
}, ms);
|
|
41
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function tableMapOf(raw) {
|
|
45
|
+
if (!raw || typeof raw !== 'object')
|
|
46
|
+
return {};
|
|
47
|
+
return Object.fromEntries(Object.entries(raw).filter((entry) => typeof entry[1] === 'string'));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A failed construction's coded error on one line: `CODE: message` for a
|
|
51
|
+
* database error, `CODE {context}` for one raised through errors.raise_error.
|
|
52
|
+
*/
|
|
53
|
+
function describeConstructionError(error) {
|
|
54
|
+
if (!error || typeof error !== 'object')
|
|
55
|
+
return null;
|
|
56
|
+
const { code, message, context } = error;
|
|
57
|
+
const head = [code, message].filter((part) => typeof part === 'string' && part).join(': ');
|
|
58
|
+
const hasContext = Boolean(context && typeof context === 'object' && Object.keys(context).length);
|
|
59
|
+
const tail = hasContext ? JSON.stringify(context) : '';
|
|
60
|
+
return [head, tail].filter(Boolean).join(' ') || null;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Why a construction failed, from its `error` column. That column came with
|
|
64
|
+
* the queued build, and the modules SDK predates it, so it is read on its own
|
|
65
|
+
* after a failure: the status reads stay valid on a backend without it. The
|
|
66
|
+
* SDK's select type doesn't list `error`, but the ORM writes each select key
|
|
67
|
+
* as the field name.
|
|
68
|
+
*/
|
|
69
|
+
export async function readConstructionFailure(modules, constructionId) {
|
|
70
|
+
try {
|
|
71
|
+
const result = await modules.blueprintConstruction
|
|
72
|
+
.findOne({ id: constructionId, select: { error: true } })
|
|
73
|
+
.unwrap();
|
|
74
|
+
const row = result.blueprintConstruction;
|
|
75
|
+
return describeConstructionError(row?.error) ?? 'the backend gave no reason';
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
return `the backend gave no reason (reading it failed: ${errorText(err)})`;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Wait for a construction to finish. Resolves with its table map once it is
|
|
83
|
+
* constructed, and throws with the backend's coded error once it failed, or
|
|
84
|
+
* when the deadline passes, the signal aborts, or its status can't be read.
|
|
85
|
+
*/
|
|
86
|
+
export async function waitForConstruction(modules, constructionId, options = {}) {
|
|
87
|
+
const { signal, deadlineMs = CONSTRUCTION_DEADLINE_MS, pollMs = CONSTRUCTION_POLL_MS, now = Date.now, sleep = abortableSleep, } = options;
|
|
88
|
+
const deadline = now() + deadlineMs;
|
|
89
|
+
let status = 'pending';
|
|
90
|
+
let readFailures = 0;
|
|
91
|
+
for (;;) {
|
|
92
|
+
if (signal?.aborted)
|
|
93
|
+
throw stopped();
|
|
94
|
+
let row;
|
|
95
|
+
try {
|
|
96
|
+
const result = await modules.blueprintConstruction
|
|
97
|
+
.findOne({ id: constructionId, select: { status: true, tableMap: true } })
|
|
98
|
+
.unwrap();
|
|
99
|
+
row = result.blueprintConstruction;
|
|
100
|
+
readFailures = 0;
|
|
101
|
+
}
|
|
102
|
+
catch (err) {
|
|
103
|
+
// A GraphQL error is the backend's answer and won't change on a reread.
|
|
104
|
+
if (!isTransientProvisionError(err) || ++readFailures >= MAX_CONSECUTIVE_READ_FAILURES) {
|
|
105
|
+
throw new Error(`Could not read the blueprint build's status: ${errorText(err)}. ${MAY_STILL_FINISH}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (row === null)
|
|
109
|
+
throw new Error(`The backend has no blueprint construction ${constructionId}.`);
|
|
110
|
+
if (row?.status === 'constructed')
|
|
111
|
+
return tableMapOf(row.tableMap);
|
|
112
|
+
if (row?.status === 'failed') {
|
|
113
|
+
throw new Error(`The blueprint build failed: ${await readConstructionFailure(modules, constructionId)}`);
|
|
114
|
+
}
|
|
115
|
+
status = row?.status ?? status;
|
|
116
|
+
if (now() >= deadline) {
|
|
117
|
+
const where = status === 'pending' ? "waiting in the backend's build queue" : 'building';
|
|
118
|
+
throw new Error(`The blueprint build was still ${where} after ${Math.round(deadlineMs / 1000)} s. ${MAY_STILL_FINISH}`);
|
|
119
|
+
}
|
|
120
|
+
await sleep(pollMs, signal);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A build that failed after the backend queued it, or that the wait gave up
|
|
125
|
+
* on. The construction carries on without the caller, so constructing again
|
|
126
|
+
* would queue the same tables a second time.
|
|
127
|
+
*/
|
|
128
|
+
export class BlueprintBuildError extends Error {
|
|
129
|
+
constructionId;
|
|
130
|
+
constructor(message, constructionId) {
|
|
131
|
+
super(message);
|
|
132
|
+
this.name = 'BlueprintBuildError';
|
|
133
|
+
this.constructionId = constructionId;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Construct a blueprint into a schema and wait for the build. Resolves with
|
|
138
|
+
* the construction and the created tables. Once the backend has queued the
|
|
139
|
+
* build, every failure is a BlueprintBuildError with its construction id.
|
|
140
|
+
*/
|
|
141
|
+
export async function buildBlueprint(modules, input, options = {}) {
|
|
142
|
+
const result = await modules.mutation
|
|
143
|
+
.constructBlueprint({ input }, { select: { result: true } })
|
|
144
|
+
.unwrap();
|
|
145
|
+
const constructionId = result.constructBlueprint?.result;
|
|
146
|
+
if (!constructionId)
|
|
147
|
+
throw new Error('constructBlueprint returned no construction id');
|
|
148
|
+
try {
|
|
149
|
+
return { constructionId, tables: await waitForConstruction(modules, constructionId, options) };
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
throw new BlueprintBuildError(errorText(err), constructionId);
|
|
153
|
+
}
|
|
154
|
+
}
|
package/esm/context.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { api, modules } from '@constructive-io/sdk';
|
|
2
|
+
import { type UnreachableCause } from './db-probe';
|
|
2
3
|
export type ApiClient = ReturnType<typeof api.createClient>;
|
|
3
4
|
export type ModulesClient = ReturnType<typeof modules.createClient>;
|
|
4
5
|
export type ProjectContext = {
|
|
@@ -12,12 +13,15 @@ export type ProjectContext = {
|
|
|
12
13
|
databaseName: string;
|
|
13
14
|
accessToken: string;
|
|
14
15
|
dataEndpoint: string;
|
|
16
|
+
hash?: string;
|
|
15
17
|
};
|
|
16
|
-
export type ProjectContextFailureCode = 'no-env' | 'missing-credentials' | 'db-missing' | 'account-mismatch' | 'backend-unreachable' | 'schema-unresolved';
|
|
18
|
+
export type ProjectContextFailureCode = 'no-env' | 'missing-credentials' | 'db-missing' | 'db-suspended' | 'account-mismatch' | 'backend-unreachable' | 'schema-unresolved';
|
|
17
19
|
export type ResolveResult = {
|
|
18
20
|
context: ProjectContext | null;
|
|
19
21
|
reason: string;
|
|
20
22
|
code?: ProjectContextFailureCode;
|
|
23
|
+
/** Why the backend couldn't answer, with code 'backend-unreachable'. */
|
|
24
|
+
cause?: UnreachableCause;
|
|
21
25
|
};
|
|
22
26
|
export declare const CONTEXT_ENV_KEYS: readonly ["ACCESS_TOKEN", "DATABASE_ID", "API_ENDPOINT", "MODULES_ENDPOINT", "DATABASE_NAME", "OWNER_ID"];
|
|
23
27
|
export type ContextEnvKey = (typeof CONTEXT_ENV_KEYS)[number];
|
package/esm/context.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { api, auth, modules } from '@constructive-io/sdk';
|
|
4
4
|
import { parseDotenv } from '12factor-env/dotenv';
|
|
5
5
|
import { probeDatabase } from './db-probe';
|
|
6
|
-
import { DEFAULT_DATA_TOKEN_SKEW_MS, getHost } from './host';
|
|
6
|
+
import { DEFAULT_DATA_TOKEN_SKEW_MS, getHost, reprovisionAdvice } from './host';
|
|
7
7
|
const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql';
|
|
8
8
|
const DEFAULT_MODULES_ENDPOINT = 'http://modules.localhost:3000/graphql';
|
|
9
9
|
// The project values pi needs, and where they come from. A scaffolded project
|
|
@@ -107,17 +107,18 @@ export async function resolveProjectContext(input, options = {}) {
|
|
|
107
107
|
databaseId,
|
|
108
108
|
signInHint: host.signInHint,
|
|
109
109
|
});
|
|
110
|
-
if (probe.
|
|
110
|
+
if (probe.status === 'unreachable') {
|
|
111
111
|
return {
|
|
112
112
|
context: null,
|
|
113
|
-
reason:
|
|
113
|
+
reason: unreachableReason(controlApiEndpoint, probe.cause, probe.detail),
|
|
114
114
|
code: 'backend-unreachable',
|
|
115
|
+
cause: probe.cause,
|
|
115
116
|
};
|
|
116
117
|
}
|
|
117
|
-
if (probe.
|
|
118
|
+
if (probe.status === 'missing') {
|
|
118
119
|
return {
|
|
119
120
|
context: null,
|
|
120
|
-
reason: `The bound database (DATABASE_ID=${databaseId}) no longer exists on this backend
|
|
121
|
+
reason: `The bound database (DATABASE_ID=${databaseId}) no longer exists on this backend, or this account lost access to it. ${reprovisionAdvice(host)}`,
|
|
121
122
|
code: 'db-missing',
|
|
122
123
|
};
|
|
123
124
|
}
|
|
@@ -134,10 +135,20 @@ export async function resolveProjectContext(input, options = {}) {
|
|
|
134
135
|
probe.ownerId !== sessionUserId) {
|
|
135
136
|
return {
|
|
136
137
|
context: null,
|
|
137
|
-
reason: `The bound database (DATABASE_ID=${databaseId}) was provisioned under a different account than the one signed in.
|
|
138
|
+
reason: `The bound database (DATABASE_ID=${databaseId}) was provisioned under a different account than the one signed in. ${reprovisionAdvice(host)}`,
|
|
138
139
|
code: 'account-mismatch',
|
|
139
140
|
};
|
|
140
141
|
}
|
|
142
|
+
// A suspended database still holds its data, so it is never a reason to
|
|
143
|
+
// reprovision: nothing can read or change it until the hold is lifted.
|
|
144
|
+
if (probe.status === 'suspended') {
|
|
145
|
+
const why = probe.suspendedReason ? ` (${probe.suspendedReason})` : '';
|
|
146
|
+
return {
|
|
147
|
+
context: null,
|
|
148
|
+
reason: `The bound database (DATABASE_ID=${databaseId}) is suspended${why} since ${probe.suspendedAt}. Its data is kept, but nothing can read or change it until the suspension is lifted.`,
|
|
149
|
+
code: 'db-suspended',
|
|
150
|
+
};
|
|
151
|
+
}
|
|
141
152
|
const databaseName = env.DATABASE_NAME || probe.name || '';
|
|
142
153
|
const ownerId = probe.ownerId || env.OWNER_ID || undefined;
|
|
143
154
|
const dataEndpoint = databaseName
|
|
@@ -160,10 +171,24 @@ export async function resolveProjectContext(input, options = {}) {
|
|
|
160
171
|
databaseName,
|
|
161
172
|
accessToken,
|
|
162
173
|
dataEndpoint,
|
|
174
|
+
hash: probe.hash,
|
|
163
175
|
},
|
|
164
176
|
reason: '',
|
|
165
177
|
};
|
|
166
178
|
}
|
|
179
|
+
function unreachableReason(endpoint, cause, detail) {
|
|
180
|
+
switch (cause) {
|
|
181
|
+
case 'auth':
|
|
182
|
+
// The probe's detail says the credential was rejected and how to sign in.
|
|
183
|
+
return `Could not check the bound database on the Constructive backend at ${endpoint}: ${detail}`;
|
|
184
|
+
case 'error':
|
|
185
|
+
return `The Constructive backend at ${endpoint} answered with an error while checking the bound database (${detail}). Retry; if it keeps failing, check the backend's logs.`;
|
|
186
|
+
case 'server':
|
|
187
|
+
return `The Constructive backend at ${endpoint} is failing (${detail}). Check it is running, then retry.`;
|
|
188
|
+
default:
|
|
189
|
+
return `Could not reach the Constructive backend at ${endpoint} (${detail}). Check it is running, then retry.`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
167
192
|
const needsAuthReason = () => `Not signed in to the app database yet. ${getHost().signInHint ??
|
|
168
193
|
'Sign in from the Sheets tab, or open the Preview and sign in to your app, then try again.'}`;
|
|
169
194
|
// Resolve a data-plane token for record operations: serve the broker's active
|