Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
03e2b32
feat(nextly): add the single metadata service with intent-first ordering
mobeenabdullah Aug 4, 2026
103c173
refactor(nextly): move the single companion reconcile out of the handler
mobeenabdullah Aug 4, 2026
c18d391
feat(nextly): create a single's table and its registry row in one ser…
mobeenabdullah Aug 4, 2026
66792c4
test(nextly): prove a single create reaches the database on every dia…
mobeenabdullah Aug 4, 2026
3ac67ce
chore: add changeset for the single create schema-change service
mobeenabdullah Aug 4, 2026
54fda4c
refactor(nextly): name the create result concretely
mobeenabdullah Aug 4, 2026
cd86b7a
fix(nextly): let a builder migration be re-run after it stops half way
mobeenabdullah Aug 5, 2026
0413573
chore: widen the changeset to the mysql retry fix
mobeenabdullah Aug 5, 2026
ba8bf38
perf(nextly): load the schema machinery on demand in the single metad…
mobeenabdullah Aug 5, 2026
00dcc12
fix(nextly): type the singles metadata service into the service map
mobeenabdullah Aug 5, 2026
e0927b5
fix(nextly): reject a create before it is recorded, and check the tab…
mobeenabdullah Aug 5, 2026
2881d63
fix(nextly): let a companion reconcile be re-run like the table besid…
mobeenabdullah Aug 5, 2026
ea49e6e
fix(nextly): verify a builder table matches before recording it applied
mobeenabdullah Aug 5, 2026
2add12b
fix(nextly): resume a create whose outcome was never recorded
mobeenabdullah Aug 5, 2026
7cd3325
fix(nextly): compare nullability when checking a repaired table
mobeenabdullah Aug 5, 2026
c75d556
fix(nextly): reject a repair that leaves stale columns or indexes behind
mobeenabdullah Aug 5, 2026
fa5865f
fix(nextly): adopt only a create that recorded its own failure
mobeenabdullah Aug 5, 2026
093cf83
fix(nextly): check only what the builder and the descriptor agree on
mobeenabdullah Aug 5, 2026
95948af
fix(nextly): claim a failed create atomically before retrying it
mobeenabdullah Aug 5, 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
33 changes: 33 additions & 0 deletions .changeset/single-create-schema-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
---
"nextly": patch
"create-nextly-app": patch
"@nextlyhq/admin": patch
"@nextlyhq/admin-css": patch
"@nextlyhq/blocks-engine": patch
"@nextlyhq/blocks-react": 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
---

Creating a Single from the Schema Builder now writes its table and its registry row as one
operation, so an interrupted create leaves a record of what it was doing instead of a table nothing
knows about.

