Skip to content
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
cfaf32b
refactor(nextly): make the recorded plan the authority a resume executes
mobeenabdullah Jul 30, 2026
f67bfaa
feat(nextly): pair each table rename with its registry pointer update
mobeenabdullah Jul 30, 2026
900f883
fix(nextly): refuse schema sync while a storage migration is in flight
mobeenabdullah Jul 30, 2026
39b6b64
feat(nextly): observe migration storage through the existing introspe…
mobeenabdullah Jul 30, 2026
211d827
chore(release): add changeset for the field group migration DDL slice
mobeenabdullah Jul 30, 2026
a79df78
fix(nextly): bind pointer updates the way each driver expects, and ch…
mobeenabdullah Jul 30, 2026
9d8ce40
fix(nextly): resolve introspected table names under the server's case…
mobeenabdullah Jul 30, 2026
cfda80c
fix(nextly): move a companion with its owner and the pointer together
mobeenabdullah Jul 30, 2026
2de2115
fix(nextly): re-check the migration marker on every watched re-sync
mobeenabdullah Jul 30, 2026
d02993e
fix(nextly): identify a resume by registry row, not by slug alone
mobeenabdullah Jul 30, 2026
3c7d601
fix(nextly): gather a column step's observations before its transaction
mobeenabdullah Jul 30, 2026
179e7f1
fix(nextly): scope postgres index introspection to the public schema
mobeenabdullah Jul 30, 2026
a2caac5
feat(adapter-drizzle): run drizzle statements against tables the regi…
mobeenabdullah Jul 30, 2026
e728527
fix(nextly): move a companion on its owner's entry rather than beside it
mobeenabdullah Jul 30, 2026
22400f7
docs(release): describe the migration's per-dialect commit behaviour …
mobeenabdullah Jul 30, 2026
abfec9a
fix(nextly): include a row's companion in the migration's registry id…
mobeenabdullah Jul 30, 2026
1a48785
fix(nextly): hold the migration exclusion for a whole sync rather tha…
mobeenabdullah Jul 30, 2026
f3f1fff
fix(nextly): tie a recorded migration step to the build that produced it
mobeenabdullah Jul 30, 2026
1839483
fix(adapter-drizzle): refuse an unrecognised statement result instead…
mobeenabdullah Jul 30, 2026
dc5626d
fix(nextly): treat a database with no meta table as one that never mi…
mobeenabdullah Jul 30, 2026
82c116d
test(nextly): cover a field group repointed at a target its run has n…
mobeenabdullah Jul 30, 2026
38359b3
fix(nextly): keep an interrupted schema sync from stranding the migra…
mobeenabdullah Jul 30, 2026
ff987ec
fix(nextly): reach the migration lock table without the schema registry
mobeenabdullah Jul 30, 2026
0e1a40e
fix(nextly): keep a settled marker readable across marker versions
mobeenabdullah Jul 30, 2026
3866d07
fix(nextly): hold a migration's claim through an interrupt, release o…
mobeenabdullah Jul 30, 2026
27442f6
test(nextly): exercise the migration lock against a live postgres server
mobeenabdullah Jul 30, 2026
85b1963
fix(nextly): keep an in-flight migration away from HMR and boot-time …
mobeenabdullah Jul 30, 2026
9255f6c
fix(nextly): hold the migration exclusion across an HMR reload, not j…
mobeenabdullah Jul 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/field-group-migration-ddl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/ui": patch
"@nextlyhq/adapter-drizzle": patch
"@nextlyhq/adapter-postgres": patch
"@nextlyhq/adapter-mysql": patch
"@nextlyhq/adapter-sqlite": patch
"@nextlyhq/storage-s3": patch
"@nextlyhq/storage-uploadthing": patch
"@nextlyhq/storage-vercel-blob": patch
"@nextlyhq/plugin-form-builder": patch
"@nextlyhq/plugin-page-builder": patch
"@nextlyhq/plugin-seo": patch
"@nextlyhq/plugin-sdk": patch
"@nextlyhq/eslint-config": patch
"@nextlyhq/prettier-config": patch
"@nextlyhq/telemetry": patch
"@nextlyhq/tsconfig": patch
---

Fixes PostgreSQL index introspection reading indexes from the wrong table. Table names are unique per schema rather than per database, so a table with the same name in another schema had its indexes merged into the one being inspected. That could hide an index that needed creating, or report one that was never there.

Refuses to run a schema sync while a field group storage migration is in flight. Mid-run some tables carry their old names and some their new ones, and the registry rows pointing at them move one step at a time, so a sync during that window could delete storage it could not account for.

