Skip to content

feat(db-tools,graphile-meta): composed-app codegen, resumable provisions and form metadata in _meta - #1919

Open
yyyyaaa wants to merge 12 commits into
mainfrom
feat/fast-app-path
Open

yyyyaaa wants to merge 12 commits into
mainfrom
feat/fast-app-path

Conversation

@yyyyaaa

@yyyyaaa yyyyaaa commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

Backend and tooling changes behind Constructive Desktop's fast app path: db-tools works on an app composed from a template, and _meta gains the form-level facts a generated form needs.

db-tools

  • Codegen on a composed app. When packages/app already exists, run_codegen leaves its package.json and lockfile alone. ensureComposedInstall installs it from its own lockfile (--frozen-lockfile --prefer-offline, and a no-op when a finished install is already there), and run_codegen 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 reports its timings and exports their types.
  • Bounded provision polling, resumable provisions. A host can set provisionPollDeadlineMs when the caller sits 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.
  • Never a second database by accident. provisionDatabase runs under a per-project lock and treats DATABASE_ID alone as a binding. A ticket that reads back empty is looked up again by name and never re-requested. Replace mode is a compare-and-swap on the database it replaces, and adopt binds your own database when its name is already taken. Failures carry a code, such as db-missing, db-suspended, binding-live or name-taken with heldBy.
  • Database health. probeBoundDatabase returns one of four states: found (with the schema hash), suspended, missing, or unreachable with a cause. An unrecognised error is never reported as missing. resolveProjectContext gains db-suspended and the hash.
  • The live schema of a project. readSchemaModel reads the app tables with stable ids. For each column it reads the normalised type, whether it's required, the default and the allowed values. It also reads foreign keys with their delete actions, and the access the policies amount to: shared, private or custom. The read is all or nothing.
  • Hooks for hosts. toolReprovision: false stops the agent's reprovision: true. persistBinding saves the binding before .env is written. reprovisionHint changes the advice in messages.
  • ACCESS_SUSPENDED stays visible. add_records, run_codegen and create_api_key return code: 'database-unavailable' instead of a generic 403, and run_codegen no longer retries one.

graphile-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.
  • Nothing internal is exposed. _meta is public, so it adds only form-level facts: no SQL, constraint, trigger or smart-tag internals, and the descriptions are written as public copy.
  • Existing fields are unchanged. This was checked against _meta captured before the change on two local-stack databases.
{ _meta { tables { name isJunction fields { name allowedValues isRequiredOnCreate isWritableOnUpdate } } } }

Release

  • db-tools: Lerna publishes it as 0.11.0 after this merges (0.10.6 is already on npm without these changes). Constructive Desktop's fast app branch depends on ensureComposedInstall, the probe, the schema model and the provision guard, and bumps to ^0.11.0.
  • graphile-meta: ships in the next constructive image. Deployed stacks serve the new fields once constructive-db bumps its image pin (ci/pins.json and the platform-graphql-server image strings). Clients ask for the new fields only when the backend serves them, so nothing breaks before the bump.

🤖 Generated with Claude Code

https://claude.ai/code/session_01TzSs1SgpRVHH81mRAK3ecK

…ockfile 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.
…ng 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.
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.
@tenki-reviewer

tenki-reviewer Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review complete. 🟠 1 high · 🟡 2 medium

💬 Inline comments (3)


This PR wires a resumable database-provisioning flow into agentic/db-tools: pending tickets persisted to .env, a 240s poll deadline, ownership probing before binding, and a codegen tool that validates workspace installs. In parallel, graphile/graphile-meta gains CHECK-constraint allowed-value extraction via a new pg_node_tree/varlena parser, detected junction relations, shared column-exposure helpers, and write-input metadata, with matching test and snapshot updates.