On MySQL, retrying any Schema Builder migration that stopped part way now succeeds instead of
reporting the already-correct schema as a failed migration. MySQL cannot express "create this index
only if it is missing", so the retry previously failed the same way every time and left no way
forward.
3 changes: 3 additions & 0 deletions packages/nextly/src/di/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ import { builtByFor } from "../domains/schema/pipeline/registered-collections";
import type { DesiredCollection } from "../domains/schema/pipeline/types";
import type { ColumnOrigin } from "../domains/schema/services/field-column-descriptor";
import type { SingleEntryService } from "../domains/singles/services/single-entry-service";
import type { SingleMetadataService } from "../domains/singles/services/single-metadata-service";
import type {
SingleRegistryService,
CodeFirstSingleConfig,
Expand Down Expand Up @@ -327,6 +328,8 @@ export interface ServiceMap {
mediaService: UnifiedMediaService;
singleRegistryService: SingleRegistryService;
singleEntryService: SingleEntryService;
/** Owns a Single's table change together with the registry write that records it. */
singleMetadataService: SingleMetadataService;
fieldGroupRegistryService: FieldGroupRegistryService;
fieldGroupSchemaService: FieldGroupSchemaService;
fieldGroupDataService: FieldGroupDataService;
Expand Down
15 changes: 15 additions & 0 deletions packages/nextly/src/di/registrations/register-singles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { RBACAccessControlService } from "../../domains/auth/services/rbac-
import { MetaRetentionGate } from "../../domains/retention/gate";
import { buildRetentionRunner } from "../../domains/retention/passes";
import { SingleEntryService } from "../../domains/singles/services/single-entry-service";
import { SingleMetadataService } from "../../domains/singles/services/single-metadata-service";
import { SingleRegistryService } from "../../domains/singles/services/single-registry-service";
import type { WebhookFastDrainScheduler } from "../../domains/webhooks/after-drain";
import type { CacheRevalidator } from "../../revalidation/types";
Expand Down Expand Up @@ -46,6 +47,20 @@ export function registerSingleServices(ctx: RegistrationContext): void {
}
);

// Schema changes for a Single, holding the table change and the registry write together. It is
// registered rather than built per request so a single wrapper here governs every caller: the
// migration lock has to enclose both halves, and a lock applied at one call site leaves the
// others uncovered.
container.registerSingleton<SingleMetadataService>(
"singleMetadataService",
() =>
new SingleMetadataService(
container.get<SingleRegistryService>("singleRegistryService"),
logger,
adapter
)
);

container.registerSingleton<SingleEntryService>("singleEntryService", () => {
const singleRegistryService = container.get<SingleRegistryService>(
"singleRegistryService"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../helpers/di", () => ({
getSingleRegistryFromDI: vi.fn(),
getSingleEntryServiceFromDI: vi.fn(),
getSingleMetadataServiceFromDI: vi.fn(),
getComponentRegistryFromDI: vi.fn().mockReturnValue(undefined),
getAdapterFromDI: vi.fn(),
// Reached by the companion reconciliation a localized create runs. Omitted, calling it throws and
// the handler catches that as a companion-provisioning failure — so the assertions below would
// describe a FAILED migration while passing.
getConfigFromDI: vi.fn(() => undefined),
// Reached by the companion's runtime registration. Answering `undefined` is what a server with no
// schema registry does; omitting it throws inside the registration instead, which is a different
// path and not one a create ever takes.
getSchemaRegistryFromDI: vi.fn(() => undefined),
}));

const executed: string[] = [];
Expand Down Expand Up @@ -64,19 +69,34 @@ vi.mock("../../../di/container", () => ({
},
}));

import { SingleMetadataService } from "../../../domains/singles/services/single-metadata-service";
import type { SingleRegistryService } from "../../../domains/singles/services/single-registry-service";
import type { Logger } from "../../../shared/types";
import {
getSingleEntryServiceFromDI,
getSingleMetadataServiceFromDI,
getSingleRegistryFromDI,
} from "../../helpers/di";
import { dispatchSingles } from "../single-dispatcher";

/** Silent: these tests read the statements, not the log. */
const logger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};

function wireRegistry() {
const registry = {
listSingles: vi.fn(),
// Answers "no single owns that table" so the create reaches the DDL path.
getAllSingles: vi.fn().mockResolvedValue([]),
getSingleBySlug: vi.fn(),
registerSingle: vi.fn(async (row: unknown) => row),
// The confirm write. A create persists its intent as `pending` before touching the table and
// records the outcome here afterwards, so a double without it fails the whole create.
updateMigrationStatus: vi.fn(),
updateSingle: vi.fn(),
deleteSingle: vi.fn(),
};
Expand All @@ -87,24 +107,54 @@ function wireRegistry() {
get: vi.fn(),
update: vi.fn(),
} as unknown as ReturnType<typeof getSingleEntryServiceFromDI>);
// The REAL service over the same doubles, because the create path's behaviour IS this service's
// behaviour: what the request forwards into the DDL is decided inside it, and a stub standing in
// for it would leave these assertions describing the stub.
vi.mocked(getSingleMetadataServiceFromDI).mockReturnValue(
new SingleMetadataService(
registry as unknown as SingleRegistryService,
logger,
adapter as unknown as ConstructorParameters<
typeof SingleMetadataService
>[2]
)
);
return registry;
}

/** Everything the adapter was asked to run, as one string. */
/**
* Everything the adapter was asked to run, as one string.
*
* 🔴 The create's OWN recorded outcome is checked before any of it is returned, and that check is
* here rather than in each test because it is a precondition of the entire file: statements that
* ran on the way to a failure prove nothing about what a working create emits. Breaking a rule
* shows a test CAN fail; it does not show the code reached the state the test describes. A create
* that gave up part-way still leaves its earlier statements in `executed`, so every assertion below
* would keep passing while describing a run that never finished.
*
* `migration_status` is the product's own success signal — the value the admin reads back — so
* nothing is computed here that does not already exist, and a regression names itself:
* `expected 'failed' to be 'applied'`.
*/
async function ddlFor(
payload: Record<string, unknown>,
dialect: "postgresql" | "mysql" | "sqlite" = "postgresql"
): Promise<string> {
executed.length = 0;
adapter = makeAdapter(dialect);
wireRegistry();
const registry = wireRegistry();
await dispatchSingles("createSingle", {}, payload);

const recorded = registry.updateMigrationStatus.mock.calls[0]?.[1];
expect(recorded, "the create recorded its own outcome").toBe("applied");

return executed.join("\n");
}

