Skip to content
Merged
28 changes: 28 additions & 0 deletions .changeset/field-group-transports-provision.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
"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 field group through `nextly.fieldGroups.create()` or the mounted `POST /api/field-groups` route now creates its table. Both previously answered success while writing only a registry row, leaving a field group whose storage did not exist and every read and write to it failing.

Those two routes now also refuse a create whose table another field group already owns, which only the admin path checked before. Because a slug is normalised on its way to a table name, two slugs that differ only by hyphens and underscores name one table; such a request used to reach the schema change and rebind the existing field group's storage to the new field list. The mounted route additionally rejects a slug over 50 characters, the bound the rest of the product already validates against, instead of accepting it and provisioning a table under a name the database truncates or refuses.
155 changes: 155 additions & 0 deletions packages/nextly/src/api/__tests__/field-group-route-delegates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
// The REST create route reaches the service that owns the table, rather than writing the row itself.
//
// This route used to call `registerComponent` directly. That wrote a registry row and no table, so
// it answered `201 Field group created.` for a field group whose `comp_<slug>` did not exist, and
// every later read and write to it failed against the database.
//
// The half this file proves is the DELEGATION. The other half — that the service actually
// provisions the table on a real engine — is proved against live databases in
// `domains/field-groups/__tests__/field-group-create-transports.integration.test.ts`, through the
// two transports the harness can reach without a session. This route is permission-gated and the
// harness cannot authenticate a request, so proving it there would mean faking a session and
// testing the fake. Split deliberately, and neither half stands alone: delegation without
// provisioning proves nothing, and provisioning without delegation would leave this route still
// writing its own row.

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

const createFieldGroup = vi.fn();
const registerComponent = vi.fn();

vi.mock("../../di", () => ({
getService: vi.fn((name: string) =>
name === "fieldGroupMetadataService"
? { createFieldGroup }
: { registerComponent }
),
}));

vi.mock("../../init", () => ({
getCachedNextly: vi.fn(async () => undefined),
}));

vi.mock("../route-auth", () => ({
requireRouteAnyPermission: vi.fn(async () => undefined),
}));

describe("the field-group create route", () => {
beforeEach(() => {
createFieldGroup.mockReset();
registerComponent.mockReset();
createFieldGroup.mockResolvedValue({
record: {
id: "fg-1",
slug: "hero",
label: "Hero",
tableName: "comp_hero",
fields: [],
migrationStatus: "applied",
},
migrationStatus: "applied",
});
});

it("creates through the service that owns the table, not the registry", async () => {
const { POST } = await import("../field-groups");

await POST(
new Request("http://localhost/api/field-groups", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
slug: "hero",
label: "Hero",
fields: [{ name: "heading", type: "text" }],
}),
})
);

expect(createFieldGroup).toHaveBeenCalledTimes(1);
// Stated as well as the positive, because the defect was not that the row went unwritten: it
// was that the row was written ALONE. A route that called both would look correct here.
expect(registerComponent).not.toHaveBeenCalled();
expect(createFieldGroup.mock.calls[0]?.[0]?.tableName).toBe("comp_hero");
});

it("says so when the table could not be provisioned", async () => {
createFieldGroup.mockResolvedValue({
record: {
id: "fg-2",
slug: "hero",
label: "Hero",
tableName: "comp_hero",
fields: [],
migrationStatus: "failed",
},
migrationStatus: "failed",
});
const { POST } = await import("../field-groups");

const response = await POST(
new Request("http://localhost/api/field-groups", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
slug: "hero",
label: "Hero",
fields: [{ name: "heading", type: "text" }],
}),
})
);
const body = (await response.json()) as { message?: string };

// An unqualified success for a create whose table was never made is what let this ship unnoticed
// for as long as it did.
expect(body.message).not.toBe("Field group created.");
expect(body.message).toContain("could not be provisioned");
});

it("refuses a slug too long to survive becoming a table name", async () => {
// 48 characters: one past the bound. The bound is not the slug's own length limit, it is
// whatever leaves the LONGEST generated identifier legal — `idx_comp_<slug>_parent`, sixteen
// characters longer than this. At 48 that index name is 64: rejected by MySQL, and past the 63
// where PostgreSQL stops rejecting and starts silently truncating.
const { POST } = await import("../field-groups");

const response = await POST(
new Request("http://localhost/api/field-groups", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
slug: `a${"b".repeat(47)}`,
label: "Too long",
fields: [{ name: "heading", type: "text" }],
}),
})
);

expect(response.status).toBe(400);
// The point is WHERE it is refused. Accepted here, it reaches the DDL, provisions a table the
// verification then cannot find under the name it asked for, records the field group as failed,
// and still answers 201 for input the route declared valid.
expect(createFieldGroup).not.toHaveBeenCalled();
});