Files Change
agentic/db-tools/src/provision-database/* New ticket state machine: request/poll/resume semantics, pending-ticket persistence, and .env read/merge/remove/archive helpers.
agentic/db-tools/src/tools/*, src/app-workspace.ts, src/host.ts, src/index.ts Tool wiring for provision and codegen commands, workspace detection, and host-level config plumbing.
agentic/db-tools/__tests__/* Tests for provision resume, request-database classification, and run-codegen install checks.
graphile/graphile-meta/src/* New check-constraint allowed-values, pg_node_tree decoder, exposure helpers, junction/relation meta builders, type mappings, and plugin wiring.
graphile/graphile-meta/__tests__/*, graphile/graphile-settings/__tests__/* Form-meta tests, snapshots, fixtures, and an integration test for meta form fields.

Reviewed commit: c1f8ee6

@blacksmith-sh

This comment has been minimized.

@tenki-reviewer tenki-reviewer Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adds a provisioning resume/ticket state machine and .env binding persistence to agentic/db-tools, plus a form-metadata expansion (check-constraint allowed values, junction relations, node-tree parsing) in graphile/graphile-meta.

Key findings

Comment on lines +39 to +44
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;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 bug · high

Unterminated quote makes .env edits swallow later entries

envEntries treats any value starting with a quote/backtick that is not closed on the same line as a multi-line value and consumes all subsequent lines into that entry (agentic/db-tools/src/provision-database/env-file.ts:39-44), but the parser it claims to mirror does not: node:util.parseEnv('A="unterm B=2') yields {A: '"unterm', B: '2'}, ending the value at end-of-line. For a hand-edited .env with an unterminated quote, mergeEnv replacing that key rewrites the whole span as one line (deleting every later definition), removeEnvKeys drops those lines entirely, and archiveBindingKeys comments out live entries belonging to other keys. Impact: a reprovision or refresh silently destroys unrelated user content in the project .env.

📋 Prompt for AI Agents

In agentic/db-tools/src/provision-database/env-file.ts lines 37-44, tighten the multi-line span detection in envEntries to mirror node:util.parseEnv: only open a span when the closing quote appears as the last non-comment character of the value on a later line, and if no such terminator exists treat the entry as single-line (the parser ends an unterminated quoted value at end-of-line — verified: parseEnv('A="unterm\nB=2') === {A:'"unterm', B:'2'}). Add a unit test: for content 'A="unterm\nB=2\nC=3', mergeEnv(content, {A:'x'}) must preserve B=2 and C=3, and removeEnvKeys(content, ['A']) must keep B and C.

Comment on lines +231 to +236
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.`,
);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 security · medium

Missing ownerId handled inconsistently across ownership checks

The two ownership checks treat an absent probe.ownerId in opposite ways. The skip path fails open: a found probe with a null owner is accepted as account-owned and the current account bearer is written into the project .env (agentic/db-tools/src/tools/provision-database.ts:232), so a foreign or hand-edited binding with no owner evidence is adopted. The post-provision path fails hard: owner.ownerId !== ownerId rejects a database this account just provisioned when the probe answers with a null owner (agentic/db-tools/src/tools/provision-database.ts:364), clearing the pending ticket and telling the agent to request a new database that will hit the name-taken path. Impact: either an unverified binding is adopted, or a successful provision is abandoned.

📋 Prompt for AI Agents

In agentic/db-tools/src/tools/provision-database.ts, unify the handling of a missing ownerId across the two ownership checks. Skip path (lines 231-236): when probe.outcome === 'found' but probe.ownerId is missing, or host.account()?.userId is missing, return a fail() saying the binding's ownership could not be verified and to re-run with reprovision: true, instead of silently skipping and refreshing .env ACCESS_TOKEN. Post-provision path (line 364): change owner.outcome === 'missing' || owner.ownerId !== ownerId so an undefined ownerId does not count as a foreign account — e.g. owner.outcome === 'missing' || (owner.ownerId && owner.ownerId !== ownerId) — since the database was just provisioned under the signed-in account.

Comment on lines +201 to +208
let ticket =
resumedTicket ??
(await executor.request({
databaseName: args.databaseName,
subdomain: args.databaseName,
domain: args.domain,
request: args.request,
}));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 bug · medium

Null resume poll starts a duplicate provision

When a pending ticket is resumed, requestDatabaseProvision polls it once and falls back to a brand-new executor.request if the poll returns null (agentic/db-tools/src/provision-database/request-database.ts:201-208). The backend provision job behind the old ticket is still running, so a transient empty poll answer triggers a second provision request for the same databaseName, and the new ticket id is recorded — the duplicate the resume feature exists to prevent. Impact: an orphaned in-flight provision and either a name-taken failure or a duplicate database on the next call.

📋 Prompt for AI Agents

In agentic/db-tools/src/provision-database/request-database.ts lines 190-208, guard the resume path: when args.resumeTicketId is set and the initial executor.poll returns null (or throws), do not fall through to executor.request — throw ProvisionPendingError (for transient errors) or a 'could not be read' error carrying the resumeTicketId so the caller keeps the pending record and retries, instead of minting a second provision request for the same databaseName. Add a test that a null resume poll never triggers executor.request.

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.
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.
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.
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.
…nd 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'.
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant