From 00e4450ca41ea61b5c991baa1d9591cd8de848cb Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 19 Oct 2026 21:35:52 +0700 Subject: [PATCH 01/14] feat(db-tools): run codegen on composed apps without scaffolding or lockfile changes When packages/app already exists, run_codegen leaves its package.json and lockfile alone and runs the app's own codegen bin instead of npx with a latest pin, so a project cloned from a prepared template stays reproducible. The tool now reports its timings and exports their types. --- .../db-tools/__tests__/run-codegen.test.ts | 306 +++++++++++++++++ agentic/db-tools/src/app-workspace.ts | 132 +++++++- agentic/db-tools/src/index.ts | 1 + agentic/db-tools/src/tools/run-codegen.ts | 316 ++++++++++++------ 4 files changed, 639 insertions(+), 116 deletions(-) create mode 100644 agentic/db-tools/__tests__/run-codegen.test.ts diff --git a/agentic/db-tools/__tests__/run-codegen.test.ts b/agentic/db-tools/__tests__/run-codegen.test.ts new file mode 100644 index 0000000000..9b0efb6305 --- /dev/null +++ b/agentic/db-tools/__tests__/run-codegen.test.ts @@ -0,0 +1,306 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import { prewarmAppWorkspace, type RunCommand } from '../src/app-workspace'; +import { configureHost } from '../src/host'; +import { createRunCodegenTool } from '../src/tools/run-codegen'; + +type Call = { cmd: string; args: string[]; cwd: string; env?: Record }; + +const CODEGEN_ENV = { + NEXT_PUBLIC_DB_NAME: 'myapp', + CODEGEN_ADMIN_ENDPOINT: 'http://admin-myapp.localhost:3000/graphql', + CODEGEN_ADMIN_HOST: 'admin-myapp.localhost:3000', + CODEGEN_AUTH_ENDPOINT: 'http://auth-myapp.localhost:3000/graphql', + CODEGEN_AUTH_HOST: 'auth-myapp.localhost:3000', + CODEGEN_APP_ENDPOINT: 'http://api-myapp.localhost:3000/graphql', + CODEGEN_APP_HOST: 'api-myapp.localhost:3000', +}; + +const ENV_LOCAL = + 'NEXT_PUBLIC_DB_NAME=myapp\n' + + 'NEXT_PUBLIC_ADMIN_ENDPOINT=http://admin-myapp.localhost:3000/graphql\n' + + 'NEXT_PUBLIC_AUTH_ENDPOINT=http://auth-myapp.localhost:3000/graphql\n' + + 'NEXT_PUBLIC_APP_ENDPOINT=http://api-myapp.localhost:3000/graphql\n'; + +const STUB = '// stub until codegen runs\nexport {};\n'; + +let cwd: string; +let appDir: string; + +function write(file: string, content: string) { + mkdirSync(path.dirname(file), { recursive: true }); + writeFileSync(file, content); +} + +const read = (file: string) => readFileSync(file, 'utf8'); + +// A composed packages/app: pinned codegen, its own lockfile, the marker, and +// SDK stubs for its two targets (no admin target). +function composedApp(opts: { nodeModules?: boolean; ownWorkspace?: boolean } = {}) { + write( + path.join(appDir, 'package.json'), + `${JSON.stringify({ name: 'app', devDependencies: { '@constructive-io/graphql-codegen': '4.50.0' } }, null, 2)}\n`, + ); + write(path.join(appDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n"); + write(path.join(appDir, '.constructive', 'compose.json'), '{"templateSha":"abc"}\n'); + write(path.join(appDir, 'src', 'graphql', 'sdk', 'app', 'index.ts'), STUB); + write(path.join(appDir, 'src', 'graphql', 'sdk', 'auth', 'index.ts'), STUB); + if (opts.ownWorkspace) { + write(path.join(appDir, 'pnpm-workspace.yaml'), 'onlyBuiltDependencies:\n - esbuild\n'); + } + if (opts.nodeModules) markInstalled(); +} + +// What a finished pnpm install leaves: its .modules.yaml and the linked bins. +function markInstalled() { + write(path.join(appDir, 'node_modules', '.modules.yaml'), 'layoutVersion: 5\n'); + write(path.join(appDir, 'node_modules', '.bin', 'graphql-codegen'), ''); +} + +// What codegen writes for an auth + app config: per-target directory barrels and +// a root barrel over the targets that exist. +function writeGeneratedSdk() { + const sdk = path.join(appDir, 'src', 'graphql', 'sdk'); + write(path.join(sdk, 'index.ts'), "export * as auth from './auth';\nexport * as app from './app';\n"); + write(path.join(sdk, 'auth', 'index.ts'), "export * from './orm';\n"); + write(path.join(sdk, 'auth', 'orm', 'index.ts'), 'export const authOrm = 1;\n'); + write(path.join(sdk, 'app', 'index.ts'), "export * from './orm';\nexport * from './hooks';\n"); + write(path.join(sdk, 'app', 'orm', 'index.ts'), 'export const appOrm = 1;\n'); + write(path.join(sdk, 'app', 'hooks', 'index.ts'), 'export const appHooks = 1;\n'); +} + +function fakeExec(handle: (call: Call) => { ok: boolean; out: string } = () => ({ ok: true, out: '' })) { + const calls: Call[] = []; + const exec: RunCommand = async (cmd, args, cwd, env) => { + const call = { cmd, args, cwd, env }; + calls.push(call); + return handle(call); + }; + return { exec, calls }; +} + +const isCodegen = (call: Call) => + call.cmd.endsWith('graphql-codegen') || call.cmd === 'npx' || call.args.includes('codegen'); + +function untouchedFiles() { + return { + packageJson: read(path.join(appDir, 'package.json')), + lockfile: read(path.join(appDir, 'pnpm-lock.yaml')), + }; +} + +beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'db-tools-codegen-')); + appDir = path.join(cwd, 'packages', 'app'); + write(path.join(cwd, '.env'), 'DATABASE_NAME=myapp\n'); + configureHost({ + account: () => null, + backendConfig: () => ({ apiEndpoint: 'http://api.localhost:3000/graphql' }), + }); +}); + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('run_codegen on a composed app', () => { + it('runs the local codegen bin without scaffolding, installing, or touching package.json and the lockfile', async () => { + composedApp({ nodeModules: true }); + const before = untouchedFiles(); + const { exec, calls } = fakeExec((call) => { + if (isCodegen(call)) writeGeneratedSdk(); + return { ok: true, out: '' }; + }); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + expect(calls).toEqual([ + { + cmd: path.join(appDir, 'node_modules', '.bin', 'graphql-codegen'), + args: ['generate', '--config', './graphql-codegen.config.ts'], + cwd: appDir, + env: CODEGEN_ENV, + }, + ]); + expect(untouchedFiles()).toEqual(before); + expect(existsSync(path.join(cwd, 'pnpm-workspace.yaml'))).toBe(false); + expect(read(path.join(appDir, '.env.local'))).toBe(ENV_LOCAL); + expect(result.details).toMatchObject({ + success: true, + appKind: 'composed', + installed: false, + codegenAttempts: 1, + generatedSubmodules: ['hooks', 'orm'], + }); + expect(Object.keys(result.details.timings ?? {})).toEqual([ + 'scaffoldMs', + 'installMs', + 'codegenMs', + 'barrelsMs', + 'totalMs', + ]); + }); + + it('tolerates a config without an admin target and normalizes only the barrels that exist', async () => { + composedApp({ nodeModules: true }); + const { exec } = fakeExec((call) => { + if (isCodegen(call)) writeGeneratedSdk(); + return { ok: true, out: '' }; + }); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + const sdk = path.join(appDir, 'src', 'graphql', 'sdk'); + expect(result.details.success).toBe(true); + expect(existsSync(path.join(sdk, 'admin'))).toBe(false); + expect(read(path.join(sdk, 'index.ts'))).toBe( + "export * as auth from './auth/index';\nexport * as app from './app/index';\n", + ); + expect(read(path.join(sdk, 'app', 'index.ts'))).toBe( + "export * from './orm/index';\nexport * from './hooks/index';\n", + ); + expect(result.details.barrelsRewritten).toBe(3); + }); + + it('installs from the lockfile, frozen and offline-first, when node_modules is missing', async () => { + composedApp({ ownWorkspace: true }); + const workspace = read(path.join(appDir, 'pnpm-workspace.yaml')); + const before = untouchedFiles(); + const { exec, calls } = fakeExec((call) => { + if (isCodegen(call)) writeGeneratedSdk(); + return { ok: true, out: '' }; + }); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + // Without a linked bin, the app's own codegen script runs; never npx. + expect(calls.map(({ cmd, args, cwd }) => ({ cmd, args, cwd }))).toEqual([ + { cmd: 'pnpm', args: ['install', '--frozen-lockfile', '--prefer-offline'], cwd: appDir }, + { cmd: 'pnpm', args: ['run', 'codegen'], cwd: appDir }, + ]); + expect(calls[1].env).toEqual(CODEGEN_ENV); + expect(untouchedFiles()).toEqual(before); + expect(read(path.join(appDir, 'pnpm-workspace.yaml'))).toBe(workspace); + expect(result.details).toMatchObject({ success: true, installed: true }); + }); + + it('installs again after a failed install left node_modules half-made', async () => { + composedApp(); + let installs = 0; + const { exec, calls } = fakeExec((call) => { + if (call.args[0] === 'install') { + // pnpm got as far as creating node_modules, then failed. + write(path.join(appDir, 'node_modules', 'next', 'package.json'), '{}'); + if (++installs === 1) return { ok: false, out: 'ERR_PNPM_FETCH_404' }; + markInstalled(); + return { ok: true, out: '' }; + } + if (isCodegen(call)) writeGeneratedSdk(); + return { ok: true, out: '' }; + }); + const tool = createRunCodegenTool(exec); + + const first = await tool.execute({}, { cwd }); + const second = await tool.execute({}, { cwd }); + + expect(first.details).toMatchObject({ success: false, installed: true }); + expect(second.details).toMatchObject({ success: true, installed: true }); + expect(calls.filter((call) => call.args[0] === 'install')).toHaveLength(2); + }); + + it.each([ + ['has no lockfile', () => rmSync(path.join(appDir, 'pnpm-lock.yaml')), /no pnpm-lock\.yaml/], + ['has a marker that is not JSON', () => write(path.join(appDir, '.constructive', 'compose.json'), '{'), /not valid JSON/], + ])('refuses an app that %s, and leaves it alone', async (_case, breakApp, reason) => { + composedApp({ nodeModules: true }); + breakApp(); + const packageJson = read(path.join(appDir, 'package.json')); + const { exec, calls } = fakeExec(); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + const prewarm = await prewarmAppWorkspace(cwd, exec); + + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(reason); + expect(prewarm.ok).toBe(false); + expect(calls).toEqual([]); + expect(read(path.join(appDir, 'package.json'))).toBe(packageJson); + expect(existsSync(path.join(cwd, 'pnpm-workspace.yaml'))).toBe(false); + }); + + it('keeps the SDK stubs when codegen fails, so the app still builds', async () => { + composedApp({ nodeModules: true }); + const { exec } = fakeExec(() => ({ ok: false, out: 'GraphQL error: permission denied' })); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + expect(result.details).toMatchObject({ success: false, appKind: 'composed', codegenAttempts: 1 }); + expect(result.details.timings?.codegenMs).toEqual(expect.any(Number)); + expect(read(path.join(appDir, 'src', 'graphql', 'sdk', 'app', 'index.ts'))).toBe(STUB); + }); +}); + +describe('prewarmAppWorkspace on a composed app', () => { + it('only installs, and keeps a root workspace from capturing the app install', async () => { + composedApp(); + const rootWorkspace = 'packages:\n - "packages/*"\n'; + write(path.join(cwd, 'pnpm-workspace.yaml'), rootWorkspace); + const before = untouchedFiles(); + const { exec, calls } = fakeExec(); + + const result = await prewarmAppWorkspace(cwd, exec); + + expect(result.ok).toBe(true); + expect(calls).toEqual([ + { + cmd: 'pnpm', + args: ['install', '--frozen-lockfile', '--prefer-offline', '--ignore-workspace'], + cwd: appDir, + env: undefined, + }, + ]); + expect(untouchedFiles()).toEqual(before); + expect(read(path.join(cwd, 'pnpm-workspace.yaml'))).toBe(rootWorkspace); + }); + + it('does nothing when node_modules is already there', async () => { + composedApp({ nodeModules: true }); + const { exec, calls } = fakeExec(); + + await expect(prewarmAppWorkspace(cwd, exec)).resolves.toMatchObject({ ok: true }); + expect(calls).toEqual([]); + }); +}); + +describe('run_codegen on a constructive-app template', () => { + it('still hoists the workspace config, pins codegen to latest, and runs npx', async () => { + write( + path.join(appDir, 'package.json'), + JSON.stringify({ devDependencies: { '@constructive-io/graphql-codegen': '^4.9.0' } }), + ); + write(path.join(appDir, 'pnpm-workspace.yaml'), "packages:\n - 'packages/*'\n"); + const { exec, calls } = fakeExec((call) => { + if (isCodegen(call)) writeGeneratedSdk(); + return { ok: true, out: '' }; + }); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + expect(calls.map(({ cmd, args, cwd }) => ({ cmd, args, cwd }))).toEqual([ + { cmd: 'pnpm', args: ['install', '--no-frozen-lockfile'], cwd }, + { + cmd: 'npx', + args: ['@constructive-io/graphql-codegen', 'generate', '--config', './graphql-codegen.config.ts'], + cwd: appDir, + }, + ]); + expect(JSON.parse(read(path.join(appDir, 'package.json'))).devDependencies).toEqual({ + '@constructive-io/graphql-codegen': 'latest', + }); + expect(existsSync(path.join(appDir, 'pnpm-workspace.yaml'))).toBe(false); + expect(read(path.join(cwd, 'pnpm-workspace.yaml'))).toContain('packages:\n - "packages/*"'); + expect(result.details).toMatchObject({ success: true, appKind: 'template', installed: true }); + }); +}); diff --git a/agentic/db-tools/src/app-workspace.ts b/agentic/db-tools/src/app-workspace.ts index bfb7014346..76e6c212ed 100644 --- a/agentic/db-tools/src/app-workspace.ts +++ b/agentic/db-tools/src/app-workspace.ts @@ -1,5 +1,5 @@ import { execFile } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -26,6 +26,50 @@ const DEFAULT_GRAPHQL_OVERRIDE = '16.14.0'; const TEMPLATE_REPO = 'https://github.com/constructive-io/sandbox-templates'; const TEMPLATE_SUBDIR = path.join('nextjs', 'constructive-app'); +// A composed app carries this marker, written when its template was composed. +// It ships its own pinned package.json and pnpm-lock.yaml, so none of the +// constructive-app fixups below apply to it. +export const COMPOSE_MARKER = path.join('.constructive', 'compose.json'); + +export type AppKind = + | { kind: 'composed' } + | { kind: 'template' } + | { kind: 'invalid'; reason: string }; + +/** + * Which flow packages/app takes. Composed needs the marker to be a JSON object + * and the app's own lockfile beside it. A marker that fails either check is + * neither: running the template fixups would rewrite a composed app, so + * callers stop and report the reason instead. + */ +export function detectAppKind(appDir: string): AppKind { + const marker = path.join(appDir, COMPOSE_MARKER); + if (!existsSync(marker)) return { kind: 'template' }; + let parsed: unknown; + try { + parsed = JSON.parse(readFileSync(marker, 'utf8')); + } catch { + return { kind: 'invalid', reason: `packages/app/${COMPOSE_MARKER} is not valid JSON.` }; + } + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + return { kind: 'invalid', reason: `packages/app/${COMPOSE_MARKER} is not a JSON object.` }; + } + if (!existsSync(path.join(appDir, 'pnpm-lock.yaml'))) { + return { + kind: 'invalid', + reason: `packages/app has ${COMPOSE_MARKER} but no pnpm-lock.yaml, so it can't be installed as composed.`, + }; + } + return { kind: 'composed' }; +} + +export type RunCommand = ( + cmd: string, + args: string[], + cwd: string, + env?: Record, +) => Promise<{ ok: boolean; out: string }>; + export async function run( cmd: string, args: string[], @@ -97,7 +141,10 @@ export async function pinCodegenLatest(appDir: string): Promise { // its CLI is unreliable in the non-TTY agent shell, which made the agent thrash // for minutes. Materialize `packages/app` deterministically by shallow-cloning // the template instead (PGPM-001). -export async function ensureAppScaffold(cwd: string): Promise<{ ok: boolean; out: string }> { +export async function ensureAppScaffold( + cwd: string, + exec: RunCommand = run, +): Promise<{ ok: boolean; out: string }> { const appDir = path.join(cwd, 'packages', 'app'); if (existsSync(path.join(appDir, 'package.json'))) { return { ok: true, out: 'packages/app already scaffolded' }; @@ -105,7 +152,7 @@ export async function ensureAppScaffold(cwd: string): Promise<{ ok: boolean; out const tmp = path.join(os.tmpdir(), `airpage-app-template-${process.pid}`); await rm(tmp, { recursive: true, force: true }); - const clone = await run('git', ['clone', '--depth', '1', TEMPLATE_REPO, tmp], cwd); + const clone = await exec('git', ['clone', '--depth', '1', TEMPLATE_REPO, tmp], cwd); if (!clone.ok) { await rm(tmp, { recursive: true, force: true }); return { ok: false, out: `Template clone failed:\n${clone.out}` }; @@ -131,22 +178,91 @@ export async function ensureAppScaffold(cwd: string): Promise<{ ok: boolean; out return { ok: true, out: 'Scaffolded packages/app from the constructive-app template.' }; } +// pnpm writes node_modules/.modules.yaml once an install finishes linking, so a +// partial install (interrupted, or failed mid-way) lacks it. Codegen also +// needs its bin, so both must be there to skip the install. +function composedInstallComplete(appDir: string): boolean { + const modules = path.join(appDir, 'node_modules'); + return ( + existsSync(path.join(modules, '.modules.yaml')) && + existsSync(path.join(modules, '.bin', 'graphql-codegen')) + ); +} + +// Install a composed app exactly as its lockfile pins it, unless a finished +// install is already there. The install runs in packages/app against its own +// lockfile, from the store where it can. A nested pnpm-workspace.yaml makes the +// app its own workspace root, so the config is left alone; without one, a root +// pnpm-workspace.yaml would pull the install up to the project root, where no +// lockfile exists, so pnpm is told to ignore it instead of rewriting it. +export async function ensureComposedInstall( + cwd: string, + exec: RunCommand = run, +): Promise<{ ok: boolean; out: string; installed: boolean }> { + const appDir = path.join(cwd, 'packages', 'app'); + if (composedInstallComplete(appDir)) { + return { ok: true, out: 'packages/app dependencies already installed.', installed: false }; + } + + const args = ['install', '--frozen-lockfile', '--prefer-offline']; + const ownsWorkspace = existsSync(path.join(appDir, 'pnpm-workspace.yaml')); + if (!ownsWorkspace && existsSync(path.join(cwd, 'pnpm-workspace.yaml'))) { + args.push('--ignore-workspace'); + } + const install = await exec('pnpm', args, appDir); + if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) { + return { ok: false, out: `pnpm install failed:\n${install.out}`, installed: true }; + } + return { ok: true, out: 'Installed packages/app from its lockfile.', installed: true }; +} + // Scaffold packages/app, hoist the workspace config, pin codegen, and run the one // clean install. All of this is database-INDEPENDENT, so it can be prewarmed // concurrently with the ~90s provision_database backend mutation to keep clone + // install off run_codegen's critical path. Best-effort and fully idempotent: // run_codegen re-runs the same steps, finding the scaffold present and the install // already satisfied (a fast no-op). Errors are swallowed by callers that prewarm — -// run_codegen surfaces any real failure later. -export async function prewarmAppWorkspace(cwd: string): Promise<{ ok: boolean; out: string }> { - const scaffold = await ensureAppScaffold(cwd); - if (!scaffold.ok) return scaffold; +// run_codegen surfaces any real failure later. A composed app only gets its +// frozen install, and an invalid marker leaves the app untouched. +// +// One prewarm runs per project at a time: a call while one is in flight gets +// that same run, so a retried provision_database (or run_codegen, via +// settlePrewarm) never starts a second install beside it. +const prewarmsInFlight = new Map>(); +export function prewarmAppWorkspace( + cwd: string, + exec: RunCommand = run, +): Promise<{ ok: boolean; out: string }> { + const key = path.resolve(cwd); + const inFlight = prewarmsInFlight.get(key); + if (inFlight) return inFlight; + const started = prewarm(cwd, exec).finally(() => prewarmsInFlight.delete(key)); + prewarmsInFlight.set(key, started); + return started; +} + +/** Wait for a prewarm still running for `cwd`; its outcome doesn't matter here. */ +export async function settlePrewarm(cwd: string): Promise { + await prewarmsInFlight.get(path.resolve(cwd))?.catch((): void => undefined); +} + +async function prewarm(cwd: string, exec: RunCommand): Promise<{ ok: boolean; out: string }> { const appDir = path.join(cwd, 'packages', 'app'); + const app = detectAppKind(appDir); + if (app.kind === 'invalid') return { ok: false, out: app.reason }; + if (app.kind === 'composed') { + const install = await ensureComposedInstall(cwd, exec); + return install.ok ? { ok: true, out: 'Prewarmed packages/app install.' } : install; + } + + const scaffold = await ensureAppScaffold(cwd, exec); + if (!scaffold.ok) return scaffold; + await ensureWorkspaceConfig(cwd); await pinCodegenLatest(appDir); - const install = await run('pnpm', ['install', '--no-frozen-lockfile'], cwd); + const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], cwd); if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) { return { ok: false, out: `pnpm install failed:\n${install.out}` }; } diff --git a/agentic/db-tools/src/index.ts b/agentic/db-tools/src/index.ts index ac85c3e8d6..a522e725ae 100644 --- a/agentic/db-tools/src/index.ts +++ b/agentic/db-tools/src/index.ts @@ -101,6 +101,7 @@ export { resolveProvisionModules, } from './provision-database/resolve'; export { toolSchema } from './tool-schema'; +export { type RunCodegenDetails, type RunCodegenTimings } from './tools/run-codegen'; export { createTemplatePreviewTables } from './tools/templates'; export default constructiveDbTools; diff --git a/agentic/db-tools/src/tools/run-codegen.ts b/agentic/db-tools/src/tools/run-codegen.ts index e476b4c20d..d003aeab26 100644 --- a/agentic/db-tools/src/tools/run-codegen.ts +++ b/agentic/db-tools/src/tools/run-codegen.ts @@ -6,14 +6,19 @@ import type { HarnessTool } from '@agentic-kit/harness'; import { z } from 'zod'; import { + detectAppKind, ensureAppScaffold, + ensureComposedInstall, ensureWorkspaceConfig, IGNORED_BUILDS_RE, pinCodegenLatest, prewarmAppWorkspace, run, + type RunCommand, + settlePrewarm, } from '../app-workspace'; import { getHost } from '../host'; +import { readEnv } from '../provision-database/env-file'; import { normalizeSdkBarrels } from '../run-codegen/barrels'; import { codegenEndpointEnv, derivePlaneEndpoints, envLocalContent } from '../run-codegen/endpoints'; @@ -22,17 +27,35 @@ const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql'; const RunCodegenZod = z.object({}); type Params = z.infer; +/** Wall-clock milliseconds per step; a step that didn't run reports 0. */ +export type RunCodegenTimings = { + scaffoldMs: number; + installMs: number; + codegenMs: number; + barrelsMs: number; + totalMs: number; +}; + export type RunCodegenDetails = { success: boolean; message: string; barrelsRewritten?: number; generatedSubmodules?: string[]; + /** `composed` when packages/app carries .constructive/compose.json. */ + appKind?: 'composed' | 'template'; + /** False when a composed app's node_modules was already there. */ + installed?: boolean; + codegenAttempts?: number; + timings?: RunCodegenTimings; }; type ToolResult = { content: { type: 'text'; text: string }[]; details: RunCodegenDetails }; -function fail(message: string): ToolResult { - return { content: [{ type: 'text', text: message }], details: { success: false, message } }; +function fail(message: string, extra?: Omit): ToolResult { + return { + content: [{ type: 'text', text: message }], + details: { ...extra, success: false, message }, + }; } const FETCH_RESET_RE = /Failed to fetch schema|Connection reset|ECONNRESET|socket hang up/i; @@ -44,9 +67,11 @@ async function readProjectEnv( cwd: string, ): Promise<{ databaseName?: string; apiEndpoint?: string }> { try { - const env = await readFile(path.join(cwd, '.env'), 'utf8'); - const read = (key: string) => env.match(new RegExp(`^${key}=(.+)$`, 'm'))?.[1]?.trim(); - return { databaseName: read('DATABASE_NAME'), apiEndpoint: read('API_ENDPOINT') }; + const env = readEnv(await readFile(path.join(cwd, '.env'), 'utf8')); + return { + databaseName: env.DATABASE_NAME?.trim() || undefined, + apiEndpoint: env.API_ENDPOINT?.trim() || undefined, + }; } catch { return {}; } @@ -54,6 +79,17 @@ async function readProjectEnv( const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const CODEGEN_ARGS = ['generate', '--config', './graphql-codegen.config.ts']; + +// The template flow resolves codegen through npx. A composed app runs the codegen +// its lockfile pinned: the linked bin, or the app's own `codegen` script when the +// bin isn't linked, so nothing is resolved over the network. +function codegenCommand(appDir: string, composed: boolean): { cmd: string; args: string[] } { + if (!composed) return { cmd: 'npx', args: ['@constructive-io/graphql-codegen', ...CODEGEN_ARGS] }; + const bin = path.join(appDir, 'node_modules', '.bin', 'graphql-codegen'); + return existsSync(bin) ? { cmd: bin, args: CODEGEN_ARGS } : { cmd: 'pnpm', args: ['run', 'codegen'] }; +} + // The codegen CLI introspects three live vhosts (admin/auth/app). Under load the // Constructive backend intermittently resets a connection mid-introspection // ("Failed to fetch schema: Connection reset"), which is transient — a retry @@ -63,129 +99,193 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); async function runCodegenWithRetry( appDir: string, env: Record, + exec: RunCommand, + composed: boolean, attempts = 4, -): Promise<{ ok: boolean; out: string }> { +): Promise<{ ok: boolean; out: string; attempts: number }> { const sdk = path.join(appDir, 'src', 'graphql', 'sdk'); + const { cmd, args } = codegenCommand(appDir, composed); let last: { ok: boolean; out: string } = { ok: false, out: 'codegen never ran' }; for (let i = 1; i <= attempts; i++) { - for (const sub of ['admin', 'auth', 'app']) { - await rm(path.join(sdk, sub), { recursive: true, force: true }); + // A composed app ships SDK stubs so it builds before codegen; codegen prunes + // its own stale files, so the stubs stay until a run replaces them. + if (!composed) { + for (const sub of ['admin', 'auth', 'app']) { + await rm(path.join(sdk, sub), { recursive: true, force: true }); + } } - last = await run( - 'npx', - ['@constructive-io/graphql-codegen', 'generate', '--config', './graphql-codegen.config.ts'], - appDir, - env, - ); - if (last.ok) return last; - if (!FETCH_RESET_RE.test(last.out)) return last; + last = await exec(cmd, args, appDir, env); + if (last.ok || !FETCH_RESET_RE.test(last.out)) return { ...last, attempts: i }; if (i < attempts) await sleep(2000 * i); } - return last; + return { ...last, attempts }; } -export const runCodegenTool: HarnessTool = { - name: 'run_codegen', - label: 'Run codegen', - description: - "Generate the typed GraphQL SDK for the project's Next.js app (packages/app). Runs the exact deterministic sequence — scaffold packages/app from the constructive-app template if it doesn't exist yet, hoist the graphql override to the root workspace so a single grafast resolves, pin codegen to latest, do one clean install, run codegen (retrying transient backend connection resets), and normalize SDK barrels for Turbopack. Run AFTER provisioning the database and creating schema; it scaffolds packages/app itself, so no separate `pgpm init` step is needed. The scaffold + install are usually prewarmed during provision_database, so this is fast.", - promptSnippet: - 'run_codegen: scaffold packages/app (if missing) + regenerate the typed SDK (workspace-config + clean install + codegen-with-retry + barrel-normalize). Run after schema changes. Gated.', - parameters: RunCodegenZod, - async execute(_params: Params, ctx): Promise { - const cwd = ctx.cwd; - const appDir = path.join(cwd, 'packages', 'app'); - - // Materialize packages/app deterministically if it isn't there yet — this - // replaces the flaky `pgpm init` scaffold step (PGPM-001). When - // provision_database prewarmed it, this no-ops. - const scaffold = await ensureAppScaffold(cwd); - if (!scaffold.ok) { - return fail(scaffold.out); - } - - const { databaseName: dbName, apiEndpoint: envApiEndpoint } = await readProjectEnv(cwd); - if (!dbName) { - return fail('DATABASE_NAME missing from .env — provision the database first, then re-run.'); - } +// `exec` runs every child process (clone, install, codegen); tests inject it. +export function createRunCodegenTool( + exec: RunCommand = run, +): HarnessTool { + return { + name: 'run_codegen', + label: 'Run codegen', + description: + "Generate the typed GraphQL SDK for the project's Next.js app (packages/app). Runs the exact deterministic sequence — scaffold packages/app from the constructive-app template if it doesn't exist yet, hoist the graphql override to the root workspace so a single grafast resolves, pin codegen to latest, do one clean install, run codegen (retrying transient backend connection resets), and normalize SDK barrels for Turbopack. A composed app (packages/app/.constructive/compose.json) skips the scaffold and those fixups: it installs from its own lockfile only when node_modules is missing and runs its local codegen. Run AFTER provisioning the database and creating schema; it scaffolds packages/app itself, so no separate `pgpm init` step is needed. The scaffold + install are usually prewarmed during provision_database, so this is fast.", + promptSnippet: + 'run_codegen: scaffold packages/app (if missing) + regenerate the typed SDK (workspace-config + clean install + codegen-with-retry + barrel-normalize; a composed app keeps its lockfile and local codegen). Run after schema changes. Gated.', + parameters: RunCodegenZod, + async execute(_params: Params, ctx): Promise { + const started = Date.now(); + const since = (t: number) => Date.now() - t; + const cwd = ctx.cwd; + const appDir = path.join(cwd, 'packages', 'app'); + // A prewarm provision_database started may still be installing; let it + // finish so this run's install never races it. + await settlePrewarm(cwd); + const app = detectAppKind(appDir); + if (app.kind === 'invalid') { + return fail(`${app.reason} Recompose packages/app, or remove the marker to use the template flow.`); + } + const composed = app.kind === 'composed'; + const appKind: RunCodegenDetails['appKind'] = composed ? 'composed' : 'template'; + const timings: RunCodegenTimings = { + scaffoldMs: 0, + installMs: 0, + codegenMs: 0, + barrelsMs: 0, + totalMs: 0, + }; + let installed: boolean | undefined; + let codegenAttempts: number | undefined; + const stepDetails = () => ({ + appKind, + installed, + codegenAttempts, + timings: { ...timings, totalMs: since(started) }, + }); - const backend = getHost().backendConfig(); - const apiEndpoint = envApiEndpoint || backend?.apiEndpoint || DEFAULT_API_ENDPOINT; - const planes = derivePlaneEndpoints(apiEndpoint, dbName); - - // The dev server reads .env.local (NEXT_PUBLIC_DB_NAME + per-DB endpoint - // overrides so it doesn't fall back to localhost vhosts on a remote - // backend); the codegen config reads process.env (passed directly below). - try { - await writeFile(path.join(appDir, '.env.local'), envLocalContent(dbName, planes)); - } catch (err) { - return fail( - `Failed to write packages/app/.env.local: ${err instanceof Error ? err.message : String(err)}`, - ); - } + // Materialize packages/app deterministically if it isn't there yet — this + // replaces the flaky `pgpm init` scaffold step (PGPM-001). When + // provision_database prewarmed it, this no-ops. + if (!composed) { + const t = Date.now(); + const scaffold = await ensureAppScaffold(cwd, exec); + timings.scaffoldMs = since(t); + if (!scaffold.ok) { + return fail(scaffold.out, stepDetails()); + } + } - // Hoist the graphql override to the root workspace (PNPM-GRAPHQL-OVERRIDE-001) - // and pin codegen to latest (TS-001) before the single clean install. When - // prewarmed by provision_database these are idempotent and the install is a - // fast no-op; otherwise this is the first install (PNPM-BUILDS-001). - await ensureWorkspaceConfig(cwd); - await pinCodegenLatest(appDir); + const { databaseName: dbName, apiEndpoint: envApiEndpoint } = await readProjectEnv(cwd); + if (!dbName) { + return fail('DATABASE_NAME missing from .env — provision the database first, then re-run.'); + } - const install = await run('pnpm', ['install', '--no-frozen-lockfile'], cwd); - if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) { - return fail(`pnpm install failed:\n${install.out}`); - } + const backend = getHost().backendConfig(); + const apiEndpoint = envApiEndpoint || backend?.apiEndpoint || DEFAULT_API_ENDPOINT; + const planes = derivePlaneEndpoints(apiEndpoint, dbName); - const codegen = await runCodegenWithRetry(appDir, { - NEXT_PUBLIC_DB_NAME: dbName, - ...(planes ? codegenEndpointEnv(planes) : {}), - }); - if (!codegen.ok) { - // A reset that survives every internal retry is a backend OUTAGE, not a code - // or install problem — re-running run_codegen cannot fix it. Say so explicitly - // so the agent marks sdk-codegen `blocked` instead of looping the tool to the - // time cap (CODEGEN-OUTAGE-001 / THRASH-001). - if (FETCH_RESET_RE.test(codegen.out)) { + // The dev server reads .env.local (NEXT_PUBLIC_DB_NAME + per-DB endpoint + // overrides so it doesn't fall back to localhost vhosts on a remote + // backend); the codegen config reads process.env (passed directly below). + try { + await writeFile(path.join(appDir, '.env.local'), envLocalContent(dbName, planes)); + } catch (err) { return fail( - 'codegen failed: BACKEND OUTAGE — the Constructive backend reset the connection ' + - 'during schema introspection, and it stayed down across every internal retry. ' + - 'This is an EXTERNAL outage, not a code or install problem; re-running run_codegen ' + - 'will NOT fix it. Call run_preflight once — if the backend is down, mark ' + - 'sdk-codegen `blocked` (SERVER-001, BLOCKED-PROCEED-001) and hand back to ' + - 'build-orchestrator. Do NOT loop on run_codegen (THRASH-001).\n\n' + - codegen.out, + `Failed to write packages/app/.env.local: ${err instanceof Error ? err.message : String(err)}`, ); } - return fail(`codegen failed:\n${codegen.out}`); - } - // Normalize directory barrels so Turbopack can resolve them (TURBOPACK-BARREL-001). - const sdkRoot = path.join(appDir, 'src', 'graphql', 'sdk'); - const barrelsRewritten = normalizeSdkBarrels(sdkRoot); + const tInstall = Date.now(); + if (composed) { + const install = await ensureComposedInstall(cwd, exec); + timings.installMs = since(tInstall); + installed = install.installed; + if (!install.ok) { + return fail(install.out, stepDetails()); + } + } else { + // Hoist the graphql override to the root workspace (PNPM-GRAPHQL-OVERRIDE-001) + // and pin codegen to latest (TS-001) before the single clean install. When + // prewarmed by provision_database these are idempotent and the install is a + // fast no-op; otherwise this is the first install (PNPM-BUILDS-001). + await ensureWorkspaceConfig(cwd); + await pinCodegenLatest(appDir); - // Report which app submodules actually generated — a bare scaffold with only - // index.ts means codegen had no schema to generate against. - const appSdk = path.join(sdkRoot, 'app'); - const generatedSubmodules = ['hooks', 'orm', 'types'].filter((sub) => - existsSync(path.join(appSdk, sub)), - ); + const install = await exec('pnpm', ['install', '--no-frozen-lockfile'], cwd); + timings.installMs = since(tInstall); + installed = true; + if (!install.ok && !IGNORED_BUILDS_RE.test(install.out)) { + return fail(`pnpm install failed:\n${install.out}`, stepDetails()); + } + } - const message = - generatedSubmodules.length > 0 - ? `Codegen complete. Generated app submodules: ${generatedSubmodules.join(', ')}. Normalized ${barrelsRewritten} barrel file(s).` - : `Codegen ran, but no app submodules (hooks/orm/types) were generated — the database may have no entity tables yet. Normalized ${barrelsRewritten} barrel file(s).`; + const tCodegen = Date.now(); + const codegen = await runCodegenWithRetry( + appDir, + { + NEXT_PUBLIC_DB_NAME: dbName, + ...(planes ? codegenEndpointEnv(planes) : {}), + }, + exec, + composed, + ); + timings.codegenMs = since(tCodegen); + codegenAttempts = codegen.attempts; + if (!codegen.ok) { + // A reset that survives every internal retry is a backend OUTAGE, not a code + // or install problem — re-running run_codegen cannot fix it. Say so explicitly + // so the agent marks sdk-codegen `blocked` instead of looping the tool to the + // time cap (CODEGEN-OUTAGE-001 / THRASH-001). + if (FETCH_RESET_RE.test(codegen.out)) { + return fail( + 'codegen failed: BACKEND OUTAGE — the Constructive backend reset the connection ' + + 'during schema introspection, and it stayed down across every internal retry. ' + + 'This is an EXTERNAL outage, not a code or install problem; re-running run_codegen ' + + 'will NOT fix it. Call run_preflight once — if the backend is down, mark ' + + 'sdk-codegen `blocked` (SERVER-001, BLOCKED-PROCEED-001) and hand back to ' + + 'build-orchestrator. Do NOT loop on run_codegen (THRASH-001).\n\n' + + codegen.out, + stepDetails(), + ); + } + return fail(`codegen failed:\n${codegen.out}`, stepDetails()); + } - return { - content: [{ type: 'text', text: message }], - details: { - success: generatedSubmodules.length > 0, - message, - barrelsRewritten, - generatedSubmodules, - }, - }; - }, -}; + // Normalize directory barrels so Turbopack can resolve them (TURBOPACK-BARREL-001). + // Only directories that exist are rewritten, so a config without an admin + // target leaves nothing pointing at one. + const tBarrels = Date.now(); + const sdkRoot = path.join(appDir, 'src', 'graphql', 'sdk'); + const barrelsRewritten = normalizeSdkBarrels(sdkRoot); + timings.barrelsMs = since(tBarrels); + + // Report which app submodules actually generated — a bare scaffold with only + // index.ts means codegen had no schema to generate against. + const appSdk = path.join(sdkRoot, 'app'); + const generatedSubmodules = ['hooks', 'orm', 'types'].filter((sub) => + existsSync(path.join(appSdk, sub)), + ); + + const message = + generatedSubmodules.length > 0 + ? `Codegen complete. Generated app submodules: ${generatedSubmodules.join(', ')}. Normalized ${barrelsRewritten} barrel file(s).` + : `Codegen ran, but no app submodules (hooks/orm/types) were generated — the database may have no entity tables yet. Normalized ${barrelsRewritten} barrel file(s).`; + + return { + content: [{ type: 'text', text: message }], + details: { + success: generatedSubmodules.length > 0, + message, + barrelsRewritten, + generatedSubmodules, + ...stepDetails(), + }, + }; + }, + }; +} + +export const runCodegenTool = createRunCodegenTool(); // Re-exported so provision_database can prewarm the same deterministic scaffold + // install concurrently with its backend mutation (kept off run_codegen's path). From ab985f7b1e6c1ecc2c84388745bc2a603d83e305 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 19 Oct 2026 21:35:52 +0700 Subject: [PATCH 02/14] feat(db-tools): let the host bound provision polling and resume pending provisions A host can set provisionPollDeadlineMs for callers outside an agent tool timeout. A provision that outlasts the poll is recorded in .env with its ticket, and a later call with the same name resumes that ticket instead of requesting a second database. --- .../__tests__/provision-resume.test.ts | 243 ++++++++ .../__tests__/request-database.test.ts | 47 +- agentic/db-tools/src/host.ts | 7 + .../src/provision-database/env-file.ts | 116 +++- .../src/provision-database/pending-ticket.ts | 58 ++ .../provision-database/request-database.ts | 102 +++- .../db-tools/src/tools/provision-database.ts | 542 +++++++++++------- 7 files changed, 853 insertions(+), 262 deletions(-) create mode 100644 agentic/db-tools/__tests__/provision-resume.test.ts create mode 100644 agentic/db-tools/src/provision-database/pending-ticket.ts diff --git a/agentic/db-tools/__tests__/provision-resume.test.ts b/agentic/db-tools/__tests__/provision-resume.test.ts new file mode 100644 index 0000000000..f23d56ae7d --- /dev/null +++ b/agentic/db-tools/__tests__/provision-resume.test.ts @@ -0,0 +1,243 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// The network boundary: requestDatabase on the api client, the ticket read on +// the modules client, and the ownership probe. +const mockRequestDatabase = jest.fn(); +const mockFindTicket = jest.fn(); +jest.mock('@constructive-io/sdk', () => ({ + api: { + FetchAdapter: jest.fn(), + createClient: () => ({ + mutation: { requestDatabase: (...args: unknown[]) => ({ unwrap: () => mockRequestDatabase(...args) }) }, + }), + }, + modules: { + createClient: () => ({ + databaseProvisionModule: { + findOne: (args: { id: string }) => ({ unwrap: () => mockFindTicket(args.id) }), + }, + }), + }, +})); +jest.mock('../src/db-probe', () => ({ probeDatabase: jest.fn() })); + +import type { RunCommand } from '../src/app-workspace'; +import { probeDatabase } from '../src/db-probe'; +import { configureHost } from '../src/host'; +import { createProvisionDatabaseTool } from '../src/tools/provision-database'; + +const mockProbe = probeDatabase as jest.MockedFunction; +const API = 'http://api.localhost:3000/graphql'; + +const pending = (id: string) => ({ id, status: 'pending', bootstrapStatus: 'pending', databaseId: null as string | null }); +const completed = (id: string, databaseId: string) => ({ + id, + status: 'completed', + bootstrapStatus: 'completed', + databaseId, +}); +const ticketRead = (ticket: unknown) => ({ databaseProvisionModule: ticket }); +const requested = (ticket: unknown) => ({ requestDatabase: { result: ticket } }); +const graphqlError = (message: string) => + Object.assign(new Error(`GraphQL Error: ${message}`), { errors: [{ message }] }); + +const record = (fields: Partial> = {}) => + [ + `PROVISION_TICKET_ID=${fields.id ?? 'ticket-1'}`, + `PROVISION_TICKET_DATABASE_NAME=${fields.name ?? 'myapp'}`, + `PROVISION_TICKET_API_ENDPOINT=${fields.api ?? API}`, + `PROVISION_TICKET_USER_ID=${fields.user ?? 'user-1'}`, + `PROVISION_TICKET_ATTEMPTS=${fields.attempts ?? '1'}`, + ].join('\n') + '\n'; + +let cwd: string; +const envFile = () => readFileSync(path.join(cwd, '.env'), 'utf8'); +const envLines = () => envFile().split('\n').filter(Boolean); +const writeEnv = (content: string) => writeFileSync(path.join(cwd, '.env'), content); + +// No packages/app, so the prewarm only tries a template clone, which this +// runner refuses: nothing touches the network or the disk outside cwd. +const refuse: RunCommand = async () => ({ ok: false, out: 'not run in tests' }); +const provision = (name: string, exec: RunCommand = refuse) => + createProvisionDatabaseTool(exec).execute({ database_name: name }, { cwd }); + +beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'db-tools-provision-')); + mockRequestDatabase.mockReset(); + mockFindTicket.mockReset(); + mockProbe.mockReset(); + mockProbe.mockResolvedValue({ outcome: 'found', ownerId: 'user-1' }); + configureHost({ + account: () => ({ userId: 'user-1', accessToken: 'account-token' }), + backendConfig: () => ({ apiEndpoint: API, modulesEndpoint: 'http://modules.localhost:3000/graphql' }), + provisionPollDeadlineMs: 1, + }); +}); + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('provision_database pending tickets', () => { + it('records the ticket with its backend and account when it stops waiting, and changes nothing else', async () => { + writeEnv('KEEP=me\n'); + mockRequestDatabase.mockResolvedValue(requested(pending('ticket-1'))); + mockFindTicket.mockResolvedValue(ticketRead(pending('ticket-1'))); + + const result = await provision('myapp'); + + expect(result.details).toMatchObject({ success: false, pending: true, ticketId: 'ticket-1' }); + expect(result.details.message).toMatch(/call provision_database again with database_name "myapp"/); + expect(envLines()).toEqual(['KEEP=me', ...record().trim().split('\n')]); + }); + + it('resumes the recorded ticket for the same name, backend and account, and clears it once bound', async () => { + writeEnv(`KEEP=me\n${record()}`); + mockFindTicket.mockResolvedValue(ticketRead(completed('ticket-1', 'db-1'))); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(mockFindTicket).toHaveBeenCalledWith('ticket-1'); + expect(result.details).toMatchObject({ success: true, resumed: true, databaseId: 'db-1' }); + expect(envLines().slice(0, 2)).toEqual(['KEEP=me', 'DATABASE_ID=db-1']); + expect(envFile()).not.toMatch(/PROVISION_TICKET/); + }); + + it("surfaces a failed ticket's error and clears the record", async () => { + writeEnv(`KEEP=me\n${record()}`); + mockFindTicket.mockResolvedValue( + ticketRead({ ...pending('ticket-1'), status: 'failed', errorMessage: 'provision job crashed' }), + ); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/provision job crashed/); + expect(envFile()).toBe('KEEP=me\n'); + }); + + it('keeps the record when the backend cannot be reached, rather than requesting again', async () => { + writeEnv(`KEEP=me\n${record()}`); + mockFindTicket.mockRejectedValue(new TypeError('fetch failed')); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ success: false, pending: true, ticketId: 'ticket-1' }); + expect(envLines()).toContain('PROVISION_TICKET_ATTEMPTS=2'); + }); + + it('clears the record and fails clearly when reading the ticket gets an answer that is an error', async () => { + writeEnv(`KEEP=me\n${record()}`); + mockFindTicket.mockRejectedValue(graphqlError('jwt expired')); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details.pending).toBeUndefined(); + expect(result.details.message).toMatch(/ticket-1 could not be read: .*jwt expired/); + expect(envFile()).toBe('KEEP=me\n'); + }); + + it('stops following a ticket after the attempt cap, and says a new call starts a new provision', async () => { + writeEnv(`KEEP=me\n${record({ attempts: '5' })}`); + mockFindTicket.mockResolvedValue(ticketRead(pending('ticket-1'))); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details.pending).toBeUndefined(); + expect(result.details.message).toMatch(/still not ready after 5 waits.*a new call starts a new provision/); + expect(envFile()).toBe('KEEP=me\n'); + }); + + it.each([ + ['another name', { name: 'otherapp' }], + ['another backend', { api: 'http://api.example.test/graphql' }], + ['another account', { user: 'user-2' }], + ])('never reuses a ticket recorded for %s', async (_case, fields) => { + writeEnv(record(fields)); + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-2', 'db-2'))); + + const result = await provision('myapp'); + + expect(mockFindTicket).not.toHaveBeenCalledWith('ticket-1'); + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + expect(result.details).toMatchObject({ success: true, databaseId: 'db-2' }); + expect(result.details.resumed).toBeUndefined(); + }); + + it('does not bind a database the signed-in account does not own', async () => { + writeEnv(`KEEP=me\n${record()}`); + mockFindTicket.mockResolvedValue(ticketRead(completed('ticket-1', 'db-1'))); + mockProbe.mockResolvedValue({ outcome: 'found', ownerId: 'user-2' }); + + const result = await provision('myapp'); + + expect(result.details.success).toBe(false); + expect(result.details.message).toMatch(/isn't owned by the signed-in account, so it was not bound/); + expect(envFile()).toBe('KEEP=me\n'); + }); + + it('writes into the .env as it is when the provision ends, keeping edits made meanwhile', async () => { + writeEnv('KEEP=me\n'); + mockRequestDatabase.mockImplementation(async () => { + writeEnv('KEEP=me\nADDED_WHILE_WAITING=yes\n'); + return requested(completed('ticket-2', 'db-2')); + }); + + await provision('myapp'); + + expect(envLines()).toEqual(expect.arrayContaining(['ADDED_WHILE_WAITING=yes', 'DATABASE_ID=db-2'])); + }); + + it('reads and replaces quoted and exported keys the way the .env parser does', async () => { + writeEnv( + 'export ACCESS_TOKEN="stale"\n' + + record() + .trim() + .split('\n') + .map((line) => `export ${line.replace('=', '="')}"`) + .join('\n') + + '\n', + ); + mockFindTicket.mockResolvedValue(ticketRead(completed('ticket-1', 'db-1'))); + + const result = await provision('myapp'); + + expect(result.details).toMatchObject({ success: true, resumed: true }); + expect(envLines().filter((line) => line.includes('ACCESS_TOKEN'))).toEqual([ + 'export ACCESS_TOKEN=account-token', + ]); + expect(envFile()).not.toMatch(/PROVISION_TICKET/); + }); + + it('joins the install a retry finds still running instead of starting a second one', async () => { + // A composed app with no node_modules: the prewarm installs it, and this + // install never finishes. + const appDir = path.join(cwd, 'packages', 'app'); + mkdirSync(path.join(appDir, '.constructive'), { recursive: true }); + writeFileSync(path.join(appDir, '.constructive', 'compose.json'), '{}'); + writeFileSync(path.join(appDir, 'pnpm-lock.yaml'), "lockfileVersion: '9.0'\n"); + writeFileSync(path.join(appDir, 'package.json'), '{}'); + const installs: string[] = []; + const hangingInstall: RunCommand = (cmd, args) => { + installs.push([cmd, ...args].join(' ')); + return new Promise(() => {}); + }; + mockRequestDatabase.mockResolvedValue(requested(pending('ticket-1'))); + mockFindTicket.mockResolvedValue(ticketRead(pending('ticket-1'))); + + const first = await provision('myapp', hangingInstall); + const second = await provision('myapp', hangingInstall); + + expect(first.details.pending).toBe(true); + expect(second.details.pending).toBe(true); + expect(installs).toEqual(['pnpm install --frozen-lockfile --prefer-offline']); + expect(existsSync(path.join(appDir, 'node_modules'))).toBe(false); + }); +}); diff --git a/agentic/db-tools/__tests__/request-database.test.ts b/agentic/db-tools/__tests__/request-database.test.ts index 60de8fc0e4..c1e285cb53 100644 --- a/agentic/db-tools/__tests__/request-database.test.ts +++ b/agentic/db-tools/__tests__/request-database.test.ts @@ -1,9 +1,20 @@ import { + ProvisionPendingError, type ProvisionRequestExecutor, type ProvisionTicket, requestDatabaseProvision, } from '../src/provision-database/request-database'; +// What the SDK throws: an HTTP status from the adapter, a GraphQL error, or a +// fetch that never got a response. +const httpError = (status: number) => + Object.assign(new Error(`GraphQL Error: HTTP ${status}`), { + errors: [{ message: `HTTP ${status}` }], + }); +const graphqlError = (message: string) => + Object.assign(new Error(`GraphQL Error: ${message}`), { errors: [{ message }] }); +const networkError = () => new TypeError('fetch failed'); + function ticket(overrides: Partial = {}): ProvisionTicket { return { id: 'ticket-1', @@ -64,7 +75,7 @@ describe('requestDatabaseProvision', () => { it('resolves a fully completed ticket without ever polling', async () => { const { promise, pollCalls } = run({ initial: done }); - await expect(promise).resolves.toEqual({ databaseId: 'db-id' }); + await expect(promise).resolves.toEqual({ databaseId: 'db-id', ticketId: 'ticket-1' }); expect(pollCalls).toEqual([]); }); @@ -73,7 +84,7 @@ describe('requestDatabaseProvision', () => { initial: ticket({ status: 'completed', databaseId: 'db-id' }), polls: [done], }); - await expect(promise).resolves.toEqual({ databaseId: 'db-id' }); + await expect(promise).resolves.toEqual({ databaseId: 'db-id', ticketId: 'ticket-1' }); expect(pollCalls).toEqual(['ticket-1']); }); @@ -86,7 +97,7 @@ describe('requestDatabaseProvision', () => { done, ], }); - await expect(promise).resolves.toEqual({ databaseId: 'db-id' }); + await expect(promise).resolves.toEqual({ databaseId: 'db-id', ticketId: 'ticket-1' }); expect(pollCalls).toHaveLength(3); }); @@ -136,35 +147,45 @@ describe('requestDatabaseProvision', () => { it('tolerates three consecutive failed polls, then continues', async () => { const { promise } = run({ initial: ticket(), - polls: [new Error('502'), null, new Error('network'), done], + polls: [httpError(502), null, networkError(), done], }); - await expect(promise).resolves.toEqual({ databaseId: 'db-id' }); + await expect(promise).resolves.toEqual({ databaseId: 'db-id', ticketId: 'ticket-1' }); }); - it('gives up on the fourth consecutive failed poll', async () => { + it('leaves the ticket pending after the fourth consecutive unreachable poll', async () => { const { promise, pollCalls } = run({ initial: ticket(), - polls: [new Error('502'), new Error('502'), new Error('502'), new Error('final failure')], + polls: [httpError(502), httpError(502), networkError(), httpError(503)], }); - await expect(promise).rejects.toThrow('final failure'); + await expect(promise).rejects.toBeInstanceOf(ProvisionPendingError); + await expect(promise).rejects.toMatchObject({ ticketId: 'ticket-1' }); expect(pollCalls).toHaveLength(4); }); + it('fails at once when a poll gets a GraphQL or auth error, without calling it pending', async () => { + for (const error of [graphqlError('permission denied for table database_provision_module'), httpError(401)]) { + const { promise, pollCalls } = run({ initial: ticket(), polls: [error] }); + await expect(promise).rejects.toThrow(/could not be read/); + await expect(promise).rejects.not.toBeInstanceOf(ProvisionPendingError); + expect(pollCalls).toHaveLength(1); + } + }); + it('resets the failure budget after a readable poll', async () => { const { promise } = run({ initial: ticket(), polls: [ - new Error('502'), + httpError(502), null, - new Error('502'), + httpError(502), ticket({ status: 'in_progress' }), - new Error('502'), + networkError(), null, - new Error('502'), + httpError(502), done, ], }); - await expect(promise).resolves.toEqual({ databaseId: 'db-id' }); + await expect(promise).resolves.toEqual({ databaseId: 'db-id', ticketId: 'ticket-1' }); }); it('times out a ticket that never completes', async () => { diff --git a/agentic/db-tools/src/host.ts b/agentic/db-tools/src/host.ts index 15d9d922f8..2b9b869b7b 100644 --- a/agentic/db-tools/src/host.ts +++ b/agentic/db-tools/src/host.ts @@ -100,6 +100,13 @@ export interface ToolsHost { previewToken?(): Promise; /** Treat tokens expiring within this window as already expired. Default 30s. */ dataTokenSkewMs?: number; + /** + * How long provision_database waits for a provision ticket to complete. + * Default 240s, which keeps an agent tool call under a 5-minute tool + * timeout; a host that calls the tool outside such a timeout (e.g. a + * pipeline) can wait longer for a cold provision on a loaded backend. + */ + provisionPollDeadlineMs?: number; /** * Optional provision overlay: pick a base preset and/or layer module * add/remove on top of it. The base module list always comes from the pinned diff --git a/agentic/db-tools/src/provision-database/env-file.ts b/agentic/db-tools/src/provision-database/env-file.ts index 8d75fe8b1b..eff1dc86d0 100644 --- a/agentic/db-tools/src/provision-database/env-file.ts +++ b/agentic/db-tools/src/provision-database/env-file.ts @@ -1,28 +1,101 @@ -// Pure .env merge — used by provision_database to persist provisioning -// credentials without clobbering existing keys: replace a key in place if -// present, else append. +// Pure .env edits — used by provision_database to persist provisioning +// credentials without clobbering existing keys. // Kept side-effect-free so it is unit-testable (the tool does the fs write). +// +// Reads go through the same parser context.ts uses (quotes, `export`, +// comments, the last definition wins), and edits find a key the way that +// parser does, so a key written as `export KEY="value"` or spread over several +// lines by a quoted value is still the key being replaced or removed. + +import { parseDotenv } from '12factor-env/dotenv'; export type EnvVars = Record; +/** Parse .env text: quoted values, `export` prefixes and comments; last definition wins. */ +export function readEnv(content: string): EnvVars { + return parseDotenv(content); +} + +const ENTRY_RE = /^(\s*)(export\s+)?([\w.-]+)\s*=\s*(.*)$/; +const QUOTES = new Set(['"', "'", '`']); + +type EnvEntry = { key: string | null; exported: boolean; lines: string[] }; + +// Group lines into entries: a key with the lines its value spans (a quoted +// value left open continues to the line that closes it), or a non-entry line +// (comment, blank, anything else) with a null key. +function envEntries(content: string): EnvEntry[] { + const lines = content.split('\n'); + const entries: EnvEntry[] = []; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const match = line.trimStart().startsWith('#') ? null : ENTRY_RE.exec(line); + if (!match) { + entries.push({ key: null, exported: false, lines: [line] }); + continue; + } + const value = match[4]; + const span = [line]; + if (QUOTES.has(value[0]) && !value.slice(1).includes(value[0])) { + while (i + 1 < lines.length) { + span.push(lines[++i]); + if (lines[i].includes(value[0])) break; + } + } + entries.push({ key: match[3], exported: !!match[2], lines: span }); + } + return entries; +} + /** - * Upsert `vars` into the existing `.env` `content`. Existing keys are replaced - * in place (preserving surrounding lines/comments); new keys are appended. + * Upsert `vars` into the existing `.env` `content`. An existing key is + * replaced in place (keeping an `export` prefix), and any later definition of + * it is dropped so the new value is the one that wins; new keys are appended. * Returns the new file body, trailing-newline-terminated. */ export function mergeEnv(content: string, vars: EnvVars): string { - let out = content; - for (const [key, val] of Object.entries(vars)) { - // Escape the key for the regex, and use a replacement function so `$` - // sequences in the value are never treated as replacement specials. - const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const regex = new RegExp(`^${escapedKey}=.*`, 'm'); - if (regex.test(out)) out = out.replace(regex, () => `${key}=${val}`); - else out += `\n${key}=${val}`; + const remaining = new Map(Object.entries(vars)); + const written = new Set(); + const out: string[] = []; + for (const entry of envEntries(content)) { + if (entry.key !== null && remaining.has(entry.key)) { + out.push(`${entry.exported ? 'export ' : ''}${entry.key}=${remaining.get(entry.key)}`); + remaining.delete(entry.key); + written.add(entry.key); + } else if (entry.key === null || !written.has(entry.key)) { + out.push(...entry.lines); + } } - return out.trim() + '\n'; + let text = out.join('\n'); + for (const [key, value] of remaining) text += `\n${key}=${value}`; + return text.trim() + '\n'; } +/** + * Drop every definition of `keys`. Comments and other entries pass through, + * and content without those keys comes back unchanged. + */ +export function removeEnvKeys(content: string, keys: readonly string[]): string { + const keySet = new Set(keys); + const entries = envEntries(content); + const kept = entries.filter((entry) => entry.key === null || !keySet.has(entry.key)); + return kept.length === entries.length ? content : kept.flatMap((entry) => entry.lines).join('\n'); +} + +/** + * The provision request provision_database is still waiting on, recorded when + * it stops waiting so the next call resumes that ticket. It's resumed only + * for the same database name, backend and account. Absent whenever no request + * is pending. + */ +export const PENDING_PROVISION_KEYS = { + ticketId: 'PROVISION_TICKET_ID', + databaseName: 'PROVISION_TICKET_DATABASE_NAME', + apiEndpoint: 'PROVISION_TICKET_API_ENDPOINT', + userId: 'PROVISION_TICKET_USER_ID', + attempts: 'PROVISION_TICKET_ATTEMPTS', +} as const; + /** The credential keys provision_database owns in a project's .env. */ export const PROVISION_ENV_KEYS = [ 'DATABASE_ID', @@ -86,14 +159,11 @@ export function archiveBindingKeys( archivedOn: string, ): string { const keySet = new Set(keys); - return content - .split('\n') - .map((line) => { - if (line.trimStart().startsWith('#')) return line; - const eq = line.indexOf('='); - if (eq === -1) return line; - const key = line.slice(0, eq).trim(); - return keySet.has(key) ? `# archived ${archivedOn}: ${line.trim()}` : line; - }) + return envEntries(content) + .flatMap((entry) => + entry.key !== null && keySet.has(entry.key) + ? entry.lines.map((line) => `# archived ${archivedOn}: ${line.trim()}`) + : entry.lines, + ) .join('\n'); } diff --git a/agentic/db-tools/src/provision-database/pending-ticket.ts b/agentic/db-tools/src/provision-database/pending-ticket.ts new file mode 100644 index 0000000000..ddb8b3769b --- /dev/null +++ b/agentic/db-tools/src/provision-database/pending-ticket.ts @@ -0,0 +1,58 @@ +// The provision request provision_database stopped waiting on, as recorded in +// a project's .env. requestDatabase never resumes by name, so the ticket is +// the only way back to a provision that is still running. + +import { type EnvVars, mergeEnv, PENDING_PROVISION_KEYS, removeEnvKeys } from './env-file'; + +/** + * Pending results recorded for one ticket before provision_database stops + * following it. At the default 240 s deadline that is about 20 minutes. + */ +export const MAX_PENDING_ATTEMPTS = 5; + +export type PendingTicket = { + ticketId: string; + databaseName: string; + apiEndpoint: string; + userId: string; + /** Pending results recorded for this ticket so far. */ + attempts: number; +}; + +const KEYS = Object.values(PENDING_PROVISION_KEYS); + +/** + * The recorded ticket when it was requested for this database name, on this + * backend and by this account. A ticket from another backend or account isn't + * ours to poll, and one for another name is a different database. + */ +export function matchingPendingTicket( + env: EnvVars, + want: { databaseName: string; apiEndpoint: string; userId: string }, +): PendingTicket | undefined { + const ticketId = env[PENDING_PROVISION_KEYS.ticketId]; + if ( + !ticketId || + env[PENDING_PROVISION_KEYS.databaseName] !== want.databaseName || + env[PENDING_PROVISION_KEYS.apiEndpoint] !== want.apiEndpoint || + env[PENDING_PROVISION_KEYS.userId] !== want.userId + ) { + return undefined; + } + const attempts = Number.parseInt(env[PENDING_PROVISION_KEYS.attempts] ?? '', 10); + return { ...want, ticketId, attempts: Number.isFinite(attempts) && attempts > 0 ? attempts : 0 }; +} + +export function recordPendingTicket(content: string, ticket: PendingTicket): string { + return mergeEnv(content, { + [PENDING_PROVISION_KEYS.ticketId]: ticket.ticketId, + [PENDING_PROVISION_KEYS.databaseName]: ticket.databaseName, + [PENDING_PROVISION_KEYS.apiEndpoint]: ticket.apiEndpoint, + [PENDING_PROVISION_KEYS.userId]: ticket.userId, + [PENDING_PROVISION_KEYS.attempts]: String(ticket.attempts), + }); +} + +export function clearPendingTicket(content: string): string { + return removeEnvKeys(content, KEYS); +} diff --git a/agentic/db-tools/src/provision-database/request-database.ts b/agentic/db-tools/src/provision-database/request-database.ts index a65b9d872e..036ee40698 100644 --- a/agentic/db-tools/src/provision-database/request-database.ts +++ b/agentic/db-tools/src/provision-database/request-database.ts @@ -56,6 +56,23 @@ export const POLL_DEADLINE_MS = 240_000; */ export const MAX_CONSECUTIVE_POLL_FAILURES = 3; +/** + * The provision is still running server-side, but this call stopped waiting: + * the deadline passed, or the backend couldn't be reached. A caller should keep + * `ticketId` and pass it back as `resumeTicketId`, because requesting the same + * name again starts a second provision rather than resuming this one. + */ +export class ProvisionPendingError extends Error { + constructor( + message: string, + readonly ticketId: string, + options?: { cause?: unknown }, + ) { + super(message, options); + this.name = 'ProvisionPendingError'; + } +} + const TICKET_SELECT = { id: true, status: true, @@ -105,6 +122,28 @@ function createSdkExecutor(args: { }; } +const NETWORK_ERROR_RE = + /fetch failed|network|socket hang up|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|aborted|timed? ?out/i; + +/** + * Whether an error reading or requesting a ticket says only that the backend + * couldn't be reached or failed: a network error thrown before any response, + * or an HTTP 5xx/429 the adapter reports as the only error. A GraphQL error + * (validation, permission, an expired or rejected token) is an answer, so the + * ticket can't be followed any further. + */ +export function isTransientProvisionError(err: unknown): boolean { + const errors = (err as { errors?: { message?: string }[] } | null)?.errors; + if (Array.isArray(errors)) { + return errors.length > 0 && errors.every((e) => /^HTTP (5\d\d|429)\b/.test(e?.message ?? '')); + } + if (!(err instanceof Error)) return false; + const code = (err.cause as { code?: unknown } | undefined)?.code; + return NETWORK_ERROR_RE.test(`${err.message} ${typeof code === 'string' ? code : ''}`); +} + +const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err)); + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -114,7 +153,12 @@ function sleep(ms: number): Promise { * `status` AND `bootstrapStatus` both 'completed'. The ticket the mutation * returns is checked before the first sleep, so a warm claim with bootstrap * already done never waits. `status: 'failed'` surfaces `errorMessage`; - * `bootstrapStatus: 'failed'` surfaces `bootstrapError`. + * `bootstrapStatus: 'failed'` surfaces `bootstrapError`. Stopping short of + * either outcome throws `ProvisionPendingError`. + * + * With `resumeTicketId` the ticket of an earlier call is polled instead of + * requesting again. A ticket that can't be found (another backend or account) + * falls back to a new request; `resumed` is true when the old one was used. */ export async function requestDatabaseProvision(args: { apiEndpoint: string; @@ -126,7 +170,8 @@ export async function requestDatabaseProvision(args: { executor?: ProvisionRequestExecutor; pollIntervalMs?: number; pollDeadlineMs?: number; -}): Promise<{ databaseId: string }> { + resumeTicketId?: string; +}): Promise<{ databaseId: string; ticketId: string; resumed?: true }> { const executor = args.executor ?? createSdkExecutor({ @@ -137,14 +182,33 @@ export async function requestDatabaseProvision(args: { const intervalMs = args.pollIntervalMs ?? POLL_INTERVAL_MS; const deadlineMs = args.pollDeadlineMs ?? POLL_DEADLINE_MS; - let ticket = await executor.request({ - databaseName: args.databaseName, - subdomain: args.databaseName, - domain: args.domain, - request: args.request, - }); - const ticketId = ticket.id; + // A backend that can't be reached leaves the old ticket pending rather than + // falling back to a new request, which would start a duplicate provision. + // Any other error is final for that ticket. + let resumedTicket: ProvisionTicket | null = null; + if (args.resumeTicketId) { + try { + resumedTicket = await executor.poll(args.resumeTicketId); + } catch (err) { + if (isTransientProvisionError(err)) { + throw new ProvisionPendingError(errorText(err), args.resumeTicketId, { cause: err }); + } + throw new Error(`provision ticket ${args.resumeTicketId} could not be read: ${errorText(err)}`, { + cause: err, + }); + } + } + let ticket = + resumedTicket ?? + (await executor.request({ + databaseName: args.databaseName, + subdomain: args.databaseName, + domain: args.domain, + request: args.request, + })); + const ticketId = resumedTicket ? (ticket.id ?? args.resumeTicketId!) : ticket.id; if (!ticketId) throw new Error('requestDatabase returned a provision ticket without an id.'); + const resumed = resumedTicket ? ({ resumed: true } as const) : {}; const deadline = Date.now() + deadlineMs; let failures = 0; @@ -157,25 +221,35 @@ export async function requestDatabaseProvision(args: { } if (ticket.status === 'completed' && ticket.bootstrapStatus === 'completed') { if (!ticket.databaseId) throw new Error('provisioning completed but returned no databaseId.'); - return { databaseId: ticket.databaseId }; + return { databaseId: ticket.databaseId, ticketId, ...resumed }; } if (Date.now() >= deadline) { - throw new Error( + throw new ProvisionPendingError( `provisioning timed out after ${Math.round(deadlineMs / 1000)}s (status: ${ ticket.status ?? 'unknown' }, bootstrapStatus: ${ticket.bootstrapStatus ?? 'unknown'})`, + ticketId, ); } await sleep(intervalMs); - // A poll that throws (network blip, 502) or comes back empty is retried a - // few times rather than failing a provision that is still running. + // A poll that can't reach the backend (network blip, 502) or comes back + // empty is retried a few times rather than failing a provision that is + // still running. Past that, an unreachable backend leaves the ticket + // pending; an empty answer, or any error that is an answer, is final. let polled: ProvisionTicket | null; try { polled = await executor.poll(ticketId); } catch (err) { - if (++failures > MAX_CONSECUTIVE_POLL_FAILURES) throw err; + if (!isTransientProvisionError(err)) { + throw new Error(`provision ticket ${ticketId} could not be read: ${errorText(err)}`, { + cause: err, + }); + } + if (++failures > MAX_CONSECUTIVE_POLL_FAILURES) { + throw new ProvisionPendingError(errorText(err), ticketId, { cause: err }); + } continue; } if (!polled) { diff --git a/agentic/db-tools/src/tools/provision-database.ts b/agentic/db-tools/src/tools/provision-database.ts index 71d1455ab6..c6008db4e3 100644 --- a/agentic/db-tools/src/tools/provision-database.ts +++ b/agentic/db-tools/src/tools/provision-database.ts @@ -1,11 +1,10 @@ -import { writeFile } from 'node:fs/promises'; -import { readFile } from 'node:fs/promises'; +import { readFile, writeFile } from 'node:fs/promises'; import path from 'node:path'; import type { HarnessTool } from '@agentic-kit/harness'; import { z } from 'zod'; -import { prewarmAppWorkspace } from '../app-workspace'; +import { prewarmAppWorkspace, run, type RunCommand } from '../app-workspace'; import { probeDatabase } from '../db-probe'; import { getHost } from '../host'; import { selectProvisionCredential } from '../provision-database/credential'; @@ -14,10 +13,21 @@ import { ARCHIVED_BINDING_KEYS, mergeEnv, provisionEnvVars, + readEnv, } from '../provision-database/env-file'; import { loadProvisionManifest } from '../provision-database/manifest'; +import { + clearPendingTicket, + matchingPendingTicket, + MAX_PENDING_ATTEMPTS, + type PendingTicket, + recordPendingTicket, +} from '../provision-database/pending-ticket'; import { selectProvisionRequest } from '../provision-database/preset-match'; -import { requestDatabaseProvision } from '../provision-database/request-database'; +import { + ProvisionPendingError, + requestDatabaseProvision, +} from '../provision-database/request-database'; import { type ProvisionOverlay, resolveProvisionModules } from '../provision-database/resolve'; const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql'; @@ -63,6 +73,16 @@ export type ProvisionDatabaseDetails = { ownerId?: string; /** True when an existing live binding was kept — nothing changed. */ skipped?: boolean; + /** + * True when the call stopped waiting while the provision is still running. + * The ticket is recorded in .env, and calling again with the same + * database_name resumes it. + */ + pending?: boolean; + /** The provision ticket, on a pending result. */ + ticketId?: string; + /** True when this call finished a provision an earlier call had started. */ + resumed?: boolean; }; type ToolResult = { content: { type: 'text'; text: string }[]; details: ProvisionDatabaseDetails }; @@ -71,238 +91,336 @@ function fail(message: string): ToolResult { return { content: [{ type: 'text', text: message }], details: { success: false, message } }; } -// Minimal .env reader for the idempotency check (full parsing lives in context.ts). -function parseEnvKeys(source: string): Record { - const env: Record = {}; - for (const rawLine of source.split('\n')) { - const line = rawLine.trim(); - if (!line || line.startsWith('#')) continue; - const eq = line.indexOf('='); - if (eq === -1) continue; - env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim(); +// Edit .env from its current content, read right before the write, so changes +// made while this call waited on the backend (by another tool or the user) +// aren't overwritten with what the file held when the call started. +async function updateEnvFile(envPath: string, edit: (content: string) => string): Promise { + let content = ''; + try { + content = await readFile(envPath, 'utf8'); + } catch { + /* no .env yet */ } - return env; + await writeFile(envPath, edit(content)); } -export const provisionDatabaseTool: HarnessTool< - typeof ProvisionDatabaseZod, - ProvisionDatabaseDetails -> = { - name: 'provision_database', - label: 'Provision database', - description: - 'Bootstrap a new Constructive database for the project under your account: provision the standard module set and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards).', - promptSnippet: - 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.', - parameters: ProvisionDatabaseZod, - async execute(params: Params, ctx): Promise { - const databaseName = params.database_name.trim(); - if (!databaseName) return fail('database_name is required.'); +// The provision is still running: record its ticket so the next call with the +// same name resumes it, and tell the agent to call again rather than rename. +// Past MAX_PENDING_ATTEMPTS the ticket is dropped and the call fails instead. +async function pendingResult( + envPath: string, + ticket: PendingTicket, + reason: string, + reprovision: boolean, +): Promise { + const { databaseName, ticketId } = ticket; + if (ticket.attempts > MAX_PENDING_ATTEMPTS) { + await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); + return fail( + `Database "${databaseName}" is still not ready after ${MAX_PENDING_ATTEMPTS} waits (${reason}), so provision_database stopped following its request (ticket ${ticketId}). Check the backend's provisioning jobs before calling provision_database again: a new call starts a new provision.`, + ); + } + let recorded = true; + try { + await updateEnvFile(envPath, (content) => recordPendingTicket(content, ticket)); + } catch { + recorded = false; + } + const again = `call provision_database again with database_name "${databaseName}"${ + reprovision ? ' and reprovision: true' : '' + }`; + const message = recorded + ? `Database "${databaseName}" is still provisioning (${reason}). The request is recorded in .env; ${again} to keep waiting on it. Don't change the name: that starts a second database.` + : `Database "${databaseName}" is still provisioning (${reason}), but recording its ticket in .env failed, so a later call can't resume it. Calling again with this name starts a second provision.`; + return { + content: [{ type: 'text', text: message }], + details: { success: false, message, pending: true, ticketId }, + }; +} - const envPath = path.join(ctx.cwd, '.env'); - let existing = ''; - try { - existing = await readFile(envPath, 'utf8'); - } catch { - /* no existing .env — fine */ - } +// The host's poll deadline when it's a positive number, else the default. +function pollDeadline(ms: number | undefined): number | undefined { + return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : undefined; +} + +// `exec` runs the prewarm's child processes (clone, install); tests inject it. +export function createProvisionDatabaseTool( + exec: RunCommand = run, +): HarnessTool { + return { + name: 'provision_database', + label: 'Provision database', + description: + 'Bootstrap a new Constructive database for the project under your account: provision the standard module set and write credentials (DATABASE_ID, ACCESS_TOKEN, etc.) to the project .env. Run this ONCE before any schema/record tools. If the project is already bound to a live database under the signed-in account it skips; if the bound database no longer exists on this backend (refreshed/deleted) or belongs to a different account, pass reprovision: true to mint a fresh one (old keys archived in .env, old database kept; rebuild the schema afterwards). If a call returns while the database is still provisioning, call it again with the same database_name to keep waiting; a different name starts a second database.', + promptSnippet: + 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.', + parameters: ProvisionDatabaseZod, + async execute(params: Params, ctx): Promise { + const databaseName = params.database_name.trim(); + if (!databaseName) return fail('database_name is required.'); - // Unify endpoints onto the app's backend-config store (environment-aware: - // *.localhost:3000 in dev, *.launchql.dev packaged). A process.env override - // still wins for ad-hoc dev; the hardcoded default is a last resort if the - // store isn't ready. - const host = getHost(); - const backend = host.backendConfig(); - const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT; - const modulesEndpoint = - process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT; + const envPath = path.join(ctx.cwd, '.env'); + let existing = ''; + try { + existing = await readFile(envPath, 'utf8'); + } catch { + /* no existing .env — fine */ + } - // Every new project provisions UNDER the account (requestDatabase owns the - // database to the JWT user), so it is account-owned and enumerable. The - // account bearer is the single credential: it authenticates the provision - // mutation, the idempotency - // probe below, and is the ACCESS_TOKEN written to .env for project-local - // scripts. It expires with the login session — a relogin plus any provision - // run (including the skip path) refreshes the .env copy. A signed-in session - // with no usable bearer errors out here rather than silently minting a - // throwaway owner. Gate BEFORE prewarm so an error return doesn't leak a - // detached background scaffold/install. - const credential = selectProvisionCredential(host.account(), host.signInHint); - if (credential.mode === 'error') { - return fail(`Cannot provision a database: ${credential.reason}`); - } - const ownerId = credential.ownerId; + // Unify endpoints onto the app's backend-config store (environment-aware: + // *.localhost:3000 in dev, *.launchql.dev packaged). A process.env override + // still wins for ad-hoc dev; the hardcoded default is a last resort if the + // store isn't ready. + const host = getHost(); + const backend = host.backendConfig(); + const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT; + const modulesEndpoint = + process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT; + + // Every new project provisions UNDER the account (requestDatabase owns the + // database to the JWT user), so it is account-owned and enumerable. The + // account bearer is the single credential: it authenticates the provision + // mutation, the idempotency + // probe below, and is the ACCESS_TOKEN written to .env for project-local + // scripts. It expires with the login session — a relogin plus any provision + // run (including the skip path) refreshes the .env copy. A signed-in session + // with no usable bearer errors out here rather than silently minting a + // throwaway owner. Gate BEFORE prewarm so an error return doesn't leak a + // detached background scaffold/install. + const credential = selectProvisionCredential(host.account(), host.signInHint); + if (credential.mode === 'error') { + return fail(`Cannot provision a database: ${credential.reason}`); + } + const ownerId = credential.ownerId; + + // Idempotency keyed on binding liveness AND ownership: a binding is done only + // when its database still answers and belongs to the signed-in account + // (schemas are account-scoped). A dead binding (backend refreshed, database + // deleted, key revoked) or a live one under another account points at the + // reprovision path; an unreachable backend is a retry, never a reprovision. + const existingEnv = readEnv(existing); + // A request an earlier call stopped waiting on. Only the same name, backend + // and account resume it; asking for that name again would start a second + // provision instead. + const pending = matchingPendingTicket(existingEnv, { + databaseName, + apiEndpoint, + userId: ownerId, + }); + const hasBinding = Boolean(existingEnv.DATABASE_ID && existingEnv.ACCESS_TOKEN); + if (hasBinding && !params.reprovision) { + // The probe carries the account bearer, so it only ever targets the + // app-configured backend — never a .env-pinned endpoint, which an + // untrusted cloned project controls. + const probe = await probeDatabase({ + endpoint: apiEndpoint, + bearer: credential.bearer, + databaseId: existingEnv.DATABASE_ID, + signInHint: host.signInHint, + }); + if (probe.outcome === 'unreachable') { + return fail( + `Cannot verify the existing database binding: backend unreachable (${probe.detail}). Check the Constructive backend is running, then retry — do not reprovision on an unreachable backend.`, + ); + } + if (probe.outcome === 'missing') { + return fail( + `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database no longer exists on this backend. Re-run with reprovision: true to mint a fresh database (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, + ); + } + const sessionUserId = host.account()?.userId; + if (probe.ownerId && sessionUserId && probe.ownerId !== sessionUserId) { + return fail( + `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database was provisioned under a different account than the one signed in. Re-run with reprovision: true to mint a fresh database under this account (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, + ); + } + // Heal .env on the skip path: a stale token (relogin) or missing/stale + // endpoint pins (project provisioned before pins existed, or backend + // config changed) refresh to the current session + backend. + const refresh: Record = {}; + if (existingEnv.ACCESS_TOKEN !== credential.bearer) refresh.ACCESS_TOKEN = credential.bearer; + if (existingEnv.API_ENDPOINT !== apiEndpoint) refresh.API_ENDPOINT = apiEndpoint; + if (existingEnv.MODULES_ENDPOINT !== modulesEndpoint) { + refresh.MODULES_ENDPOINT = modulesEndpoint; + } + let tokenNote = ''; + if (Object.keys(refresh).length > 0) { + const keys = Object.keys(refresh).join(', '); + try { + await updateEnvFile(envPath, (content) => mergeEnv(content, refresh)); + tokenNote = ` Refreshed in .env from the signed-in session: ${keys}.`; + } catch (err) { + tokenNote = ` Could not refresh ${keys} in .env: ${err instanceof Error ? err.message : String(err)}.`; + } + } + const message = `Project already provisioned (DATABASE_ID=${existingEnv.DATABASE_ID}) and the database is live under this account. Skipping — pass reprovision: true only for an explicit clean rebuild.${tokenNote}`; + return { + content: [{ type: 'text', text: message }], + details: { + success: true, + message, + databaseId: existingEnv.DATABASE_ID, + databaseName: existingEnv.DATABASE_NAME, + ownerId: existingEnv.OWNER_ID, + skipped: true, + }, + }; + } + const archiveOldBinding = hasBinding && params.reprovision === true; + + // The packages/app scaffold + pnpm install are database-INDEPENDENT, so kick + // them off now to run concurrently with the provisioning request below + // (near-instant on a warm-pool hit, up to minutes on the cold path). + // This keeps clone + install off run_codegen's critical path. Best-effort: + // run_codegen re-runs the same idempotent steps, so a prewarm failure is + // harmless. Never let it reject (we await it before returning). A call + // that returns pending leaves it running; a retry joins that same run + // rather than starting a second install. + const prewarm = prewarmAppWorkspace(ctx.cwd, exec).catch( + (err: unknown): { ok: boolean; out: string } => ({ + ok: false, + out: err instanceof Error ? err.message : String(err), + }), + ); + + // Resolve the module set: pinned base preset from node-type-registry, then + // ordered overlays (host default < project-local provision.json). No list is + // hand-copied here; overlays are pure data. A bad preset/manifest fails the + // run rather than provisioning a wrong module set. + let modules; + try { + const layers: ProvisionOverlay[] = []; + const hostOverlay = await host.provisionOverlay?.(); + if (hostOverlay) layers.push(hostOverlay); + const projectOverlay = await loadProvisionManifest(ctx.cwd); + if (projectOverlay) layers.push(projectOverlay); + modules = resolveProvisionModules(layers); + } catch (err) { + return fail( + `Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Provision on the API endpoint: requestDatabase claims a warm-pool + // database when the resolved module set matches a cataloged preset + // (near-instant) and cold-provisions asynchronously otherwise; the ticket + // is polled on the modules endpoint until the database AND its deferred + // owner bootstrap complete. No retry wrapper: a pending ticket is the + // normal first response, and transient poll failures are absorbed inside + // the poll loop. The domain still derives from the api endpoint's host + // (per-DB endpoints live under it), and an explicit subdomain + // (= databaseName) keeps them deterministic (SUBDOMAIN-001). + const ticketFor = (ticketId: string): PendingTicket => ({ + ticketId, + databaseName, + apiEndpoint, + userId: ownerId, + attempts: (pending?.ticketId === ticketId ? pending.attempts : 0) + 1, + }); + const reprovision = params.reprovision === true; + let provisioned: { databaseId: string; ticketId: string; resumed?: true }; + try { + provisioned = await requestDatabaseProvision({ + apiEndpoint, + modulesEndpoint, + bearer: credential.bearer, + databaseName, + domain: provisionDomain(apiEndpoint), + request: selectProvisionRequest(modules), + pollDeadlineMs: pollDeadline(host.provisionPollDeadlineMs), + resumeTicketId: pending?.ticketId, + }); + } catch (err) { + if (err instanceof ProvisionPendingError) { + return pendingResult(envPath, ticketFor(err.ticketId), err.message, reprovision); + } + // A definitive failure ends the recorded request, so forget it. + if (pending) await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); + const detail = err instanceof Error ? err.message : String(err); + const nameTakenHint = /already exists|already taken|already in use|duplicate/i.test(detail) + ? ' The database name is already taken on the backend — re-run with a different database_name.' + : ''; + return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`); + } + const { databaseId, ticketId } = provisioned; - // Idempotency keyed on binding liveness AND ownership: a binding is done only - // when its database still answers and belongs to the signed-in account - // (schemas are account-scoped). A dead binding (backend refreshed, database - // deleted, key revoked) or a live one under another account points at the - // reprovision path; an unreachable backend is a retry, never a reprovision. - const existingEnv = parseEnvKeys(existing); - const hasBinding = Boolean(existingEnv.DATABASE_ID && existingEnv.ACCESS_TOKEN); - if (hasBinding && !params.reprovision) { - // The probe carries the account bearer, so it only ever targets the - // app-configured backend — never a .env-pinned endpoint, which an - // untrusted cloned project controls. - const probe = await probeDatabase({ + // Bind only a database this account owns. A resumed ticket is the main + // reason to look: it was recorded by an earlier call. An unreachable + // backend keeps the ticket for the next call to check again. + const owner = await probeDatabase({ endpoint: apiEndpoint, bearer: credential.bearer, - databaseId: existingEnv.DATABASE_ID, + databaseId, signInHint: host.signInHint, }); - if (probe.outcome === 'unreachable') { - return fail( - `Cannot verify the existing database binding: backend unreachable (${probe.detail}). Check the Constructive backend is running, then retry — do not reprovision on an unreachable backend.`, + if (owner.outcome === 'unreachable') { + return pendingResult( + envPath, + ticketFor(ticketId), + `provisioned, but its owner couldn't be confirmed: ${owner.detail}`, + reprovision, ); } - if (probe.outcome === 'missing') { + if (owner.outcome === 'missing' || owner.ownerId !== ownerId) { + await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); return fail( - `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database no longer exists on this backend. Re-run with reprovision: true to mint a fresh database (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, + `Database provisioning returned database ${databaseId}, but ${ + owner.outcome === 'missing' ? "it isn't visible to" : "it isn't owned by" + } the signed-in account, so it was not bound. Call provision_database again to request a new database.`, ); } - const sessionUserId = host.account()?.userId; - if (probe.ownerId && sessionUserId && probe.ownerId !== sessionUserId) { + + // Persist the binding to the project .env (upsert, preserving other keys), + // dropping any recorded pending request: the project is bound now. + try { + await updateEnvFile(envPath, (content) => + mergeEnv( + clearPendingTicket( + archiveOldBinding + ? archiveBindingKeys(content, ARCHIVED_BINDING_KEYS, new Date().toISOString().slice(0, 10)) + : content, + ), + provisionEnvVars({ + databaseId, + databaseName, + ownerId, + accessToken: credential.bearer, + apiEndpoint, + modulesEndpoint, + }), + ), + ); + } catch (err) { return fail( - `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database was provisioned under a different account than the one signed in. Re-run with reprovision: true to mint a fresh database under this account (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, + `Database provisioned (ID: ${databaseId}) but writing .env failed: ${err instanceof Error ? err.message : String(err)}`, ); } - // Heal .env on the skip path: a stale token (relogin) or missing/stale - // endpoint pins (project provisioned before pins existed, or backend - // config changed) refresh to the current session + backend. - const refresh: Record = {}; - if (existingEnv.ACCESS_TOKEN !== credential.bearer) refresh.ACCESS_TOKEN = credential.bearer; - if (existingEnv.API_ENDPOINT !== apiEndpoint) refresh.API_ENDPOINT = apiEndpoint; - if (existingEnv.MODULES_ENDPOINT !== modulesEndpoint) { - refresh.MODULES_ENDPOINT = modulesEndpoint; - } - let tokenNote = ''; - if (Object.keys(refresh).length > 0) { - const keys = Object.keys(refresh).join(', '); - try { - await writeFile(envPath, mergeEnv(existing, refresh)); - tokenNote = ` Refreshed in .env from the signed-in session: ${keys}.`; - } catch (err) { - tokenNote = ` Could not refresh ${keys} in .env: ${err instanceof Error ? err.message : String(err)}.`; - } - } - const message = `Project already provisioned (DATABASE_ID=${existingEnv.DATABASE_ID}) and the database is live under this account. Skipping — pass reprovision: true only for an explicit clean rebuild.${tokenNote}`; + + // Settle the concurrent prewarm so the scaffold + install are in place before + // run_codegen runs. A failure here is non-fatal — run_codegen redoes the work. + const prewarmResult = await prewarm; + const prewarmNote = prewarmResult.ok ? ' packages/app prewarmed.' : ''; + + const reprovisionNote = archiveOldBinding + ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.' + : ''; + const resumedNote = provisioned.resumed + ? ' Finished the provision an earlier call had started.' + : ''; + const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${resumedNote}${prewarmNote}${reprovisionNote}`; return { content: [{ type: 'text', text: message }], details: { success: true, message, - databaseId: existingEnv.DATABASE_ID, - databaseName: existingEnv.DATABASE_NAME, - ownerId: existingEnv.OWNER_ID, - skipped: true, + databaseId, + databaseName, + ownerId, + ...(provisioned.resumed ? { resumed: true } : {}), }, }; - } - if (hasBinding && params.reprovision) { - existing = archiveBindingKeys( - existing, - ARCHIVED_BINDING_KEYS, - new Date().toISOString().slice(0, 10), - ); - } - - // The packages/app scaffold + pnpm install are database-INDEPENDENT, so kick - // them off now to run concurrently with the provisioning request below - // (near-instant on a warm-pool hit, up to minutes on the cold path). - // This keeps clone + install off run_codegen's critical path. Best-effort: - // run_codegen re-runs the same idempotent steps, so a prewarm failure is - // harmless. Never let it reject (we await it before returning). - const prewarm = prewarmAppWorkspace(ctx.cwd).catch( - (err: unknown): { ok: boolean; out: string } => ({ - ok: false, - out: err instanceof Error ? err.message : String(err), - }), - ); - - // Resolve the module set: pinned base preset from node-type-registry, then - // ordered overlays (host default < project-local provision.json). No list is - // hand-copied here; overlays are pure data. A bad preset/manifest fails the - // run rather than provisioning a wrong module set. - let modules; - try { - const layers: ProvisionOverlay[] = []; - const hostOverlay = await host.provisionOverlay?.(); - if (hostOverlay) layers.push(hostOverlay); - const projectOverlay = await loadProvisionManifest(ctx.cwd); - if (projectOverlay) layers.push(projectOverlay); - modules = resolveProvisionModules(layers); - } catch (err) { - return fail( - `Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Provision on the API endpoint: requestDatabase claims a warm-pool - // database when the resolved module set matches a cataloged preset - // (near-instant) and cold-provisions asynchronously otherwise; the ticket - // is polled on the modules endpoint until the database AND its deferred - // owner bootstrap complete. No retry wrapper: a pending ticket is the - // normal first response, and transient poll failures are absorbed inside - // the poll loop. The domain still derives from the api endpoint's host - // (per-DB endpoints live under it), and an explicit subdomain - // (= databaseName) keeps them deterministic (SUBDOMAIN-001). - let databaseId: string; - try { - ({ databaseId } = await requestDatabaseProvision({ - apiEndpoint, - modulesEndpoint, - bearer: credential.bearer, - databaseName, - domain: provisionDomain(apiEndpoint), - request: selectProvisionRequest(modules), - })); - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - const nameTakenHint = /already exists|already taken|already in use|duplicate/i.test(detail) - ? ' The database name is already taken on the backend — re-run with a different database_name.' - : ''; - return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`); - } - - // Persist the binding to the project .env (upsert, preserving other keys). - const merged = mergeEnv( - existing, - provisionEnvVars({ - databaseId, - databaseName, - ownerId, - accessToken: credential.bearer, - apiEndpoint, - modulesEndpoint, - }), - ); - try { - await writeFile(envPath, merged); - } catch (err) { - return fail( - `Database provisioned (ID: ${databaseId}) but writing .env failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Settle the concurrent prewarm so the scaffold + install are in place before - // run_codegen runs. A failure here is non-fatal — run_codegen redoes the work. - const prewarmResult = await prewarm; - const prewarmNote = prewarmResult.ok ? ' packages/app prewarmed.' : ''; + }, + }; +} - const reprovisionNote = - hasBinding && params.reprovision - ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.' - : ''; - const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${prewarmNote}${reprovisionNote}`; - return { - content: [{ type: 'text', text: message }], - details: { - success: true, - message, - databaseId, - databaseName, - ownerId, - }, - }; - }, -}; +export const provisionDatabaseTool = createProvisionDatabaseTool(); From 9d64c147b9852ec59902852707191a149eeb7247 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Mon, 19 Oct 2026 21:35:53 +0700 Subject: [PATCH 03/14] feat(graphile-meta): expose form metadata in _meta MetaField gains allowedValues (from check constraints), isRequiredOnCreate, isWritableOnCreate and isWritableOnUpdate; MetaTable gains isJunction, and detected junctions get many-to-many entries named by their own keys. Only form-level facts are exposed: no SQL, constraint, trigger or smart-tag internals, because _meta is public. Descriptions are written as public copy. --- graphile/graphile-meta/README.md | 26 + .../__fixtures__/check-constraints.json | 101 ++++ .../__snapshots__/meta-schema.test.ts.snap | 365 +++++++++++++ .../graphile-meta/__tests__/form-meta.test.ts | 516 ++++++++++++++++++ .../__tests__/meta-schema.test.ts | 6 +- .../src/check-constraint-values.ts | 145 +++++ graphile/graphile-meta/src/exposure.ts | 63 +++ .../graphile-meta/src/graphql-meta-field.ts | 57 +- .../src/junction-meta-builders.ts | 167 ++++++ graphile/graphile-meta/src/pg-node-tree.ts | 209 +++++++ graphile/graphile-meta/src/plugin.ts | 27 + .../src/relation-meta-builders.ts | 68 ++- .../graphile-meta/src/table-meta-builder.ts | 153 ++++-- .../graphile-meta/src/table-meta-context.ts | 7 + graphile/graphile-meta/src/type-mappings.ts | 11 + graphile/graphile-meta/src/types.ts | 27 + .../meta-form-fields.integration.test.ts | 282 ++++++++++ 17 files changed, 2157 insertions(+), 73 deletions(-) create mode 100644 graphile/graphile-meta/__tests__/__fixtures__/check-constraints.json create mode 100644 graphile/graphile-meta/__tests__/form-meta.test.ts create mode 100644 graphile/graphile-meta/src/check-constraint-values.ts create mode 100644 graphile/graphile-meta/src/exposure.ts create mode 100644 graphile/graphile-meta/src/junction-meta-builders.ts create mode 100644 graphile/graphile-meta/src/pg-node-tree.ts create mode 100644 graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts diff --git a/graphile/graphile-meta/README.md b/graphile/graphile-meta/README.md index 3486650c7b..f347dc1276 100644 --- a/graphile/graphile-meta/README.md +++ b/graphile/graphile-meta/README.md @@ -19,6 +19,7 @@ PostGraphile v5 plugin exposing a single `_meta` root query that describes each Standard GraphQL introspection tells you about the GraphQL schema — `_meta` adds the **database identities** behind its exposed objects. `MetaSchemaPlugin` reconciles the PostGraphile registry with the final executable schema and caches a `TableMeta` entry per exposed table, covering: - **Tables and fields** — exact PostgreSQL names alongside final GraphQL names, plus Postgres type, GraphQL type, nullability, defaults, primary/foreign key flags, and scalar encoding hints (how to serialize bigints, datetimes, vectors, geojson, …) +- **Form metadata** — allowed values from CHECK constraints, whether the create input requires a field, which fields create and update accept, and whether a table is a junction - **Indexes & constraints** — primary key, unique constraints, foreign keys with referenced tables - **Relations** — `belongsTo`, `hasOne`, `hasMany`, and `manyToMany` with the exact generated field names - **Inflection** — the type names and root field names PostGraphile generated (`tableType`, `allRows`, `connection`, `createInputType`, …) @@ -60,7 +61,12 @@ query { hasDefault isPrimaryKey isForeignKey + allowedValues + isRequiredOnCreate + isWritableOnCreate + isWritableOnUpdate } + isJunction indexes { name isUnique isPrimary columns } constraints { primaryKey { name } @@ -110,6 +116,26 @@ Example response snippet: operation under `query` is `null` when that field was not emitted in the final GraphQL schema. +### Form metadata + +These fields let a generic client build correct create and edit forms from `_meta` alone. Their GraphQL descriptions are public copy that says only what a client may rely on; the rules below are how the values are derived. + +These form fields only describe what the final schema exposes: a column hidden from the API (no select behavior, or no field on the table's type) and a table without an output type contribute nothing to them, so none of their values hints that hidden columns or tables exist. That holds for these fields only. The older metadata (indexes, constraints, foreign keys and scope) isn't filtered by visibility, so it can still name columns or tables the API hides. + +- **`MetaField.allowedValues`** lists the values that simple single-column CHECK constraints permit on a text, varchar or char column: `col IN ('a', 'b')`, `col = ANY (ARRAY['a', 'b'])`, `col = ANY ('{a,b}'::text[])` and `col = 'a' OR col = 'b'`. It's read at gather time from the constraint expressions introspection already carries, so `_meta` runs no extra query, and only the values are reported, never the constraint's name or expression. Several simple constraints on one column intersect. When any other single-column CHECK on the column can't be read that way, it may reject listed values too, so the field is `null`; it's also `null` when no simple constraint applies. +- **`MetaField.isRequiredOnCreate`** is true when the field is non-null in the table's create input type, so a create must supply it. The record input is found by its inflected name (`tableFieldName`, and `patchField` of it for updates), never by guessing among the input's fields; when it can't be found the flags are `null`. It follows the input PostGraphile actually built rather than `NOT NULL`: a column a trigger fills (`@behavior -insert`) or one with a default is `NOT NULL` but not required. +- **`MetaField.isWritableOnCreate` / `isWritableOnUpdate`** say whether the create input and the patch type have the field. + + These four are set on the fields under `MetaTable.fields` and are `null` on the fields nested in indexes, constraints and relations. +- **Detected many-to-many relations.** A table is a detected junction when its exposed non-system columns are exactly the local columns of two foreign keys whose relations have the select behavior. A column is a system column when a client never picks its value in a form: + 1. it's a surrogate key: in the primary key and in no foreign key (a primary key made of the two foreign keys is the classic junction); + 2. it has a default, which covers generated ids, `owner_id DEFAULT current_user_id()` and `created_at`/`updated_at DEFAULT now()`; + 3. the create input can't set it (`@behavior -insert`); + 4. it's the table's `@scope` key column. + + Foreign keys on system columns, such as `owner_id` referencing users, don't count. A timestamp with no default that clients write (e.g. `checked_in_at`) is data, so a table carrying one isn't a junction. A detected junction adds a `relations.manyToMany` entry to each of the two tables it links, even when the many-to-many plugin doesn't create one, but only when the junction has its create mutation and the junction, both linked tables and every key column on either side are exposed. Both constraints in the entry are the junction's own foreign keys, so it never depends on, or names, a backward relation the API hides (Constructive's preset hides them unless a table opts into many-to-many). `fieldName` and `type` are `null` when the final schema has no field for the relation. +- **`MetaTable.isJunction`** is true exactly when a reported `manyToMany` relation, declared by the many-to-many plugin or detected, names the table as its junction. + ### Pairing with @constructive-io/graphql-query ```typescript diff --git a/graphile/graphile-meta/__tests__/__fixtures__/check-constraints.json b/graphile/graphile-meta/__tests__/__fixtures__/check-constraints.json new file mode 100644 index 0000000000..6cc5e762f2 --- /dev/null +++ b/graphile/graphile-meta/__tests__/__fixtures__/check-constraints.json @@ -0,0 +1,101 @@ +{ + "ab": { + "conkey": [ + 14, + 15 + ], + "definition": "CHECK ((a = b))", + "conbin": "{OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({VAR :varno 1 :varattno 14 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 14 :location -1} {VAR :varno 1 :varattno 15 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 15 :location -1}) :location -1}" + }, + "n_int": { + "conkey": [ + 9 + ], + "definition": "CHECK ((n_int = ANY (ARRAY[1, 2])))", + "conbin": "{SCALARARRAYOPEXPR :opno 96 :opfuncid 65 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 0 :args ({VAR :varno 1 :varattno 9 :vartype 23 :vartypmod -1 :varcollid 0 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 9 :location -1} {ARRAYEXPR :array_typeid 1007 :array_collid 0 :element_typeid 23 :elements ({CONST :consttype 23 :consttypmod -1 :constcollid 0 :constlen 4 :constbyval true :constisnull false :location -1 :constvalue 4 [ 1 0 0 0 0 0 0 0 ]} {CONST :consttype 23 :consttypmod -1 :constcollid 0 :constlen 4 :constbyval true :constisnull false :location -1 :constvalue 4 [ 2 0 0 0 0 0 0 0 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1}" + }, + "s_all": { + "conkey": [ + 11 + ], + "definition": "CHECK ((s_all <> ALL (ARRAY['x'::text])))", + "conbin": "{SCALARARRAYOPEXPR :opno 531 :opfuncid 157 :hashfuncid 0 :negfuncid 0 :useOr false :inputcollid 100 :args ({VAR :varno 1 :varattno 11 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 11 :location -1} {ARRAYEXPR :array_typeid 1009 :array_collid 100 :element_typeid 25 :elements ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 5 [ 20 0 0 0 120 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1}" + }, + "s_and": { + "conkey": [ + 10 + ], + "definition": "CHECK (((s_and = ANY (ARRAY['a'::text, 'b'::text])) AND (length(s_and) > 0)))", + "conbin": "{BOOLEXPR :boolop and :args ({SCALARARRAYOPEXPR :opno 98 :opfuncid 67 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({VAR :varno 1 :varattno 10 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 10 :location -1} {ARRAYEXPR :array_typeid 1009 :array_collid 100 :element_typeid 25 :elements ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 5 [ 20 0 0 0 97 ]} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 5 [ 20 0 0 0 98 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1} {OPEXPR :opno 521 :opfuncid 147 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 0 :args ({FUNCEXPR :funcid 1317 :funcresulttype 23 :funcretset false :funcvariadic false :funcformat 0 :funccollid 0 :inputcollid 100 :args ({VAR :varno 1 :varattno 10 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 10 :location -1}) :location -1} {CONST :consttype 23 :consttypmod -1 :constcollid 0 :constlen 4 :constbyval true :constisnull false :location -1 :constvalue 4 [ 0 0 0 0 0 0 0 0 ]}) :location -1}) :location -1}" + }, + "s_any": { + "conkey": [ + 2 + ], + "definition": "CHECK ((s_any = ANY (ARRAY['open'::text, 'full'::text])))", + "conbin": "{SCALARARRAYOPEXPR :opno 98 :opfuncid 67 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({VAR :varno 1 :varattno 2 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 2 :location -1} {ARRAYEXPR :array_typeid 1009 :array_collid 100 :element_typeid 25 :elements ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 111 112 101 110 ]} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 102 117 108 108 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1}" + }, + "s_bp": { + "conkey": [ + 7 + ], + "definition": "CHECK ((s_bp = ANY (ARRAY['abc'::bpchar, 'def'::bpchar])))", + "conbin": "{SCALARARRAYOPEXPR :opno 1054 :opfuncid 1048 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({VAR :varno 1 :varattno 7 :vartype 1042 :vartypmod 7 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 7 :location -1} {ARRAYEXPR :array_typeid 1014 :array_collid 100 :element_typeid 1042 :elements ({CONST :consttype 1042 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 7 [ 28 0 0 0 97 98 99 ]} {CONST :consttype 1042 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 7 [ 28 0 0 0 100 101 102 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1}" + }, + "s_eq": { + "conkey": [ + 8 + ], + "definition": "CHECK ((s_eq = 'only'::text))", + "conbin": "{OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({VAR :varno 1 :varattno 8 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 8 :location -1} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 111 110 108 121 ]}) :location -1}" + }, + "s_esc": { + "conkey": [ + 6 + ], + "definition": "CHECK ((s_esc = ANY (ARRAY['it''s'::text, 'café'::text, 'a b'::text])))", + "conbin": "{SCALARARRAYOPEXPR :opno 98 :opfuncid 67 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({VAR :varno 1 :varattno 6 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 6 :location -1} {ARRAYEXPR :array_typeid 1009 :array_collid 100 :element_typeid 25 :elements ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 105 116 39 115 ]} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 9 [ 36 0 0 0 99 97 102 195 169 ]} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 7 [ 28 0 0 0 97 32 98 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1}" + }, + "s_fn": { + "conkey": [ + 12 + ], + "definition": "CHECK ((lower(s_fn) = 'a'::text))", + "conbin": "{OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({FUNCEXPR :funcid 870 :funcresulttype 25 :funcretset false :funcvariadic false :funcformat 0 :funccollid 100 :inputcollid 100 :args ({VAR :varno 1 :varattno 12 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 12 :location -1}) :location -1} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 5 [ 20 0 0 0 97 ]}) :location -1}" + }, + "s_in": { + "conkey": [ + 1 + ], + "definition": "CHECK ((s_in = ANY (ARRAY['open'::text, 'full'::text])))", + "conbin": "{SCALARARRAYOPEXPR :opno 98 :opfuncid 67 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({VAR :varno 1 :varattno 1 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 1 :location -1} {ARRAYEXPR :array_typeid 1009 :array_collid 100 :element_typeid 25 :elements ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 111 112 101 110 ]} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 102 117 108 108 ]}) :multidims false :list_start -1 :list_end -1 :location -1}) :location -1}" + }, + "s_lit": { + "conkey": [ + 5 + ], + "definition": "CHECK ((s_lit = ANY ('{open,full}'::text[])))", + "conbin": "{SCALARARRAYOPEXPR :opno 98 :opfuncid 67 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({VAR :varno 1 :varattno 5 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 5 :location -1} {CONST :consttype 1009 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 40 [ 160 0 0 0 1 0 0 0 0 0 0 0 25 0 0 0 2 0 0 0 1 0 0 0 32 0 0 0 111 112 101 110 32 0 0 0 102 117 108 108 ]}) :location -1}" + }, + "s_or": { + "conkey": [ + 3 + ], + "definition": "CHECK (((s_or = 'open'::text) OR (s_or = 'full'::text) OR (s_or = 'cancelled'::text)))", + "conbin": "{BOOLEXPR :boolop or :args ({OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({VAR :varno 1 :varattno 3 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 3 :location -1} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 111 112 101 110 ]}) :location -1} {OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({VAR :varno 1 :varattno 3 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 3 :location -1} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 102 117 108 108 ]}) :location -1} {OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({VAR :varno 1 :varattno 3 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 3 :location -1} {CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 13 [ 52 0 0 0 99 97 110 99 101 108 108 101 100 ]}) :location -1}) :location -1}" + }, + "s_rev": { + "conkey": [ + 13 + ], + "definition": "CHECK ((('open'::text = s_rev) OR ('full'::text = s_rev)))", + "conbin": "{BOOLEXPR :boolop or :args ({OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 111 112 101 110 ]} {VAR :varno 1 :varattno 13 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 13 :location -1}) :location -1} {OPEXPR :opno 98 :opfuncid 67 :opresulttype 16 :opretset false :opcollid 0 :inputcollid 100 :args ({CONST :consttype 25 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 102 117 108 108 ]} {VAR :varno 1 :varattno 13 :vartype 25 :vartypmod -1 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 13 :location -1}) :location -1}) :location -1}" + }, + "s_vc": { + "conkey": [ + 4 + ], + "definition": "CHECK (((s_vc)::text = ANY ((ARRAY['open'::character varying, 'full'::character varying])::text[])))", + "conbin": "{SCALARARRAYOPEXPR :opno 98 :opfuncid 67 :hashfuncid 0 :negfuncid 0 :useOr true :inputcollid 100 :args ({RELABELTYPE :arg {VAR :varno 1 :varattno 4 :vartype 1043 :vartypmod 24 :varcollid 100 :varnullingrels (b) :varlevelsup 0 :varreturningtype 0 :varnosyn 1 :varattnosyn 4 :location -1} :resulttype 25 :resulttypmod -1 :resultcollid 100 :relabelformat 2 :location -1} {ARRAYCOERCEEXPR :arg {ARRAYEXPR :array_typeid 1015 :array_collid 100 :element_typeid 1043 :elements ({CONST :consttype 1043 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 111 112 101 110 ]} {CONST :consttype 1043 :consttypmod -1 :constcollid 100 :constlen -1 :constbyval false :constisnull false :location -1 :constvalue 8 [ 32 0 0 0 102 117 108 108 ]}) :multidims false :list_start -1 :list_end -1 :location -1} :elemexpr {RELABELTYPE :arg {CASETESTEXPR :typeId 1043 :typeMod -1 :collation 0} :resulttype 25 :resulttypmod -1 :resultcollid 100 :relabelformat 2 :location -1} :resulttype 1009 :resulttypmod -1 :resultcollid 100 :coerceformat 2 :location -1}) :location -1}" + } +} diff --git a/graphile/graphile-meta/__tests__/__snapshots__/meta-schema.test.ts.snap b/graphile/graphile-meta/__tests__/__snapshots__/meta-schema.test.ts.snap index ff579fa508..e1e442576e 100644 --- a/graphile/graphile-meta/__tests__/__snapshots__/meta-schema.test.ts.snap +++ b/graphile/graphile-meta/__tests__/__snapshots__/meta-schema.test.ts.snap @@ -350,6 +350,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -357,6 +358,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -374,6 +378,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "comment_author_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -381,6 +386,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -406,6 +414,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -413,6 +422,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -430,6 +442,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "comment_post_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -437,6 +450,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -463,6 +479,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "primaryKey": { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -470,6 +487,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -490,6 +510,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "fields": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -497,6 +518,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": true, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -511,6 +535,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "body", "description": null, "enumValues": null, @@ -518,6 +543,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "body", "type": { "encoding": null, @@ -530,6 +558,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -537,6 +566,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": true, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -551,6 +583,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -558,6 +591,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": true, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -576,6 +612,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -583,6 +620,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -600,6 +640,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "comment_author_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -607,6 +648,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -632,6 +676,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -639,6 +684,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -656,6 +704,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "comment_post_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -663,6 +712,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -694,6 +746,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -701,6 +754,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -734,11 +790,13 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "tableType": "Comment", "updatePayloadType": "UpdatecommentPayload", }, + "isJunction": false, "name": "Comment", "primaryKeyConstraints": [ { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -746,6 +804,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -778,6 +839,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -785,6 +847,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -809,6 +874,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -816,6 +882,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -854,6 +923,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -861,6 +931,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -878,6 +951,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_author_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -885,6 +959,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -911,6 +988,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "primaryKey": { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -918,6 +996,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -938,6 +1019,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "fields": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -945,6 +1027,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": true, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -959,6 +1044,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -966,6 +1052,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": true, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -980,6 +1069,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "metadata", "description": null, "enumValues": null, @@ -987,6 +1077,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": false, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "metadata", "type": { "encoding": null, @@ -999,6 +1092,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "title", "description": null, "enumValues": null, @@ -1006,6 +1100,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "title", "type": { "encoding": null, @@ -1022,6 +1119,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -1029,6 +1127,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -1046,6 +1147,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_author_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1053,6 +1155,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1084,6 +1189,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1091,6 +1197,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1124,11 +1233,13 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "tableType": "Post", "updatePayloadType": "UpdatepostPayload", }, + "isJunction": false, "name": "Post", "primaryKeyConstraints": [ { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1136,6 +1247,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1168,6 +1282,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "author_id", "description": null, "enumValues": null, @@ -1175,6 +1290,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "authorId", "type": { "encoding": { @@ -1201,6 +1319,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1208,6 +1327,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1232,6 +1354,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1239,6 +1362,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1265,6 +1391,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1272,6 +1399,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1296,6 +1426,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1303,6 +1434,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1330,6 +1464,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "junctionLeftConstraint": { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1337,6 +1472,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1354,6 +1492,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_post_tags", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1361,6 +1500,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1385,6 +1527,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "junctionLeftKeyAttributes": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1392,6 +1535,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1409,6 +1555,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "junctionRightConstraint": { "fields": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1416,6 +1563,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1433,6 +1583,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_tag_tag_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1440,6 +1591,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1464,6 +1618,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "junctionRightKeyAttributes": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1471,6 +1626,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1490,6 +1648,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "leftKeyAttributes": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1497,6 +1656,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1513,6 +1675,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "rightKeyAttributes": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1520,6 +1683,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1554,6 +1720,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1561,6 +1728,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1578,6 +1748,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_tag_post_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1585,6 +1756,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1610,6 +1784,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1617,6 +1792,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1634,6 +1812,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_tag_tag_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1641,6 +1820,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1667,6 +1849,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "primaryKey": { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1674,6 +1857,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1688,6 +1874,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1695,6 +1882,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1715,6 +1905,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1722,6 +1913,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": true, "isNotNull": true, "isPrimaryKey": true, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1736,6 +1930,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1743,6 +1938,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": true, "isNotNull": true, "isPrimaryKey": true, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1761,6 +1959,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1768,6 +1967,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1785,6 +1987,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_tag_post_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1792,6 +1995,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1817,6 +2023,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1824,6 +2031,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1841,6 +2051,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_tag_tag_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -1848,6 +2059,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -1880,6 +2094,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1887,6 +2102,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1901,6 +2119,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1908,6 +2127,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -1941,11 +2163,13 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "tableType": "PostTag", "updatePayloadType": "Updatepost_tagPayload", }, + "isJunction": true, "name": "PostTag", "primaryKeyConstraints": [ { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -1953,6 +2177,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -1967,6 +2194,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -1974,6 +2202,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -2006,6 +2237,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -2013,6 +2245,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -2037,6 +2272,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -2044,6 +2280,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -2082,6 +2321,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "primaryKey": { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2089,6 +2329,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2109,6 +2352,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "slug", "description": null, "enumValues": null, @@ -2116,6 +2360,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "slug", "type": { "encoding": null, @@ -2134,6 +2381,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2141,6 +2389,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": true, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2155,6 +2406,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "slug", "description": null, "enumValues": null, @@ -2162,6 +2414,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "slug", "type": { "encoding": null, @@ -2183,6 +2438,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2190,6 +2446,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2214,6 +2473,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "slug", "description": null, "enumValues": null, @@ -2221,6 +2481,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "slug", "type": { "encoding": null, @@ -2252,11 +2515,13 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "tableType": "Tag", "updatePayloadType": "UpdatetagPayload", }, + "isJunction": false, "name": "Tag", "primaryKeyConstraints": [ { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2264,6 +2529,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2297,6 +2565,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2304,6 +2573,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2330,6 +2602,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2337,6 +2610,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2364,6 +2640,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "junctionLeftConstraint": { "fields": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -2371,6 +2648,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -2388,6 +2668,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "tag_post_tags", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2395,6 +2676,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2419,6 +2703,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "junctionLeftKeyAttributes": [ { + "allowedValues": null, "columnName": "tag_id", "description": null, "enumValues": null, @@ -2426,6 +2711,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "tagId", "type": { "encoding": { @@ -2443,6 +2731,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "junctionRightConstraint": { "fields": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -2450,6 +2739,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -2467,6 +2759,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "name": "post_tag_post_fkey", "refFields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2474,6 +2767,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2498,6 +2794,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "junctionRightKeyAttributes": [ { + "allowedValues": null, "columnName": "post_id", "description": null, "enumValues": null, @@ -2505,6 +2802,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "postId", "type": { "encoding": { @@ -2524,6 +2824,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "leftKeyAttributes": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2531,6 +2832,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2547,6 +2851,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "rightKeyAttributes": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2554,6 +2859,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2584,6 +2892,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "slug", "description": null, "enumValues": null, @@ -2591,6 +2900,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "slug", "type": { "encoding": null, @@ -2613,6 +2925,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "primaryKey": { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2620,6 +2933,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2640,6 +2956,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "email_address", "description": null, "enumValues": null, @@ -2647,6 +2964,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "emailAddress", "type": { "encoding": null, @@ -2665,6 +2985,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, "fields": [ { + "allowedValues": null, "columnName": "display_name", "description": null, "enumValues": null, @@ -2672,6 +2993,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": false, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "displayName", "type": { "encoding": null, @@ -2684,6 +3008,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "email_address", "description": null, "enumValues": null, @@ -2691,6 +3016,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "emailAddress", "type": { "encoding": null, @@ -2703,6 +3031,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult }, }, { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2710,6 +3039,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": true, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2733,6 +3065,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "email_address", "description": null, "enumValues": null, @@ -2740,6 +3073,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "emailAddress", "type": { "encoding": null, @@ -2762,6 +3098,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult ], "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2769,6 +3106,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2802,11 +3142,13 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "tableType": "User", "updatePayloadType": "UpdateuserPayload", }, + "isJunction": false, "name": "User", "primaryKeyConstraints": [ { "fields": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2814,6 +3156,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2847,6 +3192,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2854,6 +3200,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2878,6 +3227,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2885,6 +3235,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2911,6 +3264,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2918,6 +3272,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2942,6 +3299,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isUnique": false, "keys": [ { + "allowedValues": null, "columnName": "id", "description": null, "enumValues": null, @@ -2949,6 +3307,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "id", "type": { "encoding": { @@ -2981,6 +3342,7 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult { "fields": [ { + "allowedValues": null, "columnName": "email_address", "description": null, "enumValues": null, @@ -2988,6 +3350,9 @@ exports[`MetaSchemaPlugin snapshot scenarios produces stable metadata for a mult "isForeignKey": false, "isNotNull": true, "isPrimaryKey": false, + "isRequiredOnCreate": null, + "isWritableOnCreate": null, + "isWritableOnUpdate": null, "name": "emailAddress", "type": { "encoding": null, diff --git a/graphile/graphile-meta/__tests__/form-meta.test.ts b/graphile/graphile-meta/__tests__/form-meta.test.ts new file mode 100644 index 0000000000..2d4a07b91e --- /dev/null +++ b/graphile/graphile-meta/__tests__/form-meta.test.ts @@ -0,0 +1,516 @@ +import { + getNamedType, + graphql, + GraphQLID, + GraphQLInputObjectType, + GraphQLNonNull, + GraphQLObjectType, + GraphQLSchema, + GraphQLString +} from 'graphql'; + +import { MetaSchemaPlugin } from '../src'; +import { allowedValuesForAttribute } from '../src/check-constraint-values'; +import { collectTablesMeta } from '../src/table-meta-builder'; +// conbin and pg_get_constraintdef() of CHECK constraints, captured from a +// PostgreSQL 18 temp table with one column per variant. +import checks from './__fixtures__/check-constraints.json'; + +type Check = { conkey: number[]; definition: string; conbin: string }; +const fixtures = checks as Record; + +function checkOf(name: string) { + const { conkey, conbin } = fixtures[name]; + return { contype: 'c', conkey, conbin }; +} + +function attr(pgType: string, opts: Record = {}) { + return { codec: { name: pgType }, notNull: false, hasDefault: false, ...opts }; +} + +function codec(name: string, attributes: Record, tags: Record = {}) { + return { name, attributes, extensions: { pg: { schemaName: 'app_public', name }, tags } }; +} + +function resource(codecValue: any, uniques: unknown[], relations: Record = {}) { + return { codec: codecValue, uniques, relations, getRelations: () => relations }; +} + +const typeName = (name: string) => + name.replace(/(^|_)([a-z])/g, (_: string, __: string, c: string) => c.toUpperCase()); + +function build(resources: Record, overrides: Record = {}) { + return { + input: { pgRegistry: { pgResources: resources } }, + inflection: { + tableType: (c: any) => typeName(c.name), + _attributeName: ({ attributeName }: { attributeName: string }) => + attributeName.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase()), + ...overrides.inflection + }, + options: { pgSchemas: ['app_public'] }, + ...overrides.build + }; +} + +const pk = { attributes: ['id'], isPrimary: true }; + +// Link a junction's forward FK to its target, with the backward relation the +// registry keeps on the target. +function link(junction: any, column: string, target: any, name: string) { + junction.relations[`${name}_fkey`] = { + localAttributes: [column], + remoteAttributes: ['id'], + remoteResource: target + }; + target.relations[`${name}_back`] = { + isReferencee: true, + localAttributes: ['id'], + remoteAttributes: [column], + remoteResource: junction + }; +} + +describe('allowedValues from CHECK constraints', () => { + it.each([ + ['s_in', ['open', 'full']], + ['s_any', ['open', 'full']], + ['s_or', ['open', 'full', 'cancelled']], + ['s_rev', ['open', 'full']], + ['s_eq', ['only']], + ['s_vc', ['open', 'full']], + ['s_bp', ['abc', 'def']], + ['s_lit', ['open', 'full']], + ['s_esc', ["it's", 'café', 'a b']] + ])('reads %s', (name, expected) => { + const check = checkOf(name); + expect(allowedValuesForAttribute([check], check.conkey[0])).toEqual(expected); + }); + + it.each([ + ['n_int', 'a non-text constant'], + ['s_and', 'an AND with another condition'], + ['s_all', 'a <> ALL'], + ['s_fn', 'a function of the column'] + ])('ignores %s (%s)', (name) => { + const check = checkOf(name); + expect(allowedValuesForAttribute([check], check.conkey[0])).toBeNull(); + }); + + it('ignores a constraint spanning two columns, and constraints on other columns', () => { + const ab = checkOf('ab'); + expect(allowedValuesForAttribute([ab], ab.conkey[0])).toBeNull(); + expect(allowedValuesForAttribute([checkOf('s_in')], 99)).toBeNull(); + }); + + it('reports no fixed set when another check on the column is not simple', () => { + const simple = checkOf('s_in'); + // s_fn's lower(...) check, moved onto s_in's column. + const other = { + ...simple, + conbin: checkOf('s_fn').conbin.replace(/:varattno \d+ /, `:varattno ${simple.conkey[0]} `) + }; + expect(allowedValuesForAttribute([simple, other], simple.conkey[0])).toBeNull(); + }); + + it('intersects several simple constraints on one column', () => { + const wide = checkOf('s_or'); + // s_in's open/full list, moved onto s_or's column. + const narrow = { + ...wide, + conbin: checkOf('s_in').conbin.replace(':varattno 1 ', `:varattno ${wide.conkey[0]} `) + }; + expect(allowedValuesForAttribute([wide, narrow], wide.conkey[0])).toEqual(['open', 'full']); + }); + + it('reaches _meta through the gather hook', () => { + const check = checkOf('s_or'); + const attribute: any = { codec: { name: 'text' } }; + const hook = MetaSchemaPlugin.gather!.hooks!.pgCodecs_attribute as any; + hook({}, { + pgClass: { getConstraints: () => [check, checkOf('s_and')] }, + pgAttribute: { attnum: check.conkey[0] }, + attribute + }); + + const shifts = codec('shifts', { id: attr('uuid', { hasDefault: true }), status: attribute }); + const [table] = collectTablesMeta(build({ shifts: resource(shifts, [pk]) }) as any); + expect(table.fields.find((f) => f.name === 'status')?.allowedValues).toEqual([ + 'open', + 'full', + 'cancelled' + ]); + expect(table.fields.find((f) => f.name === 'id')?.allowedValues).toBeNull(); + }); +}); + +describe('junction detection', () => { + function foodBank(extra: Record = {}) { + const users = resource(codec('users', { id: attr('uuid', { hasDefault: true }) }), [pk]); + const shifts = resource(codec('shifts', { id: attr('uuid', { hasDefault: true }) }), [pk]); + const volunteers = resource(codec('volunteers', { id: attr('uuid', { hasDefault: true }) }), [pk]); + const signups = resource( + codec('shift_signups', { + id: attr('uuid', { notNull: true, hasDefault: true }), + shift_id: attr('uuid', { notNull: true }), + volunteer_id: attr('uuid', { notNull: true }), + owner_id: attr('uuid', { notNull: true, hasDefault: true }), + created_at: attr('timestamptz', { notNull: true, hasDefault: true }), + updated_at: attr('timestamptz', { notNull: true, hasDefault: true }), + ...extra + }), + [pk] + ); + link(signups, 'shift_id', shifts, 'shift_signups_shift'); + link(signups, 'volunteer_id', volunteers, 'shift_signups_volunteer'); + link(signups, 'owner_id', users, 'shift_signups_owner'); + return { users, shifts, volunteers, shift_signups: signups }; + } + + const byName = (tables: any[]) => Object.fromEntries(tables.map((t) => [t.tableName, t])); + + it('treats id, a defaulted owner FK and timestamps as system columns and links both sides', () => { + const tables = byName(collectTablesMeta(build(foodBank()) as any)); + + expect(tables.shift_signups.isJunction).toBe(true); + expect(tables.shifts.isJunction).toBe(false); + expect(tables.shifts.relations.manyToMany).toMatchObject([ + { + fieldName: null, + type: null, + junctionTable: { name: 'shift_signups' }, + rightTable: { name: 'volunteers' } + } + ]); + expect(tables.shifts.relations.manyToMany[0].junctionLeftKeyAttributes.map((f: any) => f.name)).toEqual(['shiftId']); + expect(tables.shifts.relations.manyToMany[0].junctionRightKeyAttributes.map((f: any) => f.name)).toEqual(['volunteerId']); + expect(tables.volunteers.relations.manyToMany).toMatchObject([ + { junctionTable: { name: 'shift_signups' }, rightTable: { name: 'shifts' } } + ]); + expect(tables.users.relations.manyToMany).toEqual([]); + }); + + it('counts trigger-filled and @scope key columns as system, but not a writable timestamp', () => { + const scoped = foodBank({ + entity_id: attr('uuid', { notNull: true }), + database_id: attr('uuid', { notNull: true }) + }); + scoped.shift_signups.codec.extensions.tags = { scope: 'org', scopeTier: 'entity', scopeKey: 'entity_id' }; + const insertable = (entity: [any, string], behavior: string) => + !(behavior === 'attribute:insert' && entity[1] === 'database_id'); + const withBehavior = build(scoped, { build: { behavior: { pgCodecAttributeMatches: insertable } } }); + expect(byName(collectTablesMeta(withBehavior as any)).shift_signups.isJunction).toBe(true); + + const withData = foodBank({ checked_in_at: attr('timestamptz') }); + expect(byName(collectTablesMeta(build(withData) as any)).shift_signups.isJunction).toBe(false); + }); + + it('treats a primary key made of the two foreign keys as the junction itself', () => { + const posts = resource(codec('posts', { id: attr('uuid', { hasDefault: true }) }), [pk]); + const tags = resource(codec('tags', { id: attr('uuid', { hasDefault: true }) }), [pk]); + const postTags = resource( + codec('post_tags', { post_id: attr('uuid', { notNull: true }), tag_id: attr('uuid', { notNull: true }) }), + [{ attributes: ['post_id', 'tag_id'], isPrimary: true }] + ); + link(postTags, 'post_id', posts, 'post_tags_post'); + link(postTags, 'tag_id', tags, 'post_tags_tag'); + + const tables = byName(collectTablesMeta(build({ posts, tags, post_tags: postTags }) as any)); + expect(tables.post_tags.isJunction).toBe(true); + expect(tables.tags.relations.manyToMany).toMatchObject([{ rightTable: { name: 'posts' } }]); + }); + + const singular: Record = { + shifts: 'Shift', + volunteers: 'Volunteer', + shift_signups: 'ShiftSignup', + users: 'User' + }; + const camel = (name: string) => name.replace(/_([a-z])/g, (_: string, c: string) => c.toUpperCase()); + + // A final schema exposing each table's columns as fields and its create + // mutation, minus the tables in `omit`, the columns in `hide` and the + // create mutations in `noCreate`. + function exposedSchema( + resources: Record, + opts: { + omit?: string[]; + hide?: Record; + noCreate?: string[]; + extra?: Record>; + } = {} + ) { + const exposed = Object.values(resources).filter((r: any) => !opts.omit?.includes(r.codec.name)); + const types = exposed.map((r: any) => { + const columns = Object.keys(r.codec.attributes).filter((c) => !opts.hide?.[r.codec.name]?.includes(c)); + return new GraphQLObjectType({ + name: singular[r.codec.name], + fields: { + ...Object.fromEntries(columns.map((c) => [camel(c), { type: GraphQLString }])), + ...opts.extra?.[r.codec.name] + } + }); + }); + const creates = exposed + .filter((r: any) => !opts.noCreate?.includes(r.codec.name)) + .map((r: any) => [ + `create${singular[r.codec.name]}`, + { + type: new GraphQLObjectType({ + name: `Create${singular[r.codec.name]}Payload`, + fields: { clientMutationId: { type: GraphQLString } } + }) + } + ]); + return new GraphQLSchema({ + query: new GraphQLObjectType({ name: 'Query', fields: { ping: { type: GraphQLString } } }), + mutation: new GraphQLObjectType({ name: 'Mutation', fields: Object.fromEntries(creates) }), + types + }); + } + + const inflected = (overrides: Record = {}) => ({ + inflection: { + tableType: (c: any) => singular[c.name], + createField: (r: any) => `create${singular[r.codec.name]}`, + createPayloadType: (r: any) => `Create${singular[r.codec.name]}Payload`, + ...overrides + } + }); + + it('names the GraphQL field when the final schema has one, and reports null when not', () => { + const resources = foodBank(); + const meta = build( + resources, + inflected({ + manyToManyRelationConnectionField: (details: any) => + details.leftTable.codec.name === 'shifts' ? 'volunteers' : null, + manyToManyRelationConnectionType: () => 'ShiftVolunteersManyToManyConnection' + }) + ); + const connection = new GraphQLObjectType({ + name: 'ShiftVolunteersManyToManyConnection', + fields: { totalCount: { type: GraphQLString } } + }); + const schema = exposedSchema(resources, { extra: { shifts: { volunteers: { type: connection } } } }); + + const tables = byName(collectTablesMeta(meta as any, schema)); + expect(tables.shift_signups.isJunction).toBe(true); + expect(tables.shifts.relations.manyToMany).toMatchObject([ + { fieldName: 'volunteers', type: 'ShiftVolunteersManyToManyConnection', rightTable: { name: 'Volunteer' } } + ]); + expect(tables.volunteers.relations.manyToMany).toMatchObject([ + { fieldName: null, type: null, rightTable: { name: 'Shift' } } + ]); + }); + + it('reports no link, and no junction, when the other side is not exposed', () => { + const resources = foodBank(); + const schema = exposedSchema(resources, { omit: ['volunteers'] }); + + const tables = byName(collectTablesMeta(build(resources, inflected()) as any, schema)); + expect(tables.volunteers).toBeUndefined(); + expect(tables.shifts.relations.manyToMany).toEqual([]); + expect(tables.shift_signups.isJunction).toBe(false); + }); + + it('reports no link when a key column is hidden', () => { + const resources = foodBank(); + const schema = exposedSchema(resources, { hide: { shift_signups: ['volunteer_id'] } }); + + const tables = byName(collectTablesMeta(build(resources, inflected()) as any, schema)); + expect(tables.shifts.relations.manyToMany).toEqual([]); + expect(tables.shift_signups.isJunction).toBe(false); + }); + + it('reports no link when the junction has no create mutation', () => { + const resources = foodBank(); + const schema = exposedSchema(resources, { noCreate: ['shift_signups'] }); + + const tables = byName(collectTablesMeta(build(resources, inflected()) as any, schema)); + expect(tables.shifts.relations.manyToMany).toEqual([]); + expect(tables.shift_signups.isJunction).toBe(false); + }); + + it('reports no link through a relation hidden from the API', () => { + const resources = foodBank(); + const meta = build(resources, { + ...inflected(), + build: { + behavior: { + pgCodecRelationMatches: (relation: any, behavior: string) => + !(behavior === 'select' && relation.localAttributes?.includes('volunteer_id')) + } + } + }); + + const tables = byName(collectTablesMeta(meta as any, exposedSchema(resources))); + expect(tables.shifts.relations.manyToMany).toEqual([]); + expect(tables.shift_signups.isJunction).toBe(false); + }); + + it('gives the same answer whether or not the junction has hidden columns', () => { + const resources = foodBank({ internal_note: attr('text') }); + const schema = exposedSchema(resources, { hide: { shift_signups: ['internal_note'] } }); + + const tables = byName(collectTablesMeta(build(resources, inflected()) as any, schema)); + expect(tables.shift_signups.isJunction).toBe(true); + expect(tables.shift_signups.fields.map((f: any) => f.columnName)).not.toContain('internal_note'); + }); +}); + +describe('create and update writability', () => { + it('reads requiredness from the create input, not from NOT NULL', async () => { + const messages = codec('agent_message', { + id: attr('uuid', { notNull: true, hasDefault: true }), + thread_id: attr('uuid', { notNull: true }), + entity_id: attr('uuid', { notNull: true }), + kind: attr('text', { notNull: true, hasDefault: true }) + }); + const meta = build( + { agent_message: resource(messages, [pk]) }, + { + inflection: { + createField: () => 'createAgentMessage', + createPayloadType: () => 'CreateAgentMessagePayload', + updateByKeysField: () => 'updateAgentMessage', + updatePayloadType: () => 'UpdateAgentMessagePayload', + tableFieldName: () => 'agentMessage', + patchField: (name: string) => `${name}Patch` + } + } + ); + + // entity_id is NOT NULL with no default, yet absent from the create input + // (a trigger fills it), as PostGraphile builds it for `@behavior -insert`. + const messageType = new GraphQLObjectType({ + name: 'AgentMessage', + fields: { id: { type: GraphQLID }, threadId: { type: GraphQLID }, entityId: { type: GraphQLID }, kind: { type: GraphQLString } } + }); + const record = new GraphQLInputObjectType({ + name: 'AgentMessageInput', + fields: { id: { type: GraphQLID }, threadId: { type: new GraphQLNonNull(GraphQLID) }, kind: { type: GraphQLString } } + }); + const patch = new GraphQLInputObjectType({ + name: 'AgentMessagePatch', + fields: { kind: { type: GraphQLString } } + }); + const mutation = (name: string, input: Record) => ({ + type: new GraphQLObjectType({ name: `${name[0].toUpperCase()}${name.slice(1)}Payload`, fields: { m: { type: messageType } } }), + args: { + input: { + type: new GraphQLNonNull( + new GraphQLInputObjectType({ + name: `${name[0].toUpperCase()}${name.slice(1)}Input`, + fields: { clientMutationId: { type: GraphQLString }, ...input } + }) + ) + } + } + }); + const queryFields = MetaSchemaPlugin.schema!.hooks!.GraphQLObjectType_fields as any; + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: 'Query', + fields: () => queryFields({ ping: { type: GraphQLString } }, meta, { scope: { isRootQuery: true } }) + }), + mutation: new GraphQLObjectType({ + name: 'Mutation', + fields: { + // A nested-write input beside the record must not be taken for it. + createAgentMessage: mutation('createAgentMessage', { + agentMessage: { type: new GraphQLNonNull(record) }, + threadByThreadId: { + type: new GraphQLInputObjectType({ name: 'NestedThreadInput', fields: { title: { type: GraphQLString } } }) + } + }), + updateAgentMessage: mutation('updateAgentMessage', { + id: { type: new GraphQLNonNull(GraphQLID) }, + agentMessagePatch: { type: new GraphQLNonNull(patch) } + }) + } + }) + }); + + const result = await graphql({ + schema, + source: '{ _meta { tables { isJunction fields { name isNotNull isRequiredOnCreate isWritableOnCreate isWritableOnUpdate } } } }' + }); + + expect(result.errors).toBeUndefined(); + const [table] = (result.data as any)._meta.tables; + expect(table.isJunction).toBe(false); + expect(table.fields).toEqual([ + { name: 'id', isNotNull: true, isRequiredOnCreate: false, isWritableOnCreate: true, isWritableOnUpdate: false }, + { name: 'threadId', isNotNull: true, isRequiredOnCreate: true, isWritableOnCreate: true, isWritableOnUpdate: false }, + { name: 'entityId', isNotNull: true, isRequiredOnCreate: false, isWritableOnCreate: false, isWritableOnUpdate: false }, + { name: 'kind', isNotNull: true, isRequiredOnCreate: false, isWritableOnCreate: true, isWritableOnUpdate: true } + ]); + }); +}); + +describe('only what the API exposes', () => { + function allowedValuesIn(value: unknown, found: unknown[] = []): unknown[] { + if (Array.isArray(value)) value.forEach((item) => allowedValuesIn(item, found)); + else if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + if (key === 'allowedValues') found.push(child); + else allowedValuesIn(child, found); + } + } + return found; + } + + it('reports no allowedValues for a hidden column, anywhere in the table', () => { + const status = attr('text', { notNull: true, extensions: { allowedValues: ['open', 'full'] } }); + const shifts = resource(codec('shifts', { id: attr('uuid', { hasDefault: true }), status }), [ + pk, + { attributes: ['status'], tags: { name: 'shifts_status_key' } } + ]); + const meta = build( + { shifts }, + { + inflection: { tableType: () => 'Shift' }, + build: { + behavior: { + pgCodecAttributeMatches: (entity: [any, string], behavior: string) => + !(behavior === 'attribute:select' && entity[1] === 'status') + } + } + } + ); + const schema = new GraphQLSchema({ + query: new GraphQLObjectType({ name: 'Query', fields: { ping: { type: GraphQLString } } }), + types: [new GraphQLObjectType({ name: 'Shift', fields: { id: { type: GraphQLID } } })] + }); + + const [table] = collectTablesMeta(meta as any, schema); + expect(table.fields.map((f) => f.columnName)).toEqual(['id']); + expect(allowedValuesIn(table).every((values) => values === null)).toBe(true); + }); + + // Every type and field description `_meta` serves is public copy: it says + // what a client may rely on, never how a value is derived. + it('describes every _meta type and field without backend internals', () => { + const hook = MetaSchemaPlugin.schema!.hooks!.GraphQLObjectType_fields as any; + const metaField = hook({}, build({}), { scope: { isRootQuery: true } })._meta; + const texts: string[] = [metaField.description]; + const seen = new Set(); + const visit = (type: any) => { + const named = getNamedType(type as GraphQLObjectType) as GraphQLObjectType; + if (seen.has(named.name) || !('getFields' in named)) return; + seen.add(named.name); + if (named.description) texts.push(named.description); + for (const field of Object.values(named.getFields())) { + if (field.description) texts.push(field.description); + visit(field.type); + } + }; + visit(metaField.type); + + expect(seen.has('MetaField') && seen.has('MetaScope') && seen.has('MetaStorageUpload')).toBe(true); + const forbidden = + /@\w|smart ?tag|plugin|registry|presigned|derived|inflect|introspect|trigger|behavio|provenance|check constraint/i; + expect(texts.filter((text) => forbidden.test(text))).toEqual([]); + }); +}); diff --git a/graphile/graphile-meta/__tests__/meta-schema.test.ts b/graphile/graphile-meta/__tests__/meta-schema.test.ts index f6528bce19..32ca1752cc 100644 --- a/graphile/graphile-meta/__tests__/meta-schema.test.ts +++ b/graphile/graphile-meta/__tests__/meta-schema.test.ts @@ -901,7 +901,11 @@ describe('MetaSchemaPlugin', () => { isPrimaryKey: false, isForeignKey: false, description: null, - enumValues: null + enumValues: null, + allowedValues: null, + isRequiredOnCreate: null, + isWritableOnCreate: null, + isWritableOnUpdate: null }); }); diff --git a/graphile/graphile-meta/src/check-constraint-values.ts b/graphile/graphile-meta/src/check-constraint-values.ts new file mode 100644 index 0000000000..050579b29a --- /dev/null +++ b/graphile/graphile-meta/src/check-constraint-values.ts @@ -0,0 +1,145 @@ +import { + asPgNode, + decodeTextArrayDatum, + decodeTextDatum, + isPgDatum, + isPgNode, + parsePgNodeTree, + type PgNode, + type PgNodeValue, +} from './pg-node-tree'; + +/** + * Allowed values from simple CHECK constraints on one text-like column: + * + * - `col IN ('a', 'b')` and `col = ANY (ARRAY['a', 'b'])`, which Postgres + * stores as the same `= ANY` expression; + * - `col = ANY ('{a,b}'::text[])`; + * - `col = 'a' OR col = 'b'` (either operand order), and a lone `col = 'a'`. + * + * The column may be text, varchar (compared through a binary-compatible cast) + * or char(n). Anything else in the expression (another column, a function + * call, AND, a non-equality operator, a non-text constant) makes the + * constraint not simple, and it contributes nothing. + */ + +// Built-in equality operators: text = text (varchar columns relabel to text) +// and bpchar = bpchar. Built-in operators keep the same oids on every server. +const TEXT_EQUALITY_OPERATORS = new Set(['98', '1054']); +// text, varchar, bpchar and their array types. +const TEXT_TYPES: ReadonlySet = new Set(['25', '1043', '1042']); +const TEXT_ARRAY_TYPES: ReadonlySet = new Set(['1009', '1015', '1014']); + +export interface CheckConstraintLike { + contype: string; + conkey: ReadonlyArray | null; + conbin: string | null; +} + +function unwrapRelabel(value: PgNodeValue | undefined): PgNodeValue | undefined { + let current = value; + while (isPgNode(current, 'RELABELTYPE')) current = current.fields.arg; + return current; +} + +function isColumn(value: PgNodeValue | undefined, attnum: number): boolean { + const column = unwrapRelabel(value); + return ( + isPgNode(column, 'VAR') && + column.fields.varattno === String(attnum) && + column.fields.varlevelsup === '0' + ); +} + +function textConstant(value: PgNodeValue | undefined): string | null { + const constant = unwrapRelabel(value); + if (!isPgNode(constant, 'CONST')) return null; + if (!TEXT_TYPES.has(constant.fields.consttype as string)) return null; + const datum = constant.fields.constvalue; + return isPgDatum(datum) ? decodeTextDatum(datum) : null; +} + +function textArray(value: PgNodeValue | undefined): string[] | null { + let array = value; + // varchar elements reach a text column through an element-wise relabel. + if (isPgNode(array, 'ARRAYCOERCEEXPR')) { + if (!isPgNode(array.fields.elemexpr, 'RELABELTYPE')) return null; + array = array.fields.arg; + } + if (isPgNode(array, 'ARRAYEXPR')) { + const elements = array.fields.elements; + if (!Array.isArray(elements) || elements.length === 0) return null; + const values = elements.map(textConstant); + return values.every((v): v is string => v !== null) ? values : null; + } + if (isPgNode(array, 'CONST') && TEXT_ARRAY_TYPES.has(array.fields.consttype as string)) { + const datum = array.fields.constvalue; + return isPgDatum(datum) ? decodeTextArrayDatum(datum, TEXT_TYPES) : null; + } + return null; +} + +function valuesFromExpression(expr: PgNode, attnum: number): string[] | null { + const args = expr.fields.args; + switch (expr.node) { + case 'SCALARARRAYOPEXPR': { + if (expr.fields.useOr !== 'true') return null; + if (!TEXT_EQUALITY_OPERATORS.has(expr.fields.opno as string)) return null; + if (!Array.isArray(args) || args.length !== 2 || !isColumn(args[0], attnum)) return null; + return textArray(args[1]); + } + case 'OPEXPR': { + if (!TEXT_EQUALITY_OPERATORS.has(expr.fields.opno as string)) return null; + if (!Array.isArray(args) || args.length !== 2) return null; + const [left, right] = args; + const value = isColumn(left, attnum) + ? textConstant(right) + : isColumn(right, attnum) + ? textConstant(left) + : null; + return value === null ? null : [value]; + } + case 'BOOLEXPR': { + if (expr.fields.boolop !== 'or' || !Array.isArray(args)) return null; + const values: string[] = []; + for (const arg of args) { + const node = asPgNode(arg); + const branch = node ? valuesFromExpression(node, attnum) : null; + if (!branch) return null; + values.push(...branch); + } + return values; + } + default: + return null; + } +} + +/** Allowed values for one CHECK expression on column `attnum`, or null. */ +export function allowedValuesFromCheck(conbin: string, attnum: number): string[] | null { + const expr = parsePgNodeTree(conbin); + const values = expr ? valuesFromExpression(expr, attnum) : null; + return values ? [...new Set(values)] : null; +} + +/** + * Allowed values for column `attnum` across a table's constraints. Only CHECK + * constraints on that column alone count. When several are simple, a value + * must pass all of them, so the lists intersect in the first one's order. One + * that isn't simple may reject listed values too, so then the column has no + * fixed set to report. + */ +export function allowedValuesForAttribute( + constraints: ReadonlyArray, + attnum: number, +): string[] | null { + let allowed: string[] | null = null; + for (const constraint of constraints) { + if (constraint.contype !== 'c') continue; + if (constraint.conkey?.length !== 1 || constraint.conkey[0] !== attnum) continue; + const values = constraint.conbin ? allowedValuesFromCheck(constraint.conbin, attnum) : null; + if (!values) return null; + allowed = allowed ? allowed.filter((value) => values.includes(value)) : values; + } + return allowed; +} diff --git a/graphile/graphile-meta/src/exposure.ts b/graphile/graphile-meta/src/exposure.ts new file mode 100644 index 0000000000..70ffc81be1 --- /dev/null +++ b/graphile/graphile-meta/src/exposure.ts @@ -0,0 +1,63 @@ +import { getNamedType } from 'graphql'; + +import { getFieldContainerType } from './graphql-schema-utils'; +import { resolveTableType } from './name-meta-builders'; +import type { BuildContext } from './table-meta-context'; +import type { PgCodec } from './types'; + +/** + * The columns a table exposes as GraphQL fields in the final schema, mapped to + * each field's output type name. A column is exposed when its select behavior + * holds, the table's output type has a field under its inflected name, and + * that field's type is the one registered for the column's codec. A table whose + * output type isn't in the schema exposes nothing. + * + * Without a final schema (registry-only builds) every column counts, with a + * null type name. Everything `_meta` adds about columns goes through this, so + * a column hidden from the API contributes nothing anywhere. + */ +export function exposedColumns( + codec: PgCodec, + context: BuildContext, +): Map { + const cache = (context.exposedColumns ??= new Map()); + const cached = cache.get(codec); + if (cached) return cached; + + const columns = new Map(); + cache.set(codec, columns); + const attributes = codec.attributes ?? {}; + if (!context.schema) { + for (const attrName of Object.keys(attributes)) columns.set(attrName, null); + return columns; + } + + const tableType = getFieldContainerType( + context.schema, + resolveTableType(context.build, codec), + ); + if (!tableType) return columns; + const build = context.build; + const finalFields = tableType.getFields(); + + for (const [attrName, attr] of Object.entries(attributes)) { + if ( + build.behavior?.pgCodecAttributeMatches && + !build.behavior.pgCodecAttributeMatches([codec, attrName], 'attribute:select') + ) { + continue; + } + const finalField = finalFields[context.inflectAttr(attrName, codec)]; + if (!finalField) continue; + + const finalTypeName = getNamedType(finalField.type).name; + const codecForLookup = attr.codec?.arrayOfCodec || attr.codec; + const registeredTypeName = + codecForLookup && build.hasGraphQLTypeForPgCodec?.(codecForLookup, 'output') + ? build.getGraphQLTypeNameByPgCodec?.(codecForLookup, 'output') + : null; + if (registeredTypeName && registeredTypeName !== finalTypeName) continue; + columns.set(attrName, finalTypeName); + } + return columns; +} diff --git a/graphile/graphile-meta/src/graphql-meta-field.ts b/graphile/graphile-meta/src/graphql-meta-field.ts index f3541c2079..7e1519b1ee 100644 --- a/graphile/graphile-meta/src/graphql-meta-field.ts +++ b/graphile/graphile-meta/src/graphql-meta-field.ts @@ -30,7 +30,7 @@ function createMetaSchemaType(): GraphQLObjectType { const MetaScalarEncodingType = new GraphQLObjectType({ name: 'MetaScalarEncoding', description: - 'How a client must serialize/parse a scalar — the one field-type detail standard GraphQL introspection cannot describe. Null for plain scalars whose wire format is obvious from gqlType.', + 'How a client must serialize and parse a scalar whose wire format gqlType alone does not settle. Null for plain scalars whose wire format is obvious from gqlType.', fields: () => ({ kind: { type: nn(GraphQLString), @@ -89,7 +89,25 @@ function createMetaSchemaType(): GraphQLObjectType { isPrimaryKey: { type: nn(GraphQLBoolean) }, isForeignKey: { type: nn(GraphQLBoolean) }, description: { type: GraphQLString }, - enumValues: { type: MetaEnumType, description: 'Enum metadata if this field has an enum type' } + enumValues: { type: MetaEnumType, description: 'Enum metadata if this field has an enum type' }, + // Public copy: describe what a client may rely on, never how it is derived. + allowedValues: { + type: new GraphQLList(nn(GraphQLString)), + description: + "Values this field accepts, when the set is fixed; null otherwise. Set on a table's own fields only." + }, + isRequiredOnCreate: { + type: GraphQLBoolean, + description: "Whether a create must provide this field. Set on a table's own fields only." + }, + isWritableOnCreate: { + type: GraphQLBoolean, + description: "Whether a create may set this field. Set on a table's own fields only." + }, + isWritableOnUpdate: { + type: GraphQLBoolean, + description: "Whether an update may change this field. Set on a table's own fields only." + } }) }); @@ -156,7 +174,7 @@ function createMetaSchemaType(): GraphQLObjectType { const MetaInflectionType = new GraphQLObjectType({ name: 'MetaInflection', - description: 'Table inflection names', + description: 'Type and field names generated for this table', fields: () => ({ tableType: { type: nn(GraphQLString) }, allRows: { type: nn(GraphQLString) }, @@ -240,7 +258,7 @@ function createMetaSchemaType(): GraphQLObjectType { const MetaStorageUploadType = new GraphQLObjectType({ name: 'MetaStorageUpload', - description: 'The GraphQL upload surface of a storage plane, derived from the registry facts the presigned-url plugin emits from', + description: 'The upload mutations and types for a storage table pair', fields: () => ({ mutation: { type: nn(GraphQLString), description: 'Root mutation field for single-file upload (e.g. uploadAppFile)' }, inputType: { type: nn(GraphQLString), description: 'Input type of the single upload mutation' }, @@ -250,7 +268,7 @@ function createMetaSchemaType(): GraphQLObjectType { bulkPayloadType: { type: nn(GraphQLString), description: 'Payload type of the bulk upload mutation' }, bulkFileInputType: { type: nn(GraphQLString), description: 'Per-file input type inside the bulk input' }, bulkFilePayloadType: { type: nn(GraphQLString), description: 'Per-file payload type inside the bulk payload' }, - requiresOwnerId: { type: nn(GraphQLBoolean), description: 'Whether the upload input requires ownerId (entity-keyed plane)' } + requiresOwnerId: { type: nn(GraphQLBoolean), description: 'Whether the upload input requires ownerId' } }) }); @@ -260,16 +278,16 @@ function createMetaSchemaType(): GraphQLObjectType { fields: () => ({ isFilesTable: { type: nn(GraphQLBoolean), description: 'Whether this table is a storage files table' }, isBucketsTable: { type: nn(GraphQLBoolean), description: 'Whether this table is a storage buckets table' }, - filesType: { type: nn(GraphQLString), description: 'GraphQL type name of the plane\'s files table' }, - bucketsType: { type: nn(GraphQLString), description: 'GraphQL type name of the plane\'s buckets table' }, + filesType: { type: nn(GraphQLString), description: 'GraphQL type name of the paired files table' }, + bucketsType: { type: nn(GraphQLString), description: 'GraphQL type name of the paired buckets table' }, downloadUrlField: { type: GraphQLString, description: 'Computed download-URL field on the files type; null on the buckets side' }, - upload: { type: nn(MetaStorageUploadType), description: 'The plane\'s GraphQL upload surface' } + upload: { type: nn(MetaStorageUploadType), description: 'Upload mutations and types for this storage' } }) }); const MetaSearchConfigType = new GraphQLObjectType({ name: 'MetaSearchConfig', - description: 'Per-table search configuration from @searchConfig smart tag', + description: 'Per-table search configuration', fields: () => ({ weights: { type: GraphQLString, @@ -315,7 +333,7 @@ function createMetaSchemaType(): GraphQLObjectType { const MetaI18nType = new GraphQLObjectType({ name: 'MetaI18n', - description: 'i18n metadata for a table with @i18n tag', + description: 'i18n metadata for a translatable table', fields: () => ({ translationTable: { type: nn(GraphQLString), description: 'Name of the translation table' }, translatableFields: { type: nnList(MetaI18nFieldType), description: 'Fields that are translatable' } @@ -324,7 +342,7 @@ function createMetaSchemaType(): GraphQLObjectType { const MetaRealtimeType = new GraphQLObjectType({ name: 'MetaRealtime', - description: 'Realtime metadata for a table with @realtime tag', + description: 'Realtime metadata for a table with change subscriptions', fields: () => ({ subscriptionFieldName: { type: nn(GraphQLString), description: 'The generated subscription field name (e.g. onPostChanged)' } }) @@ -336,9 +354,9 @@ function createMetaSchemaType(): GraphQLObjectType { fields: () => ({ scope: { type: nn(GraphQLString), description: "Provisioning scope: 'platform', 'app', 'database', or an entity scope (e.g. 'org')" }, tier: { type: nn(GraphQLString), description: "Coarse bucket: 'global', 'database', or 'entity'" }, - keyColumn: { type: GraphQLString, description: 'Inflected scope key column (e.g. databaseId, orgId), null for global tiers' }, - entityTable: { type: GraphQLString, description: 'SQL name of the entity table for entity scopes, else null' }, - source: { type: nn(GraphQLString), description: "Provenance of the scope metadata (always 'smartTag')" } + keyColumn: { type: GraphQLString, description: 'Field name of the scope key (e.g. databaseId, orgId), null for global tiers' }, + entityTable: { type: GraphQLString, description: 'Name of the entity table for entity scopes, else null' }, + source: { type: nn(GraphQLString), description: 'Where this scope metadata comes from' } }) }); @@ -366,9 +384,14 @@ function createMetaSchemaType(): GraphQLObjectType { query: { type: nn(MetaQueryType) }, storage: { type: MetaStorageType, description: 'Storage metadata (null if not a storage table)' }, search: { type: MetaSearchType, description: 'Search metadata (null if no search configured)' }, - i18n: { type: MetaI18nType, description: 'i18n metadata (null if no @i18n tag)' }, - realtime: { type: MetaRealtimeType, description: 'Realtime metadata (null if no @realtime tag)' }, - scope: { type: MetaScopeType, description: 'Provisioning scope metadata (null if no @scope tag)' } + i18n: { type: MetaI18nType, description: 'i18n metadata (null if the table is not translatable)' }, + realtime: { type: MetaRealtimeType, description: 'Realtime metadata (null if the table has no change subscriptions)' }, + scope: { type: MetaScopeType, description: 'Provisioning scope metadata (null if the table has no scope)' }, + isJunction: { + type: nn(GraphQLBoolean), + description: + 'Whether this table only links rows of two other tables; the link appears in their manyToMany relations' + } }) }); diff --git a/graphile/graphile-meta/src/junction-meta-builders.ts b/graphile/graphile-meta/src/junction-meta-builders.ts new file mode 100644 index 0000000000..ab3fad47cb --- /dev/null +++ b/graphile/graphile-meta/src/junction-meta-builders.ts @@ -0,0 +1,167 @@ +import { exposedColumns } from './exposure'; +import { getFieldContainerType } from './graphql-schema-utils'; +import { buildQueryMeta, resolveTableType } from './name-meta-builders'; +import type { BuildContext, TableResourceWithCodec } from './table-meta-context'; +import { getRelations, getUniques, sameAttributes } from './table-resource-utils'; +import type { PgCodec, PgManyToManyRelationDetails, PgRelation } from './types'; + +export interface JunctionIndex { + /** Detected many-to-many relations, keyed by the codec of their left table. */ + detected: Map; +} + +/** + * A column is a system column when a client never picks its value in a form: + * + * 1. it is a surrogate key: part of the primary key and of no foreign key (a + * primary key made of the two foreign keys is the classic junction, and + * those columns are its data); + * 2. it has a default: generated ids, `owner_id DEFAULT current_user_id()`, + * `created_at`/`updated_at DEFAULT now()`; + * 3. the create input can't set it (`@behavior -insert`), such as a column a + * trigger fills; + * 4. it is the table's `@scope` key column (e.g. `entity_id`), which clients + * fill from context, not from a form. + * + * A timestamp with no default that clients write (e.g. `checked_in_at`) is + * data, so it isn't a system column and a table carrying one isn't a junction. + */ +export function isSystemColumn( + codec: PgCodec, + attrName: string, + surrogateKey: ReadonlySet, + context: BuildContext, +): boolean { + if (surrogateKey.has(attrName)) return true; + if (codec.attributes?.[attrName]?.hasDefault) return true; + const matches = context.build.behavior?.pgCodecAttributeMatches; + if (matches && !matches([codec, attrName], 'attribute:insert')) return true; + return codec.extensions?.tags?.scopeKey === attrName; +} + +// A relation hidden from the API (no select behavior) never links a junction. +function relationSelectable(relation: PgRelation, context: BuildContext): boolean { + const matches = context.build.behavior?.pgCodecRelationMatches; + return !matches || matches(relation, 'select'); +} + +/** + * The two foreign keys of a junction table: the forward relations whose local + * columns are all non-system and, between them, cover every non-system column + * exactly once. Foreign keys on system columns (e.g. `owner_id` to users) + * don't count. Null when the table isn't a junction. + * + * Only exposed columns take part. A column hidden from the API neither stops a + * table from being a junction nor makes it one, so the answer never hints that + * a hidden column exists. + */ +export function junctionForeignKeys( + resource: TableResourceWithCodec, + context: BuildContext, +): [string, PgRelation][] | null { + const codec = resource.codec; + const exposed = exposedColumns(codec, context); + const forwardRelations = Object.entries(getRelations(resource)).filter( + ([, relation]) => + !relation.isReferencee && + relationSelectable(relation, context) && + (relation.localAttributes ?? []).every((attrName) => exposed.has(attrName)), + ); + const foreignKeyColumns = new Set( + forwardRelations.flatMap(([, relation]) => relation.localAttributes ?? []), + ); + const surrogateKey = new Set( + getUniques(resource) + .filter((unique) => unique.isPrimary) + .flatMap((unique) => unique.attributes) + .filter((attrName) => !foreignKeyColumns.has(attrName)), + ); + const dataColumns = [...exposed.keys()].filter( + (attrName) => !isSystemColumn(codec, attrName, surrogateKey, context), + ); + if (dataColumns.length === 0) return null; + + const keys = forwardRelations.filter( + ([, relation]) => + !!relation.remoteResource?.codec && + (relation.localAttributes?.length ?? 0) > 0 && + relation.localAttributes!.every((attrName) => dataColumns.includes(attrName)), + ); + if (keys.length !== 2) return null; + + const [left, right] = keys.map(([, relation]) => relation.localAttributes!); + if (left.some((attrName) => right.includes(attrName))) return null; + return left.length + right.length === dataColumns.length ? keys : null; +} + +/** + * Find every junction among `resources` and the many-to-many relations it + * implies on both of its tables. A relation is only reported when the + * junction (with its create mutation), both linked tables, the junction's two + * keys and every key column on either side are exposed in the final schema, so + * it never names something the API hides. + * Whether a GraphQL field serves it is decided later, when it is built. + */ +export function indexJunctions( + resources: TableResourceWithCodec[], + context: BuildContext, +): JunctionIndex { + const detected = new Map(); + + const exposed = new Map( + resources + .filter( + (resource) => + !context.schema || + !!getFieldContainerType(context.schema, resolveTableType(context.build, resource.codec)), + ) + .map((resource) => [resource.codec as PgCodec, resource]), + ); + + for (const junction of resources) { + if (!exposed.has(junction.codec)) continue; + // A link a client can't create isn't one to offer: the junction needs its + // create mutation in the final schema. + if (context.schema) { + const tableType = resolveTableType(context.build, junction.codec); + const query = buildQueryMeta(junction, getUniques(junction), tableType, context.build, context.schema); + if (!query.create) continue; + } + const keys = junctionForeignKeys(junction, context); + if (!keys) continue; + + const sides = keys.map(([name, relation]) => { + const table = exposed.get(relation.remoteResource!.codec!); + const referenced = table ? exposedColumns(table.codec, context) : null; + const visible = (relation.remoteAttributes ?? []).every((attrName) => + referenced?.has(attrName), + ); + return { name, relation, table: visible ? table : undefined }; + }); + const keyColumns = keys.flatMap(([, relation]) => relation.localAttributes!); + const allowsMultipleEdgesToNode = !getUniques(junction).some((unique) => + sameAttributes(unique.attributes, keyColumns), + ); + + for (const [left, right] of [ + [sides[0], sides[1]], + [sides[1], sides[0]], + ]) { + if (!left.table || !right.table) continue; + const list = detected.get(left.table.codec) ?? []; + // The left side is named by the junction's forward key, not the left + // table's backward relation, which the API may not expose. + list.push({ + leftTable: left.table, + leftRelationName: left.name, + junctionTable: junction, + rightRelationName: right.name, + rightTable: right.table, + allowsMultipleEdgesToNode, + }); + detected.set(left.table.codec, list); + } + } + + return { detected }; +} diff --git a/graphile/graphile-meta/src/pg-node-tree.ts b/graphile/graphile-meta/src/pg-node-tree.ts new file mode 100644 index 0000000000..1bbc197da1 --- /dev/null +++ b/graphile/graphile-meta/src/pg-node-tree.ts @@ -0,0 +1,209 @@ +/** + * Reader for PostgreSQL's `pg_node_tree` text: the form `pg_constraint.conbin` + * stores a CHECK expression in. Introspection already selects `conbin`, so + * reading it here needs no query of its own. + * + * The format is `{NODE :field value :field value …}`, where a value is a nested + * node, a parenthesized list, `<>` for null, a bare token, or a datum written + * as ` [ … ]`. + */ + +export interface PgNode { + node: string; + fields: Record; +} + +export interface PgDatum { + length: number; + bytes: number[]; +} + +export type PgNodeValue = string | null | PgNode | PgDatum | PgNodeValue[]; + +const DELIMITERS = new Set(['(', ')', '{', '}']); + +function tokenize(text: string): string[] { + const tokens: string[] = []; + let i = 0; + while (i < text.length) { + const ch = text[i]; + if (/\s/.test(ch)) { + i++; + continue; + } + if (DELIMITERS.has(ch)) { + tokens.push(ch); + i++; + continue; + } + let token = ''; + while (i < text.length && !/\s/.test(text[i]) && !DELIMITERS.has(text[i])) { + // A backslash escapes the next character, so a token can carry a + // delimiter or a space. + if (text[i] === '\\' && i + 1 < text.length) { + token += text[i + 1]; + i += 2; + continue; + } + token += text[i]; + i++; + } + tokens.push(token); + } + return tokens; +} + +class TokenReader { + private pos = 0; + constructor(private readonly tokens: string[]) {} + + peek(): string | undefined { + return this.tokens[this.pos]; + } + + next(): string { + const token = this.tokens[this.pos++]; + if (token === undefined) throw new Error('malformed node tree'); + return token; + } + + done(): boolean { + return this.pos >= this.tokens.length; + } +} + +function readValue(reader: TokenReader): PgNodeValue { + const token = reader.next(); + if (token === '{') return readNode(reader); + if (token === '(') return readList(reader); + if (token === '<>') return null; + if (reader.peek() === '[') { + reader.next(); + const bytes: number[] = []; + while (reader.peek() !== ']') bytes.push(Number(reader.next())); + reader.next(); + return { length: Number(token), bytes }; + } + return token; +} + +function readList(reader: TokenReader): PgNodeValue[] { + const items: PgNodeValue[] = []; + while (reader.peek() !== ')') items.push(readValue(reader)); + reader.next(); + return items; +} + +function readNode(reader: TokenReader): PgNode { + const node = reader.next(); + const fields: Record = {}; + while (reader.peek() !== '}') { + const key = reader.next(); + if (!key.startsWith(':')) throw new Error('malformed node tree'); + fields[key.slice(1)] = readValue(reader); + } + reader.next(); + return { node, fields }; +} + +/** Parse a node tree; null when the text isn't a single well-formed node. */ +export function parsePgNodeTree(text: string): PgNode | null { + try { + const reader = new TokenReader(tokenize(text)); + if (reader.next() !== '{') return null; + const root = readNode(reader); + return reader.done() ? root : null; + } catch { + return null; + } +} + +export function asPgNode(value: PgNodeValue | undefined): PgNode | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) && 'node' in value + ? value + : null; +} + +export function isPgNode(value: PgNodeValue | undefined, node: string): value is PgNode { + return asPgNode(value)?.node === node; +} + +export function isPgDatum(value: PgNodeValue | undefined): value is PgDatum { + return typeof value === 'object' && value !== null && !Array.isArray(value) && 'bytes' in value; +} + +const utf8 = new TextDecoder('utf-8', { fatal: true }); + +// Varlena datums as a little-endian server writes them (every supported +// platform today). A 1-byte header has its low bit set and holds the total +// size in the upper 7 bits; a 4-byte header has its two low bits clear and +// holds the total size in the upper 30. Compressed and TOAST-pointer forms +// can't appear in a short constant, so they are rejected rather than guessed. +function readVarlena(bytes: number[], offset: number): { text: string; size: number } | null { + const first = bytes[offset]; + if (first === undefined) return null; + let headerSize: number; + let size: number; + if ((first & 0x01) === 1) { + if (first === 0x01) return null; + headerSize = 1; + size = first >>> 1; + } else { + if ((first & 0x03) !== 0) return null; + headerSize = 4; + size = + (first | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24)) >>> 2; + } + if (size < headerSize || offset + size > bytes.length) return null; + try { + return { + text: utf8.decode(Uint8Array.from(bytes.slice(offset + headerSize, offset + size))), + size, + }; + } catch { + return null; + } +} + +/** Decode a text-like datum (text, varchar, bpchar). */ +export function decodeTextDatum(datum: PgDatum): string | null { + const value = readVarlena(datum.bytes, 0); + return value && value.size === datum.bytes.length ? value.text : null; +} + +function readInt32(bytes: number[], offset: number): number { + return bytes[offset] | (bytes[offset + 1] << 8) | (bytes[offset + 2] << 16) | (bytes[offset + 3] << 24); +} + +/** + * Decode a one-dimensional array datum without nulls whose element type is in + * `elementTypes`. Layout: varlena header, ndim, data offset (0 when there's no + * null bitmap), element type oid, then per dimension its length and lower + * bound, then the elements, each 4-byte-header element aligned to 4 bytes. + */ +export function decodeTextArrayDatum( + datum: PgDatum, + elementTypes: ReadonlySet, +): string[] | null { + const { bytes } = datum; + if (bytes.length < 24 || (bytes[0] & 0x03) !== 0) return null; + if (readInt32(bytes, 0) >>> 2 !== bytes.length) return null; + const ndim = readInt32(bytes, 4); + const dataOffset = readInt32(bytes, 8); + const elementType = String(readInt32(bytes, 12) >>> 0); + if (ndim !== 1 || dataOffset !== 0 || !elementTypes.has(elementType)) return null; + + const count = readInt32(bytes, 16); + const values: string[] = []; + let offset = 24; + for (let i = 0; i < count; i++) { + // A zero byte here is alignment padding. A 4-byte header is always written + // at a 4-byte boundary, so a zero at an unaligned offset can't be one. + if (bytes[offset] === 0) offset = (offset + 3) & ~3; + const value = readVarlena(bytes, offset); + if (!value) return null; + values.push(value.text); + offset += value.size; + } + return offset === bytes.length ? values : null; +} diff --git a/graphile/graphile-meta/src/plugin.ts b/graphile/graphile-meta/src/plugin.ts index b87b7b1dbc..54fe7f0be4 100644 --- a/graphile/graphile-meta/src/plugin.ts +++ b/graphile/graphile-meta/src/plugin.ts @@ -1,13 +1,24 @@ // Type augmentation: adds the 'schema' property to GraphileConfig.Plugin import 'graphile-build'; +import type {} from 'graphile-build-pg'; import type { GraphileConfig } from 'graphile-config'; import type { GraphQLSchema } from 'graphql'; +import { allowedValuesForAttribute } from './check-constraint-values'; import { extendQueryWithMetaField } from './graphql-meta-field'; import { collectTablesMeta } from './table-meta-builder'; import type { MetaBuild, TableMeta } from './types'; +declare global { + namespace DataplanPg { + interface PgCodecAttributeExtensions { + /** Values permitted by the column's simple CHECK constraints. */ + allowedValues?: string[]; + } + } +} + const runtimeTablesBySchema = new WeakMap(); function getRuntimeTablesMeta( @@ -37,6 +48,22 @@ export const MetaSchemaPlugin: GraphileConfig.Plugin = { name: 'MetaSchemaPlugin', version: '1.0.0', description: 'Exposes _meta query for database schema introspection', + gather: { + hooks: { + // Record each column's CHECK-constraint values on the codec attribute, from + // the introspection the gather phase already holds (conbin), so _meta can + // report them without querying the database. + pgCodecs_attribute(_info, event) { + const { pgClass, pgAttribute, attribute } = event; + const allowedValues = allowedValuesForAttribute( + pgClass.getConstraints(), + pgAttribute.attnum + ); + if (!allowedValues) return; + attribute.extensions = { ...attribute.extensions, allowedValues }; + }, + }, + }, schema: { hooks: { GraphQLObjectType_fields(rawFields, rawBuild, rawContext) { diff --git a/graphile/graphile-meta/src/relation-meta-builders.ts b/graphile/graphile-meta/src/relation-meta-builders.ts index 8b41228197..de8ab3ebf2 100644 --- a/graphile/graphile-meta/src/relation-meta-builders.ts +++ b/graphile/graphile-meta/src/relation-meta-builders.ts @@ -197,9 +197,17 @@ export function buildReverseRelations( return { hasOne, hasMany }; } +// A relation the many-to-many plugin declared is dropped when the final schema +// has no field for it. A detected one is kept with a null fieldName and type, +// which tells clients the link exists but no GraphQL field serves it. +// +// A declared relation's `leftRelationName` is the backward relation on the +// left table. A detected one names the junction's own forward key instead, so +// it never depends on, or names, a backward relation the API may hide. function buildManyToManyRelation( details: PgManyToManyRelationDetails, context: BuildContext, + detected = false, ): ManyToManyRelation | null { const leftCodec = getResourceCodec(details.leftTable); const junctionCodec = getResourceCodec(details.junctionTable); @@ -238,9 +246,11 @@ function buildManyToManyRelation( }, ]) : null; - if (context.schema && !executable) return null; + if (context.schema && !executable && !detected) return null; - const leftRelation = getRelation(details.leftTable, details.leftRelationName); + const leftRelation = detected + ? flipped(getRelation(details.junctionTable, details.leftRelationName)) + : getRelation(details.leftTable, details.leftRelationName); const junctionRightRelation = getRelation(details.junctionTable, details.rightRelationName); if (!leftRelation || !junctionRightRelation) return null; @@ -251,10 +261,12 @@ function buildManyToManyRelation( const relationFieldName = executable?.name ?? - safeInflection( - () => context.build.inflection._manyToManyRelation?.(details), - details.rightRelationName || rightCodec.name || null, - ); + (detected + ? null + : safeInflection( + () => context.build.inflection._manyToManyRelation?.(details), + details.rightRelationName || rightCodec.name || null, + )); const junctionLeftConstraint = buildForeignKeyConstraint( details.leftRelationName || `${junctionCodec.name}_${leftCodec.name}_fkey`, @@ -287,7 +299,7 @@ function buildManyToManyRelation( return { fieldName: relationFieldName, - type: executable?.typeName ?? rightCodec.name ?? null, + type: executable?.typeName ?? (detected ? null : rightCodec.name ?? null), junctionTable: { name: context.schema ? junctionTypeName : junctionCodec.name || 'unknown', }, @@ -323,13 +335,45 @@ function buildManyToManyRelation( }; } +// A forward relation read from the other end, so it lines up with a backward one. +function flipped(relation: PgRelation | null): PgRelation | null { + return relation + ? { ...relation, localAttributes: relation.remoteAttributes, remoteAttributes: relation.localAttributes } + : null; +} + export function buildManyToManyRelations( resource: PgTableResource, context: BuildContext, ): ManyToManyRelation[] { - const relationships = - context.build.pgManyToManyRealtionshipsByResource?.get(resource) ?? []; - return relationships - .map((details) => buildManyToManyRelation(details, context)) - .filter((relation): relation is ManyToManyRelation => relation !== null); + const declared = ( + context.build.pgManyToManyRealtionshipsByResource?.get(resource) ?? [] + ).flatMap((details) => { + const relation = buildManyToManyRelation(details, context); + return relation ? [{ details, relation }] : []; + }); + const emit = (details: PgManyToManyRelationDetails) => { + if (details.junctionTable.codec) context.emittedJunctions?.add(details.junctionTable.codec); + }; + declared.forEach((entry) => emit(entry.details)); + const detected = ( + (resource.codec && context.junctions?.detected.get(resource.codec)) || [] + ).filter( + (details) => + !declared.some( + (entry) => + entry.details.junctionTable.codec === details.junctionTable.codec && + entry.details.rightRelationName === details.rightRelationName, + ), + ); + + return [ + ...declared.map((entry) => entry.relation), + ...detected.flatMap((details) => { + const relation = buildManyToManyRelation(details, context, true); + if (!relation) return []; + emit(details); + return [relation]; + }), + ]; } diff --git a/graphile/graphile-meta/src/table-meta-builder.ts b/graphile/graphile-meta/src/table-meta-builder.ts index 1d5e2cc161..3084a14520 100644 --- a/graphile/graphile-meta/src/table-meta-builder.ts +++ b/graphile/graphile-meta/src/table-meta-builder.ts @@ -1,4 +1,10 @@ -import { getNamedType, type GraphQLSchema } from 'graphql'; +import { + getNamedType, + type GraphQLInputObjectType, + type GraphQLSchema, + isInputObjectType, + isNonNullType +} from 'graphql'; import { buildForeignKeyConstraints, @@ -6,7 +12,10 @@ import { buildPrimaryKey, buildUniqueConstraints } from './constraint-meta-builders'; +import { exposedColumns } from './exposure'; import { getFieldContainerType } from './graphql-schema-utils'; +import { safeInflection } from './inflection-utils'; +import { indexJunctions } from './junction-meta-builders'; import { buildInflectionMeta, buildQueryMeta, resolveTableType } from './name-meta-builders'; import { buildBelongsToRelations, @@ -27,15 +36,59 @@ import { getUniques, isTableResource } from './table-resource-utils'; -import { buildFieldMeta } from './type-mappings'; +import { buildFieldMeta, readAllowedValues } from './type-mappings'; import type { ConstraintsMeta, MetaBuild, PgCodec, + QueryMeta, RelationsMeta, TableMeta } from './types'; +// The record a create or update mutation writes. `exists` is false when the +// mutation isn't in the schema; `fields` is null when it is but its record +// input can't be found under the inflected name, so writability is unknown. +type RecordInput = + | { exists: false } + | { exists: true; fields: ReturnType | null }; + +// The record field of the mutation's `input` argument, by the name PostGraphile +// gives it (`CreateShiftInput.shift`, `UpdateShiftInput.shiftPatch`); other +// input objects in the same input (nested writes) are never mistaken for it. +function mutationRecordInput( + schema: GraphQLSchema, + mutationName: string | null, + recordFieldName: string | null +): RecordInput { + const mutation = mutationName ? schema.getMutationType()?.getFields()[mutationName] : undefined; + if (!mutation) return { exists: false }; + const inputArg = mutation.args.find((arg) => arg.name === 'input'); + const inputType = inputArg ? getNamedType(inputArg.type) : null; + const recordField = + isInputObjectType(inputType) && recordFieldName + ? inputType.getFields()[recordFieldName] + : undefined; + const record = recordField ? getNamedType(recordField.type) : null; + return { exists: true, fields: isInputObjectType(record) ? record.getFields() : null }; +} + +function resolveWriteInputs( + schema: GraphQLSchema, + query: QueryMeta, + resource: TableResourceWithCodec, + build: MetaBuild +) { + const tableFieldName = safeInflection(() => build.inflection.tableFieldName?.(resource), null); + const patchFieldName = tableFieldName + ? safeInflection(() => build.inflection.patchField?.(tableFieldName), null) + : null; + return { + create: mutationRecordInput(schema, query.create, tableFieldName), + patch: mutationRecordInput(schema, query.update, patchFieldName) + }; +} + function buildTableMeta( resource: TableResourceWithCodec, schemaName: string, @@ -64,17 +117,23 @@ function buildTableMeta( for (const attrName of relation.localAttributes || []) fkAttrNames.add(attrName); } + const query = buildQueryMeta( + resource, + uniques, + tableType, + context.build, + context.schema + ); + // Requiredness comes from the create input PostGraphile actually built, not + // from NOT NULL: a trigger-filled column (`@behavior -insert`) or one with a + // default is NOT NULL yet optional or absent there. + const writeInputs = context.schema + ? resolveWriteInputs(context.schema, query, resource, context.build) + : null; + + const exposed = exposedColumns(codec, context); const fields = Object.entries(attributes).flatMap(([attrName, attr]) => { - if ( - context.schema && - context.build.behavior?.pgCodecAttributeMatches && - !context.build.behavior.pgCodecAttributeMatches( - [codec, attrName], - 'attribute:select' - ) - ) { - return []; - } + if (!exposed.has(attrName)) return []; const fieldName = context.inflectAttr(attrName, codec); const fieldMeta = buildFieldMeta(fieldName, attrName, attr, context.build, { @@ -82,25 +141,22 @@ function buildTableMeta( isForeignKey: fkAttrNames.has(attrName) }); - if (finalTableType) { - const finalField = finalTableType.getFields()[fieldName]; - if (!finalField) return []; - - const finalTypeName = getNamedType(finalField.type).name; - const codecForLookup = attr.codec?.arrayOfCodec || attr.codec; - const registeredTypeName = - codecForLookup && - context.build.hasGraphQLTypeForPgCodec?.(codecForLookup, 'output') - ? context.build.getGraphQLTypeNameByPgCodec?.( - codecForLookup, - 'output' - ) - : null; - if (registeredTypeName && registeredTypeName !== finalTypeName) { - return []; - } + const finalTypeName = exposed.get(attrName); + if (finalTypeName) fieldMeta.type.gqlType = finalTypeName; + // Only an exposed field reports its CHECK values, so a constraint on a + // hidden column contributes nothing. + fieldMeta.allowedValues = readAllowedValues(attr); - fieldMeta.type.gqlType = finalTypeName; + if (writeInputs) { + const { create, patch } = writeInputs; + const createField = create.exists ? create.fields?.[fieldName] : undefined; + fieldMeta.isWritableOnCreate = !create.exists ? false : create.fields ? !!createField : null; + fieldMeta.isRequiredOnCreate = !create.exists + ? false + : create.fields + ? !!createField && isNonNullType(createField.type) + : null; + fieldMeta.isWritableOnUpdate = !patch.exists ? false : patch.fields ? !!patch.fields[fieldName] : null; } return [fieldMeta]; @@ -139,14 +195,6 @@ function buildTableMeta( const realtime = buildRealtimeMeta(codec, context.build); const scope = buildScopeMeta(codec, context.inflectAttr); - const query = buildQueryMeta( - resource, - uniques, - tableType, - context.build, - context.schema - ); - return { name: tableType, tableName: codec.extensions?.pg?.name ?? codec.name, @@ -164,7 +212,9 @@ function buildTableMeta( search, i18n, realtime, - scope + scope, + // Settled in collectTablesMeta once every table's relations are built. + isJunction: false }; } @@ -175,7 +225,7 @@ export function collectTablesMeta( const configuredSchemas = getConfiguredSchemas(build); const context = createBuildContext(build, schema); const seenCodecs = new Set(); - const tablesMeta: TableMeta[] = []; + const tables: { resource: TableResourceWithCodec; schemaName: string }[] = []; for (const resource of Object.values(build.input.pgRegistry.pgResources || {})) { if (!isTableResource(resource)) continue; @@ -193,9 +243,26 @@ export function collectTablesMeta( continue; } - const tableMeta = buildTableMeta(resource, schemaName, context); - if (tableMeta) tablesMeta.push(tableMeta); + tables.push({ resource, schemaName }); } - return tablesMeta; + // Junctions are a property of the whole table set: a detected junction adds + // a many-to-many relation to each of the two tables it links. + context.junctions = indexJunctions( + tables.map((table) => table.resource), + context + ); + context.emittedJunctions = new Set(); + + const built: { codec: PgCodec; meta: TableMeta }[] = []; + for (const { resource, schemaName } of tables) { + const tableMeta = buildTableMeta(resource, schemaName, context); + if (tableMeta) built.push({ codec: resource.codec, meta: tableMeta }); + } + // A table is a junction exactly when a reported many-to-many relation names + // it, so the flag says nothing the relations don't already show. + for (const { codec, meta } of built) { + meta.isJunction = context.emittedJunctions.has(codec); + } + return built.map(({ meta }) => meta); } diff --git a/graphile/graphile-meta/src/table-meta-context.ts b/graphile/graphile-meta/src/table-meta-context.ts index 2d85594f98..ba499e46a0 100644 --- a/graphile/graphile-meta/src/table-meta-context.ts +++ b/graphile/graphile-meta/src/table-meta-context.ts @@ -1,6 +1,7 @@ import type { GraphQLSchema } from 'graphql'; import { createAttributeInflector } from './inflection-utils'; +import type { JunctionIndex } from './junction-meta-builders'; import { buildFieldMeta } from './type-mappings'; import type { FieldMeta, @@ -16,6 +17,12 @@ export interface BuildContext { build: MetaBuild; inflectAttr: AttributeInflector; schema?: GraphQLSchema; + /** Many-to-many relations implied by detected junction tables. */ + junctions?: JunctionIndex; + /** Codecs of the junction tables named by a reported many-to-many relation. */ + emittedJunctions?: Set; + /** Memo for `exposedColumns`, per table codec. */ + exposedColumns?: Map>; } export type TableResourceWithCodec = PgTableResource & { diff --git a/graphile/graphile-meta/src/type-mappings.ts b/graphile/graphile-meta/src/type-mappings.ts index 06a4415a7f..8d7bf8629d 100644 --- a/graphile/graphile-meta/src/type-mappings.ts +++ b/graphile/graphile-meta/src/type-mappings.ts @@ -93,6 +93,12 @@ function extractEnumMeta( }; } +/** The allowed values the gather phase recorded on a column, if any. */ +export function readAllowedValues(attr: PgAttribute | null | undefined): string[] | null { + const values = attr?.extensions?.allowedValues; + return Array.isArray(values) && values.every((v) => typeof v === 'string') ? values : null; +} + export interface BuildFieldMetaOptions { isPrimaryKey?: boolean; isForeignKey?: boolean; @@ -128,5 +134,10 @@ export function buildFieldMeta( isForeignKey: options?.isForeignKey ?? false, description: attr?.description ?? null, enumValues: extractEnumMeta(attr?.codec), + // Set by the table builder for exposed fields only. + allowedValues: null, + isRequiredOnCreate: null, + isWritableOnCreate: null, + isWritableOnUpdate: null, }; } diff --git a/graphile/graphile-meta/src/types.ts b/graphile/graphile-meta/src/types.ts index fac0356fa1..f12e6e7be3 100644 --- a/graphile/graphile-meta/src/types.ts +++ b/graphile/graphile-meta/src/types.ts @@ -18,6 +18,12 @@ export interface TableMeta { i18n: I18nMeta | null; realtime: RealtimeMeta | null; scope: ScopeMeta | null; + /** + * Whether a reported many-to-many relation names this table as its junction, + * either one the many-to-many plugin declares or one detected by + * `junctionForeignKeys` (see `isSystemColumn` for the system-column rule). + */ + isJunction?: boolean; } /** Coarse provisioning tier a table's scope belongs to. */ @@ -142,6 +148,22 @@ export interface FieldMeta { isForeignKey: boolean; description: string | null; enumValues: EnumMeta | null; + /** + * Values permitted by simple single-column CHECK constraints + * (`col IN (…)`, `col = ANY (ARRAY[…])`, `col = 'a' OR col = 'b'`), else null. + * Set on `TableMeta.fields` only, which holds exposed columns only. + */ + allowedValues?: string[] | null; + /** + * Whether the field is non-null in the table's create input type. Null when + * not resolved against the final schema, which is the case for fields nested + * in indexes, constraints and relations. + */ + isRequiredOnCreate?: boolean | null; + /** Whether the table's create input type has this field (null as above). */ + isWritableOnCreate?: boolean | null; + /** Whether the table's patch type has this field (null as above). */ + isWritableOnUpdate?: boolean | null; } export interface TypeMeta { @@ -296,10 +318,13 @@ export interface PgCodec { schemaName?: string; name?: string; }; + tags?: Record; }; } export interface PgAttributeExtensions extends Record { + /** Set at gather time from the column's simple CHECK constraints. */ + allowedValues?: string[] | null; geometrySubtype?: string | null; geometrySrid?: number | null; vectorDimensions?: number | null; @@ -395,6 +420,7 @@ export interface MetaInflection { details: { resource: PgTableResource }, ) => string | null | undefined; tableFieldName?: (resource: PgTableResource) => string | null | undefined; + patchField?: (fieldName: string) => string | null | undefined; createField?: (resource: PgTableResource) => string | null | undefined; rowByUnique?: (details: PgUniqueInflectionDetails) => string | null | undefined; updateByKeysField?: (details: PgUniqueInflectionDetails) => string | null | undefined; @@ -438,6 +464,7 @@ export interface MetaBuild extends GqlTypeResolverBuild { entity: [PgCodec, string], behavior: string, ) => boolean; + pgCodecRelationMatches?: (relation: PgRelation, behavior: string) => boolean; }; options?: { pgSchemas?: string[]; diff --git a/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts b/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts new file mode 100644 index 0000000000..cca5520b22 --- /dev/null +++ b/graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts @@ -0,0 +1,282 @@ +import { getConnectionsObjectUnwrapped, seed } from 'graphile-test'; +import { join } from 'path'; + +import { ConstructivePreset } from '../src/presets/constructive-preset'; + +jest.setTimeout(60000); + +const baseSeed = join(__dirname, '../../../__fixtures__/seed/base/setup.sql'); + +// The form metadata graphile-meta adds to _meta, read from a real schema: the +// CHECK values come from the gather hook, writability from the create and +// patch inputs PostGraphile builds, and junctions from real v5 relations. +const formSchema = ` + CREATE SCHEMA form_meta; + + CREATE TABLE form_meta.locations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + name text NOT NULL + ); + + CREATE TABLE form_meta.shifts ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + title text NOT NULL, + status text NOT NULL DEFAULT 'open' + CONSTRAINT shifts_status_chk CHECK (status IN ('open', 'full', 'cancelled')), + priority text CHECK (priority = 'low' OR priority = 'high'), + code varchar(10) CHECK (code IN ('a', 'b')) CHECK (length(code) > 0), + internal_level text CHECK (internal_level IN ('x', 'y')), + filled_in text NOT NULL, + location_id uuid NOT NULL REFERENCES form_meta.locations (id) + ); + COMMENT ON COLUMN form_meta.shifts.internal_level IS E'@behavior -select'; + COMMENT ON COLUMN form_meta.shifts.filled_in IS E'@behavior -insert\\nFilled in for you'; + + CREATE TABLE form_meta.volunteers ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + full_name text NOT NULL + ); + + -- A link table the many-to-many plugin doesn't opt into: id, a timestamp + -- with a default and a hidden note around the two foreign keys. + CREATE TABLE form_meta.shift_signups ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shift_id uuid NOT NULL REFERENCES form_meta.shifts (id), + volunteer_id uuid NOT NULL REFERENCES form_meta.volunteers (id), + created_at timestamptz NOT NULL DEFAULT now(), + internal_note text + ); + COMMENT ON COLUMN form_meta.shift_signups.internal_note IS E'@behavior -select'; + + -- A link table the plugin does opt into: one entry per side, not two. + CREATE TABLE form_meta.skills ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + label text NOT NULL + ); + CREATE TABLE form_meta.volunteer_skills ( + volunteer_id uuid NOT NULL REFERENCES form_meta.volunteers (id), + skill_id uuid NOT NULL REFERENCES form_meta.skills (id), + PRIMARY KEY (volunteer_id, skill_id) + ); + COMMENT ON TABLE form_meta.volunteer_skills IS E'@behavior +manyToMany'; + + -- A link table whose other side is hidden from the API. + CREATE TABLE form_meta.badges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid() + ); + COMMENT ON TABLE form_meta.badges IS E'@behavior -*'; + CREATE TABLE form_meta.shift_badges ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + shift_id uuid NOT NULL REFERENCES form_meta.shifts (id), + badge_id uuid NOT NULL REFERENCES form_meta.badges (id) + ); +`; + +const metaQuery = ` + query FormMeta { + _meta { + tables { + name + tableName + schemaName + isJunction + fields { + name + columnName + description + isNotNull + hasDefault + allowedValues + isRequiredOnCreate + isWritableOnCreate + isWritableOnUpdate + type { pgType gqlType } + enumValues { name values } + } + indexes { name columns fields { name allowedValues } } + constraints { + primaryKey { name } + unique { name } + foreignKey { name referencedTable fields { name allowedValues } } + } + foreignKeyConstraints { name } + relations { + belongsTo { fieldName type references { name } } + hasMany { fieldName type referencedBy { name } } + manyToMany { + fieldName + type + junctionTable { name } + rightTable { name } + junctionLeftConstraint { name } + junctionRightConstraint { name } + junctionLeftKeyAttributes { name } + junctionRightKeyAttributes { name } + } + } + inflection { tableType createInputType patchType } + query { all one create update delete } + scope { scope tier keyColumn entityTable } + } + } + } +`; + +type Field = { + name: string; + description: string | null; + isNotNull: boolean; + allowedValues: string[] | null; + isRequiredOnCreate: boolean | null; + isWritableOnCreate: boolean | null; + isWritableOnUpdate: boolean | null; +}; +type ManyToMany = { + fieldName: string | null; + type: string | null; + junctionTable: { name: string }; + rightTable: { name: string }; + junctionLeftConstraint: { name: string }; + junctionRightConstraint: { name: string }; + junctionLeftKeyAttributes: { name: string }[]; + junctionRightKeyAttributes: { name: string }[]; +}; +type Table = { + name: string; + tableName: string; + isJunction: boolean; + fields: Field[]; + foreignKeyConstraints: { name: string }[]; + relations: { manyToMany: ManyToMany[] }; +}; + +function stringsIn(value: unknown, found: string[] = []): string[] { + if (typeof value === 'string') found.push(value); + else if (Array.isArray(value)) value.forEach((item) => stringsIn(item, found)); + else if (value && typeof value === 'object') Object.values(value).forEach((item) => stringsIn(item, found)); + return found; +} + +function allowedValuesIn(value: unknown, found: unknown[] = []): unknown[] { + if (Array.isArray(value)) value.forEach((item) => allowedValuesIn(item, found)); + else if (value && typeof value === 'object') { + for (const [key, child] of Object.entries(value)) { + if (key === 'allowedValues') found.push(child); + else allowedValuesIn(child, found); + } + } + return found; +} + +describe('_meta form metadata on a real schema', () => { + let teardown: () => Promise; + let meta: { _meta: { tables: Table[] } }; + const table = (tableName: string) => meta._meta.tables.find((t) => t.tableName === tableName); + const field = (tableName: string, name: string) => + table(tableName)?.fields.find((f) => f.name === name); + + beforeAll(async () => { + const connections = await getConnectionsObjectUnwrapped( + { + schemas: ['form_meta'], + preset: { extends: [ConstructivePreset] }, + useRoot: true + }, + [ + seed.sqlfile([baseSeed]), + seed.fn(async ({ pg }) => { + await pg.query(formSchema); + }) + ] + ); + teardown = connections.teardown; + meta = await connections.query<{ _meta: { tables: Table[] } }>({ query: metaQuery }); + }); + + afterAll(async () => { + if (teardown) await teardown(); + }); + + it('reports CHECK values read at gather time, and nothing for a column with another kind of check', () => { + expect(field('shifts', 'status')?.allowedValues).toEqual(['open', 'full', 'cancelled']); + expect(field('shifts', 'priority')?.allowedValues).toEqual(['low', 'high']); + expect(field('shifts', 'code')?.allowedValues).toBeNull(); + }); + + it('says nothing about a column hidden with -select, anywhere in the response', () => { + expect(field('shifts', 'internalLevel')).toBeUndefined(); + const reported = allowedValuesIn(meta).filter((values) => values !== null); + expect(reported).toEqual([ + ['open', 'full', 'cancelled'], + ['low', 'high'] + ]); + }); + + it('reads requiredness from the create input, so a -insert column is neither required nor writable', () => { + expect(field('shifts', 'title')).toMatchObject({ + isNotNull: true, + isRequiredOnCreate: true, + isWritableOnCreate: true, + isWritableOnUpdate: true + }); + expect(field('shifts', 'status')).toMatchObject({ isRequiredOnCreate: false, isWritableOnCreate: true }); + expect(field('shifts', 'filledIn')).toMatchObject({ + isNotNull: true, + isRequiredOnCreate: false, + isWritableOnCreate: false, + isWritableOnUpdate: true, + description: 'Filled in for you' + }); + }); + + it('detects a link table with real relation names, and links both sides', () => { + const signups = table('shift_signups'); + const shiftLinks = table('shifts')?.relations.manyToMany.filter((m) => m.junctionTable.name === signups?.name); + const volunteerLinks = table('volunteers')?.relations.manyToMany.filter( + (m) => m.junctionTable.name === signups?.name + ); + + expect(signups?.isJunction).toBe(true); + expect(shiftLinks).toHaveLength(1); + expect(shiftLinks?.[0]).toMatchObject({ + fieldName: null, + type: null, + rightTable: { name: table('volunteers')?.name }, + junctionLeftKeyAttributes: [{ name: 'shiftId' }], + junctionRightKeyAttributes: [{ name: 'volunteerId' }] + }); + // Both keys are the junction's own foreign keys, named as the junction + // reports them, never the left tables' backward relations, which this + // preset hides from the API. + const junctionKeys = signups?.foreignKeyConstraints.map((c) => c.name).sort(); + for (const link of [shiftLinks?.[0], volunteerLinks?.[0]]) { + expect([link?.junctionLeftConstraint.name, link?.junctionRightConstraint.name].sort()).toEqual(junctionKeys); + } + expect(volunteerLinks?.[0]).toMatchObject({ fieldName: null, rightTable: { name: table('shifts')?.name } }); + }); + + it('keeps one entry per side for a link table the many-to-many plugin opts into', () => { + const skills = table('volunteer_skills'); + const links = table('volunteers')?.relations.manyToMany.filter((m) => m.junctionTable.name === skills?.name); + + expect(skills?.isJunction).toBe(true); + expect(links).toHaveLength(1); + expect(links?.[0].fieldName).toEqual(expect.any(String)); + }); + + it('reports no link, and no junction, when the other side is hidden from the API', () => { + const badges = table('shift_badges'); + + expect(table('badges')).toBeUndefined(); + expect(badges?.isJunction).toBe(false); + expect( + table('shifts')?.relations.manyToMany.filter((m) => m.junctionTable.name === badges?.name) + ).toEqual([]); + }); + + it('serves no backend internals in any value', () => { + const forbidden = + /@\w|smart ?tag|plugin|registry|presigned|derived|inflect|introspect|trigger|behavio|provenance|check constraint/i; + expect(stringsIn(meta).filter((value) => forbidden.test(value))).toEqual([]); + }); +}); From 1e4f0b75b308ca4269f0dc1f8ee9d48569d320d4 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:35:58 +0700 Subject: [PATCH 04/14] feat(db-tools): keep the HTTP status and error code of GraphQL failures The SDK FetchAdapter turns any non-2xx into 'HTTP 403: Forbidden' and drops the body, so a tenant API's ACCESS_SUSPENDED reads like any refusal. postGraphQL and statusAdapter keep extensions.code and add the status, and classifyBackendErrors sorts failures into suspended, network, server, auth, forbidden and graphql. --- .../db-tools/__tests__/graphql-http.test.ts | 89 +++++++++ agentic/db-tools/src/graphql-http.ts | 179 ++++++++++++++++++ 2 files changed, 268 insertions(+) create mode 100644 agentic/db-tools/__tests__/graphql-http.test.ts create mode 100644 agentic/db-tools/src/graphql-http.ts diff --git a/agentic/db-tools/__tests__/graphql-http.test.ts b/agentic/db-tools/__tests__/graphql-http.test.ts new file mode 100644 index 0000000000..09157cae4f --- /dev/null +++ b/agentic/db-tools/__tests__/graphql-http.test.ts @@ -0,0 +1,89 @@ +import { + classifyBackendErrors, + type GraphQLError, + postGraphQL, + statusAdapter, +} from '../src/graphql-http'; + +const ENDPOINT = 'http://api-myapp.example.test/graphql'; + +const respond = (status: number, body?: unknown, statusText = '') => + jest.fn(async () => + body === undefined + ? new Response('', { status, statusText }) + : new Response(JSON.stringify(body), { status, statusText, headers: { 'Content-Type': 'application/json' } }), + ) as unknown as typeof fetch; + +const SUSPENDED_BODY = { + errors: [{ message: 'Access to this database is suspended.', extensions: { code: 'ACCESS_SUSPENDED' } }], +}; + +describe('postGraphQL', () => { + it('keeps the code of a 403 body and adds the status', async () => { + const result = await postGraphQL({ endpoint: ENDPOINT, query: '{ __typename }', fetchFn: respond(403, SUSPENDED_BODY, 'Forbidden') }); + + expect(result.httpStatus).toBe(403); + expect(result.errors).toEqual([ + { + message: 'Access to this database is suspended.', + extensions: { code: 'ACCESS_SUSPENDED', httpStatus: 403 }, + }, + ]); + expect(classifyBackendErrors(result.errors)).toBe('suspended'); + }); + + it('turns a 403 without a body into an HTTP_ERROR that still reads as a refusal', async () => { + const result = await postGraphQL({ endpoint: ENDPOINT, query: '{ __typename }', fetchFn: respond(403, undefined, 'Forbidden') }); + + expect(result).toEqual({ + httpStatus: 403, + data: null, + errors: [{ message: 'HTTP 403: Forbidden', extensions: { code: 'HTTP_ERROR', httpStatus: 403 } }], + }); + expect(classifyBackendErrors(result.errors)).toBe('forbidden'); + }); + + it('reports a fetch that threw as NETWORK, with no status', async () => { + const fetchFn = jest.fn(async () => { + throw Object.assign(new TypeError('fetch failed'), { cause: { code: 'ECONNREFUSED' } }); + }) as unknown as typeof fetch; + + const result = await postGraphQL({ endpoint: ENDPOINT, query: '{ __typename }', fetchFn }); + + expect(result.httpStatus).toBeNull(); + expect(result.errors).toEqual([{ message: 'fetch failed (ECONNREFUSED)', extensions: { code: 'NETWORK' } }]); + expect(classifyBackendErrors(result.errors)).toBe('network'); + }); + + it('gives the SDK clients the same errors through statusAdapter', async () => { + const adapter = statusAdapter(ENDPOINT, { Authorization: 'Bearer t' }, respond(403, SUSPENDED_BODY)); + + const failed = await adapter.execute('{ __typename }'); + const ok = await statusAdapter(ENDPOINT, {}, respond(200, { data: { __typename: 'Query' } })).execute( + '{ __typename }', + ); + + expect(failed).toMatchObject({ ok: false, data: null, errors: [{ extensions: { code: 'ACCESS_SUSPENDED' } }] }); + expect(ok).toEqual({ ok: true, data: { __typename: 'Query' }, errors: undefined }); + }); +}); + +describe('classifyBackendErrors', () => { + const error = (message: string, extensions?: Record): GraphQLError => ({ message, extensions }); + + it.each<[string, GraphQLError[], ReturnType]>([ + ['ACCESS_SUSPENDED beats the 403 it came with', [error('x', { code: 'ACCESS_SUSPENDED', httpStatus: 403 })], 'suspended'], + ['no response', [error('fetch failed', { code: 'NETWORK' })], 'network'], + ['a 5xx', [error('Bad Gateway', { httpStatus: 502 })], 'server'], + ['a 429', [error('Too Many Requests', { httpStatus: 429 })], 'server'], + ["the SDK adapter's HTTP 503 text", [error('HTTP 503: Service Unavailable')], 'server'], + ['a 401', [error('Unauthorized', { httpStatus: 401 })], 'auth'], + ['UNAUTHENTICATED', [error('x', { code: 'UNAUTHENTICATED' })], 'auth'], + ['an expired jwt', [error('jwt expired')], 'auth'], + ['FORBIDDEN', [error('x', { code: 'FORBIDDEN' })], 'forbidden'], + ['permission denied', [error('permission denied for table database')], 'forbidden'], + ['anything else', [error('Cannot query field "hash" on type "Database".')], 'graphql'], + ])('%s', (_case, errors, kind) => { + expect(classifyBackendErrors(errors)).toBe(kind); + }); +}); diff --git a/agentic/db-tools/src/graphql-http.ts b/agentic/db-tools/src/graphql-http.ts new file mode 100644 index 0000000000..c99708b62a --- /dev/null +++ b/agentic/db-tools/src/graphql-http.ts @@ -0,0 +1,179 @@ +// A GraphQL POST that keeps what the SDK's FetchAdapter throws away. That +// adapter turns any non-2xx into `HTTP 403: Forbidden` and drops the body, so +// a tenant API's `ACCESS_SUSPENDED` (a 403 with a JSON body, sent for a +// deleted or suspended database) reads the same as any other refusal. Here +// the body's errors keep their `extensions.code`, and every error carries the +// HTTP status it arrived with. + +import { + createFetch, + type GraphQLAdapter, + type GraphQLError, + type QueryResult, +} from '@constructive-io/graphql-query/runtime'; + +export type { GraphQLError }; + +export type HttpGraphQLResponse = { + /** The response status, or null when no response arrived. */ + httpStatus: number | null; + data: T | null; + /** + * Body errors, each with `extensions.httpStatus` added. A non-2xx without + * body errors becomes one `{ code: 'HTTP_ERROR', httpStatus }` error, and a + * fetch that threw becomes one `{ code: 'NETWORK' }` error. + */ + errors: GraphQLError[]; +}; + +export type PostGraphQLArgs = { + endpoint: string; + headers?: Record; + query: string; + variables?: Record; + fetchFn?: typeof fetch; +}; + +const errorText = (err: unknown): string => (err instanceof Error ? err.message : String(err)); + +export async function postGraphQL(args: PostGraphQLArgs): Promise> { + const fetchFn = (args.fetchFn ?? createFetch()).bind(globalThis) as typeof fetch; + let response: Response; + try { + response = await fetchFn(args.endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + ...args.headers, + }, + body: JSON.stringify({ query: args.query, variables: args.variables ?? {} }), + }); + } catch (err) { + const cause = (err as { cause?: { code?: unknown } } | null)?.cause?.code; + return { + httpStatus: null, + data: null, + errors: [ + { + message: typeof cause === 'string' ? `${errorText(err)} (${cause})` : errorText(err), + extensions: { code: 'NETWORK' }, + }, + ], + }; + } + + const httpStatus = response.status; + let body: { data?: T | null; errors?: GraphQLError[] } | null = null; + try { + body = (await response.json()) as typeof body; + } catch { + body = null; + } + + const bodyErrors = Array.isArray(body?.errors) ? body.errors : []; + if (bodyErrors.length > 0) { + return { + httpStatus, + data: body?.data ?? null, + errors: bodyErrors.map((error) => ({ + ...error, + message: error?.message ?? '', + extensions: { ...error?.extensions, httpStatus }, + })), + }; + } + if (!response.ok || !body) { + const text = response.statusText ? `: ${response.statusText}` : ''; + return { + httpStatus, + data: null, + errors: [ + { + message: response.ok ? `HTTP ${httpStatus}${text} (the body was not JSON)` : `HTTP ${httpStatus}${text}`, + extensions: { code: 'HTTP_ERROR', httpStatus }, + }, + ], + }; + } + return { httpStatus, data: body.data ?? null, errors: [] }; +} + +/** + * An ORM adapter over `postGraphQL`: the SDK clients take it in place of their + * FetchAdapter, and their failures then carry the status and code. + */ +export function statusAdapter( + endpoint: string, + headers?: Record, + fetchFn?: typeof fetch, +): GraphQLAdapter { + let current = { ...headers }; + return { + async execute(document: string, variables?: Record): Promise> { + const result = await postGraphQL({ endpoint, headers: current, query: document, variables, fetchFn }); + return result.errors.length > 0 + ? { ok: false, data: null, errors: result.errors } + : { ok: true, data: result.data as T, errors: undefined }; + }, + setHeaders(next: Record) { + current = { ...current, ...next }; + }, + getEndpoint: () => endpoint, + }; +} + +export type BackendErrorKind = 'suspended' | 'auth' | 'forbidden' | 'server' | 'network' | 'graphql'; + +const codeOf = (error: GraphQLError): string | undefined => { + const code = error?.extensions?.code; + return typeof code === 'string' ? code : undefined; +}; + +// The status an error arrived with: ours from extensions, or the SDK +// adapter's `HTTP 503: …` message. +const statusOf = (error: GraphQLError): number | undefined => { + const status = error?.extensions?.httpStatus; + if (typeof status === 'number') return status; + const match = /^HTTP (\d{3})\b/.exec(error?.message ?? ''); + return match ? Number(match[1]) : undefined; +}; + +const AUTH_MESSAGE_RE = /jwt|unauthenticated|invalid token/i; +const FORBIDDEN_MESSAGE_RE = /permission denied/i; + +/** + * What a failed backend answer says, first match wins: the database is + * suspended or gone (`ACCESS_SUSPENDED`), no response, the backend failing + * (5xx, 429), a rejected credential (401), a refusal (403), or anything else. + */ +export function classifyBackendErrors(errors: readonly GraphQLError[]): BackendErrorKind { + const list = errors ?? []; + const some = (test: (error: GraphQLError) => boolean) => list.some((error) => test(error)); + if (some((e) => codeOf(e) === 'ACCESS_SUSPENDED')) return 'suspended'; + if (some((e) => codeOf(e) === 'NETWORK')) return 'network'; + if ( + some((e) => { + const status = statusOf(e); + return status !== undefined && (status >= 500 || status === 429); + }) + ) { + return 'server'; + } + if ( + some((e) => statusOf(e) === 401 || codeOf(e) === 'UNAUTHENTICATED' || AUTH_MESSAGE_RE.test(e?.message ?? '')) + ) { + return 'auth'; + } + if ( + some((e) => statusOf(e) === 403 || codeOf(e) === 'FORBIDDEN' || FORBIDDEN_MESSAGE_RE.test(e?.message ?? '')) + ) { + return 'forbidden'; + } + return 'graphql'; +} + +/** The messages of a failed answer, joined for a reason string. */ +export function errorsText(errors: readonly GraphQLError[]): string { + return (errors ?? []).map((error) => error?.message || codeOf(error) || 'unknown error').join('; '); +} From e212a3770b7292cb9cd1e76ea75a30aa87b6ff99 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:35:58 +0700 Subject: [PATCH 05/14] feat(db-tools): read the live schema of a project's app tables readSchemaModel reads every APP table in one piece, or throws: stable ids, every field with its category, normalised types, structured defaults, check-derived allowed values, key flags, foreign keys with their actions, and policies, with accessOf mapping them to shared, private or custom. The schema hash is read first. --- .../db-tools/__tests__/schema-model.test.ts | 319 +++++++++++++ agentic/db-tools/src/schema-model.ts | 436 ++++++++++++++++++ .../db-tools/src/schema-model/normalize.ts | 252 ++++++++++ 3 files changed, 1007 insertions(+) create mode 100644 agentic/db-tools/__tests__/schema-model.test.ts create mode 100644 agentic/db-tools/src/schema-model.ts create mode 100644 agentic/db-tools/src/schema-model/normalize.ts diff --git a/agentic/db-tools/__tests__/schema-model.test.ts b/agentic/db-tools/__tests__/schema-model.test.ts new file mode 100644 index 0000000000..3fe0c11532 --- /dev/null +++ b/agentic/db-tools/__tests__/schema-model.test.ts @@ -0,0 +1,319 @@ +import type { ProjectContext } from '../src/context'; +import { accessOf, type ModelPolicy, readSchemaModel, SchemaReadError } from '../src/schema-model'; +import { columnDefault, normalizeType, readCheck } from '../src/schema-model/normalize'; + +// Postgres AST JSON as the metaschema stores it. +const str = (sval: string) => ({ String: { sval } }); +const col = (name: string) => ({ ColumnRef: { fields: [str(name)] } }); +const inList = (column: string, items: unknown) => ({ + A_Expr: { kind: 'AEXPR_IN', name: [str('=')], lexpr: col(column), rexpr: items }, +}); + +describe('normalizeType', () => { + it.each([ + [{ name: 'int4' }, 'integer'], + [{ name: 'int8' }, 'bigint'], + [{ name: 'bool' }, 'boolean'], + [{ name: 'decimal' }, 'numeric'], + [{ name: 'timestamp with time zone' }, 'timestamptz'], + [{ name: 'character varying' }, 'varchar'], + [{ name: 'email' }, 'email'], + [{ name: 'text', array_dimensions: 1 }, 'text[]'], + [{ name: 'int4', array_dimensions: 2 }, 'integer[][]'], + ['TIMESTAMPTZ', 'timestamptz'], + ])('%j → %s', (raw, type) => { + expect(normalizeType(raw)).toBe(type); + }); +}); + +describe('columnDefault', () => { + it.each([ + [{ value: 'active' }, { kind: 'value', value: 'active' }], + [{ value: 0 }, { kind: 'value', value: 0 }], + [{ value: false }, { kind: 'value', value: false }], + [{ sql_keyword: 'CURRENT_TIMESTAMP' }, { kind: 'now' }], + [{ function: 'now' }, { kind: 'now' }], + [{ sql_keyword: 'CURRENT_DATE' }, { kind: 'today' }], + [{ function: 'uuidv7' }, { kind: 'generated', fn: 'uuidv7' }], + [{ function: 'gen_random_uuid' }, { kind: 'generated', fn: 'gen_random_uuid' }], + [ + { operator: '+', left: { function: 'now' }, right: { value: '1 day', cast: { name: 'interval' } } }, + { kind: 'expression', text: "now() + '1 day'::interval" }, + ], + [{ function: 'nextval', args: ['items_seq'] }, { kind: 'expression', text: "nextval('items_seq')" }], + ['now()', { kind: 'now' }], + [{ value: null }, null], + [null, null], + ])('%j', (raw, expected) => { + expect(columnDefault(raw)).toEqual(expected); + }); +}); + +describe('readCheck', () => { + it('reads the plain sval constants DataStatusField writes', () => { + const chk = { expression: inList('status', [{ A_Const: { sval: 'active' } }, { A_Const: { sval: 'archived' } }]) }; + + expect(readCheck(chk)).toEqual({ values: ['active', 'archived'], other: false }); + }); + + it("reads the parser's wrapped constants inside a List", () => { + const expr = inList('size', { List: { items: [{ A_Const: { sval: { sval: 'small' } } }, { A_Const: { sval: { sval: 'large' } } }] } }); + + expect(readCheck(expr)).toEqual({ values: ['small', 'large'], other: false }); + }); + + it('reads integer constants, zero included', () => { + const expr = inList('qty', [{ A_Const: { ival: { ival: 3 } } }, { A_Const: { ival: {} } }]); + + expect(readCheck(expr).values).toEqual(['3', '0']); + }); + + it('reads = ANY (ARRAY[...]) through casts', () => { + const cast = (sval: string) => ({ TypeCast: { arg: { A_Const: { sval: { sval } } }, typeName: {} } }); + const expr = { + A_Expr: { + kind: 'AEXPR_OP_ANY', + name: [str('=')], + lexpr: col('status'), + rexpr: { TypeCast: { arg: { A_ArrayExpr: { elements: [cast('open'), cast('closed')] } } } }, + }, + }; + + expect(readCheck(expr)).toEqual({ values: ['open', 'closed'], other: false }); + }); + + it('keeps the values of an IN-list ANDed with a range, and flags the range', () => { + const range = { A_Expr: { kind: 'AEXPR_OP', name: [str('>=')], lexpr: col('qty'), rexpr: { A_Const: { ival: {} } } } }; + const expr = { BoolExpr: { boolop: 'AND_EXPR', args: [range, inList('qty', [{ A_Const: { ival: 1 } }])] } }; + + expect(readCheck(expr)).toEqual({ values: ['1'], other: true }); + }); + + it('flags a check it cannot read as other', () => { + expect(readCheck({ expression: { FuncCall: { funcname: [str('char_length')] } } })).toEqual({ other: true }); + }); +}); + +describe('accessOf', () => { + const policy = (type: string, privilege: string, extra: Partial = {}): ModelPolicy => ({ + id: `${type}-${privilege}`, + type, + privilege, + role: 'authenticated', + permissive: true, + disabled: false, + data: type === 'AuthzAppMemberOwner' ? { owner_field: 'owner_id' } : {}, + ...extra, + }); + const crud = (type: string, extra: Partial = {}) => + ['select', 'insert', 'update', 'delete'].map((p) => policy(type, p, extra)); + + it('reads the app-member owner policy on owner_id as private', () => { + expect(accessOf(crud('AuthzAppMemberOwner'))).toBe('private'); + }); + + it('reads the plain app-membership policy as shared, ignoring an internal one', () => { + expect(accessOf([...crud('AuthzAppMembership'), policy('AuthzNotReadOnly', 'update', { permissive: false })])).toBe( + 'shared', + ); + }); + + it('treats a parameter left at its no-op value as unset', () => { + expect(accessOf(crud('AuthzAppMembership', { data: { is_admin: false, levels: [] } }))).toBe('shared'); + }); + + it.each([ + ['no policies', []], + ['a missing privilege', crud('AuthzAppMembership').slice(0, 3)], + ['another owner field', crud('AuthzAppMemberOwner', { data: { owner_field: 'author_id' } })], + ['a required level', crud('AuthzAppMembership', { data: { levels: ['gold'] } })], + ['another role', crud('AuthzAppMembership', { role: 'anonymous' })], + ['mixed modes', [...crud('AuthzAppMembership').slice(0, 2), ...crud('AuthzAppMemberOwner').slice(2)]], + ['another policy type', crud('AuthzEntityMembership')], + ])('reads %s as custom', (_case, policies) => { + expect(accessOf(policies)).toBe('custom'); + }); +}); + +describe('readSchemaModel', () => { + // A fake platform api: each model's findMany returns its rows, in call order. + function fakeApi(overrides: Record = {}) { + const calls: { model: string; args: Record }[] = []; + const rows: Record = { + database: { databases: { nodes: [{ id: 'db-1', hash: 'h1' }] } }, + table: { + tables: { + nodes: [ + { id: 't-items', name: 'items', category: 'APP', label: 'Items' }, + { id: 't-lists', name: 'lists', category: 'APP' }, + { id: 't-users', name: 'users', category: 'CORE' }, + { id: 't-audit', name: 'audit_log', category: 'MODULE' }, + ], + }, + }, + field: { + fields: { + nodes: [ + { id: 'f-id', tableId: 't-items', name: 'id', category: 'APP', fieldOrder: 1, type: { name: 'uuid' }, isRequired: true, defaultValue: { function: 'uuidv7' } }, + { id: 'f-name', tableId: 't-items', name: 'name', category: 'APP', fieldOrder: 2, type: { name: 'text' }, isRequired: true }, + { + id: 'f-status', tableId: 't-items', name: 'status', category: 'MODULE', fieldOrder: 3, type: { name: 'text' }, + defaultValue: { value: 'active' }, + chk: { expression: inList('status', [{ A_Const: { sval: 'active' } }, { A_Const: { sval: 'archived' } }]) }, + }, + { id: 'f-size', tableId: 't-items', name: 'size', category: 'APP', fieldOrder: 4, type: { name: 'text' } }, + { id: 'f-code', tableId: 't-items', name: 'code', category: 'APP', fieldOrder: 5, type: { name: 'citext' } }, + { id: 'f-list', tableId: 't-items', name: 'list_id', category: 'APP', fieldOrder: 6, type: { name: 'uuid' } }, + { id: 'f-owner', tableId: 't-items', name: 'owner_id', category: 'APP', fieldOrder: 7, type: { name: 'uuid' } }, + { id: 'f-parent', tableId: 't-items', name: 'parent_id', category: 'APP', fieldOrder: 8, type: { name: 'uuid' } }, + { id: 'f-a', tableId: 't-items', name: 'a_id', category: 'APP', fieldOrder: 9, type: { name: 'uuid' } }, + { id: 'f-d', tableId: 't-items', name: 'd_id', category: 'APP', fieldOrder: 10, type: { name: 'uuid' } }, + { id: 'l-id', tableId: 't-lists', name: 'id', category: 'APP', fieldOrder: 1, type: { name: 'uuid' } }, + ], + }, + }, + primaryKeyConstraint: { + primaryKeyConstraints: { + nodes: [ + { id: 'pk-items', tableId: 't-items', fieldIds: ['f-id'] }, + { id: 'pk-lists', tableId: 't-lists', fieldIds: ['l-id'] }, + ], + }, + }, + uniqueConstraint: { + uniqueConstraints: { + nodes: [ + { id: 'u-code', tableId: 't-items', fieldIds: ['f-code'] }, + { id: 'u-pair', tableId: 't-items', fieldIds: ['f-name', 'f-list'] }, + ], + }, + }, + checkConstraint: { + checkConstraints: { + nodes: [ + { + id: 'c-size', + tableId: 't-items', + fieldIds: ['f-size'], + expr: { expression: inList('size', { List: { items: [{ A_Const: { sval: { sval: 'small' } } }, { A_Const: { sval: { sval: 'large' } } }] } }) }, + }, + ], + }, + }, + foreignKeyConstraint: { + foreignKeyConstraints: { + nodes: [ + { id: 'fk-list', tableId: 't-items', fieldIds: ['f-list'], refTableId: 't-lists', refFieldIds: ['l-id'], deleteAction: 'c', updateAction: 'a' }, + { id: 'fk-owner', tableId: 't-items', fieldIds: ['f-owner'], refTableId: 't-users', refFieldIds: ['u-id'], deleteAction: 'n', updateAction: 'a' }, + { id: 'fk-parent', tableId: 't-items', fieldIds: ['f-parent'], refTableId: 't-items', refFieldIds: ['f-id'], deleteAction: 'r', updateAction: 'a' }, + { id: 'fk-a', tableId: 't-items', fieldIds: ['f-a'], refTableId: 't-lists', refFieldIds: ['l-id'], deleteAction: 'a', updateAction: 'a' }, + { id: 'fk-d', tableId: 't-items', fieldIds: ['f-d'], refTableId: 't-lists', refFieldIds: ['l-id'], deleteAction: 'd', updateAction: 'c' }, + ], + }, + }, + policy: { + policies: { + nodes: [ + ...['select', 'insert', 'update', 'delete'].map((privilege) => ({ + id: `p-items-${privilege}`, tableId: 't-items', policyType: 'AuthzAppMemberOwner', privilege, + granteeName: 'authenticated', permissive: true, disabled: false, data: { owner_field: 'owner_id' }, + })), + { + id: 'p-items-ro', tableId: 't-items', policyType: 'AuthzNotReadOnly', privilege: 'update', + granteeName: 'authenticated', permissive: false, disabled: false, data: {}, + }, + ...['select', 'insert', 'update', 'delete'].map((privilege) => ({ + id: `p-lists-${privilege}`, tableId: 't-lists', policyType: 'AuthzAppMembership', privilege, + granteeName: 'authenticated', permissive: true, disabled: false, data: {}, + })), + ], + }, + }, + ...overrides, + }; + const model = (name: string) => ({ + findMany: (args: Record) => { + calls.push({ model: name, args }); + return { + execute: async () => { + const where = args.where as { id?: { in?: string[] } } | undefined; + // The second field read: the columns a foreign key points at outside the APP tables. + if (name === 'field' && where?.id?.in) { + return { ok: true, data: { fields: { nodes: [{ id: 'u-id', name: 'id' }] } }, errors: undefined }; + } + const data = rows[name]; + return data && (data as { ok?: boolean }).ok === false + ? data + : { ok: true, data, errors: undefined }; + }, + }; + }, + }); + const api = Object.fromEntries( + ['database', 'table', 'field', 'primaryKeyConstraint', 'uniqueConstraint', 'checkConstraint', 'foreignKeyConstraint', 'policy'].map( + (name) => [name, model(name)], + ), + ); + return { api, calls }; + } + + const contextWith = (api: unknown) => ({ api, databaseId: 'db-1' }) as unknown as ProjectContext; + + it('reads the hash first, then only APP tables, resolving keys to tables outside them', async () => { + const { api, calls } = fakeApi(); + + const model = await readSchemaModel(contextWith(api)); + + expect(calls[0].model).toBe('database'); + expect(model.hash).toBe('h1'); + expect(model.tables.map((t) => t.name)).toEqual(['items', 'lists']); + const items = model.tables[0]; + expect(items.foreignKeys.find((fk) => fk.id === 'fk-owner')).toMatchObject({ + columns: ['owner_id'], + targetTable: 'users', + targetCategory: 'CORE', + targetColumns: ['id'], + onDelete: 'set_null', + }); + expect(items.foreignKeys.map((fk) => fk.onDelete)).toEqual(['cascade', 'set_null', 'restrict', 'no_action', 'set_default']); + expect(items.foreignKeys.find((fk) => fk.id === 'fk-d')?.onUpdate).toBe('cascade'); + }); + + it('gives every field with its category, keys, defaults and allowed values', async () => { + const { api } = fakeApi(); + + const [items, lists] = (await readSchemaModel(contextWith(api))).tables; + const column = (name: string) => items.columns.find((c) => c.name === name); + + expect(items.columns.map((c) => c.name)).toEqual([ + 'id', 'name', 'status', 'size', 'code', 'list_id', 'owner_id', 'parent_id', 'a_id', 'd_id', + ]); + expect(column('id')).toMatchObject({ isPrimaryKey: true, isUnique: false, type: 'uuid', default: { kind: 'generated', fn: 'uuidv7' } }); + expect(column('status')).toMatchObject({ + category: 'MODULE', + allowedValues: ['active', 'archived'], + default: { kind: 'value', value: 'active' }, + }); + expect(column('size')).toMatchObject({ allowedValues: ['small', 'large'] }); + expect(column('code')).toMatchObject({ isUnique: true, type: 'citext' }); + expect(column('name')).toMatchObject({ isUnique: false, isRequired: true }); + expect(items.uniqueConstraints).toEqual([ + { id: 'u-code', columns: ['code'] }, + { id: 'u-pair', columns: ['name', 'list_id'] }, + ]); + expect(items.access).toBe('private'); + expect(items.policies.map((p) => p.type)).not.toContain('AuthzNotReadOnly'); + expect(lists.access).toBe('shared'); + }); + + it('throws instead of returning a partial model when one read fails', async () => { + const { api } = fakeApi({ + policy: { ok: false, data: null, errors: [{ message: 'permission denied for table policy' }] }, + }); + + const read = readSchemaModel(contextWith(api)); + + await expect(read).rejects.toBeInstanceOf(SchemaReadError); + await expect(read).rejects.toThrow(/policies: permission denied/); + }); +}); diff --git a/agentic/db-tools/src/schema-model.ts b/agentic/db-tools/src/schema-model.ts new file mode 100644 index 0000000000..c34fb7e687 --- /dev/null +++ b/agentic/db-tools/src/schema-model.ts @@ -0,0 +1,436 @@ +// The live schema of a project's app tables, read from the metaschema in one +// piece: every APP table with every field (whatever its category), keys, +// checks, foreign keys and policies. The ids are the metaschema's own, so they +// stay put across renames. + +import { isInternalPolicy } from '@agentic-kit/harness'; +import type { QueryResult } from '@constructive-io/graphql-query/runtime'; + +import type { ProjectContext } from './context'; +import { errorsText, type GraphQLError } from './graphql-http'; +import { columnDefault, fkAction, normalizeType, readCheck } from './schema-model/normalize'; + +export type ObjectCategory = 'APP' | 'AUTH' | 'CAPABILITIES' | 'CORE' | 'MEMBERSHIPS' | 'MODULE'; + +export type ColumnDefault = + | { kind: 'value'; value: string | number | boolean | null } + | { kind: 'now' } + | { kind: 'today' } + /** A generated id: `uuidv7()`, `gen_random_uuid()`, … */ + | { kind: 'generated'; fn: string } + /** Anything else; a planner should leave the column's default alone. */ + | { kind: 'expression'; text: string }; + +export type ModelColumn = { + id: string; + name: string; + /** A status column made by DataStatusField is 'MODULE'. */ + category: ObjectCategory; + order: number; + /** Normalised (`integer`, `timestamptz`, `email`, …) plus `[]` per array dimension. */ + type: string; + rawType: Record | null; + isRequired: boolean; + default: ColumnDefault | null; + /** The values an IN-list check allows. `_meta` stays the enum source; these spot disagreement. */ + allowedValues?: string[]; + /** True when some other check (range, pattern, …) applies too. */ + hasOtherCheck?: boolean; + isPrimaryKey: boolean; + /** True when a single-column unique constraint covers it. */ + isUnique: boolean; + /** An identity or generated column. */ + generated: boolean; + label?: string; + description?: string; + smartTags?: Record; +}; + +export type FkAction = 'cascade' | 'set_null' | 'restrict' | 'no_action' | 'set_default'; + +export type ModelForeignKey = { + id: string; + name?: string; + columnIds: string[]; + columns: string[]; + targetTableId: string; + targetTable: string; + targetCategory: ObjectCategory | null; + targetColumns: string[]; + onDelete: FkAction; + onUpdate: FkAction; +}; + +export type ModelPolicy = { + id: string; + type: string; + privilege: string; + role: string; + permissive: boolean; + disabled: boolean; + data: Record | null; + withCheck?: Record | null; +}; + +export type TableAccess = 'shared' | 'private' | 'custom'; + +export type ModelTable = { + id: string; + name: string; + category: ObjectCategory; + label?: string; + columns: ModelColumn[]; + foreignKeys: ModelForeignKey[]; + uniqueConstraints: { id: string; columns: string[] }[]; + access: TableAccess; + /** The table's policies, internal ones left out. */ + policies: ModelPolicy[]; +}; + +export type SchemaModel = { + databaseId: string; + /** The schema hash, read before anything else; null on a backend without it. */ + hash: string | null; + /** APP tables, sorted by name. */ + tables: ModelTable[]; +}; + +/** A read failed, so there is no model: a partial one would look like columns dropped outside. */ +export class SchemaReadError extends Error { + constructor( + message: string, + readonly step: string, + readonly errors?: readonly GraphQLError[], + ) { + super(message); + this.name = 'SchemaReadError'; + } +} + +// ---- access ------------------------------------------------------------ + +const CRUD = ['select', 'insert', 'update', 'delete']; +const OWNER_FIELD = 'owner_id'; + +// A parameter counts only when it narrows the policy: `is_admin: false` or +// `levels: []` is the same as leaving it out. +const isSet = (value: unknown): boolean => + Array.isArray(value) + ? value.length > 0 + : typeof value === 'object' && value !== null + ? Object.keys(value).length > 0 + : Boolean(value); + +function modeOf(policy: ModelPolicy): 'shared' | 'private' | null { + if (!policy.permissive || policy.role !== 'authenticated') return null; + if (policy.withCheck && isSet(policy.withCheck)) return null; + const data = policy.data ?? {}; + const extras = Object.keys(data).filter((key) => key !== 'owner_field' && isSet(data[key])); + if (extras.length > 0) return null; + if (policy.type === 'AuthzAppMembership') return isSet(data.owner_field) ? null : 'shared'; + if (policy.type === 'AuthzAppMemberOwner') { + const owner = data.owner_field; + return owner === undefined || owner === null || owner === OWNER_FIELD ? 'private' : null; + } + return null; +} + +/** + * The access mode a table's policies amount to. `private` is the app-member + * owner policy on `owner_id` and `shared` the plain app-membership policy, + * each for the authenticated role over select, insert, update and delete. + * Anything else, no policies included, is `custom`. Disabled and internal + * policies don't count. + */ +export function accessOf(policies: readonly ModelPolicy[]): TableAccess { + const live = policies.filter((policy) => !policy.disabled && !isInternalPolicy(policy.type)); + if (live.length === 0) return 'custom'; + const modes = new Set(live.map(modeOf)); + if (modes.size !== 1 || modes.has(null)) return 'custom'; + const covered = new Set( + live.flatMap((policy) => { + const privilege = (policy.privilege ?? '').toLowerCase(); + return privilege === 'all' ? CRUD : [privilege]; + }), + ); + return CRUD.every((privilege) => covered.has(privilege)) ? [...modes][0] : 'custom'; +} + +// ---- reader -------------------------------------------------------------- + +type Executable = { execute(): Promise> }; + +const nodesOf = (connection: { nodes?: (T | null)[] | null } | null | undefined): T[] => + (connection?.nodes ?? []).filter((node): node is T => Boolean(node)); + +async function read(step: string, query: Executable): Promise { + let result: QueryResult; + try { + result = await query.execute(); + } catch (err) { + throw new SchemaReadError( + `Could not read ${step}: ${err instanceof Error ? err.message : String(err)}`, + step, + ); + } + if (!result.ok) { + throw new SchemaReadError(`Could not read ${step}: ${errorsText(result.errors ?? [])}`, step, result.errors); + } + return result.data; +} + +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record) : null; + +const groupBy = (rows: T[]): Map => { + const map = new Map(); + for (const row of rows) { + const list = map.get(row.tableId) ?? []; + list.push(row); + map.set(row.tableId, list); + } + return map; +}; + +// The hash first: anything that changes the schema after this read moves it, +// so a caller comparing hashes later never trusts a model older than it looks. +async function readHash(context: ProjectContext): Promise { + try { + const data = await read( + 'the database', + context.api.database.findMany({ + select: { id: true, hash: true }, + where: { id: { equalTo: context.databaseId } }, + first: 1, + }), + ); + const node = nodesOf(data.databases)[0]; + if (!node) { + throw new SchemaReadError(`Database ${context.databaseId} was not found on this backend.`, 'the database'); + } + return node.hash ?? null; + } catch (err) { + // A backend from before Database.hash. + if (err instanceof SchemaReadError && (err.errors ?? []).some((e) => /\bhash\b/.test(e?.message ?? ''))) { + return null; + } + throw err; + } +} + +/** + * Read the APP tables of the context's database. All or nothing: any failed + * query throws `SchemaReadError`. + */ +export async function readSchemaModel(context: ProjectContext): Promise { + const { api, databaseId } = context; + const hash = await readHash(context); + + // Every table of the database, so a foreign key to a non-APP table (users) + // still names its target. + const tableData = await read( + 'tables', + api.table.findMany({ + select: { id: true, name: true, category: true, label: true }, + where: { databaseId: { equalTo: databaseId } }, + orderBy: ['NAME_ASC'], + }), + ); + const allTables = nodesOf(tableData.tables); + const tablesById = new Map(allTables.map((table) => [table.id, table])); + const appTables = allTables + .filter((table) => table.category === 'APP') + .sort((a, b) => a.name.localeCompare(b.name)); + if (appTables.length === 0) return { databaseId, hash, tables: [] }; + + const inApp = { tableId: { in: appTables.map((table) => table.id) } }; + const [fieldData, pkData, uniqueData, checkData, fkData, policyData] = await Promise.all([ + read( + 'fields', + api.field.findMany({ + select: { + id: true, + name: true, + tableId: true, + category: true, + fieldOrder: true, + type: true, + isRequired: true, + defaultValue: true, + chk: true, + chkExpr: true, + generationType: true, + identityGeneration: true, + label: true, + description: true, + smartTags: true, + }, + where: inApp, + }), + ), + read( + 'primary keys', + api.primaryKeyConstraint.findMany({ + select: { id: true, tableId: true, fieldIds: true }, + where: inApp, + }), + ), + read( + 'unique constraints', + api.uniqueConstraint.findMany({ + select: { id: true, tableId: true, fieldIds: true }, + where: inApp, + }), + ), + read( + 'check constraints', + api.checkConstraint.findMany({ + select: { id: true, tableId: true, fieldIds: true, expr: true }, + where: inApp, + }), + ), + read( + 'foreign keys', + api.foreignKeyConstraint.findMany({ + select: { + id: true, + name: true, + tableId: true, + fieldIds: true, + refTableId: true, + refFieldIds: true, + deleteAction: true, + updateAction: true, + }, + where: inApp, + }), + ), + read( + 'policies', + api.policy.findMany({ + select: { + id: true, + tableId: true, + policyType: true, + privilege: true, + granteeName: true, + permissive: true, + disabled: true, + data: true, + withCheck: true, + }, + where: inApp, + }), + ), + ]); + + const fields = nodesOf(fieldData.fields); + const fieldNames = new Map(fields.map((field) => [field.id, field.name])); + const foreignKeys = nodesOf(fkData.foreignKeyConstraints); + + // Columns a foreign key points at outside the APP tables (users.id). + const outside = [ + ...new Set(foreignKeys.flatMap((fk) => fk.refFieldIds ?? []).filter((id) => !fieldNames.has(id))), + ]; + if (outside.length > 0) { + const refData = await read( + 'referenced fields', + api.field.findMany({ + select: { id: true, name: true }, + where: { id: { in: outside } }, + }), + ); + for (const field of nodesOf(refData.fields)) fieldNames.set(field.id, field.name); + } + + const fieldsByTable = groupBy(fields); + const pksByTable = groupBy(nodesOf(pkData.primaryKeyConstraints)); + const uniquesByTable = groupBy(nodesOf(uniqueData.uniqueConstraints)); + const checksByTable = groupBy(nodesOf(checkData.checkConstraints)); + const fksByTable = groupBy(foreignKeys); + const policiesByTable = groupBy(nodesOf(policyData.policies)); + const names = (ids: string[] | null | undefined) => (ids ?? []).map((id) => fieldNames.get(id) ?? id); + + const tables = appTables.map((table): ModelTable => { + const pkIds = new Set((pksByTable.get(table.id) ?? []).flatMap((pk) => pk.fieldIds ?? [])); + const uniques = uniquesByTable.get(table.id) ?? []; + const uniqueIds = new Set( + uniques.filter((u) => (u.fieldIds ?? []).length === 1).map((u) => (u.fieldIds ?? [])[0]), + ); + const checks = checksByTable.get(table.id) ?? []; + + const columns = (fieldsByTable.get(table.id) ?? []) + .map((field, index): ModelColumn => { + const chk = asRecord(field.chk); + const sources = [ + chk && 'expression' in chk ? chk.expression : field.chk, + field.chkExpr, + ...checks.filter((c) => (c.fieldIds ?? []).length === 1 && c.fieldIds[0] === field.id).map((c) => c.expr), + ].map(readCheck); + const allowedValues = sources.find((source) => source.values)?.values; + const hasOtherCheck = sources.some((source) => source.other); + return { + id: field.id, + name: field.name, + category: field.category, + order: typeof field.fieldOrder === 'number' ? field.fieldOrder : index, + type: normalizeType(field.type), + rawType: asRecord(field.type), + isRequired: Boolean(field.isRequired), + default: columnDefault(field.defaultValue), + ...(allowedValues ? { allowedValues } : {}), + ...(hasOtherCheck ? { hasOtherCheck } : {}), + isPrimaryKey: pkIds.has(field.id), + isUnique: uniqueIds.has(field.id), + generated: Boolean(field.generationType || field.identityGeneration), + ...(field.label ? { label: field.label } : {}), + ...(field.description ? { description: field.description } : {}), + ...(field.smartTags ? { smartTags: field.smartTags } : {}), + }; + }) + .sort((a, b) => a.order - b.order || a.name.localeCompare(b.name)); + + const policies = (policiesByTable.get(table.id) ?? []) + .filter((policy) => !isInternalPolicy(policy.policyType)) + .map( + (policy): ModelPolicy => ({ + id: policy.id, + type: policy.policyType ?? '', + privilege: (policy.privilege ?? '').toLowerCase(), + role: policy.granteeName ?? '', + permissive: policy.permissive !== false, + disabled: policy.disabled === true, + data: asRecord(policy.data), + withCheck: asRecord(policy.withCheck), + }), + ); + + return { + id: table.id, + name: table.name, + category: table.category, + ...(table.label ? { label: table.label } : {}), + columns, + foreignKeys: (fksByTable.get(table.id) ?? []).map((fk): ModelForeignKey => { + const target = fk.refTableId ? tablesById.get(fk.refTableId) : undefined; + return { + id: fk.id, + ...(fk.name ? { name: fk.name } : {}), + columnIds: fk.fieldIds ?? [], + columns: names(fk.fieldIds), + targetTableId: fk.refTableId ?? '', + targetTable: target?.name ?? '', + targetCategory: target?.category ?? null, + targetColumns: names(fk.refFieldIds), + onDelete: fkAction(fk.deleteAction), + onUpdate: fkAction(fk.updateAction), + }; + }), + uniqueConstraints: uniques.map((u) => ({ id: u.id, columns: names(u.fieldIds) })), + access: accessOf(policies), + policies, + }; + }); + + return { databaseId, hash, tables }; +} diff --git a/agentic/db-tools/src/schema-model/normalize.ts b/agentic/db-tools/src/schema-model/normalize.ts new file mode 100644 index 0000000000..5d0df19e6c --- /dev/null +++ b/agentic/db-tools/src/schema-model/normalize.ts @@ -0,0 +1,252 @@ +// Pure readers for the metaschema's stored shapes: field types, structured +// defaults, check expressions (Postgres AST JSON) and foreign key actions. + +import { fieldTypeToTypeName, toFieldDefault } from '@agentic-kit/harness'; + +import type { ColumnDefault, FkAction } from '../schema-model'; + +const TYPE_ALIASES: Record = { + int: 'integer', + int4: 'integer', + serial: 'integer', + serial4: 'integer', + int8: 'bigint', + bigserial: 'bigint', + serial8: 'bigint', + int2: 'smallint', + smallserial: 'smallint', + bool: 'boolean', + decimal: 'numeric', + float4: 'real', + float: 'double precision', + float8: 'double precision', + 'timestamp with time zone': 'timestamptz', + 'timestamp without time zone': 'timestamp', + 'time with time zone': 'timetz', + 'time without time zone': 'time', + 'character varying': 'varchar', + character: 'char', + bpchar: 'char', +}; + +/** A field's type as one name (`integer`, `timestamptz`, `email`, …) plus `[]` per array dimension. */ +export function normalizeType(rawType: unknown): string { + const name = fieldTypeToTypeName(rawType).trim().toLowerCase(); + const dims = /(\[\])*$/.exec(name)?.[0] ?? ''; + const base = name.slice(0, name.length - dims.length).trim(); + return (TYPE_ALIASES[base] ?? base) + dims; +} + +const NOW_KEYWORDS = new Set(['CURRENT_TIMESTAMP', 'LOCALTIMESTAMP']); +const NOW_FUNCTIONS = new Set([ + 'now', + 'current_timestamp', + 'statement_timestamp', + 'transaction_timestamp', + 'clock_timestamp', +]); + +type StructuredDefault = Partial<{ + value: unknown; + function: string; + args: unknown[]; + cast: unknown; + sql_keyword: string; + operator: string; + left: unknown; + right: unknown; +}>; + +/** + * A field's stored default as one of the shapes a planner can compare: a + * literal, the current time or date, a generated id, or an expression it + * should leave alone. `DEFAULT NULL` is no default. + */ +export function columnDefault(raw: unknown): ColumnDefault | null { + if (raw === null || raw === undefined) return null; + if (typeof raw === 'number' || typeof raw === 'boolean') return { kind: 'value', value: raw }; + if (typeof raw === 'string') { + const parsed = toFieldDefault(raw); + return parsed ? fromStructured(parsed as StructuredDefault) : null; + } + if (typeof raw === 'object' && !Array.isArray(raw)) return fromStructured(raw as StructuredDefault); + return { kind: 'expression', text: JSON.stringify(raw) }; +} + +function fromStructured(def: StructuredDefault): ColumnDefault | null { + if (typeof def.sql_keyword === 'string') { + const keyword = def.sql_keyword.toUpperCase(); + if (NOW_KEYWORDS.has(keyword)) return { kind: 'now' }; + if (keyword === 'CURRENT_DATE') return { kind: 'today' }; + return { kind: 'expression', text: keyword }; + } + if (typeof def.function === 'string') { + const fn = def.function.toLowerCase(); + const bare = !(Array.isArray(def.args) && def.args.length > 0) && def.cast == null; + if (bare && NOW_FUNCTIONS.has(fn)) return { kind: 'now' }; + if (bare && fn === 'current_date') return { kind: 'today' }; + if (bare && fn.includes('uuid')) return { kind: 'generated', fn: def.function }; + return { kind: 'expression', text: renderDefault(def) }; + } + if ('value' in def && def.operator === undefined) { + const value = def.value; + if (value === null || value === undefined) return null; + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return { kind: 'value', value }; + } + return { kind: 'expression', text: JSON.stringify(value) }; + } + return { kind: 'expression', text: renderDefault(def) }; +} + +const sqlLiteral = (value: unknown): string => + typeof value === 'string' ? `'${value.replace(/'/g, "''")}'` : JSON.stringify(value); + +// SQL-ish text for a default no other shape fits; it is shown, never run. +function renderDefault(def: unknown): string { + if (def === null || def === undefined) return 'NULL'; + if (typeof def !== 'object') return sqlLiteral(def); + const d = def as StructuredDefault; + let text: string; + if (typeof d.sql_keyword === 'string') text = d.sql_keyword; + else if (typeof d.function === 'string') { + text = `${d.function}(${Array.isArray(d.args) ? d.args.map(renderDefault).join(', ') : ''})`; + } else if (typeof d.operator === 'string') text = `${renderDefault(d.left)} ${d.operator} ${renderDefault(d.right)}`; + else if ('value' in d) text = d.value === null ? 'NULL' : sqlLiteral(d.value); + else return JSON.stringify(def); + const cast = fieldTypeToTypeName(d.cast); + return cast ? `${text}::${cast}` : text; +} + +// ---- check expressions ------------------------------------------------- + +type Node = Record; + +const isObject = (value: unknown): value is Node => + typeof value === 'object' && value !== null && !Array.isArray(value); + +/** A node's list: a plain array, or a `{ List: { items } }` wrapper. */ +function listItems(value: unknown): unknown[] | undefined { + if (Array.isArray(value)) return value; + if (isObject(value)) { + const list = isObject(value.List) ? value.List : value; + if (Array.isArray(list.items)) return list.items; + } + return undefined; +} + +// `{ expression: }` is how the metaschema stores a raw AST on +// field.chk and check_constraint.expr. +function unwrapExpression(value: unknown): unknown { + return isObject(value) && 'expression' in value ? value.expression : value; +} + +function unwrapCast(value: unknown): unknown { + let node = value; + while (isObject(node) && isObject(node.TypeCast)) node = node.TypeCast.arg; + return node; +} + +const stringOf = (value: unknown): string | undefined => { + if (typeof value === 'string') return value; + if (isObject(value)) { + if (typeof value.sval === 'string') return value.sval; + if (typeof value.str === 'string') return value.str; + if (isObject(value.String)) return stringOf(value.String); + } + return undefined; +}; + +/** The operator of an `A_Expr`: the last name in its `name` list. */ +function operatorOf(name: unknown): string | undefined { + const items = listItems(name); + return items && items.length > 0 ? stringOf(items[items.length - 1]) : undefined; +} + +/** An `A_Const` as text: `sval` plain or wrapped, `ival`, `fval`, `boolval`. */ +function constValue(value: unknown): string | undefined { + const node = unwrapCast(value); + if (!isObject(node) || !isObject(node.A_Const)) return undefined; + const c = node.A_Const; + if (c.isnull === true) return undefined; + if ('sval' in c) return stringOf(c.sval); + if ('ival' in c) { + // The parser's JSON leaves out zero: `{ ival: {} }` is 0. + const ival = isObject(c.ival) ? (c.ival.ival ?? 0) : c.ival; + return typeof ival === 'number' ? String(ival) : undefined; + } + if ('fval' in c) { + const fval = isObject(c.fval) ? c.fval.fval : c.fval; + return typeof fval === 'string' || typeof fval === 'number' ? String(fval) : undefined; + } + if ('boolval' in c) { + const boolval = isObject(c.boolval) ? (c.boolval.boolval ?? false) : c.boolval; + return typeof boolval === 'boolean' ? String(boolval) : undefined; + } + if (isObject(c.val)) { + const val = c.val; + if (isObject(val.String)) return stringOf(val.String); + if (isObject(val.Integer)) return String(val.Integer.ival ?? 0); + } + return undefined; +} + +/** The values of `col IN (…)` or `col = ANY (ARRAY[…])`; undefined for anything else. */ +function inListValues(value: unknown): string[] | undefined { + if (!isObject(value) || !isObject(value.A_Expr)) return undefined; + const expr = value.A_Expr; + if (operatorOf(expr.name) !== '=') return undefined; + let items: unknown[] | undefined; + if (expr.kind === 'AEXPR_IN') { + items = listItems(expr.rexpr); + } else if (expr.kind === 'AEXPR_OP_ANY') { + const array = unwrapCast(expr.rexpr); + items = isObject(array) && isObject(array.A_ArrayExpr) ? listItems(array.A_ArrayExpr.elements) : undefined; + } + if (!items || items.length === 0) return undefined; + const values = items.map(constValue); + return values.every((v): v is string => v !== undefined) ? values : undefined; +} + +export type CheckRead = { values?: string[]; other: boolean }; + +/** + * What one check expression allows. An IN-list gives `values`; anything else + * it can't read (ranges, patterns, other columns) sets `other`. In an AND, + * one IN-list still gives its values, beside `other` for the rest. + */ +export function readCheck(raw: unknown): CheckRead { + const node = unwrapExpression(raw); + if (node === null || node === undefined) return { other: false }; + if (isObject(node) && isObject(node.BoolExpr) && /AND/i.test(String(node.BoolExpr.boolop ?? ''))) { + const parts = (listItems(node.BoolExpr.args) ?? []).map(readCheck); + const lists = parts.filter((part) => part.values); + return { + values: lists.length === 1 ? lists[0].values : undefined, + other: lists.length > 1 || parts.some((part) => part.other), + }; + } + const values = inListValues(node); + return values ? { values, other: false } : { other: true }; +} + +// ---- foreign keys ----------------------------------------------------- + +const FK_ACTIONS: Record = { + c: 'cascade', + n: 'set_null', + r: 'restrict', + a: 'no_action', + d: 'set_default', + cascade: 'cascade', + set_null: 'set_null', + restrict: 'restrict', + no_action: 'no_action', + set_default: 'set_default', +}; + +/** A stored action code (`c n r a d`) as a name; Postgres' default is no action. */ +export function fkAction(code: unknown): FkAction { + const key = String(code ?? '').trim().toLowerCase().replace(/\s+/g, '_'); + return FK_ACTIONS[key] ?? 'no_action'; +} From cf13374846e423813dd52cd500b7dfceb55fd255 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:36:26 +0700 Subject: [PATCH 06/14] feat(db-tools): tell suspended, missing and unreachable databases apart probeDatabase returns found (with the schema hash), suspended, missing or unreachable with a cause. Only an empty answer is missing: an auth rejection, a failing backend or an error the probe doesn't know are unreachable, never a reason to reprovision. A backend without hash gets one retry with the older fields, and probeBoundDatabase probes with the host's account. resolveProjectContext adds db-suspended, the unreachable cause and context.hash. The host gains reprovisionHint, toolReprovision and persistBinding. --- agentic/db-tools/__tests__/context.test.ts | 54 ++++- agentic/db-tools/__tests__/db-probe.test.ts | 136 +++++++++++++ .../__tests__/provision-resume.test.ts | 4 +- agentic/db-tools/src/context.ts | 47 ++++- agentic/db-tools/src/db-probe.ts | 184 ++++++++++++------ agentic/db-tools/src/host.ts | 48 +++++ .../db-tools/src/tools/provision-database.ts | 10 +- 7 files changed, 411 insertions(+), 72 deletions(-) create mode 100644 agentic/db-tools/__tests__/db-probe.test.ts diff --git a/agentic/db-tools/__tests__/context.test.ts b/agentic/db-tools/__tests__/context.test.ts index 7ca0aa5aca..ac2340d938 100644 --- a/agentic/db-tools/__tests__/context.test.ts +++ b/agentic/db-tools/__tests__/context.test.ts @@ -38,7 +38,7 @@ let dir: string; beforeEach(() => { dir = mkdtempSync(path.join(os.tmpdir(), 'pi-context-')); - mockProbe.mockResolvedValue({ outcome: 'found', name: 'myapp', ownerId: 'u1' }); + mockProbe.mockResolvedValue({ status: 'found', databaseId: 'db-1', name: 'myapp', ownerId: 'u1', hash: 'h1' }); mockCreateClient.mockReturnValue(schemaClient); configureHost({ account: () => ({ userId: 'u1', accessToken: 'account-bearer' }), @@ -134,6 +134,58 @@ describe('resolveProjectContext sources', () => { }); }); +describe('resolveProjectContext binding health', () => { + const source = fromEnvironment({ + [`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key', + [`${CONTEXT_ENV_PREFIX}DATABASE_ID`]: 'db-1', + }); + + it("carries the database's hash from the probe", async () => { + const resolved = await resolveProjectContext(source); + + expect(resolved.context?.hash).toBe('h1'); + }); + + it('reports a suspended database as db-suspended, never as missing', async () => { + mockProbe.mockResolvedValue({ + status: 'suspended', + databaseId: 'db-1', + ownerId: 'u1', + suspendedAt: '2026-10-01T00:00:00Z', + }); + + const resolved = await resolveProjectContext(source); + + expect(resolved).toMatchObject({ context: null, code: 'db-suspended' }); + expect(resolved.reason).not.toMatch(/reprovision/i); + }); + + it('reports an unknown backend error as unreachable with its cause', async () => { + mockProbe.mockResolvedValue({ status: 'unreachable', databaseId: 'db-1', cause: 'error', detail: 'boom' }); + + const resolved = await resolveProjectContext(source); + + expect(resolved).toMatchObject({ context: null, code: 'backend-unreachable', cause: 'error' }); + expect(resolved.reason).toMatch(/answered with an error/); + }); + + it("gives the host's reprovision advice for a missing database instead of the tool's", async () => { + configureHost({ + account: () => ({ userId: 'u1', accessToken: 'account-bearer' }), + backendConfig: () => null, + reprovisionHint: 'Approve the rebuild card to re-provision.', + toolReprovision: false, + }); + mockProbe.mockResolvedValue({ status: 'missing', databaseId: 'db-1' }); + + const resolved = await resolveProjectContext(source); + + expect(resolved.code).toBe('db-missing'); + expect(resolved.reason).toMatch(/Approve the rebuild card/); + expect(resolved.reason).not.toMatch(/reprovision: true/); + }); +}); + describe('resolveProjectContext schema resolution', () => { const source = fromEnvironment({ [`${CONTEXT_ENV_PREFIX}ACCESS_TOKEN`]: 'project-key', diff --git a/agentic/db-tools/__tests__/db-probe.test.ts b/agentic/db-tools/__tests__/db-probe.test.ts new file mode 100644 index 0000000000..2871a078ff --- /dev/null +++ b/agentic/db-tools/__tests__/db-probe.test.ts @@ -0,0 +1,136 @@ +// The network boundary is postGraphQL; classification stays real. +jest.mock('../src/graphql-http', () => ({ + ...jest.requireActual('../src/graphql-http'), + postGraphQL: jest.fn(), +})); + +import { probeBoundDatabase, probeDatabase } from '../src/db-probe'; +import { type HttpGraphQLResponse, postGraphQL } from '../src/graphql-http'; +import { configureHost } from '../src/host'; + +const mockPost = postGraphQL as jest.MockedFunction; + +const answer = (nodes: Record[]): HttpGraphQLResponse => ({ + httpStatus: 200, + data: { databases: { nodes } }, + errors: [], +}); +const failed = ( + message: string, + extensions: Record = {}, + httpStatus: number | null = 200, +): HttpGraphQLResponse => ({ httpStatus, data: null, errors: [{ message, extensions }] }); + +const probe = () => + probeDatabase({ + endpoint: 'http://api.localhost:3000/graphql', + bearer: 'account-token', + databaseId: 'db-1', + signInHint: 'Sign in from Settings.', + }); + +beforeEach(() => { + mockPost.mockReset(); +}); + +describe('probeDatabase', () => { + it('finds a live database with its hash', async () => { + mockPost.mockResolvedValue(answer([{ id: 'db-1', name: 'myapp', ownerId: 'u1', hash: 'h1', suspendedAt: null }])); + + await expect(probe()).resolves.toEqual({ + status: 'found', + databaseId: 'db-1', + name: 'myapp', + ownerId: 'u1', + hash: 'h1', + }); + }); + + it('tells a suspended database apart from a live one', async () => { + mockPost.mockResolvedValue( + answer([ + { id: 'db-1', name: 'myapp', ownerId: 'u1', hash: 'h1', suspendedAt: '2026-10-01T00:00:00Z', suspendedReason: 'billing' }, + ]), + ); + + await expect(probe()).resolves.toMatchObject({ + status: 'suspended', + suspendedAt: '2026-10-01T00:00:00Z', + suspendedReason: 'billing', + }); + }); + + it('reports missing only when the backend answers with no such database', async () => { + mockPost.mockResolvedValue(answer([])); + + await expect(probe()).resolves.toEqual({ status: 'missing', databaseId: 'db-1' }); + }); + + it('never reads an unknown GraphQL error as missing', async () => { + mockPost.mockResolvedValue(failed('relation "metaschema_public.database" does not exist')); + + await expect(probe()).resolves.toMatchObject({ status: 'unreachable', cause: 'error' }); + }); + + it.each([ + ['a 403 FORBIDDEN', failed('Forbidden', { code: 'FORBIDDEN', httpStatus: 403 }, 403)], + ['a 401', failed('HTTP 401: Unauthorized', { code: 'HTTP_ERROR', httpStatus: 401 }, 401)], + ])('reads %s as a rejected account credential, with the sign-in hint', async (_case, response) => { + mockPost.mockResolvedValue(response); + + const result = await probe(); + + expect(result).toMatchObject({ status: 'unreachable', cause: 'auth' }); + expect(result.status === 'unreachable' && result.detail).toMatch(/Sign in from Settings\./); + }); + + it.each([ + ['a 5xx', failed('HTTP 502: Bad Gateway', { code: 'HTTP_ERROR', httpStatus: 502 }, 502), 'server'], + ['a thrown fetch', failed('fetch failed', { code: 'NETWORK' }, null), 'network'], + ])('reads %s as unreachable', async (_case, response, cause) => { + mockPost.mockResolvedValue(response); + + await expect(probe()).resolves.toMatchObject({ status: 'unreachable', cause }); + }); + + it('falls back to the older fields on a backend without hash, once', async () => { + mockPost + .mockResolvedValueOnce(failed('Cannot query field "hash" on type "Database".')) + .mockResolvedValueOnce(answer([{ id: 'db-1', name: 'myapp', ownerId: 'u1' }])); + + const result = await probe(); + + expect(result).toEqual({ status: 'found', databaseId: 'db-1', name: 'myapp', ownerId: 'u1', hash: undefined }); + expect(mockPost).toHaveBeenCalledTimes(2); + expect(mockPost.mock.calls[1][0].query).not.toMatch(/hash|suspended/); + }); +}); + +describe('probeBoundDatabase', () => { + it("sends the account bearer only to the host's control endpoint", async () => { + configureHost({ + account: () => ({ userId: 'u1', accessToken: 'account-token' }), + backendConfig: () => ({ apiEndpoint: 'http://api.host.test/graphql' }), + }); + mockPost.mockResolvedValue(answer([{ id: 'db-1', ownerId: 'u1' }])); + + await expect(probeBoundDatabase('db-1')).resolves.toMatchObject({ status: 'found' }); + expect(mockPost).toHaveBeenCalledWith( + expect.objectContaining({ + endpoint: 'http://api.host.test/graphql', + headers: { Authorization: 'Bearer account-token' }, + }), + ); + }); + + it('is unreachable with cause auth when signed out, without asking the backend', async () => { + configureHost({ account: () => null, backendConfig: () => null, signInHint: 'Sign in from Settings.' }); + + await expect(probeBoundDatabase('db-1')).resolves.toMatchObject({ + status: 'unreachable', + cause: 'auth', + detail: expect.stringMatching(/Sign in from Settings\./), + }); + expect(mockPost).not.toHaveBeenCalled(); + }); +}); diff --git a/agentic/db-tools/__tests__/provision-resume.test.ts b/agentic/db-tools/__tests__/provision-resume.test.ts index f23d56ae7d..19ba4669d6 100644 --- a/agentic/db-tools/__tests__/provision-resume.test.ts +++ b/agentic/db-tools/__tests__/provision-resume.test.ts @@ -68,7 +68,7 @@ beforeEach(() => { mockRequestDatabase.mockReset(); mockFindTicket.mockReset(); mockProbe.mockReset(); - mockProbe.mockResolvedValue({ outcome: 'found', ownerId: 'user-1' }); + mockProbe.mockResolvedValue({ status: 'found', databaseId: 'db-1', ownerId: 'user-1' }); configureHost({ account: () => ({ userId: 'user-1', accessToken: 'account-token' }), backendConfig: () => ({ apiEndpoint: API, modulesEndpoint: 'http://modules.localhost:3000/graphql' }), @@ -174,7 +174,7 @@ describe('provision_database pending tickets', () => { it('does not bind a database the signed-in account does not own', async () => { writeEnv(`KEEP=me\n${record()}`); mockFindTicket.mockResolvedValue(ticketRead(completed('ticket-1', 'db-1'))); - mockProbe.mockResolvedValue({ outcome: 'found', ownerId: 'user-2' }); + mockProbe.mockResolvedValue({ status: 'found', databaseId: 'db-1', ownerId: 'user-2' }); const result = await provision('myapp'); diff --git a/agentic/db-tools/src/context.ts b/agentic/db-tools/src/context.ts index dc9cd16426..69151bc80a 100644 --- a/agentic/db-tools/src/context.ts +++ b/agentic/db-tools/src/context.ts @@ -4,8 +4,8 @@ import path from 'node:path'; import { api, auth, modules } from '@constructive-io/sdk'; import { parseDotenv } from '12factor-env/dotenv'; -import { probeDatabase } from './db-probe'; -import { DEFAULT_DATA_TOKEN_SKEW_MS, getHost } from './host'; +import { probeDatabase, type UnreachableCause } from './db-probe'; +import { DEFAULT_DATA_TOKEN_SKEW_MS, getHost, reprovisionAdvice } from './host'; const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql'; const DEFAULT_MODULES_ENDPOINT = 'http://modules.localhost:3000/graphql'; @@ -26,12 +26,16 @@ export type ProjectContext = { // Per-DB data/CRUD endpoint (api-.localhost). Empty when DATABASE_NAME is // absent — record tools need it; schema tools use `api` and ignore it. dataEndpoint: string; + // The database's schema hash as the binding probe read it; it changes on + // every schema change. Undefined on a backend that doesn't serve it. + hash?: string; }; export type ProjectContextFailureCode = | 'no-env' | 'missing-credentials' | 'db-missing' + | 'db-suspended' | 'account-mismatch' | 'backend-unreachable' | 'schema-unresolved'; @@ -40,6 +44,8 @@ export type ResolveResult = { context: ProjectContext | null; reason: string; code?: ProjectContextFailureCode; + /** Why the backend couldn't answer, with code 'backend-unreachable'. */ + cause?: UnreachableCause; }; // The project values pi needs, and where they come from. A scaffolded project @@ -182,17 +188,18 @@ export async function resolveProjectContext( databaseId, signInHint: host.signInHint, }); - if (probe.outcome === 'unreachable') { + if (probe.status === 'unreachable') { return { context: null, - reason: `Could not reach the Constructive backend at ${controlApiEndpoint} (${probe.detail}). Check it is running, then retry.`, + reason: unreachableReason(controlApiEndpoint, probe.cause, probe.detail), code: 'backend-unreachable', + cause: probe.cause, }; } - if (probe.outcome === 'missing') { + if (probe.status === 'missing') { return { context: null, - 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.`, + reason: `The bound database (DATABASE_ID=${databaseId}) no longer exists on this backend, or this account lost access to it. ${reprovisionAdvice(host)}`, code: 'db-missing', }; } @@ -212,11 +219,22 @@ export async function resolveProjectContext( ) { return { context: null, - 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.`, + reason: `The bound database (DATABASE_ID=${databaseId}) was provisioned under a different account than the one signed in. ${reprovisionAdvice(host)}`, code: 'account-mismatch', }; } + // A suspended database still holds its data, so it is never a reason to + // reprovision: nothing can read or change it until the hold is lifted. + if (probe.status === 'suspended') { + const why = probe.suspendedReason ? ` (${probe.suspendedReason})` : ''; + return { + context: null, + 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.`, + code: 'db-suspended', + }; + } + const databaseName = env.DATABASE_NAME || probe.name || ''; const ownerId = probe.ownerId || env.OWNER_ID || undefined; const dataEndpoint = databaseName @@ -241,11 +259,26 @@ export async function resolveProjectContext( databaseName, accessToken, dataEndpoint, + hash: probe.hash, }, reason: '', }; } +function unreachableReason(endpoint: string, cause: UnreachableCause, detail: string): string { + switch (cause) { + case 'auth': + // The probe's detail says the credential was rejected and how to sign in. + return `Could not check the bound database on the Constructive backend at ${endpoint}: ${detail}`; + case 'error': + 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.`; + case 'server': + return `The Constructive backend at ${endpoint} is failing (${detail}). Check it is running, then retry.`; + default: + return `Could not reach the Constructive backend at ${endpoint} (${detail}). Check it is running, then retry.`; + } +} + const needsAuthReason = (): string => `Not signed in to the app database yet. ${ getHost().signInHint ?? diff --git a/agentic/db-tools/src/db-probe.ts b/agentic/db-tools/src/db-probe.ts index 1d05fb4367..7083e2ba24 100644 --- a/agentic/db-tools/src/db-probe.ts +++ b/agentic/db-tools/src/db-probe.ts @@ -1,76 +1,146 @@ -import { api } from '@constructive-io/sdk'; +import { classifyBackendErrors, errorsText, type GraphQLError, postGraphQL } from './graphql-http'; +import { getHost } from './host'; + +const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql'; + +export type UnreachableCause = 'network' | 'server' | 'auth' | 'error'; export type DatabaseProbe = - | { outcome: 'found'; name?: string; ownerId?: string } - | { outcome: 'missing' } - | { outcome: 'unreachable'; detail: string }; + | { status: 'found'; databaseId: string; name?: string; ownerId?: string; hash?: string } + | { + status: 'suspended'; + databaseId: string; + name?: string; + ownerId?: string; + hash?: string; + suspendedAt: string; + suspendedReason?: string; + } + | { status: 'missing'; databaseId: string } + | { status: 'unreachable'; databaseId: string; cause: UnreachableCause; detail: string }; -type ProbePayload = { - databases: { - nodes: { id: string; name: string | null; ownerId: string | null }[]; - } | null; +type ProbeNode = { + id: string; + name?: string | null; + ownerId?: string | null; + hash?: string | null; + suspendedAt?: string | null; + suspendedReason?: string | null; }; -export type ProbeExecutor = { - execute( - document: string, - variables?: Record, - ): Promise< - | { ok: true; data: T; errors: undefined } - | { ok: false; data: null; errors: { message: string }[] } - >; -}; +type ProbePayload = { databases: { nodes: ProbeNode[] } | null }; + +const FIELDS = 'id name ownerId hash suspendedAt suspendedReason'; +// A backend from before `hash` and the suspension columns. +const BASIC_FIELDS = 'id name ownerId'; +const NEWER_FIELD_RE = /\b(hash|suspended_?at|suspended_?reason)\b/i; -const AUTH_ERROR_RE = /unauthenticated|unauthorized|permission denied|jwt|http 401|http 403/i; +const document = (databaseId: string, fields: string) => ` + query ProbeDatabase { + databases(where: { id: { equalTo: ${JSON.stringify(databaseId)} } }, first: 1) { + nodes { ${fields} } + } + }`; -// Liveness probe for a bound database. The split drives recovery: 'missing' -// means the backend answered and the binding is dead (database deleted, -// backend refreshed) — recovery is reprovision. 'unreachable' means no usable -// answer (backend down, network error, rejected credential) — recovery is -// retry/re-sign-in, never reprovision. +// Liveness probe for a bound database, on the platform api. Only 'missing' +// means the binding is dead: the backend answered and the account can't see +// the database (deleted, or access to it lost). 'suspended' means it exists +// and is held, so its data must not be replaced. 'unreachable' is any answer +// that says nothing about the database (no response, a failing backend, a +// rejected account credential, an error this probe doesn't know); recovery is +// a retry or a sign-in, never a reprovision. // // The probe authenticates with the ACCOUNT bearer, not the project's .env key: // the platform api rejects per-database keys, and the singular `database(id:)` // field is gone from the current contract, so the lookup goes through the -// `databases` collection filter via the SDK's raw FetchAdapter. +// `databases` collection filter. export async function probeDatabase(args: { endpoint: string; bearer: string; databaseId: string; - executor?: ProbeExecutor; signInHint?: string; + post?: typeof postGraphQL; }): Promise { - const executor = - args.executor ?? - new api.FetchAdapter(args.endpoint, { Authorization: `Bearer ${args.bearer}` }); - const document = ` - query ProbeDatabase { - databases(where: { id: { equalTo: ${JSON.stringify(args.databaseId)} } }) { - nodes { id name ownerId } - } - }`; - try { - const result = await executor.execute(document); - if (result.ok) { - const node = result.data.databases?.nodes?.[0]; - if (!node) return { outcome: 'missing' }; - return { outcome: 'found', name: node.name ?? undefined, ownerId: node.ownerId ?? undefined }; - } - const detail = result.errors?.[0]?.message ?? 'unknown error'; - // A 5xx is the backend failing, not an answer about this database. An auth - // rejection is a dead ACCOUNT credential — never evidence the binding is - // dead, so it must not push toward reprovision. - if (/^HTTP 5\d\d/i.test(detail)) return { outcome: 'unreachable', detail }; - if (AUTH_ERROR_RE.test(detail)) { - return { - outcome: 'unreachable', - detail: `${detail} — the account credential was rejected. ${ - args.signInHint ?? 'Sign in again, then retry.' - }`, - }; - } - return { outcome: 'missing' }; - } catch (err) { - return { outcome: 'unreachable', detail: err instanceof Error ? err.message : String(err) }; + const post = args.post ?? postGraphQL; + const { databaseId } = args; + const ask = (fields: string) => + post({ + endpoint: args.endpoint, + headers: { Authorization: `Bearer ${args.bearer}` }, + query: document(databaseId, fields), + }); + + let result = await ask(FIELDS); + if (result.errors.length > 0 && result.errors.some((e) => NEWER_FIELD_RE.test(e?.message ?? ''))) { + result = await ask(BASIC_FIELDS); + } + if (result.errors.length > 0) return unreachable(databaseId, result.errors, args.signInHint); + + const nodes = result.data?.databases?.nodes; + if (!Array.isArray(nodes)) { + return { status: 'unreachable', databaseId, cause: 'error', detail: 'the backend answered without a databases list' }; + } + const node = nodes[0]; + if (!node) return { status: 'missing', databaseId }; + const found = { + databaseId, + name: node.name ?? undefined, + ownerId: node.ownerId ?? undefined, + hash: node.hash ?? undefined, + }; + if (node.suspendedAt) { + return { + status: 'suspended', + ...found, + suspendedAt: node.suspendedAt, + suspendedReason: node.suspendedReason ?? undefined, + }; + } + return { status: 'found', ...found }; +} + +function unreachable(databaseId: string, errors: GraphQLError[], signInHint?: string): DatabaseProbe { + const detail = errorsText(errors); + switch (classifyBackendErrors(errors)) { + case 'network': + return { status: 'unreachable', databaseId, cause: 'network', detail }; + case 'server': + return { status: 'unreachable', databaseId, cause: 'server', detail }; + case 'auth': + case 'forbidden': + // A rejected ACCOUNT credential is never evidence the binding is dead. + return { + status: 'unreachable', + databaseId, + cause: 'auth', + detail: `${detail}: the account credential was rejected. ${signInHint ?? 'Sign in again, then retry.'}`, + }; + default: + return { status: 'unreachable', databaseId, cause: 'error', detail }; + } +} + +/** + * Probe a database with the host's account bearer on its configured control + * endpoint (never a project-pinned one). Without a bearer the answer is + * unreachable with cause 'auth'. + */ +export async function probeBoundDatabase(databaseId: string): Promise { + const host = getHost(); + const account = host.account(); + const bearer = account?.apiKey ?? account?.accessToken; + if (!bearer) { + return { + status: 'unreachable', + databaseId, + cause: 'auth', + detail: `No usable account credential. ${host.signInHint ?? 'Sign in to the app, then retry.'}`, + }; } + return probeDatabase({ + endpoint: host.backendConfig()?.apiEndpoint || DEFAULT_API_ENDPOINT, + bearer, + databaseId, + signInHint: host.signInHint, + }); } diff --git a/agentic/db-tools/src/host.ts b/agentic/db-tools/src/host.ts index 2b9b869b7b..9a7c7aef3d 100644 --- a/agentic/db-tools/src/host.ts +++ b/agentic/db-tools/src/host.ts @@ -83,6 +83,24 @@ export type StepUpRequest = { apiEndpoint: string; }; +/** + * The database a project is bound to, handed to the host before provisioning + * writes it to the project's `.env`, so a host that keeps its own record of + * the binding never ends up with a `.env` it doesn't know about. + */ +export type ProjectBinding = { + cwd: string; + databaseId: string; + databaseName: string; + ownerId: string; + apiEndpoint: string; + modulesEndpoint: string; + /** The database this binding replaced (replace mode). */ + replaces?: string; + /** True when the existing binding was kept: nothing was provisioned. */ + skipped?: boolean; +}; + export interface ToolsHost { /** Signed-in platform account, or null/undefined when signed out. */ account(): HostAccount | null | undefined; @@ -129,10 +147,40 @@ export interface ToolsHost { * Required for create_api_key — without it the tool refuses to mint. */ deliverSecret?(delivery: SecretDelivery): Promise; + /** + * What to tell the agent to do about a database that is gone or belongs to + * another account, in place of the default advice to call + * provision_database with `reprovision: true`. + */ + reprovisionHint?: string; + /** + * Whether the agent may replace a binding through provision_database's + * `reprovision: true`. Default true. A host that replaces databases only + * after the user approves (calling `provisionDatabase` in replace mode + * itself) sets false, and the tool then refuses. + */ + toolReprovision?: boolean; + /** + * Save a binding in the host's own store. Provisioning calls it before + * writing `.env`, on a new binding and on a kept one (`skipped`). If it + * throws, `.env` is left alone, the provision ticket stays recorded, and + * the call fails with `binding-not-saved`. + */ + persistBinding?(binding: ProjectBinding): Promise; } export const DEFAULT_DATA_TOKEN_SKEW_MS = 30_000; +const DEFAULT_REPROVISION_ADVICE = + 'Re-provision with provision_database (reprovision: true): the schema is rebuilt from the project, and records do not carry over.'; + +/** The advice for a database that is gone or under another account. */ +export function reprovisionAdvice(host: ToolsHost): string { + if (host.reprovisionHint) return host.reprovisionHint; + if (host.toolReprovision === false) return 'Ask the user to re-provision it from the app.'; + return DEFAULT_REPROVISION_ADVICE; +} + let currentHost: ToolsHost | null = null; export function configureHost(host: ToolsHost): void { diff --git a/agentic/db-tools/src/tools/provision-database.ts b/agentic/db-tools/src/tools/provision-database.ts index c6008db4e3..b012beb876 100644 --- a/agentic/db-tools/src/tools/provision-database.ts +++ b/agentic/db-tools/src/tools/provision-database.ts @@ -218,12 +218,12 @@ export function createProvisionDatabaseTool( databaseId: existingEnv.DATABASE_ID, signInHint: host.signInHint, }); - if (probe.outcome === 'unreachable') { + if (probe.status === 'unreachable') { return fail( `Cannot verify the existing database binding: backend unreachable (${probe.detail}). Check the Constructive backend is running, then retry — do not reprovision on an unreachable backend.`, ); } - if (probe.outcome === 'missing') { + if (probe.status === 'missing') { return fail( `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database no longer exists on this backend. Re-run with reprovision: true to mint a fresh database (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, ); @@ -353,7 +353,7 @@ export function createProvisionDatabaseTool( databaseId, signInHint: host.signInHint, }); - if (owner.outcome === 'unreachable') { + if (owner.status === 'unreachable') { return pendingResult( envPath, ticketFor(ticketId), @@ -361,11 +361,11 @@ export function createProvisionDatabaseTool( reprovision, ); } - if (owner.outcome === 'missing' || owner.ownerId !== ownerId) { + if (owner.status === 'missing' || owner.ownerId !== ownerId) { await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); return fail( `Database provisioning returned database ${databaseId}, but ${ - owner.outcome === 'missing' ? "it isn't visible to" : "it isn't owned by" + owner.status === 'missing' ? "it isn't visible to" : "it isn't owned by" } the signed-in account, so it was not bound. Call provision_database again to request a new database.`, ); } From 4eea8ce6ff09a411cffc4ba14832e24095c5fec9 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:36:26 +0700 Subject: [PATCH 07/14] feat(db-tools): never provision a second database silently Provisioning moves to provisionDatabase and runs under a per-project lock. A binding is DATABASE_ID alone, probed and healed or refused. Replace mode is a compare-and-swap on the binding the caller saw and refuses a suspended one, or a live one on the host path. A ticket that reads back empty never falls back to a new request: an in-flight provision of the same name is found with listOwn first, and a completed one is only reported with name-taken, for the host to adopt. Past the attempt cap the call fails provision-stuck until the host abandons the ticket. The host saves the binding before .env is written, and failures carry a code. The tool's schema is unchanged; toolReprovision: false makes its reprovision refuse. --- .../__tests__/provision-guard.test.ts | 336 ++++++++ .../__tests__/provision-resume.test.ts | 25 +- .../__tests__/request-database.test.ts | 13 + .../src/provision-database/pending-ticket.ts | 67 +- .../src/provision-database/provision.ts | 780 ++++++++++++++++++ .../provision-database/request-database.ts | 60 +- .../db-tools/src/tools/provision-database.ts | 401 +-------- 7 files changed, 1258 insertions(+), 424 deletions(-) create mode 100644 agentic/db-tools/__tests__/provision-guard.test.ts create mode 100644 agentic/db-tools/src/provision-database/provision.ts diff --git a/agentic/db-tools/__tests__/provision-guard.test.ts b/agentic/db-tools/__tests__/provision-guard.test.ts new file mode 100644 index 0000000000..468b5f2ae7 --- /dev/null +++ b/agentic/db-tools/__tests__/provision-guard.test.ts @@ -0,0 +1,336 @@ +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +// The network boundary: requestDatabase on the api client, the ticket read and +// the lookup by name on the modules client, and the ownership probe. +const mockRequestDatabase = jest.fn(); +const mockFindTicket = jest.fn(); +const mockListOwn = jest.fn(); +jest.mock('@constructive-io/sdk', () => ({ + api: { + FetchAdapter: jest.fn(), + createClient: () => ({ + mutation: { requestDatabase: (...args: unknown[]) => ({ unwrap: () => mockRequestDatabase(...args) }) }, + }), + }, + modules: { + createClient: () => ({ + databaseProvisionModule: { + findOne: (args: { id: string }) => ({ unwrap: () => mockFindTicket(args.id) }), + findMany: (args: unknown) => ({ unwrap: () => mockListOwn(args) }), + }, + }), + }, +})); +jest.mock('../src/db-probe', () => ({ probeDatabase: jest.fn() })); + +import type { RunCommand } from '../src/app-workspace'; +import { type DatabaseProbe, probeDatabase } from '../src/db-probe'; +import { configureHost, type ToolsHost } from '../src/host'; +import { provisionDatabase, type ProvisionInput } from '../src/provision-database/provision'; +import { createProvisionDatabaseTool } from '../src/tools/provision-database'; + +const mockProbe = probeDatabase as jest.MockedFunction; +const API = 'http://api.localhost:3000/graphql'; + +type Ticket = { id: string; status: string; bootstrapStatus: string; databaseId: string | null; createdAt?: string }; +const pending = (id: string): Ticket => ({ id, status: 'pending', bootstrapStatus: 'pending', databaseId: null }); +const completed = (id: string, databaseId: string): Ticket => ({ + id, + status: 'completed', + bootstrapStatus: 'completed', + databaseId, +}); +const ticketRead = (ticket: Ticket | null) => ({ databaseProvisionModule: ticket }); +const requested = (ticket: Ticket) => ({ requestDatabase: { result: ticket } }); +const listed = (...tickets: Ticket[]) => ({ databaseProvisionModules: { nodes: tickets } }); + +const record = (fields: Partial> = {}) => + [ + `PROVISION_TICKET_ID=${fields.id ?? 'ticket-1'}`, + `PROVISION_TICKET_DATABASE_NAME=${fields.name ?? 'myapp'}`, + `PROVISION_TICKET_API_ENDPOINT=${fields.api ?? API}`, + 'PROVISION_TICKET_USER_ID=user-1', + `PROVISION_TICKET_ATTEMPTS=${fields.attempts ?? '1'}`, + ].join('\n') + '\n'; + +let cwd: string; +let persistBinding: jest.Mock; +const envPath = () => path.join(cwd, '.env'); +const envFile = () => readFileSync(envPath(), 'utf8'); +const writeEnv = (content: string) => writeFileSync(envPath(), content); + +// No packages/app, so the prewarm only tries a template clone, which this +// runner refuses: nothing touches the network or the disk outside cwd. +const refuse: RunCommand = async () => ({ ok: false, out: 'not run in tests' }); +const provision = (name: string, reprovision?: boolean) => + createProvisionDatabaseTool(refuse).execute( + { database_name: name, ...(reprovision ? { reprovision } : {}) }, + { cwd }, + ); +const hostProvision = (input: Partial = {}) => + provisionDatabase({ databaseName: 'myapp', ...input }, { cwd }, refuse); + +const found = (databaseId: string, ownerId = 'user-1'): DatabaseProbe => ({ + status: 'found', + databaseId, + ownerId, + name: 'myapp', +}); + +function useHost(extra: Partial = {}) { + configureHost({ + account: () => ({ userId: 'user-1', accessToken: 'account-token' }), + backendConfig: () => ({ apiEndpoint: API, modulesEndpoint: 'http://modules.localhost:3000/graphql' }), + provisionPollDeadlineMs: 1, + persistBinding, + ...extra, + }); +} + +beforeEach(() => { + cwd = mkdtempSync(path.join(os.tmpdir(), 'db-tools-guard-')); + mockRequestDatabase.mockReset(); + mockFindTicket.mockReset(); + mockListOwn.mockReset(); + mockListOwn.mockResolvedValue(listed()); + mockProbe.mockReset(); + mockProbe.mockImplementation(async ({ databaseId }) => found(databaseId)); + persistBinding = jest.fn(async (): Promise => undefined); + useHost(); +}); + +afterEach(() => { + rmSync(cwd, { recursive: true, force: true }); +}); + +describe('a binding is DATABASE_ID alone', () => { + it('heals a partial .env from the live database without requesting one', async () => { + writeEnv('DATABASE_ID=db-1\n'); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ success: true, skipped: true, databaseId: 'db-1', databaseName: 'myapp' }); + expect(envFile()).toMatch(/^ACCESS_TOKEN=account-token$/m); + expect(envFile()).toMatch(/^DATABASE_NAME=myapp$/m); + expect(envFile()).toMatch(/^OWNER_ID=user-1$/m); + expect(persistBinding).toHaveBeenCalledWith(expect.objectContaining({ databaseId: 'db-1', skipped: true })); + }); + + it('makes one request when two calls race on an unbound project', async () => { + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-1', 'db-1'))); + + const [first, second] = await Promise.all([provision('myapp'), provision('myapp')]); + + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + expect(first.details).toMatchObject({ success: true, databaseId: 'db-1' }); + expect(second.details).toMatchObject({ success: true, skipped: true, databaseId: 'db-1' }); + }); +}); + +describe('lost and recorded tickets', () => { + it('follows an in-flight ticket found by name when the recorded one reads back empty', async () => { + writeEnv(record()); + mockFindTicket.mockImplementation(async (id: string) => + ticketRead(id === 'ticket-2' ? completed('ticket-2', 'db-2') : null), + ); + mockListOwn.mockResolvedValue(listed(pending('ticket-2'))); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(mockListOwn).toHaveBeenCalledWith( + expect.objectContaining({ where: { databaseName: { equalTo: 'myapp' }, ownerId: { equalTo: 'user-1' } } }), + ); + expect(result.details).toMatchObject({ success: true, recovered: true, resumed: true, databaseId: 'db-2' }); + expect(envFile()).toMatch(/^DATABASE_ID=db-2$/m); + }); + + it('stops with ticket-lost, creating nothing, when no provision of that name is in flight', async () => { + writeEnv(record()); + mockFindTicket.mockResolvedValue(ticketRead(null)); + // A completed one is never followed: it may be another project's database. + mockListOwn.mockResolvedValue(listed(completed('ticket-0', 'db-0'))); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ success: false, code: 'ticket-lost' }); + expect(envFile()).not.toMatch(/PROVISION_TICKET_ID|DATABASE_ID/); + }); + + it('refuses another name while a provision is recorded for this project', async () => { + writeEnv(record({ name: 'otherapp' })); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result.details).toMatchObject({ success: false, code: 'pending-other', ticketId: 'ticket-1' }); + expect(envFile()).toBe(record({ name: 'otherapp' })); + }); + + it('archives a record from another backend and requests', async () => { + writeEnv(record({ api: 'http://api.example.test/graphql' })); + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-2', 'db-2'))); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + expect(result.details).toMatchObject({ success: true, databaseId: 'db-2' }); + expect(envFile()).toMatch(/^# archived \d{4}-\d{2}-\d{2}: PROVISION_TICKET_ID=ticket-1$/m); + }); + + it('finds the ticket on the backend when recording it failed, so the retry makes no second request', async () => { + mkdirSync(envPath()); // writing .env fails + mockRequestDatabase.mockResolvedValue(requested(pending('ticket-1'))); + mockFindTicket.mockResolvedValueOnce(ticketRead(pending('ticket-1'))); + mockListOwn.mockImplementation(async () => + listed(...(mockRequestDatabase.mock.calls.length > 0 ? [pending('ticket-1')] : [])), + ); + + const first = await provision('myapp'); + rmSync(envPath(), { recursive: true }); + mockFindTicket.mockResolvedValue(ticketRead(completed('ticket-1', 'db-1'))); + const second = await provision('myapp'); + + expect(first.details).toMatchObject({ success: false, pending: true }); + expect(first.details.message).toMatch(/Recording its ticket in .env failed/); + expect(second.details).toMatchObject({ success: true, recovered: true, databaseId: 'db-1' }); + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + }); + + it('stops at the attempt cap with provision-stuck, and requests again only when the host abandons it', async () => { + writeEnv(record({ attempts: '5' })); + mockFindTicket.mockResolvedValue(ticketRead(pending('ticket-1'))); + mockListOwn.mockResolvedValue(listed(pending('ticket-1'))); + + const stuck = await provision('myapp'); + const again = await provision('myapp'); + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(stuck.details).toMatchObject({ success: false, code: 'provision-stuck', ticketId: 'ticket-1' }); + expect(stuck.details.pending).toBeUndefined(); + expect(again.details.code).toBe('provision-stuck'); + expect(envFile()).toMatch(/^PROVISION_TICKET_ID=ticket-1$/m); + + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-2', 'db-2'))); + const abandoned = await hostProvision({ abandonPending: true }); + + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + expect(abandoned).toMatchObject({ success: true, databaseId: 'db-2' }); + expect(envFile()).toMatch(/^PROVISION_TICKET_ABANDONED=ticket-1$/m); + }); +}); + +describe('replace mode', () => { + it('refuses when the binding is no longer the one the caller saw', async () => { + writeEnv('DATABASE_ID=db-new\n'); + + const result = await hostProvision({ mode: 'replace', replaces: 'db-old' }); + + expect(result.code).toBe('binding-changed'); + expect(mockProbe).not.toHaveBeenCalled(); + expect(mockRequestDatabase).not.toHaveBeenCalled(); + }); + + it.each<[string, DatabaseProbe, string]>([ + ['live', found('db-old'), 'binding-live'], + ['suspended', { status: 'suspended', databaseId: 'db-old', ownerId: 'user-1', suspendedAt: '2026-10-01' }, 'db-suspended'], + ])('refuses to replace a %s binding', async (_case, probe, code) => { + writeEnv('DATABASE_ID=db-old\n'); + mockProbe.mockResolvedValue(probe); + + const result = await hostProvision({ mode: 'replace', replaces: 'db-old' }); + + expect(result.code).toBe(code); + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(envFile()).toBe('DATABASE_ID=db-old\n'); + }); + + it('archives a missing binding and binds a new database in its place', async () => { + writeEnv('DATABASE_ID=db-old\nDATABASE_NAME=myapp\n'); + mockProbe.mockImplementation(async ({ databaseId }) => + databaseId === 'db-old' ? { status: 'missing', databaseId } : found(databaseId), + ); + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-2', 'db-2'))); + + const result = await hostProvision({ mode: 'replace', replaces: 'db-old' }); + + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + expect(result).toMatchObject({ success: true, databaseId: 'db-2', replaced: 'db-old' }); + expect(envFile()).toMatch(/^# archived \d{4}-\d{2}-\d{2}: DATABASE_ID=db-old$/m); + expect(envFile()).toMatch(/^DATABASE_ID=db-2$/m); + expect(persistBinding).toHaveBeenCalledWith(expect.objectContaining({ databaseId: 'db-2', replaces: 'db-old' })); + }); + + it("refuses the tool's reprovision when the host turned it off", async () => { + useHost({ toolReprovision: false, reprovisionHint: 'Approve the rebuild card in the app.' }); + writeEnv('DATABASE_ID=db-old\n'); + + const result = await provision('myapp', true); + + expect(result.details).toMatchObject({ success: false, code: 'reprovision-not-allowed' }); + expect(result.details.message).toMatch(/Approve the rebuild card/); + expect(mockProbe).not.toHaveBeenCalled(); + expect(mockRequestDatabase).not.toHaveBeenCalled(); + }); +}); + +describe('saving the binding', () => { + it('hands the binding to the host before .env is written', async () => { + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-1', 'db-1'))); + const envAtSave: string[] = []; + persistBinding.mockImplementation(async () => { + envAtSave.push(existsSync(envPath()) ? envFile() : ''); + }); + + const result = await provision('myapp'); + + expect(result.details.success).toBe(true); + expect(envAtSave).toHaveLength(1); + expect(envAtSave[0]).not.toMatch(/DATABASE_ID/); + expect(envFile()).toMatch(/^DATABASE_ID=db-1$/m); + }); + + it('keeps the ticket when the host cannot save it, and the retry binds it without a second request', async () => { + mockRequestDatabase.mockResolvedValue(requested(completed('ticket-1', 'db-1'))); + mockFindTicket.mockResolvedValue(ticketRead(completed('ticket-1', 'db-1'))); + persistBinding.mockRejectedValueOnce(new Error('store is locked')); + + const failed = await provision('myapp'); + expect(failed.details).toMatchObject({ success: false, code: 'binding-not-saved', databaseId: 'db-1' }); + expect(envFile()).not.toMatch(/^DATABASE_ID=/m); + expect(envFile()).toMatch(/^PROVISION_TICKET_ID=ticket-1$/m); + + const retried = await provision('myapp'); + + expect(retried.details).toMatchObject({ success: true, resumed: true, databaseId: 'db-1' }); + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + }); +}); + +describe('a taken name', () => { + it("reports this account's database holding it, and never binds it", async () => { + mockRequestDatabase.mockRejectedValue(new Error('database "myapp" already exists')); + mockListOwn.mockResolvedValue(listed({ ...completed('ticket-0', 'db-9'), createdAt: '2026-10-20T08:00:00Z' })); + + const result = await provision('myapp'); + + expect(mockRequestDatabase).toHaveBeenCalledTimes(1); + expect(result.details).toMatchObject({ + success: false, + code: 'name-taken', + heldBy: { databaseId: 'db-9', yours: true, createdAt: '2026-10-20T08:00:00Z' }, + }); + expect(persistBinding).not.toHaveBeenCalled(); + }); + + it('lets the host adopt that database without a request', async () => { + const result = await hostProvision({ adopt: 'db-9' }); + + expect(mockRequestDatabase).not.toHaveBeenCalled(); + expect(result).toMatchObject({ success: true, databaseId: 'db-9' }); + expect(envFile()).toMatch(/^DATABASE_ID=db-9$/m); + }); +}); diff --git a/agentic/db-tools/__tests__/provision-resume.test.ts b/agentic/db-tools/__tests__/provision-resume.test.ts index 19ba4669d6..c26ee744a1 100644 --- a/agentic/db-tools/__tests__/provision-resume.test.ts +++ b/agentic/db-tools/__tests__/provision-resume.test.ts @@ -2,10 +2,11 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import os from 'node:os'; import path from 'node:path'; -// The network boundary: requestDatabase on the api client, the ticket read on -// the modules client, and the ownership probe. +// The network boundary: requestDatabase on the api client, the ticket read and +// the lookup by name on the modules client, and the ownership probe. const mockRequestDatabase = jest.fn(); const mockFindTicket = jest.fn(); +const mockListOwn = jest.fn(); jest.mock('@constructive-io/sdk', () => ({ api: { FetchAdapter: jest.fn(), @@ -17,6 +18,7 @@ jest.mock('@constructive-io/sdk', () => ({ createClient: () => ({ databaseProvisionModule: { findOne: (args: { id: string }) => ({ unwrap: () => mockFindTicket(args.id) }), + findMany: (args: unknown) => ({ unwrap: () => mockListOwn(args) }), }, }), }, @@ -67,8 +69,10 @@ beforeEach(() => { cwd = mkdtempSync(path.join(os.tmpdir(), 'db-tools-provision-')); mockRequestDatabase.mockReset(); mockFindTicket.mockReset(); + mockListOwn.mockReset(); + mockListOwn.mockResolvedValue({ databaseProvisionModules: { nodes: [] } }); mockProbe.mockReset(); - mockProbe.mockResolvedValue({ status: 'found', databaseId: 'db-1', ownerId: 'user-1' }); + mockProbe.mockImplementation(async ({ databaseId }) => ({ status: 'found', databaseId, ownerId: 'user-1' })); configureHost({ account: () => ({ userId: 'user-1', accessToken: 'account-token' }), backendConfig: () => ({ apiEndpoint: API, modulesEndpoint: 'http://modules.localhost:3000/graphql' }), @@ -143,23 +147,10 @@ describe('provision_database pending tickets', () => { expect(envFile()).toBe('KEEP=me\n'); }); - it('stops following a ticket after the attempt cap, and says a new call starts a new provision', async () => { - writeEnv(`KEEP=me\n${record({ attempts: '5' })}`); - mockFindTicket.mockResolvedValue(ticketRead(pending('ticket-1'))); - - const result = await provision('myapp'); - - expect(mockRequestDatabase).not.toHaveBeenCalled(); - expect(result.details.pending).toBeUndefined(); - expect(result.details.message).toMatch(/still not ready after 5 waits.*a new call starts a new provision/); - expect(envFile()).toBe('KEEP=me\n'); - }); - it.each([ - ['another name', { name: 'otherapp' }], ['another backend', { api: 'http://api.example.test/graphql' }], ['another account', { user: 'user-2' }], - ])('never reuses a ticket recorded for %s', async (_case, fields) => { + ])('never follows a ticket recorded for %s', async (_case, fields) => { writeEnv(record(fields)); mockRequestDatabase.mockResolvedValue(requested(completed('ticket-2', 'db-2'))); diff --git a/agentic/db-tools/__tests__/request-database.test.ts b/agentic/db-tools/__tests__/request-database.test.ts index c1e285cb53..d5a16cacc1 100644 --- a/agentic/db-tools/__tests__/request-database.test.ts +++ b/agentic/db-tools/__tests__/request-database.test.ts @@ -2,6 +2,7 @@ import { ProvisionPendingError, type ProvisionRequestExecutor, type ProvisionTicket, + ProvisionTicketLostError, requestDatabaseProvision, } from '../src/provision-database/request-database'; @@ -33,6 +34,7 @@ function run(args: { initial: ProvisionTicket; polls?: (ProvisionTicket | null | Error)[]; deadlineMs?: number; + resumeTicketId?: string; }) { const polls = [...(args.polls ?? [])]; const pollCalls: string[] = []; @@ -46,6 +48,7 @@ function run(args: { if (next instanceof Error) throw next; return next; }, + listOwn: async () => [], }; const promise = requestDatabaseProvision({ apiEndpoint: 'http://api.test/graphql', @@ -57,6 +60,7 @@ function run(args: { executor, pollIntervalMs: 0, pollDeadlineMs: args.deadlineMs ?? 5_000, + resumeTicketId: args.resumeTicketId, }); return { promise, request, pollCalls }; } @@ -188,6 +192,15 @@ describe('requestDatabaseProvision', () => { await expect(promise).resolves.toEqual({ databaseId: 'db-id', ticketId: 'ticket-1' }); }); + it('throws ProvisionTicketLostError for a resumed ticket that reads back empty, never requesting', async () => { + const { promise, request, pollCalls } = run({ initial: done, polls: [null], resumeTicketId: 'ticket-9' }); + + await expect(promise).rejects.toBeInstanceOf(ProvisionTicketLostError); + await expect(promise).rejects.toMatchObject({ ticketId: 'ticket-9' }); + expect(pollCalls).toEqual(['ticket-9']); + expect(request).not.toHaveBeenCalled(); + }); + it('times out a ticket that never completes', async () => { const { promise, pollCalls } = run({ initial: ticket(), deadlineMs: 0 }); await expect(promise).rejects.toThrow(/timed out.*status: pending.*bootstrapStatus: pending/); diff --git a/agentic/db-tools/src/provision-database/pending-ticket.ts b/agentic/db-tools/src/provision-database/pending-ticket.ts index ddb8b3769b..6aa490b50f 100644 --- a/agentic/db-tools/src/provision-database/pending-ticket.ts +++ b/agentic/db-tools/src/provision-database/pending-ticket.ts @@ -2,7 +2,14 @@ // a project's .env. requestDatabase never resumes by name, so the ticket is // the only way back to a provision that is still running. -import { type EnvVars, mergeEnv, PENDING_PROVISION_KEYS, removeEnvKeys } from './env-file'; +import { + archiveBindingKeys, + type EnvVars, + mergeEnv, + PENDING_PROVISION_KEYS, + readEnv, + removeEnvKeys, +} from './env-file'; /** * Pending results recorded for one ticket before provision_database stops @@ -22,25 +29,31 @@ export type PendingTicket = { const KEYS = Object.values(PENDING_PROVISION_KEYS); /** - * The recorded ticket when it was requested for this database name, on this - * backend and by this account. A ticket from another backend or account isn't - * ours to poll, and one for another name is a different database. + * Tickets the host told provisioning to stop following (`abandonPending`), + * comma-separated. A lookup by name skips them, so an abandoned provision is + * never resumed. */ -export function matchingPendingTicket( - env: EnvVars, - want: { databaseName: string; apiEndpoint: string; userId: string }, -): PendingTicket | undefined { +export const ABANDONED_TICKETS_KEY = 'PROVISION_TICKET_ABANDONED'; + +/** The newest abandoned ids kept; older ones are long finished or failed. */ +const MAX_ABANDONED = 10; + +/** + * The recorded ticket, with the name, backend and account it was requested + * for. Only a ticket from this backend and account is ours to poll, and one + * for another name is a different database. + */ +export function recordedTicket(env: EnvVars): PendingTicket | undefined { const ticketId = env[PENDING_PROVISION_KEYS.ticketId]; - if ( - !ticketId || - env[PENDING_PROVISION_KEYS.databaseName] !== want.databaseName || - env[PENDING_PROVISION_KEYS.apiEndpoint] !== want.apiEndpoint || - env[PENDING_PROVISION_KEYS.userId] !== want.userId - ) { - return undefined; - } + if (!ticketId) return undefined; const attempts = Number.parseInt(env[PENDING_PROVISION_KEYS.attempts] ?? '', 10); - return { ...want, ticketId, attempts: Number.isFinite(attempts) && attempts > 0 ? attempts : 0 }; + return { + ticketId, + databaseName: env[PENDING_PROVISION_KEYS.databaseName] ?? '', + apiEndpoint: env[PENDING_PROVISION_KEYS.apiEndpoint] ?? '', + userId: env[PENDING_PROVISION_KEYS.userId] ?? '', + attempts: Number.isFinite(attempts) && attempts > 0 ? attempts : 0, + }; } export function recordPendingTicket(content: string, ticket: PendingTicket): string { @@ -56,3 +69,23 @@ export function recordPendingTicket(content: string, ticket: PendingTicket): str export function clearPendingTicket(content: string): string { return removeEnvKeys(content, KEYS); } + +/** Comment the record out (`# archived : …`), keeping it readable. */ +export function archivePendingTicket(content: string, archivedOn: string): string { + return archiveBindingKeys(content, KEYS, archivedOn); +} + +export function abandonedTickets(env: EnvVars): Set { + return new Set( + (env[ABANDONED_TICKETS_KEY] ?? '') + .split(',') + .map((id) => id.trim()) + .filter(Boolean), + ); +} + +/** Add ticket ids to the abandoned list. */ +export function recordAbandoned(content: string, ticketIds: readonly string[]): string { + const ids = [...new Set([...abandonedTickets(readEnv(content)), ...ticketIds])].slice(-MAX_ABANDONED); + return mergeEnv(content, { [ABANDONED_TICKETS_KEY]: ids.join(',') }); +} diff --git a/agentic/db-tools/src/provision-database/provision.ts b/agentic/db-tools/src/provision-database/provision.ts new file mode 100644 index 0000000000..923fa6430e --- /dev/null +++ b/agentic/db-tools/src/provision-database/provision.ts @@ -0,0 +1,780 @@ +// Provisioning a project's database without ever creating a second one by +// accident. Every step runs under a per-project lock, and: +// - a binding is DATABASE_ID alone: it is probed, then kept (and healed) or +// refused, never provisioned over; +// - replace mode swaps a binding only if it is still the one the caller saw, +// and never one that is live (host path) or suspended; +// - a recorded ticket that reads back empty, or a call that lost its ticket, +// finds an in-flight provision of the same name before anything new is +// requested, and a completed one is only reported (binding it is `adopt`); +// - the host saves the binding before `.env` is written. + +import { readFile, realpath, writeFile } from 'node:fs/promises'; +import path from 'node:path'; + +import { prewarmAppWorkspace, run, type RunCommand } from '../app-workspace'; +import { type DatabaseProbe, probeDatabase } from '../db-probe'; +import { getHost, type ProjectBinding, reprovisionAdvice, type ToolsHost } from '../host'; +import { selectProvisionCredential } from './credential'; +import { archiveBindingKeys, ARCHIVED_BINDING_KEYS, type EnvVars, mergeEnv, provisionEnvVars, readEnv } from './env-file'; +import { loadProvisionManifest } from './manifest'; +import { + abandonedTickets, + archivePendingTicket, + clearPendingTicket, + MAX_PENDING_ATTEMPTS, + type PendingTicket, + recordAbandoned, + recordedTicket, + recordPendingTicket, +} from './pending-ticket'; +import { selectProvisionRequest } from './preset-match'; +import { + createProvisionExecutor, + isTicketInFlight, + isTransientProvisionError, + ProvisionPendingError, + type ProvisionRequestExecutor, + type ProvisionTicket, + ProvisionTicketLostError, + requestDatabaseProvision, +} from './request-database'; +import { type ProvisionOverlay, resolveProvisionModules } from './resolve'; + +const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql'; +const DEFAULT_MODULES_ENDPOINT = 'http://modules.localhost:3000/graphql'; + +export type ProvisionInput = { + databaseName: string; + /** `ensure` keeps a live binding; `replace` swaps the binding for a new database. */ + mode?: 'ensure' | 'replace'; + /** + * Replace mode: the DATABASE_ID the caller expects bound (null: none). Any + * other binding fails `binding-changed`. Undefined replaces whatever is bound. + */ + replaces?: string | null; + /** Host only: stop following an in-flight provision and request a new one. */ + abandonPending?: boolean; + /** + * Host only: bind this database, which the signed-in account owns and is + * active, without requesting one (recovery after `name-taken`). + */ + adopt?: string; +}; + +export type ProvisionFailureCode = + | 'signed-out' + | 'backend-unreachable' + | 'db-missing' + | 'db-suspended' + | 'account-mismatch' + | 'binding-live' + | 'binding-changed' + | 'pending-other' + | 'ticket-lost' + | 'provision-stuck' + | 'name-taken' + | 'binding-not-saved' + | 'env-write-failed' + | 'reprovision-not-allowed' + | 'failed'; + +export type ProvisionDatabaseDetails = { + success: boolean; + message: string; + /** Why it failed; absent on success and on a pending result. */ + code?: ProvisionFailureCode; + databaseId?: string; + databaseName?: string; + ownerId?: string; + /** True when an existing live binding was kept: nothing changed. */ + skipped?: boolean; + /** + * True when the call stopped waiting while the provision is still running. + * Calling again with the same database_name resumes it. + */ + pending?: boolean; + /** The provision ticket, on a pending or stuck result. */ + ticketId?: string; + /** True when this call finished a provision an earlier call had started. */ + resumed?: boolean; + /** True when that provision was found on the backend by name, not in `.env`. */ + recovered?: boolean; + /** The database the new binding replaced (replace mode). */ + replaced?: string; + /** With `name-taken`: a database of this account holds the name. Never bound automatically. */ + heldBy?: { databaseId: string; yours: boolean; createdAt?: string }; +}; + +type Details = ProvisionDatabaseDetails; + +function failure(code: ProvisionFailureCode, message: string, extra: Partial
= {}): Details { + return { ...extra, success: false, message, code }; +} + +const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err)); +const today = () => new Date().toISOString().slice(0, 10); +const noop = (): void => undefined; + +async function readText(file: string): Promise { + try { + return await readFile(file, 'utf8'); + } catch { + return ''; + } +} + +// Edit .env from its current content, read right before the write, so changes +// made while this call waited on the backend (by another tool or the user) +// aren't overwritten with what the file held when the call started. +async function updateEnvFile(envPath: string, edit: (content: string) => string): Promise { + await writeFile(envPath, edit(await readText(envPath))); +} + +/** The DATABASE_ID in a project's `.env`, if any. */ +export async function boundDatabaseId(cwd: string): Promise { + return readEnv(await readText(path.join(cwd, '.env'))).DATABASE_ID || undefined; +} + +// The per-DB endpoints are subdomain.; the provisioning domain is the api +// endpoint's host minus its first label (api.localhost -> localhost, +// api.launchql.dev -> launchql.dev), so dev and devnet both provision correctly. +function provisionDomain(apiEndpoint: string): string { + try { + const { hostname } = new URL(apiEndpoint); + // A bare IP host has no domain hierarchy to strip: dropping its first label + // ('192.168.1.10' -> '168.1.10') would yield a garbage domain. Use it verbatim. + if (hostname.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return hostname; + const parts = hostname.split('.'); + return parts.length > 1 ? parts.slice(1).join('.') : parts.join('.'); + } catch { + return 'localhost'; + } +} + +// The host's poll deadline when it's a positive number, else the default. +function pollDeadline(ms: number | undefined): number | undefined { + return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : undefined; +} + +// ---- per-project lock ---------------------------------------------------- + +const projectLocks = new Map>(); + +// One provision per project at a time: a second call waits, then sees what +// the first one left (a binding, a recorded ticket) instead of racing it. +async function withProjectLock(cwd: string, fn: () => Promise): Promise { + const key = await realpath(cwd).catch(() => path.resolve(cwd)); + const previous = projectLocks.get(key) ?? Promise.resolve(); + let release: () => void = noop; + const done = new Promise((resolve) => (release = resolve)); + const tail = previous.then(() => done); + projectLocks.set(key, tail); + await previous; + try { + return await fn(); + } finally { + release(); + if (projectLocks.get(key) === tail) projectLocks.delete(key); + } +} + +// ---- the run --------------------------------------------------------------- + +type Run = { + host: ToolsHost; + cwd: string; + envPath: string; + name: string; + mode: 'ensure' | 'replace'; + apiEndpoint: string; + modulesEndpoint: string; + bearer: string; + ownerId: string; + viaTool: boolean; + executor: ProvisionRequestExecutor; + probe(databaseId: string): Promise; +}; + +/** + * Provision the project's database, or keep the one it is bound to. The host + * entry point: replace mode refuses a live binding here, and `abandonPending` + * and `adopt` are honoured. + */ +export function provisionDatabase( + input: ProvisionInput, + ctx: { cwd: string; signal?: AbortSignal }, + exec: RunCommand = run, +): Promise
{ + return withProjectLock(ctx.cwd, () => provision(input, ctx.cwd, exec, false)); +} + +/** + * The provision_database tool's entry point: its `reprovision: true` may + * replace a live binding (a clean rebuild), unless the host turned tool + * reprovisioning off. + */ +export function provisionFromTool( + input: Pick, + ctx: { cwd: string; signal?: AbortSignal }, + exec: RunCommand = run, +): Promise
{ + return withProjectLock(ctx.cwd, () => provision(input, ctx.cwd, exec, true)); +} + +async function provision(input: ProvisionInput, cwd: string, exec: RunCommand, viaTool: boolean): Promise
{ + const name = input.databaseName?.trim(); + if (!name) return failure('failed', 'database_name is required.'); + + // Unify endpoints onto the app's backend-config store (environment-aware: + // *.localhost:3000 in dev, *.launchql.dev packaged). A process.env override + // still wins for ad-hoc dev; the hardcoded default is a last resort if the + // store isn't ready. + const host = getHost(); + const backend = host.backendConfig(); + const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT; + const modulesEndpoint = process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT; + + // Every project provisions UNDER the account (requestDatabase owns the + // database to the JWT user). The account bearer authenticates the mutation + // and the probes, and is the ACCESS_TOKEN written to .env; it expires with + // the login session, and any provision run (the skip path too) refreshes it. + const credential = selectProvisionCredential(host.account(), host.signInHint); + if (credential.mode === 'error') { + return failure('signed-out', `Cannot provision a database: ${credential.reason}`); + } + + const envPath = path.join(cwd, '.env'); + const env = readEnv(await readText(envPath)); + const bound = env.DATABASE_ID || undefined; + const r: Run = { + host, + cwd, + envPath, + name, + mode: input.mode ?? 'ensure', + apiEndpoint, + modulesEndpoint, + bearer: credential.bearer, + ownerId: credential.ownerId, + viaTool, + executor: createProvisionExecutor({ apiEndpoint, modulesEndpoint, bearer: credential.bearer }), + // The probe carries the account bearer, so it only ever targets the + // app-configured backend, never a .env-pinned endpoint. + probe: (databaseId) => + probeDatabase({ endpoint: apiEndpoint, bearer: credential.bearer, databaseId, signInHint: host.signInHint }), + }; + + let replaced: string | undefined; + if (r.mode === 'replace') { + const refusal = await checkReplace(r, input, bound); + if (refusal) return refusal; + replaced = bound; + } else if (bound) { + return keepBinding(r, env, bound, input.adopt); + } + + if (input.adopt) { + const prewarm = startPrewarm(r, exec); + return bindDatabase(r, { databaseId: input.adopt, replaced, adopted: true }, prewarm); + } + return requestAndBind(r, input, env, replaced, exec); +} + +// ---- ensure: keep the binding -------------------------------------------- + +async function keepBinding(r: Run, env: EnvVars, bound: string, adopt?: string): Promise
{ + const probe = await r.probe(bound); + if (probe.status === 'unreachable') { + return failure( + 'backend-unreachable', + `Cannot verify the existing database binding (DATABASE_ID=${bound}): ${probe.detail}. Retry once the backend answers; don't reprovision on an unreachable backend.`, + { databaseId: bound }, + ); + } + if (probe.status === 'missing') { + return failure( + 'db-missing', + `Project carries a binding (DATABASE_ID=${bound}) but that database no longer exists on this backend, or this account lost access to it. ${reprovisionAdvice(r.host)}`, + { databaseId: bound }, + ); + } + if (probe.ownerId && probe.ownerId !== r.ownerId) { + return failure( + 'account-mismatch', + `Project carries a binding (DATABASE_ID=${bound}) but that database was provisioned under a different account than the one signed in. ${reprovisionAdvice(r.host)}`, + { databaseId: bound }, + ); + } + if (probe.status === 'suspended') { + return failure('db-suspended', suspendedText(bound, probe), { databaseId: bound }); + } + if (adopt && adopt !== bound) { + return failure( + 'binding-live', + `The project is bound to a live database (DATABASE_ID=${bound}); adopt binds only an unbound project. Use replace mode to swap it.`, + { databaseId: bound }, + ); + } + + // Heal .env on the skip path: a stale token (relogin), missing or stale + // endpoint pins, and the binding keys a partial .env lacks. + const databaseName = env.DATABASE_NAME || probe.name || ''; + const ownerId = probe.ownerId ?? r.ownerId; + const refresh: Record = {}; + if (env.ACCESS_TOKEN !== r.bearer) refresh.ACCESS_TOKEN = r.bearer; + if (env.API_ENDPOINT !== r.apiEndpoint) refresh.API_ENDPOINT = r.apiEndpoint; + if (env.MODULES_ENDPOINT !== r.modulesEndpoint) refresh.MODULES_ENDPOINT = r.modulesEndpoint; + if (!env.DATABASE_NAME && databaseName) refresh.DATABASE_NAME = databaseName; + if (!env.PGDATABASE && databaseName) refresh.PGDATABASE = databaseName; + if (!env.OWNER_ID) refresh.OWNER_ID = ownerId; + + const saved = await saveBinding(r, { + cwd: r.cwd, + databaseId: bound, + databaseName, + ownerId, + apiEndpoint: r.apiEndpoint, + modulesEndpoint: r.modulesEndpoint, + skipped: true, + }); + if (saved) return saved; + + let note = ''; + if (Object.keys(refresh).length > 0) { + const keys = Object.keys(refresh).join(', '); + try { + await updateEnvFile(r.envPath, (content) => mergeEnv(content, refresh)); + note = ` Refreshed in .env: ${keys}.`; + } catch (err) { + note = ` Could not refresh ${keys} in .env: ${errorText(err)}.`; + } + } + const advice = r.host.toolReprovision === false ? '' : ' Pass reprovision: true only for an explicit clean rebuild.'; + return { + success: true, + message: `Project already provisioned (DATABASE_ID=${bound}) and the database is live under this account. Skipping.${advice}${note}`, + databaseId: bound, + databaseName, + ownerId, + skipped: true, + }; +} + +function suspendedText(databaseId: string, probe: Extract): string { + const why = probe.suspendedReason ? ` (${probe.suspendedReason})` : ''; + return `The bound database (DATABASE_ID=${databaseId}) is suspended${why} since ${probe.suspendedAt}. Its data is kept, so it isn't replaced; nothing can read or change it until the suspension is lifted.`; +} + +// ---- replace: compare and swap ------------------------------------------- + +async function checkReplace(r: Run, input: ProvisionInput, bound: string | undefined): Promise
{ + if (r.viaTool && r.host.toolReprovision === false) { + return failure( + 'reprovision-not-allowed', + `provision_database can't replace this project's database: the app replaces a database only after the user approves it. ${reprovisionAdvice(r.host)}`, + ); + } + const expected = input.replaces === undefined ? bound : input.replaces || undefined; + if ((expected ?? '') !== (bound ?? '')) { + return failure( + 'binding-changed', + `The project's binding changed (DATABASE_ID is ${bound ?? 'unset'}, expected ${expected ?? 'unset'}), so nothing was replaced.`, + { databaseId: bound }, + ); + } + if (!bound) return null; + + const probe = await r.probe(bound); + if (probe.status === 'unreachable') { + return failure( + 'backend-unreachable', + `Cannot verify the binding being replaced (DATABASE_ID=${bound}): ${probe.detail}. Retry once the backend answers.`, + { databaseId: bound }, + ); + } + if (probe.status === 'suspended') return failure('db-suspended', suspendedText(bound, probe), { databaseId: bound }); + // A missing or foreign-owned binding is replaced; a live one of this account + // only through the tool's explicit rebuild. + if (probe.status === 'found' && (!probe.ownerId || probe.ownerId === r.ownerId) && !r.viaTool) { + return failure( + 'binding-live', + `The bound database (DATABASE_ID=${bound}) is live under this account, so it wasn't replaced.`, + { databaseId: bound }, + ); + } + return null; +} + +// ---- request, resume, recover -------------------------------------------- + +type Lookup = { inFlight: ProvisionTicket[]; refusal?: Details; problem?: string }; + +// The account's in-flight provisions of this name, newest first. A lookup that +// can't reach the backend refuses (nothing may be requested blind); one that +// gets another error leaves the caller to decide. +async function findInFlight(r: Run, skip: ReadonlySet): Promise { + try { + const tickets = await r.executor.listOwn({ databaseName: r.name, ownerId: r.ownerId }); + return { inFlight: tickets.filter((t) => t.id && !skip.has(t.id) && isTicketInFlight(t)) }; + } catch (err) { + if (isTransientProvisionError(err)) { + return { + inFlight: [], + refusal: failure( + 'backend-unreachable', + `Could not check the backend for a provision of "${r.name}" already in progress (${errorText(err)}). Nothing was requested; retry once the backend answers.`, + ), + }; + } + return { inFlight: [], problem: errorText(err) }; + } +} + +const ticketOf = (r: Run, ticket: ProvisionTicket): PendingTicket => ({ + ticketId: ticket.id, + databaseName: r.name, + apiEndpoint: r.apiEndpoint, + userId: r.ownerId, + attempts: 0, +}); + +function startPrewarm(r: Run, exec: RunCommand): Promise<{ ok: boolean; out: string }> { + // The packages/app scaffold + install are database-INDEPENDENT, so they run + // alongside the provision. Best-effort: run_codegen re-runs the same + // idempotent steps. A call that returns early leaves it running; a retry + // joins that same run rather than starting a second install. + return prewarmAppWorkspace(r.cwd, exec).catch((err: unknown) => ({ ok: false, out: errorText(err) })); +} + +async function requestAndBind( + r: Run, + input: ProvisionInput, + env: EnvVars, + replaced: string | undefined, + exec: RunCommand, +): Promise
{ + const abandoned = abandonedTickets(env); + const abandon = async (ids: string[]) => { + for (const id of ids) abandoned.add(id); + await updateEnvFile(r.envPath, (content) => recordAbandoned(clearPendingTicket(content), ids)).catch(noop); + }; + + // A ticket an earlier call stopped waiting on. + let current: PendingTicket | undefined; + let recovered = false; + const recorded = recordedTicket(env); + if (recorded) { + if (recorded.apiEndpoint !== r.apiEndpoint || recorded.userId !== r.ownerId) { + // Requested on another backend or by another account: not ours to follow. + await updateEnvFile(r.envPath, (content) => archivePendingTicket(content, today())).catch(noop); + } else if (input.abandonPending) { + await abandon([recorded.ticketId]); + } else if (recorded.databaseName !== r.name) { + return failure( + 'pending-other', + `A provision of "${recorded.databaseName}" (ticket ${recorded.ticketId}) is still recorded for this project. Call provision_database with database_name "${recorded.databaseName}" to finish it; another name would start a second database.`, + { ticketId: recorded.ticketId }, + ); + } else { + current = recorded; + } + } + + // No ticket of ours is recorded, but an earlier call may have lost track of + // one (its record failed to write, or the call was cut off): look it up by + // name. Only an in-flight one is resumed. + if (!current) { + const lookup = await findInFlight(r, abandoned); + if (lookup.refusal) return lookup.refusal; + if (lookup.inFlight.length > 0 && input.abandonPending) { + await abandon(lookup.inFlight.map((t) => t.id)); + } else if (lookup.inFlight.length > 0) { + current = ticketOf(r, lookup.inFlight[0]); + recovered = true; + } + } + + const prewarm = startPrewarm(r, exec); + + // Resolve the module set: pinned base preset from node-type-registry, then + // ordered overlays (host default < project-local provision.json). A bad + // preset/manifest fails the run rather than provisioning a wrong module set. + let modules; + try { + const layers: ProvisionOverlay[] = []; + const hostOverlay = await r.host.provisionOverlay?.(); + if (hostOverlay) layers.push(hostOverlay); + const projectOverlay = await loadProvisionManifest(r.cwd); + if (projectOverlay) layers.push(projectOverlay); + modules = resolveProvisionModules(layers); + } catch (err) { + return failure('failed', `Cannot resolve provision modules: ${errorText(err)}`); + } + + // requestDatabase claims a warm-pool database when the module set matches a + // cataloged preset and cold-provisions otherwise; the ticket is polled on the + // modules endpoint until the database AND its owner bootstrap complete. An + // explicit subdomain (= name) keeps the per-DB endpoints deterministic. + const ticketFor = (ticketId: string): PendingTicket => ({ + ticketId, + databaseName: r.name, + apiEndpoint: r.apiEndpoint, + userId: r.ownerId, + attempts: (current?.ticketId === ticketId ? current.attempts : 0) + 1, + }); + const lost = new Set(); + let provisioned: { databaseId: string; ticketId: string; resumed?: true }; + for (;;) { + try { + provisioned = await requestDatabaseProvision({ + apiEndpoint: r.apiEndpoint, + modulesEndpoint: r.modulesEndpoint, + bearer: r.bearer, + databaseName: r.name, + domain: provisionDomain(r.apiEndpoint), + request: selectProvisionRequest(modules), + executor: r.executor, + // Past the attempt cap one read says whether it finished; no more waiting. + pollDeadlineMs: + current && current.attempts >= MAX_PENDING_ATTEMPTS ? 0 : pollDeadline(r.host.provisionPollDeadlineMs), + resumeTicketId: current?.ticketId, + }); + break; + } catch (err) { + if (err instanceof ProvisionPendingError) { + return pendingResult(r, ticketFor(err.ticketId), err.message, recovered); + } + if (err instanceof ProvisionTicketLostError) { + // The ticket can't be read back, which doesn't mean the provision + // stopped. Follow another in-flight one of this name, or stop without + // requesting anything. + lost.add(err.ticketId); + const lookup = await findInFlight(r, new Set([...abandoned, ...lost])); + if (lookup.refusal) return lookup.refusal; + if (lookup.inFlight.length > 0) { + current = ticketOf(r, lookup.inFlight[0]); + recovered = true; + continue; + } + await updateEnvFile(r.envPath, clearPendingTicket).catch(noop); + const checked = lookup.problem + ? `the backend couldn't be asked for another one (${lookup.problem})` + : 'no other provision of that name is in progress'; + return failure( + 'ticket-lost', + `The provision of "${r.name}" (ticket ${err.ticketId}) can no longer be found on this backend, and ${checked}. Nothing was created, and the record is cleared, so calling provision_database again requests a new database.`, + { ticketId: err.ticketId }, + ); + } + // A definitive failure ends the recorded request, so forget it. + if (current) await updateEnvFile(r.envPath, clearPendingTicket).catch(noop); + return requestFailure(r, err); + } + } + + return bindDatabase( + r, + { + databaseId: provisioned.databaseId, + ticket: ticketFor(provisioned.ticketId), + resumed: provisioned.resumed === true, + recovered, + replaced, + }, + prewarm, + ); +} + +// The provision is still running: record its ticket so the next call with the +// same name resumes it. Past MAX_PENDING_ATTEMPTS the ticket stays recorded +// and the call fails `provision-stuck`: nothing new is requested while it is +// in flight, until the host abandons it. +async function pendingResult(r: Run, ticket: PendingTicket, reason: string, recovered: boolean): Promise
{ + const { ticketId } = ticket; + let recordedOk = true; + try { + await updateEnvFile(r.envPath, (content) => recordPendingTicket(content, ticket)); + } catch { + recordedOk = false; + } + if (ticket.attempts > MAX_PENDING_ATTEMPTS) { + return failure( + 'provision-stuck', + `Database "${r.name}" is still not ready after ${MAX_PENDING_ATTEMPTS} waits (${reason}). Its request (ticket ${ticketId}) stays recorded, and no other database is requested while it is in progress. Check the backend's provisioning jobs.`, + { ticketId }, + ); + } + const again = `call provision_database again with database_name "${r.name}"${ + r.mode === 'replace' && r.viaTool ? ' and reprovision: true' : '' + }`; + const message = recordedOk + ? `Database "${r.name}" is still provisioning (${reason}). The request is recorded in .env; ${again} to keep waiting on it. Don't change the name: that starts a second database.` + : `Database "${r.name}" is still provisioning (${reason}). Recording its ticket in .env failed, but the backend still has it: ${again} to keep waiting on it. Don't change the name: that starts a second database.`; + return { success: false, message, pending: true, ticketId, ...(recovered ? { recovered: true } : {}) }; +} + +const NAME_TAKEN_RE = /already exists|already taken|already in use|duplicate/i; + +async function requestFailure(r: Run, err: unknown): Promise
{ + const detail = errorText(err); + if (!NAME_TAKEN_RE.test(detail)) return failure('failed', `Database provisioning failed: ${detail}.`); + const heldBy = await ownHolder(r); + if (heldBy) { + const when = heldBy.createdAt ? `, requested ${heldBy.createdAt}` : ''; + return failure( + 'name-taken', + `Database provisioning failed: ${detail}. The name "${r.name}" belongs to a database this account provisioned earlier (ID ${heldBy.databaseId}${when}) that this project isn't bound to, so it was not bound. Re-run with a different database_name.`, + { heldBy }, + ); + } + return failure( + 'name-taken', + `Database provisioning failed: ${detail}. The database name is already taken on the backend; re-run with a different database_name.`, + ); +} + +// A completed provision of this name by this account whose database still +// exists under it: reported with name-taken so the host can adopt it. +async function ownHolder(r: Run): Promise { + let tickets: ProvisionTicket[]; + try { + tickets = await r.executor.listOwn({ databaseName: r.name, ownerId: r.ownerId }); + } catch { + return undefined; + } + const done = tickets.find((t) => t.status === 'completed' && t.databaseId && !isTicketInFlight(t)); + if (!done) return undefined; + const probe = await r.probe(done.databaseId); + if (probe.status !== 'found' && probe.status !== 'suspended') return undefined; + if (probe.ownerId && probe.ownerId !== r.ownerId) return undefined; + return { databaseId: done.databaseId, yours: true, ...(done.createdAt ? { createdAt: done.createdAt } : {}) }; +} + +// ---- binding --------------------------------------------------------------- + +async function saveBinding(r: Run, binding: ProjectBinding, ticket?: PendingTicket): Promise
{ + if (!r.host.persistBinding) return null; + try { + await r.host.persistBinding(binding); + return null; + } catch (err) { + // Keep the ticket recorded, so the next call resumes it and binds it. + if (ticket) await updateEnvFile(r.envPath, (content) => recordPendingTicket(content, ticket)).catch(noop); + return failure( + 'binding-not-saved', + `Database "${binding.databaseName}" (ID ${binding.databaseId}) is ready, but the app couldn't save the binding (${errorText(err)}), so .env was left unchanged.${ + ticket ? ' Calling provision_database again with the same name binds it.' : '' + }`, + { databaseId: binding.databaseId, ...(ticket ? { ticketId: ticket.ticketId } : {}) }, + ); + } +} + +type Bind = { + databaseId: string; + ticket?: PendingTicket; + resumed?: boolean; + recovered?: boolean; + replaced?: string; + adopted?: boolean; +}; + +async function bindDatabase( + r: Run, + bind: Bind, + prewarm: Promise<{ ok: boolean; out: string }>, +): Promise
{ + const { databaseId, ticket } = bind; + + // Bind only a database this account owns. An unreachable backend keeps the + // ticket for the next call to check again. + const owner = await r.probe(databaseId); + if (owner.status === 'unreachable') { + if (ticket) { + return pendingResult(r, ticket, `provisioned, but its owner couldn't be confirmed: ${owner.detail}`, bind.recovered); + } + return failure('backend-unreachable', `Cannot verify database ${databaseId}: ${owner.detail}. Retry once the backend answers.`); + } + const visible = owner.status !== 'missing'; + if (!visible || owner.ownerId !== r.ownerId) { + if (ticket) await updateEnvFile(r.envPath, clearPendingTicket).catch(noop); + const why = visible ? "it isn't owned by" : "it isn't visible to"; + if (bind.adopted) { + return failure(visible ? 'account-mismatch' : 'db-missing', `Database ${databaseId} was not bound: ${why} the signed-in account.`); + } + return failure( + 'failed', + `Database provisioning returned database ${databaseId}, but ${why} the signed-in account, so it was not bound. Call provision_database again to request a new database.`, + ); + } + if (bind.adopted && owner.status === 'suspended') { + return failure('db-suspended', suspendedText(databaseId, owner)); + } + + const saved = await saveBinding( + r, + { + cwd: r.cwd, + databaseId, + databaseName: r.name, + ownerId: r.ownerId, + apiEndpoint: r.apiEndpoint, + modulesEndpoint: r.modulesEndpoint, + ...(bind.replaced ? { replaces: bind.replaced } : {}), + }, + ticket, + ); + if (saved) return saved; + + // Write the binding into .env (upsert, preserving other keys), dropping any + // recorded pending request: the project is bound now. + try { + await updateEnvFile(r.envPath, (content) => + mergeEnv( + clearPendingTicket( + bind.replaced ? archiveBindingKeys(content, ARCHIVED_BINDING_KEYS, today()) : content, + ), + provisionEnvVars({ + databaseId, + databaseName: r.name, + ownerId: r.ownerId, + accessToken: r.bearer, + apiEndpoint: r.apiEndpoint, + modulesEndpoint: r.modulesEndpoint, + }), + ), + ); + } catch (err) { + return failure( + 'env-write-failed', + `Database provisioned (ID: ${databaseId}) but writing .env failed: ${errorText(err)}`, + { databaseId }, + ); + } + + // Settle the concurrent prewarm so the scaffold + install are in place before + // run_codegen runs. A failure here is non-fatal: run_codegen redoes the work. + const prewarmed = (await prewarm).ok ? ' packages/app prewarmed.' : ''; + const notes = [ + bind.resumed ? ' Finished the provision an earlier call had started.' : '', + bind.recovered ? ' Found it on the backend by name: its ticket was no longer recorded.' : '', + prewarmed, + bind.replaced + ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.' + : '', + ].join(''); + const message = bind.adopted + ? `Bound database "${r.name}" (ID: ${databaseId}), which this account provisioned earlier. Credentials written to .env.${notes}` + : `Provisioned database "${r.name}" (ID: ${databaseId}). Credentials written to .env.${notes}`; + return { + success: true, + message, + databaseId, + databaseName: r.name, + ownerId: r.ownerId, + ...(bind.resumed ? { resumed: true } : {}), + ...(bind.recovered ? { recovered: true } : {}), + ...(bind.replaced ? { replaced: bind.replaced } : {}), + }; +} diff --git a/agentic/db-tools/src/provision-database/request-database.ts b/agentic/db-tools/src/provision-database/request-database.ts index 036ee40698..b560dde1a1 100644 --- a/agentic/db-tools/src/provision-database/request-database.ts +++ b/agentic/db-tools/src/provision-database/request-database.ts @@ -30,11 +30,13 @@ export type ProvisionTicket = { databaseId?: string | null; errorMessage?: string | null; bootstrapError?: string | null; + databaseName?: string | null; + createdAt?: string | null; }; /** - * Request + poll seam, injectable for tests (pattern: db-probe's - * ProbeExecutor). The default executor drives the SDK clients. + * Request, poll and lookup seam, injectable for tests. The default executor + * drives the SDK clients. */ export type ProvisionRequestExecutor = { request(input: { @@ -44,6 +46,8 @@ export type ProvisionRequestExecutor = { request: ProvisionRequest; }): Promise; poll(ticketId: string): Promise; + /** The account's newest tickets for a database name, newest first. */ + listOwn(filter: { databaseName: string; ownerId: string }): Promise; }; export const POLL_INTERVAL_MS = 2_000; @@ -73,6 +77,21 @@ export class ProvisionPendingError extends Error { } } +/** + * The recorded ticket can't be read back: it is gone, or this account can't + * see it. Nothing says the provision stopped, so a caller must not request + * the same database again on this alone. + */ +export class ProvisionTicketLostError extends Error { + constructor( + message: string, + readonly ticketId: string, + ) { + super(message); + this.name = 'ProvisionTicketLostError'; + } +} + const TICKET_SELECT = { id: true, status: true, @@ -82,11 +101,14 @@ const TICKET_SELECT = { bootstrapError: true, } as const; +/** How many of an account's tickets for one name a lookup reads. */ +const LIST_OWN_LIMIT = 5; + type RequestDatabaseInput = Parameters< ReturnType['mutation']['requestDatabase'] >[0]['input']; -function createSdkExecutor(args: { +export function createProvisionExecutor(args: { apiEndpoint: string; modulesEndpoint: string; bearer: string; @@ -119,9 +141,26 @@ function createSdkExecutor(args: { .unwrap(); return result.databaseProvisionModule ?? null; }, + async listOwn({ databaseName, ownerId }) { + const result = await modulesClient.databaseProvisionModule + .findMany({ + select: { ...TICKET_SELECT, databaseName: true, createdAt: true }, + where: { databaseName: { equalTo: databaseName }, ownerId: { equalTo: ownerId } }, + orderBy: ['CREATED_AT_DESC'], + first: LIST_OWN_LIMIT, + }) + .unwrap(); + return (result.databaseProvisionModules?.nodes ?? []).filter((ticket) => Boolean(ticket)); + }, }; } +/** A ticket whose provision or owner bootstrap is still running. */ +export function isTicketInFlight(ticket: ProvisionTicket): boolean { + if (ticket.status === 'pending' || ticket.status === 'in_progress') return true; + return ticket.status === 'completed' && (ticket.bootstrapStatus === 'pending' || ticket.bootstrapStatus === 'in_progress'); +} + const NETWORK_ERROR_RE = /fetch failed|network|socket hang up|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|ENOTFOUND|aborted|timed? ?out/i; @@ -157,8 +196,9 @@ function sleep(ms: number): Promise { * either outcome throws `ProvisionPendingError`. * * With `resumeTicketId` the ticket of an earlier call is polled instead of - * requesting again. A ticket that can't be found (another backend or account) - * falls back to a new request; `resumed` is true when the old one was used. + * requesting again, and `resumed` is true. A ticket that reads back empty + * throws `ProvisionTicketLostError`, never a new request: the provision may + * still be running where this account can't see it. */ export async function requestDatabaseProvision(args: { apiEndpoint: string; @@ -174,7 +214,7 @@ export async function requestDatabaseProvision(args: { }): Promise<{ databaseId: string; ticketId: string; resumed?: true }> { const executor = args.executor ?? - createSdkExecutor({ + createProvisionExecutor({ apiEndpoint: args.apiEndpoint, modulesEndpoint: args.modulesEndpoint, bearer: args.bearer, @@ -197,6 +237,12 @@ export async function requestDatabaseProvision(args: { cause: err, }); } + if (!resumedTicket) { + throw new ProvisionTicketLostError( + `provision ticket ${args.resumeTicketId} could not be found on this backend.`, + args.resumeTicketId, + ); + } } let ticket = resumedTicket ?? @@ -254,7 +300,7 @@ export async function requestDatabaseProvision(args: { } if (!polled) { if (++failures > MAX_CONSECUTIVE_POLL_FAILURES) { - throw new Error(`provision ticket ${ticketId} could not be read back.`); + throw new ProvisionTicketLostError(`provision ticket ${ticketId} could not be read back.`, ticketId); } continue; } diff --git a/agentic/db-tools/src/tools/provision-database.ts b/agentic/db-tools/src/tools/provision-database.ts index b012beb876..244fc58d5f 100644 --- a/agentic/db-tools/src/tools/provision-database.ts +++ b/agentic/db-tools/src/tools/provision-database.ts @@ -1,53 +1,14 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import path from 'node:path'; - import type { HarnessTool } from '@agentic-kit/harness'; import { z } from 'zod'; -import { prewarmAppWorkspace, run, type RunCommand } from '../app-workspace'; -import { probeDatabase } from '../db-probe'; -import { getHost } from '../host'; -import { selectProvisionCredential } from '../provision-database/credential'; -import { - archiveBindingKeys, - ARCHIVED_BINDING_KEYS, - mergeEnv, - provisionEnvVars, - readEnv, -} from '../provision-database/env-file'; -import { loadProvisionManifest } from '../provision-database/manifest'; +import { run, type RunCommand } from '../app-workspace'; import { - clearPendingTicket, - matchingPendingTicket, - MAX_PENDING_ATTEMPTS, - type PendingTicket, - recordPendingTicket, -} from '../provision-database/pending-ticket'; -import { selectProvisionRequest } from '../provision-database/preset-match'; -import { - ProvisionPendingError, - requestDatabaseProvision, -} from '../provision-database/request-database'; -import { type ProvisionOverlay, resolveProvisionModules } from '../provision-database/resolve'; - -const DEFAULT_API_ENDPOINT = 'http://api.localhost:3000/graphql'; -const DEFAULT_MODULES_ENDPOINT = 'http://modules.localhost:3000/graphql'; + boundDatabaseId, + type ProvisionDatabaseDetails, + provisionFromTool, +} from '../provision-database/provision'; -// The per-DB endpoints are subdomain.; the provisioning domain is the api -// endpoint's host minus its first label (api.localhost -> localhost, -// api.launchql.dev -> launchql.dev), so dev and devnet both provision correctly. -function provisionDomain(apiEndpoint: string): string { - try { - const { hostname } = new URL(apiEndpoint); - // A bare IP host has no domain hierarchy to strip: dropping its first label - // ('192.168.1.10' -> '168.1.10') would yield a garbage domain. Use it verbatim. - if (hostname.includes(':') || /^\d{1,3}(\.\d{1,3}){3}$/.test(hostname)) return hostname; - const parts = hostname.split('.'); - return parts.length > 1 ? parts.slice(1).join('.') : parts.join('.'); - } catch { - return 'localhost'; - } -} +export type { ProvisionDatabaseDetails }; const ProvisionDatabaseZod = z.object({ database_name: z @@ -65,84 +26,8 @@ const ProvisionDatabaseZod = z.object({ type Params = z.infer; -export type ProvisionDatabaseDetails = { - success: boolean; - message: string; - databaseId?: string; - databaseName?: string; - ownerId?: string; - /** True when an existing live binding was kept — nothing changed. */ - skipped?: boolean; - /** - * True when the call stopped waiting while the provision is still running. - * The ticket is recorded in .env, and calling again with the same - * database_name resumes it. - */ - pending?: boolean; - /** The provision ticket, on a pending result. */ - ticketId?: string; - /** True when this call finished a provision an earlier call had started. */ - resumed?: boolean; -}; - type ToolResult = { content: { type: 'text'; text: string }[]; details: ProvisionDatabaseDetails }; -function fail(message: string): ToolResult { - return { content: [{ type: 'text', text: message }], details: { success: false, message } }; -} - -// Edit .env from its current content, read right before the write, so changes -// made while this call waited on the backend (by another tool or the user) -// aren't overwritten with what the file held when the call started. -async function updateEnvFile(envPath: string, edit: (content: string) => string): Promise { - let content = ''; - try { - content = await readFile(envPath, 'utf8'); - } catch { - /* no .env yet */ - } - await writeFile(envPath, edit(content)); -} - -// The provision is still running: record its ticket so the next call with the -// same name resumes it, and tell the agent to call again rather than rename. -// Past MAX_PENDING_ATTEMPTS the ticket is dropped and the call fails instead. -async function pendingResult( - envPath: string, - ticket: PendingTicket, - reason: string, - reprovision: boolean, -): Promise { - const { databaseName, ticketId } = ticket; - if (ticket.attempts > MAX_PENDING_ATTEMPTS) { - await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); - return fail( - `Database "${databaseName}" is still not ready after ${MAX_PENDING_ATTEMPTS} waits (${reason}), so provision_database stopped following its request (ticket ${ticketId}). Check the backend's provisioning jobs before calling provision_database again: a new call starts a new provision.`, - ); - } - let recorded = true; - try { - await updateEnvFile(envPath, (content) => recordPendingTicket(content, ticket)); - } catch { - recorded = false; - } - const again = `call provision_database again with database_name "${databaseName}"${ - reprovision ? ' and reprovision: true' : '' - }`; - const message = recorded - ? `Database "${databaseName}" is still provisioning (${reason}). The request is recorded in .env; ${again} to keep waiting on it. Don't change the name: that starts a second database.` - : `Database "${databaseName}" is still provisioning (${reason}), but recording its ticket in .env failed, so a later call can't resume it. Calling again with this name starts a second provision.`; - return { - content: [{ type: 'text', text: message }], - details: { success: false, message, pending: true, ticketId }, - }; -} - -// The host's poll deadline when it's a positive number, else the default. -function pollDeadline(ms: number | undefined): number | undefined { - return typeof ms === 'number' && Number.isFinite(ms) && ms > 0 ? ms : undefined; -} - // `exec` runs the prewarm's child processes (clone, install); tests inject it. export function createProvisionDatabaseTool( exec: RunCommand = run, @@ -156,269 +41,19 @@ export function createProvisionDatabaseTool( 'provision_database: one-time bootstrap of the project database (owner + modules + .env). Run before describe_schema/provision_blueprint. Skips when the existing binding is live under the signed-in account; reprovision: true replaces a dead or foreign-account binding (archives old keys, never deletes the old db). Gated.', parameters: ProvisionDatabaseZod, async execute(params: Params, ctx): Promise { - const databaseName = params.database_name.trim(); - if (!databaseName) return fail('database_name is required.'); - - const envPath = path.join(ctx.cwd, '.env'); - let existing = ''; - try { - existing = await readFile(envPath, 'utf8'); - } catch { - /* no existing .env — fine */ - } - - // Unify endpoints onto the app's backend-config store (environment-aware: - // *.localhost:3000 in dev, *.launchql.dev packaged). A process.env override - // still wins for ad-hoc dev; the hardcoded default is a last resort if the - // store isn't ready. - const host = getHost(); - const backend = host.backendConfig(); - const apiEndpoint = process.env.API_ENDPOINT || backend?.apiEndpoint || DEFAULT_API_ENDPOINT; - const modulesEndpoint = - process.env.MODULES_ENDPOINT || backend?.modulesEndpoint || DEFAULT_MODULES_ENDPOINT; - - // Every new project provisions UNDER the account (requestDatabase owns the - // database to the JWT user), so it is account-owned and enumerable. The - // account bearer is the single credential: it authenticates the provision - // mutation, the idempotency - // probe below, and is the ACCESS_TOKEN written to .env for project-local - // scripts. It expires with the login session — a relogin plus any provision - // run (including the skip path) refreshes the .env copy. A signed-in session - // with no usable bearer errors out here rather than silently minting a - // throwaway owner. Gate BEFORE prewarm so an error return doesn't leak a - // detached background scaffold/install. - const credential = selectProvisionCredential(host.account(), host.signInHint); - if (credential.mode === 'error') { - return fail(`Cannot provision a database: ${credential.reason}`); - } - const ownerId = credential.ownerId; - - // Idempotency keyed on binding liveness AND ownership: a binding is done only - // when its database still answers and belongs to the signed-in account - // (schemas are account-scoped). A dead binding (backend refreshed, database - // deleted, key revoked) or a live one under another account points at the - // reprovision path; an unreachable backend is a retry, never a reprovision. - const existingEnv = readEnv(existing); - // A request an earlier call stopped waiting on. Only the same name, backend - // and account resume it; asking for that name again would start a second - // provision instead. - const pending = matchingPendingTicket(existingEnv, { - databaseName, - apiEndpoint, - userId: ownerId, - }); - const hasBinding = Boolean(existingEnv.DATABASE_ID && existingEnv.ACCESS_TOKEN); - if (hasBinding && !params.reprovision) { - // The probe carries the account bearer, so it only ever targets the - // app-configured backend — never a .env-pinned endpoint, which an - // untrusted cloned project controls. - const probe = await probeDatabase({ - endpoint: apiEndpoint, - bearer: credential.bearer, - databaseId: existingEnv.DATABASE_ID, - signInHint: host.signInHint, - }); - if (probe.status === 'unreachable') { - return fail( - `Cannot verify the existing database binding: backend unreachable (${probe.detail}). Check the Constructive backend is running, then retry — do not reprovision on an unreachable backend.`, - ); - } - if (probe.status === 'missing') { - return fail( - `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database no longer exists on this backend. Re-run with reprovision: true to mint a fresh database (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, - ); - } - const sessionUserId = host.account()?.userId; - if (probe.ownerId && sessionUserId && probe.ownerId !== sessionUserId) { - return fail( - `Project carries a binding (DATABASE_ID=${existingEnv.DATABASE_ID}) but that database was provisioned under a different account than the one signed in. Re-run with reprovision: true to mint a fresh database under this account (old keys archived in .env, old database untouched), then rebuild the schema (provision_blueprint + run_codegen); records do not carry over.`, - ); - } - // Heal .env on the skip path: a stale token (relogin) or missing/stale - // endpoint pins (project provisioned before pins existed, or backend - // config changed) refresh to the current session + backend. - const refresh: Record = {}; - if (existingEnv.ACCESS_TOKEN !== credential.bearer) refresh.ACCESS_TOKEN = credential.bearer; - if (existingEnv.API_ENDPOINT !== apiEndpoint) refresh.API_ENDPOINT = apiEndpoint; - if (existingEnv.MODULES_ENDPOINT !== modulesEndpoint) { - refresh.MODULES_ENDPOINT = modulesEndpoint; - } - let tokenNote = ''; - if (Object.keys(refresh).length > 0) { - const keys = Object.keys(refresh).join(', '); - try { - await updateEnvFile(envPath, (content) => mergeEnv(content, refresh)); - tokenNote = ` Refreshed in .env from the signed-in session: ${keys}.`; - } catch (err) { - tokenNote = ` Could not refresh ${keys} in .env: ${err instanceof Error ? err.message : String(err)}.`; - } - } - const message = `Project already provisioned (DATABASE_ID=${existingEnv.DATABASE_ID}) and the database is live under this account. Skipping — pass reprovision: true only for an explicit clean rebuild.${tokenNote}`; - return { - content: [{ type: 'text', text: message }], - details: { - success: true, - message, - databaseId: existingEnv.DATABASE_ID, - databaseName: existingEnv.DATABASE_NAME, - ownerId: existingEnv.OWNER_ID, - skipped: true, - }, - }; - } - const archiveOldBinding = hasBinding && params.reprovision === true; - - // The packages/app scaffold + pnpm install are database-INDEPENDENT, so kick - // them off now to run concurrently with the provisioning request below - // (near-instant on a warm-pool hit, up to minutes on the cold path). - // This keeps clone + install off run_codegen's critical path. Best-effort: - // run_codegen re-runs the same idempotent steps, so a prewarm failure is - // harmless. Never let it reject (we await it before returning). A call - // that returns pending leaves it running; a retry joins that same run - // rather than starting a second install. - const prewarm = prewarmAppWorkspace(ctx.cwd, exec).catch( - (err: unknown): { ok: boolean; out: string } => ({ - ok: false, - out: err instanceof Error ? err.message : String(err), - }), - ); - - // Resolve the module set: pinned base preset from node-type-registry, then - // ordered overlays (host default < project-local provision.json). No list is - // hand-copied here; overlays are pure data. A bad preset/manifest fails the - // run rather than provisioning a wrong module set. - let modules; - try { - const layers: ProvisionOverlay[] = []; - const hostOverlay = await host.provisionOverlay?.(); - if (hostOverlay) layers.push(hostOverlay); - const projectOverlay = await loadProvisionManifest(ctx.cwd); - if (projectOverlay) layers.push(projectOverlay); - modules = resolveProvisionModules(layers); - } catch (err) { - return fail( - `Cannot resolve provision modules: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Provision on the API endpoint: requestDatabase claims a warm-pool - // database when the resolved module set matches a cataloged preset - // (near-instant) and cold-provisions asynchronously otherwise; the ticket - // is polled on the modules endpoint until the database AND its deferred - // owner bootstrap complete. No retry wrapper: a pending ticket is the - // normal first response, and transient poll failures are absorbed inside - // the poll loop. The domain still derives from the api endpoint's host - // (per-DB endpoints live under it), and an explicit subdomain - // (= databaseName) keeps them deterministic (SUBDOMAIN-001). - const ticketFor = (ticketId: string): PendingTicket => ({ - ticketId, - databaseName, - apiEndpoint, - userId: ownerId, - attempts: (pending?.ticketId === ticketId ? pending.attempts : 0) + 1, - }); - const reprovision = params.reprovision === true; - let provisioned: { databaseId: string; ticketId: string; resumed?: true }; - try { - provisioned = await requestDatabaseProvision({ - apiEndpoint, - modulesEndpoint, - bearer: credential.bearer, - databaseName, - domain: provisionDomain(apiEndpoint), - request: selectProvisionRequest(modules), - pollDeadlineMs: pollDeadline(host.provisionPollDeadlineMs), - resumeTicketId: pending?.ticketId, - }); - } catch (err) { - if (err instanceof ProvisionPendingError) { - return pendingResult(envPath, ticketFor(err.ticketId), err.message, reprovision); - } - // A definitive failure ends the recorded request, so forget it. - if (pending) await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); - const detail = err instanceof Error ? err.message : String(err); - const nameTakenHint = /already exists|already taken|already in use|duplicate/i.test(detail) - ? ' The database name is already taken on the backend — re-run with a different database_name.' - : ''; - return fail(`Database provisioning failed: ${detail}.${nameTakenHint}`); - } - const { databaseId, ticketId } = provisioned; - - // Bind only a database this account owns. A resumed ticket is the main - // reason to look: it was recorded by an earlier call. An unreachable - // backend keeps the ticket for the next call to check again. - const owner = await probeDatabase({ - endpoint: apiEndpoint, - bearer: credential.bearer, - databaseId, - signInHint: host.signInHint, - }); - if (owner.status === 'unreachable') { - return pendingResult( - envPath, - ticketFor(ticketId), - `provisioned, but its owner couldn't be confirmed: ${owner.detail}`, - reprovision, - ); - } - if (owner.status === 'missing' || owner.ownerId !== ownerId) { - await updateEnvFile(envPath, clearPendingTicket).catch((): void => undefined); - return fail( - `Database provisioning returned database ${databaseId}, but ${ - owner.status === 'missing' ? "it isn't visible to" : "it isn't owned by" - } the signed-in account, so it was not bound. Call provision_database again to request a new database.`, - ); - } - - // Persist the binding to the project .env (upsert, preserving other keys), - // dropping any recorded pending request: the project is bound now. - try { - await updateEnvFile(envPath, (content) => - mergeEnv( - clearPendingTicket( - archiveOldBinding - ? archiveBindingKeys(content, ARCHIVED_BINDING_KEYS, new Date().toISOString().slice(0, 10)) - : content, - ), - provisionEnvVars({ - databaseId, - databaseName, - ownerId, - accessToken: credential.bearer, - apiEndpoint, - modulesEndpoint, - }), - ), - ); - } catch (err) { - return fail( - `Database provisioned (ID: ${databaseId}) but writing .env failed: ${err instanceof Error ? err.message : String(err)}`, - ); - } - - // Settle the concurrent prewarm so the scaffold + install are in place before - // run_codegen runs. A failure here is non-fatal — run_codegen redoes the work. - const prewarmResult = await prewarm; - const prewarmNote = prewarmResult.ok ? ' packages/app prewarmed.' : ''; - - const reprovisionNote = archiveOldBinding - ? ' Previous binding archived in .env (old database kept); rebuild the schema with provision_blueprint + run_codegen.' - : ''; - const resumedNote = provisioned.resumed - ? ' Finished the provision an earlier call had started.' - : ''; - const message = `Provisioned database "${databaseName}" (ID: ${databaseId}). Credentials written to .env.${resumedNote}${prewarmNote}${reprovisionNote}`; - return { - content: [{ type: 'text', text: message }], - details: { - success: true, - message, - databaseId, - databaseName, - ownerId, - ...(provisioned.resumed ? { resumed: true } : {}), + // reprovision: true replaces the binding the project has right now; one + // that changes while this call waits for the project lock is not replaced. + const replace = params.reprovision === true; + const details = await provisionFromTool( + { + databaseName: params.database_name, + mode: replace ? 'replace' : 'ensure', + replaces: replace ? ((await boundDatabaseId(ctx.cwd)) ?? null) : undefined, }, - }; + ctx, + exec, + ); + return { content: [{ type: 'text', text: details.message }], details }; }, }; } From e9fa2687b5a9208c17d55b5d751eaf1074508fba Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:36:26 +0700 Subject: [PATCH 08/14] fix(db-tools): keep ACCESS_SUSPENDED visible to the record, codegen and key tools add_records and create_api_key read the app APIs through statusAdapter, so a deleted or suspended database answers database-unavailable instead of a bare HTTP 403. run_codegen no longer retries a refused introspection, and when the app API itself answers ACCESS_SUSPENDED it returns code: 'database-unavailable'. --- .../db-tools/__tests__/create-api-key.test.ts | 4 +- .../db-tools/__tests__/run-codegen.test.ts | 43 ++++++++++++++++ agentic/db-tools/src/tools/add-records.ts | 28 +++++++--- agentic/db-tools/src/tools/create-api-key.ts | 51 ++++++++++++++----- agentic/db-tools/src/tools/run-codegen.ts | 21 +++++++- 5 files changed, 125 insertions(+), 22 deletions(-) diff --git a/agentic/db-tools/__tests__/create-api-key.test.ts b/agentic/db-tools/__tests__/create-api-key.test.ts index a6ccd1fd1c..7e00b7d6a6 100644 --- a/agentic/db-tools/__tests__/create-api-key.test.ts +++ b/agentic/db-tools/__tests__/create-api-key.test.ts @@ -85,7 +85,7 @@ function mockFetchWith(entityIdsSupported: boolean) { }, } : { data: { createPrincipal: { result: 'prin-1' } } }; - return { json: async () => json }; + return { ok: true, status: 200, json: async () => json }; }); } @@ -187,6 +187,8 @@ describe('create_api_key execute', () => { mockGetHost.mockReturnValue(makeHost() as never); mockToken.mockResolvedValue({ token: 'tok' }); global.fetch = jest.fn(async () => ({ + ok: true, + status: 200, json: async () => ({ data: { __type: { inputFields: [{ name: 'name' }, { name: 'entityIds' }] } }, }), diff --git a/agentic/db-tools/__tests__/run-codegen.test.ts b/agentic/db-tools/__tests__/run-codegen.test.ts index 9b0efb6305..8fa9a058f4 100644 --- a/agentic/db-tools/__tests__/run-codegen.test.ts +++ b/agentic/db-tools/__tests__/run-codegen.test.ts @@ -2,10 +2,19 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import os from 'node:os'; import path from 'node:path'; +// The app API check after a refused codegen; classification stays real. +jest.mock('../src/graphql-http', () => ({ + ...jest.requireActual('../src/graphql-http'), + postGraphQL: jest.fn(), +})); + import { prewarmAppWorkspace, type RunCommand } from '../src/app-workspace'; +import { postGraphQL } from '../src/graphql-http'; import { configureHost } from '../src/host'; import { createRunCodegenTool } from '../src/tools/run-codegen'; +const mockPost = postGraphQL as jest.MockedFunction; + type Call = { cmd: string; args: string[]; cwd: string; env?: Record }; const CODEGEN_ENV = { @@ -242,6 +251,40 @@ describe('run_codegen on a composed app', () => { }); }); +describe('run_codegen when the app API refuses', () => { + const refused = () => fakeExec((call) => ({ ok: !isCodegen(call), out: 'Failed to fetch schema: HTTP 403 Forbidden' })); + + it('stops at the first 403 and reports a deleted or suspended database', async () => { + composedApp({ nodeModules: true }); + mockPost.mockResolvedValue({ + httpStatus: 403, + data: null, + errors: [{ message: 'Access to this database is suspended.', extensions: { code: 'ACCESS_SUSPENDED', httpStatus: 403 } }], + }); + const { exec, calls } = refused(); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + expect(calls.filter(isCodegen)).toHaveLength(1); + expect(mockPost).toHaveBeenCalledWith( + expect.objectContaining({ endpoint: 'http://api-myapp.localhost:3000/graphql' }), + ); + expect(result.details).toMatchObject({ success: false, code: 'database-unavailable', codegenAttempts: 1 }); + }); + + it('keeps a 403 from a database that is up a plain codegen failure', async () => { + composedApp({ nodeModules: true }); + mockPost.mockResolvedValue({ httpStatus: 200, data: { __typename: 'Query' }, errors: [] }); + const { exec, calls } = refused(); + + const result = await createRunCodegenTool(exec).execute({}, { cwd }); + + expect(calls.filter(isCodegen)).toHaveLength(1); + expect(result.details).toMatchObject({ success: false, codegenAttempts: 1 }); + expect(result.details.code).toBeUndefined(); + }); +}); + describe('prewarmAppWorkspace on a composed app', () => { it('only installs, and keeps a root workspace from capturing the app install', async () => { composedApp(); diff --git a/agentic/db-tools/src/tools/add-records.ts b/agentic/db-tools/src/tools/add-records.ts index d1331a9f69..ac002415e4 100644 --- a/agentic/db-tools/src/tools/add-records.ts +++ b/agentic/db-tools/src/tools/add-records.ts @@ -4,10 +4,10 @@ import { toCamelCaseSingular, toCreateMutationName, } from '@constructive-io/graphql-query/generators'; -import { objects } from '@constructive-io/sdk'; import { z } from 'zod'; import { resolveDataToken, resolveProjectContext } from '../context'; +import { classifyBackendErrors, errorsText, type GraphQLError, statusAdapter } from '../graphql-http'; import { cleanTable, findMetaTable, META_QUERY, type MetaResponse } from '../records/meta'; export type AddRecordsDetails = { @@ -22,6 +22,8 @@ export type AddRecordsDetails = { // app in the Preview (or the session token expired). The renderer shows a // text prompt to sign in there. needsAuth?: boolean; + // The app API answered ACCESS_SUSPENDED: the database is deleted or suspended. + code?: 'database-unavailable'; }; type ToolResult = { content: { type: 'text'; text: string }[]; details: AddRecordsDetails }; @@ -30,6 +32,19 @@ function fail(message: string): ToolResult { return { content: [{ type: 'text', text: message }], details: { success: false, message } }; } +const UNAVAILABLE = + "The app database is unavailable: the backend answered ACCESS_SUSPENDED, so it was deleted or is suspended. Retrying won't help until it is restored."; + +function unavailable(inserted = 0): ToolResult { + const message = inserted > 0 ? `${UNAVAILABLE} ${inserted} row(s) were inserted before it stopped.` : `${UNAVAILABLE} No rows were inserted.`; + return { + content: [{ type: 'text', text: message }], + details: { success: false, message, code: 'database-unavailable', ...(inserted > 0 ? { createdCount: inserted } : {}) }, + }; +} + +const isSuspended = (errors: readonly GraphQLError[]) => classifyBackendErrors(errors) === 'suspended'; + function needsAuth(message: string): ToolResult { return { content: [{ type: 'text', text: message }], @@ -80,15 +95,15 @@ export const addRecordsTool: HarnessTool(META_QUERY); if (!meta.ok) { - return fail(`Failed to read schema from the app database: ${meta.errors.map((e) => e.message).join('; ')}`); + if (isSuspended(meta.errors)) return unavailable(); + return fail(`Failed to read schema from the app database: ${errorsText(meta.errors)}`); } tables = (meta.data._meta?.tables ?? []).filter((t): t is NonNullable => Boolean(t)); } catch (err) { @@ -124,7 +139,8 @@ export const addRecordsTool: HarnessTool>(mutation, variables); if (!result.ok) { - errors.push(result.errors.map((e) => e.message).join('; ')); + if (isSuspended(result.errors)) return unavailable(created.length); + errors.push(errorsText(result.errors)); continue; } const payload = result.data[mutationName] as Record | undefined; diff --git a/agentic/db-tools/src/tools/create-api-key.ts b/agentic/db-tools/src/tools/create-api-key.ts index 049808cca0..51b7660923 100644 --- a/agentic/db-tools/src/tools/create-api-key.ts +++ b/agentic/db-tools/src/tools/create-api-key.ts @@ -3,6 +3,13 @@ import { auth } from '@constructive-io/sdk'; import { z } from 'zod'; import { deriveSubdomainEndpoint, resolveDataToken, resolveProjectContext } from '../context'; +import { + classifyBackendErrors, + errorsText, + type GraphQLError, + postGraphQL, + statusAdapter, +} from '../graphql-http'; import { getHost } from '../host'; const CreateApiKeyZod = z.object({ @@ -45,6 +52,8 @@ export type CreateApiKeyDetails = { principalName?: string; scope?: string; needsAuth?: boolean; + /** The app API answered ACCESS_SUSPENDED: the database is deleted or suspended. */ + code?: 'database-unavailable'; }; type ToolResult = { content: { type: 'text'; text: string }[]; details: CreateApiKeyDetails }; @@ -80,29 +89,39 @@ export function describeScope(entityIds: string[] | undefined, readOnly: boolean return parts.join(', '); } +// The plain fetch, read at call time. postGraphQL keeps a 403's body, so a +// deleted or suspended database's ACCESS_SUSPENDED reaches the caller. async function rawGraphql( endpoint: string, token: string | undefined, query: string, variables?: Record, -): Promise<{ data?: Record; errors?: { message: string }[] }> { - const res = await fetch(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ query, variables }), +): Promise<{ data?: Record; errors?: GraphQLError[] }> { + const result = await postGraphQL>({ + endpoint, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + query, + variables, + fetchFn: (input, init) => fetch(input, init), }); - return (await res.json()) as { data?: Record; errors?: { message: string }[] }; + return { data: result.data ?? undefined, errors: result.errors.length > 0 ? result.errors : undefined }; } -async function createPrincipalInputFields(endpoint: string): Promise> { +const UNAVAILABLE_MESSAGE = + 'The app database is unavailable: the backend answered ACCESS_SUSPENDED, so it was deleted or is suspended. No key was minted.'; + +const isSuspended = (errors: readonly GraphQLError[] | undefined) => + Boolean(errors?.length) && classifyBackendErrors(errors) === 'suspended'; + +const unavailable = (): ToolResult => fail(UNAVAILABLE_MESSAGE, { code: 'database-unavailable' }); + +async function createPrincipalInputFields(endpoint: string): Promise | 'unavailable'> { const probe = await rawGraphql( endpoint, undefined, '{ __type(name: "CreatePrincipalInput") { inputFields { name } } }', ); + if (isSuspended(probe.errors)) return 'unavailable'; const type = probe.data?.__type as { inputFields?: { name: string }[] } | null | undefined; return new Set((type?.inputFields ?? []).map((f) => f.name)); } @@ -152,6 +171,7 @@ export const createApiKeyTool: HarnessTool e.message).join('; ')}`); + return fail(`createPrincipal failed: ${errorsText(created.errors)}`); } const payload = created.data?.createPrincipal as { result?: string } | undefined; principalId = payload?.result; @@ -248,7 +268,8 @@ export const createApiKeyTool: HarnessTool e.message).join('; ')}`); + if (isSuspended(minted.errors)) return unavailable(); + return fail(`createApiKey failed: ${errorsText(minted.errors)}`); } const record = minted.data?.createApiKey?.result; @@ -280,6 +301,8 @@ export const createApiKeyTool: HarnessTool new Promise((r) => setTimeout(r, ms)); +// Ask the app API itself; a 403 from codegen alone could be anything. +async function isDatabaseUnavailable(appEndpoint: string): Promise { + const answer = await postGraphQL({ endpoint: appEndpoint, query: '{ __typename }' }); + return classifyBackendErrors(answer.errors) === 'suspended'; +} + const CODEGEN_ARGS = ['generate', '--config', './graphql-codegen.config.ts']; // The template flow resolves codegen through npx. A composed app runs the codegen @@ -115,7 +126,7 @@ async function runCodegenWithRetry( } } last = await exec(cmd, args, appDir, env); - if (last.ok || !FETCH_RESET_RE.test(last.out)) return { ...last, attempts: i }; + if (last.ok || !FETCH_RESET_RE.test(last.out) || REFUSED_RE.test(last.out)) return { ...last, attempts: i }; if (i < attempts) await sleep(2000 * i); } return { ...last, attempts }; @@ -232,6 +243,14 @@ export function createRunCodegenTool( timings.codegenMs = since(tCodegen); codegenAttempts = codegen.attempts; if (!codegen.ok) { + // A refused introspection may be the database itself: deleted or + // suspended databases answer ACCESS_SUSPENDED on every app API. + if (REFUSED_RE.test(codegen.out) && planes && (await isDatabaseUnavailable(planes.app))) { + return fail( + `codegen failed: the app database "${dbName}" is unavailable (the backend answered ACCESS_SUSPENDED: it was deleted or is suspended). Re-running run_codegen won't help until it is restored.`, + { ...stepDetails(), code: 'database-unavailable' }, + ); + } // A reset that survives every internal retry is a backend OUTAGE, not a code // or install problem — re-running run_codegen cannot fix it. Say so explicitly // so the agent marks sdk-codegen `blocked` instead of looping the tool to the From 8f64050bbdd1aa2b2d553ebf6ccb0e9b6b320df8 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:36:27 +0700 Subject: [PATCH 09/14] feat(db-tools): export the database health, schema and provisioning API --- agentic/db-tools/src/index.ts | 45 +++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/agentic/db-tools/src/index.ts b/agentic/db-tools/src/index.ts index a522e725ae..6cb21dbc15 100644 --- a/agentic/db-tools/src/index.ts +++ b/agentic/db-tools/src/index.ts @@ -66,8 +66,22 @@ export { type ProjectContextFailureCode, resolveDataToken, resolveProjectContext, + type ResolveResult, } from './context'; +export { + type DatabaseProbe, + probeBoundDatabase, + probeDatabase, + type UnreachableCause, +} from './db-probe'; export { constructiveGateDeps, type ConstructiveGateResolvers } from './gate'; +export { + type BackendErrorKind, + classifyBackendErrors, + type HttpGraphQLResponse, + postGraphQL, + statusAdapter, +} from './graphql-http'; export { type ActiveDataToken, configureHost, @@ -78,10 +92,19 @@ export { type HostBackendConfig, type HostProvisionOverlay, type PreviewToken, + type ProjectBinding, type SecretDelivery, type StepUpRequest, type ToolsHost, } from './host'; +export { + archiveBindingKeys, + ARCHIVED_BINDING_KEYS, + type EnvVars, + mergeEnv, + provisionEnvVars, + readEnv, +} from './provision-database/env-file'; export { loadProvisionManifest, parseProvisionManifest, @@ -95,12 +118,34 @@ export { type ModulePreset, type ProvisionModule, } from './provision-database/presets'; +export { + provisionDatabase, + type ProvisionDatabaseDetails, + type ProvisionFailureCode, + type ProvisionInput, +} from './provision-database/provision'; export { moduleKey, type ProvisionOverlay, resolveProvisionModules, } from './provision-database/resolve'; +export { + accessOf, + type ColumnDefault, + type FkAction, + type ModelColumn, + type ModelForeignKey, + type ModelPolicy, + type ModelTable, + type ObjectCategory, + readSchemaModel, + type SchemaModel, + SchemaReadError, + type TableAccess, +} from './schema-model'; export { toolSchema } from './tool-schema'; +export { type AddRecordsDetails } from './tools/add-records'; +export { type CreateApiKeyDetails } from './tools/create-api-key'; export { type RunCodegenDetails, type RunCodegenTimings } from './tools/run-codegen'; export { createTemplatePreviewTables } from './tools/templates'; From 1c699b762a1a044fe8aee207e06c80f1ff8a0bc8 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 13:36:27 +0700 Subject: [PATCH 10/14] docs(db-tools): describe probe states, the schema model and provisioning codes --- agentic/db-tools/README.md | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/agentic/db-tools/README.md b/agentic/db-tools/README.md index bf7f79a2a1..046aad29d7 100644 --- a/agentic/db-tools/README.md +++ b/agentic/db-tools/README.md @@ -29,7 +29,7 @@ npm install @agentic-kit/db-tools - **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. - **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. -- **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. +- **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. - **Provisioning model** — the pinned `node-type-registry` presets, the provision manifest, and overlay resolution (`resolveProvisionModules`). - **`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. - **`toolSchema`** — a tool's zod parameters as plain JSON Schema, for adapters whose harness wants JSON Schema rather than zod. @@ -56,6 +56,37 @@ A tool executes against nothing but its params and a context, so it is callable const result = await describeSchemaTool.execute({ database_name: 'my-app' }, { cwd: process.cwd() }); ``` +## Is the bound database still there? + +`probeDatabase` (or `probeBoundDatabase(id)`, which uses the host's account and control endpoint) asks the platform api and returns one of: + +| `status` | Means | Recovery | +| --- | --- | --- | +| `found` | Live; carries `name`, `ownerId` and `hash` (the schema hash, which changes on every schema change) | none | +| `suspended` | It exists and keeps its data, but is held (`suspendedAt`, `suspendedReason`) | wait for the hold to lift; never replace it | +| `missing` | The backend answered and this account can't see it: deleted, or access lost | re-provision | +| `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 | + +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`. + +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. + +## The live schema + +`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. + +## Provisioning + +`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: `) 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`. + +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: `), `binding-not-saved`, `env-write-failed`, `reprovision-not-allowed` or `failed`. + +Host hooks for this, all optional: + +- `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`. +- `toolReprovision: false` makes the tool's `reprovision: true` fail `reprovision-not-allowed`, for a host that replaces databases only after the user approves. +- `reprovisionHint` replaces the "re-provision with `reprovision: true`" advice in failure messages. + ## Related | Package | Role | From 4db1556b7b64e35acd4769fd7d975d216e8f20c2 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 14:01:12 +0700 Subject: [PATCH 11/14] test(graphile-meta): update the _meta snapshots for the form fields --- .../schema-snapshot.test.ts.snap | 57 +++++++--- .../__snapshots__/graphile-test.test.ts.snap | 104 +++++++++++++++--- 2 files changed, 127 insertions(+), 34 deletions(-) diff --git a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap index c8eb9ad86b..6da8ddae73 100644 --- a/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap +++ b/graphql/server-test/__tests__/__snapshots__/schema-snapshot.test.ts.snap @@ -707,6 +707,11 @@ type MetaEnum { """Information about a table field/column""" type MetaField { + """ + Values this field accepts, when the set is fixed; null otherwise. Set on a table's own fields only. + """ + allowedValues: [String!] + """PostgreSQL column name""" columnName: String! description: String @@ -718,6 +723,19 @@ type MetaField { isNotNull: Boolean! isPrimaryKey: Boolean! + """ + Whether a create must provide this field. Set on a table's own fields only. + """ + isRequiredOnCreate: Boolean + + """Whether a create may set this field. Set on a table's own fields only.""" + isWritableOnCreate: Boolean + + """ + Whether an update may change this field. Set on a table's own fields only. + """ + isWritableOnUpdate: Boolean + """Final GraphQL field name""" name: String! type: MetaType! @@ -742,7 +760,7 @@ type MetaHasRelation { type: String } -"""i18n metadata for a table with @i18n tag""" +"""i18n metadata for a translatable table""" type MetaI18n { """Fields that are translatable""" translatableFields: [MetaI18nField!]! @@ -769,7 +787,7 @@ type MetaIndex { name: String! } -"""Table inflection names""" +"""Type and field names generated for this table""" type MetaInflection { allRows: String! conditionType: String! @@ -814,7 +832,7 @@ type MetaQuery { update: String } -"""Realtime metadata for a table with @realtime tag""" +"""Realtime metadata for a table with change subscriptions""" type MetaRealtime { """The generated subscription field name (e.g. onPostChanged)""" subscriptionFieldName: String! @@ -835,7 +853,7 @@ type MetaRelations { } """ -How a client must serialize/parse a scalar — the one field-type detail standard GraphQL introspection cannot describe. Null for plain scalars whose wire format is obvious from gqlType. +How a client must serialize and parse a scalar whose wire format gqlType alone does not settle. Null for plain scalars whose wire format is obvious from gqlType. """ type MetaScalarEncoding { """For 'vector': declared length, else null.""" @@ -866,11 +884,11 @@ type MetaSchema { """Provisioning scope metadata for a table""" type MetaScope { - """SQL name of the entity table for entity scopes, else null""" + """Name of the entity table for entity scopes, else null""" entityTable: String """ - Inflected scope key column (e.g. databaseId, orgId), null for global tiers + Field name of the scope key (e.g. databaseId, orgId), null for global tiers """ keyColumn: String @@ -879,7 +897,7 @@ type MetaScope { """ scope: String! - """Provenance of the scope metadata (always 'smartTag')""" + """Where this scope metadata comes from""" source: String! """Coarse bucket: 'global', 'database', or 'entity'""" @@ -910,7 +928,7 @@ type MetaSearchColumn { name: String! } -"""Per-table search configuration from @searchConfig smart tag""" +"""Per-table search configuration""" type MetaSearchConfig { """Exponential decay factor per day""" boostRecencyDecay: Float @@ -927,7 +945,7 @@ type MetaSearchConfig { """Storage metadata for a table""" type MetaStorage { - """GraphQL type name of the plane's buckets table""" + """GraphQL type name of the paired buckets table""" bucketsType: String! """ @@ -935,7 +953,7 @@ type MetaStorage { """ downloadUrlField: String - """GraphQL type name of the plane's files table""" + """GraphQL type name of the paired files table""" filesType: String! """Whether this table is a storage buckets table""" @@ -944,13 +962,11 @@ type MetaStorage { """Whether this table is a storage files table""" isFilesTable: Boolean! - """The plane's GraphQL upload surface""" + """Upload mutations and types for this storage""" upload: MetaStorageUpload! } -""" -The GraphQL upload surface of a storage plane, derived from the registry facts the presigned-url plugin emits from -""" +"""The upload mutations and types for a storage table pair""" type MetaStorageUpload { """Per-file input type inside the bulk input""" bulkFileInputType: String! @@ -976,7 +992,7 @@ type MetaStorageUpload { """Payload type of the single upload mutation""" payloadType: String! - """Whether the upload input requires ownerId (entity-keyed plane)""" + """Whether the upload input requires ownerId""" requiresOwnerId: Boolean! } @@ -986,22 +1002,27 @@ type MetaTable { fields: [MetaField!]! foreignKeyConstraints: [MetaForeignKeyConstraint!]! - """i18n metadata (null if no @i18n tag)""" + """i18n metadata (null if the table is not translatable)""" i18n: MetaI18n indexes: [MetaIndex!]! inflection: MetaInflection! + """ + Whether this table only links rows of two other tables; the link appears in their manyToMany relations + """ + isJunction: Boolean! + """Final GraphQL output type name""" name: String! primaryKeyConstraints: [MetaPrimaryKeyConstraint!]! query: MetaQuery! - """Realtime metadata (null if no @realtime tag)""" + """Realtime metadata (null if the table has no change subscriptions)""" realtime: MetaRealtime relations: MetaRelations! schemaName: String! - """Provisioning scope metadata (null if no @scope tag)""" + """Provisioning scope metadata (null if the table has no scope)""" scope: MetaScope """Search metadata (null if no search configured)""" diff --git a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap index 15847312f3..4839d07655 100644 --- a/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap +++ b/graphql/test/__tests__/__snapshots__/graphile-test.test.ts.snap @@ -1478,7 +1478,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "i18n metadata (null if no @i18n tag)", + "description": "i18n metadata (null if the table is not translatable)", "isDeprecated": false, "name": "i18n", "type": { @@ -1490,7 +1490,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "Realtime metadata (null if no @realtime tag)", + "description": "Realtime metadata (null if the table has no change subscriptions)", "isDeprecated": false, "name": "realtime", "type": { @@ -1502,7 +1502,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "Provisioning scope metadata (null if no @scope tag)", + "description": "Provisioning scope metadata (null if the table has no scope)", "isDeprecated": false, "name": "scope", "type": { @@ -1511,6 +1511,22 @@ based pagination. May not be used with \`last\`.", "ofType": null, }, }, + { + "args": [], + "deprecationReason": null, + "description": "Whether this table only links rows of two other tables; the link appears in their manyToMany relations", + "isDeprecated": false, + "name": "isJunction", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + }, + }, + }, ], "inputFields": null, "interfaces": [], @@ -1658,6 +1674,62 @@ based pagination. May not be used with \`last\`.", "ofType": null, }, }, + { + "args": [], + "deprecationReason": null, + "description": "Values this field accepts, when the set is fixed; null otherwise. Set on a table's own fields only.", + "isDeprecated": false, + "name": "allowedValues", + "type": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null, + }, + }, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Whether a create must provide this field. Set on a table's own fields only.", + "isDeprecated": false, + "name": "isRequiredOnCreate", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Whether a create may set this field. Set on a table's own fields only.", + "isDeprecated": false, + "name": "isWritableOnCreate", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + }, + }, + { + "args": [], + "deprecationReason": null, + "description": "Whether an update may change this field. Set on a table's own fields only.", + "isDeprecated": false, + "name": "isWritableOnUpdate", + "type": { + "kind": "SCALAR", + "name": "Boolean", + "ofType": null, + }, + }, ], "inputFields": null, "interfaces": [], @@ -1773,7 +1845,7 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "How a client must serialize/parse a scalar — the one field-type detail standard GraphQL introspection cannot describe. Null for plain scalars whose wire format is obvious from gqlType.", + "description": "How a client must serialize and parse a scalar whose wire format gqlType alone does not settle. Null for plain scalars whose wire format is obvious from gqlType.", "enumValues": null, "fields": [ { @@ -2845,7 +2917,7 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "Table inflection names", + "description": "Type and field names generated for this table", "enumValues": null, "fields": [ { @@ -3145,7 +3217,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "GraphQL type name of the plane's files table", + "description": "GraphQL type name of the paired files table", "isDeprecated": false, "name": "filesType", "type": { @@ -3161,7 +3233,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "GraphQL type name of the plane's buckets table", + "description": "GraphQL type name of the paired buckets table", "isDeprecated": false, "name": "bucketsType", "type": { @@ -3189,7 +3261,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "The plane's GraphQL upload surface", + "description": "Upload mutations and types for this storage", "isDeprecated": false, "name": "upload", "type": { @@ -3210,7 +3282,7 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "The GraphQL upload surface of a storage plane, derived from the registry facts the presigned-url plugin emits from", + "description": "The upload mutations and types for a storage table pair", "enumValues": null, "fields": [ { @@ -3344,7 +3416,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "Whether the upload input requires ownerId (entity-keyed plane)", + "description": "Whether the upload input requires ownerId", "isDeprecated": false, "name": "requiresOwnerId", "type": { @@ -3495,7 +3567,7 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "Per-table search configuration from @searchConfig smart tag", + "description": "Per-table search configuration", "enumValues": null, "fields": [ { @@ -3568,7 +3640,7 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "i18n metadata for a table with @i18n tag", + "description": "i18n metadata for a translatable table", "enumValues": null, "fields": [ { @@ -3662,7 +3734,7 @@ based pagination. May not be used with \`last\`.", "possibleTypes": null, }, { - "description": "Realtime metadata for a table with @realtime tag", + "description": "Realtime metadata for a table with change subscriptions", "enumValues": null, "fields": [ { @@ -3727,7 +3799,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "Inflected scope key column (e.g. databaseId, orgId), null for global tiers", + "description": "Field name of the scope key (e.g. databaseId, orgId), null for global tiers", "isDeprecated": false, "name": "keyColumn", "type": { @@ -3739,7 +3811,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "SQL name of the entity table for entity scopes, else null", + "description": "Name of the entity table for entity scopes, else null", "isDeprecated": false, "name": "entityTable", "type": { @@ -3751,7 +3823,7 @@ based pagination. May not be used with \`last\`.", { "args": [], "deprecationReason": null, - "description": "Provenance of the scope metadata (always 'smartTag')", + "description": "Where this scope metadata comes from", "isDeprecated": false, "name": "source", "type": { From 9f754241147a0d3ce72bdf50c0704f59026f8e17 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Tue, 20 Oct 2026 14:27:56 +0700 Subject: [PATCH 12/14] fix(db-tools): read provisioned app-membership policies as shared or private The backend fills sprt_table, sprt_schema and membership_type into every app-membership policy it stores, so readSchemaModel read every table the desktop built as custom access. Those keys no longer count as narrowing the policy; a membership type other than the app's still does. --- agentic/db-tools/__tests__/schema-model.test.ts | 7 +++++++ agentic/db-tools/src/schema-model.ts | 15 +++++++++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/agentic/db-tools/__tests__/schema-model.test.ts b/agentic/db-tools/__tests__/schema-model.test.ts index 3fe0c11532..ac5205b92d 100644 --- a/agentic/db-tools/__tests__/schema-model.test.ts +++ b/agentic/db-tools/__tests__/schema-model.test.ts @@ -122,6 +122,12 @@ describe('accessOf', () => { expect(accessOf(crud('AuthzAppMembership', { data: { is_admin: false, levels: [] } }))).toBe('shared'); }); + it('ignores the keys the backend fills in for an app membership, as a provisioned table stores them', () => { + const resolved = { sprt_table: 'app_memberships', sprt_schema: 'app_private', membership_type: 1 }; + expect(accessOf(crud('AuthzAppMembership', { data: resolved }))).toBe('shared'); + expect(accessOf(crud('AuthzAppMemberOwner', { data: { ...resolved, owner_field: 'owner_id' } }))).toBe('private'); + }); + it.each([ ['no policies', []], ['a missing privilege', crud('AuthzAppMembership').slice(0, 3)], @@ -130,6 +136,7 @@ describe('accessOf', () => { ['another role', crud('AuthzAppMembership', { role: 'anonymous' })], ['mixed modes', [...crud('AuthzAppMembership').slice(0, 2), ...crud('AuthzAppMemberOwner').slice(2)]], ['another policy type', crud('AuthzEntityMembership')], + ['an org membership', crud('AuthzAppMembership', { data: { membership_type: 2 } })], ])('reads %s as custom', (_case, policies) => { expect(accessOf(policies)).toBe('custom'); }); diff --git a/agentic/db-tools/src/schema-model.ts b/agentic/db-tools/src/schema-model.ts index c34fb7e687..a73320cf4a 100644 --- a/agentic/db-tools/src/schema-model.ts +++ b/agentic/db-tools/src/schema-model.ts @@ -112,6 +112,11 @@ export class SchemaReadError extends Error { const CRUD = ['select', 'insert', 'update', 'delete']; const OWNER_FIELD = 'owner_id'; +// Keys the backend fills in when it stores an app-membership policy: where the +// membership table lives, and that it's the app's (type 1, not an org's). +const RESOLVED_KEYS = new Set(['sprt_table', 'sprt_schema']); +const APP_MEMBERSHIP_TYPE = 1; + // A parameter counts only when it narrows the policy: `is_admin: false` or // `levels: []` is the same as leaving it out. const isSet = (value: unknown): boolean => @@ -121,12 +126,18 @@ const isSet = (value: unknown): boolean => ? Object.keys(value).length > 0 : Boolean(value); +/** Whether a policy parameter narrows who the policy lets in. */ +const narrows = (data: Record, key: string): boolean => { + if (key === 'owner_field' || RESOLVED_KEYS.has(key)) return false; + if (key === 'membership_type') return Number(data[key]) !== APP_MEMBERSHIP_TYPE; + return isSet(data[key]); +}; + function modeOf(policy: ModelPolicy): 'shared' | 'private' | null { if (!policy.permissive || policy.role !== 'authenticated') return null; if (policy.withCheck && isSet(policy.withCheck)) return null; const data = policy.data ?? {}; - const extras = Object.keys(data).filter((key) => key !== 'owner_field' && isSet(data[key])); - if (extras.length > 0) return null; + if (Object.keys(data).some((key) => narrows(data, key))) return null; if (policy.type === 'AuthzAppMembership') return isSet(data.owner_field) ? null : 'shared'; if (policy.type === 'AuthzAppMemberOwner') { const owner = data.owner_field; From 19f65fc8b7e6bd3985d1fc52a7c21258c33514bc Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Fri, 23 Oct 2026 11:48:08 +0700 Subject: [PATCH 13/14] fix(db-tools): wait for queued blueprint builds constructBlueprint now queues the build and returns the construction id at once (constructive-db#4156), so provision_blueprint and add_relation reported success before anything was built, and apply_template read an empty table map. buildBlueprint runs the mutation and waits on the construction: constructed returns the table map, failed throws with the coded error from the new error column. That column is read in its own query after the failure, so the status reads stay valid on a backend without it. The wait honors the tool's signal, tolerates 3 dropped reads in a row, and gives up after 240 s: the build's own 120 s statement timeout plus as long again in the queue, under the 5-minute limit hosts put on the long tools. --- agentic/db-tools/README.md | 6 + .../__tests__/blueprint-construction.test.ts | 152 +++++++++++++++ .../db-tools/src/blueprint-construction.ts | 178 ++++++++++++++++++ agentic/db-tools/src/index.ts | 8 + agentic/db-tools/src/tools/add-relation.ts | 5 +- .../db-tools/src/tools/provision-blueprint.ts | 8 +- agentic/db-tools/src/tools/templates.ts | 24 +-- 7 files changed, 354 insertions(+), 27 deletions(-) create mode 100644 agentic/db-tools/__tests__/blueprint-construction.test.ts create mode 100644 agentic/db-tools/src/blueprint-construction.ts diff --git a/agentic/db-tools/README.md b/agentic/db-tools/README.md index 046aad29d7..1b23d27a35 100644 --- a/agentic/db-tools/README.md +++ b/agentic/db-tools/README.md @@ -87,6 +87,12 @@ Host hooks for this, all optional: - `toolReprovision: false` makes the tool's `reprovision: true` fail `reprovision-not-allowed`, for a host that replaces databases only after the user approves. - `reprovisionHint` replaces the "re-provision with `reprovision: true`" advice in failure messages. +## Blueprint builds + +`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 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`. + +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. + ## Related | Package | Role | diff --git a/agentic/db-tools/__tests__/blueprint-construction.test.ts b/agentic/db-tools/__tests__/blueprint-construction.test.ts new file mode 100644 index 0000000000..52018b2f57 --- /dev/null +++ b/agentic/db-tools/__tests__/blueprint-construction.test.ts @@ -0,0 +1,152 @@ +import type { HarnessToolContext } from '@agentic-kit/harness'; + +jest.mock('../src/context', () => ({ resolveProjectContext: jest.fn() })); + +import { modules } from '@constructive-io/sdk'; + +import { buildBlueprint, waitForConstruction } from '../src/blueprint-construction'; +import { resolveProjectContext } from '../src/context'; +import { provisionBlueprintTool } from '../src/tools/provision-blueprint'; + +const mockResolve = resolveProjectContext as jest.MockedFunction; + +type Row = { status: string; tableMap?: Record }; + +// A schema from before the queued build has no `error` column, and rejects any +// query that names it. +const NO_ERROR_COLUMN = Symbol('no error column'); + +const ok = (data: unknown): modules.QueryResult => ({ ok: true, data, errors: undefined }); + +// The modules backend behind the real ORM client, so the test sees the +// documents the SDK builds. Each status read returns the next of `statuses`, +// and the last one repeats. +function backend(statuses: Row[], error: unknown = null, onRead?: () => void) { + const documents: string[] = []; + let reads = 0; + const answer = (document: string): modules.QueryResult => { + documents.push(document); + if (document.includes('constructBlueprint')) return ok({ constructBlueprint: { result: 'con-1' } }); + if (document.includes('createBlueprint')) { + return ok({ createBlueprint: { blueprint: { id: 'bp-1', name: 'library' } } }); + } + if (/\berror\b/.test(document)) { + if (error === NO_ERROR_COLUMN) { + const message = 'Cannot query field "error" on type "BlueprintConstruction".'; + return { ok: false, data: null, errors: [{ message }] }; + } + return ok({ blueprintConstructions: { nodes: [{ error }] } }); + } + onRead?.(); + const row = statuses[Math.min(reads++, statuses.length - 1)]; + return ok({ blueprintConstructions: { nodes: [row] } }); + }; + const adapter: modules.GraphQLAdapter = { + execute: async (document: string) => answer(document) as modules.QueryResult, + }; + return { client: modules.createClient({ adapter }), documents, reads: () => reads }; +} + +// A clock that only moves when the wait sleeps. +function fakeTime() { + let t = 0; + return { + now: () => t, + sleep: async (ms: number): Promise => { + t += ms; + }, + }; +} + +const INPUT = { blueprintId: 'bp-1', schemaId: 'schema-1' }; + +describe('waiting for a blueprint build', () => { + it('polls a queued build until it is constructed and returns its tables', async () => { + const { client, documents } = backend([ + { status: 'pending' }, + { status: 'constructing' }, + { status: 'constructed', tableMap: { books: 'tbl-1', authors: 'tbl-2' } }, + ]); + + await expect(buildBlueprint(client, INPUT, fakeTime())).resolves.toEqual({ + books: 'tbl-1', + authors: 'tbl-2', + }); + // The status reads never name `error`, so they stay valid on older schemas. + expect(documents.filter((doc) => /\berror\b/.test(doc))).toEqual([]); + }); + + it.each([ + [ + 'a coded error', + { code: 'BLUEPRINT_MODULE_NOT_INSTALLED', context: { module: 'agent' }, class: 'public' }, + 'The blueprint build failed: BLUEPRINT_MODULE_NOT_INSTALLED {"module":"agent"}', + ], + [ + 'a database error', + { code: '57014', message: 'canceling statement due to statement timeout' }, + 'The blueprint build failed: 57014: canceling statement due to statement timeout', + ], + ])('surfaces %s of a failed build', async (_kind, error, message) => { + const { client } = backend([{ status: 'pending' }, { status: 'failed' }], error); + + await expect(buildBlueprint(client, INPUT, fakeTime())).rejects.toThrow(message); + }); + + it('fails clearly when the schema has no error column', async () => { + const { client } = backend([{ status: 'failed' }], NO_ERROR_COLUMN); + + await expect(buildBlueprint(client, INPUT, fakeTime())).rejects.toThrow( + /^The blueprint build failed: the backend gave no reason \(reading it failed: .*Cannot query field "error"/, + ); + }); + + it('gives up at the deadline and says the build may still finish', async () => { + const { client } = backend([{ status: 'pending' }]); + + await expect(waitForConstruction(client, 'con-1', fakeTime())).rejects.toThrow( + /still waiting in the backend's build queue after 240 s\. The build may still finish/, + ); + }); + + it('stops polling as soon as the signal aborts', async () => { + const controller = new AbortController(); + const { client, reads } = backend([{ status: 'pending' }], null, () => controller.abort()); + + // A short deadline, so a wait that ignored the signal fails fast instead + // of polling for minutes. + const options = { signal: controller.signal, pollMs: 1_000, deadlineMs: 1_000 }; + await expect(waitForConstruction(client, 'con-1', options)).rejects.toThrow( + /^Stopped waiting for the blueprint build/, + ); + expect(reads()).toBe(1); + }); +}); + +describe('provision_blueprint', () => { + it('reports a failed build instead of the tables it asked for', async () => { + const { client } = backend([{ status: 'failed' }], { + code: 'RELATION_TARGET_NOT_FOUND', + context: { target_table: 'authors' }, + class: 'public', + }); + mockResolve.mockResolvedValue({ + context: { modules: client, databaseId: 'db-1', schemaId: 'schema-1', ownerId: 'user-1' }, + reason: '', + } as never); + const ctx: HarnessToolContext = { cwd: '/tmp/project' }; + + const result = await provisionBlueprintTool.execute( + { + name: 'library', + definition: { tables: [{ table_name: 'books', fields: [], policies: [] }] }, + } as never, + ctx, + ); + + expect(result.details).toMatchObject({ + created: 0, + error: 'The blueprint build failed: RELATION_TARGET_NOT_FOUND {"target_table":"authors"}', + }); + }); +}); diff --git a/agentic/db-tools/src/blueprint-construction.ts b/agentic/db-tools/src/blueprint-construction.ts new file mode 100644 index 0000000000..4eb558aea1 --- /dev/null +++ b/agentic/db-tools/src/blueprint-construction.ts @@ -0,0 +1,178 @@ +// Building a blueprint: constructBlueprint enqueues a `blueprint:construct` +// job and returns the construction id at once (constructive-db#4156). A worker +// builds one blueprint at a time, each under a 120 s statement timeout, and +// moves the construction row pending → constructing → constructed | failed, +// with a failed build's coded error in `error`. A backend from before that +// builds inside the mutation and raises the build's error from it, so the +// first read already says constructed, and the same wait serves both. + +import type { ModulesClient } from './context'; +import { isTransientProvisionError } from './provision-database/request-database'; + +/** + * How long a tool waits for its build: the build's own 120 s statement + * timeout, plus as long again for the builds queued ahead of it (a cold build + * of a curated blueprint takes 15-40 s). It stays under the 5-minute limit + * hosts put on the long database tools, so the tool reports the wait itself + * instead of being cut off. + */ +export const CONSTRUCTION_DEADLINE_MS = 240_000; +export const CONSTRUCTION_POLL_MS = 2_000; +/** + * Unreadable statuses in a row tolerated before giving up. The build goes on + * whether or not a read gets through, so a dropped request or a momentary 502 + * must not end the wait. + */ +export const MAX_CONSECUTIVE_READ_FAILURES = 3; + +const MAY_STILL_FINISH = + 'The build may still finish on the backend: read the schema before you call again.'; + +/** Table name → id, for the tables a construction created. */ +export type ConstructionTableMap = Record; + +export type WaitForConstructionOptions = { + signal?: AbortSignal; + deadlineMs?: number; + pollMs?: number; + now?: () => number; + sleep?: (ms: number, signal?: AbortSignal) => Promise; +}; + +const errorText = (err: unknown) => (err instanceof Error ? err.message : String(err)); + +function stopped(): Error { + return new Error(`Stopped waiting for the blueprint build. ${MAY_STILL_FINISH}`); +} + +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) return reject(stopped()); + const onAbort = () => { + clearTimeout(timer); + reject(stopped()); + }; + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort); + resolve(); + }, ms); + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +function tableMapOf(raw: unknown): ConstructionTableMap { + if (!raw || typeof raw !== 'object') return {}; + return Object.fromEntries( + Object.entries(raw).filter((entry): entry is [string, string] => typeof entry[1] === 'string'), + ); +} + +/** + * A failed construction's coded error on one line: `CODE: message` for a + * database error, `CODE {context}` for one raised through errors.raise_error. + */ +function describeConstructionError(error: unknown): string | null { + if (!error || typeof error !== 'object') return null; + const { code, message, context } = error as { code?: unknown; message?: unknown; context?: unknown }; + const head = [code, message].filter((part) => typeof part === 'string' && part).join(': '); + const hasContext = Boolean(context && typeof context === 'object' && Object.keys(context).length); + const tail = hasContext ? JSON.stringify(context) : ''; + return [head, tail].filter(Boolean).join(' ') || null; +} + +type ConstructionSelect = Parameters[0]['select']; + +/** + * Why a construction failed, from its `error` column. That column came with + * the queued build, and the modules SDK predates it, so it is read on its own + * after a failure: the status reads stay valid on a backend without it. The + * SDK's select type doesn't list `error`, but the ORM writes each select key + * as the field name. + */ +export async function readConstructionFailure( + modules: ModulesClient, + constructionId: string, +): Promise { + try { + const result = await modules.blueprintConstruction + .findOne({ id: constructionId, select: { error: true } as ConstructionSelect }) + .unwrap(); + const row = result.blueprintConstruction as { error?: unknown } | null; + return describeConstructionError(row?.error) ?? 'the backend gave no reason'; + } catch (err) { + return `the backend gave no reason (reading it failed: ${errorText(err)})`; + } +} + +/** + * Wait for a construction to finish. Resolves with its table map once it is + * constructed, and throws with the backend's coded error once it failed, or + * when the deadline passes, the signal aborts, or its status can't be read. + */ +export async function waitForConstruction( + modules: ModulesClient, + constructionId: string, + options: WaitForConstructionOptions = {}, +): Promise { + const { + signal, + deadlineMs = CONSTRUCTION_DEADLINE_MS, + pollMs = CONSTRUCTION_POLL_MS, + now = Date.now, + sleep = abortableSleep, + } = options; + const deadline = now() + deadlineMs; + let status = 'pending'; + let readFailures = 0; + + for (;;) { + if (signal?.aborted) throw stopped(); + let row: { status?: string | null; tableMap?: unknown } | null | undefined; + try { + const result = await modules.blueprintConstruction + .findOne({ id: constructionId, select: { status: true, tableMap: true } }) + .unwrap(); + row = result.blueprintConstruction; + readFailures = 0; + } catch (err) { + // A GraphQL error is the backend's answer and won't change on a reread. + if (!isTransientProvisionError(err) || ++readFailures >= MAX_CONSECUTIVE_READ_FAILURES) { + throw new Error( + `Could not read the blueprint build's status: ${errorText(err)}. ${MAY_STILL_FINISH}`, + ); + } + } + if (row === null) throw new Error(`The backend has no blueprint construction ${constructionId}.`); + if (row?.status === 'constructed') return tableMapOf(row.tableMap); + if (row?.status === 'failed') { + throw new Error( + `The blueprint build failed: ${await readConstructionFailure(modules, constructionId)}`, + ); + } + status = row?.status ?? status; + if (now() >= deadline) { + const where = status === 'pending' ? "waiting in the backend's build queue" : 'building'; + throw new Error( + `The blueprint build was still ${where} after ${Math.round(deadlineMs / 1000)} s. ${MAY_STILL_FINISH}`, + ); + } + await sleep(pollMs, signal); + } +} + +/** + * Construct a blueprint into a schema and wait for the build. Resolves with + * the created tables; throws with the backend's reason when it fails. + */ +export async function buildBlueprint( + modules: ModulesClient, + input: { blueprintId: string; schemaId: string }, + options: WaitForConstructionOptions = {}, +): Promise { + const result = await modules.mutation + .constructBlueprint({ input }, { select: { result: true } }) + .unwrap(); + const constructionId = result.constructBlueprint?.result; + if (!constructionId) throw new Error('constructBlueprint returned no construction id'); + return waitForConstruction(modules, constructionId, options); +} diff --git a/agentic/db-tools/src/index.ts b/agentic/db-tools/src/index.ts index 6cb21dbc15..13191c2b50 100644 --- a/agentic/db-tools/src/index.ts +++ b/agentic/db-tools/src/index.ts @@ -53,6 +53,14 @@ export function createConstructiveDbTools(host: ToolsHost): readonly AnyHarnessT return constructiveDbTools; } +export { + buildBlueprint, + CONSTRUCTION_DEADLINE_MS, + type ConstructionTableMap, + readConstructionFailure, + waitForConstruction, + type WaitForConstructionOptions, +} from './blueprint-construction'; export { CONTEXT_ENV_KEYS, CONTEXT_ENV_PREFIX, diff --git a/agentic/db-tools/src/tools/add-relation.ts b/agentic/db-tools/src/tools/add-relation.ts index 923dffd820..94222a5faa 100644 --- a/agentic/db-tools/src/tools/add-relation.ts +++ b/agentic/db-tools/src/tools/add-relation.ts @@ -1,5 +1,6 @@ import { expandBlueprintDefaults, type HarnessTool } from '@agentic-kit/harness'; +import { buildBlueprint } from '../blueprint-construction'; import { resolveProjectContext } from '../context'; import { resolveSchema, resolveTable } from '../schema-resolve'; import { type AddRelationParams as Params, AddRelationZod, buildRelation } from './add-relation-schema'; @@ -72,9 +73,7 @@ export const addRelationTool: HarnessTool; + const tableMap = await buildBlueprint( + modules, + { blueprintId, schemaId }, + { signal: ctx.signal }, + ); const createdCount = Object.keys(tableMap).length; return done( From 24fd30d7b3b84e1f679472827daee33c3b1631f7 Mon Sep 17 00:00:00 2001 From: yyyyaaa Date: Fri, 23 Oct 2026 11:57:00 +0700 Subject: [PATCH 14/14] feat(db-tools): carry the queued construction id on provision_blueprint Once constructBlueprint has queued a build, the build carries on whatever the call does next, so a host that retries a failed provision_blueprint queues the same tables again and the second build fails on the tables the first made. buildBlueprint now resolves with the construction id beside the tables, and throws every failure after the queue as a BlueprintBuildError that carries it. provision_blueprint puts the id on details.constructionId, on success and on failure, and leaves it null when nothing was queued, so a host can tell a retryable failure from one it must not run again. --- agentic/db-tools/README.md | 4 +- .../__tests__/blueprint-construction.test.ts | 79 ++++++++++++++----- .../db-tools/src/blueprint-construction.ts | 26 +++++- agentic/db-tools/src/index.ts | 1 + .../db-tools/src/tools/provision-blueprint.ts | 24 +++++- agentic/db-tools/src/tools/templates.ts | 4 +- 6 files changed, 108 insertions(+), 30 deletions(-) diff --git a/agentic/db-tools/README.md b/agentic/db-tools/README.md index 1b23d27a35..bee3ba96d5 100644 --- a/agentic/db-tools/README.md +++ b/agentic/db-tools/README.md @@ -89,10 +89,12 @@ Host hooks for this, all optional: ## Blueprint builds -`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 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`. +`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`. 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. +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. + ## Related | Package | Role | diff --git a/agentic/db-tools/__tests__/blueprint-construction.test.ts b/agentic/db-tools/__tests__/blueprint-construction.test.ts index 52018b2f57..3e1f677505 100644 --- a/agentic/db-tools/__tests__/blueprint-construction.test.ts +++ b/agentic/db-tools/__tests__/blueprint-construction.test.ts @@ -15,18 +15,32 @@ type Row = { status: string; tableMap?: Record }; // A schema from before the queued build has no `error` column, and rejects any // query that names it. const NO_ERROR_COLUMN = Symbol('no error column'); +// A status read the backend refuses. +const REFUSED = Symbol('refused'); +// A construct request that never gets an answer. +const DROPPED = Symbol('dropped'); + +type Backend = { + statuses: ReadonlyArray; + error?: unknown; + construct?: typeof DROPPED; + onRead?: () => void; +}; const ok = (data: unknown): modules.QueryResult => ({ ok: true, data, errors: undefined }); // The modules backend behind the real ORM client, so the test sees the // documents the SDK builds. Each status read returns the next of `statuses`, // and the last one repeats. -function backend(statuses: Row[], error: unknown = null, onRead?: () => void) { +function backend({ statuses, error = null, construct, onRead }: Backend) { const documents: string[] = []; let reads = 0; const answer = (document: string): modules.QueryResult => { documents.push(document); - if (document.includes('constructBlueprint')) return ok({ constructBlueprint: { result: 'con-1' } }); + if (document.includes('constructBlueprint')) { + if (construct === DROPPED) throw new TypeError('fetch failed'); + return ok({ constructBlueprint: { result: 'con-1' } }); + } if (document.includes('createBlueprint')) { return ok({ createBlueprint: { blueprint: { id: 'bp-1', name: 'library' } } }); } @@ -39,6 +53,9 @@ function backend(statuses: Row[], error: unknown = null, onRead?: () => void) { } onRead?.(); const row = statuses[Math.min(reads++, statuses.length - 1)]; + if (row === REFUSED) { + return { ok: false, data: null, errors: [{ message: 'permission denied for table blueprint_construction' }] }; + } return ok({ blueprintConstructions: { nodes: [row] } }); }; const adapter: modules.GraphQLAdapter = { @@ -62,15 +79,17 @@ const INPUT = { blueprintId: 'bp-1', schemaId: 'schema-1' }; describe('waiting for a blueprint build', () => { it('polls a queued build until it is constructed and returns its tables', async () => { - const { client, documents } = backend([ - { status: 'pending' }, - { status: 'constructing' }, - { status: 'constructed', tableMap: { books: 'tbl-1', authors: 'tbl-2' } }, - ]); + const { client, documents } = backend({ + statuses: [ + { status: 'pending' }, + { status: 'constructing' }, + { status: 'constructed', tableMap: { books: 'tbl-1', authors: 'tbl-2' } }, + ], + }); await expect(buildBlueprint(client, INPUT, fakeTime())).resolves.toEqual({ - books: 'tbl-1', - authors: 'tbl-2', + constructionId: 'con-1', + tables: { books: 'tbl-1', authors: 'tbl-2' }, }); // The status reads never name `error`, so they stay valid on older schemas. expect(documents.filter((doc) => /\berror\b/.test(doc))).toEqual([]); @@ -88,13 +107,13 @@ describe('waiting for a blueprint build', () => { 'The blueprint build failed: 57014: canceling statement due to statement timeout', ], ])('surfaces %s of a failed build', async (_kind, error, message) => { - const { client } = backend([{ status: 'pending' }, { status: 'failed' }], error); + const { client } = backend({ statuses: [{ status: 'pending' }, { status: 'failed' }], error }); await expect(buildBlueprint(client, INPUT, fakeTime())).rejects.toThrow(message); }); it('fails clearly when the schema has no error column', async () => { - const { client } = backend([{ status: 'failed' }], NO_ERROR_COLUMN); + const { client } = backend({ statuses: [{ status: 'failed' }], error: NO_ERROR_COLUMN }); await expect(buildBlueprint(client, INPUT, fakeTime())).rejects.toThrow( /^The blueprint build failed: the backend gave no reason \(reading it failed: .*Cannot query field "error"/, @@ -102,7 +121,7 @@ describe('waiting for a blueprint build', () => { }); it('gives up at the deadline and says the build may still finish', async () => { - const { client } = backend([{ status: 'pending' }]); + const { client } = backend({ statuses: [{ status: 'pending' }] }); await expect(waitForConstruction(client, 'con-1', fakeTime())).rejects.toThrow( /still waiting in the backend's build queue after 240 s\. The build may still finish/, @@ -111,7 +130,10 @@ describe('waiting for a blueprint build', () => { it('stops polling as soon as the signal aborts', async () => { const controller = new AbortController(); - const { client, reads } = backend([{ status: 'pending' }], null, () => controller.abort()); + const { client, reads } = backend({ + statuses: [{ status: 'pending' }], + onRead: () => controller.abort(), + }); // A short deadline, so a wait that ignored the signal fails fast instead // of polling for minutes. @@ -124,18 +146,12 @@ describe('waiting for a blueprint build', () => { }); describe('provision_blueprint', () => { - it('reports a failed build instead of the tables it asked for', async () => { - const { client } = backend([{ status: 'failed' }], { - code: 'RELATION_TARGET_NOT_FOUND', - context: { target_table: 'authors' }, - class: 'public', - }); + async function provision(client: ReturnType['client']) { mockResolve.mockResolvedValue({ context: { modules: client, databaseId: 'db-1', schemaId: 'schema-1', ownerId: 'user-1' }, reason: '', } as never); const ctx: HarnessToolContext = { cwd: '/tmp/project' }; - const result = await provisionBlueprintTool.execute( { name: 'library', @@ -143,10 +159,31 @@ describe('provision_blueprint', () => { } as never, ctx, ); + return result.details; + } - expect(result.details).toMatchObject({ + it('reports a failed build instead of the tables it asked for', async () => { + const { client } = backend({ + statuses: [{ status: 'failed' }], + error: { code: 'RELATION_TARGET_NOT_FOUND', context: { target_table: 'authors' }, class: 'public' }, + }); + + expect(await provision(client)).toMatchObject({ created: 0, error: 'The blueprint build failed: RELATION_TARGET_NOT_FOUND {"target_table":"authors"}', }); }); + + // A queued build carries on without the call, so the host must not call + // again; a construct that never answered queued nothing. + it.each([ + ['its status could not be read after the build was queued', { statuses: [REFUSED] }, 'con-1'], + ['the construct request never answered', { statuses: [], construct: DROPPED }, null], + ] as const)('carries the construction id only once a build is queued: %s', async (_case, setup, id) => { + const { client } = backend(setup); + + const details = await provision(client); + expect(details.error).toBeTruthy(); + expect(details.constructionId).toBe(id); + }); }); diff --git a/agentic/db-tools/src/blueprint-construction.ts b/agentic/db-tools/src/blueprint-construction.ts index 4eb558aea1..81f5429713 100644 --- a/agentic/db-tools/src/blueprint-construction.ts +++ b/agentic/db-tools/src/blueprint-construction.ts @@ -160,19 +160,39 @@ export async function waitForConstruction( } } +/** + * A build that failed after the backend queued it, or that the wait gave up + * on. The construction carries on without the caller, so constructing again + * would queue the same tables a second time. + */ +export class BlueprintBuildError extends Error { + readonly constructionId: string; + + constructor(message: string, constructionId: string) { + super(message); + this.name = 'BlueprintBuildError'; + this.constructionId = constructionId; + } +} + /** * Construct a blueprint into a schema and wait for the build. Resolves with - * the created tables; throws with the backend's reason when it fails. + * the construction and the created tables. Once the backend has queued the + * build, every failure is a BlueprintBuildError with its construction id. */ export async function buildBlueprint( modules: ModulesClient, input: { blueprintId: string; schemaId: string }, options: WaitForConstructionOptions = {}, -): Promise { +): Promise<{ constructionId: string; tables: ConstructionTableMap }> { const result = await modules.mutation .constructBlueprint({ input }, { select: { result: true } }) .unwrap(); const constructionId = result.constructBlueprint?.result; if (!constructionId) throw new Error('constructBlueprint returned no construction id'); - return waitForConstruction(modules, constructionId, options); + try { + return { constructionId, tables: await waitForConstruction(modules, constructionId, options) }; + } catch (err) { + throw new BlueprintBuildError(errorText(err), constructionId); + } } diff --git a/agentic/db-tools/src/index.ts b/agentic/db-tools/src/index.ts index 13191c2b50..1689c7504a 100644 --- a/agentic/db-tools/src/index.ts +++ b/agentic/db-tools/src/index.ts @@ -54,6 +54,7 @@ export function createConstructiveDbTools(host: ToolsHost): readonly AnyHarnessT } export { + BlueprintBuildError, buildBlueprint, CONSTRUCTION_DEADLINE_MS, type ConstructionTableMap, diff --git a/agentic/db-tools/src/tools/provision-blueprint.ts b/agentic/db-tools/src/tools/provision-blueprint.ts index 4c57f6c39e..3bc127b66a 100644 --- a/agentic/db-tools/src/tools/provision-blueprint.ts +++ b/agentic/db-tools/src/tools/provision-blueprint.ts @@ -6,7 +6,7 @@ import { type HarnessTool, } from '@agentic-kit/harness'; -import { buildBlueprint } from '../blueprint-construction'; +import { BlueprintBuildError, buildBlueprint } from '../blueprint-construction'; import { resolveProjectContext } from '../context'; type Params = Blueprint; @@ -30,6 +30,12 @@ export type ProvisionBlueprintDetails = { total: number; tables: ProvisionedTable[]; error: string | null; + /** + * The construction the backend queued, also when the build then failed or + * the wait gave up: the build carries on, so calling again would build the + * same tables twice. Null when nothing was queued. + */ + constructionId: string | null; }; function formatText(name: string, details: ProvisionBlueprintDetails): string { @@ -58,7 +64,13 @@ export const provisionBlueprintTool: HarnessTool r.source_table === t.table_name).length, })), error: null, + constructionId, }; return { content: [{ type: 'text', text: formatText(params.name, details) }], details }; } catch (err) { @@ -121,6 +138,7 @@ export const provisionBlueprintTool: HarnessTool