it("accepts a slug at the bound", async () => {
// The positive control. Without it, a rule that rejected everything would satisfy the case
// above while breaking every real create.
const { POST } = await import("../field-groups");

const response = await POST(
new Request("http://localhost/api/field-groups", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
slug: `a${"b".repeat(46)}`,
label: "At the bound",
fields: [{ name: "heading", type: "text" }],
}),
})
);

expect(response.status).toBe(201);
expect(createFieldGroup).toHaveBeenCalledTimes(1);
});
});
47 changes: 40 additions & 7 deletions packages/nextly/src/api/field-groups.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ import { z } from "zod";

import { getService } from "../di";
import { clampLimit } from "../domains/collections/query/query-parser";
import type {
CreateFieldGroupInput,
FieldGroupMetadataService,
} from "../domains/field-groups/services/field-group-metadata-service";
import { MAX_FIELD_GROUP_SLUG_LENGTH } from "../domains/field-groups/services/field-group-schema-service";
import { calculateSchemaHash } from "../domains/schema/services/schema-hash";
import { resolveComponentTableName } from "../domains/schema/utils/resolve-table-name";
import { getCachedNextly } from "../init";
Expand All @@ -38,11 +43,33 @@ async function getComponentRegistry(): Promise<FieldGroupRegistryService> {
return getService("fieldGroupRegistryService");
}

/**
* The service that owns a field group's table and its registry row together.
*
* This route used to call the registry directly, which wrote the row and made no table: it answered
* 201 for a field group whose `comp_<slug>` did not exist, and every later read and write to it
* failed against the database.
*/
async function getFieldGroupMetadataService(): Promise<FieldGroupMetadataService> {
await getCachedNextly();
return getService("fieldGroupMetadataService");
}

const createComponentSchema = z.object({
slug: z
.string()
.min(1, "Slug is required")
.max(255, "Slug must be 255 characters or less")
// Bounded by the longest IDENTIFIER a field group generates, not by the slug itself and not by
// any column width. The slug is prefixed into a table name and that table name is prefixed and
// suffixed into `idx_comp_<slug>_parent`, sixteen characters longer than what the caller typed.
// The product's usual 50 therefore still yields a 66-character index name: MySQL rejects past
// 64, and PostgreSQL silently truncates past 63 — leaving an index under a name nothing can
// address. Accepted here, the table is created and the index creation fails, so the field group
// is recorded failed with an unbound table and the route still answers 201.
.max(
MAX_FIELD_GROUP_SLUG_LENGTH,
`Slug must be ${MAX_FIELD_GROUP_SLUG_LENGTH} characters or less`
)
.regex(
/^[a-z][a-z0-9-]*$/,
"Slug must start with a letter and contain only lowercase letters, numbers, and hyphens"
Expand Down Expand Up @@ -164,7 +191,7 @@ export const POST = withErrorHandler(async (request: Request) => {

// Boot services before the permission check (see GET) so lazy DI init does
// not turn a valid caller's first request into a 403/503.
const registry = await getComponentRegistry();
const metadata = await getFieldGroupMetadataService();

await requireRouteAnyPermission(request, [
{ action: "create", resource: "settings" },
Expand All @@ -190,13 +217,11 @@ export const POST = withErrorHandler(async (request: Request) => {

// Validated by assertValidFieldsPayload above; cast through `unknown`
// to the registry's config type while keeping the payload unstripped.
const fields = validated.fields as unknown as Parameters<
typeof registry.registerComponent
>[0]["fields"];
const fields = validated.fields as unknown as CreateFieldGroupInput["fields"];

const schemaHash = calculateSchemaHash(fields);

const component = await registry.registerComponent({
const { record, migrationStatus } = await metadata.createFieldGroup({
slug: validated.slug,
label: validated.label,
tableName,
Comment thread
mobeenabdullah marked this conversation as resolved.
Expand All @@ -208,5 +233,13 @@ export const POST = withErrorHandler(async (request: Request) => {
schemaHash,
});

return respondMutation("Field group created.", component, { status: 201 });
// The status is reported rather than swallowed. A create whose DDL failed still has a row
// describing what was attempted, and answering an unqualified success for it is what let a field
// group with no table look healthy.
const message =
migrationStatus === "applied"
? "Field group created."
: "Field group created, but its table could not be provisioned. The field group is recorded with a failed migration and holds its slug, so creating it again is refused as a duplicate: check the server logs, then delete this field group before creating it again.";

return respondMutation(message, record, { status: 201 });
});
2 changes: 2 additions & 0 deletions packages/nextly/src/di/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
getEmailProviderRegistry,
resetEmailProviderRegistry,
} from "../domains/email/services/email-provider-registry";
import type { FieldGroupMetadataService } from "../domains/field-groups/services/field-group-metadata-service";
import {
resolveFieldGroupRegistryName,
resolveKnownTypeColumns,
Expand Down Expand Up @@ -334,6 +335,7 @@ export interface ServiceMap {
singleEntryService: SingleEntryService;
/** Owns a Single's table change together with the registry write that records it. */
singleMetadataService: SingleMetadataService;
fieldGroupMetadataService: FieldGroupMetadataService;
fieldGroupRegistryService: FieldGroupRegistryService;
fieldGroupSchemaService: FieldGroupSchemaService;
fieldGroupDataService: FieldGroupDataService;
Expand Down
16 changes: 16 additions & 0 deletions packages/nextly/src/di/registrations/register-components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* resolution time) for component-field read/write support.
*/

import { FieldGroupMetadataService } from "../../domains/field-groups/services/field-group-metadata-service";
import type { CollectionRelationshipService } from "../../services/collections/collection-relationship-service";
import {
FieldGroupDataService,
Expand All @@ -32,6 +33,21 @@ export function registerComponentServices(ctx: RegistrationContext): void {
() => new FieldGroupSchemaService(adapter.getCapabilities().dialect)
);

// FieldGroupMetadataService — schema changes for a field group, holding the table change and the
// registry write together. Registered rather than built per request so one 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. That is the same reason the three create transports were
// allowed to disagree about whether the table gets made at all.
container.registerSingleton<FieldGroupMetadataService>(
"fieldGroupMetadataService",
() =>
new FieldGroupMetadataService(
container.get<FieldGroupRegistryService>("fieldGroupRegistryService"),
logger,
adapter
)
);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// FieldGroupDataService — CRUD for component instance data.
// Depends on FieldGroupRegistryService for component metadata lookups
// and optionally on CollectionRelationshipService (registered later by
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest";

import { FieldGroupMetadataService } from "../../domains/field-groups/services/field-group-metadata-service";
import type { FieldGroupRegistryService } from "../../services/field-groups/field-group-registry-service";
import type { Logger } from "../../shared/types";
import { createFieldGroupsNamespace } from "../namespaces/field-groups";

// fieldGroups.create() derives the physical table name through the canonical
Expand All @@ -17,9 +20,29 @@ describe("fieldGroups.create derives its table name", () => {
source: "code",
migrationStatus: "pending",
});
const logger: Logger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
// Answers "no field group owns any table", which is the state a create starts from. The service
// refuses a table another field group already holds before it renders any DDL, so a double
// without this method describes a registry the create can no longer be performed against.
const registry = {
registerComponent,
getAllComponents: vi.fn().mockResolvedValue([]),
};
const ctx = {
fieldGroupRegistryService: { registerComponent },
logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() },
fieldGroupRegistryService: registry,
// The REAL service, with no adapter: the create then generates its statements and runs none,
// which is the configuration this product supports and the one that keeps this test about
// table-name derivation rather than about DDL.
fieldGroupMetadataService: new FieldGroupMetadataService(
registry as unknown as FieldGroupRegistryService,
logger
),
logger,
};
return { ctx, registerComponent };
}
Expand Down
2 changes: 2 additions & 0 deletions packages/nextly/src/direct-api/namespaces/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import type { PermissionService } from "../../domains/auth/services/permission-s
import type { RBACAccessControlService } from "../../domains/auth/services/rbac-access-control-service";
import type { RolePermissionService } from "../../domains/auth/services/role-permission-service";
import type { RoleService } from "../../domains/auth/services/role-service";
import type { FieldGroupMetadataService } from "../../domains/field-groups/services/field-group-metadata-service";
import type { CollectionsHandler } from "../../services/collections-handler";
import type { EmailProviderService } from "../../services/email/email-provider-service";
import type { EmailService } from "../../services/email/email-service";
Expand Down Expand Up @@ -51,6 +52,7 @@ export interface NextlyContext {
/** @internal */ readonly userService: UserService;
/** @internal */ readonly mediaService: MediaService;
/** @internal */ readonly fieldGroupRegistryService: FieldGroupRegistryService;
/** @internal */ readonly fieldGroupMetadataService: FieldGroupMetadataService;
/** @internal */ readonly emailProviderService: EmailProviderService;
/** @internal */ readonly emailTemplateService: EmailTemplateService;
/** @internal */ readonly userFieldDefinitionService: UserFieldDefinitionService;
Expand Down
Loading
Loading