-
Notifications
You must be signed in to change notification settings - Fork 8
fix(nextly): introspection resolves a table the way an unqualified write does #1721
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
mobeenabdullah
merged 2 commits into
main
from
fix/pg-introspection-resolves-like-the-writer
Sep 10, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,43 @@ | ||
| --- | ||
| "@nextlyhq/adapter-drizzle": patch | ||
| "@nextlyhq/adapter-mysql": patch | ||
| "@nextlyhq/adapter-postgres": patch | ||
| "@nextlyhq/adapter-sqlite": patch | ||
| "@nextlyhq/admin": patch | ||
| "@nextlyhq/admin-css": patch | ||
| "@nextlyhq/blocks-engine": patch | ||
| "@nextlyhq/blocks-react": patch | ||
| "@nextlyhq/builder": patch | ||
| "create-nextly-app": patch | ||
| "@nextlyhq/eslint-config": patch | ||
| "@nextlyhq/eslint-plugin": patch | ||
| "@nextlyhq/module-specifiers": patch | ||
| "nextly": patch | ||
| "@nextlyhq/plugin-form-builder": patch | ||
| "@nextlyhq/plugin-page-builder": patch | ||
| "@nextlyhq/plugin-sdk": patch | ||
| "@nextlyhq/plugin-seo": patch | ||
| "@nextlyhq/prettier-config": patch | ||
| "@nextlyhq/storage-s3": patch | ||
| "@nextlyhq/storage-uploadthing": patch | ||
| "@nextlyhq/storage-vercel-blob": patch | ||
| "@nextlyhq/telemetry": patch | ||
| "@nextlyhq/tsconfig": patch | ||
| "@nextlyhq/ui": patch | ||
| --- | ||
|
|
||
| On PostgreSQL, Nextly reads a database's current shape to work out what a | ||
| migration should change. Those reads looked in a schema called `public`, while | ||
| every statement Nextly writes is unqualified and lands wherever the connection's | ||
| `search_path` points. On a deployment that uses its own schema — one per tenant, | ||
| or just a house convention — the two disagreed. | ||
|
|
||
| The reads now resolve a table the same way the writes do, quoting the name first | ||
| so one whose spelling carries capitals — which a custom table name may — resolves | ||
| to itself rather than to nothing. Nothing changes for a database that uses | ||
| `public`, which is the default. | ||
|
|
||
| What it fixes on the others: columns that exist read as absent, so a migration | ||
| offered to add what was already there; and where a table of the same name existed | ||
| in `public`, its shape answered for the real one — comparing against a table | ||
| nothing writes to. |
140 changes: 140 additions & 0 deletions
140
...tly/src/domains/schema/pipeline/diff/__tests__/introspect-search-path.integration.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,140 @@ | ||
| /** | ||
| * Introspection must read the table the WRITES hit, not one named `public`. | ||
| * | ||
| * Every statement this package emits is unqualified — the DDL that created the | ||
| * tables and the DDL the diff generates — so PostgreSQL resolves it across the | ||
| * whole `search_path`. A read filtered on a schema NAME asks a different | ||
| * question, and the answers diverge exactly where it matters: a deployment whose | ||
| * search path is `tenant, public`. | ||
| * | ||
| * Only a real database can establish this. The predicate is SQL the server | ||
| * evaluates, and every failure mode is silent — columns that exist read as | ||
| * absent, so the diff proposes to add what is already there; or a same-named | ||
| * table in another schema answers for this one. | ||
| * | ||
| * 🔴 A DECOY, not an empty schema. Pointing the search path somewhere empty | ||
| * would prove only that something was found somewhere; a test that cannot tell | ||
| * "read the right table" from "read any table" passes on a predicate that names | ||
| * `public` outright. | ||
| * | ||
| * 🔴 ONE SESSION, held open for the whole file. `PostgresAdapter.getDrizzle()` | ||
| * wraps a `pg.Pool`, so `SET search_path` binds to whichever client served that | ||
| * statement and the next `execute()` may run on another — which would make this | ||
| * file assert against the decoy at random. A `Client` is one connection by | ||
| * construction, so the search path this sets is the one every read below uses. | ||
| * | ||
| * @module domains/schema/pipeline/diff/__tests__/introspect-search-path.integration | ||
| */ | ||
| import { sql } from "drizzle-orm"; | ||
| import { drizzle } from "drizzle-orm/node-postgres"; | ||
| import { Client } from "pg"; | ||
| import { afterAll, beforeAll, describe, expect, it } from "vitest"; | ||
|
|
||
| import { queryLiveColumnTypes } from "../../live-column-types"; | ||
| import { introspectLiveSnapshot } from "../introspect-live"; | ||
|
|
||
| const URL = process.env.TEST_POSTGRES_URL ?? ""; | ||
|
|
||
| // Dialect gate, matching the other PostgreSQL integration tests in this package. | ||
| const describePg = describe.skipIf(!URL); | ||
|
|
||
| const TENANT = "nx_search_path_probe"; | ||
| const TABLE = "nx_search_path_subject"; | ||
| // Uppercase on purpose: `resolveCollectionTableName` passes an author's `dbName` | ||
| // through verbatim, so a real table can carry capitals — and an unquoted name | ||
| // handed to `to_regclass` is folded to lower case and resolves to nothing. | ||
| const MIXED_CASE = "nx_SearchPath_Mixed"; | ||
|
|
||
| describePg("introspection follows the search path (postgres)", () => { | ||
| let client: Client | undefined; | ||
| // Typed from the call rather than from `typeof drizzle`, whose default | ||
| // overload infers a Pool-backed client and does not accept this one. | ||
| let db: ReturnType<typeof drizzle<Record<string, never>, Client>>; | ||
|
|
||
| beforeAll(async () => { | ||
| if (!URL) return; | ||
| client = new Client({ connectionString: URL }); | ||
| await client.connect(); | ||
| db = drizzle({ client }); | ||
|
|
||
| const run = (text: string) => client!.query(text); | ||
|
|
||
| await run(`DROP TABLE IF EXISTS public."${TABLE}"`); | ||
| await run(`DROP SCHEMA IF EXISTS ${TENANT} CASCADE`); | ||
| await run(`CREATE SCHEMA ${TENANT}`); | ||
|
|
||
| // The decoy, in `public`: same name, a column the real one does not have. | ||
| // A read pinned to `public` finds THIS one and reports its shape. | ||
| await run(`CREATE TABLE public."${TABLE}" (decoy_only integer)`); | ||
| // The subject, in the tenant schema, carrying a different column. | ||
| await run(`CREATE TABLE ${TENANT}."${TABLE}" (subject_only text)`); | ||
| await run(`CREATE INDEX ix_subject ON ${TENANT}."${TABLE}" (subject_only)`); | ||
| // And one whose name survives only if it is quoted before it is resolved. | ||
| await run(`CREATE TABLE ${TENANT}."${MIXED_CASE}" (mixed_only text)`); | ||
|
|
||
| // What a deployment on a non-default search path looks like. | ||
| await run(`SET search_path TO ${TENANT}, public`); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| }); | ||
|
|
||
| afterAll(async () => { | ||
| if (client === undefined) return; | ||
| await client.query(`SET search_path TO public`); | ||
| await client.query(`DROP TABLE IF EXISTS public."${TABLE}"`); | ||
| await client.query(`DROP SCHEMA IF EXISTS ${TENANT} CASCADE`); | ||
| await client.end(); | ||
| }); | ||
|
|
||
| it("proves the session is the one that was configured", async () => { | ||
| // The control for every assertion below. If the search path were not the | ||
| // one set in `beforeAll` — a pooled connection, a reset — the decoy would | ||
| // answer and the failures would look like defects in the predicate. | ||
| const shown = await db.execute(sql`SHOW search_path`); | ||
| const rows = (shown as unknown as { rows: { search_path: string }[] }).rows; | ||
| expect(rows[0]?.search_path).toContain(TENANT); | ||
| }); | ||
|
|
||
| it("reads the columns of the table an unqualified write would hit", async () => { | ||
| const live = await introspectLiveSnapshot(db, "postgresql", [TABLE]); | ||
| const columns = live.tables | ||
| .find(t => t.name === TABLE) | ||
| ?.columns.map(c => c.name); | ||
|
|
||
| expect(columns).toEqual(["subject_only"]); | ||
| // Named separately: the decoy's column appearing IS the defect, and saying | ||
| // so makes a failure readable rather than a diff of two arrays. | ||
| expect(columns).not.toContain("decoy_only"); | ||
| }); | ||
|
|
||
| it("reads the indexes of that table, not a same-named one elsewhere", async () => { | ||
| const live = await introspectLiveSnapshot(db, "postgresql", [TABLE]); | ||
| const indexes = live.tables.find(t => t.name === TABLE)?.indexes ?? []; | ||
|
|
||
| // The decoy carries no index, so a read pinned to `public` reports none — | ||
| // which reads as "this table has no indexes" rather than as an error. | ||
| expect(indexes.map(i => i.name)).toContain("ix_subject"); | ||
| }); | ||
|
|
||
| it("resolves a table whose name carries capitals", async () => { | ||
| // 🔴 `to_regclass` reparses its argument as an identifier reference, so an | ||
| // unquoted `nx_SearchPath_Mixed` folds to lower case and resolves to | ||
| // nothing — and the table reads as ABSENT, which makes a diff propose to | ||
| // create one that already exists. | ||
| const live = await introspectLiveSnapshot(db, "postgresql", [MIXED_CASE]); | ||
| const columns = live.tables | ||
| .find(t => t.name === MIXED_CASE) | ||
| ?.columns.map(c => c.name); | ||
|
|
||
| expect(columns).toEqual(["mixed_only"]); | ||
| }); | ||
|
|
||
| it("reports live column TYPES from the same table", async () => { | ||
| // A second query with the same predicate, feeding the type diff. Pinned to | ||
| // `public` it answers for the decoy: a column the subject does not have, | ||
| // and none of the ones it does. | ||
| const types = await queryLiveColumnTypes(db, "postgresql", [TABLE]); | ||
| const forTable = types.get(TABLE); | ||
|
|
||
| expect(forTable?.get("subject_only")).toBe("text"); | ||
| expect(forTable?.has("decoy_only")).toBe(false); | ||
| }); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
64 changes: 64 additions & 0 deletions
64
packages/nextly/src/domains/schema/pipeline/pg-visible-relation.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| /** | ||
| * Which PostgreSQL relation a read is about, decided the way the WRITES decide. | ||
| * | ||
| * Every statement this package emits is unqualified — the DDL that created a | ||
| * table and the DDL a diff generates — so PostgreSQL resolves it across the | ||
| * whole `search_path`. A read that filters on a schema NAME asks a different | ||
| * question, and the two answers separate exactly where it matters: a deployment | ||
| * whose search path is `tenant, public`. | ||
| * | ||
| * 🔴 `current_schema()` is not the answer either. It is only the FIRST entry of | ||
| * the search path, so a table in `public` reached from `tenant, public` is | ||
| * written by the ALTER and missed by the read. `live-table-facts.ts` settled | ||
| * this for the same reason and resolves through `to_regclass`; this is that rule | ||
| * for the reads that go through `information_schema` rather than through an OID. | ||
| * | ||
| * The failures it prevents are silent and point the wrong way. Columns that | ||
| * exist read as absent, so a diff offers to add what is already there. And where | ||
| * a same-named table exists in another schema on the path, ITS shape answers for | ||
| * the real one — so the diff compares against a table nothing writes to. | ||
| * | ||
| * ## Why the name is quoted before it is resolved | ||
| * | ||
| * 🔴 `to_regclass` takes TEXT and reparses it as an identifier reference, so an | ||
| * unquoted catalog name is folded and split exactly as if it had been typed: | ||
| * `dc_LegacyPosts` resolves as `dc_legacyposts`, and a name containing a dot | ||
| * resolves as `schema.table`. Both are reachable — `resolveCollectionTableName` | ||
| * passes an author's `dbName` through verbatim, prefixing `dc_` and nothing | ||
| * else — and both would make an existing table read as ABSENT, which is the | ||
| * worst answer this module can give: a diff that proposes to create a table that | ||
| * is already there. | ||
| * | ||
| * `quote_ident` is the inverse of that parse, so the text goes back in as the | ||
| * one identifier the catalog says it is. | ||
| * | ||
| * @module domains/schema/pipeline/pg-visible-relation | ||
| */ | ||
| import { sql, type SQL } from "drizzle-orm"; | ||
|
|
||
| /** | ||
| * True for the one `information_schema.columns` row set whose relation an | ||
| * unqualified statement would reach. | ||
| * | ||
| * 🔴 Written against the alias `c`, because a predicate over | ||
| * `information_schema` has to name that view's own columns and they cannot be | ||
| * passed as parameters. Every caller aliases the view `c`; there are two, both | ||
| * in this directory, and a caller that aliases it otherwise gets a SQL error | ||
| * rather than a wrong answer — which is the safe direction for a mistake that | ||
| * would otherwise change WHICH TABLE is reported. | ||
| * | ||
| * Compared by identity rather than by spelling: `format('%I.%I', …)::regclass` | ||
| * is this row's relation, `to_regclass(…)` is the one the search path resolves, | ||
| * and the equality asks whether they are the same relation. The same device the | ||
| * sequence-ownership check in `introspect-live.ts` already uses. | ||
| */ | ||
| export const PG_RELATION_THE_WRITES_HIT: SQL = sql`format('%I.%I', c.table_schema, c.table_name)::regclass = to_regclass(quote_ident(c.table_name))`; | ||
|
|
||
| /** | ||
| * The same question asked of a `pg_class` row, which carries the OID directly. | ||
| * | ||
| * Spelled here rather than inline at the index query so the two reads cannot | ||
| * drift: they must agree about which relation they are describing, or the | ||
| * columns come from one table and the indexes from another. | ||
| */ | ||
| export const PG_CLASS_IS_THE_RELATION_THE_WRITES_HIT: SQL = sql`t.oid = to_regclass(quote_ident(t.relname))`; |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When this PostgreSQL integration suite runs,
runsends all setup DDL directly throughpg.Client.query, bypassing the repository's required Drizzle-only database-access path; the followingCREATE TABLEstatements are also hand-copied fixture DDL. Keep the dedicatedClientfor session affinity, but execute the setup through the wrapped Drizzle connection and derive table DDL through the production helpers rather than introducing a second DDL implementation.AGENTS.md reference: AGENTS.md:L274-L276
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed on the access path and fixed in #1727: setup and teardown now go through
db.executeon the client-bound instance, so the statements that establish thesession travel the same path the reads do. The
Clientstays and is still closedin
afterAll— it is what pins the session.On deriving the DDL from production helpers, I have not done that, and I would
rather say so plainly than be quietly partial.
These tables are synthetic — a decoy and a subject that exist only to be resolved
by the search path, with no production counterpart. The hazard the rule guards is
a fixture copying a real table's definition and then drifting from it, and there
is nothing here to drift from. Deriving them from a real table would couple the
file to that table's shape and change what it measures.
Checked rather than assumed — this repository's own PostgreSQL integration tests
create synthetic probe tables inline for the same reason:
getSchemaEventsDdl, the helper AGENTS.md names, builds one specific productiontable. There is no helper for a two-column decoy, and adding one would be the
second DDL implementation the advice warns against.
The reasoning is now in the file's docblock so the next reader does not have to
re-litigate it. Happy to be overruled if the rule is meant to cover synthetic
fixtures too — in that case the right fix is a shared probe-table helper rather
than reusing a production table's shape, and that is worth its own change.