diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ddc4a16b..4fd8bbd3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,32 @@ here.** ### Added +- **Prisma as the ORM, declared once in the model (TypeScript, #479).** Set `orm: "prisma"` in + `metaobjects.config.ts` and wire the new `prisma-schema` generator (`prismaSchemaFile()`), and + `meta gen` writes `schema.prisma` from the model for the `postgresql`, `mysql` or `sqlite` + provider. It maps types, ids and their generation, defaults, nullability, enums, relations, + unique keys, indexes and `@map`/`@@map`, and Postgres `@schema` maps to `@@schema`. Prisma cannot + declare CHECK constraints, so the ones the model implies go in `schema.checks.sql`, which you + apply with `prisma db execute --file`. `meta verify --codegen` fails on a hand-edited or stale + schema; the generator overwrites hand edits rather than merging them. On Postgres, a database + created by `prisma db push` with the checks file applied passes `meta verify --db`, and a + change to it fails. On SQLite, which cannot take the checks file, the gate reports each + missing CHECK and nothing else, and a change to the database fails it. + Most of what a Prisma schema cannot express is refused with an `ERR_PRISMA_*` code, never dropped: a + view `meta verify --db` expects, an expression or partial index, a CHECK on SQLite, a `@schema` + outside Postgres, a table with no primary key, a relation Prisma cannot model, a name + collision, or an unsupported provider. The exception is MySQL, which gets the enum CHECKs only: + the other checks are Postgres SQL and are left out. A model with projections, write-through read views or + served reports therefore needs each of those views marked `@unmanaged` or its object left out + by the generator's `filter`. A flattened `field.object` maps to one Prisma field per column. + `orm: "prisma"` also makes every target default to `runtime: false`. It refuses a target set to + `runtime: true`, and the generators that write Drizzle code (`queries`, `routes`, + `routes-hono`, `callable`). The default, `orm: "drizzle"`, changes nothing. The generator is + ejectable (`meta eject prisma-schema`). `@metaobjectsdev/codegen-ts` now depends on + `@metaobjectsdev/migrate-ts` for the expected schema, through the new + `@metaobjectsdev/migrate-ts/expected` subpath, which does not load kysely. Recipe: + `docs/recipes/prisma.md`. + - **Java and Kotlin consume a shared model (FR-023 Phase 2).** A project that declares `dependencies` in `.metaobjects/config.json` loads its committed `.metaobjects/deps/` snapshot ahead of its own files, in the Maven plugin (every rung of the source ladder) and the Java @@ -25,6 +51,7 @@ here.** an imported object unless `scope.include` names its package. Runs all 24 resolution cases of `fixtures/dependency-conformance/` (the 11 check cases pin the Node `meta deps check`); Kotlin loads through the same loader and adds a codegen test. Spring runtime auto-load and `verify` drift for dependencies are later slices. + - **Java, Kotlin and C# can compile and match `scope` patterns.** `com.metaobjects.config.Scope` (Java; Kotlin inherits it) and `MetaObjects.Config.Scope` (C#) port the scope-pattern grammar from TypeScript (`compilePattern` / `compileScope` / `matchesScope`), and both run the shared @@ -98,6 +125,20 @@ here.** ### Fixed +- **`@default: "now"` is the current time on Postgres, not the table's creation time.** The + expected schema stored it as the literal `'now'`, and Postgres converts that literal to a + timestamp once, when it creates the column's `DEFAULT`, so every row got the same value. It + is now the expression `now()`, which is what `@autoSet` and the generated Drizzle column + already used. On a database `meta migrate` created from such a field, the next `meta migrate` + proposes a change-column-default, and `meta verify --db` reports the old default until it is + applied. + +- **`meta verify --db` on SQLite accepts how other tools spell the same storage.** A column + declared `BOOLEAN` now matches the model's `INTEGER`, `DECIMAL(p,s)` matches `NUMERIC`, and an + unquoted `TRUE`/`FALSE` default matches `1`/`0`. SQLite stores each pair identically. Prisma + writes the first of each, so before this fix a database pushed by Prisma reported drift on + every boolean and decimal column. + - **`meta deps sync` and `meta deps check` finish cleanly on a project whose own metadata the Node toolchain cannot read.** A Maven project (`src/main/resources/metadata`), a C# project, or a model using vocabulary only another port registers declares no neutral `sources`, so the closing diff --git a/README.md b/README.md index e6258ac52..4b720b3f4 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ first-week wedge plan — and `meta init` picks up from there. | Payload-VO codegen (the value object's own type, ADR-0056) | Yes (`entityFile()`) | Yes (`SpringValueObjectGenerator`) | Yes (`KotlinEntityGenerator`) | Yes (`EntityGenerator`) | Yes (`entity`) | | Migration emission | `meta migrate` (Postgres / SQLite / D1) | Via TS toolchain (`@metaobjectsdev/cli migrate`) | Via TS toolchain (`@metaobjectsdev/cli migrate`) | Via TS toolchain (ADR-0015) | Via TS toolchain (ADR-0015) | | DB-drift verify | `meta verify --db ` | Template-drift: `Verify.check`; schema-drift is TS-owned (ADR-0015) | Template-drift: `Verify.check`; startup: `MetadataStartupValidator` | `dotnet meta verify` (codegen-drift) | Schema-drift is TS-owned (ADR-0015) | +| Prisma schema (`orm: "prisma"`, #479) | `prismaSchemaFile()` writes `schema.prisma` (Postgres / MySQL / SQLite); `verify --codegen` and `verify --db` gate it | No | No | No | No | | Template-drift verify | Yes | Yes (`Verify.check`) | Yes (via Java) | Yes (`dotnet meta verify`) | Yes (`metaobjects.render.verify`) | | YAML authoring (sigil-free → JSON) | Yes | Yes | Yes (via Java) | Yes | Yes | | Capability requirements (`requirement.*`) | `meta verify` gate + authoring lint; `requirementTests()` stubs | `mvn metaobjects:verify` gate; JUnit Jupiter `requirement-tests` | Gate via the Java Maven goal; JUnit Jupiter `requirement-tests` | `dotnet meta verify` gate; xUnit `requirement-tests` | `metaobjects verify` gate; pytest `requirement-tests` | diff --git a/agent-context/skills/metaobjects-codegen/SKILL.md b/agent-context/skills/metaobjects-codegen/SKILL.md index e8e56449e..df1815ec9 100644 --- a/agent-context/skills/metaobjects-codegen/SKILL.md +++ b/agent-context/skills/metaobjects-codegen/SKILL.md @@ -44,6 +44,7 @@ exact config, generator names and commands for this project; read it. The other | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | +| `references/typescript-prisma.md` | a TypeScript project that keeps Prisma as its ORM | | `references/typescript-retargeting.md` | a TypeScript project on a framework other than Fastify, Hono or React | | `references/typescript-templates.md` | declarative Mustache template-codegen in a TypeScript project | | `references/typescript-docs.md` | the docs generators (`docsFile`, `apiDocsFile`, `agentDocsFile`, `requirementsFile`, `metamodelDocsFile`, `docsSiteFile`) in a TypeScript project | diff --git a/agent-context/skills/metaobjects-codegen/references/any-stack.md b/agent-context/skills/metaobjects-codegen/references/any-stack.md index 57f1c9651..91f568f59 100644 --- a/agent-context/skills/metaobjects-codegen/references/any-stack.md +++ b/agent-context/skills/metaobjects-codegen/references/any-stack.md @@ -34,7 +34,7 @@ reference generators target React and TanStack. Anything else is this page. | Layer | Always available | When your target is not built in | |---|---|---| | **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — | -| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 | +| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java); a Prisma schema (TypeScript) | §3 | | **api** — the HTTP surface | Fastify, Hono | §4 | | **client** — hooks, forms, grids | React, TanStack | §5 | @@ -52,6 +52,12 @@ the column-type table and the behaviour differences are in `typescript-mysql.md` OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are dialect-neutral. +**Prisma as the ORM (TypeScript).** Set `orm: "prisma"` and wire `prismaSchemaFile()`. The +model then writes `schema.prisma` (Postgres, MySQL, SQLite) beside Zod schemas and types, and +Prisma runs the database. `meta verify --codegen` and `--db` gate it. Setup, the mapping and +what it refuses are in `typescript-prisma.md`. Any other ORM (TypeORM, Sequelize, MikroORM) is +a generator you write: the Prisma one is a model to copy (`meta eject prisma-schema`). + **A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB, Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite` (or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries diff --git a/agent-context/skills/metaobjects-codegen/references/typescript-prisma.md b/agent-context/skills/metaobjects-codegen/references/typescript-prisma.md new file mode 100644 index 000000000..4ec7d2f66 --- /dev/null +++ b/agent-context/skills/metaobjects-codegen/references/typescript-prisma.md @@ -0,0 +1,160 @@ +# Prisma on TypeScript + +> Part of the `metaobjects-codegen` skill's TypeScript reference. Read it when the app keeps +> Prisma as its ORM. + +Under `orm: "prisma"`, `meta gen` writes `schema.prisma` from the model, along with Zod schemas +and types that have no Drizzle code. Prisma still creates the tables (`prisma db push` or +`prisma migrate`) and runs the queries. `meta verify --codegen` fails when someone edits +`schema.prisma` or it is out of date. `meta verify --db` fails when the database Prisma created +differs from the model (Postgres and SQLite). Do not hand-write a Prisma model for a table the +MetaObjects model declares. + +## Contents +- TypeScript setup +- The workflow +- How the model maps +- What it refuses +- SQLite and MySQL +- Eject it + +## TypeScript setup + +```ts +// metaobjects.config.ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { prismaSchemaFile } from "@metaobjectsdev/codegen-ts/generators"; + +export default defineConfig({ + orm: "prisma", + dialect: "postgres", // "postgres" | "mysql" | "sqlite": picks the Prisma provider + outDir: "src/generated", // Zod schemas and types + targets: { prisma: { outDir: "prisma" } }, + generators: ["entity", prismaSchemaFile({ target: "prisma" })], +}); +``` + +`orm: "prisma"` does two things: + +- **Every target defaults to `runtime: false`.** The `entity` generator then writes Zod schemas + and types with no `drizzle-orm` import. A target that sets `runtime: true` is refused. +- **Generators that write Drizzle code are refused** by name: `queries`, `routes`, + `routes-hono` and `callable`. Your routes call the Prisma Client instead. `meta gen` exits + with `orm: "prisma" refuses "queries", …` and makes no changes. + +The default `orm` is `"drizzle"`, and it changes nothing. + +Prisma 7 reads the connection URL from `prisma.config.ts`, not from the schema: + +```ts +// prisma.config.ts +import { defineConfig } from "prisma/config"; +export default defineConfig({ schema: "prisma/schema.prisma", datasource: { url: process.env.DATABASE_URL! } }); +``` + +The generated schema names the `prisma-client` generator, writing to `prisma/generated`. To +choose your own generator and datasource blocks, pass `header` as a string. Pass +`header: false` for a multi-file schema (`prisma/schema/`) whose blocks live in a file you own. +Other options: + +- `provider` overrides the provider that `dialect` picks. +- `fileName` renames the file (default `schema.prisma`). +- `filter` narrows which objects get a model. +- `checks: "omit"` drops the CHECK file. + +## The workflow + +```bash +meta gen # writes prisma/schema.prisma (+ schema.checks.sql) +npx prisma db push # or: npx prisma migrate dev +npx prisma db execute --file prisma/schema.checks.sql +npx prisma generate # the Prisma Client, as usual +meta verify --codegen --db "$DATABASE_URL" # in CI: fails on an edited schema or a drifted database +``` + +To change a field, edit the model and run `meta gen`. **Do not edit `schema.prisma` by hand.** +The generator overwrites hand edits instead of merging them. Prisma treats the whole file as +the schema, so a merged edit would change the database without the model knowing. +`meta verify --codegen` fails on the edit until you run `meta gen` again. `prisma format` +leaves the file unchanged, so formatting it does not count as an edit. + +A Prisma schema cannot declare a CHECK constraint, so the constraints the model implies go in +`schema.checks.sql`. These are enum membership checks plus the `@min`/`@max` and length +validators. Apply the file after every `db push` or migration. On Postgres the file drops each +constraint before adding it, so you can apply it more than once. On MySQL it only adds them, +so apply it once, when you create the table. When a check changes, drop the old one in a Prisma +migration. When the model has no checks left, `meta gen` deletes the file. + +With `prisma migrate`, keep the Prisma migration chain: `meta gen` changes `schema.prisma`, and +`prisma migrate dev` writes the SQL. To make the checks part of the chain, put the contents of +`schema.checks.sql` into a migration (`prisma migrate dev --create-only`, then add the SQL). + +## How the model maps + +The schema is built from the same expected schema that `meta migrate` writes and +`meta verify --db` compares against. A database that Prisma creates from it therefore passes +the gate. + +| Model | Prisma | +|---|---| +| object with a writable `source.rdb` | `model ` with `@@map("")` when the names differ | +| field | a scalar field with `@map("")` when the names differ; `?` when the field is not `@required` | +| `field.string @maxLength` | `String @db.VarChar(n)` (Postgres, MySQL) | +| `field.int` / `field.long` / `field.currency` | `Int` / `BigInt` / `BigInt` | +| `field.double` / `field.float` | `Float` / `Float @db.Real` (Postgres) | +| `field.decimal` | `Decimal @db.Decimal(p, s)` | +| `field.boolean` | `Boolean` | +| `field.timestamp` / `field.date` / `field.time` | `DateTime` with `@db.Timestamptz`/`@db.Timestamp`, `@db.Date`, `@db.Time` (Postgres); `String` on SQLite, where the column holds ISO text | +| `field.uuid` | `String @db.Uuid` (Postgres), `@db.VarChar(36)` (MySQL) | +| `field.enum` | `String` plus a CHECK in `schema.checks.sql` (not a Prisma `enum`, so the column matches `meta migrate`'s) | +| `field.object`, `field.map`, `@storage: jsonb` | `Json` | +| `field.object @storage: flattened` | one field per field of the value object, `` with `@map("_")` | +| `isArray` scalar | a list (`String[]`) on Postgres; `Json` on MySQL; `String` holding JSON text on SQLite | +| `identity.primary` | `@id` or `@@id([...])`; `@generation: increment` is `@default(autoincrement())`; `@generation: uuid` is `gen_random_uuid()` on Postgres, `uuid()` elsewhere | +| `@default` | `@default(...)` for a literal; `"now"` and other expressions are `dbgenerated(...)`, so the default text matches what `verify --db` expects (a MySQL `DateTime` takes `now()`) | +| `identity.secondary` | `@unique` or `@@unique` (the index name in `map:`) | +| `index.lookup` | `@@index` (`@orders` becomes `sort: Desc`) | +| `identity.reference` | a relation field on both models, with `fields`, `references`, `onDelete`, `onUpdate` and the constraint name in `map:` | +| `@schema` on `source.rdb` | `@@schema("")` and the datasource's `schemas` list (Postgres) | + +## What it refuses + +The generator stops with an error code rather than write a schema that would push a database +the model does not describe. Each message names the object or index, says why, and says what +to change. + +| Code | When | What to do | +|---|---|---| +| `ERR_PRISMA_READ_ONLY_SOURCE` | an object declares a view that `meta migrate` creates and `meta verify --db` expects (a projection, a write-through entity's read view, or a served report) | create the view in a Prisma migration and mark its read `source.rdb` `@unmanaged: true`, or leave the object out with `filter` | +| `ERR_PRISMA_UNSUPPORTED_INDEX` | an index has `@expr` or `@where`, or a `@using` method other than `btree` or, on Postgres, `hash`, `gin`, `gist`, `brin` or `spgist` | create it in a Prisma migration, or drop the attribute | +| `ERR_PRISMA_UNSUPPORTED_SCHEMA` | a `source.rdb` declares `@schema` and the provider is not `postgresql`, where Prisma has no `@@schema` | remove the `@schema`, or use the `postgresql` provider | +| `ERR_PRISMA_UNSUPPORTED_CHECK` | the provider is SQLite and the model implies a CHECK | pass `checks: "omit"` | +| `ERR_PRISMA_NO_PRIMARY_KEY` | a table has no primary key | declare an `identity.primary` | +| `ERR_PRISMA_UNMAPPABLE_RELATION` | a foreign key points at a table with no model, at a non-unique key, or at a column of another type | include the table, reference a key, or match the subtype | +| `ERR_PRISMA_NAME_COLLISION` | two objects, or two fields, would get the same Prisma name | rename one, or narrow `filter` | +| `ERR_PRISMA_UNSUPPORTED_PROVIDER` | the dialect or `provider` is not `postgresql`, `mysql` or `sqlite` | pass `provider` | + +A model with projections, write-through read views or served reports therefore needs one of +those two fixes before the generator runs. A stored procedure, table function or materialized +view is not refused: `meta migrate` does not create it, so neither does Prisma. Read the view's rows with Prisma's `$queryRaw`, and validate them with the +generated Zod schema. + +## SQLite and MySQL + +- **SQLite** cannot add a CHECK to an existing table. Pass `checks: "omit"`. `meta verify --db` + then reports each missing CHECK as an `add-check` row and nothing else. Only the Zod schemas + enforce those rules. Timestamps, dates and times are `String` in the Prisma Client, because the + column holds ISO text, as it does under `meta migrate`. +- **MySQL** gets the enum CHECKs only. The other checks are written in Postgres SQL. The schema + matches the column types in the MySQL recipe, but `meta verify --db` does not support MySQL, + so on MySQL the drift gate is `verify --codegen` alone. + +## Eject it + +```bash +meta eject prisma-schema +``` + +This copies the generator to `codegen/generators/prisma-schema.ts`, and the mapping and +renderers to `codegen/templates/prisma/`. To change a type mapping, edit your copy. A mapping +that no longer matches the expected schema makes `meta verify --db` report drift. diff --git a/agent-context/skills/metaobjects-codegen/references/typescript.md b/agent-context/skills/metaobjects-codegen/references/typescript.md index 6db44b8d4..0765fafc5 100644 --- a/agent-context/skills/metaobjects-codegen/references/typescript.md +++ b/agent-context/skills/metaobjects-codegen/references/typescript.md @@ -15,8 +15,9 @@ packages. Codegen runs through the Node `meta` CLI (`@metaobjectsdev/cli`, binar - Field subtype → column mapping Topic files beside this one, to open when they come up: -`typescript-mysql.md` (a MySQL database), `typescript-document-store.md` (MongoDB or any -store MetaObjects does not manage, with a tested repository generator), +`typescript-mysql.md` (a MySQL database), `typescript-prisma.md` (Prisma as the ORM), +`typescript-document-store.md` (MongoDB or any store MetaObjects does not manage, with a +tested repository generator), `typescript-retargeting.md` (a framework other than Fastify, Hono or React), `typescript-templates.md` (declarative Mustache template-codegen) and `typescript-docs.md` (the docs generators). The procedure for any unsupported stack is `any-stack.md`. @@ -85,6 +86,7 @@ import { tanstackQuery, tanstackGrid } from "@metaobjectsdev/codegen-ts-tanstack export default defineConfig({ outDir: "src/generated", dialect: "postgres", // "postgres" | "sqlite" | "d1" | "mysql" (mysql: codegen + runtime; migrate does not own it) + // orm: "prisma", // keep Prisma: contract-only targets + prismaSchemaFile(); see `typescript-prisma.md` extStyle: "js", // "js" (default) for Node ESM / plain tsc; "none" for a bundler-resolution toolchain — see `owning-generators.md` "Your framework isn't the default" apiPrefix: "/api", // flows to routes AND client fetch URLs timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date) diff --git a/agent-context/skills/metaobjects-fit-assessment/SKILL.md b/agent-context/skills/metaobjects-fit-assessment/SKILL.md index e51534a79..bf2287436 100644 --- a/agent-context/skills/metaobjects-fit-assessment/SKILL.md +++ b/agent-context/skills/metaobjects-fit-assessment/SKILL.md @@ -105,7 +105,7 @@ Confidence: high | medium | low (and what would change it) Why, in three facts: , , Seams where it would pay off: (or "none found" — which is itself the finding for a single app) -Where it would not pay off: +Where it would not pay off: What you would not get: Next step: <"stop here" | "run the full assessment on " | the first-week wedge> ``` @@ -333,7 +333,7 @@ invalid output). Each row rules out *scopes*, not MetaObjects wholesale: | Backend language outside TS/Java/Kotlin/C#/Python | no codegen or runtime for that side; a seam to a supported-language consumer can still take a contract, generated for that side only | | No relational store (MongoDB, Cassandra, Neo4j, a remote API) | schema pillar N/A; model the stored records as sourceless entities (`identity.primary`, no `source.*`) — types, create/PATCH schemas, filter allowlists, `verify` and prompts IN; data access is a generator the adopter owns (`docs/recipes/document-graph-and-wide-column-stores.md`) | | DB not Postgres/SQLite/D1 (MySQL, ClickHouse, SQL Server, …) | `meta migrate` / `verify --db` OUT for that database — say so; types and data access unaffected. MySQL is a supported codegen + runtime dialect (`dialect: "mysql"`; `docs/recipes/mysql.md`) | -| ORM no reference generator targets (Prisma, TypeORM, Sequelize, SQLAlchemy ORM, Django ORM, Hibernate-only entities on the Java lane) | FULL means replacing the data layer — price it as such; CONTRACT SPINE and PARTIAL leave it untouched | +| ORM no reference generator targets (TypeORM, Sequelize, SQLAlchemy ORM, Django ORM, Hibernate-only entities on the Java lane; Prisma on TypeScript IS targeted: `orm: "prisma"` generates `schema.prisma`, so FULL keeps Prisma) | FULL means replacing the data layer — price it as such; CONTRACT SPINE and PARTIAL leave it untouched | | No seam (single app, no second consumer of any shape) | CONTRACT SPINE has nothing to protect | | Seams already gated (OpenAPI generation + check, shared-types package, schema registry) | that seam is not an opportunity; do not count it | | Few entities today (< ~5) | **not a flat verdict — the M8 trap.** Done at this size → NOT WORTH IT; expected to grow → FULL (adopt early); unanswered → both branches | diff --git a/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md b/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md index 5944aa65f..8d355c007 100644 --- a/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md +++ b/agent-context/skills/metaobjects-fit-assessment/references/capability-sheet.md @@ -43,9 +43,13 @@ building block. Every generator is a reference helper an adopter selects, ejects target is hand-building the same design. **Out of the box, contract-only does not** (say so when it matters, and say the fix): -- ship a generator for a Prisma schema, TypeORM entities, SQLAlchemy/Django models or JPA - entities — alongside those ORMs the shipped contract is Zod/TS types, records, POCOs or - Pydantic models *next to* the ORM model; +- ship a generator for TypeORM entities, SQLAlchemy/Django models or JPA entities — alongside + those ORMs the shipped contract is Zod/TS types, records, POCOs or Pydantic models *next to* + the ORM model. **Prisma is the exception (TypeScript):** under `orm: "prisma"`, `prisma-schema` + generates `schema.prisma` from the model for Postgres, MySQL or SQLite, `verify --codegen` fails + on a hand edit, and `verify --db` checks the database Prisma pushed (Postgres, SQLite). + Views, expression/partial indexes and SQLite CHECKs are refused, not dropped + (`docs/recipes/prisma.md`); - ship an OpenAPI, AsyncAPI or JSON Schema generator; - reach a consumer outside the five ports (Go, Rust, Ruby, PHP, Swift…) directly. diff --git a/bun.lock b/bun.lock index 1b9ca0549..090774d45 100644 --- a/bun.lock +++ b/bun.lock @@ -165,6 +165,7 @@ "kysely": "^0.27.0", "pg": "^8.0.0", "pg-mem": "^3.0.4", + "prisma": "7.10.1", "typescript": "^5.6.0", "zod": "^4.4.3", }, @@ -184,6 +185,7 @@ "@biomejs/wasm-nodejs": "^1.9.4", "@metaobjectsdev/docs-site": "workspace:*", "@metaobjectsdev/metadata": "workspace:*", + "@metaobjectsdev/migrate-ts": "workspace:*", "@metaobjectsdev/render": "workspace:*", "@toon-format/toon": "^2.3.0", "ts-poet": "^6.10.0", @@ -191,7 +193,6 @@ "devDependencies": { "@biomejs/biome": "^1.9.0", "@metaobjectsdev/codegen-ts-react": "workspace:*", - "@metaobjectsdev/migrate-ts": "workspace:*", "@metaobjectsdev/runtime-ts": "workspace:*", "bun-types": "latest", "drizzle-orm": "^0.38.0", @@ -578,6 +579,10 @@ "@electric-sql/pglite": ["@electric-sql/pglite@0.5.12", "", {}, "sha512-IiAxv1ld1V9QP0AWsflliDqrGRo+20O+yXOr2fjk6YzBHsCvTPclwQhCB1uZ7eHtPrxUrSllNzWgqy/Ot76PEw=="], + "@electric-sql/pglite-socket": ["@electric-sql/pglite-socket@0.1.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" }, "bin": { "pglite-server": "dist/scripts/server.js" } }, "sha512-LAciWM0M1dCL8hlsxu2venbVZcdxema0BtDfpWYVqr+Y468UADw0pFWidhKw1M8sfJ8rdLT71tjMmnirf/IZRQ=="], + + "@electric-sql/pglite-tools": ["@electric-sql/pglite-tools@0.3.3", "", { "peerDependencies": { "@electric-sql/pglite": "0.4.3" } }, "sha512-AlzLJTRJ8+UFgK8CmxIpyIpJ0+YaFw02IiOSdYrqxwPXdSyeIShz8aa9Tq+tYFXdPwcaMp/Fc80mQZ1dkOQ/wg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], "@exodus/bytes": ["@exodus/bytes@1.16.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-IcpW84uEn3N7ETtNZMlxKhfl6Pec8rUNGOTBtWbK1FKhJxIFAptZyVrvVRVBimAJxJCgc3PxepxkdWWG4DVzfA=="], @@ -772,6 +777,26 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@prisma/config": ["@prisma/config@7.10.1", "", { "dependencies": { "c12": "3.3.4", "deepmerge-ts": "8.0.2", "effect": "3.20.0", "empathic": "2.0.0" } }, "sha512-+0swF5ELUa3P/9cIR26+gpYjjCbGE0qvEBZhV4zMDkYsrtHjZ884MQbOnWFWXj600N0sSJNnHbtBqe12yDngWg=="], + + "@prisma/debug": ["@prisma/debug@7.10.1", "", {}, "sha512-eFCAkGZ13D/qPg48JBlG5IWi9cSBBOC7oG3abDPcAkYioDWlIjh0YJJLK2J7qo5S+xWWD8I6egAN/8KbCaiMCA=="], + + "@prisma/dev": ["@prisma/dev@0.24.17", "", { "dependencies": { "@electric-sql/pglite": "0.4.3", "@electric-sql/pglite-socket": "0.1.3", "@electric-sql/pglite-tools": "0.3.3", "@prisma/get-platform": "7.2.0", "@prisma/query-plan-executor": "7.2.0", "@prisma/streams-local": "0.1.11", "find-my-way": "9.7.0", "foreground-child": "3.3.1", "get-port-please": "3.2.0", "pathe": "2.0.3", "proper-lockfile": "4.1.2", "remeda": "2.33.4", "std-env": "3.10.0", "valibot": "1.4.2", "zeptomatch": "2.1.0" } }, "sha512-UvdZzmpFwknnfreh6Jije84ekkYGPYEJhXG1tFzCsCfQyzJifrOo38eZc0qajzvaC6OLUOrN9ML5XfCnEZL9DA=="], + + "@prisma/engines": ["@prisma/engines@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1", "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "@prisma/fetch-engine": "7.10.1", "@prisma/get-platform": "7.10.1" } }, "sha512-R6/ghv1Bu36FhCdQLFof1pNtyoWoeDzJLYeTKjgqb4eGtOAJx7ZqJtcm4pzkahLfZ9rYz8AkmsMDwkyiYbXPCQ=="], + + "@prisma/engines-version": ["@prisma/engines-version@8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "", {}, "sha512-XjXmwjN2NE340RpXoXv8ZF8aNAvr0FE+7gNfkKeW6vyjXs7F8uxvEbWQoVrhioXDQoIsnNmv7r+wxvzfdEZaWA=="], + + "@prisma/fetch-engine": ["@prisma/fetch-engine@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1", "@prisma/engines-version": "8.1.0-1.d1b3f377ca835d73fd50954a5e22128b9ebdd6b8", "@prisma/get-platform": "7.10.1" } }, "sha512-qZezeaExLWG2ptbHOrMxdiWluKHgqEclTdJbvY6pk6+Wg7EiVkeq5U3cXz9ti8nd0bngNvsDgledLHVRg5LF1w=="], + + "@prisma/get-platform": ["@prisma/get-platform@7.2.0", "", { "dependencies": { "@prisma/debug": "7.2.0" } }, "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA=="], + + "@prisma/query-plan-executor": ["@prisma/query-plan-executor@7.2.0", "", {}, "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ=="], + + "@prisma/streams-local": ["@prisma/streams-local@0.1.11", "", { "dependencies": { "ajv": "^8.12.0", "better-result": "^2.7.0", "env-paths": "^3.0.0", "proper-lockfile": "^4.1.2" } }, "sha512-0TcebL559MByKqTJ+SsrFIEg228iw8UCVRFckzgfRSiJqczhs+MuAgWOF9lnOIV/IVqvu+KMnFTH0eDeTQMpUg=="], + + "@prisma/studio-core": ["@prisma/studio-core@0.33.0", "", { "dependencies": { "@radix-ui/react-toggle": "1.1.10", "@visx/curve": "4.0.1-alpha.0", "@visx/event": "4.0.1-alpha.0", "@visx/grid": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/responsive": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "d3-array": "3.2.4", "d3-shape": "3.2.0", "elkjs": "0.11.1" }, "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-V2fX/nKEymNTrHXwfP26PGjoLStO35Ogu+ex7CFJbLrMYEcZxxZpiSNOs7px23Hk5mzLWvM5RsqG6Ka+rha+wg=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], @@ -790,6 +815,22 @@ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-toggle": ["@radix-ui/react-toggle@1.1.10", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], @@ -798,6 +839,8 @@ "@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@stryker-mutator/api": ["@stryker-mutator/api@9.6.1", "", { "dependencies": { "mutation-testing-metrics": "3.7.3", "mutation-testing-report-schema": "3.7.3", "tslib": "~2.8.0", "typed-inject": "~5.0.0" } }, "sha512-g8VNoFWQWbx0pdal3Vt8jVCZW+v3sc3gi94iI0GVtVgUGTqphAjJF6EAruPTx0lqvtonsaAxn5TD36hcG1d6Wg=="], "@stryker-mutator/core": ["@stryker-mutator/core@9.6.1", "", { "dependencies": { "@inquirer/prompts": "^8.0.0", "@stryker-mutator/api": "9.6.1", "@stryker-mutator/instrumenter": "9.6.1", "@stryker-mutator/util": "9.6.1", "ajv": "~8.18.0", "chalk": "~5.6.0", "commander": "~14.0.0", "diff-match-patch": "1.0.5", "emoji-regex": "~10.6.0", "execa": "~9.6.0", "json-rpc-2.0": "^1.7.0", "lodash.groupby": "~4.6.0", "minimatch": "~10.2.4", "mutation-server-protocol": "~0.4.0", "mutation-testing-elements": "3.7.3", "mutation-testing-metrics": "3.7.3", "mutation-testing-report-schema": "3.7.3", "npm-run-path": "~6.0.0", "progress": "~2.0.3", "rxjs": "~7.8.1", "semver": "^7.6.3", "source-map": "~0.7.4", "tree-kill": "~1.2.2", "tslib": "2.8.1", "typed-inject": "~5.0.0", "typed-rest-client": "~2.3.0" }, "bin": { "stryker": "bin/stryker.js" } }, "sha512-WMgnvf+Wyh/yiruhNZwc8w8DlzmmjXhPjSn5MR8RhAXzlnWji8TQrUYgBUkHk9bEgSaIlB3KZHm37iiU5Q2cLQ=="], @@ -826,12 +869,38 @@ "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/d3-array": ["@types/d3-array@3.0.3", "", {}, "sha512-Reoy+pKnvsksN0lQUlcH6dOGjRZ/3WRwXR//m+/8lt1BXeI4xyaUZoqULNjyXXRuh0Mj4LNpkCvhUpQlY3X5xQ=="], + + "@types/d3-color": ["@types/d3-color@3.1.0", "", {}, "sha512-HKuicPHJuvPgCD+np6Se9MQvS6OCbJmOjGvylzMJRlDwUXjKTTXs6Pwgk79O09Vj/ho3u1ofXnhFOaEWWPrlwA=="], + + "@types/d3-delaunay": ["@types/d3-delaunay@6.0.1", "", {}, "sha512-tLxQ2sfT0p6sxdG75c6f/ekqxjyYR0+LwPrsO1mbC9YDBzPJhs2HbJJRrn8Ez1DBoHRo2yx7YEATI+8V1nGMnQ=="], + + "@types/d3-format": ["@types/d3-format@3.0.1", "", {}, "sha512-5KY70ifCCzorkLuIkDe0Z9YTf9RR2CjBX1iaJG+rgM/cPP+sO+q9YdQ9WdhQcgPj1EQiJ2/0+yUkkziTG6Lubg=="], + + "@types/d3-geo": ["@types/d3-geo@3.1.0", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.1", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-jx5leotSeac3jr0RePOH1KdR9rISG91QIE4Q2PYTu4OymLTZfA3SrnURSLzKH48HmXVUru50b8nje4E79oQSQw=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.2", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-Yk4htunhPAwN0XGlIwArRomOjdoBFXC3+kCxK2Ubg7I9shQlVSJy/pG/Ht5ASN+gdMIalpk8TJ5xV74jFsetLA=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.7", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg=="], + + "@types/d3-time": ["@types/d3-time@3.0.0", "", {}, "sha512-sZLCdHvBUcNby1cB6Fd3ZBrABbjz3v1Vm90nysCQ6Vt7vd6e/h9Lt7SiJUoEX0l4Dzc7P5llKyhqSi1ycSf1Hg=="], + + "@types/d3-time-format": ["@types/d3-time-format@2.1.0", "", {}, "sha512-/myT3I7EwlukNOX2xVdMzb8FRgNzRMpsZddwst9Ld/VFe6LyJyRp0s32l/V9XoUzk+Gqu56F/oGk6507+8BxrA=="], + "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], "@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], + "@types/lodash": ["@types/lodash@4.17.27", "", {}, "sha512-VenMRv/eKNrspX9v3H4dbmjsrnCOYdYBG3kxXKk3dG6X+urBGcj2Prww3xw1U5K4FV/mE4h5GbWd0szEKf+9ew=="], + "@types/mustache": ["@types/mustache@4.2.6", "", {}, "sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw=="], "@types/node": ["@types/node@22.20.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA=="], @@ -848,6 +917,24 @@ "@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="], + "@visx/curve": ["@visx/curve@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-jRu61Uz274pV1zyioXmboyrLutYbnKsgjj4njSGCnhdXj5GkZvZbg+ThDb6oOzoAnJOBRLz4rzPlWvNJOzuVMg=="], + + "@visx/event": ["@visx/event@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/point": "4.0.1-alpha.0" } }, "sha512-EQqCMSv/s8NbFjo+hz3FKsvvYfP+2QslsFJ/24/O5l/W+7UC6J6aAvO0ujVwrTwdYbuQ+vhxKi1xdPdKR/qj1g=="], + + "@visx/grid": ["@visx/grid@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/point": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/shape": "4.0.1-alpha.0", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-rycutGmTHO+znNdPumheWMglm7YfpffvRwUkVy5zy4WoORIuKTMkDxwnOzHG2xMxU3EE/YCd37xFV5AxA30yeg=="], + + "@visx/group": ["@visx/group@4.0.1-alpha.0", "", { "dependencies": { "@types/react": "*", "classnames": "^2.3.1" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-V19l7iQ7jccBv8kao/EByuI6o4xtxzzLV9nqVI1hRvmdzTVsuLpqlwzYCZUXJaTVvUWf8s4D2SQFjGkj/Nw+0w=="], + + "@visx/point": ["@visx/point@4.0.1-alpha.0", "", {}, "sha512-ijTfr/Nx09f03vIj9nyTr3z4Xth4Y75427UaogJh6dnIRLMEFHQOwNu791sbfiNj0a+ZXuaE32h0vKrFe4/8Qg=="], + + "@visx/responsive": ["@visx/responsive@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-o+1zGywQZY0+yOx3Iw87wc4bbPJRr/HnIukTwfOz4UVyj9pB1OQNVHB7OORO1+LBHJceWpB31co/ZV9KHncKrA=="], + + "@visx/scale": ["@visx/scale@4.0.1-alpha.0", "", { "dependencies": { "@visx/vendor": "4.0.0-alpha.0" } }, "sha512-nzjeE87vFSAXGWFiiNfBpNLAf0Q8Qmf6syvKLjqNi4kGZkdhbUll3E/59YsgWXmjM8+llPLWzGsP+JPvo5eq1A=="], + + "@visx/shape": ["@visx/shape@4.0.1-alpha.0", "", { "dependencies": { "@types/lodash": "^4.17.13", "@types/react": "*", "@visx/curve": "4.0.1-alpha.0", "@visx/group": "4.0.1-alpha.0", "@visx/scale": "4.0.1-alpha.0", "@visx/vendor": "4.0.0-alpha.0", "classnames": "^2.3.1", "lodash": "^4.17.21" }, "peerDependencies": { "react": "^16.14.0 || ^17.0.0-0 || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-62QeiVNmPlterQGwhkEDcbq7M0MqY0lBsK5QKXtM9ZoPZWkuGV3aykA3+Xu20B2FAvyJq4LqJzBc7Sxr+EAdbA=="], + + "@visx/vendor": ["@visx/vendor@4.0.0-alpha.0", "", { "dependencies": { "@types/d3-array": "3.0.3", "@types/d3-color": "3.1.0", "@types/d3-delaunay": "6.0.1", "@types/d3-format": "3.0.1", "@types/d3-geo": "3.1.0", "@types/d3-interpolate": "3.0.1", "@types/d3-path": "3.1.1", "@types/d3-scale": "4.0.2", "@types/d3-shape": "3.1.7", "@types/d3-time": "3.0.0", "@types/d3-time-format": "2.1.0", "d3-array": "3.2.1", "d3-color": "3.1.0", "d3-delaunay": "6.0.2", "d3-format": "3.1.0", "d3-geo": "3.1.0", "d3-interpolate": "3.0.1", "d3-path": "3.1.0", "d3-scale": "4.0.2", "d3-shape": "3.2.0", "d3-time": "3.1.0", "d3-time-format": "4.1.0", "internmap": "2.0.3" } }, "sha512-6I+MuqXBcv9jnlcVowHoHKSdk9gXTWkHLKyqBwRWg7LY6A3Ei8SHfubpqGV5rBUSppxMq2RszPJUS6w+H0YgmQ=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], "abstract-logging": ["abstract-logging@2.0.1", "", {}, "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA=="], @@ -906,6 +993,8 @@ "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + "better-result": ["better-result@2.10.0", "", {}, "sha512-oQhh0y1qo2/ZKdAAEvHZAqKKiHOFU5k/bW96fE2ScgQOVkJRiHwB+nOS1SgFsYqRlxMDWvefXi9Q3px7QvgNDw=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], @@ -924,6 +1013,8 @@ "byline": ["byline@5.0.0", "", {}, "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q=="], + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], + "call-bind": ["call-bind@1.0.9", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "get-intrinsic": "^1.3.0", "set-function-length": "^1.2.2" } }, "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -936,8 +1027,12 @@ "chardet": ["chardet@2.2.0", "", {}, "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA=="], + "chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + "classnames": ["classnames@2.5.1", "", {}, "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="], + "cli-width": ["cli-width@4.1.0", "", {}, "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ=="], "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], @@ -954,6 +1049,8 @@ "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], "cookie": ["cookie@2.0.1", "", {}, "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w=="], @@ -972,6 +1069,28 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-delaunay": ["d3-delaunay@6.0.2", "", { "dependencies": { "delaunator": "5" } }, "sha512-IMLNldruDQScrcfT+MWnazhHbDJhcRJyOEBAJfwQnHle1RPh6WDuLvxNArUju2VSMSUuKlY5BGHRJ2cYyoFLQQ=="], + + "d3-format": ["d3-format@3.1.0", "", {}, "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA=="], + + "d3-geo": ["d3-geo@3.1.0", "", { "dependencies": { "d3-array": "2.5.0 - 3" } }, "sha512-JEo5HxXDdDYXCaWdwLRt79y7giK8SbhZJbFWXqbRTolCHFI5jRqteLzCsq51NKbUoX0PjBVSohxrx+NoOUujYA=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], @@ -980,12 +1099,20 @@ "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + "deepmerge-ts": ["deepmerge-ts@8.0.2", "", {}, "sha512-uqbvqLUMrc6p0MO+WBRtTxY55hmyh94WRwI5a++PZe54X+bfVh59FSN7uWCBCW1CCVjzjnrwzfI8zidE2obMMw=="], + "define-data-property": ["define-data-property@1.1.4", "", { "dependencies": { "es-define-property": "^1.0.0", "es-errors": "^1.3.0", "gopd": "^1.0.1" } }, "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A=="], + "defu": ["defu@6.1.7", "", {}, "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ=="], + + "delaunator": ["delaunator@5.1.0", "", { "dependencies": { "robust-predicates": "^3.0.2" } }, "sha512-AGrQ4QSgssa1NGmWmLPqN5NY2KajF5MqxetNEO+o0n3ZwZZeTmt7bBnvzHWrmkZFxGgr4HdyFgelzgi06otLuQ=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "des.js": ["des.js@1.1.0", "", { "dependencies": { "inherits": "^2.0.1", "minimalistic-assert": "^1.0.0" } }, "sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg=="], + "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], + "detect-libc": ["detect-libc@2.0.2", "", {}, "sha512-UX6sGumvvqSaXgdKGUsgZWqcUyIXZ/vZTrlRT/iobiKhGL0zL4d3osHj3uqllWJK+i+sixDS/3COVEOFbupFyw=="], "diff-match-patch": ["diff-match-patch@1.0.5", "", {}, "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw=="], @@ -1000,6 +1127,8 @@ "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="], + "dprint-node": ["dprint-node@1.0.8", "", { "dependencies": { "detect-libc": "^1.0.3" } }, "sha512-iVKnUtYfGrYcW1ZAlfR/F59cUVL8QIhWoBJoSjkkdua/dkWIgjZfiLMeTjiB06X0ZLkQ0M2C1VbUj/CxkIf1zg=="], "drizzle-orm": ["drizzle-orm@0.45.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@netlify/db": ">=0.4.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "prisma": "*", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@netlify/db", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "prisma", "sql.js", "sqlite3"] }, "sha512-NiM1YZpJ2UxyeE67DpDDZ+Tsw4YLX3trSlkNT7Tm9eKoUNb1LvaAaQzXgYQsWs+AQ9TUmmwMmBOCXuqqxUWKKQ=="], @@ -1008,14 +1137,22 @@ "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "effect": ["effect@3.20.0", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw=="], + "electron-to-chromium": ["electron-to-chromium@1.5.464", "", {}, "sha512-nRa65aFge40viPAKxMKULIY8tcXKwKboJWNn4RoKwyWXJ6SP7BnrDGKUTzc64818jIciH0PK7LSF8QzZes19GA=="], + "elkjs": ["elkjs@0.11.1", "", {}, "sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg=="], + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "empathic": ["empathic@2.0.0", "", {}, "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + "env-paths": ["env-paths@3.0.0", "", {}, "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A=="], + "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -1036,6 +1173,10 @@ "exit-hook": ["exit-hook@2.2.1", "", {}, "sha512-eNTPlAD67BmP31LDINZ3U7HSF8l57TxOY2PmBJ1shpCvpnxBF93mWCE8YHBnXs8qiUZJc9WDcWIeC3a2HIAMfw=="], + "exsolve": ["exsolve@1.2.0", "", {}, "sha512-h1pQPR9XoeSMdntRNCjyTznaume4UpfXrULBnlkZ2CUM/G5eSa+pBByGldO5uR8dxbPC+OuJXsPPNf+JZ81ADg=="], + + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fast-decode-uri-component": ["fast-decode-uri-component@1.0.1", "", {}, "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg=="], "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], @@ -1084,10 +1225,14 @@ "get-port": ["get-port@7.2.0", "", {}, "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg=="], + "get-port-please": ["get-port-please@3.2.0", "", {}, "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A=="], + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], "get-stream": ["get-stream@9.0.1", "", { "dependencies": { "@sec-ant/readable-stream": "^0.4.1", "is-stream": "^4.0.1" } }, "sha512-kVCxPF3vQM/N0B1PmoqVUqgHP+EeVjmZSQn+1oCRPxd2P21P2F19lIgbR3HBosbB1PUhOAoctJnfEn2GbN2eZA=="], + "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "glob-to-regexp": ["glob-to-regexp@0.4.1", "", {}, "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw=="], @@ -1096,6 +1241,10 @@ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "grammex": ["grammex@3.1.13", "", {}, "sha512-LnPnhOBLEJEVKS8WFDVaA397L9Kq55Q9oSITJiVLHVdhAclfUkWzQv74KhvZHKL2Q09Pb1XdsrOsZ4LfTFFTEg=="], + + "graphmatch": ["graphmatch@1.1.1", "", {}, "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg=="], + "has-property-descriptors": ["has-property-descriptors@1.0.2", "", { "dependencies": { "es-define-property": "^1.0.0" } }, "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -1118,6 +1267,8 @@ "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "ipaddr.js": ["ipaddr.js@2.5.0", "", {}, "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w=="], "is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="], @@ -1252,6 +1403,8 @@ "object-keys": ["object-keys@1.1.1", "", {}, "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="], + "ohash": ["ohash@2.0.12", "", {}, "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw=="], + "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -1266,6 +1419,10 @@ "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "perfect-debounce": ["perfect-debounce@2.1.0", "", {}, "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g=="], + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], @@ -1294,6 +1451,10 @@ "pino-std-serializers": ["pino-std-serializers@7.1.0", "", {}, "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw=="], + "pkg-types": ["pkg-types@2.4.0", "", { "dependencies": { "confbox": "^0.3.1", "exsolve": "^1.1.3", "pathe": "^2.0.3" } }, "sha512-07Fys7iVx0X6TcviMOgogM1Lk33ZyqOezMnU79S+kffWqwolG/5W9V8EgQSeYCIfFTYVgssRbnH630Oa2+soBA=="], + + "postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], @@ -1306,6 +1467,8 @@ "pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="], + "prisma": ["prisma@7.10.1", "", { "dependencies": { "@prisma/config": "7.10.1", "@prisma/dev": "0.24.17", "@prisma/engines": "7.10.1", "@prisma/studio-core": "0.33.0", "mysql2": "3.24.4", "postgres": "3.4.7" }, "peerDependencies": { "better-sqlite3": ">=9.0.0", "typescript": ">=5.4.0" }, "optionalPeers": ["better-sqlite3", "typescript"], "bin": { "prisma": "build/index.js" } }, "sha512-GoVeErHGGtsSjsflQ1r2BA3RKTXtxNraQJz8OLkLhoLr7OgCscdx4qw4WMM4HnERYc+a1cq2VnMNbep+vOjNcQ=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], @@ -1326,6 +1489,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "qs": ["qs@6.16.1", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg=="], "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], @@ -1334,6 +1499,8 @@ "randexp": ["randexp@0.4.6", "", { "dependencies": { "discontinuous-range": "1.0.0", "ret": "~0.1.10" } }, "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ=="], + "rc9": ["rc9@3.1.0", "", { "dependencies": { "defu": "^6.1.7", "destr": "^2.0.5" } }, "sha512-ufjkNVzbRHKcCOmTahZkmVsyc3W+MSk3jY03m+a7tGHkIsdVMG9l10/3HvFbWkkKzY5VFp3pkRsIo/UYgmFL7Q=="], + "react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="], "react-dom": ["react-dom@19.3.1", "", { "dependencies": { "scheduler": "^0.28.0" }, "peerDependencies": { "react": "^19.3.1" } }, "sha512-Y0bARz53lhEC1NMCASs0/j+faB/zqt3R8hpvg1e8+61ZVJ4UL0UdC4cBbXfZMcIuhrfgeILTLe+ZeBCavQPtKQ=="], @@ -1348,8 +1515,12 @@ "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + "readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="], + "real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], + "remeda": ["remeda@2.33.4", "", {}, "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ=="], + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], @@ -1362,6 +1533,8 @@ "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + "robust-predicates": ["robust-predicates@3.0.3", "", {}, "sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA=="], + "rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="], "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], @@ -1418,6 +1591,8 @@ "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + "stoppable": ["stoppable@1.1.0", "", {}, "sha512-KXDYZ9dszj6bzvnEMRYvxgeTHU74QBFL54XKtP3nyMuJ81CFYtABZ3bAzL2EdFUaEwJOBOgENyFj3R7oTzDyyw=="], "streamx": ["streamx@2.28.1", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA=="], @@ -1492,6 +1667,8 @@ "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + "valibot": ["valibot@1.4.2", "", { "peerDependencies": { "typescript": ">=5" }, "optionalPeers": ["typescript"] }, "sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], "weapon-regex": ["weapon-regex@1.3.6", "", {}, "sha512-wsf1m1jmMrso5nhwVFJJHSubEBf3+pereGd7+nBKtYJ18KoB/PWJOHS3WRkwS04VrOU0iJr2bZU+l1QaTJ+9nA=="], @@ -1538,6 +1715,8 @@ "youch-core": ["youch-core@0.3.3", "", { "dependencies": { "@poppinss/exception": "^1.2.2", "error-stack-parser-es": "^1.0.5" } }, "sha512-ho7XuGjLaJ2hWHoK8yFnsUGy2Y5uDpqSTq1FkHLK4/oqKtyUU1AFbOOxY4IpC9f0fTLjwYbslUz0Po5BpD1wrA=="], + "zeptomatch": ["zeptomatch@2.1.0", "", { "dependencies": { "grammex": "^3.1.11", "graphmatch": "^1.1.0" } }, "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA=="], + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], "zod": ["zod@4.6.7", "", {}, "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg=="], @@ -1554,6 +1733,10 @@ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@electric-sql/pglite-socket/@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], + + "@electric-sql/pglite-tools/@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], + "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="], "@inquirer/core/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], @@ -1576,12 +1759,24 @@ "@metaobjectsdev/runtime-ts/@libsql/client": ["@libsql/client@0.15.15", "", { "dependencies": { "@libsql/core": "^0.15.14", "@libsql/hrana-client": "^0.7.0", "js-base64": "^3.7.5", "libsql": "^0.5.22", "promise-limit": "^2.7.0" } }, "sha512-twC0hQxPNHPKfeOv3sNT6u2pturQjLcI+CnpTM0SjRpocEGgfiZ7DWKXLNnsothjyJmDqEsBQJ5ztq9Wlu470w=="], + "@prisma/dev/@electric-sql/pglite": ["@electric-sql/pglite@0.4.3", "", {}, "sha512-ichuWTgtd4mOM1G4SpyGJa5trT03lWbMypDV0fUXUCXg5hiHqVAz/bZyV68NqmkLB7WcYmj1RMJVSp8HV/v/ZQ=="], + + "@prisma/dev/find-my-way": ["find-my-way@9.7.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-querystring": "^1.0.0", "safe-regex2": "^5.0.0" } }, "sha512-f2JHn75x2JlwUwLenZypgczR7YWMb/uO9BvUXtus+JMgkbIkLADd38cI4EiV+OQqrGo1Zlq6V8wnqMJ8e62wUQ=="], + + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1" } }, "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg=="], + + "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.10.1", "", { "dependencies": { "@prisma/debug": "7.10.1" } }, "sha512-iVnghHne5vcaobBjPVCMEjh5r/1UCj394EIGTUclasFRatpR/MQwcM2veLv5kgoFSyYP54nch/lNFCQRYcDxvg=="], + + "@prisma/get-platform/@prisma/debug": ["@prisma/debug@7.2.0", "", {}, "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw=="], + "@stryker-mutator/instrumenter/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + "@visx/vendor/d3-array": ["d3-array@3.2.1", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-gUY/qeHq/yNqqoCKNq4vtpFLdoCdvyNpWoC/KNjhGbhDuQpAM9sIQQKkXSNpXa9h5KySs/gzm7R88WkUutgwWQ=="], + "ajv/fast-uri": ["fast-uri@3.1.9", "", {}, "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -1618,6 +1813,10 @@ "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + "pkg-types/confbox": ["confbox@0.3.1", "", {}, "sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA=="], + + "prisma/mysql2": ["mysql2@3.24.4", "", { "dependencies": { "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", "iconv-lite": "^0.7.3", "long": "^5.3.2", "lru.min": "^1.1.4", "named-placeholders": "^1.1.6", "sql-escaper": "^1.5.1" }, "peerDependencies": { "@types/node": ">= 8" } }, "sha512-A2olluVlj0mvgyIRRISMEzXc51m+21mRtcMVjJyIpt2GG98+XrC9m9HzsqcMsX2LcnfccJvY5NB22g8fENBnOA=="], + "randexp/ret": ["ret@0.1.15", "", {}, "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg=="], "readdir-glob/minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], diff --git a/docs/README.md b/docs/README.md index b958e2a88..48dea4332 100644 --- a/docs/README.md +++ b/docs/README.md @@ -73,6 +73,7 @@ this tree is documentation, not the source of truth. | Record what the system is supposed to do, and stop agents reviving retired features | [`features/requirements.md`](features/requirements.md) | | Declare what a dashboard groups by and counts (`dimension`, `measure`, `segment`, `object.report`; load-time checked; a view-backed report becomes a SQL view served by a generated read-only list route and a TypeScript list hook) | [`features/reporting.md`](features/reporting.md) | | Download a report as CSV, JSON or XML (`?format=`, the `report-export` generator, its cap and null forms) | [`features/reporting.md`](features/reporting.md#exporting-a-report) | +| Keep Prisma as the ORM, with `schema.prisma` generated from the model and gated by `verify` (TypeScript) | [`recipes/prisma.md`](recipes/prisma.md) | | Export the reporting vocabulary to Cube (`cube-model`: cubes, joins, rollups; the `cube` lane that checks it against a real Cube) | [`features/cube-export.md`](features/cube-export.md) | | Wire prompt construction (FR-004) | [`features/templates-and-payloads.md`](features/templates-and-payloads.md) | | Share a metadata shape across multiple instances (abstracts, `extends:`) | [`features/abstracts-and-inheritance.md`](features/abstracts-and-inheritance.md) | diff --git a/docs/llms/llms-full.txt b/docs/llms/llms-full.txt index 8a3f52e18..62ade3416 100644 --- a/docs/llms/llms-full.txt +++ b/docs/llms/llms-full.txt @@ -57,7 +57,7 @@ Reference generators you own, ejectable in every port. They emit per-language co **What is guaranteed, and what is yours** (ADR-0034 Amendment 3). The core is the promise: the metamodel, loader, canonical format and registry; runtime metadata access (the `ObjectManager`, not the HTTP adapters that mount it); schema migrations (`meta migrate`); the drift gates (`meta verify`); prompt render and the reply parser. Every generator that writes application code into the repo is a reference helper — a starting point that compiles and passes its reference fixtures, which the adopter copies with `meta eject` and owns. A defect in the core is a MetaObjects bug; a defect in a generator is fixed in the reference, and the adopter's copy is theirs. The test is mechanical: what the tool guarantees is core; what it writes into your repo is a helper. -- TypeScript: Drizzle + Zod + Fastify integration (and React / TanStack on the client side). **Keep your ORM:** a target with `runtime: false` makes the `entity` generator write Zod schemas and inferred types only, with no Drizzle and no `runtime-ts` import +- TypeScript: Drizzle + Zod + Fastify integration (and React / TanStack on the client side). **Keep your ORM:** a target with `runtime: false` makes the `entity` generator write Zod schemas and inferred types only, with no Drizzle and no `runtime-ts` import. **Prisma:** `orm: "prisma"` also generates `schema.prisma` from the model (Postgres, MySQL, SQLite) with `prismaSchemaFile()`, and `verify --codegen` and `verify --db` fail when Prisma and the model drift (`docs/recipes/prisma.md`) - Java: Spring REST + DTO + repository interfaces (the persistence impl is consumer-supplied) via `codegen-spring`. **Keep your ORM:** `SpringDtoGenerator` and `SpringValueObjectGenerator` write records (jakarta validation) and your JPA entity stays hand-written. Baseline Java 21, Spring Boot 3.x (Boot 2 is not supported) - Kotlin: KotlinPoet + Exposed + Spring via `codegen-kotlin`. **Keep your ORM:** `KotlinEntityGenerator` alone writes plain data classes; JVM 21, Spring Boot 3.x - C#: EF Core + ASP.NET (.NET 8). **Keep your EF model:** an object declared with no `source.rdb` generates a POCO with DataAnnotations and no EF mapping, a contract separate from your entities diff --git a/docs/llms/llms.txt b/docs/llms/llms.txt index cd3ea282b..c40809af7 100644 --- a/docs/llms/llms.txt +++ b/docs/llms/llms.txt @@ -50,7 +50,7 @@ MetaObjects pillars are capabilities of the same metadata spine, not separate pr - **Requirements and testing** — vocabulary, the `verify` checks and a `requirement-tests` generator in all five ports; the authoring lint is TypeScript-only; dogfooded on maintainer-owned projects, no outside adopter yet. - **Libraries** — since 1.0.4, in all five ports: `ai` is `stable`, `iam` is `preview` (the same labels `meta gen --list` prints). -- **Codegen** — reference generators you own (ejectable in every port) that emit per-language code from a single metadata model. Drizzle + Zod + Fastify (TypeScript), Spring REST + DTO + repository interfaces (the persistence impl is consumer-supplied) (Java via `codegen-spring`), KotlinPoet + Exposed + Spring (Kotlin via `codegen-kotlin`), EF Core + ASP.NET (C#), Pydantic + FastAPI (Python). Hand-edit-preserving regeneration via three-way merge is TypeScript-only; the other ports refuse an edited generated file by name and let you eject the generator to own it. Keep your ORM: contract-only output (TypeScript `runtime: false`: Zod and types; Java and Kotlin: records and data classes; C#: sourceless POCOs; Python: Pydantic) leaves your Prisma, JPA or EF Core models untouched. Includes M:N relationship codegen (FR-018) in all five ports — entity navigation, idiomatic ORM wiring, and REST traversal (`GET //{id}/`). +- **Codegen** — reference generators you own (ejectable in every port) that emit per-language code from a single metadata model. Drizzle + Zod + Fastify (TypeScript), Spring REST + DTO + repository interfaces (the persistence impl is consumer-supplied) (Java via `codegen-spring`), KotlinPoet + Exposed + Spring (Kotlin via `codegen-kotlin`), EF Core + ASP.NET (C#), Pydantic + FastAPI (Python). Hand-edit-preserving regeneration via three-way merge is TypeScript-only; the other ports refuse an edited generated file by name and let you eject the generator to own it. Keep your ORM: contract-only output (TypeScript `runtime: false`: Zod and types; Java and Kotlin: records and data classes; C#: sourceless POCOs; Python: Pydantic) leaves your JPA or EF Core models untouched. Prisma on TypeScript goes further: `orm: "prisma"` generates `schema.prisma` from the model (Postgres, MySQL, SQLite), and `verify --codegen` and `verify --db` fail when Prisma and the model drift (`docs/recipes/prisma.md`). Includes M:N relationship codegen (FR-018) in all five ports — entity navigation, idiomatic ORM wiring, and REST traversal (`GET //{id}/`). - **Runtime metadata** — load metadata at runtime and drive behavior dynamically: CRUD, validation, relationships, dynamic admin UIs. (Typed tool payloads are declared today; MCP exposure of tools is on the roadmap.) Kysely (TS), a DB-API 2 driver (pg8000 / psycopg) via ObjectManager (Python), modernized JDBC + Spring-tx via OMDB (Java), Exposed (Kotlin); C# runtime metadata is on the roadmap (its EF Core output is generated code, not a metadata-driven runtime). Runtime queries return native in-process types (ADR-0019); wire canonicalization happens only at the serialization boundary. - **Drift detection** — catch divergence between generated code and metadata before it ships. `verify` is one verb with explicit subverbs (ADR-0021): `verify --codegen` (regen-and-diff against committed output), `verify --templates` (prompt `{{field}}` ↔ payload-VO drift), and `verify --db` (live-DB schema drift, Node `meta` only). Surfaces drift as build-time breakage rather than a production incident. - **Prompt construction** — treat LLM prompts as governed metadata instead of strings scattered across services. A typed payload declared as a projection (so payload bloat and token cost are a diff, not a mystery), external provider-resolved prompt text, and a logic-less Mustache engine that renders deterministically: snapshot-testable in CI, byte-stable so an exact-prefix prompt-cache hit doesn't break on a stray whitespace, and drift-checked at build time so a renamed field can't silently degrade a prompt. Conformance-gated, so the guarantee holds in every language port. Render + payload-VO codegen + `verify` + `template.output` parser-on-receipt (FR-006) + the output-format prompt fragment & tolerant `extract` parser (FR-010/FR-011) ship in all five ports today. diff --git a/docs/ports/typescript.md b/docs/ports/typescript.md index b7d4eb876..50ca13f60 100644 --- a/docs/ports/typescript.md +++ b/docs/ports/typescript.md @@ -19,7 +19,7 @@ generator (`entityFile()`) writes a Drizzle table binding only for a target that runtime bindings, which is the default. Point it at a **contract-only target** (`runtime: false`) and it writes Zod schemas and inferred TypeScript types, with no `drizzle-orm` and no `@metaobjectsdev/runtime-ts` import, for entities, value objects and -projections alike. Your Prisma (or other ORM) models stay hand-written beside them. +projections alike. Your ORM's models stay hand-written beside them, except Prisma's (below). ```ts export default defineConfig({ @@ -30,6 +30,26 @@ export default defineConfig({ }); ``` +**Prisma.** Set `orm: "prisma"`, and the model also writes your `schema.prisma`, so each field +is declared once. Every target defaults to `runtime: false`, the generators that write Drizzle +code (`queries`, `routes`, `routes-hono`, `callable`) are refused, and `prismaSchemaFile()` +writes the schema for Postgres, MySQL or SQLite, plus a `schema.checks.sql` holding the CHECK +constraints a Prisma schema cannot declare. Prisma creates the tables. `meta verify --codegen` +fails on a hand-edited or stale schema, and `meta verify --db` fails when the database Prisma +pushed differs from the model. What a Prisma schema cannot express (a view, an expression or +partial index, a CHECK on SQLite) is refused with an `ERR_PRISMA_*` code, not dropped. See +[the Prisma recipe](../recipes/prisma.md). + +```ts +export default defineConfig({ + orm: "prisma", + dialect: "postgres", + outDir: "src/generated", + targets: { prisma: { outDir: "prisma" } }, + generators: ["entity", prismaSchemaFile({ target: "prisma" })], +}); +``` + A model of only value objects and sourceless projections needs no `dialect`. A sourceless object generates its type and nothing else: no table, no migration, no routes. See [Own your codegen](../features/own-your-codegen.md) for ejecting a generator, and the diff --git a/docs/recipes/prisma.md b/docs/recipes/prisma.md new file mode 100644 index 000000000..0c8640536 --- /dev/null +++ b/docs/recipes/prisma.md @@ -0,0 +1,181 @@ +# Prisma + +> The sections from "TypeScript setup" on are shipped verbatim in the `metaobjects-codegen` +> skill as `references/typescript-prisma.md`; change them here and copy them there (a test +> compares the two). + +A project that keeps Prisma as its ORM declares each field once, in the MetaObjects model. Then: + +- `meta gen` writes `schema.prisma` from the model, with Zod schemas and TypeScript types beside + it and no Drizzle code. +- `meta verify --codegen` fails when someone edits `schema.prisma` or it is out of date. +- `meta verify --db` compares the database Prisma created against the model, and fails when + they differ. + +Prisma still runs the database: `prisma db push` or `prisma migrate` creates the tables, and the +Prisma Client reads and writes them. MetaObjects does not run `meta migrate` against it. + +This is TypeScript only. The other ports do not generate a Prisma schema. + +## What works with Prisma + +| Tier | Postgres (`postgresql`) | MySQL (`mysql`) | SQLite (`sqlite`) | +|---|---|---|---| +| `schema.prisma` | yes | yes | yes | +| CHECK constraints | every one, in `schema.checks.sql` | enum membership only, in `schema.checks.sql` | none: pass `checks: "omit"` | +| `meta verify --codegen` | yes | yes | yes | +| `meta verify --db` | passes after `db push` plus the checks file | not available: `verify --db` does not support MySQL | reports each CHECK as missing, and nothing else | +| Generated entity modules | Zod schemas and types (`runtime: false`) | the same | the same | + +These tests exercise it: + +- `codegen-ts/test/prisma/prisma-schema.test.ts`: golden schemas for all three providers, and + every refusal. +- `cli/test/integration/prisma-schema.test.ts`: `meta gen`, then `prisma validate` and + `prisma format`, then `meta verify --codegen`. It also runs `prisma db push` and + `meta verify --db` on SQLite and on a real Postgres, and checks that a change to either + database fails the gate. + +SQL Server, Oracle and CockroachDB are not supported. Neither is a schema that Prisma owns and +MetaObjects reads (`prisma db pull` into the model). + +## TypeScript setup + +```ts +// metaobjects.config.ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { prismaSchemaFile } from "@metaobjectsdev/codegen-ts/generators"; + +export default defineConfig({ + orm: "prisma", + dialect: "postgres", // "postgres" | "mysql" | "sqlite": picks the Prisma provider + outDir: "src/generated", // Zod schemas and types + targets: { prisma: { outDir: "prisma" } }, + generators: ["entity", prismaSchemaFile({ target: "prisma" })], +}); +``` + +`orm: "prisma"` does two things: + +- **Every target defaults to `runtime: false`.** The `entity` generator then writes Zod schemas + and types with no `drizzle-orm` import. A target that sets `runtime: true` is refused. +- **Generators that write Drizzle code are refused** by name: `queries`, `routes`, + `routes-hono` and `callable`. Your routes call the Prisma Client instead. `meta gen` exits + with `orm: "prisma" refuses "queries", …` and makes no changes. + +The default `orm` is `"drizzle"`, and it changes nothing. + +Prisma 7 reads the connection URL from `prisma.config.ts`, not from the schema: + +```ts +// prisma.config.ts +import { defineConfig } from "prisma/config"; +export default defineConfig({ schema: "prisma/schema.prisma", datasource: { url: process.env.DATABASE_URL! } }); +``` + +The generated schema names the `prisma-client` generator, writing to `prisma/generated`. To +choose your own generator and datasource blocks, pass `header` as a string. Pass +`header: false` for a multi-file schema (`prisma/schema/`) whose blocks live in a file you own. +Other options: + +- `provider` overrides the provider that `dialect` picks. +- `fileName` renames the file (default `schema.prisma`). +- `filter` narrows which objects get a model. +- `checks: "omit"` drops the CHECK file. + +## The workflow + +```bash +meta gen # writes prisma/schema.prisma (+ schema.checks.sql) +npx prisma db push # or: npx prisma migrate dev +npx prisma db execute --file prisma/schema.checks.sql +npx prisma generate # the Prisma Client, as usual +meta verify --codegen --db "$DATABASE_URL" # in CI: fails on an edited schema or a drifted database +``` + +To change a field, edit the model and run `meta gen`. **Do not edit `schema.prisma` by hand.** +The generator overwrites hand edits instead of merging them. Prisma treats the whole file as +the schema, so a merged edit would change the database without the model knowing. +`meta verify --codegen` fails on the edit until you run `meta gen` again. `prisma format` +leaves the file unchanged, so formatting it does not count as an edit. + +A Prisma schema cannot declare a CHECK constraint, so the constraints the model implies go in +`schema.checks.sql`. These are enum membership checks plus the `@min`/`@max` and length +validators. Apply the file after every `db push` or migration. On Postgres the file drops each +constraint before adding it, so you can apply it more than once. On MySQL it only adds them, +so apply it once, when you create the table. When a check changes, drop the old one in a Prisma +migration. When the model has no checks left, `meta gen` deletes the file. + +With `prisma migrate`, keep the Prisma migration chain: `meta gen` changes `schema.prisma`, and +`prisma migrate dev` writes the SQL. To make the checks part of the chain, put the contents of +`schema.checks.sql` into a migration (`prisma migrate dev --create-only`, then add the SQL). + +## How the model maps + +The schema is built from the same expected schema that `meta migrate` writes and +`meta verify --db` compares against. A database that Prisma creates from it therefore passes +the gate. + +| Model | Prisma | +|---|---| +| object with a writable `source.rdb` | `model ` with `@@map("
")` when the names differ | +| field | a scalar field with `@map("")` when the names differ; `?` when the field is not `@required` | +| `field.string @maxLength` | `String @db.VarChar(n)` (Postgres, MySQL) | +| `field.int` / `field.long` / `field.currency` | `Int` / `BigInt` / `BigInt` | +| `field.double` / `field.float` | `Float` / `Float @db.Real` (Postgres) | +| `field.decimal` | `Decimal @db.Decimal(p, s)` | +| `field.boolean` | `Boolean` | +| `field.timestamp` / `field.date` / `field.time` | `DateTime` with `@db.Timestamptz`/`@db.Timestamp`, `@db.Date`, `@db.Time` (Postgres); `String` on SQLite, where the column holds ISO text | +| `field.uuid` | `String @db.Uuid` (Postgres), `@db.VarChar(36)` (MySQL) | +| `field.enum` | `String` plus a CHECK in `schema.checks.sql` (not a Prisma `enum`, so the column matches `meta migrate`'s) | +| `field.object`, `field.map`, `@storage: jsonb` | `Json` | +| `field.object @storage: flattened` | one field per field of the value object, `` with `@map("_")` | +| `isArray` scalar | a list (`String[]`) on Postgres; `Json` on MySQL; `String` holding JSON text on SQLite | +| `identity.primary` | `@id` or `@@id([...])`; `@generation: increment` is `@default(autoincrement())`; `@generation: uuid` is `gen_random_uuid()` on Postgres, `uuid()` elsewhere | +| `@default` | `@default(...)` for a literal; `"now"` and other expressions are `dbgenerated(...)`, so the default text matches what `verify --db` expects (a MySQL `DateTime` takes `now()`) | +| `identity.secondary` | `@unique` or `@@unique` (the index name in `map:`) | +| `index.lookup` | `@@index` (`@orders` becomes `sort: Desc`) | +| `identity.reference` | a relation field on both models, with `fields`, `references`, `onDelete`, `onUpdate` and the constraint name in `map:` | +| `@schema` on `source.rdb` | `@@schema("")` and the datasource's `schemas` list (Postgres) | + +## What it refuses + +The generator stops with an error code rather than write a schema that would push a database +the model does not describe. Each message names the object or index, says why, and says what +to change. + +| Code | When | What to do | +|---|---|---| +| `ERR_PRISMA_READ_ONLY_SOURCE` | an object declares a view that `meta migrate` creates and `meta verify --db` expects (a projection, a write-through entity's read view, or a served report) | create the view in a Prisma migration and mark its read `source.rdb` `@unmanaged: true`, or leave the object out with `filter` | +| `ERR_PRISMA_UNSUPPORTED_INDEX` | an index has `@expr` or `@where`, or a `@using` method other than `btree` or, on Postgres, `hash`, `gin`, `gist`, `brin` or `spgist` | create it in a Prisma migration, or drop the attribute | +| `ERR_PRISMA_UNSUPPORTED_SCHEMA` | a `source.rdb` declares `@schema` and the provider is not `postgresql`, where Prisma has no `@@schema` | remove the `@schema`, or use the `postgresql` provider | +| `ERR_PRISMA_UNSUPPORTED_CHECK` | the provider is SQLite and the model implies a CHECK | pass `checks: "omit"` | +| `ERR_PRISMA_NO_PRIMARY_KEY` | a table has no primary key | declare an `identity.primary` | +| `ERR_PRISMA_UNMAPPABLE_RELATION` | a foreign key points at a table with no model, at a non-unique key, or at a column of another type | include the table, reference a key, or match the subtype | +| `ERR_PRISMA_NAME_COLLISION` | two objects, or two fields, would get the same Prisma name | rename one, or narrow `filter` | +| `ERR_PRISMA_UNSUPPORTED_PROVIDER` | the dialect or `provider` is not `postgresql`, `mysql` or `sqlite` | pass `provider` | + +A model with projections, write-through read views or served reports therefore needs one of +those two fixes before the generator runs. A stored procedure, table function or materialized +view is not refused: `meta migrate` does not create it, so neither does Prisma. Read the view's rows with Prisma's `$queryRaw`, and validate them with the +generated Zod schema. + +## SQLite and MySQL + +- **SQLite** cannot add a CHECK to an existing table. Pass `checks: "omit"`. `meta verify --db` + then reports each missing CHECK as an `add-check` row and nothing else. Only the Zod schemas + enforce those rules. Timestamps, dates and times are `String` in the Prisma Client, because the + column holds ISO text, as it does under `meta migrate`. +- **MySQL** gets the enum CHECKs only. The other checks are written in Postgres SQL. The schema + matches the column types in the MySQL recipe, but `meta verify --db` does not support MySQL, + so on MySQL the drift gate is `verify --codegen` alone. + +## Eject it + +```bash +meta eject prisma-schema +``` + +This copies the generator to `codegen/generators/prisma-schema.ts`, and the mapping and +renderers to `codegen/templates/prisma/`. To change a type mapping, edit your copy. A mapping +that no longer matches the expected schema makes `meta verify --db` report drift. diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index e8e56449e..df1815ec9 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -44,6 +44,7 @@ exact config, generator names and commands for this project; read it. The other | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | +| `references/typescript-prisma.md` | a TypeScript project that keeps Prisma as its ORM | | `references/typescript-retargeting.md` | a TypeScript project on a framework other than Fastify, Hono or React | | `references/typescript-templates.md` | declarative Mustache template-codegen in a TypeScript project | | `references/typescript-docs.md` | the docs generators (`docsFile`, `apiDocsFile`, `agentDocsFile`, `requirementsFile`, `metamodelDocsFile`, `docsSiteFile`) in a TypeScript project | diff --git a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md index 57f1c9651..91f568f59 100644 --- a/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md +++ b/fixtures/agent-context-conformance/java-kotlin-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md @@ -34,7 +34,7 @@ reference generators target React and TanStack. Anything else is this page. | Layer | Always available | When your target is not built in | |---|---|---| | **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — | -| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 | +| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java); a Prisma schema (TypeScript) | §3 | | **api** — the HTTP surface | Fastify, Hono | §4 | | **client** — hooks, forms, grids | React, TanStack | §5 | @@ -52,6 +52,12 @@ the column-type table and the behaviour differences are in `typescript-mysql.md` OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are dialect-neutral. +**Prisma as the ORM (TypeScript).** Set `orm: "prisma"` and wire `prismaSchemaFile()`. The +model then writes `schema.prisma` (Postgres, MySQL, SQLite) beside Zod schemas and types, and +Prisma runs the database. `meta verify --codegen` and `--db` gate it. Setup, the mapping and +what it refuses are in `typescript-prisma.md`. Any other ORM (TypeORM, Sequelize, MikroORM) is +a generator you write: the Prisma one is a model to copy (`meta eject prisma-schema`). + **A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB, Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite` (or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md index e8e56449e..df1815ec9 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -44,6 +44,7 @@ exact config, generator names and commands for this project; read it. The other | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | +| `references/typescript-prisma.md` | a TypeScript project that keeps Prisma as its ORM | | `references/typescript-retargeting.md` | a TypeScript project on a framework other than Fastify, Hono or React | | `references/typescript-templates.md` | declarative Mustache template-codegen in a TypeScript project | | `references/typescript-docs.md` | the docs generators (`docsFile`, `apiDocsFile`, `agentDocsFile`, `requirementsFile`, `metamodelDocsFile`, `docsSiteFile`) in a TypeScript project | diff --git a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/any-stack.md b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/any-stack.md index 57f1c9651..91f568f59 100644 --- a/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/any-stack.md +++ b/fixtures/agent-context-conformance/java-react/expected/.claude/skills/metaobjects-codegen/references/any-stack.md @@ -34,7 +34,7 @@ reference generators target React and TanStack. Anything else is this page. | Layer | Always available | When your target is not built in | |---|---|---| | **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — | -| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 | +| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java); a Prisma schema (TypeScript) | §3 | | **api** — the HTTP surface | Fastify, Hono | §4 | | **client** — hooks, forms, grids | React, TanStack | §5 | @@ -52,6 +52,12 @@ the column-type table and the behaviour differences are in `typescript-mysql.md` OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are dialect-neutral. +**Prisma as the ORM (TypeScript).** Set `orm: "prisma"` and wire `prismaSchemaFile()`. The +model then writes `schema.prisma` (Postgres, MySQL, SQLite) beside Zod schemas and types, and +Prisma runs the database. `meta verify --codegen` and `--db` gate it. Setup, the mapping and +what it refuses are in `typescript-prisma.md`. Any other ORM (TypeORM, Sequelize, MikroORM) is +a generator you write: the Prisma one is a model to copy (`meta eject prisma-schema`). + **A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB, Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite` (or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md index e8e56449e..df1815ec9 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -44,6 +44,7 @@ exact config, generator names and commands for this project; read it. The other | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | +| `references/typescript-prisma.md` | a TypeScript project that keeps Prisma as its ORM | | `references/typescript-retargeting.md` | a TypeScript project on a framework other than Fastify, Hono or React | | `references/typescript-templates.md` | declarative Mustache template-codegen in a TypeScript project | | `references/typescript-docs.md` | the docs generators (`docsFile`, `apiDocsFile`, `agentDocsFile`, `requirementsFile`, `metamodelDocsFile`, `docsSiteFile`) in a TypeScript project | diff --git a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/any-stack.md b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/any-stack.md index 57f1c9651..91f568f59 100644 --- a/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/any-stack.md +++ b/fixtures/agent-context-conformance/python/expected/.claude/skills/metaobjects-codegen/references/any-stack.md @@ -34,7 +34,7 @@ reference generators target React and TanStack. Anything else is this page. | Layer | Always available | When your target is not built in | |---|---|---| | **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — | -| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 | +| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java); a Prisma schema (TypeScript) | §3 | | **api** — the HTTP surface | Fastify, Hono | §4 | | **client** — hooks, forms, grids | React, TanStack | §5 | @@ -52,6 +52,12 @@ the column-type table and the behaviour differences are in `typescript-mysql.md` OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are dialect-neutral. +**Prisma as the ORM (TypeScript).** Set `orm: "prisma"` and wire `prismaSchemaFile()`. The +model then writes `schema.prisma` (Postgres, MySQL, SQLite) beside Zod schemas and types, and +Prisma runs the database. `meta verify --codegen` and `--db` gate it. Setup, the mapping and +what it refuses are in `typescript-prisma.md`. Any other ORM (TypeORM, Sequelize, MikroORM) is +a generator you write: the Prisma one is a model to copy (`meta eject prisma-schema`). + **A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB, Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite` (or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md index e8e56449e..df1815ec9 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -44,6 +44,7 @@ exact config, generator names and commands for this project; read it. The other | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | +| `references/typescript-prisma.md` | a TypeScript project that keeps Prisma as its ORM | | `references/typescript-retargeting.md` | a TypeScript project on a framework other than Fastify, Hono or React | | `references/typescript-templates.md` | declarative Mustache template-codegen in a TypeScript project | | `references/typescript-docs.md` | the docs generators (`docsFile`, `apiDocsFile`, `agentDocsFile`, `requirementsFile`, `metamodelDocsFile`, `docsSiteFile`) in a TypeScript project | diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md index 57f1c9651..91f568f59 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/any-stack.md @@ -34,7 +34,7 @@ reference generators target React and TanStack. Anything else is this page. | Layer | Always available | When your target is not built in | |---|---|---| | **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — | -| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 | +| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java); a Prisma schema (TypeScript) | §3 | | **api** — the HTTP surface | Fastify, Hono | §4 | | **client** — hooks, forms, grids | React, TanStack | §5 | @@ -52,6 +52,12 @@ the column-type table and the behaviour differences are in `typescript-mysql.md` OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are dialect-neutral. +**Prisma as the ORM (TypeScript).** Set `orm: "prisma"` and wire `prismaSchemaFile()`. The +model then writes `schema.prisma` (Postgres, MySQL, SQLite) beside Zod schemas and types, and +Prisma runs the database. `meta verify --codegen` and `--db` gate it. Setup, the mapping and +what it refuses are in `typescript-prisma.md`. Any other ORM (TypeORM, Sequelize, MikroORM) is +a generator you write: the Prisma one is a model to copy (`meta eject prisma-schema`). + **A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB, Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite` (or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-prisma.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-prisma.md new file mode 100644 index 000000000..4ec7d2f66 --- /dev/null +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript-prisma.md @@ -0,0 +1,160 @@ +# Prisma on TypeScript + +> Part of the `metaobjects-codegen` skill's TypeScript reference. Read it when the app keeps +> Prisma as its ORM. + +Under `orm: "prisma"`, `meta gen` writes `schema.prisma` from the model, along with Zod schemas +and types that have no Drizzle code. Prisma still creates the tables (`prisma db push` or +`prisma migrate`) and runs the queries. `meta verify --codegen` fails when someone edits +`schema.prisma` or it is out of date. `meta verify --db` fails when the database Prisma created +differs from the model (Postgres and SQLite). Do not hand-write a Prisma model for a table the +MetaObjects model declares. + +## Contents +- TypeScript setup +- The workflow +- How the model maps +- What it refuses +- SQLite and MySQL +- Eject it + +## TypeScript setup + +```ts +// metaobjects.config.ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { prismaSchemaFile } from "@metaobjectsdev/codegen-ts/generators"; + +export default defineConfig({ + orm: "prisma", + dialect: "postgres", // "postgres" | "mysql" | "sqlite": picks the Prisma provider + outDir: "src/generated", // Zod schemas and types + targets: { prisma: { outDir: "prisma" } }, + generators: ["entity", prismaSchemaFile({ target: "prisma" })], +}); +``` + +`orm: "prisma"` does two things: + +- **Every target defaults to `runtime: false`.** The `entity` generator then writes Zod schemas + and types with no `drizzle-orm` import. A target that sets `runtime: true` is refused. +- **Generators that write Drizzle code are refused** by name: `queries`, `routes`, + `routes-hono` and `callable`. Your routes call the Prisma Client instead. `meta gen` exits + with `orm: "prisma" refuses "queries", …` and makes no changes. + +The default `orm` is `"drizzle"`, and it changes nothing. + +Prisma 7 reads the connection URL from `prisma.config.ts`, not from the schema: + +```ts +// prisma.config.ts +import { defineConfig } from "prisma/config"; +export default defineConfig({ schema: "prisma/schema.prisma", datasource: { url: process.env.DATABASE_URL! } }); +``` + +The generated schema names the `prisma-client` generator, writing to `prisma/generated`. To +choose your own generator and datasource blocks, pass `header` as a string. Pass +`header: false` for a multi-file schema (`prisma/schema/`) whose blocks live in a file you own. +Other options: + +- `provider` overrides the provider that `dialect` picks. +- `fileName` renames the file (default `schema.prisma`). +- `filter` narrows which objects get a model. +- `checks: "omit"` drops the CHECK file. + +## The workflow + +```bash +meta gen # writes prisma/schema.prisma (+ schema.checks.sql) +npx prisma db push # or: npx prisma migrate dev +npx prisma db execute --file prisma/schema.checks.sql +npx prisma generate # the Prisma Client, as usual +meta verify --codegen --db "$DATABASE_URL" # in CI: fails on an edited schema or a drifted database +``` + +To change a field, edit the model and run `meta gen`. **Do not edit `schema.prisma` by hand.** +The generator overwrites hand edits instead of merging them. Prisma treats the whole file as +the schema, so a merged edit would change the database without the model knowing. +`meta verify --codegen` fails on the edit until you run `meta gen` again. `prisma format` +leaves the file unchanged, so formatting it does not count as an edit. + +A Prisma schema cannot declare a CHECK constraint, so the constraints the model implies go in +`schema.checks.sql`. These are enum membership checks plus the `@min`/`@max` and length +validators. Apply the file after every `db push` or migration. On Postgres the file drops each +constraint before adding it, so you can apply it more than once. On MySQL it only adds them, +so apply it once, when you create the table. When a check changes, drop the old one in a Prisma +migration. When the model has no checks left, `meta gen` deletes the file. + +With `prisma migrate`, keep the Prisma migration chain: `meta gen` changes `schema.prisma`, and +`prisma migrate dev` writes the SQL. To make the checks part of the chain, put the contents of +`schema.checks.sql` into a migration (`prisma migrate dev --create-only`, then add the SQL). + +## How the model maps + +The schema is built from the same expected schema that `meta migrate` writes and +`meta verify --db` compares against. A database that Prisma creates from it therefore passes +the gate. + +| Model | Prisma | +|---|---| +| object with a writable `source.rdb` | `model ` with `@@map("
")` when the names differ | +| field | a scalar field with `@map("")` when the names differ; `?` when the field is not `@required` | +| `field.string @maxLength` | `String @db.VarChar(n)` (Postgres, MySQL) | +| `field.int` / `field.long` / `field.currency` | `Int` / `BigInt` / `BigInt` | +| `field.double` / `field.float` | `Float` / `Float @db.Real` (Postgres) | +| `field.decimal` | `Decimal @db.Decimal(p, s)` | +| `field.boolean` | `Boolean` | +| `field.timestamp` / `field.date` / `field.time` | `DateTime` with `@db.Timestamptz`/`@db.Timestamp`, `@db.Date`, `@db.Time` (Postgres); `String` on SQLite, where the column holds ISO text | +| `field.uuid` | `String @db.Uuid` (Postgres), `@db.VarChar(36)` (MySQL) | +| `field.enum` | `String` plus a CHECK in `schema.checks.sql` (not a Prisma `enum`, so the column matches `meta migrate`'s) | +| `field.object`, `field.map`, `@storage: jsonb` | `Json` | +| `field.object @storage: flattened` | one field per field of the value object, `` with `@map("_")` | +| `isArray` scalar | a list (`String[]`) on Postgres; `Json` on MySQL; `String` holding JSON text on SQLite | +| `identity.primary` | `@id` or `@@id([...])`; `@generation: increment` is `@default(autoincrement())`; `@generation: uuid` is `gen_random_uuid()` on Postgres, `uuid()` elsewhere | +| `@default` | `@default(...)` for a literal; `"now"` and other expressions are `dbgenerated(...)`, so the default text matches what `verify --db` expects (a MySQL `DateTime` takes `now()`) | +| `identity.secondary` | `@unique` or `@@unique` (the index name in `map:`) | +| `index.lookup` | `@@index` (`@orders` becomes `sort: Desc`) | +| `identity.reference` | a relation field on both models, with `fields`, `references`, `onDelete`, `onUpdate` and the constraint name in `map:` | +| `@schema` on `source.rdb` | `@@schema("")` and the datasource's `schemas` list (Postgres) | + +## What it refuses + +The generator stops with an error code rather than write a schema that would push a database +the model does not describe. Each message names the object or index, says why, and says what +to change. + +| Code | When | What to do | +|---|---|---| +| `ERR_PRISMA_READ_ONLY_SOURCE` | an object declares a view that `meta migrate` creates and `meta verify --db` expects (a projection, a write-through entity's read view, or a served report) | create the view in a Prisma migration and mark its read `source.rdb` `@unmanaged: true`, or leave the object out with `filter` | +| `ERR_PRISMA_UNSUPPORTED_INDEX` | an index has `@expr` or `@where`, or a `@using` method other than `btree` or, on Postgres, `hash`, `gin`, `gist`, `brin` or `spgist` | create it in a Prisma migration, or drop the attribute | +| `ERR_PRISMA_UNSUPPORTED_SCHEMA` | a `source.rdb` declares `@schema` and the provider is not `postgresql`, where Prisma has no `@@schema` | remove the `@schema`, or use the `postgresql` provider | +| `ERR_PRISMA_UNSUPPORTED_CHECK` | the provider is SQLite and the model implies a CHECK | pass `checks: "omit"` | +| `ERR_PRISMA_NO_PRIMARY_KEY` | a table has no primary key | declare an `identity.primary` | +| `ERR_PRISMA_UNMAPPABLE_RELATION` | a foreign key points at a table with no model, at a non-unique key, or at a column of another type | include the table, reference a key, or match the subtype | +| `ERR_PRISMA_NAME_COLLISION` | two objects, or two fields, would get the same Prisma name | rename one, or narrow `filter` | +| `ERR_PRISMA_UNSUPPORTED_PROVIDER` | the dialect or `provider` is not `postgresql`, `mysql` or `sqlite` | pass `provider` | + +A model with projections, write-through read views or served reports therefore needs one of +those two fixes before the generator runs. A stored procedure, table function or materialized +view is not refused: `meta migrate` does not create it, so neither does Prisma. Read the view's rows with Prisma's `$queryRaw`, and validate them with the +generated Zod schema. + +## SQLite and MySQL + +- **SQLite** cannot add a CHECK to an existing table. Pass `checks: "omit"`. `meta verify --db` + then reports each missing CHECK as an `add-check` row and nothing else. Only the Zod schemas + enforce those rules. Timestamps, dates and times are `String` in the Prisma Client, because the + column holds ISO text, as it does under `meta migrate`. +- **MySQL** gets the enum CHECKs only. The other checks are written in Postgres SQL. The schema + matches the column types in the MySQL recipe, but `meta verify --db` does not support MySQL, + so on MySQL the drift gate is `verify --codegen` alone. + +## Eject it + +```bash +meta eject prisma-schema +``` + +This copies the generator to `codegen/generators/prisma-schema.ts`, and the mapping and +renderers to `codegen/templates/prisma/`. To change a type mapping, edit your copy. A mapping +that no longer matches the expected schema makes `meta verify --db` report drift. diff --git a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md index 6db44b8d4..0765fafc5 100644 --- a/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-react-tanstack/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -15,8 +15,9 @@ packages. Codegen runs through the Node `meta` CLI (`@metaobjectsdev/cli`, binar - Field subtype → column mapping Topic files beside this one, to open when they come up: -`typescript-mysql.md` (a MySQL database), `typescript-document-store.md` (MongoDB or any -store MetaObjects does not manage, with a tested repository generator), +`typescript-mysql.md` (a MySQL database), `typescript-prisma.md` (Prisma as the ORM), +`typescript-document-store.md` (MongoDB or any store MetaObjects does not manage, with a +tested repository generator), `typescript-retargeting.md` (a framework other than Fastify, Hono or React), `typescript-templates.md` (declarative Mustache template-codegen) and `typescript-docs.md` (the docs generators). The procedure for any unsupported stack is `any-stack.md`. @@ -85,6 +86,7 @@ import { tanstackQuery, tanstackGrid } from "@metaobjectsdev/codegen-ts-tanstack export default defineConfig({ outDir: "src/generated", dialect: "postgres", // "postgres" | "sqlite" | "d1" | "mysql" (mysql: codegen + runtime; migrate does not own it) + // orm: "prisma", // keep Prisma: contract-only targets + prismaSchemaFile(); see `typescript-prisma.md` extStyle: "js", // "js" (default) for Node ESM / plain tsc; "none" for a bundler-resolution toolchain — see `owning-generators.md` "Your framework isn't the default" apiPrefix: "/api", // flows to routes AND client fetch URLs timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date) diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md index e8e56449e..df1815ec9 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/SKILL.md @@ -44,6 +44,7 @@ exact config, generator names and commands for this project; read it. The other | `references/dependencies-and-shared-models.md` | the project consumes or publishes metadata across projects | | `references/typescript-document-store.md` | a TypeScript project on MongoDB, or any store MetaObjects does not manage, wanting a tested repository generator | | `references/typescript-mysql.md` | a TypeScript project on a MySQL database | +| `references/typescript-prisma.md` | a TypeScript project that keeps Prisma as its ORM | | `references/typescript-retargeting.md` | a TypeScript project on a framework other than Fastify, Hono or React | | `references/typescript-templates.md` | declarative Mustache template-codegen in a TypeScript project | | `references/typescript-docs.md` | the docs generators (`docsFile`, `apiDocsFile`, `agentDocsFile`, `requirementsFile`, `metamodelDocsFile`, `docsSiteFile`) in a TypeScript project | diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/any-stack.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/any-stack.md index 57f1c9651..91f568f59 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/any-stack.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/any-stack.md @@ -34,7 +34,7 @@ reference generators target React and TanStack. Anything else is this page. | Layer | Always available | When your target is not built in | |---|---|---| | **model** — types, create/PATCH validation, filter and sort allowlists | yes, in every port, for every store | — | -| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java) | §3 | +| **persistence** — how rows are read and written | Postgres, SQLite, D1; MySQL (TypeScript, Java); a Prisma schema (TypeScript) | §3 | | **api** — the HTTP surface | Fastify, Hono | §4 | | **client** — hooks, forms, grids | React, TanStack | §5 | @@ -52,6 +52,12 @@ the column-type table and the behaviour differences are in `typescript-mysql.md` OMDB's `MySQLDriver` reads and writes MySQL; the generated DTOs and controllers are dialect-neutral. +**Prisma as the ORM (TypeScript).** Set `orm: "prisma"` and wire `prismaSchemaFile()`. The +model then writes `schema.prisma` (Postgres, MySQL, SQLite) beside Zod schemas and types, and +Prisma runs the database. `meta verify --codegen` and `--db` gate it. Setup, the mapping and +what it refuses are in `typescript-prisma.md`. Any other ORM (TypeORM, Sequelize, MikroORM) is +a generator you write: the Prisma one is a model to copy (`meta eject prisma-schema`). + **A SQL database that speaks another dialect's protocol.** Use that dialect: CockroachDB, Neon, Supabase, AlloyDB and Aurora PostgreSQL are `postgres`; Turso and libSQL are `sqlite` (or `d1` on Cloudflare); MariaDB and PlanetScale are `mysql`. Test the generated queries diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-prisma.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-prisma.md new file mode 100644 index 000000000..4ec7d2f66 --- /dev/null +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript-prisma.md @@ -0,0 +1,160 @@ +# Prisma on TypeScript + +> Part of the `metaobjects-codegen` skill's TypeScript reference. Read it when the app keeps +> Prisma as its ORM. + +Under `orm: "prisma"`, `meta gen` writes `schema.prisma` from the model, along with Zod schemas +and types that have no Drizzle code. Prisma still creates the tables (`prisma db push` or +`prisma migrate`) and runs the queries. `meta verify --codegen` fails when someone edits +`schema.prisma` or it is out of date. `meta verify --db` fails when the database Prisma created +differs from the model (Postgres and SQLite). Do not hand-write a Prisma model for a table the +MetaObjects model declares. + +## Contents +- TypeScript setup +- The workflow +- How the model maps +- What it refuses +- SQLite and MySQL +- Eject it + +## TypeScript setup + +```ts +// metaobjects.config.ts +import { defineConfig } from "@metaobjectsdev/cli"; +import { prismaSchemaFile } from "@metaobjectsdev/codegen-ts/generators"; + +export default defineConfig({ + orm: "prisma", + dialect: "postgres", // "postgres" | "mysql" | "sqlite": picks the Prisma provider + outDir: "src/generated", // Zod schemas and types + targets: { prisma: { outDir: "prisma" } }, + generators: ["entity", prismaSchemaFile({ target: "prisma" })], +}); +``` + +`orm: "prisma"` does two things: + +- **Every target defaults to `runtime: false`.** The `entity` generator then writes Zod schemas + and types with no `drizzle-orm` import. A target that sets `runtime: true` is refused. +- **Generators that write Drizzle code are refused** by name: `queries`, `routes`, + `routes-hono` and `callable`. Your routes call the Prisma Client instead. `meta gen` exits + with `orm: "prisma" refuses "queries", …` and makes no changes. + +The default `orm` is `"drizzle"`, and it changes nothing. + +Prisma 7 reads the connection URL from `prisma.config.ts`, not from the schema: + +```ts +// prisma.config.ts +import { defineConfig } from "prisma/config"; +export default defineConfig({ schema: "prisma/schema.prisma", datasource: { url: process.env.DATABASE_URL! } }); +``` + +The generated schema names the `prisma-client` generator, writing to `prisma/generated`. To +choose your own generator and datasource blocks, pass `header` as a string. Pass +`header: false` for a multi-file schema (`prisma/schema/`) whose blocks live in a file you own. +Other options: + +- `provider` overrides the provider that `dialect` picks. +- `fileName` renames the file (default `schema.prisma`). +- `filter` narrows which objects get a model. +- `checks: "omit"` drops the CHECK file. + +## The workflow + +```bash +meta gen # writes prisma/schema.prisma (+ schema.checks.sql) +npx prisma db push # or: npx prisma migrate dev +npx prisma db execute --file prisma/schema.checks.sql +npx prisma generate # the Prisma Client, as usual +meta verify --codegen --db "$DATABASE_URL" # in CI: fails on an edited schema or a drifted database +``` + +To change a field, edit the model and run `meta gen`. **Do not edit `schema.prisma` by hand.** +The generator overwrites hand edits instead of merging them. Prisma treats the whole file as +the schema, so a merged edit would change the database without the model knowing. +`meta verify --codegen` fails on the edit until you run `meta gen` again. `prisma format` +leaves the file unchanged, so formatting it does not count as an edit. + +A Prisma schema cannot declare a CHECK constraint, so the constraints the model implies go in +`schema.checks.sql`. These are enum membership checks plus the `@min`/`@max` and length +validators. Apply the file after every `db push` or migration. On Postgres the file drops each +constraint before adding it, so you can apply it more than once. On MySQL it only adds them, +so apply it once, when you create the table. When a check changes, drop the old one in a Prisma +migration. When the model has no checks left, `meta gen` deletes the file. + +With `prisma migrate`, keep the Prisma migration chain: `meta gen` changes `schema.prisma`, and +`prisma migrate dev` writes the SQL. To make the checks part of the chain, put the contents of +`schema.checks.sql` into a migration (`prisma migrate dev --create-only`, then add the SQL). + +## How the model maps + +The schema is built from the same expected schema that `meta migrate` writes and +`meta verify --db` compares against. A database that Prisma creates from it therefore passes +the gate. + +| Model | Prisma | +|---|---| +| object with a writable `source.rdb` | `model ` with `@@map("
")` when the names differ | +| field | a scalar field with `@map("")` when the names differ; `?` when the field is not `@required` | +| `field.string @maxLength` | `String @db.VarChar(n)` (Postgres, MySQL) | +| `field.int` / `field.long` / `field.currency` | `Int` / `BigInt` / `BigInt` | +| `field.double` / `field.float` | `Float` / `Float @db.Real` (Postgres) | +| `field.decimal` | `Decimal @db.Decimal(p, s)` | +| `field.boolean` | `Boolean` | +| `field.timestamp` / `field.date` / `field.time` | `DateTime` with `@db.Timestamptz`/`@db.Timestamp`, `@db.Date`, `@db.Time` (Postgres); `String` on SQLite, where the column holds ISO text | +| `field.uuid` | `String @db.Uuid` (Postgres), `@db.VarChar(36)` (MySQL) | +| `field.enum` | `String` plus a CHECK in `schema.checks.sql` (not a Prisma `enum`, so the column matches `meta migrate`'s) | +| `field.object`, `field.map`, `@storage: jsonb` | `Json` | +| `field.object @storage: flattened` | one field per field of the value object, `` with `@map("_")` | +| `isArray` scalar | a list (`String[]`) on Postgres; `Json` on MySQL; `String` holding JSON text on SQLite | +| `identity.primary` | `@id` or `@@id([...])`; `@generation: increment` is `@default(autoincrement())`; `@generation: uuid` is `gen_random_uuid()` on Postgres, `uuid()` elsewhere | +| `@default` | `@default(...)` for a literal; `"now"` and other expressions are `dbgenerated(...)`, so the default text matches what `verify --db` expects (a MySQL `DateTime` takes `now()`) | +| `identity.secondary` | `@unique` or `@@unique` (the index name in `map:`) | +| `index.lookup` | `@@index` (`@orders` becomes `sort: Desc`) | +| `identity.reference` | a relation field on both models, with `fields`, `references`, `onDelete`, `onUpdate` and the constraint name in `map:` | +| `@schema` on `source.rdb` | `@@schema("")` and the datasource's `schemas` list (Postgres) | + +## What it refuses + +The generator stops with an error code rather than write a schema that would push a database +the model does not describe. Each message names the object or index, says why, and says what +to change. + +| Code | When | What to do | +|---|---|---| +| `ERR_PRISMA_READ_ONLY_SOURCE` | an object declares a view that `meta migrate` creates and `meta verify --db` expects (a projection, a write-through entity's read view, or a served report) | create the view in a Prisma migration and mark its read `source.rdb` `@unmanaged: true`, or leave the object out with `filter` | +| `ERR_PRISMA_UNSUPPORTED_INDEX` | an index has `@expr` or `@where`, or a `@using` method other than `btree` or, on Postgres, `hash`, `gin`, `gist`, `brin` or `spgist` | create it in a Prisma migration, or drop the attribute | +| `ERR_PRISMA_UNSUPPORTED_SCHEMA` | a `source.rdb` declares `@schema` and the provider is not `postgresql`, where Prisma has no `@@schema` | remove the `@schema`, or use the `postgresql` provider | +| `ERR_PRISMA_UNSUPPORTED_CHECK` | the provider is SQLite and the model implies a CHECK | pass `checks: "omit"` | +| `ERR_PRISMA_NO_PRIMARY_KEY` | a table has no primary key | declare an `identity.primary` | +| `ERR_PRISMA_UNMAPPABLE_RELATION` | a foreign key points at a table with no model, at a non-unique key, or at a column of another type | include the table, reference a key, or match the subtype | +| `ERR_PRISMA_NAME_COLLISION` | two objects, or two fields, would get the same Prisma name | rename one, or narrow `filter` | +| `ERR_PRISMA_UNSUPPORTED_PROVIDER` | the dialect or `provider` is not `postgresql`, `mysql` or `sqlite` | pass `provider` | + +A model with projections, write-through read views or served reports therefore needs one of +those two fixes before the generator runs. A stored procedure, table function or materialized +view is not refused: `meta migrate` does not create it, so neither does Prisma. Read the view's rows with Prisma's `$queryRaw`, and validate them with the +generated Zod schema. + +## SQLite and MySQL + +- **SQLite** cannot add a CHECK to an existing table. Pass `checks: "omit"`. `meta verify --db` + then reports each missing CHECK as an `add-check` row and nothing else. Only the Zod schemas + enforce those rules. Timestamps, dates and times are `String` in the Prisma Client, because the + column holds ISO text, as it does under `meta migrate`. +- **MySQL** gets the enum CHECKs only. The other checks are written in Postgres SQL. The schema + matches the column types in the MySQL recipe, but `meta verify --db` does not support MySQL, + so on MySQL the drift gate is `verify --codegen` alone. + +## Eject it + +```bash +meta eject prisma-schema +``` + +This copies the generator to `codegen/generators/prisma-schema.ts`, and the mapping and +renderers to `codegen/templates/prisma/`. To change a type mapping, edit your copy. A mapping +that no longer matches the expected schema makes `meta verify --db` report drift. diff --git a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md index 6db44b8d4..0765fafc5 100644 --- a/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md +++ b/fixtures/agent-context-conformance/ts-requirements/expected/.claude/skills/metaobjects-codegen/references/typescript.md @@ -15,8 +15,9 @@ packages. Codegen runs through the Node `meta` CLI (`@metaobjectsdev/cli`, binar - Field subtype → column mapping Topic files beside this one, to open when they come up: -`typescript-mysql.md` (a MySQL database), `typescript-document-store.md` (MongoDB or any -store MetaObjects does not manage, with a tested repository generator), +`typescript-mysql.md` (a MySQL database), `typescript-prisma.md` (Prisma as the ORM), +`typescript-document-store.md` (MongoDB or any store MetaObjects does not manage, with a +tested repository generator), `typescript-retargeting.md` (a framework other than Fastify, Hono or React), `typescript-templates.md` (declarative Mustache template-codegen) and `typescript-docs.md` (the docs generators). The procedure for any unsupported stack is `any-stack.md`. @@ -85,6 +86,7 @@ import { tanstackQuery, tanstackGrid } from "@metaobjectsdev/codegen-ts-tanstack export default defineConfig({ outDir: "src/generated", dialect: "postgres", // "postgres" | "sqlite" | "d1" | "mysql" (mysql: codegen + runtime; migrate does not own it) + // orm: "prisma", // keep Prisma: contract-only targets + prismaSchemaFile(); see `typescript-prisma.md` extStyle: "js", // "js" (default) for Node ESM / plain tsc; "none" for a bundler-resolution toolchain — see `owning-generators.md` "Your framework isn't the default" apiPrefix: "/api", // flows to routes AND client fetch URLs timestampMode: "string", // "string" (default, ISO-8601 wire contract) | "date" (Drizzle native Date) diff --git a/fixtures/generator-registry-conformance/README.md b/fixtures/generator-registry-conformance/README.md index 87374139f..28265ef40 100644 --- a/fixtures/generator-registry-conformance/README.md +++ b/fixtures/generator-registry-conformance/README.md @@ -33,7 +33,7 @@ to make the choice cheap, and `layer` is the axis it is cheap along. | layer | members | chosen by | |---|---|---| | `model` | entity, names, barrel, dto, value-object | app shape | -| `persistence` | queries, db-context, repository, exposed-table, relations, stored-proc | app shape | +| `persistence` | queries, prisma-schema, db-context, repository, exposed-table, relations, stored-proc | app shape | | `api` | routes, routes-hono, filter-allowlist, validator, spring-config | app shape | | `client` | form, hooks, grid, grid-hook | app shape | | `docs` | docs, api-docs, requirements, agent-docs, metamodel-docs, docs-site, plantuml, html-docs, ai-docs | declared in the project's generator list like any other; nothing is generated that the list does not declare (ADR-0034 Amendment 9) | diff --git a/fixtures/generator-registry-conformance/registry.json b/fixtures/generator-registry-conformance/registry.json index 84e6fd5b2..8a17d9368 100644 --- a/fixtures/generator-registry-conformance/registry.json +++ b/fixtures/generator-registry-conformance/registry.json @@ -68,6 +68,14 @@ "eject": "full", "ports": ["typescript"] }, + "prisma-schema": { + "concept": "A Prisma schema (schema.prisma) for the model's tables, with the CHECK constraints Prisma cannot declare in a companion SQL file.", + "tier": "native", + "layer": "persistence", + "note": "TYPESCRIPT ONLY by design (#479): Prisma is a TypeScript ORM, and the generator is a reference helper (ADR-0034 Amendment 3) writing a schema that imports nothing.", + "eject": "full", + "ports": ["typescript"] + }, "callable": { "concept": "Per-entity callable wrapper for a callable source (storedProc / tableFunction); in TS it also covers the service surface wrapping the query helpers.", "tier": "native", @@ -350,6 +358,7 @@ "codegen-ts/src/naming.ts", "codegen-ts/src/owned-runtime.ts", "codegen-ts/src/pk-resolver.ts", + "codegen-ts/src/projection/build-projection-views.ts", "codegen-ts/src/projection/extract-view-spec.ts", "codegen-ts/src/projection/projection-detector.ts", "codegen-ts/src/projection/report-resolve.ts", diff --git a/scripts/ci-local.sh b/scripts/ci-local.sh index c517b9614..097b89fed 100755 --- a/scripts/ci-local.sh +++ b/scripts/ci-local.sh @@ -590,6 +590,15 @@ gate_runtime_ts_pg() { test/dialect-matrix-pg.test.ts test/read-only-raw-sql-pg.test.ts test/timestamp-wire-pg.test.ts ) } +# Prisma as the ORM, against a real Postgres (#479): `prisma db push` from the generated +# schema.prisma, the CHECK file applied, then `meta verify --db` passes and fails on a +# deliberate drift. The cli suite runs in ts-unit, which has no database, so this file's +# Postgres half runs only here; MIGRATE_TS_PG_EXPECT=1 makes it fail rather than skip when +# the sidecar URL is missing. Each run creates and drops a database of its own. +gate_prisma_pg() { + bun_install && ( cd server/typescript/packages/cli && bun test test/integration/prisma-schema.test.ts ) +} + # ── conformance.yml — per-port conformance corpora (exact CI commands) ──────── gate_conf_ts() { bun_install || return 1 @@ -1087,6 +1096,7 @@ lane_ts_integ() { use_pg_sidecar step_if bun "migrate-ts real-PG suite" gate_migrate_ts_pg step_if bun "runtime-ts real-PG dialect matrix" gate_runtime_ts_pg + step_if bun "prisma real-PG round trip" gate_prisma_pg step "integration-tests (ts)" gate_integration_port ts } lane_cube() { [ "$NO_INTEG" -eq 0 ] && run_cube_lane; return 0; } @@ -1235,6 +1245,7 @@ if [ "$PARALLEL" -eq 0 ]; then if in_lane_any ts ts-slow && docker info >/dev/null 2>&1; then ensure_pg_sidecar; fi in_lane_any ts ts-slow && step_if bun "migrate-ts real-PG suite" gate_migrate_ts_pg in_lane_any ts ts-slow && step_if bun "runtime-ts real-PG dialect matrix" gate_runtime_ts_pg + in_lane_any ts ts-slow && step_if bun "prisma real-PG round trip" gate_prisma_pg in_lane_any ts ts-slow && run_integration_for ts ts in_lane_any java java-slow && run_integration_for java java kotlin in_lane python && run_integration_for python python diff --git a/scripts/prerelease.mjs b/scripts/prerelease.mjs index 121b3fb60..e9a1b284d 100644 --- a/scripts/prerelease.mjs +++ b/scripts/prerelease.mjs @@ -61,6 +61,7 @@ import { execSync } from "node:child_process"; import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs"; import { join } from "node:path"; import { mavenVersion } from "./maven-coordinate.mjs"; +import { TIER_ORDER } from "./publish-set.mjs"; import { changelogVersions, collectPages, decideBase, installRestoreHandlers, releasedVersion, run as runStep, } from "./prerelease-version.mjs"; @@ -334,9 +335,10 @@ if (wants("npm")) { writeFileSync(join(home, ".npmrc"), `@metaobjectsdev:registry=${NPM_REG}\n${NPM_REG.replace(/^https?:/, "")}:_authToken=${TOKEN}\n`); - const TIERS = ["metadata", "render", "codegen-ts", "runtime-ts", "migrate-ts", "sdk", "docs-site", - "runtime-web", "codegen-ts-react", "codegen-ts-tanstack", "react", "tanstack", "cli", "ai-runtime"]; - const ordered = [...set].sort((a, b) => TIERS.indexOf(a.short) - TIERS.indexOf(b.short)); + // The one publish order (scripts/publish-set.mjs): a dependency never lands after its dependent. + const untiered = set.filter((p) => !TIER_ORDER.includes(p.short)); + if (untiered.length) die(`no publish tier for ${untiered.map((p) => p.short).join(", ")}: add it to TIER_ORDER in scripts/publish-set.mjs`); + const ordered = [...set].sort((a, b) => TIER_ORDER.indexOf(a.short) - TIER_ORDER.indexOf(b.short)); // GATE 3 — ask bun where it would actually publish, and believe only that. const probe = await run("bun publish --dry-run", diff --git a/scripts/publish-set.mjs b/scripts/publish-set.mjs index 1eadaaf50..15d420404 100755 --- a/scripts/publish-set.mjs +++ b/scripts/publish-set.mjs @@ -41,7 +41,7 @@ const WORKSPACE_ROOTS = ["server/typescript/packages", "client/web/packages"]; // one explicit decision here, not an omission that still "works". export const TIER_ORDER = [ "metadata", "render", "docs-site", - "codegen-ts", "runtime-ts", "migrate-ts", "sdk", + "migrate-ts", "codegen-ts", "runtime-ts", "sdk", "runtime-web", "codegen-ts-react", "codegen-ts-tanstack", "react", "tanstack", diff --git a/server/typescript/packages/cli/package.json b/server/typescript/packages/cli/package.json index fc47b17ae..4ddad6037 100644 --- a/server/typescript/packages/cli/package.json +++ b/server/typescript/packages/cli/package.json @@ -83,6 +83,7 @@ "kysely": "^0.27.0", "pg": "^8.0.0", "pg-mem": "^3.0.4", + "prisma": "7.10.1", "typescript": "^5.6.0", "zod": "^4.4.3" } diff --git a/server/typescript/packages/cli/test/eject-multi.test.ts b/server/typescript/packages/cli/test/eject-multi.test.ts index de84142d4..32a8cd7d9 100644 --- a/server/typescript/packages/cli/test/eject-multi.test.ts +++ b/server/typescript/packages/cli/test/eject-multi.test.ts @@ -228,6 +228,32 @@ describe("meta eject takes many names", () => { } }); + test("prisma-schema ejects with its mapping and renderers, and the schema needs nothing installed", async () => { + // #479: the owned part is which objects get a model, the file layout and the blocks above + // the models, with buildPrismaSchema and the renderers copied beside it (Amendment 8). + const dir = tmp(); + try { + expect(await ejectCommand(["prisma-schema"], dir, "json")).toBe(0); + const source = readFileSync(join(dir, "codegen/generators/prisma-schema.ts"), "utf8"); + expect(source).toContain("export function prismaSchemaFile("); + expect(source).toContain('from "../templates/prisma/build-prisma-schema.js"'); + for (const f of ["build-prisma-schema.ts", "prisma-errors.ts", "render-prisma-schema.ts"]) { + expect(existsSync(join(dir, "codegen/templates/prisma", f))).toBe(true); + } + const [row] = payload().ejected; + expect(row?.wire.entry).toBe("prismaSchemaFile()"); + expect(row?.wire.import).toBe('import { prismaSchemaFile } from "./codegen/generators/prisma-schema.js";'); + // A Prisma schema imports no package. The copy reads the expected schema `meta verify --db` + // compares against, so migrate-ts is a dev install beside the engine. + const { install } = payload(); + expect(install.runtime).toEqual([]); + expect(install.dev.some((d) => d.startsWith("@metaobjectsdev/codegen-ts@"))).toBe(true); + expect(install.dev.some((d) => d.startsWith("@metaobjectsdev/migrate-ts@"))).toBe(true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + test("a repeated name is a usage error, not a silent second write", async () => { const dir = tmp(); try { diff --git a/server/typescript/packages/cli/test/eject-templates-contract.test.ts b/server/typescript/packages/cli/test/eject-templates-contract.test.ts index 047861cad..2c43e3b4f 100644 --- a/server/typescript/packages/cli/test/eject-templates-contract.test.ts +++ b/server/typescript/packages/cli/test/eject-templates-contract.test.ts @@ -218,8 +218,10 @@ describe("eject contract (typescript) — an owned generator owns what writes it }, 120_000); test("what a copy imports from outside the project is the packages and nothing else", () => { + // migrate-ts is core (schema migrations): prisma-schema builds its models from the same + // expected schema `meta verify --db` compares against, through its kysely-free subpath. const ALLOWED = new Set(["@metaobjectsdev/codegen-ts", "@metaobjectsdev/codegen-ts-react", "@metaobjectsdev/codegen-ts-tanstack", - "@metaobjectsdev/metadata", "@metaobjectsdev/render", "@toon-format/toon"]); + "@metaobjectsdev/metadata", "@metaobjectsdev/migrate-ts", "@metaobjectsdev/render", "@toon-format/toon"]); const found = new Set(); for (const { files } of owned.values()) { for (const file of files) { diff --git a/server/typescript/packages/cli/test/integration/prisma-schema.test.ts b/server/typescript/packages/cli/test/integration/prisma-schema.test.ts new file mode 100644 index 000000000..84e1230f5 --- /dev/null +++ b/server/typescript/packages/cli/test/integration/prisma-schema.test.ts @@ -0,0 +1,240 @@ +/** + * #479 — Prisma as the ORM, end to end: one place to declare a field, and a build that fails when + * Prisma and the model drift. + * + * - `meta gen` under `orm: "prisma"` writes schema.prisma (prisma-schema) and contract-only + * entity modules (no Drizzle import), and `prisma validate` accepts the schema. + * - `meta verify --codegen` fails when schema.prisma is hand-edited. + * - `prisma db push` from the generated schema gives a database `meta verify --db` passes, and a + * deliberate change to that database fails it: on SQLite always, and on Postgres when + * MIGRATE_TS_PG_URL names a server (the ts-slow lane's sidecar). Each Postgres run creates a + * database of its own and drops it. + */ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync, appendFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { createClient } from "@libsql/client"; +import pg from "pg"; +import { run } from "../../src/index.js"; + +const CLI_ROOT = resolve(import.meta.dirname, "../.."); +const PRISMA_BIN = join(CLI_ROOT, "node_modules", ".bin", "prisma"); +const SHOP = resolve(CLI_ROOT, "../codegen-ts/test/prisma/fixtures/shop/metaobjects"); +// Under the cli package so the project's config resolves `@metaobjectsdev/codegen-ts` and +// `prisma/config` through the workspace (gitignored). +const WORKSPACE_TMP = resolve(import.meta.dirname, "../fixtures/__tmp__"); +const PG_URL = process.env.MIGRATE_TS_PG_URL; + +type Dialect = "postgres" | "sqlite"; + +function project(dialect: Dialect, prismaOpts = ""): string { + mkdirSync(WORKSPACE_TMP, { recursive: true }); + const root = mkdtempSync(join(WORKSPACE_TMP, "prisma-schema-")); + cpSync(SHOP, join(root, "metaobjects"), { recursive: true }); + writeFileSync( + join(root, "metaobjects.config.ts"), + `import { defineConfig, prismaSchemaFile } from "@metaobjectsdev/codegen-ts"; +export default defineConfig({ + orm: "prisma", + dialect: ${JSON.stringify(dialect)}, + outDir: ${JSON.stringify(join(root, "src/generated"))}, + extStyle: "none", + targets: { prisma: { outDir: ${JSON.stringify(join(root, "prisma"))} } }, + generators: ["entity", prismaSchemaFile({ target: "prisma"${prismaOpts} })], +}); +`, + ); + return root; +} + +function prismaConfig(root: string, url: string): void { + writeFileSync( + join(root, "prisma.config.ts"), + `import { defineConfig } from "prisma/config"; +export default defineConfig({ schema: "prisma/schema.prisma", datasource: { url: ${JSON.stringify(url)} } }); +`, + ); +} + +/** Runs the Prisma CLI in `root`; returns its exit code and output. */ +function prisma(root: string, ...args: string[]): { code: number; output: string } { + const p = Bun.spawnSync([PRISMA_BIN, ...args, "--config", join(root, "prisma.config.ts")], { + cwd: root, + env: { ...process.env, PRISMA_HIDE_UPDATE_MESSAGE: "1", CHECKPOINT_DISABLE: "1" }, + }); + return { code: p.exitCode, output: `${p.stdout.toString()}${p.stderr.toString()}` }; +} + +let out: string[]; +let err: string[]; +let origLog: typeof console.log; +let origErr: typeof console.error; +beforeEach(() => { + out = []; + err = []; + origLog = console.log; + origErr = console.error; + console.log = (...a: unknown[]) => { out.push(a.map(String).join(" ")); }; + console.error = (...a: unknown[]) => { err.push(a.map(String).join(" ")); }; +}); +afterEach(() => { + console.log = origLog; + console.error = origErr; +}); +const all = (): string => [...out, ...err].join("\n"); + +describe("orm: prisma — gen, prisma validate, verify --codegen", () => { + test("gen writes schema.prisma and contract-only entity modules, and Prisma accepts the schema", async () => { + const root = project("postgres"); + try { + expect(await run(["gen", "--cwd", root])).toBe(0); + const schema = readFileSync(join(root, "prisma/schema.prisma"), "utf8"); + expect(schema).toContain('provider = "postgresql"'); + expect(schema).toContain("model Customer {"); + expect(existsSync(join(root, "prisma/schema.checks.sql"))).toBe(true); + // The entity modules are Zod schemas and types: no Drizzle table, no DB layer. + const entities = readdirSync(join(root, "src/generated")).filter((f) => f.endsWith(".ts")); + expect(entities.length).toBeGreaterThan(0); + for (const f of entities) expect(readFileSync(join(root, "src/generated", f), "utf8")).not.toContain("drizzle-orm"); + + prismaConfig(root, "postgresql://u:p@localhost:5432/x"); + const v = prisma(root, "validate"); + expect(v.output).toContain("is valid"); + expect(v.code).toBe(0); + // `prisma format` leaves the file as written, so formatting it is not an edit. + expect(prisma(root, "format").code).toBe(0); + expect(readFileSync(join(root, "prisma/schema.prisma"), "utf8")).toBe(schema); + + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + + test("a hand edit to schema.prisma is drift, and gen puts the file back", async () => { + const root = project("postgres"); + try { + expect(await run(["gen", "--cwd", root])).toBe(0); + const schemaPath = join(root, "prisma/schema.prisma"); + const written = readFileSync(schemaPath, "utf8"); + appendFileSync(schemaPath, "\nmodel Extra {\n id Int @id\n}\n"); + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(1); + expect(all()).toContain("schema.prisma"); + expect(await run(["gen", "--cwd", root])).toBe(0); + expect(readFileSync(schemaPath, "utf8")).toBe(written); + expect(await run(["verify", "--cwd", root, "--codegen"])).toBe(0); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 60_000); + + test("a Drizzle-bound generator is refused under orm: prisma", async () => { + const root = project("postgres"); + try { + const cfg = join(root, "metaobjects.config.ts"); + writeFileSync(cfg, readFileSync(cfg, "utf8").replace('generators: ["entity",', 'generators: ["entity", "queries",')); + expect(await run(["gen", "--cwd", root])).not.toBe(0); + expect(all()).toContain('orm: "prisma" refuses "queries"'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +/** The schema gate's changes from a `verify --format json` run, as `kind name` strings. */ +async function schemaChanges(root: string, url: string): Promise<{ exit: number; changes: string[] }> { + out = []; err = []; + const exit = await run(["verify", "--cwd", root, "--db", url, "--dialect", "sqlite", "--format", "json"]); + const payload = JSON.parse(out.join("\n")) as { schemaDrift?: { changes?: Array<{ kind: string; object: string; detail: string }> } }; + return { exit, changes: (payload.schemaDrift?.changes ?? []).map((c) => `${c.kind} ${c.detail}`) }; +} + +describe("orm: prisma — prisma db push, then meta verify --db (sqlite)", () => { + test("the pushed database differs from the model only by the CHECKs SQLite cannot add; a changed one differs more", async () => { + const root = project("sqlite", ', checks: "omit"'); + try { + expect(await run(["gen", "--cwd", root])).toBe(0); + const dbFile = join(root, "app.db"); + prismaConfig(root, `file:${dbFile}`); + const push = prisma(root, "db", "push"); + expect(push.output).toContain("in sync"); + expect(push.code).toBe(0); + + // Every type, default, key, index and foreign key matches. What is left is the CHECKs: + // SQLite cannot add one to a table Prisma created, so `checks: "omit"` leaves them out + // and the gate reports each missing (docs/recipes/prisma.md, "SQLite"). + const url = `file:${dbFile}`; + const pushed = await schemaChanges(root, url); + expect(pushed.changes.map((c) => c.split(" (")[0])).toEqual([ + "add-check customers check customers_display_name_length_chk", + "add-check customers check customers_tier_chk", + "add-check orders check orders_total_numeric_chk", + ]); + + const client = createClient({ url }); + await client.execute("ALTER TABLE tags ADD COLUMN stray TEXT"); + client.close(); + const changed = await schemaChanges(root, url); + expect(changed.exit).toBe(1); + expect(changed.changes.some((c) => c.startsWith("drop-column") && c.includes("stray"))).toBe(true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }, 120_000); +}); + +describe("orm: prisma — prisma db push, then meta verify --db (postgres, env-gated)", () => { + if (PG_URL === undefined || PG_URL.length === 0) { + // The ts-slow lane arms this: there the URL is the sidecar's, and a missing one is a broken + // lane, not a skip. + if (process.env.MIGRATE_TS_PG_EXPECT === "1") { + test("MIGRATE_TS_PG_URL is set where MIGRATE_TS_PG_EXPECT=1 says a Postgres is", () => { + expect(PG_URL).toBeDefined(); + }); + } else { + test.skip("skipped — MIGRATE_TS_PG_URL not set", () => {}); + } + return; + } + + test("the pushed database, with the checks applied, matches the model; a changed column does not", async () => { + const dbName = `mo_prisma_${process.pid}_${Date.now()}`; + const admin = new pg.Client({ connectionString: PG_URL }); + await admin.connect(); + await admin.query(`CREATE DATABASE ${dbName}`); + const url = new URL(PG_URL); + url.pathname = `/${dbName}`; + const root = project("postgres"); + try { + expect(await run(["gen", "--cwd", root])).toBe(0); + prismaConfig(root, url.toString()); + const push = prisma(root, "db", "push"); + expect(push.output).toContain("in sync"); + expect(push.code).toBe(0); + const checks = prisma(root, "db", "execute", "--file", join(root, "prisma/schema.checks.sql")); + expect(checks.code).toBe(0); + // The checks file is idempotent on Postgres: a second push and a second apply change nothing. + expect(prisma(root, "db", "push").code).toBe(0); + expect(prisma(root, "db", "execute", "--file", join(root, "prisma/schema.checks.sql")).code).toBe(0); + + out = []; err = []; + const ok = await run(["verify", "--cwd", root, "--db", url.toString()]); + expect(all()).not.toContain("drift ("); + expect(ok).toBe(0); + + const db = new pg.Client({ connectionString: url.toString() }); + await db.connect(); + await db.query('ALTER TABLE customers ALTER COLUMN email TYPE varchar(300)'); + await db.end(); + out = []; err = []; + expect(await run(["verify", "--cwd", root, "--db", url.toString()])).toBe(1); + expect(all()).toContain("email"); + } finally { + rmSync(root, { recursive: true, force: true }); + await admin.query(`DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`); + await admin.end(); + } + }, 120_000); +}); diff --git a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts index 5ed10ab9e..8f9996374 100644 --- a/server/typescript/packages/cli/test/unit/reporting-inert.test.ts +++ b/server/typescript/packages/cli/test/unit/reporting-inert.test.ts @@ -40,13 +40,13 @@ import { mkdtempSync, mkdirSync, copyFileSync, rmSync, readFileSync, readdirSync import { tmpdir } from "node:os"; import { join, relative, resolve, sep } from "node:path"; import { pathToFileURL } from "node:url"; -import { loadUris, OBJECT_SUBTYPE_REPORT, type MetaRoot } from "@metaobjectsdev/metadata"; +import { isMetaSource, isReadOnlySource, loadUris, OBJECT_SUBTYPE_REPORT, type MetaObject, type MetaRoot } from "@metaobjectsdev/metadata"; import { runGen, makeRenderContext, buildPkMap, buildRelationMap, buildProjectionViews, type AgentSchemaInput, type GenContext, type Generator, type MetaobjectsGenConfig, type SchemaColumnLike, } from "@metaobjectsdev/codegen-ts"; -import { agentDocsFile, apiDocsFile } from "@metaobjectsdev/codegen-ts/generators"; +import { agentDocsFile, apiDocsFile, prismaSchemaFile } from "@metaobjectsdev/codegen-ts/generators"; import { buildExpectedSchema, buildExpectedSchemaWithProvenance, columnTypeSql, diff, qualifiedDbName, type SchemaSnapshot, @@ -54,6 +54,19 @@ import { import { composeCatalog } from "../../src/lib/catalog.js"; import { run as runCli } from "../../src/index.js"; +/** Whether `o` is read from a view (a served report is). */ +const readsAView = (o: MetaObject): boolean => o.children().some((c) => isMetaSource(c) && isReadOnlySource(c)); + +/** + * The generator a catalog entry stands for, as this file runs it. `prisma-schema` refuses a + * served report (ERR_PRISMA_READ_ONLY_SOURCE): a Prisma schema cannot create its view, which a + * Prisma app creates in a Prisma migration. So it runs here as such an app wires it, over the + * table-backed objects; the refusal itself is asserted on its own below. + */ +function factoryOf(name: string, entry: { factory: () => Generator }): Generator { + return name === "prisma-schema" ? prismaSchemaFile({ filter: (o) => !readsAView(o) }) : entry.factory(); +} + // test/unit → cli → packages → typescript → server → repo root const REPO_ROOT = resolve(import.meta.dir, "..", "..", "..", "..", "..", ".."); const MODELS = join(REPO_ROOT, "fixtures", "codegen-noop", "reporting"); @@ -209,8 +222,8 @@ describe("FR-044 a sourceless report is inert; a served report emits exactly its for (const [name, entry] of codeCatalog) { test(`generator "${name}" adds exactly the served report's files and changes nothing else`, async () => { - const expected = await emit(withoutReporting, [entry.factory()]); - const actual = await emit(withReporting, [entry.factory()]); + const expected = await emit(withoutReporting, [factoryOf(name, entry)]); + const actual = await emit(withReporting, [factoryOf(name, entry)]); if ("" in expected) { // Cannot run from a bare model (pinned by name below): the same throw both ways. expect(actual).toEqual(expected); @@ -294,13 +307,24 @@ describe("FR-044 a sourceless report is inert; a served report emits exactly its }); }); + test("prisma-schema refuses a served report, and a sourceless one changes nothing", async () => { + const refused = await emit(withReporting, [prismaSchemaFile()]); + expect(refused[""]).toContain("ERR_PRISMA_READ_ONLY_SOURCE"); + expect(refused[""]).toContain("StoreTotals"); + // Without the served report the sourceless ones are inert: the same schema, byte for byte. + const onlySourceless = (o: MetaObject): boolean => o.subType !== OBJECT_SUBTYPE_REPORT || !readsAView(o); + const without = await emit(withoutReporting, [prismaSchemaFile({ filter: onlySourceless })]); + const withSourceless = await emit(withReporting, [prismaSchemaFile({ filter: onlySourceless })]); + expect(withSourceless).toEqual(without); + }); + test("exactly these generators cannot run from a bare model — the list may only shrink", async () => { // Each is compared above on its error message alone, which proves nothing about its // output. Pinned by name so a generator that starts throwing cannot drop out silently. // shared-model needs a `files` selection `meta gen` supplies at run time const threw: string[] = []; for (const [name, entry] of codeCatalog) { - if ("" in (await emit(withoutReporting, [entry.factory()]))) threw.push(name); + if ("" in (await emit(withoutReporting, [factoryOf(name, entry)]))) threw.push(name); } expect(threw.sort()).toEqual(["shared-model"]); }); @@ -316,10 +340,10 @@ describe("FR-044 a sourceless report is inert; a served report emits exactly its const runnable: string[] = []; for (const [name, entry] of Object.entries(catalog)) { if (entry.layer === "docs" && name !== "agent-docs") continue; - const alone = await emit(withoutReporting, [entry.factory()]); + const alone = await emit(withoutReporting, [factoryOf(name, entry)]); if (!("" in alone)) runnable.push(name); } - const suite = (): Generator[] => runnable.map((n) => catalog[n]!.factory()); + const suite = (): Generator[] => runnable.map((n) => factoryOf(n, catalog[n]!)); const expected = await emit(withoutReporting, suite()); const actual = await emit(withReporting, suite()); expect(expected[""]).toBeUndefined(); @@ -347,7 +371,7 @@ describe("FR-044 a selection of only reports", () => { const allGenerators = (): Generator[] => Object.values(composeCatalog()) .filter((e) => e.name !== "shared-model" && e.layer !== "docs") - .map((e) => e.factory()); + .map((e) => factoryOf(e.name, e)); const run = async (entityFilter: string[]) => { const root = mkdtempSync(join(tmpdir(), "reporting-inert-only-")); try { diff --git a/server/typescript/packages/codegen-ts/package.json b/server/typescript/packages/codegen-ts/package.json index 6d2832e32..808275bbb 100644 --- a/server/typescript/packages/codegen-ts/package.json +++ b/server/typescript/packages/codegen-ts/package.json @@ -1,7 +1,7 @@ { "name": "@metaobjectsdev/codegen-ts", "version": "1.1.4", - "description": "TypeScript codegen engine for MetaObjects — emits Drizzle, Zod, and Fastify artifacts.", + "description": "TypeScript codegen engine for MetaObjects \u2014 emits Drizzle, Zod, and Fastify artifacts.", "type": "module", "main": "./dist/index.js", "types": "./dist/index.d.ts", @@ -140,6 +140,7 @@ "@metaobjectsdev/metadata": "workspace:*", "@metaobjectsdev/docs-site": "workspace:*", "@metaobjectsdev/render": "workspace:*", + "@metaobjectsdev/migrate-ts": "workspace:*", "@biomejs/js-api": "^0.7.0", "@biomejs/wasm-nodejs": "^1.9.4", "@toon-format/toon": "^2.3.0", @@ -152,7 +153,6 @@ "devDependencies": { "@biomejs/biome": "^1.9.0", "@metaobjectsdev/codegen-ts-react": "workspace:*", - "@metaobjectsdev/migrate-ts": "workspace:*", "@metaobjectsdev/runtime-ts": "workspace:*", "bun-types": "latest", "drizzle-orm": "^0.38.0", diff --git a/server/typescript/packages/codegen-ts/src/engine-surface.ts b/server/typescript/packages/codegen-ts/src/engine-surface.ts index 80c4c5c5e..5d90f1c4c 100644 --- a/server/typescript/packages/codegen-ts/src/engine-surface.ts +++ b/server/typescript/packages/codegen-ts/src/engine-surface.ts @@ -46,6 +46,7 @@ export const ENGINE_MODULES: readonly string[] = [ "codegen-ts/src/naming.ts", "codegen-ts/src/owned-runtime.ts", "codegen-ts/src/pk-resolver.ts", + "codegen-ts/src/projection/build-projection-views.ts", "codegen-ts/src/projection/extract-view-spec.ts", "codegen-ts/src/projection/projection-detector.ts", "codegen-ts/src/projection/report-resolve.ts", diff --git a/server/typescript/packages/codegen-ts/src/engine.ts b/server/typescript/packages/codegen-ts/src/engine.ts index 29eb821e5..47ef2cb8a 100644 --- a/server/typescript/packages/codegen-ts/src/engine.ts +++ b/server/typescript/packages/codegen-ts/src/engine.ts @@ -33,6 +33,7 @@ export * from "./names.js"; export * from "./naming.js"; export * from "./owned-runtime.js"; export * from "./pk-resolver.js"; +export * from "./projection/build-projection-views.js"; export * from "./projection/extract-view-spec.js"; export * from "./projection/projection-detector.js"; export * from "./projection/report-resolve.js"; diff --git a/server/typescript/packages/codegen-ts/src/generator-registry.ts b/server/typescript/packages/codegen-ts/src/generator-registry.ts index cd6728202..f46f1b587 100644 --- a/server/typescript/packages/codegen-ts/src/generator-registry.ts +++ b/server/typescript/packages/codegen-ts/src/generator-registry.ts @@ -53,6 +53,7 @@ import { metamodelDocsFile, docsSiteFile, cubeModel, + prismaSchemaFile, } from "./generators/index.js"; export type GeneratorTier = "native" | "neutral"; @@ -240,6 +241,23 @@ export const generatorRegistry: Record = { configKeys: ["dialect", "extStyle", "pluralizeCollections", "collectionNameOverrides"], ejectable: ejectable("queries"), }, + "prisma-schema": { + name: "prisma-schema", + kind: "generator", + layer: "persistence", + description: + "A Prisma schema (schema.prisma) for the model's tables, and schema.checks.sql for the CHECK constraints Prisma cannot declare, for a project that keeps Prisma as its ORM. Under orm: \"prisma\", entity writes the types and Zod schemas with no Drizzle table.", + tier: "native", + factory: () => prismaSchemaFile(), + factoryExport: "prismaSchemaFile", + options: "provider?, fileName?, header?, checks?, filter?, target?", + // The schema imports nothing; Prisma itself is the project's own dependency. + requires: [], + runtimePackages: [], + runtimePeers: [], + configKeys: ["dialect", "orm"], + ejectable: ejectable("prisma-schema"), + }, // ----- api -------------------------------------------------------------- routes: { diff --git a/server/typescript/packages/codegen-ts/src/generators/index.ts b/server/typescript/packages/codegen-ts/src/generators/index.ts index 914b741ac..6ffe4f9d1 100644 --- a/server/typescript/packages/codegen-ts/src/generators/index.ts +++ b/server/typescript/packages/codegen-ts/src/generators/index.ts @@ -92,3 +92,7 @@ export type { // FR-044 Plan 4 — the Cube exporter: Cube data-model files (model/cubes/*.yml and, for a served // @spine report, model/views/*.yml) for the reporting vocabulary. A reference helper; its pure stages are exported from the package root. export { cubeModel, type CubeModelGeneratorOptions } from "./cube-model.js"; + +// #479 — the Prisma schema: the model's tables as `schema.prisma`, for a project that keeps Prisma +// as its ORM. A reference helper; its pure stages are exported from the package root. +export { prismaSchemaFile, type PrismaSchemaFileOptions } from "./prisma-schema-file.js"; diff --git a/server/typescript/packages/codegen-ts/src/generators/prisma-schema-file.ts b/server/typescript/packages/codegen-ts/src/generators/prisma-schema-file.ts new file mode 100644 index 000000000..8ce80aca1 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/generators/prisma-schema-file.ts @@ -0,0 +1,137 @@ +// prismaSchemaFile(), the prisma-schema reference generator: the model's tables as a Prisma +// schema, so a project that keeps Prisma as its ORM declares each field once, in the model. +// +// The schema is built from the expected schema `meta migrate` writes and `meta verify --db` +// compares against (src/prisma/build-prisma-schema.ts), so a database created by `prisma db push` +// from this file passes `meta verify --db`. What a Prisma schema cannot declare is refused, never +// dropped (PrismaSchemaError). The CHECK constraints the model implies (enum membership, numeric +// and length validators) cannot be declared in a Prisma schema, so they go to a companion SQL +// file, `.checks.sql`, which `prisma db execute --file` applies after a push. +// +// What the file holds never depends on the run: the universe is the whole loaded model narrowed +// by the generator's own `filter`. The run's selection decides only WHETHER it is written: a run +// that selects no table-backed object writes nothing. +// +// A hand edit is discarded (`handEdits: "overwrite"`): Prisma reads the whole file as the schema, +// so an edit kept by a merge would change the database without the model knowing, and +// `meta verify --codegen` fails on one. Declare the field in the model, or own this generator. +// +// Opt-in, and a reference helper (ADR-0034 Amendment 3): the files import nothing. + +import type { MetaObject } from "@metaobjectsdev/metadata"; +import { GENERATED_HEADER } from "../constants.js"; +import type { EmittedFile, GenContext, Generator } from "../generator.js"; +import { buildPrismaSchema, PRISMA_PROVIDERS, type PrismaProvider } from "../prisma/build-prisma-schema.js"; +import { ERR_PRISMA_UNSUPPORTED_PROVIDER, PrismaSchemaError } from "../prisma/prisma-errors.js"; +import { renderChecksSql, renderPrismaSchema } from "../prisma/render-prisma-schema.js"; +import { hasWritableRdbSource } from "../source-detect.js"; + +/** The generator's name, as diagnostics and the catalog print it. */ +export const PRISMA_SCHEMA_GENERATOR_NAME = "prisma-schema"; + +/** The schema file's name when the options name none. */ +export const DEFAULT_PRISMA_SCHEMA_FILE = "schema.prisma"; + +export interface PrismaSchemaFileOptions { + /** The Prisma datasource provider. Default: from the codegen config's `dialect`. */ + readonly provider?: PrismaProvider; + /** The schema file, relative to the target's outDir. Default `schema.prisma`. */ + readonly fileName?: string; + /** + * The generator and datasource blocks. Default: a `prisma-client` generator writing to + * `./generated` and a datasource naming the provider (Prisma 7 takes the URL from + * `prisma.config.ts`). A string is written verbatim. `false` writes neither, for a multi-file + * schema (`prisma/schema/`) whose blocks live in a file the project owns. + */ + readonly header?: string | false; + /** + * `"file"` (the default) writes the CHECK constraints to `.checks.sql`. `"omit"` leaves + * them out, which is the only way to take a model with checks to SQLite. + */ + readonly checks?: "file" | "omit"; + /** Which objects the schema covers, ANDed with "has a table". */ + readonly filter?: (obj: MetaObject) => boolean; + /** Named output target: the files land at its outDir. */ + readonly target?: string; +} + +const DIALECT_PROVIDER: Record = { + postgres: "postgresql", + mysql: "mysql", + sqlite: "sqlite", +}; + +/** `schema.prisma` gives `schema.checks.sql`. */ +export function prismaChecksFileName(schemaFile: string): string { + return `${schemaFile.replace(/\.prisma$/, "")}.checks.sql`; +} + +function resolveProvider(ctx: GenContext, options: PrismaSchemaFileOptions): PrismaProvider { + const provider = options.provider ?? DIALECT_PROVIDER[ctx.config.dialect]; + if (provider === undefined || !(PRISMA_PROVIDERS as readonly string[]).includes(provider)) { + const source = options.provider !== undefined ? `provider '${String(options.provider)}'` : `dialect '${ctx.config.dialect}'`; + throw new PrismaSchemaError( + ERR_PRISMA_UNSUPPORTED_PROVIDER, + `prisma-schema writes a schema for the postgresql, mysql and sqlite providers, and the ${source} is none of them. Pass the provider of the database Prisma connects to: prismaSchemaFile({ provider: "postgresql" }).`, + ); + } + return provider; +} + +function banner(checksFile: string | undefined): string[] { + const lines = [ + `${GENERATED_HEADER}. meta gen rewrites this file and discards hand edits; meta verify --codegen fails when it`, + "differs from the model. Declare a field in the MetaObjects model, not here.", + ]; + if (checksFile !== undefined) { + lines.push(`After \`prisma db push\` or a migration, apply ${checksFile}, beside this file: prisma db execute --file `); + } + return lines; +} + +function generatePrismaFiles(ctx: GenContext, options: PrismaSchemaFileOptions): EmittedFile[] { + if (!ctx.entities.some((o) => ctx.matches(o) && hasWritableRdbSource(o))) return []; + const provider = resolveProvider(ctx, options); + const spec = buildPrismaSchema(ctx.loadedRoot, { + provider, + // The generator's own filter, never the run's selection: see the header. + matches: ctx.matches, + checks: options.checks ?? "file", + }); + const fileName = options.fileName ?? DEFAULT_PRISMA_SCHEMA_FILE; + const checksFile = spec.checks.length > 0 ? prismaChecksFileName(fileName) : undefined; + const files: EmittedFile[] = [ + { path: fileName, content: renderPrismaSchema(spec, { banner: banner(checksFile), header: options.header }) }, + ]; + if (checksFile !== undefined) { + files.push({ + path: checksFile, + content: renderChecksSql(spec, [ + `${GENERATED_HEADER}. The CHECK constraints ${fileName} cannot declare.`, + "Apply after `prisma db push` or a migration: prisma db execute --file ", + ]), + }); + } + return files; +} + +/** + * The prisma-schema generator: one Prisma schema for the model's tables, plus a companion SQL + * file for the CHECK constraints when the model implies any. Throws `PrismaSchemaError` for what + * a Prisma schema cannot express. + */ +export function prismaSchemaFile(options: PrismaSchemaFileOptions = {}): Generator { + const fileName = options.fileName ?? DEFAULT_PRISMA_SCHEMA_FILE; + const checksFile = prismaChecksFileName(fileName); + const generator: Generator = { + name: PRISMA_SCHEMA_GENERATOR_NAME, + generate: (ctx) => generatePrismaFiles(ctx, options), + handEdits: "overwrite", + // The checks file is written only while the model implies a CHECK; when the last one goes, + // the file it left behind is removed (when it is still what was written). + orphanPolicy: { owns: (rel) => rel === checksFile || rel === fileName }, + }; + if (options.filter !== undefined) generator.filter = options.filter; + if (options.target !== undefined) generator.target = options.target; + return generator; +} diff --git a/server/typescript/packages/codegen-ts/src/index.ts b/server/typescript/packages/codegen-ts/src/index.ts index 15cbe507f..2b37f7b30 100644 --- a/server/typescript/packages/codegen-ts/src/index.ts +++ b/server/typescript/packages/codegen-ts/src/index.ts @@ -54,8 +54,8 @@ export { stableNameIndex, } from "./catalog-gates.js"; -export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, ApiSurface, VerifyConfig } from "./metaobjects-config.js"; -export { defineConfig, normalizeConfig, resolveGenerators, removedGeneratorMessage } from "./metaobjects-config.js"; +export type { MetaobjectsGenConfig, NormalizedMetaobjectsGenConfig, ResolvedGenConfig, Dialect, ExtStyle, ColumnNamingStrategy, MetaDataTypeProvider, GeneratorSpec, ApiSurface, VerifyConfig, Orm } from "./metaobjects-config.js"; +export { defineConfig, normalizeConfig, resolveGenerators, removedGeneratorMessage, resolveOrm, ORMS, ORM_DRIZZLE, ORM_PRISMA, DRIZZLE_BOUND_GENERATORS } from "./metaobjects-config.js"; // The dialect every generator falls back to when a config declares none, and where a // project's documentation goes when it has not chosen (`targets.docs`). export { DEFAULT_DIALECT, DEFAULT_DOCS_DIR } from "./metaobjects-config.js"; @@ -452,6 +452,47 @@ export { ERR_CUBE_UNSUPPORTED_DIALECT, } from "./cube/cube-errors.js"; export type { CubeErrorCode } from "./cube/cube-errors.js"; + +// #479 — the Prisma schema. `prismaSchemaFile()` is the reference generator; the two pure stages +// it wires are public so an owned (ejected) copy imports only this package and keeps only the +// parts it owns: which objects get a model, the file layout and the blocks above the models. +export { + prismaSchemaFile, + prismaChecksFileName, + DEFAULT_PRISMA_SCHEMA_FILE, + PRISMA_SCHEMA_GENERATOR_NAME, +} from "./generators/prisma-schema-file.js"; +export type { PrismaSchemaFileOptions } from "./generators/prisma-schema-file.js"; +export { buildPrismaSchema, prismaString, PRISMA_PROVIDERS } from "./prisma/build-prisma-schema.js"; +export type { + BuildPrismaSchemaOptions, + PrismaCheckSpec, + PrismaFieldSpec, + PrismaModelSpec, + PrismaProvider, + PrismaSchemaSpec, +} from "./prisma/build-prisma-schema.js"; +export { + DEFAULT_PRISMA_CLIENT_GENERATOR, + renderChecksSql, + renderDatasource, + renderModels, + renderPrismaSchema, +} from "./prisma/render-prisma-schema.js"; +export type { RenderPrismaSchemaOptions } from "./prisma/render-prisma-schema.js"; +export { + PrismaSchemaError, + PRISMA_ERROR_CODES, + ERR_PRISMA_NAME_COLLISION, + ERR_PRISMA_NO_PRIMARY_KEY, + ERR_PRISMA_READ_ONLY_SOURCE, + ERR_PRISMA_UNMAPPABLE_RELATION, + ERR_PRISMA_UNSUPPORTED_CHECK, + ERR_PRISMA_UNSUPPORTED_INDEX, + ERR_PRISMA_UNSUPPORTED_PROVIDER, + ERR_PRISMA_UNSUPPORTED_SCHEMA, +} from "./prisma/prisma-errors.js"; +export type { PrismaErrorCode } from "./prisma/prisma-errors.js"; export type { CubeDialect, CubeDimensionMeta, diff --git a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts index 38a24fcbc..129f70aae 100644 --- a/server/typescript/packages/codegen-ts/src/metaobjects-config.ts +++ b/server/typescript/packages/codegen-ts/src/metaobjects-config.ts @@ -24,6 +24,23 @@ export type GeneratorSpec = Generator | string; * does not own a MySQL schema (ADR-0015), so the adopter writes the DDL. */ export type Dialect = "sqlite" | "postgres" | "mysql"; +/** + * The ORM the application uses (#479). `drizzle` (the default): the entity module carries the + * Drizzle tables and the DB layer. `prisma`: the schema is `schema.prisma`, written by the + * `prisma-schema` generator, so every target is contract-only by default (Zod schemas and + * types, the `runtime: false` path) and the generators that write Drizzle code are refused. + */ +export const ORMS = ["drizzle", "prisma"] as const; +export type Orm = (typeof ORMS)[number]; +export const ORM_DRIZZLE: Orm = "drizzle"; +export const ORM_PRISMA: Orm = "prisma"; + +/** + * The generators whose output is Drizzle code: the queries, the routes over them and the + * stored-procedure wrappers. `orm: "prisma"` refuses them by these stable names. + */ +export const DRIZZLE_BOUND_GENERATORS = ["queries", "routes", "routes-hono", "callable"] as const; + /** Re-exported from metadata so codegen-ts consumers see one canonical type. */ export type { ColumnNamingStrategy, MetaDataTypeProvider } from "@metaobjectsdev/metadata"; export type { ExtStyle }; @@ -44,7 +61,8 @@ export interface TargetConfig { * server package: Drizzle tables/views + the DB layer). Set `false` for a * contract-only target — Zod schemas + inferred TS types only, no `drizzle-orm` * / `runtime-ts` import — e.g. a shared wire-contract package consumed by a web - * client with no database. See {@link ResolvedTarget.runtime}. + * client with no database. See {@link ResolvedTarget.runtime}. Under `orm: "prisma"` it + * defaults to `false`, and `true` is refused. */ runtime?: boolean; } @@ -212,6 +230,8 @@ export interface MetaobjectsGenConfig extends Omit; /** importBase for the default target (top-level outDir). */ importBase?: string; + /** The application's ORM. Default `"drizzle"`. See {@link ORMS}. */ + orm?: Orm; /** * Consumer-supplied {@link MetaDataTypeProvider}s. Threaded to `loadMemory` * by the CLI's gen/migrate commands so a project can register its own @@ -294,6 +314,8 @@ export function defineConfig(config: MetaobjectsGenConfig): MetaobjectsGenConfig * importBase does NOT inherit — it is a per-target identity). */ export function resolveTargets(config: MetaobjectsGenConfig): Record { const layout: OutputLayout = config.outputLayout ?? "flat"; + // Under Prisma no target carries Drizzle bindings: Prisma owns the client and the schema. + const runtime = resolveOrm(config) !== ORM_PRISMA; const out: Record = { [DEFAULT_TARGET_NAME]: { name: DEFAULT_TARGET_NAME, @@ -301,8 +323,8 @@ export function resolveTargets(config: MetaobjectsGenConfig): Record): Orm { + const orm: unknown = config.orm ?? ORM_DRIZZLE; + if (!(ORMS as readonly unknown[]).includes(orm)) { + throw new Error(`orm "${String(orm)}" is not one of ${ORMS.map((o) => `"${o}"`).join(", ")}.`); + } + return orm as Orm; +} + +/** + * Refuse what `orm: "prisma"` cannot run: a target that asks for Drizzle bindings, and a + * generator that writes Drizzle code. Both would compile against tables nothing creates. + */ +function checkPrismaConfig(config: MetaobjectsGenConfig, generators: readonly Generator[]): void { + for (const [name, t] of Object.entries(config.targets ?? {})) { + if (t.runtime === true) { + throw new Error( + `orm: "prisma" writes no Drizzle bindings, and target "${name}" sets runtime: true. ` + + "Remove it: under Prisma every target is contract-only (Zod schemas and types), and the " + + "schema is schema.prisma, written by the prisma-schema generator.", + ); + } + } + const bound = new Map( + DRIZZLE_BOUND_GENERATORS.map((n) => [generatorRegistry[n]!.factory().name, n]), + ); + const refused = [...new Set(generators.map((g) => bound.get(g.name)).filter((n): n is string => n !== undefined))]; + if (refused.length > 0) { + throw new Error( + `orm: "prisma" refuses ${refused.map((n) => `"${n}"`).join(", ")}: ${refused.length === 1 ? "it writes" : "they write"} ` + + "Drizzle code, over tables Prisma owns. Remove " + (refused.length === 1 ? "it" : "them") + + " from `generators`, and query through the Prisma client. The entity generator still " + + "writes the Zod schemas and types; prisma-schema writes schema.prisma.", + ); + } +} + /** Apply defaults to a MetaobjectsGenConfig, returning a NormalizedMetaobjectsGenConfig. */ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobjectsGenConfig { // ADR-0058: column naming is `naming` in .metaobjects/config.json, read by every port. A @@ -379,12 +438,14 @@ export function normalizeConfig(config: MetaobjectsGenConfig): NormalizedMetaobj throw new Error(physicalNamingRemovedMessage("columnNamingStrategy in metaobjects.config.ts", String(removedNaming), physicalNamingGuideUrl())); } const dialect = config.dialect ?? DEFAULT_DIALECT; + const generators = resolveGenerators(config.generators); + if (resolveOrm(config) === ORM_PRISMA) checkPrismaConfig(config, generators); return { ...config, dbImport: config.dbImport ?? DEFAULT_DB_IMPORT, dialect, extStyle: config.extStyle ?? DEFAULT_EXT_STYLE, - generators: resolveGenerators(config.generators), + generators, pluralizeCollections: config.pluralizeCollections ?? true, collectionNameOverrides: config.collectionNameOverrides ?? {}, // "date" mode is Postgres-only (see the doc comment on timestampMode above) — diff --git a/server/typescript/packages/codegen-ts/src/prisma/build-prisma-schema.ts b/server/typescript/packages/codegen-ts/src/prisma/build-prisma-schema.ts new file mode 100644 index 000000000..e0a4104c7 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/prisma/build-prisma-schema.ts @@ -0,0 +1,781 @@ +// The prisma-schema generator's first stage: the model as Prisma models. +// +// The tables, columns, keys, indexes, foreign keys and CHECK constraints come from +// `buildExpectedSchema`, the same schema `meta migrate` writes and `meta verify --db` compares a +// live database against. Building from it, rather than walking the model a second time, is what +// lets a database that Prisma created from this schema pass `meta verify --db`: the physical names, +// the column types, the referential actions and the index names are the ones the gate expects, +// by construction. What this stage adds is the Prisma side of each table: the model and field +// names (the entity and field names of the model), the scalar and native type per provider, and +// the relation fields Prisma needs on both ends of every foreign key. +// +// What Prisma cannot declare is refused, never dropped: a view `meta verify --db` expects (a +// projection's, a write-through entity's read view, a served report's), an expression or partial +// index, a CHECK constraint on SQLite, a database schema outside Postgres. Every refusal is a +// PrismaSchemaError naming the node and the fix. + +import { + FIELD_SUBTYPE_BOOLEAN, + FIELD_SUBTYPE_DATE, + FIELD_SUBTYPE_DECIMAL, + FIELD_SUBTYPE_DOUBLE, + FIELD_SUBTYPE_ENUM, + FIELD_SUBTYPE_FLOAT, + FIELD_SUBTYPE_INET, + FIELD_SUBTYPE_INT, + FIELD_SUBTYPE_OBJECT, + FIELD_SUBTYPE_TIME, + FIELD_SUBTYPE_TIMESTAMP, + FIELD_SUBTYPE_UUID, + FIELD_ATTR_OBJECT_REF, + FIELD_ATTR_STORAGE, + FIELD_ATTR_VALUES, + STORAGE_FLATTENED, + isMetaObject, + isWritableSource, + physicalNamingOf, + pluralize, + refTarget, + resolveColumnName, + resolveTableSchema, + type MetaField, + type MetaObject, + type MetaRoot, +} from "@metaobjectsdev/metadata"; +import { + buildExpectedSchemaWithProvenance, + qualifiedDbName, + type CheckDescriptor, + type ColumnDescriptor, + type FkAction, + type FkDescriptor, + type IndexDescriptor, + type SqlType, + type TableDescriptor, +} from "@metaobjectsdev/migrate-ts/expected"; +import { buildProjectionViews, type ExpectedView } from "../projection/build-projection-views.js"; +import { tphStorageObject } from "../tph.js"; +import { + ERR_PRISMA_NAME_COLLISION, + ERR_PRISMA_NO_PRIMARY_KEY, + ERR_PRISMA_READ_ONLY_SOURCE, + ERR_PRISMA_UNMAPPABLE_RELATION, + ERR_PRISMA_UNSUPPORTED_CHECK, + ERR_PRISMA_UNSUPPORTED_INDEX, + ERR_PRISMA_UNSUPPORTED_SCHEMA, + PrismaSchemaError, +} from "./prisma-errors.js"; + +/** The Prisma datasource providers the generator writes a schema for. */ +export const PRISMA_PROVIDERS = ["postgresql", "mysql", "sqlite"] as const; +export type PrismaProvider = (typeof PRISMA_PROVIDERS)[number]; + +/** One field line of a model: `name Type? @attr @attr`. */ +export interface PrismaFieldSpec { + readonly name: string; + /** The Prisma scalar (`String`, `BigInt`, ...) or, for a relation field, the model name. */ + readonly type: string; + readonly list: boolean; + readonly optional: boolean; + /** Field attributes in the order they are written: `@id`, `@default(...)`, `@map(...)`, `@db.X`. */ + readonly attributes: readonly string[]; +} + +export interface PrismaModelSpec { + readonly name: string; + readonly fields: readonly PrismaFieldSpec[]; + /** Block attributes: `@@id`, `@@unique`, `@@index`, `@@map`, `@@schema`. */ + readonly blockAttributes: readonly string[]; +} + +/** A CHECK constraint the schema cannot carry, as the SQL that adds it. */ +export interface PrismaCheckSpec { + readonly table: string; + readonly schema: string | undefined; + readonly name: string; + readonly expression: string; +} + +export interface PrismaSchemaSpec { + readonly provider: PrismaProvider; + readonly models: readonly PrismaModelSpec[]; + /** Database schemas the models live in, when any is not the default (Postgres multi-schema). */ + readonly schemas: readonly string[]; + readonly checks: readonly PrismaCheckSpec[]; +} + +export interface BuildPrismaSchemaOptions { + readonly provider: PrismaProvider; + /** Which objects the schema covers. Default: every object. */ + readonly matches?: (obj: MetaObject) => boolean; + /** + * `"file"` (the default) returns the CHECK constraints for a companion SQL file; `"omit"` leaves + * them out, which is the only way to take a model with checks to SQLite. + */ + readonly checks?: "file" | "omit"; +} + +/** Postgres' default schema: a table in it carries no `@@schema`. */ +const DEFAULT_PG_SCHEMA = "public"; + +/** The VARCHAR length MySQL gets for a key, indexed or defaulted string with no `@maxLength`. */ +const MYSQL_KEY_VARCHAR_LENGTH = 255; + +const PRISMA_REFERENTIAL_ACTION: Record = { + cascade: "Cascade", + "set-null": "SetNull", + restrict: "Restrict", + "no-action": "NoAction", +}; + +/** Postgres index access methods Prisma can declare, by their `@@index(type: ...)` spelling. */ +const PRISMA_PG_INDEX_TYPE: Record = { + hash: "Hash", + gin: "Gin", + gist: "Gist", + brin: "Brin", + spgist: "SpGist", +}; + +/** The scalar and native type of one column, before nullability and defaults. */ +interface ColumnType { + scalar: string; + list: boolean; + native?: string; +} + +/** The field a column holds, and its Prisma field name. */ +interface ColumnField { + field: MetaField; + name: string; +} + +/** A table on its way to a model: the descriptor, the object that declared it and its fields by column. */ +interface TableEntry { + table: TableDescriptor; + key: string; + object: MetaObject; + modelName: string; + fieldByColumn: Map; + /** Prisma field name per column. */ + fieldName: Map; + columnType: Map; + /** Every Prisma field name the model has taken so far, scalar and relation. */ + takenNames: Set; + relationFields: PrismaFieldSpec[]; +} + +const lowerFirst = (s: string): string => s.charAt(0).toLowerCase() + s.slice(1); +const upperFirst = (s: string): string => s.charAt(0).toUpperCase() + s.slice(1); + +/** A Prisma string literal. */ +export function prismaString(value: string): string { + return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; +} + +function migrateDialect(provider: PrismaProvider): "postgres" | "sqlite" | undefined { + // MySQL has no expected-schema dialect (MetaObjects does not own a MySQL schema, ADR-0015); the + // canonical schema, with no dialect normalization, is the one its tables are read from. + if (provider === "postgresql") return "postgres"; + if (provider === "sqlite") return "sqlite"; + return undefined; +} + +/** Every object of the loaded model, by resolution key. */ +function objectsByKey(root: MetaRoot): Map { + const out = new Map(); + // ADR-0039: effective children of the root. + for (const child of root.children()) { + if (isMetaObject(child)) out.set(child.resolutionKey(), child); + } + return out; +} + +function refuseView(obj: MetaObject, view: ExpectedView): PrismaSchemaError { + return new PrismaSchemaError( + ERR_PRISMA_READ_ONLY_SOURCE, + `'${obj.resolutionKey()}' declares view '${view.name}', and a Prisma schema cannot create a view: ` + + `\`prisma db push\` and \`prisma migrate\` would leave it out, and \`meta verify --db\` would report it ` + + `missing. Create it in a Prisma migration (\`prisma migrate dev --create-only\`, then add the SQL) and mark ` + + `its read source.rdb \`@unmanaged: true\`, or leave '${obj.resolutionKey()}' out of this generator with its ` + + `\`filter\` option.`, + ); +} + +function refuseSchema(obj: MetaObject, schema: string, provider: PrismaProvider): PrismaSchemaError { + return new PrismaSchemaError( + ERR_PRISMA_UNSUPPORTED_SCHEMA, + `'${obj.resolutionKey()}' declares @schema '${schema}' on its source.rdb, and a Prisma schema places a model ` + + `in a database schema (\`@@schema\`) only on postgresql; on ${provider} every model lives in the database ` + + `the connection names. Remove the @schema, or use the postgresql provider.`, + ); +} + +/** + * The fields whose column lands in the table: the declaring object's, then each TPH subtype's. A + * `field.object @storage: flattened` holds one column per field of its value object, + * `_`, which is Prisma field ``. + */ +function fieldsByColumn(obj: MetaObject, all: Iterable, strategy: ReturnType["columns"]): Map { + const members = [obj, ...[...all].filter((o) => o !== obj && tphStorageObject(o) === obj)]; + const out = new Map(); + const add = (col: string, field: MetaField, name: string): void => { + if (!out.has(col)) out.set(col, { field, name }); + }; + for (const member of members) { + // ADR-0039: effective fields — inherited columns live on the parent. + for (const field of member.fields()) { + // ADR-0039: resolving — @storage and @objectRef may be inherited via extends. + if (field.subType === FIELD_SUBTYPE_OBJECT && field.attr(FIELD_ATTR_STORAGE) === STORAGE_FLATTENED) { + const value = refTarget(field, FIELD_ATTR_OBJECT_REF); + if (value === undefined || !isMetaObject(value)) continue; + const prefix = `${resolveColumnName(field, strategy)}_`; + for (const nested of value.fields()) add(prefix + resolveColumnName(nested, strategy), nested, field.name + upperFirst(nested.name)); + continue; + } + add(resolveColumnName(field, strategy), field, field.name); + } + } + return out; +} + +/** A column shape the expected schema never produces for this provider: a defect, not a refusal. */ +function unreachableType(provider: PrismaProvider, field: MetaField, what: string): Error { + return new Error(`prisma-schema: the ${provider} expected schema gave field '${field.resolutionKey()}' ${what}.`); +} + +function pgColumnType(t: SqlType, field: MetaField): ColumnType { + switch (t.kind) { + case "text": + return t.maxLength === undefined ? { scalar: "String", list: false } : { scalar: "String", list: false, native: `@db.VarChar(${t.maxLength})` }; + case "integer": + return { scalar: t.bits === 64 ? "BigInt" : "Int", list: false }; + case "real": + return { scalar: "Float", list: false }; + case "real4": + return { scalar: "Float", list: false, native: "@db.Real" }; + case "numeric": + return t.precision === undefined + ? { scalar: "Decimal", list: false, native: "@db.Decimal" } + : { scalar: "Decimal", list: false, native: `@db.Decimal(${t.precision}, ${t.scale ?? 0})` }; + case "boolean": + return { scalar: "Boolean", list: false }; + case "timestamp": + return { scalar: "DateTime", list: false, native: t.withTimezone ? "@db.Timestamptz" : "@db.Timestamp" }; + case "date": + return { scalar: "DateTime", list: false, native: "@db.Date" }; + case "time": + return { scalar: "DateTime", list: false, native: "@db.Time" }; + case "json": + return { scalar: "Json", list: false }; + case "blob": + return { scalar: "Bytes", list: false }; + case "uuid": + return { scalar: "String", list: false, native: "@db.Uuid" }; + case "inet": + return { scalar: "String", list: false, native: "@db.Inet" }; + case "array": { + if (t.element.kind === "array") throw unreachableType("postgresql", field, "a nested array"); + const element = pgColumnType(t.element, field); + return { ...element, list: true }; + } + } +} + +/** SQLite: the column layout of the expected schema (dates and times as TEXT). */ +function sqliteColumnType(t: SqlType, field: MetaField): ColumnType { + // SQLite's expected schema stores an array as TEXT (JSON). + if (t.kind === "array") throw unreachableType("sqlite", field, "an array column"); + switch (field.subType) { + case FIELD_SUBTYPE_BOOLEAN: + return { scalar: "Boolean", list: false }; + case FIELD_SUBTYPE_DOUBLE: + case FIELD_SUBTYPE_FLOAT: + return { scalar: "Float", list: false }; + // The expected schema gives a decimal a NUMERIC column, which is Prisma's DECIMAL (SQLite + // ignores the precision either way). + case FIELD_SUBTYPE_DECIMAL: + return { scalar: "Decimal", list: false }; + // Dates and times are ISO-8601 TEXT, which every port reads. + case FIELD_SUBTYPE_DATE: + case FIELD_SUBTYPE_TIME: + case FIELD_SUBTYPE_TIMESTAMP: + return { scalar: "String", list: false }; + } + switch (t.kind) { + case "integer": + // SQLite's canonical integer is 64-bit; the field says which one the code holds. + return { scalar: field.subType === FIELD_SUBTYPE_INT || field.subType === FIELD_SUBTYPE_ENUM ? "Int" : "BigInt", list: false }; + case "real": + case "real4": + return { scalar: "Float", list: false }; + case "json": + return { scalar: "Json", list: false }; + case "blob": + return { scalar: "Bytes", list: false }; + default: + return { scalar: "String", list: false }; + } +} + +/** MySQL: the column layout the Drizzle generator's MySQL arm writes. */ +function mysqlColumnType(t: SqlType, field: MetaField, needsLength: boolean): ColumnType { + if (t.kind === "array" || t.kind === "json") return { scalar: "Json", list: false }; + const varchar = (n: number): ColumnType => ({ scalar: "String", list: false, native: `@db.VarChar(${n})` }); + switch (field.subType) { + case FIELD_SUBTYPE_UUID: + return varchar(36); + case FIELD_SUBTYPE_INET: + return varchar(45); + case FIELD_SUBTYPE_TIMESTAMP: + return { scalar: "DateTime", list: false, native: "@db.DateTime(3)" }; + case FIELD_SUBTYPE_TIME: + return { scalar: "DateTime", list: false, native: "@db.Time(3)" }; + case FIELD_SUBTYPE_DATE: + return { scalar: "DateTime", list: false, native: "@db.Date" }; + case FIELD_SUBTYPE_FLOAT: + return { scalar: "Float", list: false, native: "@db.Float" }; + case FIELD_SUBTYPE_ENUM: { + if (t.kind === "integer") return { scalar: "Int", list: false }; + // ADR-0039: resolving — @values may be inherited. + const raw = field.attr(FIELD_ATTR_VALUES); + const values = Array.isArray(raw) ? raw.map(String) : []; + return varchar(values.reduce((n, v) => Math.max(n, v.length), 1)); + } + } + switch (t.kind) { + case "text": + if (t.maxLength !== undefined) return varchar(t.maxLength); + return needsLength ? varchar(MYSQL_KEY_VARCHAR_LENGTH) : { scalar: "String", list: false, native: "@db.Text" }; + case "integer": + return { scalar: t.bits === 64 ? "BigInt" : "Int", list: false }; + case "real": + case "real4": + return { scalar: "Float", list: false }; + case "numeric": + if (t.precision === undefined) return { scalar: "Decimal", list: false, native: "@db.Decimal(19, 4)" }; + return { scalar: "Decimal", list: false, native: `@db.Decimal(${t.precision}, ${t.scale ?? 0})` }; + case "boolean": + return { scalar: "Boolean", list: false }; + case "blob": + return { scalar: "Bytes", list: false }; + default: + return { scalar: "String", list: false, native: "@db.Text" }; + } +} + +/** Columns MySQL must give a length to: keys, indexed columns, foreign keys and defaulted columns. */ +function mysqlKeyedColumns(table: TableDescriptor): Set { + const out = new Set(table.primaryKey); + for (const ix of table.indexes) for (const c of ix.columns) out.add(c); + for (const fk of table.foreignKeys) for (const c of fk.columns) out.add(c); + for (const c of table.columns) if (c.default !== undefined) out.add(c.name); + return out; +} + +const CURRENT_TIME_EXPR = /^(now\(\)|current_timestamp)$/i; +const NUMERIC_LITERAL = /^-?\d+(\.\d+)?$/; +/** The default expressions SQLite takes bare; any other needs parentheses. */ +const SQLITE_KEYWORD_DEFAULT = /^current_(timestamp|date|time)$/i; + +/** The `@default(...)` for a column, or undefined when it has none. */ +function defaultAttribute(col: ColumnDescriptor, type: ColumnType, provider: PrismaProvider): string | undefined { + if (col.identity === "increment") return "@default(autoincrement())"; + if (col.identity === "uuid") { + // Postgres gives the column a database default; the other providers fill it in the client, + // which matches a column with no default. + return provider === "postgresql" ? `@default(dbgenerated(${prismaString("gen_random_uuid()")}))` : "@default(uuid())"; + } + const d = col.default; + if (d === undefined) return undefined; + if (d.kind === "expr") { + // MySQL takes the current time only at the column's precision (CURRENT_TIMESTAMP(3) on a + // DATETIME(3)), which is what Prisma's `now()` writes there. + if (provider === "mysql" && type.scalar === "DateTime" && CURRENT_TIME_EXPR.test(d.value)) return "@default(now())"; + // Elsewhere the expression verbatim, never Prisma's `now()`: Prisma writes that as + // CURRENT_TIMESTAMP, and `meta verify --db` compares a default's text. SQLite takes an + // expression default only in parentheses, and stores it without them, so the text matches. + const expr = provider === "sqlite" && !SQLITE_KEYWORD_DEFAULT.test(d.value) ? `(${d.value})` : d.value; + return `@default(dbgenerated(${prismaString(expr)}))`; + } + const v = d.value; + if (type.list) { + if (v === "{}" || v === "[]") return "@default([])"; + return `@default(dbgenerated(${prismaString(`'${v.replace(/'/g, "''")}'`)}))`; + } + switch (type.scalar) { + case "Boolean": + if (v === "true" || v === "false") return `@default(${v})`; + // SQLite's expected schema spells a boolean default as 1 or 0. + if (v === "1" || v === "0") return `@default(${v === "1" ? "true" : "false"})`; + break; + case "Int": + case "BigInt": + case "Float": + case "Decimal": + if (NUMERIC_LITERAL.test(v)) return `@default(${v})`; + break; + case "String": + case "Json": + return `@default(${prismaString(v)})`; + } + // A literal Prisma has no typed spelling for (a date literal, say): the database default, verbatim. + return `@default(dbgenerated(${prismaString(`'${v.replace(/'/g, "''")}'`)}))`; +} + +function indexFieldList(entry: TableEntry, ix: IndexDescriptor): string { + return ix.columns + .map((c, i) => { + const name = entry.fieldName.get(c); + if (name === undefined) throw new Error(`prisma-schema: index '${ix.name}' names a column '${c}' the table has no field for.`); + return ix.orders?.[i] === "desc" ? `${name}(sort: Desc)` : name; + }) + .join(", "); +} + +function refuseIndex(entry: TableEntry, ix: IndexDescriptor, what: string): PrismaSchemaError { + return new PrismaSchemaError( + ERR_PRISMA_UNSUPPORTED_INDEX, + `index '${ix.name}' on '${entry.object.resolutionKey()}' is ${what}, which a Prisma schema cannot declare. ` + + `Create it in a Prisma migration (\`prisma migrate dev --create-only\`, then add the SQL), or drop the ` + + `@expr/@where/@using from the declaration.`, + ); +} + +/** Index attributes: field-level `@unique` for a plain single-column unique index, block attributes otherwise. */ +function indexAttributes(entry: TableEntry, provider: PrismaProvider, fieldAttrs: Map): string[] { + const block: string[] = []; + for (const ix of entry.table.indexes) { + if (ix.expr !== undefined) throw refuseIndex(entry, ix, "an expression index"); + if (ix.where !== undefined) throw refuseIndex(entry, ix, "a partial index"); + const args: string[] = []; + if (ix.using !== undefined && ix.using.toLowerCase() !== "btree") { + const type = provider === "postgresql" ? PRISMA_PG_INDEX_TYPE[ix.using.toLowerCase()] : undefined; + if (type === undefined) throw refuseIndex(entry, ix, `a '${ix.using}' index on ${provider}`); + args.push(`type: ${type}`); + } + const plainSingle = ix.unique && ix.columns.length === 1 && ix.orders?.[0] !== "desc" && args.length === 0; + if (plainSingle) { + const col = ix.columns[0]!; + fieldAttrs.get(col)?.push(`@unique(map: ${prismaString(ix.name)})`); + continue; + } + const all = [`[${indexFieldList(entry, ix)}]`, `map: ${prismaString(ix.name)}`, ...args].join(", "); + block.push(`${ix.unique ? "@@unique" : "@@index"}(${all})`); + } + return block; +} + +function sameColumns(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && [...a].sort().join("\0") === [...b].sort().join("\0"); +} + +/** True when the columns are the table's primary key or one of its unique indexes. */ +function isUniqueKey(table: TableDescriptor, columns: readonly string[]): boolean { + if (sameColumns(table.primaryKey, columns)) return true; + return table.indexes.some((ix) => ix.unique && ix.expr === undefined && ix.where === undefined && sameColumns(ix.columns, columns)); +} + +function pickName(entry: TableEntry, candidates: readonly string[], what: string): string { + for (const c of candidates) { + if (!entry.takenNames.has(c)) { + entry.takenNames.add(c); + return c; + } + } + throw new PrismaSchemaError( + ERR_PRISMA_NAME_COLLISION, + `model '${entry.modelName}' has no free name for ${what}: ${candidates.map((c) => `'${c}'`).join(", ")} are all ` + + `fields already. Rename the field that collides.`, + ); +} + +/** The forward relation field's name: `customerId` gives `customer`. */ +function forwardCandidates(holder: TableEntry, fk: FkDescriptor, target: TableEntry): string[] { + const fields = fk.columns.map((c) => holder.fieldName.get(c) ?? c); + const out: string[] = []; + if (fields.length === 1) { + const f = fields[0]!; + const stripped = f.endsWith("Id") && f.length > 2 ? f.slice(0, -2) : f.endsWith("_id") && f.length > 3 ? f.slice(0, -3) : undefined; + if (stripped !== undefined) out.push(stripped); + } + out.push(`${lowerFirst(target.modelName)}By${fields.map(upperFirst).join("")}`); + return out; +} + +function referentialArgs(fk: FkDescriptor): string { + const onDelete = PRISMA_REFERENTIAL_ACTION[fk.onDelete ?? "no-action"]; + const onUpdate = PRISMA_REFERENTIAL_ACTION[fk.onUpdate ?? "no-action"]; + return `onDelete: ${onDelete}, onUpdate: ${onUpdate}`; +} + +function typeSignature(t: ColumnType | undefined): string { + return t === undefined ? "?" : `${t.scalar}${t.list ? "[]" : ""}${t.native ?? ""}`; +} + +/** + * The model a foreign key references. The descriptor names the referenced table but not its + * schema: a table of that name in the holder's own schema wins, then the one table of that name. + */ +function fkTarget(holder: TableEntry, fk: FkDescriptor, entries: readonly TableEntry[]): TableEntry | undefined { + const named = entries.filter((e) => e.table.name === fk.refTable); + return named.find((e) => e.table.schema === holder.table.schema) ?? (named.length === 1 ? named[0] : undefined); +} + +/** Relation fields on both ends of every foreign key. */ +function addRelations(entries: readonly TableEntry[], provider: PrismaProvider): void { + // A relation needs a name when two relations join the same pair of models, or it joins a model to itself. + const pairCount = new Map(); + const pairKey = (a: string, b: string): string => [a, b].sort().join("\0"); + for (const e of entries) { + for (const fk of e.table.foreignKeys) { + const t = fkTarget(e, fk, entries); + if (t === undefined) continue; + const k = pairKey(e.key, t.key); + pairCount.set(k, (pairCount.get(k) ?? 0) + 1); + } + } + for (const holder of entries) { + for (const fk of holder.table.foreignKeys) { + const target = fkTarget(holder, fk, entries); + if (target === undefined) { + throw new PrismaSchemaError( + ERR_PRISMA_UNMAPPABLE_RELATION, + `foreign key '${fk.name}' on '${holder.object.resolutionKey()}' references '${fk.refTable}', which this ` + + `schema has no model for (it is @unmanaged, or outside the generator's filter). Prisma needs a model at both ` + + `ends of a relation: include that table, or drop the reference.`, + ); + } + if (!isUniqueKey(target.table, fk.refColumns)) { + throw new PrismaSchemaError( + ERR_PRISMA_UNMAPPABLE_RELATION, + `foreign key '${fk.name}' on '${holder.object.resolutionKey()}' references (${fk.refColumns.join(", ")}) of ` + + `'${target.object.resolutionKey()}', which is neither its primary key nor a unique key. Prisma can relate ` + + `only to a unique key: reference the primary key, or declare an identity.secondary on those fields.`, + ); + } + fk.columns.forEach((c, i) => { + const ref = fk.refColumns[i]!; + const a = typeSignature(holder.columnType.get(c)); + const b = typeSignature(target.columnType.get(ref)); + if (a !== b) { + throw new PrismaSchemaError( + ERR_PRISMA_UNMAPPABLE_RELATION, + `foreign key '${fk.name}' on '${holder.object.resolutionKey()}' joins column '${c}' (${a}) to ` + + `'${target.table.name}.${ref}' (${b}); Prisma requires a relation's fields to have the type of the ` + + `fields they reference. Declare the reference field with the referenced field's subtype.`, + ); + } + }); + const self = holder === target; + const named = self || (pairCount.get(pairKey(holder.key, target.key)) ?? 0) > 1; + const relationName = named ? `${prismaString(fk.name)}, ` : ""; + const holderFields = fk.columns.map((c) => holder.fieldName.get(c)!); + const targetFields = fk.refColumns.map((c) => target.fieldName.get(c)!); + const optional = fk.columns.some((c) => holder.table.columns.find((col) => col.name === c)?.nullable === true); + + const forward = pickName(holder, forwardCandidates(holder, fk, target), `the relation of foreign key '${fk.name}'`); + holder.relationFields.push({ + name: forward, + type: target.modelName, + list: false, + optional, + attributes: [ + `@relation(${relationName}fields: [${holderFields.join(", ")}], references: [${targetFields.join(", ")}], ` + + // SQLite has no named foreign keys. + `${referentialArgs(fk)}${provider === "sqlite" ? "" : `, map: ${prismaString(fk.name)}`})`, + ], + }); + + const oneToOne = isUniqueKey(holder.table, fk.columns); + const base = oneToOne ? lowerFirst(holder.modelName) : lowerFirst(pluralize(holder.modelName)); + const back = pickName( + target, + named ? [`${base}By${upperFirst(forward)}`] : [base, `${base}By${upperFirst(forward)}`], + `the back relation of foreign key '${fk.name}'`, + ); + target.relationFields.push({ + name: back, + type: holder.modelName, + list: !oneToOne, + optional: oneToOne, + attributes: named ? [`@relation(${prismaString(fk.name)})`] : [], + }); + } + } +} + +function refuseCheck(entry: TableEntry, check: CheckDescriptor): PrismaSchemaError { + return new PrismaSchemaError( + ERR_PRISMA_UNSUPPORTED_CHECK, + `'${entry.object.resolutionKey()}' declares CHECK constraint '${check.name}' (${check.expression}). A Prisma ` + + `schema cannot declare a CHECK, and SQLite cannot add one to a table Prisma created. Pass \`checks: "omit"\` ` + + `to generate the schema without it (the column then takes any value the Zod schema lets through, and ` + + `\`meta verify --db\` reports the constraint missing), or use the postgresql provider.`, + ); +} + +/** + * The CHECK constraints, in the SQL the provider's database takes. Postgres gets every check the + * expected schema declares, so `meta verify --db` finds them. MySQL gets the enum membership + * checks, which are what the Drizzle MySQL arm writes too: the other checks are Postgres SQL. + */ +function checksOf(entry: TableEntry, provider: PrismaProvider, omit: boolean): PrismaCheckSpec[] { + if (omit) return []; + const out: PrismaCheckSpec[] = []; + for (const check of entry.table.checks) { + if (provider === "sqlite") throw refuseCheck(entry, check); + let expression = check.expression; + if (provider === "mysql") { + const enumColumn = [...entry.fieldByColumn].find( + ([c, { field: f }]) => f.subType === FIELD_SUBTYPE_ENUM && check.name === `${entry.table.name}_${c}_chk`, + ); + if (enumColumn === undefined) continue; + expression = `\`${enumColumn[0]}\`${check.expression.slice(check.expression.indexOf(" IN ("))}`; + } + out.push({ table: entry.table.name, schema: entry.table.schema, name: check.name, expression }); + } + return out; +} + +/** The scalar fields of a model, in column order. */ +function scalarFields(entry: TableEntry, provider: PrismaProvider, fieldAttrs: Map): PrismaFieldSpec[] { + const pkSingle = entry.table.primaryKey.length === 1 ? entry.table.primaryKey[0] : undefined; + return entry.table.columns.map((col) => { + const type = entry.columnType.get(col.name)!; + const name = entry.fieldName.get(col.name)!; + const attrs: string[] = []; + if (col.name === pkSingle) attrs.push("@id"); + const def = defaultAttribute(col, type, provider); + if (def !== undefined) attrs.push(def); + attrs.push(...(fieldAttrs.get(col.name) ?? [])); + if (name !== col.name) attrs.push(`@map(${prismaString(col.name)})`); + if (type.native !== undefined) attrs.push(type.native); + return { name, type: type.scalar, list: type.list, optional: col.nullable && !type.list, attributes: attrs }; + }); +} + +/** + * The loaded model as Prisma models, one per table `meta migrate` would create, plus the CHECK + * constraints a Prisma schema cannot carry. + */ +export function buildPrismaSchema(root: MetaRoot, options: BuildPrismaSchemaOptions): PrismaSchemaSpec { + const { provider } = options; + const matches = options.matches ?? (() => true); + const objects = objectsByKey(root); + + const dialect = migrateDialect(provider); + // MySQL has no view dialect; the set of views verify expects is the same under any of them. + for (const view of buildProjectionViews(root, { dialect: dialect ?? "postgres" })) { + const obj = objects.get(view.fqn); + if (obj !== undefined && matches(obj)) throw refuseView(obj, view); + } + + if (provider !== "postgresql") { + for (const obj of objects.values()) { + // SQLite's expected schema rejects a @schema anywhere in the model, so its check cannot honour the filter. + if ((provider !== "sqlite" && !matches(obj)) || obj.isAbstract || !obj.children().some(isWritableSource)) continue; + // ADR-0039: own — mirrors the expected schema's @unmanaged test on the declared source. + if (obj.ownChildren().find(isWritableSource)?.isUnmanaged) continue; + const schema = resolveTableSchema(obj); + if (schema !== undefined) throw refuseSchema(obj, schema, provider); + } + } + const { snapshot, provenance } = buildExpectedSchemaWithProvenance(root, dialect === undefined ? {} : { dialect }); + const strategy = physicalNamingOf(root).columns; + + const entries: TableEntry[] = []; + const modelNames = new Map(); + for (const table of snapshot.tables) { + const key = qualifiedDbName(table); + const fqn = provenance.get(key); + const object = fqn === undefined ? undefined : objects.get(fqn); + if (object === undefined) throw new Error(`prisma-schema: no object declares table '${key}'.`); + if (!matches(object)) continue; + if (table.primaryKey.length === 0) { + throw new PrismaSchemaError( + ERR_PRISMA_NO_PRIMARY_KEY, + `'${object.resolutionKey()}' has no primary key, and Prisma requires one on every model. Declare an identity.primary.`, + ); + } + const other = modelNames.get(object.name); + if (other !== undefined) { + throw new PrismaSchemaError( + ERR_PRISMA_NAME_COLLISION, + `'${object.resolutionKey()}' and '${other}' would both be Prisma model '${object.name}'; a Prisma schema has one ` + + `namespace. Narrow the generator's filter to one of them, or rename one.`, + ); + } + modelNames.set(object.name, object.resolutionKey()); + + const fieldByColumn = fieldsByColumn(object, objects.values(), strategy); + const entry: TableEntry = { + table, key, object, modelName: object.name, fieldByColumn, + fieldName: new Map(), columnType: new Map(), takenNames: new Set(), relationFields: [], + }; + const keyed = provider === "mysql" ? mysqlKeyedColumns(table) : new Set(); + for (const col of table.columns) { + const mapped = fieldByColumn.get(col.name); + if (mapped === undefined) throw new Error(`prisma-schema: no field of '${object.resolutionKey()}' maps to column '${col.name}'.`); + const { field, name } = mapped; + if (entry.takenNames.has(name)) { + throw new PrismaSchemaError( + ERR_PRISMA_NAME_COLLISION, + `two columns of '${object.resolutionKey()}' would both be Prisma field '${name}' (a TPH subtype redeclares ` + + `a field with another column, or a flattened field.object's column takes the name of a field). Rename one of them.`, + ); + } + entry.takenNames.add(name); + entry.fieldName.set(col.name, name); + const type = + provider === "postgresql" ? pgColumnType(col.sqlType, field) + : provider === "sqlite" ? sqliteColumnType(col.sqlType, field) + : mysqlColumnType(col.sqlType, field, keyed.has(col.name)); + entry.columnType.set(col.name, type); + } + entries.push(entry); + } + + // SQLite: only `INTEGER PRIMARY KEY` is the auto-incrementing rowid, and Prisma writes BIGINT for a + // BigInt. An increment key there is an Int, and so is every column that references it. + if (provider === "sqlite") { + for (const e of entries) { + for (const col of e.table.columns) if (col.identity === "increment") e.columnType.set(col.name, { scalar: "Int", list: false }); + } + for (const e of entries) { + for (const fk of e.table.foreignKeys) { + const target = fkTarget(e, fk, entries); + fk.columns.forEach((c, i) => { + const ref = target?.columnType.get(fk.refColumns[i]!); + if (ref?.scalar === "Int") e.columnType.set(c, { scalar: "Int", list: false }); + }); + } + } + } + + addRelations(entries, provider); + + const nonDefaultSchemas = [...new Set(entries.map((e) => e.table.schema ?? DEFAULT_PG_SCHEMA))].filter((s) => s !== DEFAULT_PG_SCHEMA); + const multiSchema = provider === "postgresql" && nonDefaultSchemas.length > 0; + const models: PrismaModelSpec[] = []; + const checks: PrismaCheckSpec[] = []; + for (const entry of entries) { + const fieldAttrs = new Map(entry.table.columns.map((c) => [c.name, [] as string[]])); + const indexBlock = indexAttributes(entry, provider, fieldAttrs); + const block: string[] = []; + if (entry.table.primaryKey.length > 1) { + block.push(`@@id([${entry.table.primaryKey.map((c) => entry.fieldName.get(c)!).join(", ")}])`); + } + block.push(...indexBlock); + block.push(`@@map(${prismaString(entry.table.name)})`); + if (multiSchema) block.push(`@@schema(${prismaString(entry.table.schema ?? DEFAULT_PG_SCHEMA)})`); + models.push({ + name: entry.modelName, + fields: [...scalarFields(entry, provider, fieldAttrs), ...entry.relationFields], + blockAttributes: block, + }); + checks.push(...checksOf(entry, provider, options.checks === "omit")); + } + const schemas = multiSchema ? [...new Set([DEFAULT_PG_SCHEMA, ...nonDefaultSchemas])].sort() : []; + return { provider, models, schemas, checks }; +} diff --git a/server/typescript/packages/codegen-ts/src/prisma/prisma-errors.ts b/server/typescript/packages/codegen-ts/src/prisma/prisma-errors.ts new file mode 100644 index 000000000..6310071c2 --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/prisma/prisma-errors.ts @@ -0,0 +1,46 @@ +// The prisma-schema generator's own errors. They are generator errors, printed in the message; +// they are not loader codes and are not listed in fixtures/conformance/ERROR-CODES.json. + +/** The provider is not one the generator writes a schema for (postgresql, mysql, sqlite). */ +export const ERR_PRISMA_UNSUPPORTED_PROVIDER = "ERR_PRISMA_UNSUPPORTED_PROVIDER"; +/** A view `meta verify --db` expects (a projection's, a write-through read view, a report's), which Prisma cannot create. */ +export const ERR_PRISMA_READ_ONLY_SOURCE = "ERR_PRISMA_READ_ONLY_SOURCE"; +/** An index Prisma cannot declare: an expression index or a partial index. */ +export const ERR_PRISMA_UNSUPPORTED_INDEX = "ERR_PRISMA_UNSUPPORTED_INDEX"; +/** A `@schema` on a provider other than postgresql, where Prisma has no `@@schema`. */ +export const ERR_PRISMA_UNSUPPORTED_SCHEMA = "ERR_PRISMA_UNSUPPORTED_SCHEMA"; +/** A CHECK constraint on SQLite, which cannot add one to a table Prisma created. */ +export const ERR_PRISMA_UNSUPPORTED_CHECK = "ERR_PRISMA_UNSUPPORTED_CHECK"; +/** A table with no primary key: Prisma requires every model to have one. */ +export const ERR_PRISMA_NO_PRIMARY_KEY = "ERR_PRISMA_NO_PRIMARY_KEY"; +/** A foreign key Prisma cannot model as a relation. */ +export const ERR_PRISMA_UNMAPPABLE_RELATION = "ERR_PRISMA_UNMAPPABLE_RELATION"; +/** Two models, or two fields of one model, that would share a Prisma name. */ +export const ERR_PRISMA_NAME_COLLISION = "ERR_PRISMA_NAME_COLLISION"; + +export const PRISMA_ERROR_CODES = [ + ERR_PRISMA_UNSUPPORTED_PROVIDER, + ERR_PRISMA_READ_ONLY_SOURCE, + ERR_PRISMA_UNSUPPORTED_INDEX, + ERR_PRISMA_UNSUPPORTED_SCHEMA, + ERR_PRISMA_UNSUPPORTED_CHECK, + ERR_PRISMA_NO_PRIMARY_KEY, + ERR_PRISMA_UNMAPPABLE_RELATION, + ERR_PRISMA_NAME_COLLISION, +] as const; +export type PrismaErrorCode = (typeof PRISMA_ERROR_CODES)[number]; + +/** + * Something the Prisma schema cannot express. The generator refuses rather than write a schema + * that would push a database the model does not describe. `message` is `": "`; + * the detail names the node, says why Prisma cannot take it, and says what to change. + */ +export class PrismaSchemaError extends Error { + readonly code: PrismaErrorCode; + + constructor(code: PrismaErrorCode, detail: string) { + super(`${code}: ${detail}`); + this.name = "PrismaSchemaError"; + this.code = code; + } +} diff --git a/server/typescript/packages/codegen-ts/src/prisma/render-prisma-schema.ts b/server/typescript/packages/codegen-ts/src/prisma/render-prisma-schema.ts new file mode 100644 index 000000000..f95d583da --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/prisma/render-prisma-schema.ts @@ -0,0 +1,93 @@ +// The prisma-schema generator's second stage: the spec as text. The layout is what `prisma format` +// writes (columns aligned per model, block attributes after a blank line), so formatting the +// generated file is a no-op and `meta verify --codegen` does not see a formatted file as edited. + +import { + prismaString, + type PrismaCheckSpec, + type PrismaFieldSpec, + type PrismaModelSpec, + type PrismaSchemaSpec, +} from "./build-prisma-schema.js"; + +/** The generator block a schema gets when the options name none. */ +export const DEFAULT_PRISMA_CLIENT_GENERATOR = 'generator client {\n provider = "prisma-client"\n output = "./generated"\n}'; + +/** `key = value` lines with the `=` aligned, as `prisma format` writes a block. */ +function keyValueBlock(header: string, entries: ReadonlyArray): string { + const width = Math.max(...entries.map(([k]) => k.length)); + return [`${header} {`, ...entries.map(([k, v]) => ` ${k.padEnd(width)} = ${v}`), "}"].join("\n"); +} + +/** The datasource block: the provider, and the schemas of a multi-schema Postgres model. */ +export function renderDatasource(spec: PrismaSchemaSpec): string { + const entries: Array = [["provider", prismaString(spec.provider)]]; + if (spec.schemas.length > 0) entries.push(["schemas", `[${spec.schemas.map(prismaString).join(", ")}]`]); + return keyValueBlock("datasource db", entries); +} + +function fieldType(f: PrismaFieldSpec): string { + return `${f.type}${f.list ? "[]" : ""}${f.optional ? "?" : ""}`; +} + +function renderModel(model: PrismaModelSpec): string { + const nameWidth = Math.max(...model.fields.map((f) => f.name.length)); + const typeWidth = Math.max(...model.fields.map((f) => fieldType(f).length)); + const lines = model.fields.map((f) => { + const type = fieldType(f); + if (f.attributes.length === 0) return ` ${f.name.padEnd(nameWidth)} ${type}`; + return ` ${f.name.padEnd(nameWidth)} ${type.padEnd(typeWidth)} ${f.attributes.join(" ")}`; + }); + const block = model.blockAttributes.length === 0 ? [] : ["", ...model.blockAttributes.map((a) => ` ${a}`)]; + return [`model ${model.name} {`, ...lines, ...block, "}"].join("\n"); +} + +/** The models, one block each, in table order. */ +export function renderModels(spec: PrismaSchemaSpec): string { + return spec.models.map(renderModel).join("\n\n"); +} + +export interface RenderPrismaSchemaOptions { + /** Lines written above everything else, as `//` comments. */ + readonly banner: readonly string[]; + /** + * The generator and datasource blocks. `undefined` writes the default generator block and a + * datasource naming the provider; `false` writes neither, for a multi-file schema whose blocks + * live in a file the project owns; a string is written verbatim. + */ + readonly header: string | false | undefined; +} + +export function renderPrismaSchema(spec: PrismaSchemaSpec, options: RenderPrismaSchemaOptions): string { + const parts: string[] = []; + if (options.banner.length > 0) parts.push(options.banner.map((l) => (l === "" ? "//" : `// ${l}`)).join("\n")); + if (options.header === undefined) parts.push(DEFAULT_PRISMA_CLIENT_GENERATOR, renderDatasource(spec)); + else if (options.header !== false && options.header.trim() !== "") parts.push(options.header.trim()); + if (spec.models.length > 0) parts.push(renderModels(spec)); + return `${parts.join("\n\n")}\n`; +} + +function pgTable(check: PrismaCheckSpec): string { + const q = (s: string): string => `"${s.replace(/"/g, '""')}"`; + return check.schema === undefined ? q(check.table) : `${q(check.schema)}.${q(check.table)}`; +} + +/** + * The CHECK constraints as SQL, for `prisma db execute --file`. On Postgres each is dropped if + * present and added again, so the file can run after every `prisma db push`. MySQL has no + * `DROP CONSTRAINT IF EXISTS`, so its file adds each once, after the table is created. + */ +export function renderChecksSql(spec: PrismaSchemaSpec, banner: readonly string[]): string { + const lines: string[] = banner.map((l) => (l === "" ? "--" : `-- ${l}`)); + for (const check of spec.checks) { + if (spec.provider === "postgresql") { + const table = pgTable(check); + const name = `"${check.name.replace(/"/g, '""')}"`; + lines.push(`ALTER TABLE ${table} DROP CONSTRAINT IF EXISTS ${name};`); + lines.push(`ALTER TABLE ${table} ADD CONSTRAINT ${name} CHECK (${check.expression});`); + } else { + lines.push(`ALTER TABLE \`${check.table}\` ADD CONSTRAINT \`${check.name}\` CHECK (${check.expression});`); + } + } + return `${lines.join("\n")}\n`; +} diff --git a/server/typescript/packages/codegen-ts/src/reference-templates.ts b/server/typescript/packages/codegen-ts/src/reference-templates.ts index 257c7ef92..952fb86c1 100644 --- a/server/typescript/packages/codegen-ts/src/reference-templates.ts +++ b/server/typescript/packages/codegen-ts/src/reference-templates.ts @@ -29,6 +29,9 @@ export const REFERENCE_GENERATOR_NAMES = [ // The Cube exporter (FR-044 Plan 4): which entities get a cube, the file layout and the YAML, // with `buildCubeModel` and `renderCubeYaml` handed over beside it. "cube-model", + // The Prisma schema (#479): which objects get a model, the file layout and the blocks above + // the models, with `buildPrismaSchema` and the renderers handed over beside it. + "prisma-schema", // The stored-procedure / table-function wrapper and the LLM-call trace helper. "callable", "trace-helper", // The publisher's shared-model artifact: which source files may contribute and which version diff --git a/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts b/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts new file mode 100644 index 000000000..a673e333c --- /dev/null +++ b/server/typescript/packages/codegen-ts/src/reference/prisma-schema.ts @@ -0,0 +1,164 @@ +// REFERENCE TEMPLATE — copy this into your repo (e.g. codegen/generators/prisma-schema.ts) and own it. +// Then import it LOCALLY in metaobjects.config.ts: +// import { prismaSchemaFile } from "./codegen/generators/prisma-schema.js"; +// +// RUNTIME: this file executes under whatever runs `meta gen`, and the published CLI's +// shebang is `#!/usr/bin/env node` — so it runs under NODE even in a Bun project. Do not +// reach for `Bun.*` globals here; they are undefined and take the whole run down with +// `Bun is not defined`. Use `node:` builtins instead. +// targets: Prisma (prisma.io). The schema imports nothing and needs no MetaObjects package +// at runtime; Prisma reads it as its own. The postgresql, mysql and sqlite +// providers (`provider` option, default from the config's `dialect`). +// use-when: the project keeps Prisma as its ORM and wants each field declared once, in the +// model. Pair it with `entity` under `orm: "prisma"`, which writes the types and +// Zod schemas with no Drizzle table. +// emits: /schema.prisma, one model per table `meta migrate` would create; and +// /schema.checks.sql, the CHECK constraints a Prisma schema cannot declare +// (enum membership, numeric and length validators), when the model implies any. +// Apply it after `prisma db push` or a migration: `prisma db execute --file`. +// customize: which objects get a model (the `filter` option, ANDed with "has a table"), +// where the files land (`fileName`, `prismaChecksFileName`), and the generator and +// datasource blocks (`header`; `false` for a multi-file schema whose blocks live +// in a file you own). Copied beside it, so yours too: `buildPrismaSchema`, which +// maps the model's tables to Prisma models and raises every refusal +// (`PrismaSchemaError`), and the renderers in `render-prisma-schema`. Change the +// type or name mapping only if you mean to stop agreeing with `meta verify --db`, +// which compares the database against the same expected schema. +// composes-with: `entity` (the types and Zod schemas the application validates with). +// +// What the file holds never depends on the run: the universe is the whole loaded model narrowed +// by this generator's own `filter`. The run's selection decides only WHETHER it is written: a run +// that selects no table-backed object writes nothing. +// +// A hand edit is discarded (`handEdits: "overwrite"`): Prisma reads the whole file as the schema, +// so an edit kept by a merge would change the database without the model knowing, and +// `meta verify --codegen` fails on one. Declare the field in the model, or change this file. +// +// Everything below imports the engine from `@metaobjectsdev/codegen-ts` (the generator base, the +// generated-file header and the source walk), the object type from `@metaobjectsdev/metadata`, +// and the Prisma mapping from `../prisma/`. `meta eject` copies those modules into your project, +// under `codegen/templates/`, and your copy of this file imports them from there: they are yours +// too. The mapping reads the expected schema from `@metaobjectsdev/migrate-ts`. + +import { + GENERATED_HEADER, + hasWritableRdbSource, + type EmittedFile, + type GenContext, + type Generator, +} from "@metaobjectsdev/codegen-ts/engine"; +import { buildPrismaSchema, PRISMA_PROVIDERS, type PrismaProvider } from "../prisma/build-prisma-schema.js"; +import { ERR_PRISMA_UNSUPPORTED_PROVIDER, PrismaSchemaError } from "../prisma/prisma-errors.js"; +import { renderChecksSql, renderPrismaSchema } from "../prisma/render-prisma-schema.js"; +import type { MetaObject } from "@metaobjectsdev/metadata"; + +export interface PrismaSchemaFileOptions { + /** The Prisma datasource provider. Default: from the codegen config's `dialect`. */ + readonly provider?: PrismaProvider; + /** The schema file, relative to the target's outDir. Default `schema.prisma`. */ + readonly fileName?: string; + /** + * The generator and datasource blocks. Default: a `prisma-client` generator writing to + * `./generated` and a datasource naming the provider (Prisma 7 takes the URL from + * `prisma.config.ts`). A string is written verbatim. `false` writes neither, for a multi-file + * schema (`prisma/schema/`) whose blocks live in a file the project owns. + */ + readonly header?: string | false; + /** + * `"file"` (the default) writes the CHECK constraints to `.checks.sql`. `"omit"` leaves + * them out, which is the only way to take a model with checks to SQLite. + */ + readonly checks?: "file" | "omit"; + /** Which objects the schema covers, ANDed with "has a table". */ + readonly filter?: (obj: MetaObject) => boolean; + /** Named output target: the files land at its outDir. */ + readonly target?: string; +} + +/** The generator's name, as diagnostics and the catalog print it. */ +const GENERATOR_NAME = "prisma-schema"; + +/** The schema file's name when the options name none. */ +const DEFAULT_SCHEMA_FILE = "schema.prisma"; + +const DIALECT_PROVIDER: Record = { + postgres: "postgresql", + mysql: "mysql", + sqlite: "sqlite", +}; + +/** `schema.prisma` gives `schema.checks.sql`. */ +function prismaChecksFileName(schemaFile: string): string { + return `${schemaFile.replace(/\.prisma$/, "")}.checks.sql`; +} + +function resolveProvider(ctx: GenContext, options: PrismaSchemaFileOptions): PrismaProvider { + const provider = options.provider ?? DIALECT_PROVIDER[ctx.config.dialect]; + if (provider === undefined || !(PRISMA_PROVIDERS as readonly string[]).includes(provider)) { + const source = options.provider !== undefined ? `provider '${String(options.provider)}'` : `dialect '${ctx.config.dialect}'`; + throw new PrismaSchemaError( + ERR_PRISMA_UNSUPPORTED_PROVIDER, + `prisma-schema writes a schema for the postgresql, mysql and sqlite providers, and the ${source} is none of them. Pass the provider of the database Prisma connects to: prismaSchemaFile({ provider: "postgresql" }).`, + ); + } + return provider; +} + +/** The comment lines at the top of the schema. */ +function banner(checksFile: string | undefined): string[] { + const lines = [ + `${GENERATED_HEADER}. meta gen rewrites this file and discards hand edits; meta verify --codegen fails when it`, + "differs from the model. Declare a field in the MetaObjects model, not here.", + ]; + if (checksFile !== undefined) { + lines.push(`After \`prisma db push\` or a migration, apply ${checksFile}, beside this file: prisma db execute --file `); + } + return lines; +} + +function generatePrismaFiles(ctx: GenContext, options: PrismaSchemaFileOptions): EmittedFile[] { + if (!ctx.entities.some((o) => ctx.matches(o) && hasWritableRdbSource(o))) return []; + const provider = resolveProvider(ctx, options); + const spec = buildPrismaSchema(ctx.loadedRoot, { + provider, + // The generator's own filter, never the run's selection: see the header. + matches: ctx.matches, + checks: options.checks ?? "file", + }); + const fileName = options.fileName ?? DEFAULT_SCHEMA_FILE; + const checksFile = spec.checks.length > 0 ? prismaChecksFileName(fileName) : undefined; + const files: EmittedFile[] = [ + { path: fileName, content: renderPrismaSchema(spec, { banner: banner(checksFile), header: options.header }) }, + ]; + if (checksFile !== undefined) { + files.push({ + path: checksFile, + content: renderChecksSql(spec, [ + `${GENERATED_HEADER}. The CHECK constraints ${fileName} cannot declare.`, + "Apply after `prisma db push` or a migration: prisma db execute --file ", + ]), + }); + } + return files; +} + +/** + * The prisma-schema generator: one Prisma schema for the model's tables, plus a companion SQL + * file for the CHECK constraints when the model implies any. Throws `PrismaSchemaError` for what + * a Prisma schema cannot express. + */ +export function prismaSchemaFile(options: PrismaSchemaFileOptions = {}): Generator { + const fileName = options.fileName ?? DEFAULT_SCHEMA_FILE; + const checksFile = prismaChecksFileName(fileName); + const generator: Generator = { + name: GENERATOR_NAME, + generate: (ctx) => generatePrismaFiles(ctx, options), + handEdits: "overwrite", + // The checks file is written only while the model implies a CHECK; when the last one goes, + // the file it left behind is removed (when it is still what was written). + orphanPolicy: { owns: (rel) => rel === checksFile || rel === fileName }, + }; + if (options.filter !== undefined) generator.filter = options.filter; + if (options.target !== undefined) generator.target = options.target; + return generator; +} diff --git a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts index 76897aaef..89a3c44fc 100644 --- a/server/typescript/packages/codegen-ts/test/generator-registry.test.ts +++ b/server/typescript/packages/codegen-ts/test/generator-registry.test.ts @@ -16,6 +16,7 @@ import { REFERENCE_GENERATOR_NAMES } from "../src/reference-templates.js"; const EXPECTED_NATIVE = [ "entity", "queries", + "prisma-schema", "callable", "routes", "routes-hono", diff --git a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts index f6ecdde5b..cd3fdc403 100644 --- a/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts +++ b/server/typescript/packages/codegen-ts/test/metaobjects-config.test.ts @@ -2,8 +2,10 @@ import { describe, test, expect, expectTypeOf } from "bun:test"; import { guideUrl } from "@metaobjectsdev/metadata/release-docs"; // The guide links name the tag of the running release (a prerelease keeps main). const BLOB = guideUrl(""); -import { defineConfig, normalizeConfig, resolveTargets, DEFAULT_TARGET_NAME, type MetaobjectsGenConfig, type ResolvedGenConfig, type Dialect, type ExtStyle } from "../src/metaobjects-config.js"; +import { defineConfig, normalizeConfig, resolveTargets, DEFAULT_TARGET_NAME, DRIZZLE_BOUND_GENERATORS, type MetaobjectsGenConfig, type ResolvedGenConfig, type Dialect, type ExtStyle } from "../src/metaobjects-config.js"; import type { Generator } from "../src/generator.js"; +import { generatorRegistry } from "../src/generator-registry.js"; +import { callableFile } from "../src/generators/callable-file.js"; describe("resolveTargets", () => { const base = { outDir: "db/gen", extStyle: "none" as const, dbImport: "../index", dialect: "sqlite" as const, generators: [] }; @@ -134,3 +136,41 @@ describe("columnNamingStrategy is refused (ADR-0058)", () => { }); }); + +// #479 — `orm: "prisma"`: the schema is schema.prisma, so no target carries Drizzle bindings and +// the generators that write Drizzle code are refused. +describe("orm: prisma", () => { + const base = { outDir: "db/gen", dialect: "postgres" as const, generators: [] }; + + test("every target is contract-only by default; drizzle keeps runtime on", () => { + const prisma = resolveTargets({ ...base, orm: "prisma", targets: { web: { outDir: "web/gen" } } }); + expect(prisma[DEFAULT_TARGET_NAME]!.runtime).toBe(false); + expect(prisma.web!.runtime).toBe(false); + const drizzle = resolveTargets({ ...base, orm: "drizzle", targets: { web: { outDir: "web/gen" } } }); + expect(drizzle[DEFAULT_TARGET_NAME]!.runtime).toBe(true); + expect(resolveTargets(base)[DEFAULT_TARGET_NAME]!.runtime).toBe(true); + }); + + test("a target that asks for runtime bindings is refused", () => { + expect(() => + normalizeConfig({ ...base, orm: "prisma", targets: { server: { outDir: "s", runtime: true } } }), + ).toThrow('target "server" sets runtime: true'); + }); + + test("the generators that write Drizzle code are refused, by name and as factories", () => { + expect(() => normalizeConfig({ ...base, orm: "prisma", generators: ["entity", "queries", "routes"] })).toThrow( + 'orm: "prisma" refuses "queries", "routes"', + ); + expect(() => normalizeConfig({ ...base, orm: "prisma", generators: [callableFile()] })).toThrow('refuses "callable"'); + const ok = normalizeConfig({ ...base, orm: "prisma", generators: ["entity", "prisma-schema", "barrel"] }); + expect(ok.generators.map((g) => g.name)).toContain("prisma-schema"); + }); + + test("an orm that is not one is refused", () => { + expect(() => normalizeConfig({ ...base, orm: "typeorm" as "prisma" })).toThrow('orm "typeorm" is not one of'); + }); + + test("every Drizzle-bound name is a registered generator", () => { + for (const n of DRIZZLE_BOUND_GENERATORS) expect(generatorRegistry[n]).toBeDefined(); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/prisma/fixtures/shop/metaobjects/meta.shop.json b/server/typescript/packages/codegen-ts/test/prisma/fixtures/shop/metaobjects/meta.shop.json new file mode 100644 index 000000000..9bf486f20 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/fixtures/shop/metaobjects/meta.shop.json @@ -0,0 +1,57 @@ +{ "metadata.root": { "package": "acme::shop", "children": [ + { "object.entity": { "name": "Customer", "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "email", "@required": true, "@maxLength": 200 } }, + { "field.string": { "name": "displayName", "@column": "display_name", "children": [ + { "validator.length": { "@min": 1, "@max": 80 } } + ]}}, + { "field.enum": { "name": "tier", "@required": true, "@values": ["FREE", "PRO"], "@default": "FREE" } }, + { "field.boolean": { "name": "active", "@required": true, "@default": true } }, + { "field.double": { "name": "score" } }, + { "field.timestamp": { "name": "createdAt", "@required": true, "@default": "now" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.secondary": { "name": "byEmail", "@fields": ["email"] } } + ]}}, + { "object.entity": { "name": "Employee", "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "name", "@required": true } }, + { "field.long": { "name": "managerId" } }, + { "field.long": { "name": "mentorId" } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }, + { "identity.reference": { "name": "manager", "@fields": ["managerId"], "@references": "Employee", "@onDelete": "set-null" } }, + { "identity.reference": { "name": "mentor", "@fields": ["mentorId"], "@references": "Employee" } } + ]}}, + { "object.entity": { "name": "Order", "children": [ + { "source.rdb": {} }, + { "field.uuid": { "name": "id" } }, + { "field.long": { "name": "customerId", "@required": true } }, + { "field.decimal": { "name": "total", "@required": true, "@precision": 10, "@scale": 2, "children": [ + { "validator.numeric": { "@min": 0 } } + ]}}, + { "field.date": { "name": "placedOn" } }, + { "field.time": { "name": "slot" } }, + { "field.string": { "name": "meta", "@dbColumnType": "jsonb" } }, + { "field.string": { "name": "notes", "isArray": true } }, + { "field.int": { "name": "qty", "@required": true, "@default": 1 } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "uuid" } }, + { "identity.reference": { "name": "customer", "@fields": ["customerId"], "@references": "Customer", "@onDelete": "cascade" } }, + { "index.lookup": { "name": "byPlaced", "@fields": ["customerId", "placedOn"], "@orders": ["asc", "desc"] } } + ]}}, + { "object.entity": { "name": "Tag", "children": [ + { "source.rdb": {} }, + { "field.long": { "name": "id" } }, + { "field.string": { "name": "label", "@required": true, "@maxLength": 40 } }, + { "identity.primary": { "name": "pk", "@fields": ["id"], "@generation": "increment" } }, + { "relationship.association": { "name": "orders", "@cardinality": "many", "@objectRef": "Order", "@through": "OrderTag" } } + ]}}, + { "object.entity": { "name": "OrderTag", "children": [ + { "source.rdb": {} }, + { "field.uuid": { "name": "orderId", "@required": true } }, + { "field.long": { "name": "tagId", "@required": true } }, + { "identity.primary": { "name": "pk", "@fields": ["orderId", "tagId"] } }, + { "identity.reference": { "name": "order", "@fields": ["orderId"], "@references": "Order", "@onDelete": "cascade" } }, + { "identity.reference": { "name": "tag", "@fields": ["tagId"], "@references": "Tag", "@onDelete": "cascade" } } + ]}} +]}} diff --git a/server/typescript/packages/codegen-ts/test/prisma/golden/shop.mysql.checks.sql b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.mysql.checks.sql new file mode 100644 index 000000000..692efcbd0 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.mysql.checks.sql @@ -0,0 +1,3 @@ +-- @generated by @metaobjectsdev/codegen-ts. The CHECK constraints schema.prisma cannot declare. +-- Apply after `prisma db push` or a migration: prisma db execute --file +ALTER TABLE `customers` ADD CONSTRAINT `customers_tier_chk` CHECK (`tier` IN ('FREE', 'PRO')); diff --git a/server/typescript/packages/codegen-ts/test/prisma/golden/shop.mysql.prisma b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.mysql.prisma new file mode 100644 index 000000000..f247bcf6c --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.mysql.prisma @@ -0,0 +1,72 @@ +// @generated by @metaobjectsdev/codegen-ts. meta gen rewrites this file and discards hand edits; meta verify --codegen fails when it +// differs from the model. Declare a field in the MetaObjects model, not here. +// After `prisma db push` or a migration, apply schema.checks.sql, beside this file: prisma db execute --file + +generator client { + provider = "prisma-client" + output = "./generated" +} + +datasource db { + provider = "mysql" +} + +model Customer { + id BigInt @id @default(autoincrement()) + email String @unique(map: "byEmail") @db.VarChar(200) + displayName String? @map("display_name") @db.Text + tier String @default("FREE") @db.VarChar(4) + active Boolean @default(true) + score Float? + createdAt DateTime @default(now()) @map("created_at") @db.DateTime(3) + orders Order[] + + @@map("customers") +} + +model Employee { + id BigInt @id @default(autoincrement()) + name String @db.Text + managerId BigInt? @map("manager_id") + mentorId BigInt? @map("mentor_id") + manager Employee? @relation("employees_manager_id_fk", fields: [managerId], references: [id], onDelete: SetNull, onUpdate: NoAction, map: "employees_manager_id_fk") + employeesByManager Employee[] @relation("employees_manager_id_fk") + mentor Employee? @relation("employees_mentor_id_fk", fields: [mentorId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "employees_mentor_id_fk") + employeesByMentor Employee[] @relation("employees_mentor_id_fk") + + @@map("employees") +} + +model Order { + id String @id @default(uuid()) @db.VarChar(36) + customerId BigInt @map("customer_id") + total Decimal @db.Decimal(10, 2) + placedOn DateTime? @map("placed_on") @db.Date + slot DateTime? @db.Time(3) + meta Json? + notes Json? + qty Int @default(1) + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "orders_customer_id_fk") + orderTags OrderTag[] + + @@index([customerId, placedOn(sort: Desc)], map: "byPlaced") + @@map("orders") +} + +model Tag { + id BigInt @id @default(autoincrement()) + label String @db.VarChar(40) + orderTags OrderTag[] + + @@map("tags") +} + +model OrderTag { + orderId String @map("order_id") @db.VarChar(36) + tagId BigInt @map("tag_id") + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "order_tags_order_id_fk") + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "order_tags_tag_id_fk") + + @@id([orderId, tagId]) + @@map("order_tags") +} diff --git a/server/typescript/packages/codegen-ts/test/prisma/golden/shop.postgresql.checks.sql b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.postgresql.checks.sql new file mode 100644 index 000000000..b6baa6e8b --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.postgresql.checks.sql @@ -0,0 +1,8 @@ +-- @generated by @metaobjectsdev/codegen-ts. The CHECK constraints schema.prisma cannot declare. +-- Apply after `prisma db push` or a migration: prisma db execute --file +ALTER TABLE "customers" DROP CONSTRAINT IF EXISTS "customers_display_name_length_chk"; +ALTER TABLE "customers" ADD CONSTRAINT "customers_display_name_length_chk" CHECK (length("display_name") >= 1 AND length("display_name") <= 80); +ALTER TABLE "customers" DROP CONSTRAINT IF EXISTS "customers_tier_chk"; +ALTER TABLE "customers" ADD CONSTRAINT "customers_tier_chk" CHECK ("tier" IN ('FREE', 'PRO')); +ALTER TABLE "orders" DROP CONSTRAINT IF EXISTS "orders_total_numeric_chk"; +ALTER TABLE "orders" ADD CONSTRAINT "orders_total_numeric_chk" CHECK ("total" >= 0); diff --git a/server/typescript/packages/codegen-ts/test/prisma/golden/shop.postgresql.prisma b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.postgresql.prisma new file mode 100644 index 000000000..ed7807d40 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.postgresql.prisma @@ -0,0 +1,72 @@ +// @generated by @metaobjectsdev/codegen-ts. meta gen rewrites this file and discards hand edits; meta verify --codegen fails when it +// differs from the model. Declare a field in the MetaObjects model, not here. +// After `prisma db push` or a migration, apply schema.checks.sql, beside this file: prisma db execute --file + +generator client { + provider = "prisma-client" + output = "./generated" +} + +datasource db { + provider = "postgresql" +} + +model Customer { + id BigInt @id @default(autoincrement()) + email String @unique(map: "byEmail") @db.VarChar(200) + displayName String? @map("display_name") + tier String @default("FREE") + active Boolean @default(true) + score Float? + createdAt DateTime @default(dbgenerated("now()")) @map("created_at") @db.Timestamptz + orders Order[] + + @@map("customers") +} + +model Employee { + id BigInt @id @default(autoincrement()) + name String + managerId BigInt? @map("manager_id") + mentorId BigInt? @map("mentor_id") + manager Employee? @relation("employees_manager_id_fk", fields: [managerId], references: [id], onDelete: SetNull, onUpdate: NoAction, map: "employees_manager_id_fk") + employeesByManager Employee[] @relation("employees_manager_id_fk") + mentor Employee? @relation("employees_mentor_id_fk", fields: [mentorId], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "employees_mentor_id_fk") + employeesByMentor Employee[] @relation("employees_mentor_id_fk") + + @@map("employees") +} + +model Order { + id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid + customerId BigInt @map("customer_id") + total Decimal @db.Decimal(10, 2) + placedOn DateTime? @map("placed_on") @db.Date + slot DateTime? @db.Time + meta Json? + notes String[] + qty Int @default(1) + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "orders_customer_id_fk") + orderTags OrderTag[] + + @@index([customerId, placedOn(sort: Desc)], map: "byPlaced") + @@map("orders") +} + +model Tag { + id BigInt @id @default(autoincrement()) + label String @db.VarChar(40) + orderTags OrderTag[] + + @@map("tags") +} + +model OrderTag { + orderId String @map("order_id") @db.Uuid + tagId BigInt @map("tag_id") + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "order_tags_order_id_fk") + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "order_tags_tag_id_fk") + + @@id([orderId, tagId]) + @@map("order_tags") +} diff --git a/server/typescript/packages/codegen-ts/test/prisma/golden/shop.sqlite.prisma b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.sqlite.prisma new file mode 100644 index 000000000..c088364c0 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/golden/shop.sqlite.prisma @@ -0,0 +1,71 @@ +// @generated by @metaobjectsdev/codegen-ts. meta gen rewrites this file and discards hand edits; meta verify --codegen fails when it +// differs from the model. Declare a field in the MetaObjects model, not here. + +generator client { + provider = "prisma-client" + output = "./generated" +} + +datasource db { + provider = "sqlite" +} + +model Customer { + id Int @id @default(autoincrement()) + email String @unique(map: "byEmail") + displayName String? @map("display_name") + tier String @default("FREE") + active Boolean @default(true) + score Float? + createdAt String @default(dbgenerated("(strftime('%Y-%m-%dT%H:%M:%fZ','now'))")) @map("created_at") + orders Order[] + + @@map("customers") +} + +model Employee { + id Int @id @default(autoincrement()) + name String + managerId Int? @map("manager_id") + mentorId Int? @map("mentor_id") + manager Employee? @relation("employees_manager_id_fk", fields: [managerId], references: [id], onDelete: SetNull, onUpdate: NoAction) + employeesByManager Employee[] @relation("employees_manager_id_fk") + mentor Employee? @relation("employees_mentor_id_fk", fields: [mentorId], references: [id], onDelete: NoAction, onUpdate: NoAction) + employeesByMentor Employee[] @relation("employees_mentor_id_fk") + + @@map("employees") +} + +model Order { + id String @id @default(uuid()) + customerId Int @map("customer_id") + total Decimal + placedOn String? @map("placed_on") + slot String? + meta Json? + notes String? + qty Int @default(1) + customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade, onUpdate: NoAction) + orderTags OrderTag[] + + @@index([customerId, placedOn(sort: Desc)], map: "byPlaced") + @@map("orders") +} + +model Tag { + id Int @id @default(autoincrement()) + label String + orderTags OrderTag[] + + @@map("tags") +} + +model OrderTag { + orderId String @map("order_id") + tagId Int @map("tag_id") + order Order @relation(fields: [orderId], references: [id], onDelete: Cascade, onUpdate: NoAction) + tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade, onUpdate: NoAction) + + @@id([orderId, tagId]) + @@map("order_tags") +} diff --git a/server/typescript/packages/codegen-ts/test/prisma/prisma-schema.test.ts b/server/typescript/packages/codegen-ts/test/prisma/prisma-schema.test.ts new file mode 100644 index 000000000..9f71701c8 --- /dev/null +++ b/server/typescript/packages/codegen-ts/test/prisma/prisma-schema.test.ts @@ -0,0 +1,387 @@ +// #479 — the prisma-schema generator: the model's tables as a Prisma schema, with the CHECK +// constraints a Prisma schema cannot declare in a companion SQL file. The golden files are the +// generator's output for the shop fixture under each provider; `prisma validate` and the +// `prisma db push` + `meta verify --db` round trip run them in the cli package's integration +// tests. Refresh the goldens with UPDATE_GOLDEN=1. + +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { InMemoryStringSource, MetaDataLoader, type MetaRoot } from "@metaobjectsdev/metadata"; +import { defineConfig, runGen, type Generator } from "../../src/index.js"; +import { prismaSchemaFile, type PrismaSchemaFileOptions } from "../../src/generators/prisma-schema-file.js"; +import { buildPrismaSchema } from "../../src/prisma/build-prisma-schema.js"; +import { + ERR_PRISMA_NAME_COLLISION, + ERR_PRISMA_READ_ONLY_SOURCE, + ERR_PRISMA_UNMAPPABLE_RELATION, + ERR_PRISMA_UNSUPPORTED_CHECK, + ERR_PRISMA_UNSUPPORTED_INDEX, + ERR_PRISMA_UNSUPPORTED_PROVIDER, + ERR_PRISMA_UNSUPPORTED_SCHEMA, + ERR_PRISMA_NO_PRIMARY_KEY, + PrismaSchemaError, +} from "../../src/prisma/prisma-errors.js"; + +const UPDATE = process.env.UPDATE_GOLDEN === "1"; +const SHOP = join(import.meta.dir, "fixtures", "shop", "metaobjects", "meta.shop.json"); +const GOLDEN_DIR = join(import.meta.dir, "golden"); + +type Json = Record; + +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(join(tmpdir(), "prisma-schema-")); +}); +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +async function loadJson(text: string): Promise { + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(text)]); + expect(errors.map((e) => e.message)).toEqual([]); + return root; +} + +const loadShop = (): Promise => loadJson(readFileSync(SHOP, "utf8")); + +function model(children: Json[]): string { + return JSON.stringify({ "metadata.root": { package: "acme::shop", children } }); +} + +function entity(name: string, children: Json[], extra: Json = {}): Json { + return { "object.entity": { name, ...extra, children } }; +} + +const longId: Json = { "field.long": { name: "id" } }; +const pk: Json = { "identity.primary": { name: "pk", "@fields": ["id"], "@generation": "increment" } }; + +/** Runs the generators into `tmp`; returns the files written, by path, without the gen state. */ +async function gen(root: MetaRoot, generators: Generator[], dialect: "postgres" | "mysql" | "sqlite" = "postgres", entities?: string[]) { + await runGen({ + config: defineConfig({ outDir: tmp, dialect, generators }), + metadata: root, + projectRoot: tmp, + ...(entities !== undefined ? { entities } : {}), + }); + const out = new Map(); + for (const name of ["schema.prisma", "schema.checks.sql", "models.prisma", "models.checks.sql"]) { + const p = join(tmp, name); + if (existsSync(p)) out.set(name, readFileSync(p, "utf8")); + } + return out; +} + +function expectGolden(name: string, actual: string): void { + const path = join(GOLDEN_DIR, name); + if (UPDATE) { + mkdirSync(GOLDEN_DIR, { recursive: true }); + writeFileSync(path, actual); + } + expect(actual).toBe(readFileSync(path, "utf8")); +} + +function refusal(fn: () => unknown): PrismaSchemaError { + try { + fn(); + } catch (e) { + if (e instanceof PrismaSchemaError) return e; + throw e; + } + throw new Error("expected a PrismaSchemaError"); +} + +describe("prisma-schema — golden output for the shop fixture", () => { + const CASES: ReadonlyArray<{ dialect: "postgres" | "mysql" | "sqlite"; opts: PrismaSchemaFileOptions; golden: string; checks: boolean }> = [ + { dialect: "postgres", opts: {}, golden: "shop.postgresql", checks: true }, + { dialect: "mysql", opts: {}, golden: "shop.mysql", checks: true }, + { dialect: "sqlite", opts: { checks: "omit" }, golden: "shop.sqlite", checks: false }, + ]; + for (const c of CASES) { + test(`${c.golden}: the provider comes from the config's dialect`, async () => { + const files = await gen(await loadShop(), [prismaSchemaFile(c.opts)], c.dialect); + expectGolden(`${c.golden}.prisma`, files.get("schema.prisma") ?? ""); + if (c.checks) expectGolden(`${c.golden}.checks.sql`, files.get("schema.checks.sql") ?? ""); + else expect(files.has("schema.checks.sql")).toBe(false); + }); + } +}); + +describe("prisma-schema — what the schema says", () => { + test("names: entity and field names, with @@map and @map onto the physical names", async () => { + const spec = buildPrismaSchema(await loadShop(), { provider: "postgresql" }); + const customer = spec.models.find((m) => m.name === "Customer")!; + expect(customer.blockAttributes).toContain('@@map("customers")'); + const displayName = customer.fields.find((f) => f.name === "displayName")!; + expect(displayName.attributes).toContain('@map("display_name")'); + expect(displayName.optional).toBe(true); + }); + + test("an enum is a String with its membership in the checks file, never a Prisma enum", async () => { + const spec = buildPrismaSchema(await loadShop(), { provider: "postgresql" }); + const tier = spec.models.find((m) => m.name === "Customer")!.fields.find((f) => f.name === "tier")!; + expect(tier.type).toBe("String"); + expect(spec.checks.map((c) => c.name)).toContain("customers_tier_chk"); + }); + + test("a self relation is named, so its two ends pair", async () => { + const spec = buildPrismaSchema(await loadShop(), { provider: "postgresql" }); + const employee = spec.models.find((m) => m.name === "Employee")!; + const manager = employee.fields.find((f) => f.name === "manager")!; + expect(manager.attributes[0]).toStartWith('@relation("employees_manager_id_fk", '); + const back = employee.fields.find((f) => f.name === "employeesByManager")!; + expect(back).toMatchObject({ type: "Employee", list: true, attributes: ['@relation("employees_manager_id_fk")'] }); + }); + + test("a one-to-one reference gets an optional singular back relation", async () => { + const root = await loadJson(model([ + entity("User", [{ "source.rdb": {} }, longId, pk]), + entity("Profile", [ + { "source.rdb": {} }, + longId, + { "field.long": { name: "userId", "@required": true } }, + pk, + { "identity.secondary": { name: "byUser", "@fields": ["userId"] } }, + { "identity.reference": { name: "user", "@fields": ["userId"], "@references": "User" } }, + ]), + ])); + const user = buildPrismaSchema(root, { provider: "postgresql" }).models.find((m) => m.name === "User")!; + expect(user.fields.find((f) => f.name === "profile")).toMatchObject({ type: "Profile", list: false, optional: true }); + }); + + test("sqlite: an expression default is in parentheses, which SQLite requires and does not store", async () => { + const spec = buildPrismaSchema(await loadShop(), { provider: "sqlite", checks: "omit" }); + const createdAt = spec.models.find((m) => m.name === "Customer")!.fields.find((f) => f.name === "createdAt")!; + expect(createdAt.attributes).toContain(`@default(dbgenerated("(strftime('%Y-%m-%dT%H:%M:%fZ','now'))"))`); + const pgCreatedAt = buildPrismaSchema(await loadShop(), { provider: "postgresql" }) + .models.find((m) => m.name === "Customer")!.fields.find((f) => f.name === "createdAt")!; + expect(pgCreatedAt.attributes).toContain('@default(dbgenerated("now()"))'); + }); + + test("an @unmanaged object is not a model", async () => { + const root = await loadJson(model([ + entity("Legacy", [{ "source.rdb": { "@unmanaged": true } }, longId, pk]), + entity("Fresh", [{ "source.rdb": {} }, longId, pk]), + ])); + expect(buildPrismaSchema(root, { provider: "postgresql" }).models.map((m) => m.name)).toEqual(["Fresh"]); + }); + + test("a non-default Postgres schema turns on multi-schema", async () => { + const root = await loadJson(model([ + entity("Audit", [{ "source.rdb": { "@schema": "audit" } }, longId, pk]), + entity("Fresh", [{ "source.rdb": {} }, longId, pk]), + ])); + const spec = buildPrismaSchema(root, { provider: "postgresql" }); + expect(spec.schemas).toEqual(["audit", "public"]); + expect(spec.models.find((m) => m.name === "Audit")!.blockAttributes).toContain('@@schema("audit")'); + }); + + test("a flattened field.object is one field per column of its value object", async () => { + const root = await loadJson(model([ + { "object.value": { name: "Address", children: [ + { "field.string": { name: "street", "@required": true } }, + { "field.string": { name: "postalCode", "@maxLength": 10 } }, + ] } }, + entity("Customer", [ + { "source.rdb": {} }, + longId, + { "field.object": { name: "homeAddress", "@objectRef": "Address", "@storage": "flattened" } }, + pk, + ]), + ])); + for (const provider of ["postgresql", "mysql", "sqlite"] as const) { + const fields = buildPrismaSchema(root, { provider }).models.find((m) => m.name === "Customer")!.fields; + expect(fields.map((f) => f.name)).toEqual(["id", "homeAddressStreet", "homeAddressPostalCode"]); + expect(fields[1]).toMatchObject({ type: "String", optional: false }); + expect(fields[1]!.attributes).toContain('@map("home_address_street")'); + expect(fields[2]!.attributes).toContain('@map("home_address_postal_code")'); + } + const pgPostal = buildPrismaSchema(root, { provider: "postgresql" }).models[0]!.fields[2]!; + expect(pgPostal.attributes).toContain("@db.VarChar(10)"); + }); + + test("a stored procedure is not a model, and not refused: meta migrate does not create it either", async () => { + const root = await loadJson(model([ + entity("Fresh", [{ "source.rdb": {} }, longId, pk]), + { "object.projection": { name: "TopCustomers", children: [ + { "source.rdb": { "@kind": "storedProc", "@proc": "top_customers" } }, + { "field.long": { name: "id" } }, + ] } }, + ])); + expect(buildPrismaSchema(root, { provider: "postgresql" }).models.map((m) => m.name)).toEqual(["Fresh"]); + }); +}); + +describe("prisma-schema — refusals", () => { + test("a view-backed object that is not @unmanaged", async () => { + const root = await loadJson(model([ + entity("Program", [{ "source.rdb": {} }, longId, { "field.string": { name: "title" } }, pk]), + { "object.projection": { name: "ProgramTitle", children: [ + { "source.rdb": { "@kind": "view", "@view": "v_program_title" } }, + { "field.long": { name: "id", extends: "Program.id" } }, + { "field.string": { name: "title", extends: "Program.title" } }, + { "identity.primary": { name: "pk", extends: "Program.pk" } }, + ] } }, + ])); + const e = refusal(() => buildPrismaSchema(root, { provider: "postgresql" })); + expect(e.code).toBe(ERR_PRISMA_READ_ONLY_SOURCE); + expect(e.message).toContain("ProgramTitle"); + expect(e.message).toContain("@unmanaged"); + }); + + test("a write-through entity's read view, though the entity has a table", async () => { + const root = await loadJson(model([ + entity("Customer", [{ "source.rdb": {} }, longId, { "field.string": { name: "name" } }, pk]), + entity("Order", [ + { "source.rdb": { "@role": "primary", "@table": "orders" } }, + { "source.rdb": { "@role": "replica", "@kind": "view", "@view": "v_order_with_customer" } }, + longId, + { "field.long": { name: "customerId", "@required": true } }, + { "field.string": { name: "customerName", children: [ + { "origin.passthrough": { "@from": "Customer.name", "@via": "Order.customer" } }, + ] } }, + { "relationship.association": { name: "customer", "@objectRef": "Customer", "@cardinality": "one" } }, + pk, + { "identity.reference": { name: "refCustomer", "@fields": ["customerId"], "@references": "Customer" } }, + ]), + ])); + for (const provider of ["postgresql", "mysql", "sqlite"] as const) { + const e = refusal(() => buildPrismaSchema(root, { provider, checks: "omit" })); + expect(e.code).toBe(ERR_PRISMA_READ_ONLY_SOURCE); + expect(e.message).toContain("v_order_with_customer"); + } + const narrowed = buildPrismaSchema(root, { provider: "postgresql", matches: (o) => o.name === "Customer" }); + expect(narrowed.models.map((m) => m.name)).toEqual(["Customer"]); + }); + + test("a @schema outside Postgres", async () => { + const root = await loadJson(model([ + entity("Audit", [{ "source.rdb": { "@schema": "audit" } }, longId, pk]), + entity("Fresh", [{ "source.rdb": {} }, longId, pk]), + ])); + for (const provider of ["mysql", "sqlite"] as const) { + const e = refusal(() => buildPrismaSchema(root, { provider, checks: "omit" })); + expect(e.code).toBe(ERR_PRISMA_UNSUPPORTED_SCHEMA); + expect(e.message).toContain("audit"); + } + expect(buildPrismaSchema(root, { provider: "mysql", matches: (o) => o.name !== "Audit" }).models.map((m) => m.name)).toEqual(["Fresh"]); + }); + + test("an expression index", async () => { + const root = await loadJson(model([ + entity("Customer", [ + { "source.rdb": {} }, + longId, + { "field.string": { name: "email" } }, + pk, + { "index.lookup": { name: "byLowerEmail", "@expr": "lower(email)" } }, + ]), + ])); + expect(refusal(() => buildPrismaSchema(root, { provider: "postgresql" })).code).toBe(ERR_PRISMA_UNSUPPORTED_INDEX); + }); + + test("a partial index", async () => { + const root = await loadJson(model([ + entity("Customer", [ + { "source.rdb": {} }, + longId, + { "field.string": { name: "email" } }, + pk, + { "index.lookup": { name: "byEmail", "@fields": ["email"], "@where": "email IS NOT NULL" } }, + ]), + ])); + expect(refusal(() => buildPrismaSchema(root, { provider: "postgresql" })).code).toBe(ERR_PRISMA_UNSUPPORTED_INDEX); + }); + + test("a CHECK on sqlite, unless omitted", async () => { + const root = await loadShop(); + expect(refusal(() => buildPrismaSchema(root, { provider: "sqlite" })).code).toBe(ERR_PRISMA_UNSUPPORTED_CHECK); + expect(buildPrismaSchema(root, { provider: "sqlite", checks: "omit" }).checks).toEqual([]); + }); + + test("an array column is a Postgres list and JSON on MySQL", async () => { + const root = await loadShop(); + const notes = (provider: "postgresql" | "mysql") => + buildPrismaSchema(root, { provider }).models.find((m) => m.name === "Order")!.fields.find((f) => f.name === "notes")!; + expect(notes("postgresql")).toMatchObject({ type: "String", list: true, optional: false }); + expect(notes("mysql")).toMatchObject({ type: "Json", list: false }); + }); + + test("a table with no primary key", async () => { + const root = await loadJson(model([entity("Log", [{ "source.rdb": {} }, { "field.string": { name: "line" } }])])); + expect(refusal(() => buildPrismaSchema(root, { provider: "postgresql" })).code).toBe(ERR_PRISMA_NO_PRIMARY_KEY); + }); + + test("a reference to a table outside the generator's filter", async () => { + const root = await loadShop(); + const e = refusal(() => buildPrismaSchema(root, { provider: "postgresql", matches: (o) => o.name !== "Customer" })); + expect(e.code).toBe(ERR_PRISMA_UNMAPPABLE_RELATION); + expect(e.message).toContain("orders_customer_id_fk"); + }); + + test("two objects that would be one model", async () => { + const a = JSON.stringify({ "metadata.root": { package: "acme::a", children: [entity("Item", [{ "source.rdb": { "@table": "a_items" } }, longId, pk])] } }); + const b = JSON.stringify({ "metadata.root": { package: "acme::b", children: [entity("Item", [{ "source.rdb": { "@table": "b_items" } }, longId, pk])] } }); + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(a), new InMemoryStringSource(b)]); + expect(errors).toEqual([]); + expect(refusal(() => buildPrismaSchema(root, { provider: "postgresql" })).code).toBe(ERR_PRISMA_NAME_COLLISION); + }); + + test("a dialect with no Prisma provider", async () => { + const root = await loadShop(); + await expect( + runGen({ + config: defineConfig({ outDir: tmp, dialect: "postgres", generators: [prismaSchemaFile({ provider: "sqlserver" as "postgresql" })] }), + metadata: root, + projectRoot: tmp, + }), + ).rejects.toThrow(ERR_PRISMA_UNSUPPORTED_PROVIDER); + }); +}); + +describe("prisma-schema — the generator", () => { + test("the schema does not depend on the run's selection", async () => { + const root = await loadShop(); + const all = await gen(root, [prismaSchemaFile()]); + rmSync(join(tmp, "schema.prisma")); + const one = await gen(root, [prismaSchemaFile()], "postgres", ["Tag"]); + expect(one.get("schema.prisma")).toBe(all.get("schema.prisma")!); + }); + + test("a run that selects no table-backed object writes nothing", async () => { + const root = await loadJson(model([ + { "object.value": { name: "Address", children: [{ "field.string": { name: "line1" } }] } }, + ])); + expect((await gen(root, [prismaSchemaFile()])).size).toBe(0); + }); + + test("header false writes models only; a string header is written verbatim", async () => { + const root = await loadShop(); + const bare = await gen(root, [prismaSchemaFile({ header: false, fileName: "models.prisma" })]); + expect(bare.get("models.prisma")).not.toContain("datasource"); + expect(bare.get("models.prisma")).not.toContain("generator"); + expect(bare.has("models.checks.sql")).toBe(true); + const own = 'generator client {\n provider = "prisma-client-js"\n}'; + const mine = await gen(root, [prismaSchemaFile({ header: own })]); + expect(mine.get("schema.prisma")).toContain(own); + expect(mine.get("schema.prisma")).not.toContain("datasource"); + }); + + test("a hand edit is discarded, not merged", async () => { + const root = await loadShop(); + const first = await gen(root, [prismaSchemaFile()]); + writeFileSync(join(tmp, "schema.prisma"), `${first.get("schema.prisma")}\nmodel Extra {\n id Int @id\n}\n`); + const second = await gen(root, [prismaSchemaFile()]); + expect(second.get("schema.prisma")).toBe(first.get("schema.prisma")!); + }); + + test("the checks file goes when the last CHECK does", async () => { + const withCheck = await loadJson(model([ + entity("Customer", [{ "source.rdb": {} }, longId, { "field.enum": { name: "tier", "@values": ["A", "B"] } }, pk]), + ])); + expect((await gen(withCheck, [prismaSchemaFile()])).has("schema.checks.sql")).toBe(true); + const without = await loadJson(model([entity("Customer", [{ "source.rdb": {} }, longId, pk])])); + expect((await gen(without, [prismaSchemaFile()])).has("schema.checks.sql")).toBe(false); + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts index 8e5c517bc..a67dff329 100644 --- a/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-byte-identical.test.ts @@ -42,6 +42,8 @@ import { import { renderRequirementTest as builtinRenderRequirementTest } from "../src/templates/requirement-test.js"; import { cubeModel as builtinCubeModel } from "../src/generators/cube-model.js"; import { cubeModel as refCubeModel } from "../src/reference/cube-model.js"; +import { prismaSchemaFile as builtinPrismaSchema } from "../src/generators/prisma-schema-file.js"; +import { prismaSchemaFile as refPrismaSchema } from "../src/reference/prisma-schema.js"; import { callableFile as builtinCallable } from "../src/generators/callable-file.js"; import { callableFile as refCallable } from "../src/reference/callable.js"; import { traceHelperFile as builtinTraceHelper } from "../src/generators/trace-helper-file.js"; @@ -71,7 +73,7 @@ import { readTree as readCubeTree, } from "../scripts/gen-cube-model-canonical.js"; import type { RequirementTestArgs, RequirementTestsOpts } from "../src/index.js"; -import { MetaDataLoader, InMemoryStringSource } from "@metaobjectsdev/metadata"; +import { MetaDataLoader, InMemoryStringSource, isMetaSource, isReadOnlySource, type MetaObject } from "@metaobjectsdev/metadata"; import { FileSource } from "@metaobjectsdev/metadata/core"; const FIXTURE_DIR = resolve(import.meta.dir, "fixtures"); @@ -156,6 +158,12 @@ const SHARED_MODEL_OPTS = { version: "1.2.3", files: [join(FIXTURE_DIR, "trainer-website-shape.json")], }; +/** The prisma-schema options for the loop over the entity-shaped fixtures (see its entry). */ +const PRISMA_LOOP_OPTS = { + checks: "omit", + filter: (o: MetaObject) => !o.children().some((c) => isMetaSource(c) && isReadOnlySource(c)), +} as const; + const PAIRS: Record Generator; ref: () => Generator }> = { entity: { builtin: builtinEntity, ref: () => refEntity(PKG) }, queries: { builtin: builtinQueries, ref: refQueries }, @@ -176,6 +184,13 @@ const PAIRS: Record Generator; ref: () // vocabulary), so the pair is compared over two empty sets there; the describe at the end // of this file runs both over models that DO, under both Cube dialects. "cube-model": { builtin: builtinCubeModel, ref: refCubeModel }, + // These fixtures run under sqlite, where a CHECK is refused unless omitted, and some declare + // views, which a Prisma schema cannot create; the describe at the end of this file runs the + // pair over the Prisma fixture under each provider, checks and refusals included. + "prisma-schema": { + builtin: () => builtinPrismaSchema(PRISMA_LOOP_OPTS), + ref: () => refPrismaSchema(PRISMA_LOOP_OPTS), + }, // Both emit nothing over the entity-shaped fixtures above (none declares a callable // source or a trace entity); the describe at the end of this file runs each pair over // a model that does. @@ -1550,3 +1565,67 @@ describe("ADR-0034 Amendments 8 and 9 — the requirements and agent-docs refere } }); }); + +// The prisma-schema reference is the same GENERATOR as the built-in: over the Prisma fixture +// (self relations, a junction with a composite key, a descending index, arrays and validator +// checks) under each provider and each header form, both write the same files byte for byte, +// and both refuse the same model with the same message. +describe("ADR-0034 — the prisma-schema reference is byte-identical to the built-in", () => { + const SHOP = join(import.meta.dir, "prisma", "fixtures", "shop", "metaobjects", "meta.shop.json"); + type PrismaOpts = Parameters[0]; + const CASES: ReadonlyArray<{ label: string; opts: PrismaOpts; files: string[] }> = [ + { label: "postgresql", opts: { provider: "postgresql" }, files: ["schema.checks.sql", "schema.prisma"] }, + { label: "mysql", opts: { provider: "mysql" }, files: ["schema.checks.sql", "schema.prisma"] }, + { label: "sqlite, checks omitted", opts: { provider: "sqlite", checks: "omit" }, files: ["schema.prisma"] }, + { + label: "a header of the project's own", + opts: { provider: "postgresql", header: 'datasource db {\n provider = "postgresql"\n}' }, + files: ["schema.checks.sql", "schema.prisma"], + }, + { + label: "no header, another file name", + opts: { provider: "postgresql", header: false, fileName: "models.prisma" }, + files: ["models.checks.sql", "models.prisma"], + }, + ]; + + for (const c of CASES) { + test(c.label, async () => { + const root = await loadFiles([SHOP]); + const aDir = mkdtempSync(join(tmpdir(), "prisma-builtin-")); + const bDir = mkdtempSync(join(tmpdir(), "prisma-reference-")); + try { + const a = await gen(aDir, [builtinPrismaSchema(c.opts)], root); + const b = await gen(bDir, [refPrismaSchema(c.opts)], root); + const written = (t: Record) => Object.keys(t).filter((k) => !k.startsWith(".")).sort(); + expect(written(a)).toEqual(c.files); + expect(written(b)).toEqual(c.files); + for (const k of c.files) expect(`${k}:\n${b[k]}`).toBe(`${k}:\n${a[k]}`); + } finally { + rmSync(aDir, { recursive: true, force: true }); + rmSync(bDir, { recursive: true, force: true }); + } + }); + } + + test("both refuse a CHECK on sqlite with the same message", async () => { + const root = await loadFiles([SHOP]); + const dir = mkdtempSync(join(tmpdir(), "prisma-refuse-")); + try { + const message = async (g: Generator): Promise => { + try { + await gen(dir, [g], root); + } catch (e) { + return e instanceof Error ? e.message : String(e); + } + return "no error"; + }; + const a = await message(builtinPrismaSchema({ provider: "sqlite" })); + const b = await message(refPrismaSchema({ provider: "sqlite" })); + expect(a).toContain("ERR_PRISMA_UNSUPPORTED_CHECK"); + expect(b).toBe(a); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/server/typescript/packages/codegen-ts/test/reference-templates.test.ts b/server/typescript/packages/codegen-ts/test/reference-templates.test.ts index c3aa04e84..d7e8fb442 100644 --- a/server/typescript/packages/codegen-ts/test/reference-templates.test.ts +++ b/server/typescript/packages/codegen-ts/test/reference-templates.test.ts @@ -15,7 +15,7 @@ describe("reference-templates reader", () => { expect([...REFERENCE_GENERATOR_NAMES]).toEqual([ "entity", "queries", "routes", "routes-hono", "barrel", "names", "report-export", "prompt-render", "output-parser", "extractor", "output-prompt", "render-helper", - "requirement-tests", "cube-model", "callable", "trace-helper", + "requirement-tests", "cube-model", "prisma-schema", "callable", "trace-helper", "shared-model", "docs", "api-docs", "requirements", "agent-docs", "metamodel-docs", ]); }); diff --git a/server/typescript/packages/migrate-ts/package.json b/server/typescript/packages/migrate-ts/package.json index 5bc0cae67..6c8679c99 100644 --- a/server/typescript/packages/migrate-ts/package.json +++ b/server/typescript/packages/migrate-ts/package.json @@ -10,6 +10,11 @@ "bun": "./src/index.ts", "types": "./dist/index.d.ts", "default": "./dist/index.js" + }, + "./expected": { + "bun": "./src/expected.ts", + "types": "./dist/expected.d.ts", + "default": "./dist/expected.js" } }, "files": [ diff --git a/server/typescript/packages/migrate-ts/src/diff/index.ts b/server/typescript/packages/migrate-ts/src/diff/index.ts index 00e5fba0d..b72fabf52 100644 --- a/server/typescript/packages/migrate-ts/src/diff/index.ts +++ b/server/typescript/packages/migrate-ts/src/diff/index.ts @@ -608,6 +608,12 @@ function canonTypeForDialect(t: SqlType, dialect: Dialect | undefined): SqlType if (dialect !== "sqlite" && dialect !== "d1") return t; if (t.kind === "json") return { kind: "text" }; if (t.kind === "text" && t.maxLength !== undefined) return { kind: "text" }; + // SQLite has no boolean storage class: a BOOLEAN column stores integers, which is the + // INTEGER the expected schema writes. And it ignores a NUMERIC column's precision and + // scale, so DECIMAL and NUMERIC(10,2) are one column. Another tool's DDL writes the + // first spelling of each (Prisma does). + if (t.kind === "boolean") return { kind: "integer", bits: 64 }; + if (t.kind === "numeric") return { kind: "numeric" }; return t; } diff --git a/server/typescript/packages/migrate-ts/src/expected-schema.ts b/server/typescript/packages/migrate-ts/src/expected-schema.ts index da72637b8..6f41e955f 100644 --- a/server/typescript/packages/migrate-ts/src/expected-schema.ts +++ b/server/typescript/packages/migrate-ts/src/expected-schema.ts @@ -1085,6 +1085,13 @@ function flattenObjectField( return cols; } +/** + * `@default: "now"`: the current time. Not a literal, because Postgres converts the literal + * `'now'` to a timestamp once, when the column's DEFAULT is created, and every row then gets the + * table's creation time. + */ +const BARE_NOW_DEFAULT = /^now$/i; + const EXPR_DEFAULT_PATTERNS = [ /^current_timestamp$/i, /^now\(\)$/i, @@ -1131,8 +1138,14 @@ function buildColumn( // codegen emits `.default("")` and the DB gets `DEFAULT ''`, so dropping it here // (a falsy `.length > 0` check) made the column drift forever on sqlite/d1 and // disagree with codegen. Keep it as a literal; only `undefined` means "no default". - const isExpr = defaultRaw.length > 0 && EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw)); - col.default = { kind: isExpr ? "expr" : "literal", value: defaultRaw }; + if (BARE_NOW_DEFAULT.test(defaultRaw)) { + // The current time, as `@autoSet` spells it and codegen's column mapper reads it. + // As a literal, Postgres would turn `DEFAULT 'now'` into the table's creation time. + col.default = { kind: "expr", value: "now()" }; + } else { + const isExpr = defaultRaw.length > 0 && EXPR_DEFAULT_PATTERNS.some((re) => re.test(defaultRaw)); + col.default = { kind: isExpr ? "expr" : "literal", value: defaultRaw }; + } } } else if (typeof defaultRaw === "boolean" || typeof defaultRaw === "number") { col.default = { kind: "literal", value: String(defaultRaw) }; diff --git a/server/typescript/packages/migrate-ts/src/expected.ts b/server/typescript/packages/migrate-ts/src/expected.ts new file mode 100644 index 000000000..2be7b18c7 --- /dev/null +++ b/server/typescript/packages/migrate-ts/src/expected.ts @@ -0,0 +1,23 @@ +// `@metaobjectsdev/migrate-ts/expected`: the expected schema without the database half. +// +// The package root also exports introspection, apply and replay, which import kysely (a peer +// dependency) when the module loads. A caller that only needs the schema the model implies (the +// prisma-schema generator in `@metaobjectsdev/codegen-ts`) imports this entry instead, so it does +// not need kysely installed. Its import graph is the model walk and nothing that opens a +// connection; keep it that way. + +export { buildExpectedSchema, buildExpectedSchemaWithProvenance } from "./expected-schema.js"; +export type { BuildExpectedSchemaOptions, ExpectedSchemaWithProvenance, SchemaProvenance } from "./expected-schema.js"; +export { qualifiedDbName } from "./qualified-name.js"; +export type { SqlType } from "./sql-type.js"; +export type { + CheckDescriptor, + ColumnDefault, + ColumnDescriptor, + FkAction, + FkDescriptor, + IndexDescriptor, + SchemaSnapshot, + TableDescriptor, + ViewDescriptor, +} from "./types.js"; diff --git a/server/typescript/packages/migrate-ts/src/index.ts b/server/typescript/packages/migrate-ts/src/index.ts index f6f0ad991..47f7c5464 100644 --- a/server/typescript/packages/migrate-ts/src/index.ts +++ b/server/typescript/packages/migrate-ts/src/index.ts @@ -72,7 +72,7 @@ export { describeSqlType, isWidening, sqlTypeEquals } from "./sql-type.js"; export type { SqlType } from "./sql-type.js"; export type { SchemaSnapshot, SnapshotMeta, - TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor, ColumnDefault, + TableDescriptor, ColumnDescriptor, IndexDescriptor, FkDescriptor, CheckDescriptor, ColumnDefault, ViewDescriptor, ViewChangeReason, FkAction, NameChange, Change, ChangeKind, ChangeStatus, AllowOptions, AmbiguousChange, AmbiguousResolution, AmbiguousCallback, diff --git a/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts b/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts index 271e9fd4d..d682afb98 100644 --- a/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts +++ b/server/typescript/packages/migrate-ts/src/introspect/sqlite-shared.ts @@ -45,6 +45,12 @@ export function parseSqliteDefault(raw: string | null): ColumnDefault | undefine return { kind: "literal", value: quoted[1]!.replace(/''/g, "'") }; } + // The unquoted keywords TRUE and FALSE are SQLite's spellings of 1 and 0 (a quoted 'true' + // is a string, matched above). Another tool's DDL writes `BOOLEAN DEFAULT true` (Prisma + // does); the expected schema spells the same default 1 or 0. + const bool = raw.trim().toLowerCase(); + if (bool === "true" || bool === "false") return { kind: "literal", value: bool === "true" ? "1" : "0" }; + const isExpr = SQLITE_EXPR_DEFAULT_PATTERNS.some((re) => re.test(raw)); if (isExpr) return { kind: "expr", value: raw }; return { kind: "literal", value: raw }; diff --git a/server/typescript/packages/migrate-ts/test/integration/sqlite-hand-migrated-verify.test.ts b/server/typescript/packages/migrate-ts/test/integration/sqlite-hand-migrated-verify.test.ts index 4b9a47581..d751a18a4 100644 --- a/server/typescript/packages/migrate-ts/test/integration/sqlite-hand-migrated-verify.test.ts +++ b/server/typescript/packages/migrate-ts/test/integration/sqlite-hand-migrated-verify.test.ts @@ -186,3 +186,51 @@ describe("SQLite/D1 hand-migrated verify — non-physical annotations", () => { expect(result.changes.map((c) => c.kind)).toContain("drop-check"); }); }); + +// Another tool's spelling of the same columns (Prisma's `db push` writes these): BOOLEAN with +// `DEFAULT true` where the expected schema writes INTEGER with 1, and DECIMAL where it writes +// NUMERIC(10,2). SQLite stores both pairs identically: it has no boolean storage class, reads +// TRUE as 1, and ignores a NUMERIC column's precision. +const METADATA_OTHER_SPELLINGS = JSON.stringify({ + "metadata.root": { + package: "test", + children: [ + { + "object.entity": { + name: "Order", + children: [ + { "source.rdb": { "@table": "orders" } }, + { "field.string": { name: "id", "@required": true } }, + { "field.boolean": { name: "paid", "@required": true, "@default": true } }, + { "field.decimal": { name: "total", "@precision": 10, "@scale": 2 } }, + { "identity.primary": { "@fields": ["id"] } }, + ], + }, + }, + ], + }, +}); + +describe("SQLite/D1 verify — another tool's spelling of the same storage", () => { + test("BOOLEAN DEFAULT true and DECIMAL verify clean against field.boolean and a sized field.decimal", async () => { + const metadata = (await new MetaDataLoader().load([new InMemoryStringSource(METADATA_OTHER_SPELLINGS)])).root; + const expected = buildExpectedSchema(metadata, { dialect: "sqlite" }); + await sql + .raw('CREATE TABLE orders (id TEXT PRIMARY KEY NOT NULL, paid BOOLEAN NOT NULL DEFAULT true, total DECIMAL)') + .execute(k); + const actual = await introspectSqlite(k); + const result = await diff({ expected, actual, dialect: "sqlite" }); + expect(result.changes).toEqual([]); + }); + + test("a changed boolean default is still drift", async () => { + const metadata = (await new MetaDataLoader().load([new InMemoryStringSource(METADATA_OTHER_SPELLINGS)])).root; + const expected = buildExpectedSchema(metadata, { dialect: "sqlite" }); + await sql + .raw('CREATE TABLE orders (id TEXT PRIMARY KEY NOT NULL, paid BOOLEAN NOT NULL DEFAULT false, total DECIMAL)') + .execute(k); + const actual = await introspectSqlite(k); + const result = await diff({ expected, actual, dialect: "sqlite" }); + expect(result.changes.map((c) => c.kind)).toEqual(["change-column-default"]); + }); +}); diff --git a/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts b/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts index b5fdb5012..bf6a05773 100644 --- a/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts +++ b/server/typescript/packages/migrate-ts/test/unit/expected-schema.test.ts @@ -165,6 +165,43 @@ describe("buildExpectedSchema — @autoSet timestamp default", () => { }); }); +// --------------------------------------------------------------------------- +// `@default: "now"` is the current time, not the string 'now'. +// +// Regression: the bare token was not an expression pattern, so the column got the literal +// `DEFAULT 'now'`. Postgres converts that literal to a timestamp ONCE, when the table is +// created, so every row defaulted to the table's creation time. Codegen's column mapper +// always read `now` as the current time (`.defaultNow()`), so the database disagreed with the +// generated code. It now lowers to the same `now()` expression `@autoSet` uses. +// --------------------------------------------------------------------------- + +describe("buildExpectedSchema — @default \"now\"", () => { + async function stampDefault(value: string, dialect?: "postgres" | "sqlite") { + const doc = { "metadata.root": { package: "acme", children: [ + { "object.entity": { name: "Event", children: [ + { "source.rdb": {} }, + { "field.long": { name: "id" } }, + { "identity.primary": { "name": "id", "@fields": "id" } }, + { "field.timestamp": { name: "stampedAt", "@required": true, "@default": value } }, + ] } }, + ] } }; + const { root, errors } = await new MetaDataLoader().load([new InMemoryStringSource(JSON.stringify(doc))]); + expect(errors).toHaveLength(0); + const cols = buildExpectedSchema(root, dialect === undefined ? {} : { dialect }).tables[0]?.columns ?? []; + return cols.find((c) => c.name === "stamped_at")?.default; + } + + test("lowers to the now() expression, in any case", async () => { + expect(await stampDefault("now")).toEqual({ kind: "expr", value: "now()" }); + expect(await stampDefault("NOW")).toEqual({ kind: "expr", value: "now()" }); + }); + + test("is the same default @autoSet gives, on Postgres and on SQLite", async () => { + expect(await stampDefault("now", "postgres")).toEqual({ kind: "expr", value: "now()" }); + expect(await stampDefault("now", "sqlite")).toEqual(await stampDefault("now()", "sqlite")); + }); +}); + // --------------------------------------------------------------------------- // Native SQL array columns are DERIVED from `isArray` (dbColumnType slim-and-derive // Phase 1): the array-ness already lives on the field's `isArray` axis, so the DDL diff --git a/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts b/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts index 3a08b6d39..5cbc19819 100644 --- a/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts +++ b/server/typescript/packages/migrate-ts/test/unit/review-holes.test.ts @@ -37,6 +37,12 @@ describe("hole 1 — a quoted literal is a literal, even with parens in it", () .toEqual({ kind: "expr", value: "(lower(hex(randomblob(16))))" }); }); + test("the bare keywords TRUE and FALSE are 1 and 0; a quoted 'true' stays a string", () => { + expect(parseSqliteDefault("true")).toEqual({ kind: "literal", value: "1" }); + expect(parseSqliteDefault("FALSE")).toEqual({ kind: "literal", value: "0" }); + expect(parseSqliteDefault("'true'")).toEqual({ kind: "literal", value: "true" }); + }); + test("a bare NULL is NO default (the D1 runner stringifies SQL NULL to \"null\")", () => { // `DEFAULT NULL` ≡ no default. A no-default column on D1 arrives as the string // "null" — it must NOT become a literal-"null" default (permanent false drift). diff --git a/server/typescript/packages/sdk/test/agent-context/recipe-mirrors.test.ts b/server/typescript/packages/sdk/test/agent-context/recipe-mirrors.test.ts index 8d560a000..7c2cd9d1f 100644 --- a/server/typescript/packages/sdk/test/agent-context/recipe-mirrors.test.ts +++ b/server/typescript/packages/sdk/test/agent-context/recipe-mirrors.test.ts @@ -1,6 +1,6 @@ // test/agent-context/recipe-mirrors.test.ts // -// Two recipes are shipped INSIDE the scaffolded codegen skill, because an agent in an +// Three recipes are shipped INSIDE the scaffolded codegen skill, because an agent in an // adopter's project has no copy of this repository's docs/: it only has what `meta init` // wrote. The copies must not drift from the originals, which are the tested ones — // docs/recipes/generators/typescript/mongo-repository.ts is copied into a fresh project and @@ -27,3 +27,11 @@ test("the shipped MySQL guide carries the recipe's setup, DDL and behaviour sect expect(shipped.includes(MARK)).toBe(true); expect(shipped.slice(shipped.indexOf(MARK))).toBe(original.slice(original.indexOf(MARK))); }); + +test("the shipped Prisma guide carries the recipe's setup, workflow, mapping and refusal sections unchanged", () => { + const MARK = "## TypeScript setup"; + const shipped = readFileSync(join(REFS, "typescript-prisma.md"), "utf8"); + const original = readFileSync(join(REPO, "docs/recipes/prisma.md"), "utf8"); + expect(shipped.includes(MARK)).toBe(true); + expect(shipped.slice(shipped.indexOf(MARK))).toBe(original.slice(original.indexOf(MARK))); +});