Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0ec14b8
fix(nextly): keep the recording generation monotonic across resets
mobeenabdullah Jul 28, 2026
cd0773a
fix(nextly): stop localized writes overwriting default-locale content
mobeenabdullah Jul 28, 2026
c7f7fdb
fix(nextly): seed the companion so existing content stays readable
mobeenabdullah Jul 28, 2026
638dc16
fix(nextly): backfill missing default-locale rows and respect no-auto…
mobeenabdullah Jul 29, 2026
b9acaf2
fix(nextly): seed only during the localization transition
mobeenabdullah Jul 29, 2026
0bcc530
refactor(nextly): read companion shape through schema introspection
mobeenabdullah Jul 29, 2026
904690b
fix(nextly): provision companions on the reload path and guard produc…
mobeenabdullah Jul 29, 2026
ca77085
fix(nextly): carry a newly localized field's shared value to the comp…
mobeenabdullah Jul 29, 2026
c3d470e
fix(nextly): keep unattended companion reconciliation additive and gated
mobeenabdullah Jul 29, 2026
197e2cc
fix(nextly): provision companions before the HMR schema apply
mobeenabdullah Jul 29, 2026
4e2317e
fix(nextly): refuse localized field-group writes without a companion
mobeenabdullah Jul 29, 2026
addc310
fix(nextly): re-provision companions after the HMR apply creates tables
mobeenabdullah Jul 29, 2026
8ab99d8
refactor(nextly): split companion seeding out of the write guard
mobeenabdullah Jul 29, 2026
25a1f5d
fix(nextly): probe the companion before splitting a localized write
mobeenabdullah Jul 29, 2026
b8bdba5
fix(nextly): verify main columns before the pre-companion fallback
mobeenabdullah Jul 29, 2026
d0968b4
fix(nextly): resolve companion existence before the write transaction
mobeenabdullah Jul 29, 2026
380a257
fix(nextly): scope the companion guard to payloads that need it
mobeenabdullah Jul 29, 2026
f149baa
fix(nextly): back out the shared-only bypass and backfill localized s…
mobeenabdullah Jul 29, 2026
c13ec61
fix(nextly): drop the status reconcile, which cannot be made retryable
mobeenabdullah Jul 30, 2026
e33135f
fix(nextly): check every localized column and survive concurrent prov…
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
31 changes: 31 additions & 0 deletions .changeset/localized-write-companion-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"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
---

Saving a translation could overwrite the original language. `nextly db:sync` marks a collection as localized in a separate process from the running app, so the app could show the language switcher before its translations table existed — and a translation saved in that window wrote over the original-language values and changed the entry's URL, while reporting success.

The translations table is now prepared during `db:sync` and during a dev config reload, for collections, singles and field groups alike. If it is still missing, a write in a non-default language is refused with a clear message instead of overwriting anything, and the same refusal now covers singles and embedded field groups rather than only collections.

Writing the default language before the table exists still goes to the main table as before. The one exception is content that was localized from the start, whose translatable values have never had a main-table column to fall back to: saving that while the translations table is missing used to fail with a database error, and now reports the same clear message as the case above.

Collections and singles that set a custom `dbName` are handled correctly here too; previously their translations table could be created against a table name that does not exist. And a database that is unreachable or refusing connections is no longer reported as a missing translations table.
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
/**
* `nextly db:sync` must create the companion `_locales` table in the SAME run.
*
* The push pipeline deliberately does not manage companion tables
* (`managed-tables.isCompanionTable`), and `ensureCompanionTable` — written as
* the db:sync/dev-boot counterpart to migration-owned creation — used to be
* called only at boot. Because `db:sync` runs in its own CLI process, it flipped
* the registry's `localized` flag and left companion creation to whenever the app
* next started. A server already running then read `localized: 1`, registered the
* companion in its runtime registry, and rendered the whole localization UI over
* a table that did not exist. Writes in that window overwrote the default
* language.
*
* So the assertion is specifically that the table exists when the sync sequence
* returns, not that it exists eventually.
*
* This drives the real `syncCollections` → `syncSingles` → `syncComponents` →
* `ensureLocalizedCompanions` sequence against a real SQLite file, because
* ORDER is the thing that broke: a companion carries a foreign key to its main
* table, so running the hook before singles and components are pushed creates
* nothing for them. What it does NOT prove is that `db-sync.ts` and
* `dev-watcher.ts` still call the hook — that wiring is a single line in each,
* checked by reading them.
*/
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

import {
defineCollection,
defineConfig,
defineSingle,
text,
} from "../../../config";
import { createAdapter } from "../../../database/factory";
import { getDialectTables } from "../../../database/index";
import { SchemaRegistry } from "../../../database/schema-registry";
import { createLogger } from "../../utils/logger";
import {
ensureLocalizedCompanions,
syncCollections,
syncComponents,
syncSingles,
} from "../dev-build";
import { ensureCoreTables } from "../dev-server";

import type { DrizzleAdapter } from "@nextlyhq/adapter-drizzle/types";
import type { CLIDatabaseAdapter } from "../../utils/adapter";
import type { CommandContext } from "../../program";
import type { LoadConfigResult } from "../../utils/config-loader";
import type { ResolvedDevOptions } from "../db-sync";

let dir: string;
let adapter: Awaited<ReturnType<typeof createAdapter>> | undefined;
let previousDialect: string | undefined;

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), "nextly-dbsync-companion-"));
previousDialect = process.env.DB_DIALECT;
});

afterEach(async () => {
await adapter?.disconnect();
adapter = undefined;
// Integration files share one fork, so a dialect left set here would be read
// by every file that follows.
if (previousDialect === undefined) delete process.env.DB_DIALECT;
else process.env.DB_DIALECT = previousDialect;
rmSync(dir, { recursive: true, force: true });
});

