Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
22 changes: 17 additions & 5 deletions apps/playground/scripts/__tests__/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,24 @@ describe("doctor.checkEnvFile", () => {
await fs.rm(tmp, { recursive: true });
});

it("returns failure with copy command when .env is missing", async () => {
// Use a path that ends in .env so the doctor's path-derivation regex
// (replace(/\.env$/, ".env.example")) produces a real "cp .env.example .env" hint.
const result = await checkEnvFile("/tmp/doctor-missing-env-test/.env");
it("auto-creates .env by copying .env.example when present", async () => {
const tmp = `/tmp/doctor-env-copy-${Date.now()}`;
await fs.mkdir(tmp, { recursive: true });
await fs.writeFile(`${tmp}/.env.example`, "DB_DIALECT=sqlite\n");
const result = await checkEnvFile(`${tmp}/.env`);
expect(result.ok).toBe(true);
// The .env was created from the example.
await expect(fs.access(`${tmp}/.env`)).resolves.toBeUndefined();
await fs.rm(tmp, { recursive: true });
});

it("fails with a manual-create hint when both .env and .env.example are missing", async () => {
const tmp = `/tmp/doctor-env-none-${Date.now()}`;
await fs.mkdir(tmp, { recursive: true });
const result = await checkEnvFile(`${tmp}/.env`);
expect(result.ok).toBe(false);
expect(result.fix).toMatch(/cp .*\.env\.example .*\.env/);
expect(result.fix).toMatch(/create .*manually/i);
await fs.rm(tmp, { recursive: true });
});
});

Expand Down
27 changes: 26 additions & 1 deletion apps/playground/scripts/__tests__/reset.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import * as path from "node:path";

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

import { wipeDbSqlite, wipeFileState } from "../reset";
import { resolveDialect, wipeDbSqlite, wipeFileState } from "../reset";

const TMP = path.join("/tmp", "playground-reset-test");

Expand Down Expand Up @@ -47,6 +47,31 @@ describe("wipeFileState", () => {
});
});

describe("resolveDialect", () => {
it("detects postgresql from a postgres:// URL when DB_DIALECT is unset", () => {
expect(resolveDialect(undefined, "postgresql://u:p@host/db")).toBe(
"postgresql"
);
expect(resolveDialect(undefined, "postgres://u:p@host/db")).toBe(
"postgresql"
);
});

it("detects mysql from a mysql:// URL", () => {
expect(resolveDialect(undefined, "mysql://u:p@host/db")).toBe("mysql");
});

it("falls back to sqlite for a file: URL or no URL", () => {
expect(resolveDialect(undefined, "file:./data/x.db")).toBe("sqlite");
expect(resolveDialect(undefined, undefined)).toBe("sqlite");
});

it("honors an explicit DB_DIALECT over the URL (and normalizes 'postgres')", () => {
expect(resolveDialect("postgres", "file:./x.db")).toBe("postgresql");
expect(resolveDialect("sqlite", "postgresql://u:p@host/db")).toBe("sqlite");
});
});

describe("wipeDbSqlite", () => {
beforeEach(() => fs.rm(TMP, { recursive: true, force: true }));
afterEach(() => fs.rm(TMP, { recursive: true, force: true }));
Expand Down
38 changes: 31 additions & 7 deletions apps/playground/scripts/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,19 @@
* Playground reset.
*
* Wipes the local DB, uploaded media, Next.js / Turbo caches, generated
* types and Drizzle migrations, then re-runs the seed. Branches on
* DB_DIALECT: SQLite deletes the file, Postgres drops/recreates the
* `public` schema, MySQL drops/recreates the database.
* types and Drizzle migrations, then re-runs the seed. The dialect is taken
* from `DB_DIALECT` when set, otherwise auto-detected from `DATABASE_URL`
* (see `resolveDialect`): SQLite deletes the file, Postgres drops/recreates
* the `public` schema, MySQL drops/recreates the database.
*
* Does NOT spawn `next dev`. Contributor runs `pnpm dev:app` after.
*
* Does NOT touch node_modules, .env, or pnpm-lock.yaml. Those belong
* to other tools and survive a reset.
*
* Usage:
* pnpm dev:reset # SQLite by default
* DB_DIALECT=postgresql pnpm dev:reset
* pnpm dev:reset # dialect auto-detected from DATABASE_URL
* DB_DIALECT=postgresql pnpm dev:reset # explicit override
* DB_DIALECT=mysql pnpm dev:reset
*/
import * as fs from "node:fs/promises";
Expand Down Expand Up @@ -101,17 +102,40 @@ function resolveSqlitePath(databaseUrl: string | undefined): string {
return path.isAbsolute(raw) ? raw : path.resolve(PLAYGROUND_DIR, raw);
}

/**
* Resolve the DB dialect to reset. An explicit `DB_DIALECT` wins; otherwise
* detect from the `DATABASE_URL` scheme (matching how the `nextly` migrate CLI
* auto-detects). Falling back to "sqlite" only when there's no Postgres/MySQL
* URL avoids the footgun where a bare `pnpm dev:reset` against a Postgres
* `DATABASE_URL` silently wiped a nonexistent SQLite file and left the real DB
* untouched.
*/
export function resolveDialect(
dbDialect: string | undefined,
databaseUrl: string | undefined
): "sqlite" | "postgresql" | "mysql" {
if (dbDialect === "postgresql" || dbDialect === "postgres")
return "postgresql";
if (dbDialect === "mysql") return "mysql";
if (dbDialect === "sqlite") return "sqlite";

const url = databaseUrl ?? "";
if (/^postgres(ql)?:\/\//i.test(url)) return "postgresql";
if (/^mysql:\/\//i.test(url)) return "mysql";
return "sqlite"; // file: URL or unset
}

export async function runReset(): Promise<void> {
const dialect = process.env.DB_DIALECT ?? "sqlite";
const databaseUrl = process.env.DATABASE_URL;
const dialect = resolveDialect(process.env.DB_DIALECT, databaseUrl);

console.log("[nextly] Wiping file state...");
await wipeFileState(PLAYGROUND_DIR);

console.log(`[nextly] Wiping ${dialect} database...`);
if (dialect === "sqlite") {
await wipeDbSqlite(resolveSqlitePath(databaseUrl));
} else if (dialect === "postgresql" || dialect === "postgres") {
} else if (dialect === "postgresql") {
if (!databaseUrl) {
throw new Error("DATABASE_URL is required for postgres reset");
}
Expand Down
12 changes: 7 additions & 5 deletions docs/guides/testing-migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ the migration CLI, and authoring schema two ways — **code-first** and via the
> Make sure `DATABASE_URL` in `apps/playground/.env` points at a **throwaway**
> Postgres DB — several commands drop tables.

> **Looking for the full playbook?** This page is the quick reference. For an
> exhaustive manual-testing guide — every command + flag, the migrate lock,
> rollback, production run-on-boot, CI/build integration, and troubleshooting —
> see [manual-testing-migrations.md](./manual-testing-migrations.md).

---

## 1. The migration commands
Expand Down Expand Up @@ -61,11 +66,8 @@ export const Books = defineCollection({

### B. Admin UI (the builder)

1. In `apps/playground/.env`, set the builder mode:
- `NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE=0` → **database mode** (applies to the DB
**and** writes `ui-schema.json` — the new dual-write).
- `NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE=1` → **file mode** (writes
`ui-schema.json` only, no DB change).
1. The builder always **dual-writes**: every create/edit/delete applies to the
dev DB **and** writes the committable `ui-schema.json`. (No mode flag.)
2. Start the dev server: `pnpm dev:app` (from the repo root).
3. Go to `/admin` → builder → create a **collection** or **single**, add fields
(the picker now offers the full canonical set, incl. `relationship` +
Expand Down
14 changes: 14 additions & 0 deletions packages/admin/src/lib/builder/to-manifest-entity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,18 @@ describe("collectionToManifestEntity", () => {
})
).toThrowError(/unsupported field type/i);
});

it("maps an empty field list to a field-less entity with labels + status (create case)", () => {
const entity = collectionToManifestEntity({
slug: "widgets",
settings: { singularName: "Widget", pluralName: "Widgets", status: true },
fields: [],
});
expect(entity).toEqual({
slug: "widgets",
fields: [],
labels: { singular: "Widget", plural: "Widgets" },
status: true,
});
});
});
31 changes: 2 additions & 29 deletions packages/admin/src/lib/builder/ui-schema-mode.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,9 @@
/**
* @module lib/builder/ui-schema-mode.test
* @since v0.0.3-alpha (Plan D4)
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { describe, expect, it } from "vitest";

import { isUiSchemaWriteMode, UI_SCHEMA_FIELD_TYPES } from "./ui-schema-mode";

const ORIGINAL_ENV = process.env.NODE_ENV;
const ORIGINAL_FLAG = process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE;

beforeEach(() => {
process.env.NODE_ENV = "development";
process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE = "1";
});
afterEach(() => {
process.env.NODE_ENV = ORIGINAL_ENV;
process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE = ORIGINAL_FLAG;
});

describe("isUiSchemaWriteMode", () => {
it("is true in development with the flag on", () => {
expect(isUiSchemaWriteMode()).toBe(true);
});
it("is false when the flag is off", () => {
process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE = "";
expect(isUiSchemaWriteMode()).toBe(false);
});
it("is false in production even with the flag on", () => {
process.env.NODE_ENV = "production";
expect(isUiSchemaWriteMode()).toBe(false);
});
});
import { UI_SCHEMA_FIELD_TYPES } from "./ui-schema-mode";

describe("UI_SCHEMA_FIELD_TYPES", () => {
it("is the full canonical field-type set (mirrors the package UI_FIELD_TYPES)", () => {
Expand Down
18 changes: 5 additions & 13 deletions packages/admin/src/lib/builder/ui-schema-mode.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/**
* UI-schema write-mode gate for the admin schema builder (spec §4.12).
* UI-schema field-type allowlist for the admin schema builder.
*
* The builder is dev-only. When this mode is active, "Save" writes the
* committable `ui-schema.json` manifest via the dev API instead of applying
* directly to the database. Opt-in (flag, default off) so the existing
* DB-apply dev flow is unchanged until a project adopts the JSON workflow.
* The builder always dual-writes — every create/edit/delete applies to the dev
* DB and mirrors to the committable `ui-schema.json` — so there is no longer a
* mode flag. This module just owns the canonical field-type set the picker and
* the manifest mappers share with the package zod schema.
*
* @module lib/builder/ui-schema-mode
* @since v0.0.3-alpha (Plan D4)
Expand Down Expand Up @@ -40,11 +40,3 @@ export const UI_SCHEMA_FIELD_TYPES = [
] as const;

export type UiSchemaFieldType = (typeof UI_SCHEMA_FIELD_TYPES)[number];

/** True when builder saves should write ui-schema.json (dev + opt-in flag). */
export function isUiSchemaWriteMode(): boolean {
return (
process.env.NODE_ENV === "development" &&
process.env.NEXT_PUBLIC_NEXTLY_UI_SCHEMA_WRITE === "1"
);
}
28 changes: 0 additions & 28 deletions packages/admin/src/pages/dashboard/collections/builder/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,6 @@ import { nextDuplicateName } from "@admin/lib/builder/duplicate-field-name";
import { isInsideRepeatingAncestor } from "@admin/lib/builder/is-inside-repeating-ancestor";
import { packIntoRows, parseWidth } from "@admin/lib/builder/reflow";
import { collectionToManifestEntity } from "@admin/lib/builder/to-manifest-entity";
import { isUiSchemaWriteMode } from "@admin/lib/builder/ui-schema-mode";
import { COLLECTION_BUILDER_CONFIG } from "@admin/pages/dashboard/collections/builder/builder-config";
import {
schemaApi,
Expand Down Expand Up @@ -278,33 +277,6 @@ export default function CollectionBuilderEditPage({
) => {
if (!slug) return;

// D4: file-write mode (dev + opt-in flag). Persist to ui-schema.json via
// the dev API instead of mutating the DB. No DB change → no restart; the
// change applies later via `nextly migrate:create` + `migrate`.
if (isUiSchemaWriteMode()) {
try {
const entity = collectionToManifestEntity({
slug,
settings: {
singularName: settings?.singularName,
pluralName: settings?.pluralName,
},
fields: fieldDefinitions,
});
await schemaFileApi.writeCollection(entity);
const collectionLabel = settings?.singularName?.trim() || slug;
toast.success(`${collectionLabel} written to ui-schema.json`);
setShowSchemaDialog(false);
setPreviewData(null);
setOriginalFields(builder.fields.filter(f => !f.isSystem));
if (settings) setOriginalSettings(settings);
} catch (err) {
const errorObj = err as { message?: string };
toast.error(errorObj?.message || "Failed to write ui-schema.json");
}
return;
}

setIsApplyingSchema(true);
if (typeof window !== "undefined") window.__nextlySchemaApplying = true;
startRestart();
Expand Down
37 changes: 32 additions & 5 deletions packages/admin/src/pages/dashboard/collections/builder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ import { toast } from "@admin/components/ui";
import { ROUTES, buildRoute } from "@admin/constants/routes";
import { useCreateCollection } from "@admin/hooks/queries";
import { toSnakeName } from "@admin/lib/builder";
import { collectionToManifestEntity } from "@admin/lib/builder/to-manifest-entity";
import { navigateTo } from "@admin/lib/navigation";
import { schemaFileApi } from "@admin/services/schemaFileApi";

import { COLLECTION_BUILDER_CONFIG } from "./builder-config";

Expand Down Expand Up @@ -64,11 +66,36 @@ export default function CollectionBuilderPage(): React.ReactElement | null {
},
{
onSuccess: () => {
toast.success("Collection created");
// Navigate to the edit page where the user can add fields.
// We use the slug we computed because the API doesn't return
// the created entity in this response shape.
navigateTo(buildRoute(ROUTES.BUILDER_COLLECTIONS_EDIT, { slug }));
// Mirror the new (field-less) collection to ui-schema.json so the
// committable manifest stays in sync with the dev DB. Best-effort:
// a file-write failure warns but never blocks navigation (the DB
// create already succeeded). Wrapped in a void IIFE so the mutation
// callback stays synchronous (void return).
void (async () => {
try {
await schemaFileApi.writeCollection(
collectionToManifestEntity({
slug,
settings: {
singularName: singular,
pluralName: plural,
status: values.status === true,
},
fields: [],
})
);
} catch (err) {
const m = (err as { message?: string })?.message;
toast.warning(
`Collection created, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.`
);
}
toast.success("Collection created");
// Navigate to the edit page where the user can add fields.
// We use the slug we computed because the API doesn't return
// the created entity in this response shape.
navigateTo(buildRoute(ROUTES.BUILDER_COLLECTIONS_EDIT, { slug }));
})();
},
onError: err => {
const error = err as { message?: string };
Expand Down
25 changes: 23 additions & 2 deletions packages/admin/src/pages/dashboard/component/builder/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import { toast } from "@admin/components/ui";
import { ROUTES, buildRoute } from "@admin/constants/routes";
import { useCreateComponent } from "@admin/hooks/queries/useComponents";
import { toSnakeName } from "@admin/lib/builder";
import { componentToManifestEntity } from "@admin/lib/builder/to-manifest-entity-component";
import { navigateTo } from "@admin/lib/navigation";
import { schemaFileApi } from "@admin/services/schemaFileApi";

import { COMPONENT_BUILDER_CONFIG } from "./builder-config";

Expand Down Expand Up @@ -55,8 +57,27 @@ export default function ComponentBuilderPage(): React.ReactElement | null {
},
{
onSuccess: () => {
toast.success("Component created");
navigateTo(buildRoute(ROUTES.BUILDER_COMPONENTS_EDIT, { slug }));
// Mirror the new (field-less) component to ui-schema.json (best-effort;
// a file-write failure warns but never blocks navigation). Wrapped in
// a void IIFE so the mutation callback stays synchronous.
void (async () => {
try {
await schemaFileApi.writeComponent(
componentToManifestEntity({
slug,
settings: { singularName: singular },
fields: [],
})
);
} catch (err) {
const m = (err as { message?: string })?.message;
toast.warning(
`Component created, but ui-schema.json could not be updated${m ? `: ${m}` : ""}.`
);
}
toast.success("Component created");
navigateTo(buildRoute(ROUTES.BUILDER_COMPONENTS_EDIT, { slug }));
})();
},
onError: err => {
const error = err as { message?: string };
Expand Down
Loading
Loading