Also further groundwork for that migration: it can now execute its rename steps and check its own work. A table, its localization companion and the registry row pointing at them move as one step, and on PostgreSQL and SQLite they commit together. MySQL applies a schema change as soon as it is issued, so there the halves land in sequence and a resume completes whatever did not; a reader in that window sees a table as missing rather than reading anything wrong. Every step verifies against the database rather than trusting that it ran, and index survival is checked by name, so an index dropped and replaced by another is caught rather than passing on an unchanged count. Nothing calls the migration itself yet.
55 changes: 54 additions & 1 deletion packages/adapter-drizzle/src/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
*/

import { asc, desc, getColumns } from "drizzle-orm";
import type { AnyRelations } from "drizzle-orm";
import type { AnyRelations, SQL } from "drizzle-orm";

import { buildDrizzleWhere } from "./drizzle-where";
import type {
Expand Down Expand Up @@ -249,6 +249,59 @@ export abstract class DrizzleAdapter {
return this.tableResolver?.getTable(tableName) ?? null;
}

/**
* Run a Drizzle-built statement and return its rows.
*
* @remarks
* The CRUD methods resolve their table through the schema registry and reject
* any name it does not declare, which leaves no way to read from a table the
* ORM does not know — one mid-rename, above all — except by assembling SQL and
* quoting identifiers by hand. This takes Drizzle's `sql` template instead, so
* the dialect in use decides the quoting and the parameter binding.
*
* Concrete rather than abstract so existing adapters keep working unchanged.
* The three drivers disagree about both the call and the result: node-postgres
* returns `{ rows }`, mysql2 a `[rows, fields]` tuple, and better-sqlite3 has
* no `execute` at all and answers `all`. Keeping that here rather than at each
* call site is the point — a caller reasoning about it would be reasoning
* about a driver it cannot see.
*
* @param statement - Drizzle `sql` template to run
* @returns Rows the statement produced
*/
async queryStatement<T = Record<string, unknown>>(
statement: SQL
): Promise<T[]> {
if (this.getCapabilities().dialect === "sqlite") {
const db = this.getDrizzle<{ all(query: SQL): T[] | Promise<T[]> }>();
return await db.all(statement);
}
const db = this.getDrizzle<{ execute(query: SQL): Promise<unknown> }>();
const result = await db.execute(statement);
// `{ rows }` is node-postgres. A bare array is mysql2's `[rows, fields]`
// tuple, told apart by its first element being an array — the only test that
// does not depend on trusting one driver to keep its shape.
if (Array.isArray(result)) {
return (Array.isArray(result[0]) ? result[0] : result) as T[];
}
if (
typeof result === "object" &&
result !== null &&
Array.isArray((result as { rows?: unknown }).rows)
) {
return (result as { rows: T[] }).rows;
}
// Refused rather than reported as no rows. All three drivers answer one of
// the shapes above, so anything else means the result was not understood —
// and "I could not read this" must not reach a caller as "there is nothing
// there", which is an answer it would act on.
throw this.createDatabaseError(
"query",
"Drizzle statement returned a result shape this adapter does not recognise; refusing to report it as an empty result.",
undefined
);
}

/**
* Map data keys from SQL column names (snake_case) to Drizzle JS property names (camelCase).
* Drizzle schemas define columns as e.g. `createdAt: timestamp("created_at")` — the JS
Expand Down
39 changes: 39 additions & 0 deletions packages/adapter-drizzle/src/types/transaction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
* @packageDocumentation
*/

import type { SQL } from "drizzle-orm";

import type { SqlParam } from "./core";
import type {
SelectOptions,
Expand Down Expand Up @@ -81,6 +83,43 @@ export interface TransactionContext {
*/
execute<T = unknown>(sql: string, params?: SqlParam[]): Promise<T[]>;

/**
* Run a Drizzle-built statement within the transaction, for its effect.
*
* @remarks
* The typed CRUD methods below resolve their table through the schema
* registry and reject any name it does not declare. That leaves no way to
* write to a table addressed under a name the ORM does not know — a table
* mid-rename, most of all — other than assembling SQL by hand, which also
* means hand-picking each driver's placeholder syntax.
*
* This accepts Drizzle's `sql` template instead, so identifier quoting and
* parameter binding are generated for the dialect in use. Implemented per
* adapter because the underlying call is not uniform: node-postgres and
* mysql2 expose `execute`, while better-sqlite3 needs `run` and throws on a
* statement that returns no rows.
*
* Returns nothing: this is for statements run to change data, not to read it.
*
* @param statement - Drizzle `sql` template to run
*/
runStatement(statement: SQL): Promise<void>;

/**
* Run a Drizzle-built statement within the transaction and return its rows.
*
* @remarks
* The reading half of `runStatement`, for the same reason: a table the schema
* registry does not declare cannot be reached through the typed CRUD methods,
* which reject the name outright. Implemented per adapter because the drivers
* disagree about both the call and the result — node-postgres answers
* `{ rows }`, mysql2 a `[rows, fields]` tuple, and better-sqlite3 needs `all`.
*
* @param statement - Drizzle `sql` template to run
* @returns Rows the statement produced
*/
queryStatement<T = Record<string, unknown>>(statement: SQL): Promise<T[]>;

/**
* Take an exclusive lock on a single row for the rest of this transaction.
*
Expand Down
28 changes: 27 additions & 1 deletion packages/adapter-mysql/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import {
type SslConfig,
} from "@nextlyhq/adapter-drizzle/types";
import { checkDialectVersion } from "@nextlyhq/adapter-drizzle/version-check";
import type { AnyRelations } from "drizzle-orm";
import type { AnyRelations, SQL } from "drizzle-orm";
import { drizzle, type MySql2Database } from "drizzle-orm/mysql2";
import type {
Pool as CallbackPool,
Expand Down Expand Up @@ -780,6 +780,32 @@ export class MySqlAdapter extends DrizzleAdapter {
return rows as T[];
},

// Run on the transaction-bound Drizzle instance rather than the pool, so
// the statement is part of this transaction and sees its uncommitted rows.
runStatement: async (statement: SQL): Promise<void> => {
await txDb().execute(statement);
},

// mysql2 answers a `[rows, fields]` tuple; the transaction-bound instance
// keeps the read inside this transaction so it sees its uncommitted writes.
queryStatement: async <T = Record<string, unknown>>(
statement: SQL
): Promise<T[]> => {
const result = await txDb().execute(statement);
// A tuple's first element is the rows. Anything else was not understood,
// and must not reach a caller as "there is nothing there" — the same
// reason the pooled `queryStatement` refuses rather than answering
// empty.
if (!Array.isArray(result)) {
throw this.createDatabaseError(
"query",
"Drizzle statement returned a result shape this adapter does not recognise; refusing to report it as an empty result.",
undefined
);
}
return result[0] as unknown as T[];
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.

lockRow: async (table: string, id: SqlParam): Promise<void> => {
const idColumn = this.escapeIdentifier("id");
await connection.query(
Expand Down
17 changes: 16 additions & 1 deletion packages/adapter-postgres/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ import {
isDatabaseError,
} from "@nextlyhq/adapter-drizzle/types";
import { checkDialectVersion } from "@nextlyhq/adapter-drizzle/version-check";
import type { AnyRelations } from "drizzle-orm";
import type { AnyRelations, SQL } from "drizzle-orm";
import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres";
import type { PoolClient, PoolConfig } from "pg";
import { Pool } from "pg";
Expand Down Expand Up @@ -936,6 +936,21 @@ export class PostgresAdapter extends DrizzleAdapter {
return result.rows as T[];
},

// Run on the transaction-bound Drizzle instance rather than the pool, so
// the statement is part of this transaction and sees its uncommitted rows.
runStatement: async (statement: SQL): Promise<void> => {
await txDb().execute(statement);
},

// node-postgres answers `{ rows }`; the transaction-bound instance keeps
// the read inside this transaction so it sees its uncommitted writes.
queryStatement: async <T = Record<string, unknown>>(
statement: SQL
): Promise<T[]> => {
const result = await txDb().execute(statement);
return result.rows as T[];
},

lockRow: async (table: string, id: SqlParam): Promise<void> => {
const idColumn = this.escapeIdentifier("id");
await client.query(
Expand Down
21 changes: 20 additions & 1 deletion packages/adapter-sqlite/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ import {
} from "@nextlyhq/adapter-drizzle/types";
import { checkDialectVersion } from "@nextlyhq/adapter-drizzle/version-check";
import type Database from "better-sqlite3";
import type { AnyRelations } from "drizzle-orm";
import type { AnyRelations, SQL } from "drizzle-orm";
import {
drizzle,
type BetterSQLite3Database,
Expand Down Expand Up @@ -714,6 +714,25 @@ export class SqliteAdapter extends DrizzleAdapter {
// lock up front and serializes writers for the whole transaction.
lockRow: (): Promise<void> => Promise.resolve(),

// `run`, not `all`: better-sqlite3 throws on a statement that returns no
// rows, which is every statement this method exists to carry. The Drizzle
// instance is the transaction-bound one, so the statement runs inside this
// transaction.
//
// better-sqlite3 is synchronous, so this resolves an already-settled
// promise rather than being declared `async` over a body that never
// awaits.
runStatement: (statement: SQL): Promise<void> => {
txDb().run(statement);
return Promise.resolve();
},

// `all`, not `run`: this is the reading half, and better-sqlite3 returns
// rows only from `all`. Synchronous, so the promise is already settled.
queryStatement: <T = Record<string, unknown>>(
statement: SQL
): Promise<T[]> => Promise.resolve(txDb().all(statement)),

// eslint-disable-next-line @typescript-eslint/require-await
execute: async <T = unknown>(
sql: string,
Expand Down
Loading
Loading