/** Physical presence, probed the same way the write path probes it. */
async function tableExists(name: string): Promise<boolean> {
try {
await adapter?.executeQuery(`SELECT 1 FROM "${name}" LIMIT 0`);
return true;
} catch {
return false;
}
}

/** The command's own sequence, minus the parts that read from disk. */
async function runSync(config: LoadConfigResult["config"]): Promise<void> {
process.env.DB_DIALECT = "sqlite";
adapter = await createAdapter({
type: "sqlite",
url: `file:${join(dir, "test.db")}`,
} as Parameters<typeof createAdapter>[0]);

const logger = createLogger({ quiet: true });
const options = { cwd: dir, autoSync: true } as ResolvedDevOptions;
const context = { logger, options: {}, cwd: dir } as CommandContext;
// The real adapter is a DrizzleAdapter; `CLIDatabaseAdapter` declares only the
// handful of methods the command signatures need, so the command file converts
// between them exactly like this.
const cli = adapter as unknown as CLIDatabaseAdapter;
const drizzleAdapter = adapter as unknown as DrizzleAdapter;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Without a resolver the ORM cannot address `dynamic_collections`, so the sync
// fails before it reaches anything this test is about.
const registry = new SchemaRegistry("sqlite");
registry.registerStaticSchemas(getDialectTables("sqlite"));
drizzleAdapter.setTableResolver(registry);
await ensureCoreTables(cli, options, context);

const configResult = { config } as LoadConfigResult;
await syncCollections(configResult, cli, options, context);
await syncSingles(configResult, cli, options, context);
await syncComponents(configResult, cli, options, context);
await ensureLocalizedCompanions(config, cli, context);
}

describe("db:sync creates localized companion tables in-process (integration)", () => {
it("creates a localized collection's companion in the same run", async () => {
await runSync(
defineConfig({
localization: { locales: ["en", "es"], defaultLocale: "en" },
collections: [
defineCollection({
slug: "dbsync_posts",
localized: true,
fields: [text({ name: "title", localized: true })],
}),
],
})
);

expect(await tableExists("dc_dbsync_posts")).toBe(true);
// The assertion that fails without the fix: the push pipeline creates the
// main table and nothing in this process creates the companion.
expect(await tableExists("dc_dbsync_posts_locales")).toBe(true);
});

it("creates a localized single's companion, which needs singles synced first", async () => {
await runSync(
defineConfig({
localization: { locales: ["en", "es"], defaultLocale: "en" },
singles: [
defineSingle({
slug: "dbsync_homepage",
localized: true,
fields: [text({ name: "headline", localized: true })],
}),
],
})
);

// Singles are force-prefixed with `single_` by `resolveSingleTableName`.
// Asserting the main table too means a prefix change cannot make the companion
// check vacuously pass against a name nothing ever creates.
expect(await tableExists("single_dbsync_homepage")).toBe(true);
expect(await tableExists("single_dbsync_homepage_locales")).toBe(true);
});

it("resolves a custom dbName the way the runtime does", async () => {
// A collection's `dbName` is force-prefixed with `dc_` by the canonical
// resolver, so `dbName: "dbsync_notes"` lives at `dc_dbsync_notes`. Taking
// `dbName` verbatim instead builds `dbsync_notes_locales` with a foreign key to
// a `dbsync_notes` table that does not exist — the create fails, the warning is
// swallowed, and the entity is left marked localized with nowhere to put
// translations.
await runSync(
defineConfig({
localization: { locales: ["en", "es"], defaultLocale: "en" },
collections: [
defineCollection({
slug: "dbsync_field_notes",
dbName: "dbsync_notes",
localized: true,
fields: [text({ name: "title", localized: true })],
}),
],
})
);

expect(await tableExists("dc_dbsync_notes")).toBe(true);
expect(await tableExists("dc_dbsync_notes_locales")).toBe(true);
expect(await tableExists("dbsync_notes_locales")).toBe(false);
});

it("leaves a non-localized collection with no companion", async () => {
await runSync(
defineConfig({
collections: [
defineCollection({
slug: "dbsync_logs",
fields: [text({ name: "title" })],
}),
],
})
);

expect(await tableExists("dc_dbsync_logs")).toBe(true);
// Creating companions unconditionally would strand a dead table in every
// project that does not use localization.
expect(await tableExists("dc_dbsync_logs_locales")).toBe(false);
});
});
14 changes: 14 additions & 0 deletions packages/nextly/src/cli/commands/db-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ import {
import { formatDuration } from "../utils/logger";

import {
ensureLocalizedCompanions,
performPermissionSeeding,
syncCollections,
syncComponents,
Expand Down Expand Up @@ -302,6 +303,19 @@ export async function runDbSync(
await syncSingles(configResult, adapter, options, context);
await syncComponents(configResult, adapter, options, context);

// Step 5.6: Create the `_locales` companion of every localized entity, which
// the push pipeline does not manage. Runs after all three syncs because the
// companion references its main table. Without this, `db:sync` left the
// registry saying "localized" with no table to hold translations until the
// app next booted, and writes in that window overwrote the default language.
//
// Gated on the same flag as the rest of the schema push: this issues DDL and
// can copy rows, so `--no-auto-sync` — chosen precisely to keep physical
// schema changes in migration files — must suppress it too.
if (options.autoSync !== false) {
await ensureLocalizedCompanions(configResult.config, adapter, context);
Comment thread
mobeenabdullah marked this conversation as resolved.
}

if (collectionCount === 0) {
logger.warn("No collections defined in config");
logger.info("Add collections to your nextly.config.ts to get started.");
Expand Down
Loading
Loading