beforeEach(() => {
vi.mocked(getSingleRegistryFromDI).mockReset();
vi.mocked(getSingleEntryServiceFromDI).mockReset();
vi.mocked(getSingleMetadataServiceFromDI).mockReset();
});

describe("createSingle — what the request forwards into the DDL", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../helpers/di", () => ({
getSingleRegistryFromDI: vi.fn(),
getSingleEntryServiceFromDI: vi.fn(),
getSingleMetadataServiceFromDI: vi.fn(),
getComponentRegistryFromDI: vi.fn().mockReturnValue(undefined),
getAdapterFromDI: vi.fn(),
}));
Expand Down Expand Up @@ -53,15 +54,28 @@ vi.mock(
}
);

import { SingleMetadataService } from "../../../domains/singles/services/single-metadata-service";
import type { SingleRegistryService } from "../../../domains/singles/services/single-registry-service";
import type { Logger } from "../../../shared/types";
import {
getSingleEntryServiceFromDI,
getSingleMetadataServiceFromDI,
getSingleRegistryFromDI,
} from "../../helpers/di";
import { dispatchSingles } from "../single-dispatcher";

/** Silent: these tests read Response shapes, not the log. */
const logger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};

type Registry = {
listSingles: ReturnType<typeof vi.fn>;
registerSingle: ReturnType<typeof vi.fn>;
updateMigrationStatus: ReturnType<typeof vi.fn>;
getSingleBySlug: ReturnType<typeof vi.fn>;
getAllSingles: ReturnType<typeof vi.fn>;
updateSingle: ReturnType<typeof vi.fn>;
Expand All @@ -76,6 +90,9 @@ function makeRegistry(overrides: Partial<Registry> = {}): Registry {
return {
listSingles: vi.fn(),
registerSingle: vi.fn(),
// A create persists its intent as `pending` and records the outcome here afterwards, so a
// double without it fails the request rather than the assertion under test.
updateMigrationStatus: vi.fn(),
getSingleBySlug: vi.fn(),
// Answers "no single owns that table" so a create reaches the path under test. The handler
// asks before it emits any DDL, so a double that cannot answer fails the request instead.
Expand All @@ -101,11 +118,21 @@ function wireDi(registry: Registry, entry: Entry) {
vi.mocked(getSingleEntryServiceFromDI).mockReturnValue(
entry as unknown as ReturnType<typeof getSingleEntryServiceFromDI>
);
// The real service over the same double, with no adapter — which is the state this file puts the
// container in, and the state that leaves a create recorded as `pending`. A stub here would make
// the pending message an assumption of the test rather than a consequence of the code.
vi.mocked(getSingleMetadataServiceFromDI).mockReturnValue(
new SingleMetadataService(
registry as unknown as SingleRegistryService,
logger
)
);
}

beforeEach(() => {
vi.mocked(getSingleRegistryFromDI).mockReset();
vi.mocked(getSingleEntryServiceFromDI).mockReset();
vi.mocked(getSingleMetadataServiceFromDI).mockReset();
});

describe("dispatchSingles, paginated lists (respondList)", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -851,6 +851,9 @@ const COLLECTIONS_METHODS: Record<
}
}
}
// 🔴 STRICT on purpose, matching the other two companion paths: tolerating a re-run
// would make a half-finished localization ENABLE look like success, because the planner
// cannot tell that state from an orphan repair. A loud failure is the safer outcome.
for (const stmt of plan.statements) {
await adapter.executeQuery(stmt);
}
Expand Down
71 changes: 44 additions & 27 deletions packages/nextly/src/dispatcher/handlers/component-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { buildCompanionRuntimeTable } from "../../domains/i18n/runtime/companion
import { translatePipelinePreviewToLegacy } from "../../domains/schema/legacy-preview/translate";
import { RealClassifier } from "../../domains/schema/pipeline/classifier/classifier";
import { extractDatabaseNameFromUrl } from "../../domains/schema/pipeline/database-url";
import { buildDesiredTableFromComponentFields } from "../../domains/schema/pipeline/diff/build-from-fields";
import { RealPreCleanupExecutor } from "../../domains/schema/pipeline/pre-cleanup/executor";
import { previewDesiredSchema } from "../../domains/schema/pipeline/preview";
import {
Expand All @@ -51,9 +52,11 @@ import { RegexRenameDetector } from "../../domains/schema/pipeline/rename-detect
import type { Resolution } from "../../domains/schema/pipeline/resolution/types";
import { isIdempotencyError } from "../../domains/schema/pipeline/sql-statement-utils";
import type { DesiredFieldGroup } from "../../domains/schema/pipeline/types";
import { applyMigrationStatements } from "../../domains/schema/services/apply-migration-statements";
import { DrizzleStatementExecutor } from "../../domains/schema/services/drizzle-statement-executor";
import type { FieldResolution } from "../../domains/schema/services/schema-change-types";
import { calculateSchemaHash } from "../../domains/schema/services/schema-hash";
import { shapeMismatches } from "../../domains/schema/services/verify-applied-shape";
import { resolveComponentTableName } from "../../domains/schema/utils/resolve-table-name";
import { NextlyError } from "../../errors";
import { getProductionNotifier } from "../../runtime/notifications/index";
Expand Down Expand Up @@ -112,31 +115,6 @@ function offsetPaginationToMeta(args: {
};
}

// ============================================================
// Migration SQL execution helper
// ============================================================

async function executeMigrationStatements(
adapter: DrizzleAdapter,
migrationSQL: string
): Promise<void> {
const statements = migrationSQL
.split("--> statement-breakpoint")
.map(s => s.trim())
.filter(s => s.length > 0);

for (const statement of statements) {
const cleanStatement = statement
.split("\n")
.filter(line => !line.trim().startsWith("--"))
.join("\n")
.trim();
if (cleanStatement) {
await adapter.executeQuery(cleanStatement);
}
}
}

// Refresh the cached Drizzle table so the next entry query joining this
// component uses the new column layout without a server restart.
/**
Expand Down Expand Up @@ -305,6 +283,18 @@ async function reconcileComponentCompanion(args: {
}
}
}
// 🔴 STRICT on purpose: the companion does NOT tolerate a re-run.
//
// Tolerating it would make a half-finished localization ENABLE look like success. Interrupted
// after the companion is created but before the seed and the main-column drops, the retry is
// indistinguishable from an orphan repair — the planner cannot tell them apart, because the
// signal that separates them (`existingMainColumns`) is consumed only on the disable path — so
// the duplicate columns would be swallowed and the entity recorded as localized while its
// default-locale content is still stranded on the main table.
//
// A loud failure is the worse experience and the safer outcome. It costs the repair of a
// localized entity whose create half-failed; it prevents silently reporting a migration that did
// not happen.
for (const stmt of plan.statements) {
await adapter.executeQuery(stmt);
}
Expand Down Expand Up @@ -428,10 +418,37 @@ const COMPONENTS_METHODS: Record<string, MethodHandler<ComponentsServices>> = {
if (container.has("adapter")) {
const diAdapter = container.get<DrizzleAdapter>("adapter");

await executeMigrationStatements(diAdapter, migrationSQL);
await applyMigrationStatements(diAdapter, migrationSQL);

const tableExists = await diAdapter.tableExists(tableName);
if (tableExists) {
// 🔴 Existence is not enough now that a re-run is tolerated. `CREATE TABLE IF NOT
// EXISTS` no-ops against a field group's table left by an earlier attempt whose
// registry row is gone, and the index statements after it are tolerated, so a repair
// with a changed field set emits no error at all. Compared through the same builder
// the schema diff uses, so this cannot disagree with the pipeline about a column's
// name or its type.
const mismatches = tableExists
? await shapeMismatches(
diAdapter,
dialect,
tableName,
buildDesiredTableFromComponentFields(
tableName,
b.fields as unknown as FieldDefinition[],
dialect,
// A field group's table is made by the field-group creator, which reads a text
// field's width from a different key than the collection creator does — so the
// desired column shape depends on saying which builder made it.
{ localized: isLocalized, builtBy: "fieldGroup" }
)
Comment thread
mobeenabdullah marked this conversation as resolved.
)
: [];
if (mismatches.length > 0) {
migrationStatus = "failed";
console.error(
`[Components] Table "${tableName}" does not match this schema: ${mismatches.join("; ")}`
);
} else if (tableExists) {
migrationStatus = "applied";
registerComponentRuntimeSchema(
diAdapter,
Expand Down
Loading
Loading