Skip to content

fix(graphile-meta): count only generated values as system columns in junction detection - #1943

Merged
pyramation merged 1 commit into
mainfrom
devin/1793177113-junction-constant-default
Oct 29, 2026
Merged

pyramation merged 1 commit into
mainfrom
devin/1793177113-junction-constant-default

Conversation

@pyramation

@pyramation pyramation commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Rebased onto main now that #1919 has merged. The diff is this one commit.

isSystemColumn treated any hasDefault column as system, so a link table with a value column like quantity integer NOT NULL DEFAULT 1 was reported as isJunction: true. Now a column counts as system only when the database generates its value:

// before
if (attr.hasDefault) return true;
// after
if (attr.extensions?.generatedValue) return true;

generatedValue = attidentity || attgenerated || (adbin && !isConstantDefault(adbin))

How generatedValue is set. The introspection doesn't include pg_attrdef, so a pgIntrospection_introspection gather hook runs one query (adrelid, adnum, adbin for tables with defaulted columns), and pgCodecs_attribute sets the flag. Nothing new is exposed in _meta.

Why this signal. isConstantDefault is an allow-list on the adbin node tree:

  • Constant: CONST, plus casts around constants (RELABELTYPE, COERCEVIAIO, COERCETODOMAIN, ARRAYCOERCEEXPR, ARRAYEXPR, and FUNCEXPR only when it's an explicit or implicit cast, such as numeric(10,2) DEFAULT 0).
  • Generated: everything else, including function calls (now(), gen_random_uuid()), SQL value functions (CURRENT_TIMESTAMP, current_user), operators and unknown nodes. Serial counts too, via nextval(...). Identity and generated columns come from attidentity / attgenerated.

Column names are never checked. Treating every function call as generated is the safe direction: a constant built from a function call (DEFAULT lower('X')) means a junction is missed, never wrongly reported.

Tests (graphile/graphile-settings/__tests__/meta-form-fields.integration.test.ts, form-meta.test.ts):

  • order_items(order_id, product_id, quantity DEFAULT 1) → isJunction: false, no manyToMany. This fails before the fix.
  • product_locations with bigserial, identity, a stored generated column, CURRENT_TIMESTAMP and current_user → still a junction.
  • default_kinds: the classifier runs against real adbin values for literals, casts and generated defaults.

Publish after merge: graphile-meta, and graphile-settings to pick up the new version.

Link to Devin session: https://app.devin.ai/sessions/79f7fb78569b4a2cacec066edd47b62a
Open in Devin Desktop: https://app.devin.ai/desktop/session/79f7fb78569b4a2cacec066edd47b62a?variant=devin
Requested by: @pyramation

@devin-ai-integration

Copy link
Copy Markdown
Contributor

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@tenki-reviewer

tenki-reviewer Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Review complete. 🟡 4 medium

💬 Inline comments (2)

  • 🟡 Per-project lock is process-local; .env writes non-atomic and world-readable — provision.ts:131
  • 🟡 Empty quoted env value swallows the following lines — env-file.ts:39
📍 Findings outside the diff (2) — 🟡 2 medium — defects on lines GitHub can't attach comments to

🟡 Medium — Unvalidated database_name written unquoted into .env · provision-database.ts:14–18 · unchanged line

// agentic/db-tools/src/tools/provision-database.ts
14	  database_name: z
15	    .string()
16	    .describe(
17	      'Name for the app database (lowercase, alphanumeric + underscores). Doubles as the deterministic provisioning subdomain (api-<name>/auth-<name>/app-<name>.localhost), so keep it short and URL-safe.',
18	    ),

The provision_database tool documents database_name as "lowercase, alphanumeric + underscores" but the zod schema is a plain z.string() with no pattern (agentic/db-tools/src/tools/provision-database.ts:14-18), and provision() only trims it (agentic/db-tools/src/provision-database/provision.ts:226). mergeEnv concatenates values raw, so DATABASE_NAME/PGDATABASE are written without quoting (agentic/db-tools/src/provision-database/env-file.ts:62,70). A name containing a newline or # breaks out of its line and injects or rewrites arbitrary .env keys (e.g. DATABASE_ID, ACCESS_TOKEN), corrupting the binding the next call trusts; even a benign name with spaces, written in place over a previously quoted value, changes its parsed meaning.


🟡 Medium — Account-mismatch check hides db-suspended state · context.ts:214–229 · unchanged line

// agentic/db-tools/src/context.ts
214	  if (
215	    options.plane !== 'data' &&
216	    probe.ownerId &&
217	    sessionUserId &&
218	    probe.ownerId !== sessionUserId
219	  ) {
220	    return {
221	      context: null,
222	      reason: `The bound database (DATABASE_ID=${databaseId}) was provisioned under a different account than the one signed in. ${reprovisionAdvice(host)}`,
223	      code: 'account-mismatch',
224	    };
225	  }

A suspended database's probe result carries ownerId, and in both consumers the ownership check runs before the suspended check, so a suspended database owned by a different account reports account-mismatch with reprovision advice instead of db-suspended (agentic/db-tools/src/context.ts:214-225, agentic/db-tools/src/provision-database/provision.ts:302-308). The agent is then told to re-provision a database that is merely suspended, yet the replace path itself refuses suspended bindings (checkReplace), so the advice dead-ends and the real, recoverable condition is never surfaced.

🧹 Nitpicks (1) — 🟢 1 low
  • 🟢 Blueprint poller tolerates one fewer transient read than documented (blueprint-construction.ts:139) — waitForConstruction gives up on the third consecutive transient status-read failure because it tests ++readFailures >= MAX_CONSECUTIVE_READ_FAILURES with a budget of 3 (agentic/db-tools/src/blueprint-construction.ts:139), although the constant's contract says three unreadable statuses are tolerated.

The change spans two areas: graphile-meta gains form-facing metadata (check-constraint allowed values, per-field writability/required-on-create, many-to-many junction exposure) with matching tests and snapshot updates, and agentic/db-tools hardens the provisioning pipeline (project lock, pending-ticket resume, suspended/account-mismatch classification, env-file writing) plus a new schema-model reader and composed-app codegen flow.

Files Change
graphile/graphile-meta/src/* Form metadata: allowedValues extraction, writability/required flags, junction meta builders, type mappings, snapshot updates.
agentic/db-tools/src/provision-database/* Provisioning state machine: project lock, pending-ticket resume, polling deadline, .env merge/remove.
agentic/db-tools/src/tools/*, context.ts, db-probe.ts, graphql-http.ts, host.ts Tool harness: probe status rework, api-key minting, run-codegen, scaffolding helpers.
agentic/db-tools/src/schema-model*, blueprint-construction.ts Schema-model reader and blueprint construction wait logic.
agentic/db-tools/__tests__/*, graphile-settings integration test New unit/integration tests covering provisioning, probe, codegen, and form metadata.

Reviewed commit: 126d36c

@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.

This PR adds form metadata to the _meta GraphQL schema (allowed values, required-on-create/writability flags, junction detection) and extends agentic/db-tools with provisioning guards, resume handling, an api-key/db-probe rework, and a schema-model reader.

Key findings

Comment on lines +131 to +132
await writeFile(envPath, edit(await readText(envPath)));
}

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

Per-project lock is process-local; .env writes non-atomic and world-readable

withProjectLock serializes callers through a module-local Map (agentic/db-tools/src/provision-database/provision.ts:166-180), so two OS processes (CLI + server, or two agent sessions) provisioning the same project each acquire their own "lock" and interleave their .env read-modify-writes via the non-atomic updateEnvFile (provision.ts:131). A crash mid-write also leaves a truncated .env, destroying unrelated user keys and the DATABASE_ID binding the resume logic depends on — prompting a second database, the exact accident the lock claims to prevent. Additionally, when .env does not yet exist the write creates it with default umask permissions, putting the ACCESS_TOKEN bearer in a typically world-readable file.

📋 Prompt for AI Agents

In agentic/db-tools/src/provision-database/provision.ts: (1) rewrite updateEnvFile (lines 130-132) to read the existing file's mode (default 0o600), write the edited content to ${envPath}.tmp-${process.pid}, and rename it over envPath so every .env transition is crash-atomic and the ACCESS_TOKEN bearer is not world-readable. (2) Extend withProjectLock (lines 166-180) to also acquire a cross-process advisory lock on a lock file under the project cwd (e.g. proper-lockfile or O_EXCL create with retry) before running fn, releasing it in the finally, because the module-level Map only serializes calls within one process.

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 · medium

Empty quoted env value swallows the following lines

In envEntries, the multiline-quoted-value detection treats KEY='' (and any value whose first char is a quote with no closing quote on the same line) as an open quoted span (agentic/db-tools/src/provision-database/env-file.ts:39-44): value.slice(1) of '' contains no quote, so the span loop consumes every following line until one happens to contain a quote character or EOF. mergeEnv, removeEnvKeys and archiveBindingKeys then rewrite or delete that whole span, clobbering unrelated user keys during any pending-ticket clear, binding write, or reprovision archive.

📋 Prompt for AI Agents

In agentic/db-tools/src/provision-database/env-file.ts envEntries lines 39-44, replace the multiline detection QUOTES.has(value[0]) && !value.slice(1).includes(value[0]) with a check that counts occurrences of value[0] in the whole value (an even count > 0 means closed on the same line) so KEY='' and KEY="" are single-line entries. Also stop the span loop at EOF instead of silently absorbing the rest of the file for an unterminated quote. Add unit cases for KEY='', KEY="", and unterminated quotes.

@devin-ai-integration
devin-ai-integration Bot force-pushed the devin/1793177113-junction-constant-default branch from 126d36c to e980be6 Compare October 29, 2026 04:51
@pyramation
pyramation merged commit b836d41 into main Oct 29, 2026
19 checks passed
@pyramation
pyramation deleted the devin/1793177113-junction-constant-default branch October 29, 2026 05:10
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