@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.
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/README.md CHANGED
@@ -29,7 +29,7 @@ npm install @agentic-kit/db-tools
29
29
 
30
30
  - **18 typed db tools** — `provision_database`, `provision_blueprint`, `describe_schema`, `add_relation`, `delete_table`, `create_field` / `update_field` / `delete_field`, `add_policies`, `add_records`, `manage_entity_types`, `create_api_key`, `run_codegen`, and the template suite (`list` / `create` / `apply` / `update` / `delete`). `constructiveDbTools` is the list, in registration order.
31
31
  - **Host injection** — credentials, backend endpoints and data-plane tokens come from the host application, not from the package. Call `configureHost()` once at startup; the tools read it lazily per call.
32
- - **Project context** — `resolveProjectContext` / `resolveDataToken` resolve the database a tool acts on from the cwd's `.env` plus the host's session, and `deriveSubdomainEndpoint` derives its per-database endpoints.
32
+ - **Project context** — `resolveProjectContext` / `resolveDataToken` resolve the database a tool acts on from the cwd's `.env` plus the host's session, and `deriveSubdomainEndpoint` derives its per-database endpoints. `probeDatabase` says whether that database is still there, and `readSchemaModel` reads its app tables.
33
33
  - **Provisioning model** — the pinned `node-type-registry` presets, the provision manifest, and overlay resolution (`resolveProvisionModules`).
34
34
  - **`constructiveGateDeps`** — the confirm gate's host capabilities (is the project runnable, is there a data token, what tables would a template copy) answered by these resolvers, so every adapter gates the same tools against the same project state instead of restating it.
35
35
  - **`toolSchema`** — a tool's zod parameters as plain JSON Schema, for adapters whose harness wants JSON Schema rather than zod.
@@ -56,6 +56,45 @@ A tool executes against nothing but its params and a context, so it is callable
56
56
  const result = await describeSchemaTool.execute({ database_name: 'my-app' }, { cwd: process.cwd() });
57
57
  ```
58
58
 
59
+ ## Is the bound database still there?
60
+
61
+ `probeDatabase` (or `probeBoundDatabase(id)`, which uses the host's account and control endpoint) asks the platform api and returns one of:
62
+
63
+ | `status` | Means | Recovery |
64
+ | --- | --- | --- |
65
+ | `found` | Live; carries `name`, `ownerId` and `hash` (the schema hash, which changes on every schema change) | none |
66
+ | `suspended` | It exists and keeps its data, but is held (`suspendedAt`, `suspendedReason`) | wait for the hold to lift; never replace it |
67
+ | `missing` | The backend answered and this account can't see it: deleted, or access lost | re-provision |
68
+ | `unreachable` | No usable answer; `cause` is `network`, `server`, `auth` (account credential rejected) or `error` (an error the probe doesn't know) | retry or sign in; never re-provision |
69
+
70
+ Only `missing` means the database is gone. On a backend without `hash`, the probe retries once without it and `hash` is undefined. `resolveProjectContext` reports the same states as `db-missing`, `db-suspended`, `account-mismatch` and `backend-unreachable` (with `cause`), and a resolved context carries `hash`.
71
+
72
+ Tenant APIs answer `ACCESS_SUSPENDED` (a 403 with a JSON body) for a deleted or suspended database. The SDK's `FetchAdapter` drops that body, so `postGraphQL` and `statusAdapter` (a drop-in ORM adapter) keep each error's `extensions.code` and HTTP status, and `classifyBackendErrors` sorts them into `suspended`, `network`, `server`, `auth`, `forbidden` or `graphql`. `add_records`, `run_codegen` and `create_api_key` return `details.code: 'database-unavailable'` on `ACCESS_SUSPENDED`, and `run_codegen` doesn't retry a refused introspection.
73
+
74
+ ## The live schema
75
+
76
+ `readSchemaModel(context)` reads the app tables (category `APP`, whoever created them) from the metaschema in one piece, or throws `SchemaReadError`; a partial read would look like columns dropped outside. Each table has its stable metaschema id, every field with its category (a status column made by `DataStatusField` is `MODULE`), a normalised type, `isRequired`, a structured default (`value`, `now`, `today`, `generated` or `expression`), the values an IN-list check allows, primary-key and unique flags, foreign keys with their delete and update actions (targets outside the app tables still resolve), and its policies. `accessOf(policies)` maps them to `shared`, `private` or `custom`. The model's `hash` is read before anything else.
77
+
78
+ ## Provisioning
79
+
80
+ `provision_database` and the host-side `provisionDatabase(input, { cwd })` never create a second database by accident. Calls for one project run one at a time. A binding is `DATABASE_ID` alone: it is probed, then kept (healing the token, endpoints and any missing keys) or refused. Replace mode (`mode: 'replace', replaces: <the id the caller saw>`) swaps the binding only if it is still that one, and never swaps a suspended database, or a live one on the host path. A provision that outlasts the poll is recorded in `.env` and resumed by the next call. A ticket that can't be read back, or a call that lost track of its ticket, finds an in-flight provision of the same name with `listOwn` before anything is requested; a completed one is only reported. After `MAX_PENDING_ATTEMPTS` waits the call fails `provision-stuck` until the host passes `abandonPending`.
81
+
82
+ Failures carry a `code`: `signed-out`, `backend-unreachable`, `db-missing`, `db-suspended`, `account-mismatch`, `binding-live`, `binding-changed`, `pending-other`, `ticket-lost`, `provision-stuck`, `name-taken` (with `heldBy` when a database of this account holds the name, which the host can bind with `adopt: <id>`), `binding-not-saved`, `env-write-failed`, `reprovision-not-allowed` or `failed`.
83
+
84
+ Host hooks for this, all optional:
85
+
86
+ - `persistBinding(binding)` saves the binding in the host's own store before `.env` is written. If it throws, `.env` is left alone, the ticket stays recorded, and the call fails `binding-not-saved`.
87
+ - `toolReprovision: false` makes the tool's `reprovision: true` fail `reprovision-not-allowed`, for a host that replaces databases only after the user approves.
88
+ - `reprovisionHint` replaces the "re-provision with `reprovision: true`" advice in failure messages.
89
+
90
+ ## Blueprint builds
91
+
92
+ `constructBlueprint` queues the build and returns the construction id, and the backend builds one blueprint at a time. So `provision_blueprint`, `add_relation` and `apply_template` wait for their build through `buildBlueprint` (or `waitForConstruction`, given an id), which reads the construction every 2 s until it is `constructed`, and returns the construction id and its table map, or `failed`, and throws with the backend's error code and message. The error is read in a separate query after the failure, so the status reads also work on a backend without the `error` column, and there the failure says the backend gave no reason. A host that polls on its own reads the reason the same way with `readConstructionFailure`. On a backend that still builds inside the mutation, the first read already says `constructed`.
93
+
94
+ The wait gives up after `CONSTRUCTION_DEADLINE_MS` (240 s: the build's own 120 s statement timeout, plus as long again in the queue), when the signal aborts, or when the status can't be read (a GraphQL error, or 3 network failures in a row), and says the build may still finish. A host that limits how long a tool runs should give these three tools more than 240 s.
95
+
96
+ Once the backend has queued the build, `buildBlueprint` throws every failure as a `BlueprintBuildError` with its `constructionId`, and `provision_blueprint` puts that id on `details.constructionId`, whether the build succeeded or not. The build carries on without the call, so a host that retries failed calls shouldn't retry one that has an id: a second build of the same tables fails on the ones the first made. Without an id, nothing was queued.
97
+
59
98
  ## Related
60
99
 
61
100
  | Package | Role |
@@ -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/app-workspace.js CHANGED
@@ -3,12 +3,15 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.IGNORED_BUILDS_RE = exports.NATIVE_BUILD_DEPS = void 0;
6
+ exports.COMPOSE_MARKER = exports.IGNORED_BUILDS_RE = exports.NATIVE_BUILD_DEPS = void 0;
7
+ exports.detectAppKind = detectAppKind;
7
8
  exports.run = run;
8
9
  exports.ensureWorkspaceConfig = ensureWorkspaceConfig;
9
10
  exports.pinCodegenLatest = pinCodegenLatest;
10
11
  exports.ensureAppScaffold = ensureAppScaffold;
12
+ exports.ensureComposedInstall = ensureComposedInstall;
11
13
  exports.prewarmAppWorkspace = prewarmAppWorkspace;
14
+ exports.settlePrewarm = settlePrewarm;
12
15
  const node_child_process_1 = require("node:child_process");
13
16
  const node_fs_1 = require("node:fs");
14
17
  const promises_1 = require("node:fs/promises");
@@ -33,6 +36,38 @@ exports.IGNORED_BUILDS_RE = /ERR_PNPM_IGNORED_BUILDS|Ignored build scripts/;
33
36
  const DEFAULT_GRAPHQL_OVERRIDE = '16.14.0';
34
37
  const TEMPLATE_REPO = 'https://github.com/constructive-io/sandbox-templates';
35
38
  const TEMPLATE_SUBDIR = node_path_1.default.join('nextjs', 'constructive-app');
39
+ // A composed app carries this marker, written when its template was composed.
40
+ // It ships its own pinned package.json and pnpm-lock.yaml, so none of the
41
+ // constructive-app fixups below apply to it.
42
+ exports.COMPOSE_MARKER = node_path_1.default.join('.constructive', 'compose.json');
43
+ /**
44
+ * Which flow packages/app takes. Composed needs the marker to be a JSON object
45
+ * and the app's own lockfile beside it. A marker that fails either check is
46
+ * neither: running the template fixups would rewrite a composed app, so
47
+ * callers stop and report the reason instead.
48
+ */
49
+ function detectAppKind(appDir) {
50
+ const marker = node_path_1.default.join(appDir, exports.COMPOSE_MARKER);
51
+ if (!(0, node_fs_1.existsSync)(marker))
52
+ return { kind: 'template' };
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse((0, node_fs_1.readFileSync)(marker, 'utf8'));
56
+ }
57
+ catch {
58
+ return { kind: 'invalid', reason: `packages/app/${exports.COMPOSE_MARKER} is not valid JSON.` };
59
+ }
60
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
61
+ return { kind: 'invalid', reason: `packages/app/${exports.COMPOSE_MARKER} is not a JSON object.` };
62
+ }
63
+ if (!(0, node_fs_1.existsSync)(node_path_1.default.join(appDir, 'pnpm-lock.yaml'))) {
64
+ return {
65
+ kind: 'invalid',
66
+ reason: `packages/app has ${exports.COMPOSE_MARKER} but no pnpm-lock.yaml, so it can't be installed as composed.`,
67
+ };
68
+ }
69
+ return { kind: 'composed' };
70
+ }
36
71
  async function run(cmd, args, cwd, env) {
37
72
  try {
38
73
  const { stdout, stderr } = await execFileAsync(cmd, args, {
@@ -92,14 +127,14 @@ async function pinCodegenLatest(appDir) {
92
127
  // its CLI is unreliable in the non-TTY agent shell, which made the agent thrash
93
128
  // for minutes. Materialize `packages/app` deterministically by shallow-cloning
94
129
  // the template instead (PGPM-001).
95
- async function ensureAppScaffold(cwd) {
130
+ async function ensureAppScaffold(cwd, exec = run) {
96
131
  const appDir = node_path_1.default.join(cwd, 'packages', 'app');
97
132
  if ((0, node_fs_1.existsSync)(node_path_1.default.join(appDir, 'package.json'))) {
98
133
  return { ok: true, out: 'packages/app already scaffolded' };
99
134
  }
100
135
  const tmp = node_path_1.default.join(node_os_1.default.tmpdir(), `airpage-app-template-${process.pid}`);
101
136
  await (0, promises_1.rm)(tmp, { recursive: true, force: true });
102
- const clone = await run('git', ['clone', '--depth', '1', TEMPLATE_REPO, tmp], cwd);
137
+ const clone = await exec('git', ['clone', '--depth', '1', TEMPLATE_REPO, tmp], cwd);
103
138
  if (!clone.ok) {
104
139
  await (0, promises_1.rm)(tmp, { recursive: true, force: true });
105
140
  return { ok: false, out: `Template clone failed:\n${clone.out}` };
@@ -123,21 +158,77 @@ async function ensureAppScaffold(cwd) {
123
158
  await (0, promises_1.rm)(tmp, { recursive: true, force: true });
124
159
  return { ok: true, out: 'Scaffolded packages/app from the constructive-app template.' };
125
160
  }
161
+ // pnpm writes node_modules/.modules.yaml once an install finishes linking, so a
162
+ // partial install (interrupted, or failed mid-way) lacks it. Codegen also
163
+ // needs its bin, so both must be there to skip the install.
164
+ function composedInstallComplete(appDir) {
165
+ const modules = node_path_1.default.join(appDir, 'node_modules');
166
+ return ((0, node_fs_1.existsSync)(node_path_1.default.join(modules, '.modules.yaml')) &&
167
+ (0, node_fs_1.existsSync)(node_path_1.default.join(modules, '.bin', 'graphql-codegen')));
168
+ }
169
+ // Install a composed app exactly as its lockfile pins it, unless a finished
170
+ // install is already there. The install runs in packages/app against its own
171
+ // lockfile, from the store where it can. A nested pnpm-workspace.yaml makes the
172
+ // app its own workspace root, so the config is left alone; without one, a root
173
+ // pnpm-workspace.yaml would pull the install up to the project root, where no
174
+ // lockfile exists, so pnpm is told to ignore it instead of rewriting it.
175
+ async function ensureComposedInstall(cwd, exec = run) {
176
+ const appDir = node_path_1.default.join(cwd, 'packages', 'app');
177
+ if (composedInstallComplete(appDir)) {
178
+ return { ok: true, out: 'packages/app dependencies already installed.', installed: false };
179
+ }
180
+ const args = ['install', '--frozen-lockfile', '--prefer-offline'];
181
+ const ownsWorkspace = (0, node_fs_1.existsSync)(node_path_1.default.join(appDir, 'pnpm-workspace.yaml'));
182
+ if (!ownsWorkspace && (0, node_fs_1.existsSync)(node_path_1.default.join(cwd, 'pnpm-workspace.yaml'))) {
183
+ args.push('--ignore-workspace');
184
+ }
185
+ const install = await exec('pnpm', args, appDir);
186
+ if (!install.ok && !exports.IGNORED_BUILDS_RE.test(install.out)) {
187
+ return { ok: false, out: `pnpm install failed:\n${install.out}`, installed: true };
188
+ }
189
+ return { ok: true, out: 'Installed packages/app from its lockfile.', installed: true };
190
+ }
126
191
  // Scaffold packages/app, hoist the workspace config, pin codegen, and run the one
127
192
  // clean install. All of this is database-INDEPENDENT, so it can be prewarmed
128
193
  // concurrently with the ~90s provision_database backend mutation to keep clone +
129
194
  // install off run_codegen's critical path. Best-effort and fully idempotent:
130
195
  // run_codegen re-runs the same steps, finding the scaffold present and the install
131
196
  // already satisfied (a fast no-op). Errors are swallowed by callers that prewarm —
132
- // run_codegen surfaces any real failure later.
133
- async function prewarmAppWorkspace(cwd) {
134
- const scaffold = await ensureAppScaffold(cwd);
197
+ // run_codegen surfaces any real failure later. A composed app only gets its
198
+ // frozen install, and an invalid marker leaves the app untouched.
199
+ //
200
+ // One prewarm runs per project at a time: a call while one is in flight gets
201
+ // that same run, so a retried provision_database (or run_codegen, via
202
+ // settlePrewarm) never starts a second install beside it.
203
+ const prewarmsInFlight = new Map();
204
+ function prewarmAppWorkspace(cwd, exec = run) {
205
+ const key = node_path_1.default.resolve(cwd);
206
+ const inFlight = prewarmsInFlight.get(key);
207
+ if (inFlight)
208
+ return inFlight;
209
+ const started = prewarm(cwd, exec).finally(() => prewarmsInFlight.delete(key));
210
+ prewarmsInFlight.set(key, started);
211
+ return started;
212
+ }
213
+ /** Wait for a prewarm still running for `cwd`; its outcome doesn't matter here. */
214
+ async function settlePrewarm(cwd) {
215
+ await prewarmsInFlight.get(node_path_1.default.resolve(cwd))?.catch(() => undefined);
216
+ }
217
+ async function prewarm(cwd, exec) {
218
+ const appDir = node_path_1.default.join(cwd, 'packages', 'app');
219
+ const app = detectAppKind(appDir);
220
+ if (app.kind === 'invalid')
221
+ return { ok: false, out: app.reason };
222
+ if (app.kind === 'composed') {
223
+ const install = await ensureComposedInstall(cwd, exec);
224
+ return install.ok ? { ok: true, out: 'Prewarmed packages/app install.' } : install;
225
+ }
226
+ const scaffold = await ensureAppScaffold(cwd, exec);
135
227
  if (!scaffold.ok)
136
228
  return scaffold;
137
- const appDir = node_path_1.default.join(cwd, 'packages', 'app');
138
229
  await ensureWorkspaceConfig(cwd);
139
230
  await pinCodegenLatest(appDir);
140
- const install = await run('pnpm', ['install', '--no-frozen-lockfile'], cwd);
231
+ const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], cwd);
141
232
  if (!install.ok && !exports.IGNORED_BUILDS_RE.test(install.out)) {
142
233
  return { ok: false, out: `pnpm install failed:\n${install.out}` };
143
234
  }
@@ -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,161 @@
1
+ "use strict";
2
+ // Building a blueprint: constructBlueprint enqueues a `blueprint:construct`
3
+ // job and returns the construction id at once (constructive-db#4156). A worker
4
+ // builds one blueprint at a time, each under a 120 s statement timeout, and
5
+ // moves the construction row pending → constructing → constructed | failed,
6
+ // with a failed build's coded error in `error`. A backend from before that
7
+ // builds inside the mutation and raises the build's error from it, so the
8
+ // first read already says constructed, and the same wait serves both.
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.BlueprintBuildError = exports.MAX_CONSECUTIVE_READ_FAILURES = exports.CONSTRUCTION_POLL_MS = exports.CONSTRUCTION_DEADLINE_MS = void 0;
11
+ exports.readConstructionFailure = readConstructionFailure;
12
+ exports.waitForConstruction = waitForConstruction;
13
+ exports.buildBlueprint = buildBlueprint;
14
+ const request_database_1 = require("./provision-database/request-database");
15
+ /**
16
+ * How long a tool waits for its build: the build's own 120 s statement
17
+ * timeout, plus as long again for the builds queued ahead of it (a cold build
18
+ * of a curated blueprint takes 15-40 s). It stays under the 5-minute limit
19
+ * hosts put on the long database tools, so the tool reports the wait itself
20
+ * instead of being cut off.
21
+ */
22
+ exports.CONSTRUCTION_DEADLINE_MS = 240_000;
23
+ exports.CONSTRUCTION_POLL_MS = 2_000;
24
+ /**
25
+ * Unreadable statuses in a row tolerated before giving up. The build goes on
26
+ * whether or not a read gets through, so a dropped request or a momentary 502
27
+ * must not end the wait.
28
+ */
29
+ exports.MAX_CONSECUTIVE_READ_FAILURES = 3;
30
+ const MAY_STILL_FINISH = 'The build may still finish on the backend: read the schema before you call again.';
31
+ const errorText = (err) => (err instanceof Error ? err.message : String(err));
32
+ function stopped() {
33
+ return new Error(`Stopped waiting for the blueprint build. ${MAY_STILL_FINISH}`);
34
+ }
35
+ function abortableSleep(ms, signal) {
36
+ return new Promise((resolve, reject) => {
37
+ if (signal?.aborted)
38
+ return reject(stopped());
39
+ const onAbort = () => {
40
+ clearTimeout(timer);
41
+ reject(stopped());
42
+ };
43
+ const timer = setTimeout(() => {
44
+ signal?.removeEventListener('abort', onAbort);
45
+ resolve();
46
+ }, ms);
47
+ signal?.addEventListener('abort', onAbort, { once: true });
48
+ });
49
+ }
50
+ function tableMapOf(raw) {
51
+ if (!raw || typeof raw !== 'object')
52
+ return {};
53
+ return Object.fromEntries(Object.entries(raw).filter((entry) => typeof entry[1] === 'string'));
54
+ }
55
+ /**
56
+ * A failed construction's coded error on one line: `CODE: message` for a
57
+ * database error, `CODE {context}` for one raised through errors.raise_error.
58
+ */
59
+ function describeConstructionError(error) {
60
+ if (!error || typeof error !== 'object')
61
+ return null;
62
+ const { code, message, context } = error;
63
+ const head = [code, message].filter((part) => typeof part === 'string' && part).join(': ');
64
+ const hasContext = Boolean(context && typeof context === 'object' && Object.keys(context).length);
65
+ const tail = hasContext ? JSON.stringify(context) : '';
66
+ return [head, tail].filter(Boolean).join(' ') || null;
67
+ }
68
+ /**
69
+ * Why a construction failed, from its `error` column. That column came with
70
+ * the queued build, and the modules SDK predates it, so it is read on its own
71
+ * after a failure: the status reads stay valid on a backend without it. The
72
+ * SDK's select type doesn't list `error`, but the ORM writes each select key
73
+ * as the field name.
74
+ */
75
+ async function readConstructionFailure(modules, constructionId) {
76
+ try {
77
+ const result = await modules.blueprintConstruction
78
+ .findOne({ id: constructionId, select: { error: true } })
79
+ .unwrap();
80
+ const row = result.blueprintConstruction;
81
+ return describeConstructionError(row?.error) ?? 'the backend gave no reason';
82
+ }
83
+ catch (err) {
84
+ return `the backend gave no reason (reading it failed: ${errorText(err)})`;
85
+ }
86
+ }
87
+ /**
88
+ * Wait for a construction to finish. Resolves with its table map once it is
89
+ * constructed, and throws with the backend's coded error once it failed, or
90
+ * when the deadline passes, the signal aborts, or its status can't be read.
91
+ */
92
+ async function waitForConstruction(modules, constructionId, options = {}) {
93
+ const { signal, deadlineMs = exports.CONSTRUCTION_DEADLINE_MS, pollMs = exports.CONSTRUCTION_POLL_MS, now = Date.now, sleep = abortableSleep, } = options;
94
+ const deadline = now() + deadlineMs;
95
+ let status = 'pending';
96
+ let readFailures = 0;
97
+ for (;;) {
98
+ if (signal?.aborted)
99
+ throw stopped();
100
+ let row;
101
+ try {
102
+ const result = await modules.blueprintConstruction
103
+ .findOne({ id: constructionId, select: { status: true, tableMap: true } })
104
+ .unwrap();
105
+ row = result.blueprintConstruction;
106
+ readFailures = 0;
107
+ }
108
+ catch (err) {
109
+ // A GraphQL error is the backend's answer and won't change on a reread.
110
+ if (!(0, request_database_1.isTransientProvisionError)(err) || ++readFailures >= exports.MAX_CONSECUTIVE_READ_FAILURES) {
111
+ throw new Error(`Could not read the blueprint build's status: ${errorText(err)}. ${MAY_STILL_FINISH}`);
112
+ }
113
+ }
114
+ if (row === null)
115
+ throw new Error(`The backend has no blueprint construction ${constructionId}.`);
116
+ if (row?.status === 'constructed')
117
+ return tableMapOf(row.tableMap);
118
+ if (row?.status === 'failed') {
119
+ throw new Error(`The blueprint build failed: ${await readConstructionFailure(modules, constructionId)}`);
120
+ }
121
+ status = row?.status ?? status;
122
+ if (now() >= deadline) {
123
+ const where = status === 'pending' ? "waiting in the backend's build queue" : 'building';
124
+ throw new Error(`The blueprint build was still ${where} after ${Math.round(deadlineMs / 1000)} s. ${MAY_STILL_FINISH}`);
125
+ }
126
+ await sleep(pollMs, signal);
127
+ }
128
+ }
129
+ /**
130
+ * A build that failed after the backend queued it, or that the wait gave up
131
+ * on. The construction carries on without the caller, so constructing again
132
+ * would queue the same tables a second time.
133
+ */
134
+ class BlueprintBuildError extends Error {
135
+ constructionId;
136
+ constructor(message, constructionId) {
137
+ super(message);
138
+ this.name = 'BlueprintBuildError';
139
+ this.constructionId = constructionId;
140
+ }
141
+ }
142
+ exports.BlueprintBuildError = BlueprintBuildError;
143
+ /**
144
+ * Construct a blueprint into a schema and wait for the build. Resolves with
145
+ * the construction and the created tables. Once the backend has queued the
146
+ * build, every failure is a BlueprintBuildError with its construction id.
147
+ */
148
+ async function buildBlueprint(modules, input, options = {}) {
149
+ const result = await modules.mutation
150
+ .constructBlueprint({ input }, { select: { result: true } })
151
+ .unwrap();
152
+ const constructionId = result.constructBlueprint?.result;
153
+ if (!constructionId)
154
+ throw new Error('constructBlueprint returned no construction id');
155
+ try {
156
+ return { constructionId, tables: await waitForConstruction(modules, constructionId, options) };
157
+ }
158
+ catch (err) {
159
+ throw new BlueprintBuildError(errorText(err), constructionId);
160
+ }
161
+ }
package/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/context.js CHANGED
@@ -119,17 +119,18 @@ async function resolveProjectContext(input, options = {}) {
119
119
  databaseId,
120
120
  signInHint: host.signInHint,
121
121
  });
122
- if (probe.outcome === 'unreachable') {
122
+ if (probe.status === 'unreachable') {
123
123
  return {
124
124
  context: null,
125
- reason: `Could not reach the Constructive backend at ${controlApiEndpoint} (${probe.detail}). Check it is running, then retry.`,
125
+ reason: unreachableReason(controlApiEndpoint, probe.cause, probe.detail),
126
126
  code: 'backend-unreachable',
127
+ cause: probe.cause,
127
128
  };
128
129
  }
129
- if (probe.outcome === 'missing') {
130
+ if (probe.status === 'missing') {
130
131
  return {
131
132
  context: null,
132
- 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.`,
133
+ reason: `The bound database (DATABASE_ID=${databaseId}) no longer exists on this backend, or this account lost access to it. ${(0, host_1.reprovisionAdvice)(host)}`,
133
134
  code: 'db-missing',
134
135
  };
135
136
  }
@@ -146,10 +147,20 @@ async function resolveProjectContext(input, options = {}) {
146
147
  probe.ownerId !== sessionUserId) {
147
148
  return {
148
149
  context: null,
149
- 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.`,
150
+ reason: `The bound database (DATABASE_ID=${databaseId}) was provisioned under a different account than the one signed in. ${(0, host_1.reprovisionAdvice)(host)}`,
150
151
  code: 'account-mismatch',
151
152
  };
152
153
  }
154
+ // A suspended database still holds its data, so it is never a reason to
155
+ // reprovision: nothing can read or change it until the hold is lifted.
156
+ if (probe.status === 'suspended') {
157
+ const why = probe.suspendedReason ? ` (${probe.suspendedReason})` : '';
158
+ return {
159
+ context: null,
160
+ 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.`,
161
+ code: 'db-suspended',
162
+ };
163
+ }
153
164
  const databaseName = env.DATABASE_NAME || probe.name || '';
154
165
  const ownerId = probe.ownerId || env.OWNER_ID || undefined;
155
166
  const dataEndpoint = databaseName
@@ -172,10 +183,24 @@ async function resolveProjectContext(input, options = {}) {
172
183
  databaseName,
173
184
  accessToken,
174
185
  dataEndpoint,
186
+ hash: probe.hash,
175
187
  },
176
188
  reason: '',
177
189
  };
178
190
  }
191
+ function unreachableReason(endpoint, cause, detail) {
192
+ switch (cause) {
193
+ case 'auth':
194
+ // The probe's detail says the credential was rejected and how to sign in.
195
+ return `Could not check the bound database on the Constructive backend at ${endpoint}: ${detail}`;
196
+ case 'error':
197
+ 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.`;
198
+ case 'server':
199
+ return `The Constructive backend at ${endpoint} is failing (${detail}). Check it is running, then retry.`;
200
+ default:
201
+ return `Could not reach the Constructive backend at ${endpoint} (${detail}). Check it is running, then retry.`;
202
+ }
203
+ }
179
204
  const needsAuthReason = () => `Not signed in to the app database yet. ${(0, host_1.getHost)().signInHint ??
180
205
  'Sign in from the Sheets tab, or open the Preview and sign in to your app, then try again.'}`;
181
206
  // Resolve a data-plane token for record operations: serve the broker's active
package/db-probe.d.ts CHANGED
@@ -1,30 +1,38 @@
1
+ import { postGraphQL } from './graphql-http';
2
+ export type UnreachableCause = 'network' | 'server' | 'auth' | 'error';
1
3
  export type DatabaseProbe = {
2
- outcome: 'found';
4
+ status: 'found';
5
+ databaseId: string;
6
+ name?: string;
7
+ ownerId?: string;
8
+ hash?: string;
9
+ } | {
10
+ status: 'suspended';
11
+ databaseId: string;
3
12
  name?: string;
4
13
  ownerId?: string;
14
+ hash?: string;
15
+ suspendedAt: string;
16
+ suspendedReason?: string;
5
17
  } | {
6
- outcome: 'missing';
18
+ status: 'missing';
19
+ databaseId: string;
7
20
  } | {
8
- outcome: 'unreachable';
21
+ status: 'unreachable';
22
+ databaseId: string;
23
+ cause: UnreachableCause;
9
24
  detail: string;
10
25
  };
11
- export type ProbeExecutor = {
12
- execute<T>(document: string, variables?: Record<string, unknown>): Promise<{
13
- ok: true;
14
- data: T;
15
- errors: undefined;
16
- } | {
17
- ok: false;
18
- data: null;
19
- errors: {
20
- message: string;
21
- }[];
22
- }>;
23
- };
24
26
  export declare function probeDatabase(args: {
25
27
  endpoint: string;
26
28
  bearer: string;
27
29
  databaseId: string;
28
- executor?: ProbeExecutor;
29
30
  signInHint?: string;
31
+ post?: typeof postGraphQL;
30
32
  }): Promise<DatabaseProbe>;
33
+ /**
34
+ * Probe a database with the host's account bearer on its configured control
35
+ * endpoint (never a project-pinned one). Without a bearer the answer is
36
+ * unreachable with cause 'auth'.
37
+ */
38
+ export declare function probeBoundDatabase(databaseId: string): Promise<DatabaseProbe>;