@agentic-kit/db-tools 0.11.5 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +40 -1
  2. package/app-workspace.d.ts +29 -2
  3. package/app-workspace.js +99 -8
  4. package/blueprint-construction.d.ts +60 -0
  5. package/blueprint-construction.js +161 -0
  6. package/context.d.ts +5 -1
  7. package/context.js +30 -5
  8. package/db-probe.d.ts +25 -17
  9. package/db-probe.js +97 -37
  10. package/esm/app-workspace.d.ts +29 -2
  11. package/esm/app-workspace.js +96 -8
  12. package/esm/blueprint-construction.d.ts +60 -0
  13. package/esm/blueprint-construction.js +154 -0
  14. package/esm/context.d.ts +5 -1
  15. package/esm/context.js +31 -6
  16. package/esm/db-probe.d.ts +25 -17
  17. package/esm/db-probe.js +96 -37
  18. package/esm/graphql-http.d.ts +35 -0
  19. package/esm/graphql-http.js +134 -0
  20. package/esm/host.d.ts +46 -0
  21. package/esm/host.js +9 -0
  22. package/esm/index.d.ts +11 -2
  23. package/esm/index.js +6 -0
  24. package/esm/provision-database/env-file.d.ts +23 -2
  25. package/esm/provision-database/env-file.js +86 -27
  26. package/esm/provision-database/pending-ticket.d.ts +33 -0
  27. package/esm/provision-database/pending-ticket.js +63 -0
  28. package/esm/provision-database/provision.d.ts +71 -0
  29. package/esm/provision-database/provision.js +557 -0
  30. package/esm/provision-database/request-database.d.ts +55 -3
  31. package/esm/provision-database/request-database.js +118 -17
  32. package/esm/schema-model/normalize.d.ts +21 -0
  33. package/esm/schema-model/normalize.js +244 -0
  34. package/esm/schema-model.d.ts +110 -0
  35. package/esm/schema-model.js +303 -0
  36. package/esm/tools/add-policies.d.ts +1 -1
  37. package/esm/tools/add-records.d.ts +1 -0
  38. package/esm/tools/add-records.js +18 -6
  39. package/esm/tools/add-relation.js +2 -3
  40. package/esm/tools/create-api-key.d.ts +2 -0
  41. package/esm/tools/create-api-key.js +27 -12
  42. package/esm/tools/manage-entity-types.d.ts +1 -1
  43. package/esm/tools/provision-blueprint.d.ts +6 -0
  44. package/esm/tools/provision-blueprint.js +11 -4
  45. package/esm/tools/provision-database.d.ts +8 -11
  46. package/esm/tools/provision-database.js +23 -243
  47. package/esm/tools/run-codegen.d.ts +19 -2
  48. package/esm/tools/run-codegen.js +184 -97
  49. package/esm/tools/templates.js +3 -15
  50. package/graphql-http.d.ts +35 -0
  51. package/graphql-http.js +140 -0
  52. package/host.d.ts +46 -0
  53. package/host.js +10 -0
  54. package/index.d.ts +11 -2
  55. package/index.js +26 -1
  56. package/package.json +4 -4
  57. package/provision-database/env-file.d.ts +23 -2
  58. package/provision-database/env-file.js +89 -28
  59. package/provision-database/pending-ticket.d.ts +33 -0
  60. package/provision-database/pending-ticket.js +72 -0
  61. package/provision-database/provision.d.ts +71 -0
  62. package/provision-database/provision.js +565 -0
  63. package/provision-database/request-database.d.ts +55 -3
  64. package/provision-database/request-database.js +124 -18
  65. package/schema-model/normalize.d.ts +21 -0
  66. package/schema-model/normalize.js +250 -0
  67. package/schema-model.d.ts +110 -0
  68. package/schema-model.js +309 -0
  69. package/tools/add-policies.d.ts +1 -1
  70. package/tools/add-records.d.ts +1 -0
  71. package/tools/add-records.js +18 -6
  72. package/tools/add-relation.js +2 -3
  73. package/tools/create-api-key.d.ts +2 -0
  74. package/tools/create-api-key.js +27 -12
  75. package/tools/manage-entity-types.d.ts +1 -1
  76. package/tools/provision-blueprint.d.ts +6 -0
  77. package/tools/provision-blueprint.js +11 -4
  78. package/tools/provision-database.d.ts +8 -11
  79. package/tools/provision-database.js +23 -245
  80. package/tools/run-codegen.d.ts +19 -2
  81. package/tools/run-codegen.js +184 -96
  82. package/tools/templates.js +3 -15
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
- const sdk_1 = require("@constructive-io/sdk");
5
- const AUTH_ERROR_RE = /unauthenticated|unauthorized|permission denied|jwt|http 401|http 403/i;
6
- // Liveness probe for a bound database. The split drives recovery: 'missing'
7
- // means the backend answered and the binding is dead (database deleted,
8
- // backend refreshed) — recovery is reprovision. 'unreachable' means no usable
9
- // answer (backend down, network error, rejected credential) — recovery is
10
- // retry/re-sign-in, never reprovision.
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 via the SDK's raw FetchAdapter.
29
+ // `databases` collection filter.
16
30
  async function probeDatabase(args) {
17
- const executor = args.executor ??
18
- new sdk_1.api.FetchAdapter(args.endpoint, { Authorization: `Bearer ${args.bearer}` });
19
- const document = `
20
- query ProbeDatabase {
21
- databases(where: { id: { equalTo: ${JSON.stringify(args.databaseId)} } }) {
22
- nodes { id name ownerId }
23
- }
24
- }`;
25
- try {
26
- const result = await executor.execute(document);
27
- if (result.ok) {
28
- const node = result.data.databases?.nodes?.[0];
29
- if (!node)
30
- return { outcome: 'missing' };
31
- return { outcome: 'found', name: node.name ?? undefined, ownerId: node.ownerId ?? undefined };
32
- }
33
- const detail = result.errors?.[0]?.message ?? 'unknown error';
34
- // A 5xx is the backend failing, not an answer about this database. An auth
35
- // rejection is a dead ACCOUNT credential — never evidence the binding is
36
- // dead, so it must not push toward reprovision.
37
- if (/^HTTP 5\d\d/i.test(detail))
38
- return { outcome: 'unreachable', detail };
39
- if (AUTH_ERROR_RE.test(detail)) {
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
- outcome: 'unreachable',
42
- detail: `${detail} — the account credential was rejected. ${args.signInHint ?? 'Sign in again, then retry.'}`,
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
- return { outcome: 'missing' };
83
+ default:
84
+ return { status: 'unreachable', databaseId, cause: 'error', detail };
46
85
  }
47
- catch (err) {
48
- return { outcome: 'unreachable', detail: err instanceof Error ? err.message : String(err) };
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
  }
@@ -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>;
@@ -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 run('git', ['clone', '--depth', '1', TEMPLATE_REPO, tmp], cwd);
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
- export async function prewarmAppWorkspace(cwd) {
123
- const scaffold = await ensureAppScaffold(cwd);
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 run('pnpm', ['install', '--no-frozen-lockfile'], cwd);
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.outcome === 'unreachable') {
110
+ if (probe.status === 'unreachable') {
111
111
  return {
112
112
  context: null,
113
- reason: `Could not reach the Constructive backend at ${controlApiEndpoint} (${probe.detail}). Check it is running, then retry.`,
113
+ reason: unreachableReason(controlApiEndpoint, probe.cause, probe.detail),
114
114
  code: 'backend-unreachable',
115
+ cause: probe.cause,
115
116
  };
116
117
  }
117
- if (probe.outcome === 'missing') {
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. Re-provision with provision_database (reprovision: true) — the schema is rebuilt from the project; records do not carry over.`,
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. Re-provision with provision_database (reprovision: true) to rebuild it under this account — the schema is rebuilt from the project; records do not carry over.`,
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