From 158b4b652084644d440126f2be12fb2e4117fe38 Mon Sep 17 00:00:00 2001 From: SerhiyGreench Date: Fri, 28 Aug 2026 10:30:43 +0200 Subject: [PATCH] Rebuild on Prisma Next MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prisma 8 replaces the generated client with a contract and drops `$extends`, so the four query extensions become `SqlMiddleware` that rewrite a query AST, and everything they used to read from the generated config maps is now derived from the emitted `contract.json` instead. What the ORM cannot do, and what stands in for it: * No nested writes. `repositoriesFor` writes the children itself, in a transaction, taking the foreign key from the row it just created. * No `select` on a write. A write returned every column, which handed a caller the credential secret it had not asked for; the façade projects it, reading the row back when the projection names a relation. * No atomic update values. A function passed as one is stringified, so a counter has to be `x + 1` in the database. * `pg` hands the codec a parsed JSON value and the codec parses it again — a column holding `"5"` came back as the number 5. `dataPool` gives the codec the text it expects, and registers the enum-array oids the driver cannot know, without which every such column fails to decode. `isoDates` stays, for a different reason than before: the columns are `timestamptz` now, so the instant is no longer ambiguous, but Postgres prints it as `2026-08-14 09:30:00.123+00` and every boundary downstream — the GraphQL DateTime scalar, `z.iso.datetime()` — refuses that spelling. The Prisma 7 generator is gone. `emit/` produces the same decorated RPC classes from the contract, and carries what the contract cannot: validation rules, fields kept off the wire, and the names of enum members whose database label is not the name. Migrated seven services against this. 86 tests. --- AGENTS.md | 69 +- README.md | 188 ++- index.ts | 44 +- package-lock.json | 2087 ++++++++++++++++++++++++------- package.json | 27 +- src/access-scope.ts | 308 ++--- src/archive.ts | 52 +- src/ast.ts | 193 +++ src/audit.ts | 406 +++--- src/authorship.ts | 222 ---- src/change-notify.ts | 61 +- src/codegen.ts | 1801 -------------------------- src/data-layer.ts | 139 ++ src/derive.ts | 337 +++++ src/emit/all.ts | 137 ++ src/emit/imports.ts | 190 +++ src/emit/models.ts | 412 ++++++ src/emit/rpc.ts | 487 ++++++++ src/index.ts | 21 +- src/iso-dates.ts | 171 ++- src/migrate-down.ts | 446 ------- src/pool.ts | 165 +++ src/query-log.ts | 91 ++ src/query.ts | 356 ++++++ src/repository.ts | 564 +++++++++ src/soft-delete.ts | 169 --- src/sql-client.ts | 104 ++ src/sql-log.ts | 2 +- src/sql-runner.ts | 115 ++ src/sql-template.ts | 165 +++ src/stamp.ts | 212 ++++ test/unit/access-scope.spec.ts | 194 ++- test/unit/barrel.spec.ts | 72 +- test/unit/change-notify.spec.ts | 38 +- test/unit/derive.spec.ts | 110 ++ test/unit/emit-models.spec.ts | 172 +++ test/unit/emit-rpc.spec.ts | 70 ++ test/unit/imports.spec.ts | 95 ++ test/unit/iso-dates.spec.ts | 121 +- test/unit/query.spec.ts | 127 ++ test/unit/sql-template.spec.ts | 80 ++ test/unit/stamp.spec.ts | 168 +++ 42 files changed, 7038 insertions(+), 3950 deletions(-) create mode 100644 src/ast.ts delete mode 100644 src/authorship.ts delete mode 100644 src/codegen.ts create mode 100644 src/data-layer.ts create mode 100644 src/derive.ts create mode 100644 src/emit/all.ts create mode 100644 src/emit/imports.ts create mode 100644 src/emit/models.ts create mode 100644 src/emit/rpc.ts delete mode 100644 src/migrate-down.ts create mode 100644 src/pool.ts create mode 100644 src/query-log.ts create mode 100644 src/query.ts create mode 100644 src/repository.ts delete mode 100644 src/soft-delete.ts create mode 100644 src/sql-client.ts create mode 100644 src/sql-runner.ts create mode 100644 src/sql-template.ts create mode 100644 src/stamp.ts create mode 100644 test/unit/derive.spec.ts create mode 100644 test/unit/emit-models.spec.ts create mode 100644 test/unit/emit-rpc.spec.ts create mode 100644 test/unit/imports.spec.ts create mode 100644 test/unit/query.spec.ts create mode 100644 test/unit/sql-template.spec.ts create mode 100644 test/unit/stamp.spec.ts diff --git a/AGENTS.md b/AGENTS.md index 6da9644..5c4208e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,12 +9,13 @@ making changes. For contribution *process/terms* see ## What this is -`@imqueue/pg-prisma` is the Prisma/Postgres persistence toolkit of the @imqueue -framework. It provides Prisma client extensions, Postgres operational helpers, -and a Prisma generator that emits typed -[`@imqueue/rpc`](https://github.com/imqueue/rpc) model & repository classes. -The generated model classes validate their inputs with -[`@imqueue/validation`](https://github.com/imqueue/validation). +`@imqueue/pg-prisma` is the Prisma Next (8.x) / Postgres persistence toolkit of +the @imqueue framework. It provides query middlewares that rewrite a statement +before it is lowered to SQL, and Postgres operational helpers. + +There is no code generator. Prisma Next emits `contract.json`, and +`deriveDataLayer` reads the per-model configuration straight out of it, so +nothing is written to disk and nothing can drift from the schema. ## Toolchain & invariants (do not fight these) @@ -24,15 +25,16 @@ The generated model classes validate their inputs with - **TypeScript, `module`/`moduleResolution: nodenext`**, `target: es2024`, `verbatimModuleSyntax: true`, `isolatedModules: true`, `strict: true`. Use `import type` / `import { type X }` for type-only imports. -- **Node ≥ 22.12. Prisma 7+.** -- **`@prisma/client` is a peer dependency.** The extension modules import the - `Prisma` namespace / `PrismaClient` type from **`@prisma/client/extension`** — - the official entry for *shareable* Client extensions. It resolves without - running `prisma generate` and works no matter where the consumer generates - their client (default `@prisma/client` output or a custom output path), so - this package needs no schema or generated client of its own to build. Runtime - deps are kept minimal: `pg` (down-migrations) and `@prisma/generator-helper` - (the generator). Do not add heavyweight deps. +- **Node ≥ 22.12. Prisma Next (8.x).** +- **`@prisma/orm-postgres` is a peer dependency.** The middlewares import AST + constructors and types from **`@prisma/orm-postgres/relational-core/ast`**. + That subpath resolves without emitting a contract, so this package needs no + schema of its own to build. The single runtime dep is `pg`, for the audit + trail's own pool. Do not add heavyweight deps. +- **Use the real AST types.** `AnyQueryAst` is a discriminated union on `kind`; + narrowing on it gives `table`, `set`, `rows` and `returning` their proper + types. A hand-rolled structural type here costs the one check that catches a + mistake. - **Lint/format:** `oxlint` + `oxfmt`. Run `npm run format` before committing; CI checks `npm run format:check`. - Build **emits `.js`/`.d.ts`/`.js.map` next to sources**; these are @@ -53,10 +55,11 @@ npm run test-coverage # tests + experimental coverage summary npm run test-lcov # writes coverage/lcov.info ``` -Unit tests (`test/**/*.spec.ts`, run compiled) cover the pure helpers -(`prettifySql`, `accessWhere`). The extension and installer modules that touch a -live database are exercised by the consuming service's integration suite, not -here. +Unit tests (`test/**/*.spec.ts`, run compiled) cover the middlewares by +constructing AST nodes directly — no database is needed to assert what a +statement was rewritten into, and that is where the bugs have been. The +installer modules that touch a live database are exercised by the consuming +service's integration suite, not here. ## Layout @@ -64,16 +67,16 @@ here. |---|---| | `index.ts` | Public entry: `export * from './src/index.js'` | | `src/index.ts` | Barrel re-exporting the public API | -| `src/soft-delete.ts` | Prisma soft-delete query extension. | -| `src/audit.ts` | Prisma audit-trail query extension. | -| `src/authorship.ts` | Prisma authorship-stamping query extension. | -| `src/access-scope.ts` | `accessWhere()` row-level access-scope filter composer. | +| `src/ast.ts` | AST helpers; `filterSelects()` walks every select in a statement. | +| `src/derive.ts` | `deriveDataLayer()` — per-table config read from `contract.json`. | +| `src/data-layer.ts` | `dataLayer()` — the composed middleware array, in one call. | +| `src/stamp.ts` | Soft-delete and authorship, as one middleware. | +| `src/access-scope.ts` | Row-level access-scope middleware. | +| `src/audit.ts` | Audit-trail middleware, writing through its own pool. | | `src/archive.ts` | Row-archiving installer (aged rows → mirror `archive` schema, pg_cron). | | `src/change-notify.ts` | Postgres row-change `NOTIFY` trigger installer. | -| `src/migrate-down.ts` | `migrateDown()` — undo applied Prisma migrations; also a CLI. | | `src/pretty-sql.ts` | `prettifySql()` SQL pretty-printer for query logging. | | `src/sql-log.ts` | Cooperative SQL-log suppression (`silently`, `isSqlLogSuppressed`). | -| `src/codegen.ts` | Prisma generator: emits typed `@imqueue/rpc` models & repositories. | | `test/**` | `node:test` specs (`*.spec.ts`). | ## Behavioural invariants @@ -87,10 +90,18 @@ here. `#prisma`, RPC decorators from `@imqueue/rpc`, and validation decorators from `@imqueue/validation`. Keep those import strings stable — they are the generator's output contract. -- **`migrateDown()` is side-effect-pure at import time.** The generator and the - migrate-down CLI only run when their module is executed directly - (`import.meta.url === argv[1]`); importing the package barrel must have no side - effects and must not require the dev-only `@prisma/generator-helper`. +- **Filter the whole statement, never just its root.** Prisma Next compiles a + relation read into one statement holding several selects. A predicate applied + only to the outermost `from` returns the rows it was meant to exclude, + through any `include`, with nothing logged. Use `filterSelects()`. +- **Qualify a column by the source's alias when it has one.** `TableSource` + renders as `"public"."Session" AS "s"`, and a column qualified by the table + name is then not in scope; Postgres rejects the statement. +- **Buffer audit rows per execution, not per client.** `onRow` runs inside an + async generator, so concurrent statements on one client interleave at every + row. A shared buffer lets one statement flush another's rows under the wrong + actor — the worst failure a security trail has. Key by the plan object, and + resolve the actor at the first row, inside that statement's async context. - **`silently()` flips a shared module flag** — it is for pre-request one-offs (startup DDL), not interleaved concurrent traffic. diff --git a/README.md b/README.md index eedfb0d..b8b1f45 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,16 @@ [![npm version](https://img.shields.io/npm/v/@imqueue/pg-prisma)](https://www.npmjs.com/package/@imqueue/pg-prisma) [![License](https://img.shields.io/badge/license-GPL-blue.svg)](https://github.com/imqueue/pg-prisma/blob/master/LICENSE) -A Prisma/Postgres toolkit for Node.js & TypeScript back-ends — the persistence -helpers behind @imqueue framework services. It bundles a set of Prisma -[client extensions](https://www.prisma.io/docs/orm/prisma-client/client-extensions) -(soft-delete, audit trail, authorship stamping, row-level access scope), Postgres -operational helpers (row archiving, change-notify triggers, down-migrations, SQL -log formatting), and a Prisma generator that emits typed -[@imqueue/rpc](https://github.com/imqueue/rpc) model & repository classes. +A Prisma Next (8.x) / Postgres toolkit for Node.js & TypeScript back-ends — the +persistence helpers behind @imqueue framework services. It bundles a set of +Prisma Next query **middlewares** (soft-delete, authorship stamping, audit +trail, row-level access scope) that rewrite the statement before it is lowered +to SQL, plus Postgres operational helpers (row archiving, change-notify +triggers, SQL log formatting). + +Their per-model configuration is **derived from the emitted `contract.json`** +rather than generated: Prisma Next has no custom-generator protocol and needs +none, since the contract already names every model, field and physical column. **Documentation:** full guides, tutorial and API reference at [imqueue.org](https://imqueue.org/). Commercial licensing & support for @@ -32,93 +35,148 @@ version, licence and Node floor for every package: # Features -- **Soft-delete extension** — transparently excludes soft-deleted rows and turns - deletes into `deletedAt` stamps. -- **Audit extension** — writes an append-only audit trail of INSERT/UPDATE/DELETE. -- **Authorship extension** — stamps `createdBy`/`updatedBy`/`deletedBy` from a - caller-supplied actor id. -- **Access-scope helper** — `accessWhere(...)` composes row-level access filters - (AND of per-level OR groups) onto any Prisma `where`. -- **Row archiving** — moves aged rows out of hot tables into a mirror `archive` - schema on a pg_cron schedule (idempotent DB setup). -- **Change-notify triggers** — installs Postgres `NOTIFY` triggers for row - changes, reporting the schema alongside the table so tables of the same name - in different schemas are told apart. `withoutChangeNotify()` lets a bulk - write commit without a notification per row. -- **Down-migrations** — `migrateDown()` undoes applied Prisma migrations (Prisma - has no native "down"). -- **SQL log helpers** — `prettifySql()` and cooperative log suppression. -- **Prisma generator** — emits typed `@imqueue/rpc` models, inputs, query types - and repositories from your schema. -- **TypeScript included!** +- **Soft delete and authorship** — a `DELETE` becomes a `deletedAt` stamp, + stamped rows disappear from reads, and every write records who made it. +- **Access scope** — every read, update and delete is narrowed to the rows the + caller may see, in the data layer rather than at each call site. +- **Audit trail** — every write to a nominated table recorded with the actor, + the action and the row as the database returned it. +- **Row archiving** — aged rows moved into a mirror `archive` schema on a + pg_cron schedule. +- **Change-notify triggers** — Postgres `NOTIFY` on every row change. + +Filtering applies across the **whole statement**, not just its outermost +`FROM`. Prisma Next compiles a relation read into one statement holding several +selects, so a filter on the root alone would return soft-deleted and +out-of-scope rows through any `include`. # Requirements -- Node.js ≥ 22.12, PostgreSQL, and Prisma **7+**. -- `@prisma/client` is a **peer dependency** — the extensions import the `Prisma` - namespace from `@prisma/client/extension` (the entry for shareable Client - extensions), so they work whether you generate your client to the default - `@prisma/client` output or to a custom path. -- Some Postgres features are optional: row archiving schedules via `pg_cron` when - available (it degrades gracefully when the extension is absent). +- Node.js >= 22.12 +- `prisma` 8.x and `@prisma/orm-postgres` (peer dependency) +- PostgreSQL 15 or newer # Install ```bash -npm i --save @imqueue/pg-prisma +npm i @imqueue/pg-prisma ``` # Usage -## Client extensions +## The data layer, in one call ```typescript -import { PrismaClient } from '@prisma/client'; -import { softDelete, audit, authorship } from '@imqueue/pg-prisma'; - -// The FIRST-added extension is the OUTERMOST — keep `audit` first so that -// soft-deletes are still recorded in the audit trail. -const prisma = new PrismaClient() - .$extends(audit({ /* ...config... */ })) - .$extends(authorship({ /* ...config... */ })) - .$extends(softDelete({ /* ...config... */ })); +import { dataLayer } from '@imqueue/pg-prisma'; +import postgres from '@prisma/orm-postgres/runtime'; +import type { Contract } from './prisma/contract.d.ts'; +import contractJson from './prisma/contract.json' with { type: 'json' }; + +const layer = dataLayer({ + contract: contractJson, + scope: { Portfolio: { portfolio: ['id'] } }, + resolvers: { portfolio: () => currentPortfolioIds() }, + getActorId: currentActorId, + audit: { + connectionString: process.env.DATABASE_URL!, + config: { table: 'AuditLog', columns: { /* ... */ } }, + getPrincipal: currentPrincipal, + }, +}); + +export const db = postgres({ + contractJson, + url: process.env.DATABASE_URL!, + middleware: layer.middleware, +}); ``` +`dataLayer` returns the middlewares already composed. That is the point: a +caller never orders them, and so cannot order them wrongly. Call +`layer.close()` on shutdown to release the audit pool. + ## Access scope +Scope is the one thing that cannot be derived from the contract — Prisma Next +has no schema-level annotation to carry it — so it is declared where +`dataLayer` is called, keyed by model and field: + ```typescript -import { accessWhere } from '@imqueue/pg-prisma'; +scope: { + Portfolio: { portfolio: ['id'] }, + User: { user: ['createdBy', 'id'] }, +} +``` -const where = accessWhere( - callerWhere, - { user: ['createdBy'], portfolio: ['portfolioId'] }, - { user: () => currentUserId, portfolio: () => allowedPortfolioIds }, -); +Columns **within** one level are OR-ed; levels are AND-ed together. A resolver +returning `undefined` leaves its level inactive, a value or array restricts, +and `null` or an empty array denies everything. Get the composition backwards +and the failure is a data leak rather than an error, so a `scope` naming a +model the contract does not define is a throw, not a silent no-op. + +## Emitting the RPC model classes + +Prisma Next emits `contract.d.ts`, which carries the types but not the +decorated classes. `@classType`/`@property` are what the +[@imqueue/rpc](https://github.com/imqueue/rpc) client generator reads, and an +undecorated type is dropped from the generated client with no error — so the +DTO classes are emitted here, from the same contract: + +```typescript +import { emitModels, parseImportMap } from '@imqueue/pg-prisma'; + +await writeFile('src/generated/models.ts', emitModels({ contract })); ``` -## Prisma generator +### Redirecting the runtime imports -The package ships a Prisma generator that emits typed `@imqueue/rpc` model and -repository classes. Point a generator block at it in your `schema.prisma`: +By default the emitted file imports `@imqueue/rpc` directly. Pass `imports` to +point it somewhere else: -```prisma -generator imq { - provider = "node ./node_modules/@imqueue/pg-prisma/src/codegen.js" -} +```typescript +emitModels({ + contract, + imports: parseImportMap('@imqueue/rpc=@my-org/runtime'), +}); +``` + +```typescript +// before +import { classType, property } from '@imqueue/rpc'; + +// after +import { classType, property } from '@my-org/runtime'; ``` -The generated code assumes your project defines the subpath import aliases -`#generated/*` and `#prisma` (your `PrismaClient` instance), and imports -validation decorators from `@imqueue/validation` and RPC decorators from -`@imqueue/rpc`. Install those alongside this package if you use the generator. +**Why this exists.** The decorators are only meaningful to the registry that +defined them, so `@imqueue/rpc`, `@imqueue/validation` and `zod` each have to +be a **single copy** shared with the service. A second copy fails silently +rather than loudly — a second decorator registry nothing reads, or a `ZodError` +that fails `instanceof`. The reliable way to guarantee one copy is for one +package to own the dependency and re-export it, with every service taking it +from there; redirecting the emitted imports is what makes that possible. -## Down-migrations +Redirecting several runtimes at one package merges them into a single +statement, rather than emitting the same specifier three times: -```bash -node --import tsx node_modules/@imqueue/pg-prisma/src/migrate-down.js \ - --database-url "$DATABASE_URL" --steps 1 +```typescript +parseImportMap( + 'zod=@base, @imqueue/rpc=@base, @imqueue/validation=@base', +); +// import { classType, property, validatable, validate, z } from '@base'; ``` +Redirecting a module the generator never emits throws rather than being +ignored, because the alternative is believing a redirection was applied while +the generated files still point at the original. + +## Composing it yourself + +`stamp`, `accessScope` and `audit` are exported individually for cases +`dataLayer` does not cover, and `deriveDataLayer` produces the config they take. +The middlewares commute — `stamp` merges what were two order-dependent Prisma 7 +extensions — so there is no required order between them. + ## Running Unit Tests Tests run on the native Node.js test runner (`node:test`) with `node:assert` and diff --git a/index.ts b/index.ts index 6251382..e18a382 100644 --- a/index.ts +++ b/index.ts @@ -23,32 +23,34 @@ */ /** - * Prisma and Postgres building blocks for `@imqueue` services. + * Prisma Next (8.x) and Postgres building blocks for `@imqueue` services. * * Two kinds of thing live here, and they are used at different times. * - * Query extensions wrap a `PrismaClient` and change what queries do: - * `softDelete` turns deletes into `deletedAt` stamps and hides stamped rows, - * `accessScope` narrows every read to the records the caller is allowed to see, - * `authorship` stamps who created, updated or deleted a row, `audit` writes a - * trail of every write to a table you nominate, and `isoDates` serializes `Date` - * values so they survive the RPC wire as ISO strings. Each is independent; each is - * driven by a per-model config the code generator emits from your Prisma schema. + * Query middlewares rewrite the statement before it is lowered to SQL: + * `stamp` turns deletes into `deletedAt` stamps, hides stamped rows and records + * who created, updated or deleted a row; `accessScope` narrows every read to + * the records the caller is allowed to see; and `audit` writes a trail of every + * write to a table you nominate. `dataLayer` builds all three from an emitted + * contract in one call and is the entry point for the ordinary case — the + * individual factories are there to compose something it does not cover. * * Installers and tools run once at startup or by hand rather than per query: - * `installArchiving` moves aged rows into a mirror `archive` schema on a pg_cron - * schedule, `installChangeTriggers` makes Postgres `NOTIFY` on every row change, - * `migrateDown` rolls applied migrations back (Prisma has no native "down"), and - * `prettifySql`/`silently`/`isSqlLogSuppressed` are query-logging helpers. - * - * Ordering matters when extensions are combined, because Prisma runs the - * first-added query hook outermost. `audit` has to be added first if it is to see - * operations that `softDelete` reroutes; the individual pages say so where it - * applies. - * - * The Prisma generator that emits typed `@imqueue/rpc` models from your schema - * ships separately at `@imqueue/pg-prisma/codegen` and is invoked by Prisma, not - * imported — it is deliberately absent from this barrel. + * `installArchiving` moves aged rows into a mirror `archive` schema on a + * pg_cron schedule, `installChangeTriggers` makes Postgres `NOTIFY` on every + * row change, and `prettifySql`/`silently`/`isSqlLogSuppressed` are + * query-logging helpers. + * + * The per-model configuration is **derived from `contract.json`** by + * `deriveDataLayer` rather than generated: Prisma Next has no custom-generator + * protocol and needs none, since the contract already names every model, field + * and physical column. Nothing is written to disk and nothing can go stale + * against the schema. Access levels are the one thing that cannot be derived — + * Prisma Next has no schema annotation to carry them — so they are declared + * where `dataLayer` is called. + * + * The middlewares commute: `stamp` merges what were two order-dependent + * Prisma 7 extensions, so there is no ordering left for a caller to get wrong. * * @packageDocumentation */ diff --git a/package-lock.json b/package-lock.json index f588aa5..d47da8e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,15 +9,14 @@ "version": "1.1.0", "license": "GPL-3.0-only", "dependencies": { - "@prisma/generator-helper": "^7.8.0", - "pg": "^8.22.0" + "pg": "^8.23.0" }, "devDependencies": { - "@prisma/client": "^7.8.0", - "@types/node": "^24.9.1", - "@types/pg": "^8.20.0", - "oxfmt": "0.57.0", - "oxlint": "1.72.0", + "@prisma/orm-postgres": "^8.0.0-rc.8", + "@types/node": "^26.4.0", + "@types/pg": "8.20.4", + "oxfmt": "^0.65.0", + "oxlint": "^1.80.0", "typescript": "^7.0.2" }, "engines": { @@ -27,13 +26,79 @@ "url": "https://imqueue.com/" }, "peerDependencies": { - "@prisma/client": ">=7" + "@prisma/orm-postgres": ">=8.0.0-rc.8" } }, - "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.57.0.tgz", - "integrity": "sha512-qVBsEO+KugOsCmUHcO8iqNnqc65p7PCKpCs8M66mPZ+Ri+CWbcpoQOEJBg2OTu03+0qu++NK1jj6IzvQVs0Sig==", + "node_modules/@ark/schema": { + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@ark/schema/-/schema-0.56.2.tgz", + "integrity": "sha512-Qx4D2JFbBWpntiHZaTv7bGG4H/M2rigiknezKg/WVyDSaLdE4YCcWAOoFB7pjjDqHbbV2OqRfntm1nnXvwMexg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ark/util": "0.56.2" + } + }, + "node_modules/@ark/util": { + "version": "0.56.2", + "resolved": "https://registry.npmjs.org/@ark/util/-/util-0.56.2.tgz", + "integrity": "sha512-9kU2sUE38FZEGG7l3hamYMBieLYEJh2L1mrYD2eXpT+78EnQSV1bhjxJhnxGBMSTbtwpBSDNSK+K60WvaI/DTQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@clack/core": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.0.tgz", + "integrity": "sha512-7Wctjq6f7c1CPz8sPpkwUnz8yRgVANkpNupb81q432FjcJg4l+Sw7XANdNSdWfAKq0IHI0JTcUeK5dxs/HrGPw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@clack/prompts": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.0.tgz", + "integrity": "sha512-wKh+wTjmrUoUdkZg8KpJO5X+p9PWV+KE9mePseq9UYWkukgTKsGS47RRL2HstwVcvDQH+PenrPJWII8+MfiiyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@clack/core": "1.4.0", + "fast-string-width": "^3.0.2", + "fast-wrap-ansi": "^0.2.0", + "sisteransi": "^1.0.5" + }, + "engines": { + "node": ">= 20.12.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", "cpu": [ "arm" ], @@ -44,13 +109,13 @@ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.57.0.tgz", - "integrity": "sha512-mp6PibWbao3aizijcheOeHQaYEhcUAt8pwLniYbtLfHxL/psFF0BykAwCj+s3c6qIpa8yN8keZICWrqtZ70w8g==", + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", "cpu": [ "arm64" ], @@ -61,13 +126,30 @@ "android" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.57.0.tgz", - "integrity": "sha512-T+0stuCBqmUVY+aMIvrgXhzGhHO3sD5tNiiEcYqgSdPsnukskQqn2u5qOVD0sv1l7RLdFS5Z/f5Wi9Ktyjr3Eg==", + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", "cpu": [ "arm64" ], @@ -78,13 +160,13 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.57.0.tgz", - "integrity": "sha512-O+3JbqWs/mCI2oi4xfhRO2IVPFJNDDEBV8Odo+ZpmsUOeKJfjXoNH7nDmBEQcDgK7NfjDIyE7kRgYSZcTLDO0A==", + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", "cpu": [ "x64" ], @@ -95,15 +177,15 @@ "darwin" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.57.0.tgz", - "integrity": "sha512-pxwhxVC+JkLX9twOQ/8C/vbuOQcMZyKIDmiRDZfO7yITuVcIdZCiLRqqf4QOxb2+8FWrRXzQpm+1DBKcMpHSSQ==", + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", "cpu": [ - "x64" + "arm64" ], "dev": true, "license": "MIT", @@ -112,30 +194,30 @@ "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.57.0.tgz", - "integrity": "sha512-pxBU4zH2imB/MDBfth2rOMeVxXUMjRQLCazagwLARIFH3hVlxZJBlM4nSnHXaIHJK4/qezoFCIORN6AY8Mra4A==", + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", "cpu": [ - "arm" + "x64" ], "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "freebsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.57.0.tgz", - "integrity": "sha512-JAprOzt8tycYou36ZgEw14DlRHTiN8qdtKANdV3VZIRIvTI/lh/cX13c9pJ/EnDk2GT3FASH7KvCgQ2AufAifQ==", + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", "cpu": [ "arm" ], @@ -146,173 +228,217 @@ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.57.0.tgz", - "integrity": "sha512-ajtjaxSaj9xl4BW7REt+Cef/ttzbAq00Bq4z7JUDZEfgFXdwSjH8K9bF+IcIJzZB9lKqMfQ4eHuSFOvvlvtqOg==", + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", "cpu": [ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.57.0.tgz", - "integrity": "sha512-p4Y/+RYk9Bk5WO+zHSUXAClRmZ2fbJCejMuCAsU2HhyME4jqf6Ftt/mJYEwIah1wGCBDYOB7wEGV1x5bCEZ6hA==", + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", "cpu": [ - "arm64" + "ia32" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.57.0.tgz", - "integrity": "sha512-By6tRALAZsno0F4zedmtG+wdMvJiJmJoXM4d3+A9zHE4HRXLqXITwRH8mgrlcXc5yJM2g2W3riRPwTYdgemZLQ==", + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", "cpu": [ - "ppc64" + "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.57.0.tgz", - "integrity": "sha512-skYeG+RgvyzspqVEBsEprL90OYYZfoVNqB3HcCNR6QDJyXKOzfDRT3zncnHmUaFluIlBHuY23mU1b5WGgR98hA==", + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", "cpu": [ - "riscv64" + "mips64el" ], "dev": true, - "libc": [ - "glibc" + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.57.0.tgz", - "integrity": "sha512-FFgACrZOXAXUh5KQh2mt1CDOVOZmn+QzHP71wM9QobNwyQvoFfyAeefVUltW83g3sm7LTiH3yfFqLLVUpA5ZFQ==", + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", "cpu": [ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.57.0.tgz", - "integrity": "sha512-Nm/BAOfQeFiiKd502mZn/GAVKJwtd0RdCg17G3Wz/WSOIQmDi3+7/SZH4BHn1Ye5KvTVH3ua8WvfwLLycNIuvA==", + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", "cpu": [ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.57.0.tgz", - "integrity": "sha512-BiSy5Ku3mQqyxS6YIqAJgd403wEUWvI7kerfzPxc2l/txZVmZM0pSj7oDM+4bGBExowxOi7o73jEam1W0EDTZg==", + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ "linux" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.57.0.tgz", - "integrity": "sha512-BCRkJiotz5s9afLYD2LuMvzAoDYx9H17E/YbDyu4xK7l4zHDPeny9ErSXL//i/nJyaOwRk08x4b8cgJC00+JDg==", + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", "cpu": [ "x64" ], "dev": true, - "libc": [ - "musl" + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ - "linux" + "openbsd" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.57.0.tgz", - "integrity": "sha512-4Oaxe1qrGgXfpCJ1C/ERJ2iCtV2rN1R79ga9fsfyVHfSQRu/hVW780u2KDqZWFZ/iGTHODJji0JemxqFZ63eIQ==", + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", "cpu": [ "arm64" ], @@ -323,13 +449,30 @@ "openharmony" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.57.0.tgz", - "integrity": "sha512-MYLAsDnhdNsSGheLYhWgbk0vfIrlS84iQYun/y21fX6u0jj8iBtYtbpZMdiqYeuf8U12eVPUjVY2xE2NrCfJ0g==", + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", "cpu": [ "arm64" ], @@ -340,13 +483,13 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.57.0.tgz", - "integrity": "sha512-PBwdzZALJY/jcCx2E6is0yu+cuVXeySTDmwuseD+9j0mHqlRNxwlKgsyRTBed/woPeqfVfuXfWjoq4Cx2Zt3Eg==", + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", "cpu": [ "ia32" ], @@ -357,13 +500,13 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.57.0.tgz", - "integrity": "sha512-bQJdH9i4RRfw55jm7+8/xS7GzHLLTbHx4huhrrDxQJaJtbSDbsyOnODvP1ftT7EG0KFKAYO2S+q6AcioXODx8w==", + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", "cpu": [ "x64" ], @@ -374,13 +517,26 @@ "win32" ], "engines": { - "node": "^20.19.0 || >=22.12.0" + "node": ">=18" } }, - "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.72.0.tgz", - "integrity": "sha512-zhCmvn+1Mj3UchAc/90i99S0t7jJUsHmFVSPg4UWrjO8b8eaSGwscgO6QAUtvHBstkjQwBttQNswEnAF1mIQdA==", + "node_modules/@noble/hashes": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@oxfmt/binding-android-arm-eabi": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.65.0.tgz", + "integrity": "sha512-M10Gs1SSpTNI6ahGx3M/OlIdUF4hkaP6OgUb+MS79t/Pgflk3r1nW5gPFqsZGUAXg0H1AfANT9AvLdBSTIhZKg==", "cpu": [ "arm" ], @@ -394,10 +550,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-android-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.72.0.tgz", - "integrity": "sha512-mtH+aY/ozv1eZoCUC2owjFAtyNBKHpJHygKeEu9zXXnQGW1Q2/qOpvx+I+Lf23+TvTz66F4iiXUbl2cGvoLPCQ==", + "node_modules/@oxfmt/binding-android-arm64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.65.0.tgz", + "integrity": "sha512-6DXH5sftNlaHpWJG50hFMF+Qxtq5D2TmahvcDPxWNcGIf8qrC9Y0YgHYcYZ2hlWzaccKXh/f3GcssH8vtkl4JA==", "cpu": [ "arm64" ], @@ -411,10 +567,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.72.0.tgz", - "integrity": "sha512-EvnajNPDtfknB3ZieeOOyDTwJn9QXDiwfnF4ZDQqART6RG6hjY4WigQcZdGoK2dkB3e1vrmEzN9aYbQCUkh/gQ==", + "node_modules/@oxfmt/binding-darwin-arm64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.65.0.tgz", + "integrity": "sha512-K9m7lr53pcOLETNsC88sWes/GWHUGjZyHx95UhYcSXy0r30haLdeXlSufSenEAtoLaW753WN8/l4M7GYcRt6cg==", "cpu": [ "arm64" ], @@ -428,10 +584,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.72.0.tgz", - "integrity": "sha512-ZkCdEa/G80A7vEHfeCDz/+L3m33DE73v32mDKhgOIgz8Uwf0DFcK7+uu6qC+7LEhmz5fpOe1osWKyjSNMydFIQ==", + "node_modules/@oxfmt/binding-darwin-x64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.65.0.tgz", + "integrity": "sha512-sTNwIx1gre3MyiHOPLu7IGW4UyMScYL4DTmJT01p4vzB0En+OJUQz6KuH8t0PpsClRSaMuY3b0QmtoPItfO8Lg==", "cpu": [ "x64" ], @@ -445,10 +601,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.72.0.tgz", - "integrity": "sha512-NroXv2vh+sxVY1uya/rM5pjhx1hm8BzlYpx9q67QP0Xhw5MH2bf5GJylpvLEC+781p1Xli/317EoV9AlGwViag==", + "node_modules/@oxfmt/binding-freebsd-x64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.65.0.tgz", + "integrity": "sha512-lYZMVIiIpnjGu5hJb2jxA8NYQ/e0OTGuaiAf4dqlGPNnPmUTu23FZRMltmjro/KkQm1uE4NT4n5yJ2zWmKcpfA==", "cpu": [ "x64" ], @@ -462,10 +618,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.72.0.tgz", - "integrity": "sha512-0NDywYgfj279Ou/BcQuCYSj7NJwBfmWn5qc5uGO/Ny7fUWmXyIpvawqX/8acQlWG6IXelJsJhj+JAy6sjsKj0A==", + "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.65.0.tgz", + "integrity": "sha512-gIdXFAt/bURnjxuoedDEWdZ0PEWEmdDcm8qdpoFYYvW3QMk/5D4vUaH4mlMeRpeTdST4izUgHVO6RawQ4QulJw==", "cpu": [ "arm" ], @@ -479,10 +635,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.72.0.tgz", - "integrity": "sha512-4vpXB06h65Ezsy4hRyrGjGrfa1SkVPii09yaajiYhmVpgsFiLD+KNxIx/BNAY+XiO+i1yqp9HHdwqM8VTqa5XQ==", + "node_modules/@oxfmt/binding-linux-arm-musleabihf": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.65.0.tgz", + "integrity": "sha512-jJVyADto7gA2AaX5qAjAexrxx9PJQaKWOe8PICE7yKMbjBRyOHcmj9TtVJ+MZYDUQ3hodU0AcoTj0jFQ1W4C6Q==", "cpu": [ "arm" ], @@ -496,10 +652,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.72.0.tgz", - "integrity": "sha512-immaN4g2ZGFiOkKrvRX9LvzZdd2GkQM5wR+UyzYyUuyhUTXGQ4HKUJH18xp4G8OfhCVaVAJfKZxwE1r8+4hhaQ==", + "node_modules/@oxfmt/binding-linux-arm64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.65.0.tgz", + "integrity": "sha512-p3RFkB+u7u+8up99b/NEcI1hdpLDiGgJYNwDorB60n7eH+eKposAKuMBxx+NqB3b+sJP4CZmYDh9G7X62tUsKg==", "cpu": [ "arm64" ], @@ -516,10 +672,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.72.0.tgz", - "integrity": "sha512-JGHS9Mnr7iWyyLDxgCv1MhzVpAckgptg00F2gnxt/GD7lQ2SW1BRcxHqhSTaSdDpjWRrBkBxMMh4+Hn3aVtExg==", + "node_modules/@oxfmt/binding-linux-arm64-musl": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.65.0.tgz", + "integrity": "sha512-5Prb0uFzJHr+OUD/qS/TmU526wD+PaHDsm3KoRiUXbMIDpTSErjeQYkK3OQeshAvD/PuLa9WGEi9WPajjdOZJg==", "cpu": [ "arm64" ], @@ -536,10 +692,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.72.0.tgz", - "integrity": "sha512-AOYgBZqxNshrg83P9v0RYv+m8s10Cqkj4/PxXFDhcS3k7FqsIG5+CxErshZCIN7G8iy4Y+VGfAsuEdar8AcbBg==", + "node_modules/@oxfmt/binding-linux-ppc64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.65.0.tgz", + "integrity": "sha512-S8svxTp81obnF3admN9yd+u2rOYXtyzThLGBTg1PY6TPtGcC09BaaXLQD+TBSMa7yvqhCDZ8DFri+S/yG60qCg==", "cpu": [ "ppc64" ], @@ -556,10 +712,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.72.0.tgz", - "integrity": "sha512-QMybPS5ij3/vrKG67mqzHwW++91sYxK/PPUVi6SBtNCEzW4niS52fVBdXbQ6nou0wWbUPEpx8Sl/ZjtgE3clXA==", + "node_modules/@oxfmt/binding-linux-riscv64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.65.0.tgz", + "integrity": "sha512-WtXBr75G/h2qOHy8SiGtC1R6aS3jt4mE52v1D8AtwMXIgoOmSNP9lKvbSaTRoL0e5wsMPoi6T72QWDYPu+S+nA==", "cpu": [ "riscv64" ], @@ -576,10 +732,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.72.0.tgz", - "integrity": "sha512-gOc3W7JV0PXRpIL7stUlLe3Wa9Gp0Kdlup87IT3gHDvPKck2xNgMIl/Gs2lldYY2lyXZDC4rWi3hmoLUobkgbQ==", + "node_modules/@oxfmt/binding-linux-riscv64-musl": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.65.0.tgz", + "integrity": "sha512-YwSLVvpaz4o/nv/miiPEBJz+eJ+VmbgNIrao6RccK9ce+L5EA8wP+ZD0uFeq6wKOza6zoWv/dR0sj6lip6R3EA==", "cpu": [ "riscv64" ], @@ -596,10 +752,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.72.0.tgz", - "integrity": "sha512-rpGxph+FjjHcYI5q6uxB3Az+tnfmEnDbSA8+PK9ZE/VzyUAkvBOMeuY7ZQMhu5mpZH7YQDsTdW6Cx4kV/msc6w==", + "node_modules/@oxfmt/binding-linux-s390x-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.65.0.tgz", + "integrity": "sha512-XQTPqgvyrgkKcFq+Tp2eK6JS7sqqJ+nRmy2Fav4j3I+i4dJoPJm7YwEdoeSDX9xkqj9jZ/lWfF3bXUWztIrn6A==", "cpu": [ "s390x" ], @@ -616,10 +772,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.72.0.tgz", - "integrity": "sha512-WND+uhf/Ko13SLqQMWQUgsZuLvYYEvL0ZKgg0tgGYfLqxG7l8Ju123fHDMJyYSDl5E3bUbpFUuii/OvMreFQzw==", + "node_modules/@oxfmt/binding-linux-x64-gnu": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.65.0.tgz", + "integrity": "sha512-cjZlx6S/VkeCNWCbwZriTnLnZeTcV3DEyeRGSw/2wwLP9viq+C0bJ4bC1k/ZLkFxDcB1lUgSasPkYGP1bdraOg==", "cpu": [ "x64" ], @@ -636,10 +792,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.72.0.tgz", - "integrity": "sha512-SrpbrUL70nG9vh6zP4/oKHWgLuHquwsr7MW9XOn0olBVgh10Uqr8qscKhQoBGEn6olK/IUpn5GSKcdQ5AjUhGA==", + "node_modules/@oxfmt/binding-linux-x64-musl": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.65.0.tgz", + "integrity": "sha512-2azCjxdLtK4zCcIOU1dlXlU0xxfbPi6EjwWx7Ac7teWPidIIDOcIhudup83xNCKYhtqeVd/gaVDOxbUq4syXWA==", "cpu": [ "x64" ], @@ -656,10 +812,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.72.0.tgz", - "integrity": "sha512-qkrsEn6NmgFKr7U/QnezQMb+q/vzAy0Dd9Y95gQGQTyjzDLN+HRZMuM5u70iyH4nBLCfKBzhjMsYCehKay2jyg==", + "node_modules/@oxfmt/binding-openharmony-arm64": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.65.0.tgz", + "integrity": "sha512-KXQ7xi1e/voP0IQaw6fG6XY4Z5+Llf1XmRSZS1t7pVFCecFJ0iXaboKmVwjFtp5MLlT5iWQrJ2U1C3GJdZ2u+Q==", "cpu": [ "arm64" ], @@ -673,10 +829,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.72.0.tgz", - "integrity": "sha512-LWR6ZlFZph+KPjXv8opgZsXRDCdrdQe8VL8Cg9zxCoBS73h6znzZpydVgmdnwj8mB9AuSM5jxEgDJDpQkjboeg==", + "node_modules/@oxfmt/binding-win32-arm64-msvc": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.65.0.tgz", + "integrity": "sha512-2FbbjG5jEqLSLKVJwBap84uJfpn5Y5A53KEO0aUNr+zeiRB9nyPUIFMcSbZVMFLitfBytFWRNngozXYjb6Rsbw==", "cpu": [ "arm64" ], @@ -690,10 +846,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.72.0.tgz", - "integrity": "sha512-yt6HEh7IsHvtjRWtmeZRX134eaXKHq5Gnqlf1xBJdJl1JtdoRUEJw3nAxpZoUDS860cX/foKbztO441anVBtVQ==", + "node_modules/@oxfmt/binding-win32-ia32-msvc": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.65.0.tgz", + "integrity": "sha512-LJ+ZacAPSjegDOnSLyA1TMWAhdDrsK4el3REdr1oL2UtVBCMhO2II/Sb3cEW6mF2MfLhl8hDNCSvc7KSbgk3LQ==", "cpu": [ "ia32" ], @@ -707,10 +863,10 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.72.0.tgz", - "integrity": "sha512-b2eKFD2hX7tIwmo/cyH6TDq8vzWRZ2qNHrzoGntUTmq0h3zQh/uX3eTSHCwI8OB/ADQfJCRelLItK8BsxuucDA==", + "node_modules/@oxfmt/binding-win32-x64-msvc": { + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.65.0.tgz", + "integrity": "sha512-higu9cWEO6XXFzATD1jf0mCK34rNfN2H9JrJie7QB1IhleVpTh0QlLH9Ip2C1H/Nd5n0v5pvRtC+5R0uE4HpVg==", "cpu": [ "x64" ], @@ -724,81 +880,606 @@ "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@prisma/client": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.9.0.tgz", - "integrity": "sha512-BTG/mB+WL/1sD2gWwdNc2uuVJjNNBgCDlPFdjco6jJArgbg4IAChtzVeW4debFa/NKBbsGedCjET316sjllWTQ==", + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.80.0.tgz", + "integrity": "sha512-RM3Plj+biQpxa5d1GOOX6ciDlcUROmm4OZ/pLTpitkQt2mJv4jhtY4cbgaetOm5UKWZe05/TGQ6o1Vl8EOHkrA==", + "cpu": [ + "arm" + ], "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@prisma/client-runtime-utils": "7.9.0" - }, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], "engines": { - "node": "^20.19 || ^22.12 || >=24.0" - }, - "peerDependencies": { - "prisma": "*", - "typescript": ">=5.4.0" - }, - "peerDependenciesMeta": { - "prisma": { - "optional": true - }, - "typescript": { - "optional": true - } + "node": "^20.19.0 || >=22.12.0" } }, - "node_modules/@prisma/client-runtime-utils": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.9.0.tgz", - "integrity": "sha512-kMVmS4ZEy3xlkca+TfxOEm/ToVVlOS2x1Tc6/wIRf/HfczBqENtSPcKszy4ZpFNzjJ8SRKvlU5V0rrpoFw2KOg==", + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.80.0.tgz", + "integrity": "sha512-YlO5JEf0Yr2bUUlu8O8daVcUxtcGGbcSmyV7E7nSbJbfAdxTE0PFPwgnIlw7wXJaTYjb+qs5hI5q3jxUkI7cAw==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "Apache-2.0" - }, - "node_modules/@prisma/debug": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.9.0.tgz", - "integrity": "sha512-i0KdVQuKUE6N9NloHs+sUNAk2c9svR3myBndQbA3BoeoArsSpwtNgTdHZL+wBtCLCcdS2OOC/PKhgTe36jkF5A==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/dmmf": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@prisma/dmmf/-/dmmf-7.9.0.tgz", - "integrity": "sha512-XpS08oSPWAmQFuuvEo6gMXnNCmTHZ4U2uxY9SsxjZ8RUOWJfajVF8wkM9mL2EyWreje9YXNesCbvEYrSLukK6Q==", - "license": "Apache-2.0" - }, - "node_modules/@prisma/generator": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@prisma/generator/-/generator-7.9.0.tgz", - "integrity": "sha512-CzMNeDwfaRr8tt9eKw29iZ0y0TMWrzHfZ0yvzLTJLIuSoZ9qbkVq1KXA3ELVNdLOU3ch8OFU8Ljie5EsyPpH7g==", - "license": "Apache-2.0" + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.80.0.tgz", + "integrity": "sha512-BULDOyO3AhsmdWfQeIUCykDt3dd7XZBGLhp1eIh56skRv01O+cNjNPwXMIbeW1x4+pxcln5if72wcRgViVo7PA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.80.0.tgz", + "integrity": "sha512-YJ4JzLw7N5TDSQFlA0hAQGHvnDZgyypm1yunObVWcWiF9KM7eGCJKYKLgTC2Fi/57OdnBhbj4OkzPGdFQJ6HyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.80.0.tgz", + "integrity": "sha512-AYUIk5QnL0s8oWAYsREZwkRYy1SupJTXALo93J1TgzHywxQtdM99FecRMQ87MXEdPQ0j1TmEpeeq3fGNkpvMqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.80.0.tgz", + "integrity": "sha512-9hBZVANupQ89W9dXyE0n8doCyaW5pDyGn3y6XlIMPZ+rIKuyqkr3SNUXmVJIhuvUq0NBU3RBiSXXE69l4XI6KA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.80.0.tgz", + "integrity": "sha512-SvS2uKqzY+pbfuvAHzH4338R6Zwo805GAwrIMVvK1KxoOWCIjZUdfzTCvilD7z6JK91v011+zYMryabhDo2AsQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.80.0.tgz", + "integrity": "sha512-tCLadyqRVL3pQTRPNg7cjXKvcvS4fbyXeQHhKk5BTJ1oftQln5/yIIWbu/Xom/DX41zv2P9QGt6+D/TtQVtY3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.80.0.tgz", + "integrity": "sha512-XfpCNRlOPcLlJl4Bn/FUhjqlR6BVavEykERBf/MV7YA9VZDa5g5znVqYhyviMafcxS9Pe/i/kPvHNO0U6svEHQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.80.0.tgz", + "integrity": "sha512-3I4yMwcFG9NeO8ioY6JBBuKsIm5GL/x7MATt1S4tVWaxPu5HcJ+XnLUbcVBTxG8q2Wu56HSj+NmXQiVYb1lp6A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.80.0.tgz", + "integrity": "sha512-E1wAKymkpe1/E8helzBKdm81OBOF+ezxRyXRMEuik3ZpWDER5CPOKZwF66RsdwW98uwZv8UTFremUQtC1CzdJA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.80.0.tgz", + "integrity": "sha512-+gLRGD4sIo3+VA++iham5UxD9tKSoJ/VOrROCEXIcknrYtQg6iIQgvjN0cpiRF7N6UYC7pJbvHJlDnMge5LRpQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.80.0.tgz", + "integrity": "sha512-aR0PrzHj9leW3NmzBAAP4EzdoBNoJcs9sjnIQPIwyRnBGYrRbXUIpEB5Q39AqK3PLY5JK5uEhDQDiUa1QSAstw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.80.0.tgz", + "integrity": "sha512-vSVh5cSo3Xxs6ghBCcFJlpbkbENzDog1qXtoXLa/HC3aCrR4XO76GZbXmQoCPHnu99nQpdCeC3H9tdNICfDh7A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.80.0.tgz", + "integrity": "sha512-FfzBXpNQ8u7/ZI/p8bl73MeZ508Ax3hxWp3SiJpEFiC+BB9XcXy5FAZHTLKDPSzrUpxQZSZJAVdDmuJp/+HDBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.80.0.tgz", + "integrity": "sha512-zMzbkumtmprCgRwoYNzcB3iC39fXdJIMLMU33KdCjEGLlJGOEt1+LwQ4LF8ndLzAEKVz4BR0y3V6Xrkk3Nm3yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.80.0.tgz", + "integrity": "sha512-ib6iRcrXsk4t1fm3iKcwksyWh1ZkZXC/2mEzakl0ai2+6HZunf1WWMZ/xP9EJAvw9g9K4UVTC3NF/+G2qLrbTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.80.0.tgz", + "integrity": "sha512-xhRWBMpLxZvgKAH6+DJZmpP+W8Y8UdQOSU1JfxSWNXsaBaRGW77j+1hCuNHlzj7OH4SPN8fYd1q0o2qrDtoVyw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.80.0.tgz", + "integrity": "sha512-yAnO7lwBYQnz2pcfBPIGQQZWIX5zd5R/1aAKIF3oE+TVj7IhoHcROjOkz3sRDngzqhfPKfFaXqug5j5rE5dn6Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@prisma/cli-engine": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@prisma/cli-engine/-/cli-engine-0.3.0.tgz", + "integrity": "sha512-uurQyGX46Wx/J/A//AUwX/KN+DC+byemStM1b2Xi1t3aHhQ21eIbnRDCWNVxCMdDh8CZJ7fY9hgy+tJDbslFlA==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@clack/prompts": "1.5.0", + "@stricli/core": "1.3.0", + "c12": "3.3.4", + "colorette": "^2.0.20", + "package-manager-detector": "1.8.0", + "string-width": "^8.2.1" + }, + "engines": { + "node": ">=22.12.0" + }, + "peerDependencies": { + "@prisma/management-api-sdk": "^1.55.0" + } + }, + "node_modules/@prisma/management-api-sdk": { + "version": "1.70.0", + "resolved": "https://registry.npmjs.org/@prisma/management-api-sdk/-/management-api-sdk-1.70.0.tgz", + "integrity": "sha512-QmJo0ASv+6I/6wbdE0b8aoqrx2gr6ZKS0Y6Ui1q/1zvulD/uiTVdDdUcDFktlfXHZCQvu//siNWPiILknq+68A==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "openapi-fetch": "0.14.0" + } + }, + "node_modules/@prisma/orm-family-sql": { + "version": "8.0.0-rc.8", + "resolved": "https://registry.npmjs.org/@prisma/orm-family-sql/-/orm-family-sql-8.0.0-rc.8.tgz", + "integrity": "sha512-Bz9ZMx2ntXbuasl3Dhq99NLKRq5ZNk5IVcs8RmfOvs1vUFJAEyyJmPgIArRm7N4WixZ9bEJLYKPSCo7avqxSfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/orm-framework": "8.0.0-rc.8", + "@prisma/orm-toolchain": "8.0.0-rc.8", + "@standard-schema/spec": "^1.1.0", + "arktype": "^2.2.2", + "pathe": "^2.0.3", + "pluralize": "^8.0.0", + "ts-toolbelt": "^9.6.0" + }, + "peerDependencies": { + "typescript": ">=5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/orm-framework": { + "version": "8.0.0-rc.8", + "resolved": "https://registry.npmjs.org/@prisma/orm-framework/-/orm-framework-8.0.0-rc.8.tgz", + "integrity": "sha512-bSa1TCoXMk9PQ8FC+2038+Ug0O6W52CPLKkTHpOhm6OAkg7OYtUswUVeiOgkTatfjl3O7GNOCNHvRROhbIBvpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "arktype": "^2.2.2", + "pathe": "^2.0.3", + "uniku": "^0.5.0" + }, + "peerDependencies": { + "typescript": ">=5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/orm-postgres": { + "version": "8.0.0-rc.8", + "resolved": "https://registry.npmjs.org/@prisma/orm-postgres/-/orm-postgres-8.0.0-rc.8.tgz", + "integrity": "sha512-7IG2hVQbIaEMYJMgXwC72gc7tUBpO1PgFvxXjlbGYTbeO+xFjDPs21MwZUT4ra420xbHZ8KPIExzd/EOfthZtg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/orm-family-sql": "8.0.0-rc.8", + "@prisma/orm-framework": "8.0.0-rc.8", + "@prisma/orm-target-postgres": "8.0.0-rc.8", + "@prisma/orm-toolchain": "8.0.0-rc.8", + "@types/pg": "8.20.4", + "pathe": "^2.0.3", + "pg": "8.22.0" + }, + "peerDependencies": { + "typescript": ">=5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/orm-postgres/node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/@prisma/orm-target-postgres": { + "version": "8.0.0-rc.8", + "resolved": "https://registry.npmjs.org/@prisma/orm-target-postgres/-/orm-target-postgres-8.0.0-rc.8.tgz", + "integrity": "sha512-sbLLiXEw8y/vksSB+S+Tk2Yw/HThkNMKu4k4Q80vwdwVodhzW/KWYYOXey9XDvmUsUp07E2QmLShF6eyF8dzXQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/orm-family-sql": "8.0.0-rc.8", + "@prisma/orm-framework": "8.0.0-rc.8", + "@prisma/orm-toolchain": "8.0.0-rc.8", + "@standard-schema/spec": "^1.1.0", + "@types/pg": "8.20.4", + "arktype": "^2.2.2", + "pathe": "^2.0.3", + "pg": "8.22.0", + "pg-cursor": "^2.21.0" + }, + "peerDependencies": { + "typescript": ">=5.9" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/orm-target-postgres/node_modules/pg": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", + "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.14.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.15.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } }, - "node_modules/@prisma/generator-helper": { - "version": "7.9.0", - "resolved": "https://registry.npmjs.org/@prisma/generator-helper/-/generator-helper-7.9.0.tgz", - "integrity": "sha512-iFPTZ5AfP3yjV+u4FunwLGGS1O/GcXLHolXSsTEoAtp6y/cLLBxjnJWrOm2GU/op9K28slFOH3xtvRq3CNZMcA==", + "node_modules/@prisma/orm-toolchain": { + "version": "8.0.0-rc.8", + "resolved": "https://registry.npmjs.org/@prisma/orm-toolchain/-/orm-toolchain-8.0.0-rc.8.tgz", + "integrity": "sha512-dKjgH3L4adsuhFwEcMO/8er8E7WOR6btTTR1NaOlef4lu1EHWvYFRVPGoJOTTRTSvASC8eAQmuMTPYpuZNsdWg==", + "dev": true, "license": "Apache-2.0", "dependencies": { - "@prisma/debug": "7.9.0", - "@prisma/dmmf": "7.9.0", - "@prisma/generator": "7.9.0" + "@prisma/orm-framework": "8.0.0-rc.8", + "@vercel/detect-agent": "^1.2.4", + "arktype": "^2.2.2", + "c12": "^3.3.4", + "ci-info": "^4.3.1", + "clipanion": "4.0.0-rc.4", + "closest-match": "^1.3.3", + "colorette": "^2.0.20", + "esbuild": "^0.28.2", + "jsonc-parser": "^3.3.1", + "package-manager-detector": "^1.8.0", + "pathe": "^2.0.3", + "prettier": "^3.9.6", + "string-width": "^8.2.2", + "strip-ansi": "^7.2.0", + "vscode-languageserver": "10.1.0", + "vscode-languageserver-textdocument": "1.0.12", + "wrap-ansi": "^10.0.0" + }, + "peerDependencies": { + "@prisma/cli-engine": "0.3.0", + "typescript": ">=5.9", + "vite": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vite": { + "optional": true + } } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@stricli/core": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@stricli/core/-/core-1.3.0.tgz", + "integrity": "sha512-LnBe2HntygaLDU5trtjiC3J4C/YkmIZuM0XB52IF4qaLqJH09kgD1fnZiS2gaYUnt0nAN2a+1b4PSOBcfPrN4Q==", + "dev": true, + "license": "Apache-2.0", + "peer": true + }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", + "version": "26.4.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.4.0.tgz", + "integrity": "sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~8.3.0" } }, "node_modules/@types/pg": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", - "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", + "version": "8.20.4", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.4.tgz", + "integrity": "sha512-Jz7UDOlIiFJuacC0TlBoLyNtmwlA/wpIyPDd3tvUqlRM+HzkWy2xUgpFpaXtbfTAFF6sIGq5lsCDBdJnhky1Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -1019,138 +1700,466 @@ "x64" ], "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vercel/detect-agent": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@vercel/detect-agent/-/detect-agent-1.2.5.tgz", + "integrity": "sha512-0krENrjuitlW8s6TJu0MlqCevyCU7K7JK63jZAf7xZ6n17tx+vUEwzHT3sTxawtwZxaW21hu+oFUpoOrm49FsQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/ansi-regex": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", + "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/arkregex": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/arkregex/-/arkregex-0.0.8.tgz", + "integrity": "sha512-PJcx6G1kQTgLKPUbeYlYecDRaKq15AMSGVajlKFYWlPeJRQL+j3dKE6tyMs40HZ99djS1l9Vhl3ezAHy9JBIqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ark/util": "0.56.2" + } + }, + "node_modules/arktype": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/arktype/-/arktype-2.2.3.tgz", + "integrity": "sha512-7W+0RLTUNJiBFIIZXwOQxSR8Z273IAd6IvqBeG9+gHnQKFsIx2C0iOtGTmMrPnlX4qLXyc5+ll7A0BIj9WrbTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ark/schema": "0.56.2", + "@ark/util": "0.56.2", + "arkregex": "0.0.8" + } + }, + "node_modules/c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "license": "MIT", + "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": "*" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ci-info": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.4.0.tgz", + "integrity": "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/clipanion": { + "version": "4.0.0-rc.4", + "resolved": "https://registry.npmjs.org/clipanion/-/clipanion-4.0.0-rc.4.tgz", + "integrity": "sha512-CXkMQxU6s9GklO/1f714dkKBMu1lopS1WFF0B8o4AxPykR1hpozxSiUZ5ZUeBjfPgCWqbcNOtZVFhB8Lkfp1+Q==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ], + "dependencies": { + "typanion": "^3.8.0" + }, + "peerDependencies": { + "typanion": "*" + } + }, + "node_modules/closest-match": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/closest-match/-/closest-match-1.3.3.tgz", + "integrity": "sha512-RSdHrZwNOvt2uMQgqJDJdM/I+5MlJ1tQJEXYrbRjSMXWiCRo06g2hwObJ7+WKt2J9ySK9/pJ0Q2vbL+BPkofDA==", + "dev": true, + "license": "ISC" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, + "license": "BSD-2-Clause", "engines": { - "node": ">=16.20.0" + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" } }, - "node_modules/@typescript/typescript-netbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", - "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", - "cpu": [ - "arm64" - ], + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, "engines": { - "node": ">=16.20.0" - } + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.1.tgz", + "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", + "dev": true, + "license": "MIT" }, - "node_modules/@typescript/typescript-netbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", - "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", - "cpu": [ - "x64" - ], + "node_modules/fast-string-truncated-width": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-string-truncated-width/-/fast-string-truncated-width-3.0.3.tgz", + "integrity": "sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "peer": true + }, + "node_modules/fast-string-width": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-string-width/-/fast-string-width-3.0.2.tgz", + "integrity": "sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/@typescript/typescript-openbsd-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", - "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", - "cpu": [ - "arm64" - ], + "node_modules/fast-wrap-ansi": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", + "integrity": "sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "peer": true, + "dependencies": { + "fast-string-width": "^3.0.2" } }, - "node_modules/@typescript/typescript-openbsd-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", - "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", - "cpu": [ - "x64" - ], + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "openbsd" - ], + "license": "MIT", "engines": { - "node": ">=16.20.0" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@typescript/typescript-sunos-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", - "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", - "cpu": [ - "x64" - ], + "node_modules/giget": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.1.tgz", + "integrity": "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" } }, - "node_modules/@typescript/typescript-win32-arm64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", - "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", - "cpu": [ - "arm64" - ], + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" } }, - "node_modules/@typescript/typescript-win32-x64": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", - "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", - "cpu": [ - "x64" - ], + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=16.20.0" + "license": "MIT" + }, + "node_modules/ohash": { + "version": "2.0.12", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.12.tgz", + "integrity": "sha512-65S/5gk9YSsaRjcyf7Nfa6h/d3E8/1gslpXfI4W7Dxn/oap8IKRuNT5VXkLQ1YFKIEg4apRY4Pj6aiwFzrDdmw==", + "dev": true, + "license": "MIT" + }, + "node_modules/openapi-fetch": { + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.14.0.tgz", + "integrity": "sha512-PshIdm1NgdLvb05zp8LqRQMNSKzIlPkyMxYFxwyHR+UlKD4t2nUjkDhNxeRbhRSEd3x5EUNh2w5sJYwkhOH4fg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "openapi-typescript-helpers": "^0.0.15" } }, + "node_modules/openapi-typescript-helpers": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.0.15.tgz", + "integrity": "sha512-opyTPaunsklCBpTK8JGef6mfPhLSnyy5a0IN9vKtx3+4aExf+KxEqYwIy3hqkedXIB97u357uLMJsOnm3GVjsw==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/oxfmt": { - "version": "0.57.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.57.0.tgz", - "integrity": "sha512-ZB7Bi+rGDSqmVIo9jwcLyFgjxXvQhDdU+jx+ZrVy6VRiVXK2+CHc4hO3J4dUQjHe7V0ymHB+MDuv5z+NhK07HA==", + "version": "0.65.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.65.0.tgz", + "integrity": "sha512-SgS5VgnP42T0zl3zWD+xoH8FCqg1SAFnSRoOT/qeoa6gxcYIqrDMOmcXIg/EWSN92Du4ogB4riuKhKd6Y4CGhw==", "dev": true, "license": "MIT", "dependencies": { @@ -1166,25 +2175,25 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.57.0", - "@oxfmt/binding-android-arm64": "0.57.0", - "@oxfmt/binding-darwin-arm64": "0.57.0", - "@oxfmt/binding-darwin-x64": "0.57.0", - "@oxfmt/binding-freebsd-x64": "0.57.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.57.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.57.0", - "@oxfmt/binding-linux-arm64-gnu": "0.57.0", - "@oxfmt/binding-linux-arm64-musl": "0.57.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.57.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.57.0", - "@oxfmt/binding-linux-riscv64-musl": "0.57.0", - "@oxfmt/binding-linux-s390x-gnu": "0.57.0", - "@oxfmt/binding-linux-x64-gnu": "0.57.0", - "@oxfmt/binding-linux-x64-musl": "0.57.0", - "@oxfmt/binding-openharmony-arm64": "0.57.0", - "@oxfmt/binding-win32-arm64-msvc": "0.57.0", - "@oxfmt/binding-win32-ia32-msvc": "0.57.0", - "@oxfmt/binding-win32-x64-msvc": "0.57.0" + "@oxfmt/binding-android-arm-eabi": "0.65.0", + "@oxfmt/binding-android-arm64": "0.65.0", + "@oxfmt/binding-darwin-arm64": "0.65.0", + "@oxfmt/binding-darwin-x64": "0.65.0", + "@oxfmt/binding-freebsd-x64": "0.65.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.65.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.65.0", + "@oxfmt/binding-linux-arm64-gnu": "0.65.0", + "@oxfmt/binding-linux-arm64-musl": "0.65.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.65.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.65.0", + "@oxfmt/binding-linux-riscv64-musl": "0.65.0", + "@oxfmt/binding-linux-s390x-gnu": "0.65.0", + "@oxfmt/binding-linux-x64-gnu": "0.65.0", + "@oxfmt/binding-linux-x64-musl": "0.65.0", + "@oxfmt/binding-openharmony-arm64": "0.65.0", + "@oxfmt/binding-win32-arm64-msvc": "0.65.0", + "@oxfmt/binding-win32-ia32-msvc": "0.65.0", + "@oxfmt/binding-win32-x64-msvc": "0.65.0" }, "peerDependencies": { "svelte": "^5.0.0", @@ -1200,9 +2209,9 @@ } }, "node_modules/oxlint": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.72.0.tgz", - "integrity": "sha512-1rhdZIP/EvoI91ABIwNU5Q8+bWf8mjrS5UzIOZld4d4bXxJvtlUhlQvaoTogIGin/qdErMOrwaIJvCSIAKTLhA==", + "version": "1.80.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.80.0.tgz", + "integrity": "sha512-5nTiSps4qdbCWLbxzuO00alHkEO2exR9YMN/ig6QXWrLsYSG0KaObOAM+l6oU2LcKPWoSAGYbkZIGEu1ViiWKA==", "dev": true, "license": "MIT", "bin": { @@ -1215,28 +2224,28 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.72.0", - "@oxlint/binding-android-arm64": "1.72.0", - "@oxlint/binding-darwin-arm64": "1.72.0", - "@oxlint/binding-darwin-x64": "1.72.0", - "@oxlint/binding-freebsd-x64": "1.72.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.72.0", - "@oxlint/binding-linux-arm-musleabihf": "1.72.0", - "@oxlint/binding-linux-arm64-gnu": "1.72.0", - "@oxlint/binding-linux-arm64-musl": "1.72.0", - "@oxlint/binding-linux-ppc64-gnu": "1.72.0", - "@oxlint/binding-linux-riscv64-gnu": "1.72.0", - "@oxlint/binding-linux-riscv64-musl": "1.72.0", - "@oxlint/binding-linux-s390x-gnu": "1.72.0", - "@oxlint/binding-linux-x64-gnu": "1.72.0", - "@oxlint/binding-linux-x64-musl": "1.72.0", - "@oxlint/binding-openharmony-arm64": "1.72.0", - "@oxlint/binding-win32-arm64-msvc": "1.72.0", - "@oxlint/binding-win32-ia32-msvc": "1.72.0", - "@oxlint/binding-win32-x64-msvc": "1.72.0" + "@oxlint/binding-android-arm-eabi": "1.80.0", + "@oxlint/binding-android-arm64": "1.80.0", + "@oxlint/binding-darwin-arm64": "1.80.0", + "@oxlint/binding-darwin-x64": "1.80.0", + "@oxlint/binding-freebsd-x64": "1.80.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.80.0", + "@oxlint/binding-linux-arm-musleabihf": "1.80.0", + "@oxlint/binding-linux-arm64-gnu": "1.80.0", + "@oxlint/binding-linux-arm64-musl": "1.80.0", + "@oxlint/binding-linux-ppc64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-gnu": "1.80.0", + "@oxlint/binding-linux-riscv64-musl": "1.80.0", + "@oxlint/binding-linux-s390x-gnu": "1.80.0", + "@oxlint/binding-linux-x64-gnu": "1.80.0", + "@oxlint/binding-linux-x64-musl": "1.80.0", + "@oxlint/binding-openharmony-arm64": "1.80.0", + "@oxlint/binding-win32-arm64-msvc": "1.80.0", + "@oxlint/binding-win32-ia32-msvc": "1.80.0", + "@oxlint/binding-win32-x64-msvc": "1.80.0" }, "peerDependencies": { - "oxlint-tsgolint": ">=0.22.1", + "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "peerDependenciesMeta": { @@ -1248,15 +2257,36 @@ } } }, + "node_modules/package-manager-detector": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.8.0.tgz", + "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "dev": true, + "license": "MIT" + }, "node_modules/pg": { - "version": "8.22.0", - "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", - "integrity": "sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz", + "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==", "license": "MIT", "dependencies": { "pg-connection-string": "^2.14.0", "pg-pool": "^3.14.0", - "pg-protocol": "^1.15.0", + "pg-protocol": "^1.16.0", "pg-types": "2.2.0", "pgpass": "1.0.5" }, @@ -1288,6 +2318,16 @@ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==", "license": "MIT" }, + "node_modules/pg-cursor": { + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/pg-cursor/-/pg-cursor-2.22.0.tgz", + "integrity": "sha512-knzXLKqarTjOvb3qDSW0JiGsazmxwEKXrqHfWRte7XUsOYccQRafn3BLnQobWwInkzFJSyOej8y8cQRh2z3kGw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "pg": "^8" + } + }, "node_modules/pg-int8": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", @@ -1307,9 +2347,9 @@ } }, "node_modules/pg-protocol": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.15.0.tgz", - "integrity": "sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz", + "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==", "license": "MIT" }, "node_modules/pg-types": { @@ -1337,6 +2377,28 @@ "split2": "^4.1.0" } }, + "node_modules/pkg-types": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", + "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -1376,6 +2438,55 @@ "node": ">=0.10.0" } }, + "node_modules/prettier": { + "version": "3.9.6", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", + "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "node_modules/readdirp": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.1.1.tgz", + "integrity": "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/sisteransi": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", + "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", @@ -1385,6 +2496,39 @@ "node": ">= 10.x" } }, + "node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, "node_modules/tinypool": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", @@ -1395,6 +2539,23 @@ "node": "^20.0.0 || >=22.0.0" } }, + "node_modules/ts-toolbelt": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/ts-toolbelt/-/ts-toolbelt-9.6.0.tgz", + "integrity": "sha512-nsZd8ZeNUzukXPlJmTBwUAuABDe/9qtVDelJeT/qW0ow3ZS3BsQJtNkan1802aM9Uf68/Y8ljw86Hu0h5IUW3w==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/typanion": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/typanion/-/typanion-3.14.0.tgz", + "integrity": "sha512-ZW/lVMRabETuYCd9O9ZvMhAh8GslSqaUjxmK/JLPCh6l73CvLBiuXswj/+7LdnWOgYsQ130FqLzFz5aGT4I3Ug==", + "dev": true, + "license": "MIT", + "workspaces": [ + "website" + ] + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -1431,12 +2592,90 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/uniku": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/uniku/-/uniku-0.5.0.tgz", + "integrity": "sha512-giSrg7xqM5YWkSlyheulHgTTInhYh/m0cFZOOuChi/TO87hKlxmZLllETlvDw/lPB54NIs1iW/x2rr0y2yFXHg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@noble/hashes": "^2.2.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.1.tgz", + "integrity": "sha512-rfuA6T75H6m5EkbhtEPzre9pT0HPcDI2MMy4+nPFIBks5J8JBAUHD4tRYSgaBOijIEC7SRkC1kKyXTLqbmh9jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/vscode-languageserver": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver/-/vscode-languageserver-10.1.0.tgz", + "integrity": "sha512-9gEWpXkYGXoqG7pBnE8O8hx/yP7+Aabn4+peQ3KDicQv6qunHSWyLTud3OF0w4S2+HfDD+5HqYKiXQW9HAU6mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-languageserver-protocol": "3.18.2" + }, + "bin": { + "installServerIntoExtension": "bin/installServerIntoExtension" + } + }, + "node_modules/vscode-languageserver-protocol": { + "version": "3.18.2", + "resolved": "https://registry.npmjs.org/vscode-languageserver-protocol/-/vscode-languageserver-protocol-3.18.2.tgz", + "integrity": "sha512-XRyDbT0Pp3sSNti3JmxVEUMySWCSi1hhM+/KUlCy1hV1zmrqpM1OwO12EAki8blhmLuIMpaJrYbo0OzGVfK2Qg==", + "dev": true, + "license": "MIT", + "dependencies": { + "vscode-jsonrpc": "9.0.1", + "vscode-languageserver-types": "3.18.0" + } + }, + "node_modules/vscode-languageserver-textdocument": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/vscode-languageserver-textdocument/-/vscode-languageserver-textdocument-1.0.12.tgz", + "integrity": "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==", "dev": true, "license": "MIT" }, + "node_modules/vscode-languageserver-types": { + "version": "3.18.0", + "resolved": "https://registry.npmjs.org/vscode-languageserver-types/-/vscode-languageserver-types-3.18.0.tgz", + "integrity": "sha512-8TsGPNMIMiiBdkORgRSvLjuiEIiAFtO+KssmYWxQ+uSVvlf7RjK8YKCOjPzZ+YA04jXEV7+7LvkSmHkhpNS99g==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.1.tgz", + "integrity": "sha512-M0N4xzyzosiIok3svYlEo1sdLZts/8FPgYH/GPC3wvlmPoRvnoManGMrE54waYj3tISA8w6lsdesfVv67qSr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index b10fc39..e64fe93 100644 --- a/package.json +++ b/package.json @@ -1,23 +1,23 @@ { "name": "@imqueue/pg-prisma", "version": "1.1.0", - "description": "Prisma/Postgres toolkit for Node.js & TypeScript microservices — query extensions (soft-delete, audit, authorship, access-scope), row archiving, change-notify triggers, down-migrations, and a Prisma generator for typed @imqueue/rpc models (the @imqueue framework)", + "description": "Prisma Next (8.x) / Postgres toolkit for Node.js & TypeScript microservices — query middlewares (soft-delete, authorship, audit, access-scope) derived from the emitted contract, row archiving and change-notify triggers (the @imqueue framework)", "keywords": [ "prisma", "postgres", "postgresql", "prisma-extension", - "prisma-generator", "soft-delete", "audit", "access-control", - "migrations", "typescript", "nodejs", "microservices", "rpc", "soa", - "imqueue" + "imqueue", + "prisma-next", + "middleware" ], "scripts": { "clean-compiled": "npm run clean-js && npm run clean-typedefs && npm run clean-maps", @@ -49,18 +49,17 @@ "author": "imqueue.com (https://imqueue.com)", "license": "GPL-3.0-only", "dependencies": { - "@prisma/generator-helper": "^7.8.0", - "pg": "^8.22.0" + "pg": "^8.23.0" }, "peerDependencies": { - "@prisma/client": ">=7" + "@prisma/orm-postgres": ">=8.0.0-rc.8" }, "devDependencies": { - "@prisma/client": "^7.8.0", - "@types/node": "^24.9.1", - "@types/pg": "^8.20.0", - "oxfmt": "0.57.0", - "oxlint": "1.72.0", + "@prisma/orm-postgres": "^8.0.0-rc.8", + "@types/node": "^26.4.0", + "@types/pg": "8.20.4", + "oxfmt": "^0.65.0", + "oxlint": "^1.80.0", "typescript": "^7.0.2" }, "main": "index.js", @@ -74,10 +73,6 @@ "types": "./index.d.ts", "default": "./index.js" }, - "./codegen": { - "types": "./src/codegen.d.ts", - "default": "./src/codegen.js" - }, "./package.json": "./package.json" } } diff --git a/src/access-scope.ts b/src/access-scope.ts index c7020d3..701b7e1 100644 --- a/src/access-scope.ts +++ b/src/access-scope.ts @@ -1,8 +1,8 @@ /*! - * Prisma access-scope query-extension helper + * Prisma Next (8.x) access-scope query middleware * * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com + * Copyright (C) 2026 imqueue.com * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,239 +22,147 @@ * to get commercial licensing options. */ -import { Prisma } from '@prisma/client/extension'; - -/** - * Per-model access-scope config: `model → level → columns`. A record is in - * scope for a level when ANY of the level's columns matches (OR); a model is in - * scope when EVERY active level matches (AND). See the generated - * `ACCESS_SCOPE_MODELS`. - */ -export type AccessScopeModels = Record>; +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; +import type { AnyExpression } from '@prisma/orm-postgres/relational-core/ast'; +import { AndExpr, OrExpr } from '@prisma/orm-postgres/relational-core/ast'; +import { + type DraftPlan, + columnCondition, + conjoin, + filterSelects, + qualifierOf, +} from './ast.js'; +import type { ScopeTables } from './derive.js'; /** * Resolves the current request's value for one access level: * - `undefined` — the level does not constrain this request (skip it), - * - `null` — active but there is no value → deny (match nothing), + * - `null` — active but valueless, so deny (match nothing), * - a string — match rows where a scope column equals it, - * - an array — match rows where a scope column is `IN` it (empty → deny). + * - an array — match rows where a scope column is `IN` it (empty denies). */ export type AccessScopeResolver = () => string | string[] | null | undefined; -/** Everything {@link accessScope} needs to build its extension. */ +/** Everything {@link accessScope} needs to build its middleware. */ export interface AccessScopeOptions { - /** Scope columns per model per level (see the generated config). */ - models: AccessScopeModels; + /** Scope columns per physical table per level. */ + tables: ScopeTables; /** * One resolver per access level, keyed by level name. * * @remarks - * A level named in `models` but missing here is skipped entirely, so an - * unregistered resolver silently widens access rather than denying it. Keep - * the two keyed consistently. + * A level named in `tables` but missing here is skipped entirely, so an + * unregistered resolver silently widens access rather than denying it. */ resolvers: Record; } -type ScopeValue = string | string[] | null; - -/** One column's condition for a level value: `=`, `IN`, or the deny sentinel. */ -function columnCondition( - column: string, - value: ScopeValue, -): Record { - if (value === null) { - // Active but valueless → an impossible filter (nothing is `IN ()`). - return { [column]: { in: [] as string[] } }; - } - if (Array.isArray(value)) { - return { [column]: { in: value } }; - } - - return { [column]: value }; -} - -/** OR the level's columns: a row is in scope if any column matches the value. */ -function levelFilter( - columns: string[], - value: ScopeValue, -): Record { - return { OR: columns.map(column => columnCondition(column, value)) }; -} - /** - * Compose the access-scope `where` clause for a single model. + * Compose the scope predicate for one table, or null when nothing constrains + * it. * * @remarks - * Each active level — one whose resolver returns anything other than `undefined` — - * contributes an OR across its columns, and the level filters are AND-ed together - * and AND-ed onto the caller's own `where`. Nothing is merged by key, so a caller - * cannot widen or override the scope by supplying a condition on a scope column. + * Each active level contributes an OR across its columns and the levels are + * AND-ed together. Exported so the composition can be tested without a + * database — getting it inverted leaks rows rather than raising an error. * - * This is the pure half of the mechanism, exported so the composition can be - * tested and reused directly; {@link accessScope} is what applies it to queries. + * A level configured with no columns yields an empty OR, which Postgres reads + * as `FALSE`: it denies everything. That is the safe direction, and it is what + * an empty column list should mean. * - * @param where - The caller's own filter, or undefined. - * @param config - Scope columns per level for this one model, or undefined when - * the model is not scoped. - * @param resolvers - One resolver per access level, keyed by level name. - * @returns The combined filter, or `where` unchanged when the model is not scoped - * or no level is active — returned by identity, so callers can compare. + * @param qualifier - Table alias or name to build columns against. + * @param levels - Scope columns per level for this one table. + * @param resolvers - One resolver per access level. + * @returns The predicate, or null when no level is active. */ -export function accessWhere( - where: Record | undefined, - config: Record | undefined, +export function scopePredicate( + qualifier: string, + levels: Record | undefined, resolvers: Record, -): Record | undefined { - if (!config) { - return where; - } - const filters: Record[] = []; - for (const [level, columns] of Object.entries(config)) { - const resolver = resolvers[level]; - if (!resolver) { - continue; - } - const value = resolver(); - if (value === undefined) { - continue; - } - filters.push(levelFilter(columns, value)); +): AnyExpression | null { + if (!levels) { + return null; } - if (filters.length === 0) { - return where; + const active = Object.entries(levels) + .filter(([level]) => resolvers[level]) + .map(([level, columns]) => ({ + columns, + value: resolvers[level]?.(), + })) + .filter(entry => entry.value !== undefined) + .map(entry => + OrExpr.of( + entry.columns.map(column => + columnCondition( + qualifier, + column, + entry.value as string | string[] | null, + ), + ), + ), + ); + if (active.length === 0) { + return null; } - /* - * The caller's own conditions stay at the top level; only ours go into - * `AND`. - * - * Nesting the caller's `where` inside `AND` — which this did — is correct - * for `findMany` and silently fatal for `update`, `delete` and - * `findUnique`. Those take a `WhereUniqueInput`, and Prisma requires a - * unique field to appear at the *top level* of it: moving `id` one level - * down leaves the argument with no unique field at all, and Prisma refuses - * the call outright rather than filtering anything. Every scoped update in - * a service using this extension therefore threw a validation error, in - * scope or out — and a service that logs rather than rethrows reports that - * to its caller as a save that succeeded and changed nothing. - * - * Spreading is exactly as safe as nesting. Top-level conditions and `AND` - * conjoin, so a caller supplying their own value for a scope column gets - * ours as well and cannot widen past it; a caller's own `AND` is kept - * rather than overwritten, which is the one key that would otherwise be - * lost by spreading. - */ - const { AND: callerAnd, ...rest } = where ?? {}; - const conjoined = - callerAnd === undefined - ? [] - : Array.isArray(callerAnd) - ? callerAnd - : [callerAnd]; - - return { ...rest, AND: [...conjoined, ...filters] }; + return active.length === 1 + ? (active[0] as AnyExpression) + : AndExpr.of(active); } -type ReadArgs = { where?: Record }; - /** - * Build the query extension that restricts queries to the records the active - * access levels allow. + * Build the middleware restricting statements to the records a caller may see. * * @remarks - * For each scoped model, every level whose resolver returns a value contributes an - * OR across that level's columns; the level filters are AND-ed together and AND-ed - * onto the caller's `where`. A caller therefore cannot widen out of scope, and no - * scope column can be spoofed by supplying it in the query. A `null` from a - * resolver denies by matching nothing (`IN ()`), and an array becomes an `IN` — - * including an empty array, which also denies. + * The predicate is AND-ed onto whatever the statement already filters by, so a + * caller cannot widen out of scope and no scope column can be spoofed by + * supplying it in the query. `insert` is deliberately untouched: there is no + * existing row to filter, and ownership is stamped by `stamp`. * - * Coverage is ten operations: the reads (`findMany`, `findFirst`, `findUnique`, - * their `OrThrow` variants and `count`) plus `update`, `updateMany`, `delete` and - * `deleteMany`. On the writes the effect is silent rather than an error — an - * out-of-scope `updateMany` simply affects no rows, and an out-of-scope `update` - * throws the ordinary not-found. `create` is deliberately untouched: there is no - * existing row to filter, and ownership is stamped by {@link authorship}. + * Reads are filtered across the **whole statement**. Prisma Next compiles a + * relation read into one statement holding several selects, so a filter on the + * outermost `from` alone would return out-of-scope rows through any `include` + * — the exact bypass the Prisma 7 extension documented as a limitation. * - * Relations are never touched. A nested `where`, `include` or `select` is fetched - * as-is, so reaching a scoped model through a relation bypasses the scope — filter - * explicitly at those call sites when it matters. - * - * @param input - The per-model scope config and one resolver per level. - * @returns A Prisma extension to pass to `client.$extends()`. - * @example - * ```typescript - * const client = new PrismaClient().$extends(accessScope({ - * models: ACCESS_SCOPE_MODELS, - * resolvers: { - * // undefined for an admin: the level does not constrain them at all - * tenant: () => context.get()?.tenantId, - * }, - * })); - * ``` + * @param input - Per-table scope config and one resolver per level. + * @returns Middleware for the `middleware` array of the `postgres()` factory. */ -export function accessScope({ models, resolvers }: AccessScopeOptions) { - const restrict = (model: string, args: unknown): void => { - const config = models[model]; - if (!config) { - return; - } - const a = args as ReadArgs; - // AND our filters onto the caller's `where` — as a conjunction, never - // merged into their keys — so the scope cannot be removed or overridden - // by caller-supplied conditions. - const scoped = accessWhere(a.where, config, resolvers); - if (scoped !== a.where) { - a.where = scoped; - } - }; - - return Prisma.defineExtension({ +export function accessScope({ + tables, + resolvers, +}: AccessScopeOptions): SqlMiddleware { + return { name: 'access-scope', - query: { - $allModels: { - findMany({ model, args, query }) { - restrict(model, args); - return query(args); - }, - findFirst({ model, args, query }) { - restrict(model, args); - return query(args); - }, - findFirstOrThrow({ model, args, query }) { - restrict(model, args); - return query(args); - }, - findUnique({ model, args, query }) { - restrict(model, args); - return query(args); - }, - findUniqueOrThrow({ model, args, query }) { - restrict(model, args); - return query(args); - }, - count({ model, args, query }) { - restrict(model, args); - return query(args); - }, - update({ model, args, query }) { - restrict(model, args); - return query(args); - }, - updateMany({ model, args, query }) { - restrict(model, args); - return query(args); - }, - delete({ model, args, query }) { - restrict(model, args); - return query(args); - }, - deleteMany({ model, args, query }) { - restrict(model, args); - return query(args); - }, - }, + familyId: 'sql' as const, + async beforeCompile(draft: DraftPlan): Promise { + const ast = draft.ast; + + if (ast.kind === 'select') { + const filtered = filterSelects(ast, (table, qualifier) => + scopePredicate(qualifier, tables[table], resolvers), + ); + + return filtered.changed + ? { ...draft, ast: filtered.ast } + : undefined; + } + + if (ast.kind === 'update' || ast.kind === 'delete') { + const predicate = scopePredicate( + qualifierOf(ast.table), + tables[ast.table.name], + resolvers, + ); + + return predicate + ? { + ...draft, + ast: ast.withWhere(conjoin(ast.where, predicate)), + } + : undefined; + } + + return undefined; }, - }); + }; } diff --git a/src/archive.ts b/src/archive.ts index b807877..68b5e5d 100644 --- a/src/archive.ts +++ b/src/archive.ts @@ -23,22 +23,20 @@ */ import { createHash } from 'node:crypto'; +import type { SqlExecutor } from './sql-client.js'; import { silently } from './sql-log.js'; -/** The raw-SQL surface this installer needs (a Prisma client or its `tx`). */ -export interface ArchiveClient { - /** - * Execute a statement built by the installer. - * - * @remarks - * Named `Unsafe` because it interpolates rather than binds, which is what DDL - * requires — schema, table and column names cannot be parameters. Every - * identifier the installer interpolates is validated against - * `/^[A-Za-z_][A-Za-z0-9_]*$/` first, and it throws rather than quoting - * anything that fails. - */ - $executeRawUnsafe(sql: string, ...values: unknown[]): Promise; -} +/** + * The raw-SQL surface this installer needs. + * + * @remarks + * A `pg.Pool` satisfies it as it is. The DDL interpolates rather than binds, + * which is what DDL requires — schema, table and column names cannot be + * parameters. Every identifier interpolated here is validated against + * `/^[A-Za-z_][A-Za-z0-9_]*$/` first, and it throws rather than quoting + * anything that fails. + */ +export type ArchiveClient = SqlExecutor; /** One watched table to seed into the archive settings table. */ export interface ArchivableModel { @@ -54,7 +52,7 @@ export interface ArchivableModel { /** Everything {@link installArchiving} needs. */ export interface InstallArchiveOptions { - /** The raw-SQL surface to install through — a Prisma client or a transaction. */ + /** The raw-SQL surface to install through — a `pg.Pool`, or any executor. */ client: ArchiveClient; /** Archive schema name (default `archive`). */ archiveSchema?: string; @@ -134,7 +132,7 @@ const lit = (value: string): string => value.replace(/'/g, "''"); * @example * ```typescript * await installArchiving({ - * client: prisma, + * client: pool, * models: [{ name: 'AuditLog', periodSeconds: 7 * 24 * 3600 }], * }); * ``` @@ -161,12 +159,10 @@ export async function installArchiving( assertIdent(sourceSchema, 'source schema'); // 1. archive schema - await client.$executeRawUnsafe( - `CREATE SCHEMA IF NOT EXISTS "${archiveSchema}"`, - ); + await client.query(`CREATE SCHEMA IF NOT EXISTS "${archiveSchema}"`); // 2. settings table (+ `hash` column migration for pre-existing tables) - await client.$executeRawUnsafe( + await client.query( `CREATE TABLE IF NOT EXISTS "${archiveSchema}"."${settingsTable}" ( "table" text PRIMARY KEY, "sourceSchema" text NOT NULL DEFAULT 'public', @@ -176,7 +172,7 @@ export async function installArchiving( "hash" text NOT NULL DEFAULT '' )`, ); - await client.$executeRawUnsafe( + await client.query( `ALTER TABLE "${archiveSchema}"."${settingsTable}" ADD COLUMN IF NOT EXISTS "hash" text NOT NULL DEFAULT ''`, ); @@ -194,7 +190,7 @@ export async function installArchiving( const hash = createHash('sha1') .update([src, watchColumn, String(periodSeconds)].join('\0')) .digest('hex'); - await client.$executeRawUnsafe( + await client.query( `INSERT INTO "${archiveSchema}"."${settingsTable}" AS cfg ("table", "sourceSchema", "watchColumn", "periodSeconds", "hash") VALUES ($1, $2, $3, $4, $5) @@ -204,18 +200,14 @@ export async function installArchiving( "periodSeconds" = EXCLUDED."periodSeconds", "hash" = EXCLUDED."hash" WHERE cfg."hash" IS DISTINCT FROM EXCLUDED."hash"`, - t.name, - src, - watchColumn, - periodSeconds, - hash, + [t.name, src, watchColumn, periodSeconds, hash], ); } // 4. sweep function — reads settings at call time, so operator edits take // effect without reinstalling. Aged rows are moved atomically per table via // DELETE ... RETURNING piped into the lazily-created archive copy. - await client.$executeRawUnsafe( + await client.query( `CREATE OR REPLACE FUNCTION "${archiveSchema}"."run"() RETURNS void AS $fn$ DECLARE s record; @@ -257,7 +249,7 @@ export async function installArchiving( ); // 5. pg_cron (best-effort): create the extension if possible, then schedule. - await client.$executeRawUnsafe( + await client.query( `DO $do$ BEGIN CREATE EXTENSION IF NOT EXISTS pg_cron; @@ -269,7 +261,7 @@ export async function installArchiving( // Reconcile the schedule against pg_cron's own catalog: unschedule any // stale job that points at our run() (changed name or schedule), then // (re)create the desired one only if it isn't already present. - await client.$executeRawUnsafe( + await client.query( `DO $do$ DECLARE j record; diff --git a/src/ast.ts b/src/ast.ts new file mode 100644 index 0000000..feb75bb --- /dev/null +++ b/src/ast.ts @@ -0,0 +1,193 @@ +/*! + * Prisma Next (8.x) AST helpers shared by the query middlewares + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; +import type { + AnyExpression, + AnyQueryAst, + ParamRef as ParamRefNode, + SelectAst, + TableSource, +} from '@prisma/orm-postgres/relational-core/ast'; +import { + AndExpr, + BinaryExpr, + ColumnRef, + ListExpression, + NullCheckExpr, + ParamRef, +} from '@prisma/orm-postgres/relational-core/ast'; + +/** + * The plan draft `beforeCompile` is handed. + * + * @remarks + * Derived from the middleware's own signature rather than imported: the type + * is declared in an internal chunk that no public entry re-exports, and + * restating its shape by hand is how a field like `meta` gets quietly dropped. + */ +export type DraftPlan = NonNullable< + Awaited>> +>; + +/** Text codec every identifier column in this toolkit is stored under. */ +export const TEXT = { codecId: 'pg/text@1' } as const; + +/** Timestamp codec matching the `TimestamptzString` storage type. */ +export const TIMESTAMP = { codecId: 'pg/timestamptz-string@1' } as const; + +/** A bound parameter carrying `value` for `column`. */ +export function param( + value: unknown, + column: string, + codec: { codecId: string } = TEXT, +): ParamRefNode { + return ParamRef.of(value, { name: column, codec }); +} + +/** + * Conjoin a predicate onto whatever a statement already filters by. + * + * @remarks + * Always an `AND` of the two, never a merge of their keys, so a caller + * supplying a condition on the same column gets both and cannot widen past + * ours. + * + * @param existing - The statement's own predicate, if any. + * @param extra - The predicate to add. + * @returns The combined predicate. + */ +export function conjoin( + existing: AnyExpression | undefined, + extra: AnyExpression, +): AnyExpression { + return existing ? AndExpr.of([existing, extra]) : extra; +} + +/** + * `""."" IS NULL`. + * + * @param qualifier - Table alias where one is set, else the table name. + * @param column - The column to test. + * @returns The predicate. + */ +export function isNull(qualifier: string, column: string): AnyExpression { + return NullCheckExpr.isNull(ColumnRef.of(qualifier, column)); +} + +/** + * How a column is referred to inside the statement that selects from it. + * + * @remarks + * A `TableSource` renders as `"public"."Session" AS "s"` when it carries an + * alias, and a column qualified by the table name is then not in scope at all + * — Postgres rejects it outright. The alias is the qualifier whenever there is + * one; the name is only a fallback. + * + * @param source - The table source a predicate is being built against. + * @returns The qualifier to use in a {@link ColumnRef}. + */ +export function qualifierOf(source: TableSource): string { + return source.alias ?? source.name; +} + +/** + * One scope column's condition: equality, `IN`, or the deny sentinel. + * + * @remarks + * Both shapes bind their values. A list built from `LiteralExpr` would inline + * request-derived ids into the SQL text — the runtime's own guidance is to + * reach for `ParamRef` rather than rely on escaping — and would give every + * distinct id set its own query plan. + * + * @param qualifier - Table alias or name the column belongs to. + * @param column - The scope column. + * @param value - The level's resolved value; `null` denies. + * @returns The predicate for this column. + */ +export function columnCondition( + qualifier: string, + column: string, + value: string | string[] | null, +): AnyExpression { + const ref = ColumnRef.of(qualifier, column); + if (value === null) { + return BinaryExpr.in(ref, ListExpression.of([])); + } + if (Array.isArray(value)) { + return BinaryExpr.in( + ref, + ListExpression.of(value.map(one => param(one, column))), + ); + } + + return BinaryExpr.eq(ref, param(value, column)); +} + +/** + * Add a predicate to every `SELECT` in a statement that reads a given table. + * + * @remarks + * **This is the whole reason the middlewares are not a table lookup on the + * root of the statement.** Prisma Next compiles a relation read into one + * statement containing several selects — the related rows arrive through a + * nested select, and a paginated read wraps its subject in a derived table. A + * filter applied only to the outermost `from` therefore misses every nested + * read and returns the rows it was meant to exclude, with nothing logged. + * `AnyQueryAst.rewrite` walks the whole tree, so one pass covers the root, the + * joins, the projection subqueries and the derived sources alike. + * + * `rewrite` always returns a fresh node, so whether anything was actually + * filtered is reported rather than inferred from identity — a middleware that + * returned a new draft unconditionally would log a rewrite on every query it + * did not touch. + * + * @param ast - The statement to rewrite. + * @param predicateFor - Given a table name and the qualifier to write columns + * against, the predicate to conjoin, or null to leave that select alone. + * @returns The statement, and whether any select gained a predicate. + */ +export function filterSelects( + ast: AnyQueryAst, + predicateFor: (table: string, qualifier: string) => AnyExpression | null, +): { ast: AnyQueryAst; changed: boolean } { + const state = { changed: false }; + const rewritten = ast.rewrite({ + select: (node: SelectAst): SelectAst => { + const from = node.from; + if (from?.kind !== 'table-source') { + return node; + } + const predicate = predicateFor(from.name, qualifierOf(from)); + if (!predicate) { + return node; + } + state.changed = true; + + return node.withWhere(conjoin(node.where, predicate)); + }, + }); + + return { ast: rewritten, changed: state.changed }; +} diff --git a/src/audit.ts b/src/audit.ts index 3e12bba..f9492f8 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -1,8 +1,8 @@ /*! - * Prisma audit-trail query extension + * Prisma Next (8.x) audit query middleware * * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com + * Copyright (C) 2026 imqueue.com * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -22,248 +22,252 @@ * to get commercial licensing options. */ -import { Prisma, type PrismaClient } from '@prisma/client/extension'; +import pg from 'pg'; +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; +import type { AnyQueryAst } from '@prisma/orm-postgres/relational-core/ast'; +import type { StampTables } from './derive.js'; -/** - * The three kinds of write recorded in the audit trail. - * - * @remarks - * These are the literal strings written to the audit table's action column, so - * they are part of the stored data, not just an internal enum — a reader querying - * the trail matches on `'INSERT'`, `'UPDATE'` or `'DELETE'`. - * - * The mapping from Prisma operations is not one-to-one: `create` records `INSERT`, - * `update` and `updateMany` record `UPDATE`, `delete` and `deleteMany` record - * `DELETE`. A soft delete is recorded as `DELETE`, since it is the caller's - * `delete` that is seen — but only when {@link audit} is added before - * {@link softDelete}, because the reroute goes to the unextended client and - * otherwise never reaches the audit extension at all. - * - * The value and the type share a name and a page, as the const-plus-derived-union - * idiom requires. - */ +/** The three write actions the trail records. */ export const AuditAction = { INSERT: 'INSERT', UPDATE: 'UPDATE', DELETE: 'DELETE', } as const; -export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction]; -/** A record's `id` — every audited model carries a surrogate `id` PK — or null. */ -function recordKey(rec: Record): string | null { - return rec.id !== undefined && rec.id !== null ? String(rec.id) : null; -} +/** One of the three {@link (AuditAction:variable) | AuditAction} values. */ +export type AuditAction = (typeof AuditAction)[keyof typeof AuditAction]; /** - * Column names in the audit target table. + * Column names within the audit table. * * @remarks - * Every name is required, because the rows are written with raw SQL that quotes - * these identifiers directly — there is no Prisma model to fall back on. The code - * generator emits this as `AUDIT_CONFIG`, so it normally comes from your schema - * rather than being written by hand. + * Every one defaults to its own name, so a table whose columns are spelled the + * obvious way is configured by saying nothing. Name only what differs. */ export interface AuditColumns { /** Column holding the JSON actor, as returned by `getPrincipal`. */ - principal: string; - /** - * Column holding the action string. - * - * @remarks - * One of the three {@link (AuditAction:variable) | AuditAction} values. The member - * selector is required because the const and the type share the name, and an - * ambiguous `{@link}` renders as nothing at all rather than as an error. - */ - action: string; + principal?: string; + /** Column holding the action string. */ + action?: string; /** Column holding the name of the model that was written. */ - model: string; - /** Column holding the affected record's `id`, or `'many'` for a bulk write. */ - recordId: string; - /** Column holding the JSON payload: the record, or the args plus a count. */ - changes: string; + modelName?: string; + /** Column holding the affected record's `id`. */ + recordId?: string; + /** Column holding the JSON payload of the written row. */ + changes?: string; /** Column stamped with the database's `now()` at insert time. */ - createdAt: string; + createdAt?: string; } -/** Where the audit trail is written, and under which column names. */ +/** Where the trail goes and what its columns are called. */ export interface AuditConfig { - /** - * Table the trail is inserted into. - * - * @remarks - * Named `model` for symmetry with the rest of the config, but it is used as a - * raw table name — it need not be a Prisma model at all, which is the point of - * writing the trail with raw SQL. - */ - model: string; - /** Column names within that table. */ - columns: AuditColumns; + /** Table the trail is inserted into. Default `AuditLog`. */ + table?: string; + /** Column names within that table, where they differ from the defaults. */ + columns?: AuditColumns; } -/** Everything {@link audit} needs to build its extension. */ +/** The table and columns an audit trail has unless it says otherwise. */ +const DEFAULTS = { + table: 'AuditLog', + columns: { + principal: 'principal', + action: 'action', + modelName: 'modelName', + recordId: 'recordId', + changes: 'changes', + createdAt: 'createdAt', + }, +} as const; + +/** Everything {@link audit} needs to build its middleware. */ export interface AuditOptions { /** - * The UNEXTENDED Prisma client, used to write the audit rows. - * - * @remarks - * Deliberately unextended: audit rows written through the extended client - * would themselves be audited. - */ - client: PrismaClient; - /** Where the trail goes and what its columns are called. */ - config: AuditConfig; - /** Models whose writes are recorded to the audit log. */ - models: ReadonlySet; - /** - * Resolves the actor to record, or a falsy value to record none. + * Connection string for the trail's own pool. * * @remarks - * Called per write and serialized with `JSON.stringify`, so it can return any - * shape you want stored. Resolving it lazily is what keeps this extension - * ignorant of where the actor comes from — a request context, an auth token, - * or nothing at all. + * Deliberately a second connection rather than the client being audited: + * rows written through that client would themselves be audited, and the + * first write would not terminate. */ + connectionString: string; + /** Where the trail goes. Omitted entirely, the defaults apply. */ + config?: AuditConfig; + /** Physical table to model name, for the tables that are recorded. */ + tables: Record; + /** Soft-delete columns, so a stamped delete is recorded as a delete. */ + stamps?: StampTables; + /** Resolves the actor to record, or a falsy value to record none. */ getPrincipal: () => unknown; } +interface Entry { + action: AuditAction; + model: string; + row: Record; +} + +interface Batch { + principal: string | null; + entries: Entry[]; +} + +interface Plan { + readonly ast?: AnyQueryAst; +} + /** - * Build the query extension that records every write to an audited model. + * Build the middleware recording every write to the tables it is given. * * @remarks - * Rows are inserted into `config.model` under the names in `config.columns`, - * using raw SQL rather than a Prisma model — which is what lets the trail live in - * a table Prisma knows nothing about. The row id is generated by Postgres - * (`gen_random_uuid()`) because a Prisma-level `@default(uuid())` on the target is - * client-side and never applies to a raw insert. + * Rows are captured as the database returns them, so the trail holds what was + * actually written — including values defaulted in SQL — and is inserted once + * the statement completes. * - * What gets captured depends on the operation. `create`, `update` and `delete` - * record the affected record itself, keyed by its `id`. `updateMany` and - * `deleteMany` cannot identify rows, so they record the query args and the - * affected count under the literal `recordId` of `'many'`. A model absent from - * `models` is not recorded, and neither is a single-row write whose result has no - * `id` — every audited model is assumed to carry a surrogate `id`. + * **Buffered per execution, not per client.** `onRow` is called from an async + * generator, so two concurrent statements on one client interleave at every + * row. A single shared buffer lets one statement's `afterQuery` flush the + * other's rows and stamp them with the wrong actor, which on a security trail + * is the worst failure available. Keying by the plan object confines each + * statement to its own rows, and a `WeakMap` drops the buffer of a statement + * that is never drained — an early `break`, or a `first()` — rather than + * leaking it into whatever flushes next. * - * Auditing is fire-and-forget by design: the insert is not awaited, and a failure - * is swallowed rather than thrown or logged. A write therefore never fails - * because its audit row could not be stored — and equally, a broken audit - * configuration is silent. Verify it once against a real table rather than - * trusting that no error means it is working. + * The actor is resolved at the **first row**, inside the statement's own async + * context, for the same reason. * - * Ordering matters when this is combined with an extension that reroutes an - * operation to another client — {@link softDelete} turning a delete into an - * update is exactly that. Prisma runs the first-added query hook outermost, so - * `audit` must be added FIRST or the rerouted operation never reaches it and - * vanishes from the trail. + * Call {@link close} when shutting down, or the pool keeps the process alive. * - * @param input - The unextended client, the target config, the audited model - * names, and the actor resolver. - * @returns A Prisma extension to pass to `client.$extends()`. - * @example - * ```typescript - * const base = new PrismaClient(); - * const client = base - * .$extends(audit({ - * client: base, - * config: AUDIT_CONFIG, - * models: new Set(['User']), - * getPrincipal: () => context.get()?.user ?? null, - * })) - * .$extends(softDelete({ client: base, models: SOFT_DELETE_MODELS })); - * ``` + * @param input - The trail's connection, table config, tables and actor. + * @returns Middleware with a `close()` for teardown. */ -export function audit({ client, config, models, getPrincipal }: AuditOptions) { - const { model: auditModel, columns: col } = config; - // INSERT INTO "" ("id","action","model","recordId","principal","changes","createdAt") - // VALUES (gen_random_uuid(), $1, $2, $3, $4::jsonb, $5::jsonb, now()) - // The id is generated IN SQL: the target's Prisma-level `@default(uuid())` - // is client-side and never applies to a raw insert. - const sql = - `INSERT INTO "${auditModel}" ` + - `("id", "${col.action}", "${col.model}", "${col.recordId}", ` + - `"${col.principal}", "${col.changes}", "${col.createdAt}") ` + - `VALUES (gen_random_uuid(), $1, $2, $3, $4::jsonb, $5::jsonb, now())`; - - function principalJson(): string | null { - const principal = getPrincipal(); - - return principal ? JSON.stringify(principal) : null; - } +export function audit({ + connectionString, + config = {}, + tables, + stamps = {}, + getPrincipal, +}: AuditOptions): SqlMiddleware & { close(): Promise } { + const pool = new pg.Pool({ connectionString }); + const table = config.table ?? DEFAULTS.table; + const col = { ...DEFAULTS.columns, ...config.columns }; + const pending = new WeakMap(); - async function insert( - action: AuditAction, - model: string, - recordId: string, - changes: unknown, - ): Promise { - await client.$executeRawUnsafe( - sql, - action, - model, - recordId, - principalJson(), - JSON.stringify(changes), - ); - } + const tableOf = (ast?: AnyQueryAst): string | undefined => + ast?.kind === 'insert' || + ast?.kind === 'update' || + ast?.kind === 'delete' + ? ast.table.name + : undefined; - function auditAsync( - action: AuditAction, - model: string, - record: unknown, - ): void { - if (!models.has(model)) { - return; + const actionOf = ( + ast: AnyQueryAst, + table: string, + ): AuditAction | undefined => { + if (ast.kind === 'insert') { + return AuditAction.INSERT; } - const rec = record as Record | null; - const recordId = rec ? recordKey(rec) : null; - if (!rec || recordId === null) { - return; + if (ast.kind === 'delete') { + return AuditAction.DELETE; } - void insert(action, model, recordId, rec).catch(() => {}); - } - - function auditManyAsync( - action: AuditAction, - model: string, - args: unknown, - result: unknown, - ): void { - if (!models.has(model)) { - return; + if (ast.kind !== 'update') { + return undefined; } - const count = (result as { count?: number } | null)?.count ?? null; - void insert(action, model, 'many', { args, count }).catch(() => {}); - } + // A soft delete reaches here as an update, because `stamp` rewrote it. + // Classifying on the assignment keeps DELETE reachable for exactly the + // models where a real DELETE never happens. + const column = stamps[table]?.deletedAt; + const assigned = column + ? (ast.set[column] as { value?: unknown } | undefined) + : undefined; + + return assigned !== undefined && assigned.value !== null + ? AuditAction.DELETE + : AuditAction.UPDATE; + }; - return Prisma.defineExtension({ + return { name: 'audit', - query: { - $allModels: { - async create({ model, args, query }) { - const result = await query(args); - auditAsync(AuditAction.INSERT, model, result); - return result; - }, - async update({ model, args, query }) { - const result = await query(args); - auditAsync(AuditAction.UPDATE, model, result); - return result; - }, - async delete({ model, args, query }) { - const result = await query(args); - auditAsync(AuditAction.DELETE, model, result); - return result; - }, - async updateMany({ model, args, query }) { - const result = await query(args); - auditManyAsync(AuditAction.UPDATE, model, args, result); - return result; - }, - async deleteMany({ model, args, query }) { - const result = await query(args); - auditManyAsync(AuditAction.DELETE, model, args, result); - return result; - }, - }, + familyId: 'sql' as const, + /** Ends the trail's pool. */ + close: (): Promise => pool.end(), + async onRow(row: Record, plan: Plan): Promise { + const ast = plan?.ast; + const table = tableOf(ast); + const model = table ? tables[table] : undefined; + if ( + !ast || + !table || + !model || + row.id === undefined || + row.id === null + ) { + return; + } + const action = actionOf(ast, table); + if (!action) { + return; + } + const batch = pending.get(plan) ?? { + principal: (() => { + const actor = getPrincipal(); + + return actor ? JSON.stringify(actor) : null; + })(), + entries: [], + }; + batch.entries.push({ action, model, row }); + pending.set(plan, batch); + }, + async afterQuery( + plan: Plan, + result: { readonly completed?: boolean }, + ctx: { readonly log?: { warn?: (m: string, f?: unknown) => void } }, + ): Promise { + const batch = pending.get(plan); + pending.delete(plan); + // A statement that threw still reaches here, with `completed` + // false. Recording those would put writes in the trail that the + // database rolled back. + if ( + !batch || + batch.entries.length === 0 || + result?.completed === false + ) { + return; + } + const values = batch.entries + .map( + (_entry, i) => + `(gen_random_uuid(), $${i * 4 + 1}, $${i * 4 + 2}, ` + + `$${i * 4 + 3}, $${batch.entries.length * 4 + 1}::jsonb, ` + + `$${i * 4 + 4}::jsonb, now())`, + ) + .join(', '); + const params = batch.entries.flatMap(entry => [ + entry.action, + entry.model, + String(entry.row.id), + JSON.stringify(entry.row), + ]); + await pool + .query( + `INSERT INTO "${table}" ("id", "${col.action}", ` + + `"${col.modelName}", "${col.recordId}", "${col.principal}", ` + + `"${col.changes}", "${col.createdAt}") VALUES ${values}`, + [...params, batch.principal], + ) + .then( + () => undefined, + // An audit that cannot be written must not fail the write + // it was recording — but a trail that silently stops is + // worse than one that is noisy about stopping. + (error: unknown) => { + ctx?.log?.warn?.('audit trail insert failed', { + error, + }); + }, + ); }, - }); + }; } diff --git a/src/authorship.ts b/src/authorship.ts deleted file mode 100644 index 674994b..0000000 --- a/src/authorship.ts +++ /dev/null @@ -1,222 +0,0 @@ -/*! - * Prisma authorship-stamping query extension - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ - -import { Prisma } from '@prisma/client/extension'; - -/** Which columns on one model carry authorship, and which one triggers a delete stamp. */ -export interface AuthorshipColumns { - /** Column stamped once, when the row is created. */ - createdBy: string; - /** Column stamped on create and on every update. */ - updatedBy: string; - /** Column stamped when an update sets `deletedAt`. */ - deletedBy: string; - /** - * Soft-delete column whose being set makes an update stamp `deletedBy`. - * - * @remarks - * Optional: a model without one simply never stamps `deletedBy`. The - * extension looks at the incoming `data` for this key rather than at the - * stored row, so it stamps whenever a write is setting the column to a - * non-null value — which is what a soft delete rerouted through - * {@link softDelete} looks like. - */ - deletedAt?: string; -} - -/** - * Which models carry authorship, keyed by Prisma model name. - * - * @remarks - * A model absent from this map passes through untouched. The code generator emits - * this as `AUTHORSHIP_MODELS`, so it normally comes from your schema rather than - * being written by hand. - */ -export type AuthorshipModels = Record; - -/** Everything {@link authorship} needs to build its extension. */ -export interface AuthorshipOptions { - /** Per-model authorship column config (the models to stamp). */ - models: AuthorshipModels; - /** - * Resolves the id of the actor performing the current write, or null when - * there is none (system/unauthenticated). The extension is deliberately - * ignorant of *where* the id comes from — the caller supplies it (e.g. from - * the request context). - */ - getActorId: () => string | null; -} - -type WriteArgs = { - data?: Record | Record[]; - create?: Record; - update?: Record; -}; - -/** Drop any caller-supplied authorship fields — the plugin is their sole writer. */ -function stripAuthorship( - data: Record, - cols: AuthorshipColumns, -): Record { - const clean = { ...data }; - delete clean[cols.createdBy]; - delete clean[cols.updatedBy]; - delete clean[cols.deletedBy]; - - return clean; -} - -/** - * Build the query extension that stamps who created, updated or deleted a row. - * - * @remarks - * Five operations are covered. `create` and `createMany` stamp `createdBy` and - * `updatedBy` (each element of a `createMany` array individually). `update` and - * `updateMany` stamp `updatedBy`, plus `deletedBy` when the write is also setting - * the model's `deletedAt` column — which is what a soft delete rerouted by - * {@link softDelete} looks like from here. `upsert` stamps its `create` and - * `update` branches with the matching rule, resolving the actor once for both. - * - * Authorship cannot be spoofed: any caller-supplied value for the three - * authorship columns is stripped from `data` before the stamp is applied, so this - * extension is their sole writer. That holds even when there is no actor — with - * `getActorId` returning null, the caller's values are still removed and nothing - * is written in their place, so a system write leaves the columns untouched - * rather than taking whatever the caller passed. - * - * Models absent from `models` pass through completely untouched, authorship - * columns included. - * - * @param input - The per-model column config and the actor resolver. - * @returns A Prisma extension to pass to `client.$extends()`. - * @example - * ```typescript - * const client = new PrismaClient().$extends(authorship({ - * models: AUTHORSHIP_MODELS, - * getActorId: () => context.get()?.userId ?? null, - * })); - * ``` - */ -export function authorship({ models, getActorId }: AuthorshipOptions) { - const forCreate = ( - data: Record, - cols: AuthorshipColumns, - by: string | null, - ): Record => { - const clean = stripAuthorship(data, cols); - - return by === null - ? clean - : { ...clean, [cols.createdBy]: by, [cols.updatedBy]: by }; - }; - - const forUpdate = ( - data: Record, - cols: AuthorshipColumns, - by: string | null, - ): Record => { - const clean = stripAuthorship(data, cols); - if (by === null) { - return clean; - } - const deleting = - cols.deletedAt !== undefined && data[cols.deletedAt] != null; - - return { - ...clean, - [cols.updatedBy]: by, - ...(deleting ? { [cols.deletedBy]: by } : {}), - }; - }; - - return Prisma.defineExtension({ - name: 'authorship', - query: { - $allModels: { - create({ model, args, query }) { - const cols = models[model]; - if (cols) { - const a = args as WriteArgs; - a.data = forCreate( - (a.data as Record) ?? {}, - cols, - getActorId(), - ); - } - - return query(args); - }, - createMany({ model, args, query }) { - const cols = models[model]; - if (cols) { - const by = getActorId(); - const a = args as WriteArgs; - a.data = Array.isArray(a.data) - ? a.data.map(d => forCreate(d, cols, by)) - : forCreate(a.data ?? {}, cols, by); - } - - return query(args); - }, - update({ model, args, query }) { - const cols = models[model]; - if (cols) { - const a = args as WriteArgs; - a.data = forUpdate( - (a.data as Record) ?? {}, - cols, - getActorId(), - ); - } - - return query(args); - }, - updateMany({ model, args, query }) { - const cols = models[model]; - if (cols) { - const a = args as WriteArgs; - a.data = forUpdate( - (a.data as Record) ?? {}, - cols, - getActorId(), - ); - } - - return query(args); - }, - upsert({ model, args, query }) { - const cols = models[model]; - if (cols) { - const by = getActorId(); - const a = args as WriteArgs; - a.create = forCreate(a.create ?? {}, cols, by); - a.update = forUpdate(a.update ?? {}, cols, by); - } - - return query(args); - }, - }, - }, - }); -} diff --git a/src/change-notify.ts b/src/change-notify.ts index 803136e..6eaa6ff 100644 --- a/src/change-notify.ts +++ b/src/change-notify.ts @@ -22,6 +22,8 @@ * to get commercial licensing options. */ +import type { SqlExecutor, SqlPool } from './sql-client.js'; +import { withTransaction } from './sql-client.js'; import { silently } from './sql-log.js'; /** Default Postgres NOTIFY channel the change triggers emit on. */ @@ -84,25 +86,17 @@ export interface ChangeTriggerConfig { silent?: boolean; } -/** The raw-SQL surface used to install triggers (a Prisma client or its `tx`). */ -export interface RawExecutor { - /** Execute a statement — used for the DDL, which cannot use bind parameters. */ - $executeRawUnsafe(sql: string, ...values: unknown[]): Promise; - /** Run a query — used to read the currently installed triggers back. */ - $queryRawUnsafe(sql: string, ...values: unknown[]): Promise; -} +/** The raw-SQL surface used to install triggers. A `pg.Pool` satisfies it. */ +export type RawExecutor = SqlExecutor; -/** A {@link RawExecutor} that can also open a transaction. */ -export interface RawClient extends RawExecutor { - /** - * Run `fn` inside a transaction. - * - * @remarks - * {@link installChangeTriggers} needs this so the whole reconciliation — the - * function, the added triggers and the dropped ones — either lands or does not. - */ - $transaction(fn: (tx: RawExecutor) => Promise): Promise; -} +/** + * A {@link RawExecutor} that can also open a transaction. + * + * @remarks + * {@link installChangeTriggers} needs one so the whole reconciliation — the + * function, the added triggers and the dropped ones — either lands or does not. + */ +export type RawClient = SqlPool; /** A model name as `schema` and `table`, taking `fallback` when unqualified. */ function split( @@ -152,7 +146,7 @@ const qualify = (schema: string, table: string): string => `${schema}.${table}`; * @returns Nothing; it resolves once the triggers match `models`. * @example * ```typescript - * await installChangeTriggers(prisma, { models: ['User', 'Order'] }); + * await installChangeTriggers(pool, { models: ['User', 'Order'] }); * ``` */ export async function installChangeTriggers( @@ -168,8 +162,8 @@ export async function installChangeTriggers( }: ChangeTriggerConfig, ): Promise { const install = (): Promise => - client.$transaction(async tx => { - await tx.$executeRawUnsafe(` + withTransaction(client, async tx => { + await tx.query(` CREATE OR REPLACE FUNCTION ${functionName}() RETURNS trigger AS $fn$ DECLARE rec record; @@ -201,18 +195,17 @@ export async function installChangeTriggers( schemas.push(schema); } - const rows = await tx.$queryRawUnsafe< - { schema: string; table: string }[] - >( - `SELECT event_object_schema AS "schema", + const rows = ( + await tx.query( + `SELECT event_object_schema AS "schema", event_object_table AS "table" FROM information_schema.triggers WHERE trigger_name = $1 AND event_object_schema = ANY ($2) GROUP BY event_object_schema, event_object_table`, - triggerName, - schemas, - ); + [triggerName, schemas], + ) + ).rows as { schema: string; table: string }[]; const installed = new Set( rows.map(row => qualify(row.schema, row.table)), @@ -223,7 +216,7 @@ export async function installChangeTriggers( for (const one of wanted) { if (!installed.has(qualify(one.schema, one.table))) { - await tx.$executeRawUnsafe( + await tx.query( `CREATE TRIGGER "${triggerName}" AFTER INSERT OR UPDATE OR DELETE ON "${one.schema}"."${one.table}" @@ -235,7 +228,7 @@ export async function installChangeTriggers( for (const row of rows) { if (!required.has(qualify(row.schema, row.table))) { - await tx.$executeRawUnsafe( + await tx.query( `DROP TRIGGER IF EXISTS "${triggerName}" ON "${row.schema}"."${row.table}"`, ); @@ -276,8 +269,8 @@ export async function installChangeTriggers( * @returns Whatever `fn` resolves to. * @example * ```typescript - * await withoutChangeNotify(prisma, async tx => { - * await tx.$executeRawUnsafe(bulkUpsert); + * await withoutChangeNotify(pool, async tx => { + * await tx.query(bulkUpsert); * }); * ``` */ @@ -286,8 +279,8 @@ export async function withoutChangeNotify( fn: (tx: RawExecutor) => Promise, setting: string = CHANGE_NOTIFY_SUPPRESS_SETTING, ): Promise { - return client.$transaction(async tx => { - await tx.$executeRawUnsafe(`SET LOCAL "${setting}" = 'on'`); + return withTransaction(client, async tx => { + await tx.query(`SET LOCAL "${setting}" = 'on'`); return fn(tx); }); diff --git a/src/codegen.ts b/src/codegen.ts deleted file mode 100644 index a164cc8..0000000 --- a/src/codegen.ts +++ /dev/null @@ -1,1801 +0,0 @@ -/*! - * Prisma generator: @imqueue/rpc models & repositories - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ - -import type { DMMF, GeneratorOptions } from '@prisma/generator-helper'; -import { execSync } from 'node:child_process'; -import { mkdir, writeFile } from 'node:fs/promises'; -import { dirname, join } from 'node:path'; -import { pathToFileURL } from 'node:url'; - -type Model = DMMF.Model; -type Field = DMMF.Field; -type Used = { enums: Set }; -type Hidden = (model: Model, field: Field) => boolean; -type TypePair = { rpc: string; ts: string }; - -interface SoftDeleteConfig { - deletedAt: string; -} -interface AuthorshipConfig { - createdBy: string; - updatedBy: string; - deletedBy: string; - /** The soft-delete column whose being-set triggers `deletedBy` (if any). */ - deletedAt?: string; -} -const AUDIT_FIELDS = [ - 'principal', - 'action', - 'model', - 'recordId', - 'changes', - 'createdAt', -] as const; -type AuditField = (typeof AUDIT_FIELDS)[number]; -interface AuditConfig { - models: string[]; - table: string; - columns: Record; -} -type AssertColumn = (model: string, column: string, key: string) => void; -type AssertKnown = (key: string, names: Iterable) => void; - -/** Whether `name` is a declared model. */ -const isModel = (models: readonly Model[], name: string): boolean => - models.some(m => m.name === name); - -/** - * Parse the `softDelete` directive list into `model → { deletedAt column }`. - * `auto` = every model with a `deletedAt` column; `Model` = default column; - * `Model.col` = custom column; `-Model` = exclude. - */ -function parseSoftDelete( - directives: string[], - models: readonly Model[], - assertColumn: AssertColumn, -): Record { - const result: Record = {}; - for (const token of directives) { - if (token === 'auto') { - for (const m of models) { - if (m.fields.some(f => f.name === 'deletedAt')) { - result[m.name] = { deletedAt: 'deletedAt' }; - } - } - continue; - } - if (token.startsWith('-')) { - delete result[token.slice(1)]; - continue; - } - if (token.includes('.')) { - const [model, column] = token.split('.'); - if (!model || !column) { - throw new Error(`codegen: invalid softDelete token "${token}"`); - } - assertColumn(model, column, 'softDelete'); - result[model] = { deletedAt: column }; - continue; - } - if (!isModel(models, token)) { - throw new Error( - `codegen: unknown model "${token}" in \`softDelete\``, - ); - } - assertColumn(token, 'deletedAt', 'softDelete'); - result[token] = { deletedAt: 'deletedAt' }; - } - - return result; -} - -/** - * Parse the `authorship` directive list into `model → { createdBy, updatedBy, - * deletedBy }`. `auto` = every model with a `createdBy` column; custom columns - * are `Model.created:updated:deleted` (positional; blanks keep the default). - */ -function parseAuthorship( - directives: string[], - models: readonly Model[], - assertColumn: AssertColumn, -): Record { - const def = (): AuthorshipConfig => ({ - createdBy: 'createdBy', - updatedBy: 'updatedBy', - deletedBy: 'deletedBy', - }); - const result: Record = {}; - for (const token of directives) { - if (token === 'auto') { - for (const m of models) { - if (m.fields.some(f => f.name === 'createdBy')) { - result[m.name] = def(); - } - } - continue; - } - if (token.startsWith('-')) { - delete result[token.slice(1)]; - continue; - } - if (token.includes('.')) { - const [model, cols] = token.split('.'); - const [created, updated, deleted] = (cols ?? '').split(':'); - if (!model) { - throw new Error(`codegen: invalid authorship token "${token}"`); - } - const cfg: AuthorshipConfig = { - createdBy: created || 'createdBy', - updatedBy: updated || 'updatedBy', - deletedBy: deleted || 'deletedBy', - }; - for (const column of [ - cfg.createdBy, - cfg.updatedBy, - cfg.deletedBy, - ]) { - assertColumn(model, column, 'authorship'); - } - result[model] = cfg; - continue; - } - if (!isModel(models, token)) { - throw new Error( - `codegen: unknown model "${token}" in \`authorship\``, - ); - } - for (const column of ['createdBy', 'updatedBy', 'deletedBy']) { - assertColumn(token, column, 'authorship'); - } - result[token] = def(); - } - - return result; -} - -/** - * Parse the `audit` directive list: model directives (`auto` = every model - * except the target table, `Model`, `-Model`) plus `@table=Name` and - * `@col.=` audit-table column remaps. - */ -function parseAudit( - directives: string[], - models: readonly Model[], - assertKnown: AssertKnown, - ident: RegExp, -): AuditConfig { - // Last `@table=` wins (matches the previous last-assignment-in-loop behavior). - const tableDirective = directives.findLast(t => t.startsWith('@table=')); - const table = tableDirective - ? tableDirective.slice('@table='.length) - : 'AuditLog'; - if (tableDirective && !ident.test(table)) { - throw new Error(`codegen: invalid audit table "${table}"`); - } - const columns: Record = { - principal: 'principal', - action: 'action', - model: 'model', - recordId: 'recordId', - changes: 'changes', - createdAt: 'createdAt', - }; - const modelTokens: string[] = []; - for (const token of directives) { - if (token.startsWith('@table=')) { - continue; - } - if (token.startsWith('@col.')) { - const [field, name] = token.slice('@col.'.length).split('='); - if (!field || !name) { - throw new Error( - `codegen: invalid audit column mapping "${token}"`, - ); - } - if (!AUDIT_FIELDS.includes(field as AuditField)) { - throw new Error( - `codegen: unknown audit field "${field}" in \`audit\``, - ); - } - if (!ident.test(name)) { - throw new Error(`codegen: invalid audit column "${name}"`); - } - columns[field as AuditField] = name; - continue; - } - modelTokens.push(token); - } - - const set = new Set(); - for (const token of modelTokens) { - if (token === 'auto') { - for (const m of models) { - if (m.name !== table) { - set.add(m.name); - } - } - continue; - } - if (token.startsWith('-')) { - set.delete(token.slice(1)); - continue; - } - set.add(token); - } - set.delete(table); - assertKnown('audit', set); - - return { models: [...set], table, columns }; -} - -/** - * Parse the `validation` directive list into the set of models whose generated - * inputs/args carry `@validate` decorators. Empty (the default) means validation - * generation is OFF. `auto` = every model; `Model` = include; `-Model` = exclude. - */ -function parseValidation( - directives: string[], - models: readonly Model[], - assertKnown: AssertKnown, -): Set { - const set = new Set(); - for (const token of directives) { - if (token === 'auto') { - for (const m of models) { - set.add(m.name); - } - continue; - } - if (token.startsWith('-')) { - set.delete(token.slice(1)); - continue; - } - set.add(token); - } - assertKnown('validation', set); - - return set; -} - -interface AccessScopeResult { - /** Declared access levels, in option order. */ - levels: string[]; - /** model → level → scope columns (OR within a level, AND across levels). */ - models: Record>; -} - -/** Every `@scope(a, b)` occurrence in a doc comment as a list of level lists. */ -function scopeAnnotations(documentation: string | undefined): string[][] { - const re = /@scope\(([^)]*)\)/g; - - return [...(documentation ?? '').matchAll(re)].map(match => - match[1]! - .split(',') - .map(name => name.trim()) - .filter(Boolean), - ); -} - -/** - * Parse the `accessScope` directive list plus `/// @scope(level)` annotations - * into `model → level → columns`. Option tokens declare the levels: `level` - * (default column `${level}Id`), `level=column` (custom default column), or - * `-Model` (exclude a model from all levels). A MODEL-level `@scope(level)` - * scopes the model by that level's default column; a FIELD-level `@scope(level)` - * scopes by that field. A model's columns for a level are the union of both. - */ -function parseAccessScope( - directives: string[], - models: readonly Model[], - assertColumn: AssertColumn, - ident: RegExp, -): AccessScopeResult { - const defaults = new Map(); - const order: string[] = []; - const excluded = new Set(); - for (const token of directives) { - if (token.startsWith('-')) { - const name = token.slice(1); - if (!isModel(models, name)) { - throw new Error( - `codegen: unknown model "${name}" in \`accessScope\``, - ); - } - excluded.add(name); - continue; - } - const [rawName, rawColumn] = token.split('=').map(part => part.trim()); - const name = rawName ?? ''; - if (!ident.test(name)) { - throw new Error(`codegen: invalid accessScope level "${token}"`); - } - if (!defaults.has(name)) { - order.push(name); - } - defaults.set(name, rawColumn?.length ? rawColumn : `${name}Id`); - } - - const columns: Record>> = {}; - const add = (model: string, level: string, column: string): void => { - (columns[model] ??= {})[level] ??= new Set(); - columns[model]![level]!.add(column); - }; - const requireLevel = (level: string, where: string): void => { - if (!defaults.has(level)) { - throw new Error( - `codegen: unknown access level "${level}" in \`@scope\` on ${where}`, - ); - } - }; - - for (const model of models) { - if (excluded.has(model.name)) { - continue; - } - for (const levels of scopeAnnotations(model.documentation)) { - for (const level of levels) { - requireLevel(level, model.name); - const column = defaults.get(level)!; - assertColumn(model.name, column, 'accessScope'); - add(model.name, level, column); - } - } - for (const field of model.fields) { - for (const levels of scopeAnnotations(field.documentation)) { - for (const level of levels) { - requireLevel(level, `${model.name}.${field.name}`); - add(model.name, level, field.name); - } - } - } - } - - const result: Record> = {}; - for (const [model, byLevel] of Object.entries(columns)) { - result[model] = {}; - for (const level of order) { - if (byLevel[level]) { - result[model]![level] = [...byLevel[level]]; - } - } - } - - return { levels: order, models: result }; -} - -/** `enum name → "'A' | 'B'"` literal-union map, filled from the DMMF on generate. */ -const enumUnions: Record = {}; - -/** - * Default TypeScript type per scalar DMMF type, used verbatim for BOTH the - * `@property()` argument and the TS annotation. Unmapped scalars fall back to - * `unknown`. Overridable per-type via the generator's `scalars` config - * (e.g. `scalars = "DateTime:string"`); when `DateTime` is mapped to `string` - * the `iso-dates` extension serializes dates to ISO strings on the wire. - */ -const DEFAULT_SCALARS: Record = { - String: 'string', - Boolean: 'boolean', - Int: 'number', - Float: 'number', - BigInt: 'number', - Decimal: 'number', - DateTime: 'Date', - Json: 'Record', -}; - -/** Effective scalar map for the current generate (defaults + `scalars` config). */ -const scalars: Record = { ...DEFAULT_SCALARS }; - -const scalarType = (field: Field): string => scalars[field.type] ?? 'unknown'; - -const lowerFirst = (name: string): string => - name.charAt(0).toLowerCase() + name.slice(1); - -/** - * Resolve a DMMF field to its `@property()` argument (a source-code - * expression) and its TypeScript annotation, recording used enum imports on - * the way. Enums resolve to ready literal unions (e.g. `"'EMAIL' | 'SMS'"`). - */ -function fieldTypes(field: Field, used: Used): TypePair { - if (field.kind === 'enum') { - used.enums.add(field.type); - const union = enumUnions[field.type]!; - if (field.isList) { - return { rpc: `"Array<${union}>"`, ts: `${field.type}[]` }; - } - return { rpc: `"${union}"`, ts: field.type }; - } - if (field.kind === 'object') { - return field.isList - ? { rpc: `'Array<${field.type}>'`, ts: `${field.type}[]` } - : { rpc: `'${field.type}'`, ts: field.type }; - } - const ts = field.isList ? `${scalarType(field)}[]` : scalarType(field); - - return { rpc: `'${ts}'`, ts }; -} - -/** - * Render one `@classType()` class from pre-built property lines. When - * `validatable` is set the class is also sealed with `@validatable()` so its - * `@validate` field decorators can be inferred by the `@validated` method - * decorator. - */ -function renderClass( - name: string, - fieldLines: string[], - validatable = false, -): string { - return ( - '@classType()\n' + - (validatable ? '@validatable()\n' : '') + - `export class ${name} {\n` + - `${fieldLines.join('\n\n')}\n` + - '}' - ); -} - -/** Render the generated Prisma client enum import for the enums a file uses. */ -function renderUsedImports(used: Used): string { - return used.enums.size - ? 'import {\n' + - [...used.enums] - .sort() - .map(name => ` ${name},`) - .join('\n') + - "\n} from '#generated/prisma/client.js';\n" - : ''; -} - -function isNowDefault(field: Field): boolean { - const def = field.default; - - return ( - field.hasDefaultValue && - typeof def === 'object' && - !Array.isArray(def) && - 'name' in def && - def.name === 'now' - ); -} - -/** Fields usable in a single-field `connect` (id, `@unique`, 1-field `@@unique`). */ -function uniqueSingleFields(model: Model): Field[] { - const singles = new Set( - (model.uniqueFields ?? []) - .filter(fields => fields.length === 1) - .map(fields => fields[0]!), - ); - - return model.fields.filter( - f => f.isId || f.isUnique || singles.has(f.name), - ); -} - -const withId = (model: Model): boolean => model.fields.some(f => f.isId); - -/** - * Emit `relations.ts`: the relation map (`model → field → { target, isList }`) - * the query DSL converters need — the generator protocol delivers the full - * DMMF, including `isList`, which the client's runtime DMMF strips — plus the - * soft-delete / authorship / audit config maps the extensions consume. - */ -function renderRelations( - models: readonly Model[], - softDelete: Record, - authorship: Record, - audit: AuditConfig, - accessScope: AccessScopeResult, -): string { - const lines: string[] = []; - for (const model of models) { - const relations = model.fields.filter(f => f.kind === 'object'); - if (relations.length === 0) { - lines.push(` ${model.name}: {},`); - continue; - } - lines.push(` ${model.name}: {`); - for (const field of relations) { - lines.push( - ` ${field.name}: ` + - `{ target: '${field.type}', isList: ${field.isList} },`, - ); - } - lines.push(' },'); - } - - const softDeleteLines = Object.entries(softDelete).map( - ([model, cfg]) => ` ${model}: { deletedAt: '${cfg.deletedAt}' },`, - ); - const authorshipLines = Object.entries(authorship).map(([model, cfg]) => { - const parts = [ - `createdBy: '${cfg.createdBy}'`, - `updatedBy: '${cfg.updatedBy}'`, - `deletedBy: '${cfg.deletedBy}'`, - ]; - if (cfg.deletedAt) { - parts.push(`deletedAt: '${cfg.deletedAt}'`); - } - - return ` ${model}: { ${parts.join(', ')} },`; - }); - const auditModelLines = audit.models.map(m => ` '${m}',`); - const auditColumnLines = AUDIT_FIELDS.map( - field => ` ${field}: '${audit.columns[field]}',`, - ); - - const accessLevelLines = accessScope.levels.map(level => ` '${level}',`); - const accessLevelsBody = accessLevelLines.length - ? `\n${accessLevelLines.join('\n')}\n` - : ''; - const accessModelLines = Object.entries(accessScope.models).map( - ([model, byLevel]) => { - const parts = Object.entries(byLevel).map( - ([level, cols]) => - `${level}: [${cols.map(c => `'${c}'`).join(', ')}]`, - ); - - return ` ${model}: { ${parts.join(', ')} },`; - }, - ); - const accessModelsBody = accessModelLines.length - ? `\n${accessModelLines.join('\n')}\n` - : ''; - - return ( - 'export interface RelationInfo {\n' + - ' target: string;\n' + - ' isList: boolean;\n' + - '}\n\n' + - 'export type ModelRelations = Record;\n\n' + - 'export type RelationMap = Record;\n\n' + - 'export const RELATIONS: RelationMap = {\n' + - `${lines.join('\n')}\n` + - '};\n\n' + - '/** Soft-delete config per model: the `deletedAt` column name. */\n' + - 'export interface SoftDeleteConfig {\n' + - ' deletedAt: string;\n' + - '}\n' + - 'export const SOFT_DELETE_MODELS: Record = {\n' + - `${softDeleteLines.join('\n')}\n` + - '};\n\n' + - '/** Authorship config per model: the stamp column names. `deletedAt` is\n' + - ' * the soft-delete column whose being-set triggers `deletedBy`. */\n' + - 'export interface AuthorshipConfig {\n' + - ' createdBy: string;\n' + - ' updatedBy: string;\n' + - ' deletedBy: string;\n' + - ' deletedAt?: string;\n' + - '}\n' + - 'export const AUTHORSHIP_MODELS: Record = {\n' + - `${authorshipLines.join('\n')}\n` + - '};\n\n' + - '/** Models whose writes are recorded to the audit log. */\n' + - 'export const AUDIT_MODELS: ReadonlySet = new Set([\n' + - `${auditModelLines.join('\n')}\n` + - ']);\n\n' + - '/** Audit config: the target model and its column names. */\n' + - 'export interface AuditConfig {\n' + - ' model: string;\n' + - ' columns: {\n' + - ' principal: string;\n' + - ' action: string;\n' + - ' model: string;\n' + - ' recordId: string;\n' + - ' changes: string;\n' + - ' createdAt: string;\n' + - ' };\n' + - '}\n' + - 'export const AUDIT_CONFIG: AuditConfig = {\n' + - ` model: '${audit.table}',\n` + - ' columns: {\n' + - `${auditColumnLines.join('\n')}\n` + - ' },\n' + - '};\n\n' + - '/** Access levels declared via the `accessScope` generator option. */\n' + - `export const ACCESS_LEVELS = [${accessLevelsBody}] as const;\n` + - 'export type AccessLevel = (typeof ACCESS_LEVELS)[number];\n\n' + - '/** Per-model scope columns per access level (OR within a level,\n' + - ' * AND across levels). Consumed by the `accessScope` extension. */\n' + - 'export type AccessScopeConfig = Record<\n' + - ' string,\n' + - ' Partial>\n' + - '>;\n' + - `export const ACCESS_SCOPE_MODELS: AccessScopeConfig = {${accessModelsBody}};\n\n` + - '/** Runtime resolver per level: `undefined` = level inactive (skip),\n' + - ' * `null` = active but no value (deny), a value = `=`, an array = `IN`. */\n' + - 'export type AccessScopeValue = string | string[] | null | undefined;\n' + - 'export type AccessScopeResolvers = Record<\n' + - ' AccessLevel,\n' + - ' () => AccessScopeValue\n' + - '>;\n' - ); -} - -/** - * Emit `models.ts`: one `@classType()` class per Prisma model, each field an - * optional `@property()` (reads use `select` projections, so any field may be - * absent). Nullable columns are additionally typed `| null`. Hidden fields - * (`omit` config secrets, soft-delete `deletedAt`) are excluded from the exposed - * contract. - */ -function renderModels(models: readonly Model[], hidden: Hidden): string { - const used: Used = { enums: new Set() }; - const classes = models.map(model => { - const fields = model.fields - .filter(f => !hidden(model, f)) - .map(field => { - const { rpc, ts } = fieldTypes(field, used); - const nullable = - !field.isRequired && !field.isList ? ' | null' : ''; - return ( - ` @property(${rpc}, true)\n` + - ` ${field.name}?: ${ts}${nullable};` - ); - }); - - return renderClass(model.name, fields); - }); - - return ( - "import { classType, property } from '@imqueue/rpc';\n" + - renderUsedImports(used) + - '\n' + - `${classes.join('\n\n')}\n` - ); -} - -const DIRECTION = "'asc' | 'desc'"; - -/** Scalar operator classes the per-model `Where` classes reference. */ -const OPS_CLASSES = [ - renderClass( - 'StringWhere', - [ - ['eq', 'string'], - ['not', 'string'], - ['in', 'string[]'], - ['notIn', 'string[]'], - ['lt', 'string'], - ['lte', 'string'], - ['gt', 'string'], - ['gte', 'string'], - ['contains', 'string'], - ['startsWith', 'string'], - ['endsWith', 'string'], - ].map( - ([op, type]) => - ` @property('${type}', true)\n ${op}?: ${type};`, - ), - ), - renderClass( - 'NumberWhere', - [ - ['eq', 'number'], - ['not', 'number'], - ['in', 'number[]'], - ['notIn', 'number[]'], - ['lt', 'number'], - ['lte', 'number'], - ['gt', 'number'], - ['gte', 'number'], - ].map( - ([op, type]) => - ` @property('${type}', true)\n ${op}?: ${type};`, - ), - ), - renderClass('BooleanWhere', [ - " @property('boolean', true)\n eq?: boolean;", - " @property('boolean', true)\n not?: boolean;", - ]), - renderClass('CountOrderBy', [ - ` @property("${DIRECTION}", true)\n _count?: ${DIRECTION};`, - ]), -].join('\n\n'); - -/** `Where` field: bare value (equality) or operator object. */ -function whereField(field: Field, used: Used): TypePair { - if (field.kind === 'object') { - return { rpc: `'${field.type}Where'`, ts: `${field.type}Where` }; - } - if (field.kind === 'enum') { - used.enums.add(field.type); - const union = enumUnions[field.type]!; - if (field.isList) { - return { rpc: `"Array<${union}>"`, ts: `${field.type}[]` }; - } - return { - rpc: `"${union} | StringWhere"`, - ts: `${field.type} | StringWhere`, - }; - } - const ts = scalarType(field); - if (field.isList) { - return { rpc: `'${ts}[]'`, ts: `${ts}[]` }; - } - const ops: string | undefined = { - string: 'StringWhere', - number: 'NumberWhere', - boolean: 'BooleanWhere', - }[ts]; - if (!ops) { - return { rpc: `'${ts}'`, ts }; // Json/unknown: equality only - } - - return { rpc: `'${ts} | ${ops}'`, ts: `${ts} | ${ops}` }; -} - -/** `OrderBy` field, or null when the field cannot be ordered by. */ -function orderByField(field: Field): TypePair | null { - if (field.kind === 'object') { - return field.isList - ? { rpc: `'CountOrderBy'`, ts: 'CountOrderBy' } - : { rpc: `'${field.type}OrderBy'`, ts: `${field.type}OrderBy` }; - } - if (field.isList || field.type === 'Json') { - return null; - } - - return { rpc: `"${DIRECTION}"`, ts: DIRECTION }; -} - -/** - * The query DSL runtime emitted into `query.ts`: loose `Where`/`Select`/ - * `OrderBy` types and the `toWhere`/`toSelect`/`toOrderBy` converters. - */ -const QUERY_RUNTIME = ` -export type Direction = 'asc' | 'desc'; - -export interface FilterOps { - eq?: unknown; - not?: unknown; - in?: unknown[]; - notIn?: unknown[]; - lt?: unknown; - lte?: unknown; - gt?: unknown; - gte?: unknown; - contains?: string; - startsWith?: string; - endsWith?: string; -} - -export type Where = object; - -export type Select = object; - -export type OrderBy = object; - -type Node = Record; - -const OP_MAP: Record = { - eq: 'equals', - not: 'not', - in: 'in', - notIn: 'notIn', - lt: 'lt', - lte: 'lte', - gt: 'gt', - gte: 'gte', - contains: 'contains', - startsWith: 'startsWith', - endsWith: 'endsWith', -}; - -const LOGICAL = new Set(['AND', 'OR', 'NOT']); - -function isPlainObject(v: unknown): v is Node { - return !!v && typeof v === 'object' && !Array.isArray(v); -} - -function isOps(v: unknown): v is Node { - return ( - isPlainObject(v) && - Object.keys(v).length > 0 && - Object.keys(v).every(k => k in OP_MAP) - ); -} - -function toLeaf(value: unknown): unknown { - if (isOps(value)) { - const out: Node = {}; - for (const [op, operand] of Object.entries(value)) { - out[OP_MAP[op]!] = operand; - } - - return out; - } - - return value; -} - -export function toWhere( - map: RelationMap, - model: string, - where: Where | undefined, -): Record | undefined { - if (!where) { - return undefined; - } - const rels = map[model] ?? {}; - const out: Node = {}; - for (const [key, value] of Object.entries(where)) { - if (LOGICAL.has(key)) { - const parts: unknown[] = Array.isArray(value) ? value : [value]; - out[key] = parts.map(w => toWhere(map, model, w as Where)); - continue; - } - const rel = rels[key]; - if (rel) { - const nested = toWhere(map, rel.target, value as Where); - out[key] = rel.isList ? { some: nested } : nested; - continue; - } - out[key] = toLeaf(value); - } - - return out; -} - -export function toSelect( - select: Select | undefined, -): Record | undefined { - if (!select) { - return undefined; - } - const out: Node = {}; - for (const [key, value] of Object.entries(select)) { - if (value === true) { - out[key] = true; - continue; - } - if (isPlainObject(value)) { - out[key] = { select: toSelect(value) }; - } - } - - return Object.keys(out).length > 0 ? out : undefined; -} - -function orderValue( - map: RelationMap, - model: string, - key: string, - value: unknown, -): unknown { - const rel = (map[model] ?? {})[key]; - if (!rel) { - return value; - } - if (rel.isList) { - if (isPlainObject(value) && '_count' in value) { - return { _count: value._count }; - } - throw new Error( - \`Cannot order by a field through to-many relation "\${key}"; \` + - \`use { "\${key}": { _count: "asc" } } instead\`, - ); - } - const nested: Node = {}; - for (const [k, v] of Object.entries(value as Node)) { - nested[k] = orderValue(map, rel.target, k, v); - } - - return nested; -} - -export function toOrderBy( - map: RelationMap, - model: string, - orderBy: OrderBy | undefined, -): unknown[] | undefined { - if (!orderBy || Object.keys(orderBy).length === 0) { - return undefined; - } - - return Object.entries(orderBy).map(([key, value]) => ({ - [key]: orderValue(map, model, key, value), - })); -} -`; - -/** - * Emit `query.ts`: the query DSL — the `toWhere`/`toSelect`/`toOrderBy` - * converters and loose `Where`/`Select`/`OrderBy` types, plus per-model - * `Select`, `Where` and `OrderBy` classes typing that - * DSL for RPC clients, and the scalar operator classes they reference. Where - * semantics follow the converters: a bare value is equality, a to-many - * relation filter is wrapped in `some`, a to-many order allows `_count` only. - * Hidden fields are excluded from every surface. - */ -function renderQuery(models: readonly Model[], hidden: Hidden): string { - const used: Used = { enums: new Set() }; - const classes = models.flatMap(model => { - const fields = model.fields.filter(f => !hidden(model, f)); - const logical = ['AND', 'OR', 'NOT'].map( - op => - ` @property('${model.name}Where` + - ` | Array<${model.name}Where>', true)\n` + - ` ${op}?: ${model.name}Where | ${model.name}Where[];`, - ); - - return [ - renderClass( - `${model.name}Select`, - fields.map(field => { - const ts = - field.kind === 'object' - ? `boolean | ${field.type}Select` - : 'boolean'; - return ( - ` @property('${ts}', true)\n` + - ` ${field.name}?: ${ts};` - ); - }), - ), - renderClass(`${model.name}Where`, [ - ...logical, - ...fields.map(field => { - const { rpc, ts } = whereField(field, used); - return ( - ` @property(${rpc}, true)\n` + - ` ${field.name}?: ${ts};` - ); - }), - ]), - renderClass( - `${model.name}OrderBy`, - fields.flatMap(field => { - const type = orderByField(field); - return type - ? [ - ` @property(${type.rpc}, true)\n` + - ` ${field.name}?: ${type.ts};`, - ] - : []; - }), - ), - ]; - }); - - return ( - "import { classType, property } from '@imqueue/rpc';\n" + - renderUsedImports(used) + - "import type { RelationMap } from '#generated/relations.js';\n" + - '\n' + - `${QUERY_RUNTIME.trim()}\n\n` + - `${OPS_CLASSES}\n\n` + - `${classes.join('\n\n')}\n` - ); -} - -/** - * One `CreateInput`/`UpdateInput` property. Relations nest as - * `CreateNestedOne/Many` (`create`/`connect`). FK scalar columns - * (`isReadOnly`) and defaulted/list fields are optional; on update everything - * but `id` is optional. - */ -interface FieldLine { - line: string; - /** Whether the line carries a `@validate(...)` decorator. */ - validated: boolean; -} - -function inputFieldLine( - field: Field, - used: Used, - forceOptional: boolean, - enumValues: Record, - withValidation: boolean, -): FieldLine { - const type = - field.kind === 'object' - ? { - rpc: `'${field.type}CreateNested${field.isList ? 'Many' : 'One'}'`, - ts: `${field.type}CreateNested${field.isList ? 'Many' : 'One'}`, - } - : fieldTypes(field, used); - const required = - !forceOptional && - field.kind !== 'object' && - field.isRequired && - !field.hasDefaultValue && - !field.isReadOnly && - !field.isList; - const validator = withValidation - ? fieldValidator(field, !required, enumValues) - : null; - const validate = validator ? ` @validate(${validator})\n` : ''; - if (required) { - return { - line: `${validate} @property(${type.rpc})\n ${field.name}!: ${type.ts};`, - validated: !!validator, - }; - } - const nullable = !field.isRequired && !field.isList ? ' | null' : ''; - - return { - line: - `${validate} @property(${type.rpc}, true)\n` + - ` ${field.name}?: ${type.ts}${nullable};`, - validated: !!validator, - }; -} - -/** - * Emit `inputs.ts`: per-model `CreateInput` (and `UpdateInput` - * for models with an `id`), plus the `WhereUnique` and - * `CreateNestedOne/Many` classes relations reference. Managed columns - * (`@updatedAt`, `now()` defaults, soft-delete `deletedAt`) are excluded; the - * read-surface `omit` config does NOT apply — secrets are legitimate inputs. - */ -function renderInputs( - models: readonly Model[], - hidden: Hidden, - enumValues: Record, - validation: ReadonlySet, -): string { - const used: Used = { enums: new Set() }; - const targets = new Set( - models.flatMap(m => - m.fields - .filter(f => f.kind === 'object' && !hidden(m, f)) - .map(f => f.type), - ), - ); - const skipped = (model: Model, field: Field) => - field.isUpdatedAt || isNowDefault(field) || hidden(model, field); - - // Build an input class from field lines, sealing it `@validatable()` when any - // field carries a `@validate(...)` decorator so `@validated` can infer it. - const buildInput = (name: string, entries: FieldLine[]): string => - renderClass( - name, - entries.map(e => e.line), - entries.some(e => e.validated), - ); - - const classes = models.flatMap(model => { - const out: string[] = []; - const withValidation = validation.has(model.name); - const uniques = uniqueSingleFields(model); - if (targets.has(model.name)) { - if (uniques.length > 0) { - out.push( - renderClass( - `${model.name}WhereUnique`, - uniques.map(field => { - const { rpc, ts } = fieldTypes(field, used); - return ( - ` @property(${rpc}, true)\n` + - ` ${field.name}?: ${ts};` - ); - }), - ), - ); - } - out.push( - renderClass(`${model.name}CreateNestedOne`, [ - ` @property('${model.name}CreateInput', true)\n` + - ` create?: ${model.name}CreateInput;`, - ...(uniques.length > 0 - ? [ - ` @property('${model.name}WhereUnique', true)\n` + - ` connect?: ${model.name}WhereUnique;`, - ] - : []), - ]), - renderClass(`${model.name}CreateNestedMany`, [ - ` @property('Array<${model.name}CreateInput>', true)\n` + - ` create?: ${model.name}CreateInput[];`, - ...(uniques.length > 0 - ? [ - ` @property('Array<${model.name}WhereUnique>', true)\n` + - ` connect?: ${model.name}WhereUnique[];`, - ] - : []), - ]), - ); - } - const fields = model.fields.filter(f => !skipped(model, f)); - out.push( - buildInput( - `${model.name}CreateInput`, - fields.map(f => - inputFieldLine(f, used, false, enumValues, withValidation), - ), - ), - ); - const idField = model.fields.find(f => f.isId); - if (idField) { - const { rpc, ts } = fieldTypes(idField, used); - const idValidator = withValidation - ? fieldValidator(idField, false, enumValues) - : null; - const idLine: FieldLine = { - line: - (idValidator ? ` @validate(${idValidator})\n` : '') + - ` @property(${rpc})\n ${idField.name}!: ${ts};`, - validated: !!idValidator, - }; - out.push( - buildInput(`${model.name}UpdateInput`, [ - idLine, - ...fields - .filter(f => !f.isId) - .map(f => - inputFieldLine( - f, - used, - true, - enumValues, - withValidation, - ), - ), - ]), - ); - } - // Bulk (createMany/updateMany) data is scalar-only: Prisma accepts no - // nested relation writes there, so relations are left out entirely. - const scalars = fields.filter(f => f.kind !== 'object'); - out.push( - buildInput( - `${model.name}CreateBulkInput`, - scalars.map(f => - inputFieldLine(f, used, false, enumValues, withValidation), - ), - ), - buildInput( - `${model.name}UpdateBulkInput`, - scalars - .filter(f => !f.isId) - .map(f => - inputFieldLine( - f, - used, - true, - enumValues, - withValidation, - ), - ), - ), - ); - - return out; - }); - - const body = `${classes.join('\n\n')}\n`; - const validationImports = body.includes('@validate(') - ? "import { z } from 'zod';\n" + - "import { validatable, validate } from '@imqueue/validation';\n" - : ''; - - return ( - "import { classType, property } from '@imqueue/rpc';\n" + - renderUsedImports(used) + - validationImports + - '\n' + - body - ); -} - -/** Whether the field's DB default is a client-side `uuid()` (no arguments). */ -function isUuidDefault(field: Field): boolean { - const def = field.default; - - return ( - field.hasDefaultValue && - typeof def === 'object' && - !Array.isArray(def) && - 'name' in def && - def.name === 'uuid' - ); -} - -/** - * The base Zod expression for a field, or `null` when no meaningful validation - * applies (relations, `Json`, unmapped scalars). `uuid()`-defaulted columns are - * validated as UUIDs (this auto-derives the `id` rule without a `///` - * directive); string columns pick up their `@db.VarChar(n)` length as `.max(n)`. - */ -function zodBase( - field: Field, - enumValues: Record, -): string | null { - if (field.kind === 'object') { - return null; - } - if (field.kind === 'enum') { - const values = enumValues[field.type] ?? []; - - return `z.enum([${values.map(v => `'${v}'`).join(', ')}])`; - } - const ts = scalarType(field); - if (ts === 'string') { - if (isUuidDefault(field)) { - return 'z.uuid()'; - } - const [nativeName, nativeArgs] = field.nativeType ?? []; - const max = - nativeName && /char/i.test(nativeName) && nativeArgs?.[0] - ? `.max(${nativeArgs[0]})` - : ''; - - return `z.string()${max}`; - } - if (ts === 'number') { - return field.type === 'Int' ? 'z.number().int()' : 'z.number()'; - } - if (ts === 'boolean') { - return 'z.boolean()'; - } - - return null; -} - -/** - * The full Zod expression for a `@validate(...)` decorator on a field, or `null` - * when the field has no validation: base type + any `/// @validate ` - * refinement appended (e.g. `/// @validate .email().max(255)`) + `.optional()` - * when `optional` (kept in lockstep with the field's `@property` optionality). - */ -function fieldValidator( - field: Field, - optional: boolean, - enumValues: Record, -): string | null { - const base = zodBase(field, enumValues); - if (!base) { - return null; - } - const wrapped = field.isList ? `z.array(${base})` : base; - const tail = /@validate\s+(.+)/.exec(field.documentation ?? ''); - const refined = tail?.[1] ? wrapped + tail[1].trim() : wrapped; - - return optional ? `${refined}.optional()` : refined; -} - -/** - * Emit `args.ts`: per-model repository/RPC argument classes — - * `CreateArgs`, `UpdateArgs` (models with an `id`), - * `SingleArgs`, `ListArgs`, the bulk `{Create,Update, - * Remove}BulkArgs`, and the `Page` list result — plus the shared - * `PageOptions` and `BulkCount`. These are the exposed wire contracts of the - * generated repositories. - */ -function renderArgs( - models: readonly Model[], - validation: ReadonlySet, -): string { - // An `input!: ` line, validated (when the model opts in) by inferring - // the referenced input class's own `@validate` field schema. - const inputField = (inputName: string, withValidation: boolean): string => - (withValidation ? ` @validate(${inputName})\n` : '') + - ` @property('${inputName}')\n input!: ${inputName};`; - - const classes = [ - renderClass('PageOptions', [ - " @property('number', true)\n skip?: number;", - " @property('number', true)\n take?: number;", - " @property('boolean', true)\n withTotal?: boolean;", - ]), - renderClass('BulkCount', [ - " @property('number')\n count!: number;", - ]), - ...models.flatMap(model => { - const n = model.name; - const withValidation = validation.has(n); - const select = ` @property('${n}Select', true)\n select?: ${n}Select;`; - const out = [ - renderClass( - `${n}CreateArgs`, - [inputField(`${n}CreateInput`, withValidation), select], - withValidation, - ), - ]; - if (withId(model)) { - out.push( - renderClass( - `${n}UpdateArgs`, - [inputField(`${n}UpdateInput`, withValidation), select], - withValidation, - ), - ); - } - out.push( - renderClass(`${n}SingleArgs`, [ - ` @property('${n}Where')\n where!: ${n}Where;`, - select, - ]), - renderClass(`${n}ListArgs`, [ - ` @property('${n}Where', true)\n where?: ${n}Where;`, - select, - ` @property('${n}OrderBy', true)\n orderBy?: ${n}OrderBy;`, - ` @property('PageOptions', true)\n options?: PageOptions;`, - ]), - renderClass(`${n}CreateBulkArgs`, [ - ` @property('Array<${n}CreateBulkInput>')\n` + - ` input!: ${n}CreateBulkInput[];`, - ]), - renderClass(`${n}UpdateBulkArgs`, [ - ` @property('${n}Where')\n where!: ${n}Where;`, - ` @property('${n}UpdateBulkInput')\n` + - ` input!: ${n}UpdateBulkInput;`, - ]), - renderClass(`${n}RemoveBulkArgs`, [ - ` @property('${n}Where')\n where!: ${n}Where;`, - ]), - renderClass(`${n}Page`, [ - ` @property('Array<${n}>')\n items!: ${n}[];`, - ` @property('number', true)\n total!: number | null;`, - ]), - ); - - return out; - }), - ]; - const list = (names: string[]) => - names.map(name => ` ${name},`).join('\n'); - const inputNames = models.flatMap(m => [ - `${m.name}CreateInput`, - `${m.name}CreateBulkInput`, - `${m.name}UpdateBulkInput`, - ...(withId(m) ? [`${m.name}UpdateInput`] : []), - ]); - // Inputs referenced by a `@validate(...)` decorator need a runtime (value) - // import; the rest stay type-only. - const valueInputs = new Set( - models - .filter(m => validation.has(m.name)) - .flatMap(m => [ - `${m.name}CreateInput`, - ...(withId(m) ? [`${m.name}UpdateInput`] : []), - ]), - ); - const typeInputNames = inputNames.filter(n => !valueInputs.has(n)).sort(); - const valueInputNames = [...valueInputs].sort(); - const queryNames = models - .flatMap(m => [`${m.name}OrderBy`, `${m.name}Select`, `${m.name}Where`]) - .sort(); - const modelNames = models.map(m => m.name).sort(); - - return ( - "import { classType, property } from '@imqueue/rpc';\n" + - (valueInputNames.length > 0 - ? `import {\n${list(valueInputNames)}\n} from '#generated/inputs.js';\n` - : '') + - (typeInputNames.length > 0 - ? `import type {\n${list(typeInputNames)}\n} from '#generated/inputs.js';\n` - : '') + - `import type {\n${list(modelNames)}\n} from '#generated/models.js';\n` + - `import type {\n${list(queryNames)}\n} from '#generated/query.js';\n` + - (valueInputNames.length > 0 - ? "import { validatable, validate } from '@imqueue/validation';\n" - : '') + - '\n' + - `${classes.join('\n\n')}\n` - ); -} - -/** - * The generic entity helpers emitted into `repositories.ts`, wrapping Prisma - * delegates with the query DSL converters. - */ -const REPOSITORY_RUNTIME = ` -export interface Page { - items: Entity[]; - total: number | null; -} - -interface Delegate { - create(args: any): Promise; - createMany(args: any): Promise; - update(args: any): Promise; - updateMany(args: any): Promise; - deleteMany(args: any): Promise; - findFirst(args: any): Promise; - findMany(args: any): Promise; - count(args: any): Promise; -} - -async function createEntity( - delegate: Delegate, - args: { input: object; select?: Select }, -): Promise { - return (await delegate.create({ - data: args.input, - select: toSelect(args.select), - })) as Entity; -} - -async function updateEntity( - delegate: Delegate, - args: { input: { id: string }; select?: Select }, -): Promise { - const { id, ...data } = args.input; - - return (await delegate.update({ - where: { id }, - data, - select: toSelect(args.select), - })) as Entity; -} - -async function createBulkEntities( - delegate: Delegate, - args: { input: object[] }, -): Promise { - return (await delegate.createMany({ data: args.input })) as BulkCount; -} - -async function updateBulkEntities( - delegate: Delegate, - model: string, - args: { where: Where; input: object }, -): Promise { - return (await delegate.updateMany({ - where: toWhere(RELATIONS, model, args.where) ?? {}, - data: args.input, - })) as BulkCount; -} - -async function removeBulkEntities( - delegate: Delegate, - model: string, - args: { where: Where }, -): Promise { - return (await delegate.deleteMany({ - where: toWhere(RELATIONS, model, args.where) ?? {}, - })) as BulkCount; -} - -async function findEntity( - delegate: Delegate, - model: string, - args: { where: Where; select?: Select }, -): Promise { - return (await delegate.findFirst({ - where: toWhere(RELATIONS, model, args.where) ?? {}, - select: toSelect(args.select), - })) as Entity | null; -} - -async function findPage( - delegate: Delegate, - model: string, - args: { - where?: Where; - select?: Select; - orderBy?: OrderBy; - options?: PageOptions; - }, -): Promise> { - const { skip, take, withTotal } = args.options ?? {}; - const where = toWhere(RELATIONS, model, args.where); - - const [items, total] = await Promise.all([ - delegate.findMany({ - where, - select: toSelect(args.select), - orderBy: toOrderBy(RELATIONS, model, args.orderBy), - skip, - take, - }) as Promise, - withTotal - ? (delegate.count({ where }) as Promise) - : Promise.resolve(null), - ]); - - return { items, total }; -} -`; - -/** - * Emit `repositories.ts`: a self-contained data layer — its own generic - * entity helpers (using the query DSL converters), one repository per model - * with `create`/`update`/`single`/`list` accepting the generated - * `…Args` classes, aggregated into the `repository` export - * (`repository.user.list(...)`). `create`/`update` pass `input` straight - * through to Prisma; `update` is omitted for models without an `id` - * (composite-key join tables). - */ -function renderRepositories(models: readonly Model[]): string { - const repositories = models.map(model => { - const name = model.name; - const acc = lowerFirst(name); - const methods = [ - ` create(args: ${name}CreateArgs): Promise {\n` + - ` return createEntity(prisma.${acc}, args);\n` + - ` },`, - ...(withId(model) - ? [ - ` update(args: ${name}UpdateArgs): Promise {\n` + - ` return updateEntity(prisma.${acc}, args);\n` + - ` },`, - ] - : []), - ` single(args: ${name}SingleArgs): Promise {\n` + - ` return findEntity(prisma.${acc}, '${name}', args);\n` + - ` },`, - ` list(args: ${name}ListArgs): Promise> {\n` + - ` return findPage(prisma.${acc}, '${name}', args);\n` + - ` },`, - ` createBulk(args: ${name}CreateBulkArgs): Promise {\n` + - ` return createBulkEntities(prisma.${acc}, args);\n` + - ` },`, - ` updateBulk(args: ${name}UpdateBulkArgs): Promise {\n` + - ` return updateBulkEntities(prisma.${acc}, '${name}', args);\n` + - ` },`, - ` removeBulk(args: ${name}RemoveBulkArgs): Promise {\n` + - ` return removeBulkEntities(prisma.${acc}, '${name}', args);\n` + - ` },`, - ]; - - return `const ${acc} = {\n${methods.join('\n\n')}\n};`; - }); - - const list = (names: string[]) => - names.map(name => ` ${name},`).join('\n'); - const argNames = [ - 'BulkCount', - 'PageOptions', - ...models.flatMap(m => [ - `${m.name}CreateArgs`, - ...(withId(m) ? [`${m.name}UpdateArgs`] : []), - `${m.name}SingleArgs`, - `${m.name}ListArgs`, - `${m.name}CreateBulkArgs`, - `${m.name}UpdateBulkArgs`, - `${m.name}RemoveBulkArgs`, - ]), - ].sort(); - const modelNames = models.map(m => m.name).sort(); - const aggregate = - 'export const repository = {\n' + - models.map(m => ` ${lowerFirst(m.name)},`).join('\n') + - '\n};'; - - return ( - `import type {\n${list(argNames)}\n} from '#generated/args.js';\n` + - `import type {\n${list(modelNames)}\n} from '#generated/models.js';\n` + - 'import {\n' + - ' type OrderBy,\n' + - ' type Select,\n' + - ' type Where,\n' + - ' toOrderBy,\n' + - ' toSelect,\n' + - ' toWhere,\n' + - "} from '#generated/query.js';\n" + - "import { RELATIONS } from '#generated/relations.js';\n" + - "import { prisma } from '#prisma.js';\n" + - '\n' + - `${REPOSITORY_RUNTIME.trim()}\n\n` + - `${repositories.join('\n\n')}\n\n` + - `${aggregate}\n` - ); -} - -/** Emit `index.ts`: a barrel re-exporting every generated module. */ -function renderBarrel(): string { - return ( - ['relations', 'models', 'query', 'inputs', 'args', 'repositories'] - .map(name => `export * from '#generated/${name}.js';`) - .join('\n') + '\n' - ); -} - -const generator = { - onManifest: () => ({ - prettyName: 'RPC models & repositories', - defaultOutput: '../src/generated', - }), - async onGenerate(options: GeneratorOptions): Promise { - const output = - options.generator.output?.value ?? - join(dirname(options.schemaPath), '..', 'src', 'generated'); - const configSet = (key: string): Set => { - const raw = options.generator.config[key] ?? ''; - return new Set( - (Array.isArray(raw) ? raw.join(',') : raw) - .split(',') - .map(entry => entry.trim()) - .filter(Boolean), - ); - }; - const omit = configSet('omit'); - - const models = options.dmmf.datamodel.models; - const assertKnown = (key: string, names: Iterable) => { - for (const name of names) { - if (!models.some(m => m.name === name)) { - throw new Error( - `codegen: unknown model "${name}" in \`${key}\``, - ); - } - } - }; - - const tokens = (key: string): string[] => { - const raw = options.generator.config[key] ?? ''; - return (Array.isArray(raw) ? raw.join(',') : raw) - .split(',') - .map(entry => entry.trim()) - .filter(Boolean); - }; - const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/; - const assertColumn = ( - modelName: string, - column: string, - key: string, - ): void => { - const model = models.find(m => m.name === modelName); - if (!model?.fields.some(f => f.name === column)) { - throw new Error( - `codegen: unknown column "${modelName}.${column}" in \`${key}\``, - ); - } - }; - const softDelete = parseSoftDelete( - tokens('softDelete'), - models, - assertColumn, - ); - const authorship = parseAuthorship( - tokens('authorship'), - models, - assertColumn, - ); - // Link each authored model's soft-delete column (if any) as the trigger - // that makes an update stamp `deletedBy` (see the authorship extension). - for (const [name, cfg] of Object.entries(authorship)) { - const sd = softDelete[name]; - if (sd) { - cfg.deletedAt = sd.deletedAt; - } - } - const audit = parseAudit(tokens('audit'), models, assertKnown, IDENT); - // Validation is OFF unless enabled via the `validation` option - // (e.g. `validation = "auto"`). - const validation = parseValidation( - tokens('validation'), - models, - assertKnown, - ); - const accessScope = parseAccessScope( - tokens('accessScope'), - models, - assertColumn, - IDENT, - ); - - // `scalars` overrides the default scalar→TS map, e.g. - // `scalars = "DateTime:string,BigInt:string"`. The map starts from - // `DEFAULT_SCALARS` at module load; codegen runs once per process, so - // config overrides are applied by mutation below. - const scalarRaw = options.generator.config.scalars ?? ''; - for (const pair of (Array.isArray(scalarRaw) - ? scalarRaw.join(',') - : scalarRaw - ) - .split(',') - .map(entry => entry.trim()) - .filter(Boolean)) { - const [type, tsType] = pair.split(':').map(part => part.trim()); - if (!type || !tsType) { - throw new Error( - `codegen: invalid scalar mapping "${pair}" ` + - '(expected Type:tsType)', - ); - } - scalars[type] = tsType; - } - - const include = configSet('include'); - const exclude = configSet('exclude'); - assertKnown('include', include); - assertKnown('exclude', exclude); - const generated = models.filter( - m => - (include.size === 0 || include.has(m.name)) && - !exclude.has(m.name), - ); - if (generated.length === 0) { - throw new Error('codegen: include/exclude left no models'); - } - const generatedNames = new Set(generated.map(m => m.name)); - - Object.assign( - enumUnions, - Object.fromEntries( - options.dmmf.datamodel.enums.map(e => [ - e.name, - e.values.map(v => `'${v.name}'`).join(' | '), - ]), - ), - ); - const enumValues = Object.fromEntries( - options.dmmf.datamodel.enums.map(e => [ - e.name, - e.values.map(v => v.name), - ]), - ); - const softDeletedField: Hidden = (model, field) => - softDelete[model.name]?.deletedAt === field.name; - const droppedRelation = (field: Field): boolean => - field.kind === 'object' && !generatedNames.has(field.type); - const inputHidden: Hidden = (model, field) => - softDeletedField(model, field) || droppedRelation(field); - const readHidden: Hidden = (model, field) => - omit.has(`${model.name}.${field.name}`) || - inputHidden(model, field); - - await mkdir(output, { recursive: true }); - await writeFile( - join(output, 'relations.ts'), - renderRelations(models, softDelete, authorship, audit, accessScope), - ); - await writeFile( - join(output, 'models.ts'), - renderModels(generated, readHidden), - ); - await writeFile( - join(output, 'query.ts'), - renderQuery(generated, readHidden), - ); - await writeFile( - join(output, 'inputs.ts'), - renderInputs(generated, inputHidden, enumValues, validation), - ); - await writeFile( - join(output, 'args.ts'), - renderArgs(generated, validation), - ); - await writeFile( - join(output, 'repositories.ts'), - renderRepositories(generated), - ); - await writeFile(join(output, 'index.ts'), renderBarrel()); - - try { - execSync(`npx oxfmt "${output}"`, { - cwd: join(output, '..', '..'), - stdio: 'ignore', - }); - } catch {} - }, -}; - -// Register with Prisma only when run directly as the generator entry (Prisma -// spawns `node lib/codegen.ts`). Importing this module — e.g. via the package -// barrel — must have no side effects and must not require the dev-only -// `@prisma/generator-helper`, so it is loaded lazily here. -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - const { default: generatorHelper } = - await import('@prisma/generator-helper'); - generatorHelper.generatorHandler(generator); -} diff --git a/src/data-layer.ts b/src/data-layer.ts new file mode 100644 index 0000000..87deb60 --- /dev/null +++ b/src/data-layer.ts @@ -0,0 +1,139 @@ +/*! + * Prisma Next (8.x) data layer: one call, correctly composed + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import { isoDates } from './iso-dates.js'; +import { type AccessScopeResolver, accessScope } from './access-scope.js'; +import { type AuditConfig, audit } from './audit.js'; +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; +import { + type DerivedDataLayer, + type DeriveOptions, + deriveDataLayer, +} from './derive.js'; +import { type QueryLogOptions, queryLog } from './query-log.js'; +import { stamp } from './stamp.js'; + +/** The audit half of {@link DataLayerOptions}, omitted to record nothing. */ +export interface DataLayerAudit { + /** Connection string for the trail's own pool. */ + connectionString: string; + /** Where the trail goes. Omitted, the conventional table and columns. */ + config?: AuditConfig; + /** Resolves the actor to record, or a falsy value to record none. */ + getPrincipal: () => unknown; +} + +/** Everything {@link dataLayer} needs. */ +export interface DataLayerOptions extends DeriveOptions { + /** Resolves the id of the actor performing the current write. */ + getActorId: () => string | null; + /** One resolver per access level named in `scope`. */ + resolvers?: Record; + /** Where to record writes. Omitted, nothing is recorded. */ + audit?: DataLayerAudit; + /** Statement logging. Omitted, nothing is logged. */ + log?: QueryLogOptions; +} + +/** What {@link dataLayer} hands back. */ +export interface DataLayer { + /** Pass straight to the `middleware` option of `postgres()`. */ + middleware: readonly SqlMiddleware[]; + /** + * The config this was built from. + * + * @remarks + * Exposed so a caller that also needs it — `repositoriesFor` wants the + * relation map — reads it here rather than deriving the contract twice. + */ + derived: DerivedDataLayer; + /** Releases the audit pool. A no-op when nothing is recorded. */ + close(): Promise; +} + +/** + * Build the whole data layer from an emitted contract, in one call. + * + * @remarks + * The middlewares are returned already composed, which is the point: a caller + * never orders them and so cannot order them wrongly. Under Prisma 7 this was + * five `$extends` calls whose sequence every service repeated and any service + * could get wrong, with a soft delete losing its `deletedBy` and nothing + * reporting it. Here the two halves that were order-dependent are one + * middleware, and what remains genuinely commutes. + * + * Reach for the individual factories only to compose something this does not + * cover; for the ordinary case this is the entry point. + * + * @param options - The contract, what cannot be derived, and the resolvers. + * @returns The middleware array and a teardown hook. + * @throws When `scope` names a model the contract does not define. + * @example + * ```typescript + * const layer = dataLayer({ + * contract: contractJson, + * scope: { Portfolio: { portfolio: ['id'] } }, + * resolvers: { portfolio: () => currentPortfolioIds() }, + * getActorId: currentActorId, + * }); + * + * export const db = postgres({ + * contractJson, + * url: config.db.url, + * middleware: layer.middleware, + * }); + * ``` + */ +export function dataLayer(options: DataLayerOptions): DataLayer { + const derived = deriveDataLayer(options); + const recorded = options.audit + ? audit({ + connectionString: options.audit.connectionString, + ...(options.audit.config ? { config: options.audit.config } : {}), + getPrincipal: options.audit.getPrincipal, + stamps: derived.stamps, + tables: derived.audit, + }) + : undefined; + + return { + derived, + middleware: [ + // Logging outermost, so a line is written whatever the rewrites + // below it did — and so a failure in one of them is still timed. + ...(options.log ? [queryLog(options.log)] : []), + // Innermost of the read-side rewrites: it converts what the + // database returned, so it must not see values another hook has + // already reshaped. + isoDates({ columns: derived.dates }), + ...(recorded ? [recorded] : []), + stamp({ tables: derived.stamps, getActorId: options.getActorId }), + accessScope({ + tables: derived.scope, + resolvers: options.resolvers ?? {}, + }), + ], + close: (): Promise => recorded?.close() ?? Promise.resolve(), + }; +} diff --git a/src/derive.ts b/src/derive.ts new file mode 100644 index 0000000..b2edb3b --- /dev/null +++ b/src/derive.ts @@ -0,0 +1,337 @@ +/*! + * Prisma Next (8.x) data-layer config derived from the emitted contract + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import { TIMESTAMP_CODECS } from './iso-dates.js'; + +/** The slice of an emitted `contract.json` this derivation reads. */ +export interface ContractJson { + readonly domain?: { + readonly namespaces?: Record< + string, + { + readonly models?: Record< + string, + { + readonly fields?: Record; + readonly relations?: Record< + string, + { + readonly cardinality?: string; + readonly to?: { readonly model?: string }; + readonly on?: { + readonly localFields?: readonly string[]; + readonly targetFields?: readonly string[]; + }; + } + >; + readonly storage?: { + readonly table?: string; + readonly fields?: Record< + string, + { readonly column?: string } + >; + }; + } + >; + } + >; + }; +} + +/** One relation, as the query translator needs it. */ +export interface RelationInfo { + /** The model on the other end. */ + target: string; + /** Whether the other end is many. */ + isList: boolean; + /** Fields on this side the relation joins on. */ + localFields: string[]; + /** Fields on the other side they join to. */ + targetFields: string[]; +} + +/** Relations per model, by field name. */ +export type RelationMap = Record>; + +/** Columns a model is stamped with, by physical column name. */ +export interface StampColumns { + /** Column marking the row deleted, when the model is soft-deleted. */ + deletedAt?: string; + /** Column stamped with the time of every write. */ + updatedAt?: string; + /** + * Codec the timestamp columns are stored under. + * + * @remarks + * Read from the contract rather than assumed: a table may be `timestamp` + * or `timestamptz`, and binding a value under the wrong one is rejected by + * the driver. + */ + timestampCodec?: string; + /** Column stamped once, when the row is created. */ + createdBy?: string; + /** Column stamped on create and on every update. */ + updatedBy?: string; + /** Column stamped when a delete is rewritten into a stamp. */ + deletedBy?: string; +} + +/** Stamp columns per physical table. */ +export type StampTables = Record; + +/** + * Scope columns per physical table, per access level. + * + * @remarks + * A row is in scope for a level when ANY of that level's columns matches (OR); + * a row is in scope when EVERY active level matches (AND). + */ +export type ScopeTables = Record>; + +/** Field names to look for, where they are not the defaults. */ +export interface DeriveFields { + /** Field marking a row soft-deleted. Default `deletedAt`. */ + deletedAt?: string; + /** Field holding the time of the last write. Default `updatedAt`. */ + updatedAt?: string; + /** Field holding the creating actor. Default `createdBy`. */ + createdBy?: string; + /** Field holding the last updating actor. Default `updatedBy`. */ + updatedBy?: string; + /** Field holding the deleting actor. Default `deletedBy`. */ + deletedBy?: string; +} + +/** Everything {@link deriveDataLayer} needs. */ +export interface DeriveOptions { + /** The emitted contract, imported from `contract.json`. */ + contract: ContractJson; + /** Field names, where they differ from the defaults. */ + fields?: DeriveFields; + /** + * Access levels and the fields each is scoped by, keyed by MODEL name. + * + * @remarks + * Declared here rather than in the schema because Prisma Next has no + * schema-level annotation to carry it — the `/// @scope(...)` doc comment + * the Prisma 7 generator read does not survive into the contract. A model + * named here that the contract does not define is a throw rather than a + * silent no-op: a typo would otherwise leave that model unscoped, which is + * the failure direction that leaks rows. + */ + scope?: Record>; + /** Models to leave out of the audit trail, by MODEL name. */ + auditExclude?: readonly string[]; +} + +/** The config the middlewares consume, keyed by physical table. */ +export interface DerivedDataLayer { + /** Soft-delete and authorship columns per table. */ + stamps: StampTables; + /** Tables whose writes are recorded, and the model name to record. */ + audit: Record; + /** Scope columns per table per level. */ + scope: ScopeTables; + /** + * Columns stored under a timestamp codec, by column name. + * + * @remarks + * By name rather than by table: a row carrying included relations holds + * columns from several tables at once, and the conversion is driven by + * what a value is, not by where the statement started. + */ + dates: Set; + /** + * Relations per MODEL, for the query translator. + * + * @remarks + * Keyed by model rather than table, because the query surface a caller + * sends over the wire names models and fields — the physical table only + * matters below it. + */ + relations: RelationMap; +} + +const DEFAULTS = { + deletedAt: 'deletedAt', + updatedAt: 'updatedAt', + createdBy: 'createdBy', + updatedBy: 'updatedBy', + deletedBy: 'deletedBy', +} as const; + +interface Entry { + model: string; + table: string; + fields: Record; + codecOf: (field: string) => string | undefined; + relations: Record; + column: (field: string) => string; +} + +function entriesOf(contract: ContractJson): Entry[] { + return Object.values(contract.domain?.namespaces ?? {}).flatMap(namespace => + Object.entries(namespace.models ?? {}).map(([model, definition]) => ({ + model, + table: definition.storage?.table ?? model, + fields: definition.fields ?? {}, + codecOf: (field: string): string | undefined => + ( + definition.fields?.[field] as + | { type?: { codecId?: string } } + | undefined + )?.type?.codecId, + relations: Object.fromEntries( + Object.entries(definition.relations ?? {}).map( + ([field, relation]) => [ + field, + { + target: relation.to?.model ?? field, + isList: + relation.cardinality?.endsWith(':N') ?? false, + localFields: [...(relation.on?.localFields ?? [])], + targetFields: [ + ...(relation.on?.targetFields ?? []), + ], + }, + ], + ), + ), + column: (field: string): string => + definition.storage?.fields?.[field]?.column ?? field, + })), + ); +} + +/** + * Derive the data-layer config from an emitted contract. + * + * @remarks + * This replaces the Prisma 7 generator that wrote `SOFT_DELETE_MODELS`, + * `AUTHORSHIP_MODELS` and the rest into `src/generated`. Prisma Next has no + * custom-generator protocol and does not need one: the contract already names + * every model, field and physical column, so the same config is a lookup + * rather than a build step, and nothing can go stale against the schema. + * + * Membership is by convention, which is what `softDelete = "auto"` and + * `authorship = "auto"` meant. Everything is keyed by **physical table**, + * because that is what a statement names — resolving a table back to a model + * at query time would have to go through the contract's `roots`, which is + * keyed by bare table name only while that name is unique across namespaces. + * + * @param options - The contract and the declarations that cannot be inferred. + * @returns Config for {@link dataLayer} and the individual middlewares. + * @throws When `scope` names a model the contract does not define. + * @example + * ```typescript + * const layer = deriveDataLayer({ + * contract: contractJson, + * scope: { Portfolio: { portfolio: ['id'] } }, + * }); + * ``` + */ +export function deriveDataLayer({ + contract, + fields, + scope = {}, + auditExclude = [], +}: DeriveOptions): DerivedDataLayer { + const names = { ...DEFAULTS, ...fields }; + const entries = entriesOf(contract); + const byModel = new Map(entries.map(entry => [entry.model, entry])); + const excluded = new Set(auditExclude); + + const unknown = Object.keys(scope).filter(model => !byModel.has(model)); + if (unknown.length > 0) { + throw new Error( + `deriveDataLayer: scope names ${unknown.join(', ')}, which the ` + + 'contract does not define', + ); + } + + const stamped = (entry: Entry): StampColumns => { + const codec = entry.codecOf(names.updatedAt); + + return { + ...(entry.fields[names.deletedAt] + ? { deletedAt: entry.column(names.deletedAt) } + : {}), + ...(entry.fields[names.updatedAt] + ? { updatedAt: entry.column(names.updatedAt) } + : {}), + ...(entry.fields[names.updatedAt] && codec + ? { timestampCodec: codec } + : {}), + ...(entry.fields[names.createdBy] + ? { createdBy: entry.column(names.createdBy) } + : {}), + ...(entry.fields[names.updatedBy] + ? { updatedBy: entry.column(names.updatedBy) } + : {}), + ...(entry.fields[names.deletedBy] + ? { deletedBy: entry.column(names.deletedBy) } + : {}), + }; + }; + + return { + dates: new Set( + entries.flatMap(entry => + Object.keys(entry.fields) + .filter(field => + TIMESTAMP_CODECS.has(entry.codecOf(field) ?? ''), + ) + .map(field => entry.column(field)), + ), + ), + stamps: Object.fromEntries( + entries + .map(entry => [entry.table, stamped(entry)] as const) + .filter(([, columns]) => Object.keys(columns).length > 0), + ), + audit: Object.fromEntries( + entries + .filter(entry => !excluded.has(entry.model)) + .map(entry => [entry.table, entry.model]), + ), + scope: Object.fromEntries( + Object.entries(scope).map(([model, levels]) => [ + byModel.get(model)?.table ?? model, + Object.fromEntries( + Object.entries(levels).map(([level, columns]) => [ + level, + columns.map( + field => byModel.get(model)?.column(field) ?? field, + ), + ]), + ), + ]), + ), + relations: Object.fromEntries( + entries + .filter(entry => Object.keys(entry.relations).length > 0) + .map(entry => [entry.model, entry.relations]), + ), + }; +} diff --git a/src/emit/all.ts b/src/emit/all.ts new file mode 100644 index 0000000..4557fb0 --- /dev/null +++ b/src/emit/all.ts @@ -0,0 +1,137 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import type { DeriveFields } from '../derive.js'; +import { mkdir, writeFile } from 'node:fs/promises'; +import type { ImportMap } from './imports.js'; +import { + type EmitContract, + type EnumNames, + emitEnums, + emitModels, +} from './models.js'; +import { type ValidationRules, emitRpcTypes } from './rpc.js'; + +/** Everything {@link emitAll} needs. */ +export interface EmitAllOptions { + /** The emitted contract, read from `contract.json`. */ + contract: EmitContract; + /** Directory the files are written to. */ + outDir: string | URL; + /** Namespace to emit. Defaults to the only one, or `public`. */ + namespace?: string; + /** Validation rules per model per field, as Zod suffixes. */ + validation?: ValidationRules; + /** Where the generated files import their runtime from. */ + imports?: ImportMap; + /** Stamp field names, where a service overrides the defaults. */ + fields?: DeriveFields; + /** Fields kept off the generated surface, as `Model.field`. */ + omit?: readonly string[]; + /** Names for enum members whose database label is not the name. */ + enums?: EnumNames; +} + +/** One emitted file. */ +const FILES = { + enums: 'enums.ts', + models: 'models.ts', + rpc: 'rpc.ts', + index: 'index.ts', +} as const; + +/** + * Emit every generated file for a contract. + * + * @remarks + * The three emitters and the barrel that ties them together, in one call, + * because writing them out separately is the same handful of lines in every + * consumer — and a consumer that forgets the barrel gets a build error rather + * than a hint. + * + * Deliberately takes an `outDir` and no opinion about where a project keeps + * its contract or its generated code: those are the consumer's conventions, + * not this package's. + * + * @param options - The contract, where to write, and what to redirect. + * @returns The paths written. + * @example + * ```typescript + * await emitAll({ + * contract, + * outDir: new URL('../src/generated/', import.meta.url), + * imports: parseImportMap('zod=@my-org/runtime'), + * }); + * ``` + */ +export async function emitAll({ + contract, + outDir, + namespace, + validation, + imports, + fields, + omit, + enums, +}: EmitAllOptions): Promise { + const dir = outDir instanceof URL ? outDir : new URL(`file://${outDir}/`); + const shared = { + contract, + ...(namespace ? { namespace } : {}), + ...(omit ? { omit } : {}), + }; + const written: [string, string][] = [ + [FILES.enums, emitEnums({ ...shared, ...(enums ? { enums } : {}) })], + [ + FILES.models, + emitModels({ ...shared, ...(imports ? { imports } : {}) }), + ], + [ + FILES.rpc, + emitRpcTypes({ + ...shared, + ...(validation ? { validation } : {}), + ...(imports ? { imports } : {}), + ...(fields ? { fields } : {}), + }), + ], + [ + FILES.index, + Object.values(FILES) + .filter(name => name !== FILES.index) + .map( + name => + `export * from './${name.replace(/\.ts$/, '.js')}';\n`, + ) + .join(''), + ], + ]; + + await mkdir(dir, { recursive: true }); + await Promise.all( + written.map(([name, body]) => writeFile(new URL(name, dir), body)), + ); + + return written.map(([name]) => new URL(name, dir).pathname); +} diff --git a/src/emit/imports.ts b/src/emit/imports.ts new file mode 100644 index 0000000..72875e4 --- /dev/null +++ b/src/emit/imports.ts @@ -0,0 +1,190 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +/** + * The modules generated code imports at runtime, and how a consumer redirects + * them. + * + * @remarks + * The decorators the generated classes carry are only meaningful to the + * registry that defined them. `@imqueue/rpc`, `@imqueue/validation` and `zod` + * therefore have to be a **single copy** shared with the service, and a second + * copy fails silently rather than loudly — a second decorator registry that + * nothing reads, or a `ZodError` that fails `instanceof`. + * + * The reliable way to guarantee one copy is for one package to own the + * dependency and re-export it, with every service taking it from there. That + * only works if the generated files can be pointed at that package, which is + * what this module exists to allow. + */ + +/** A module the generated code imports from, and what it takes from it. */ +export interface RuntimeModule { + /** The specifier, before any redirection. */ + readonly from: string; + /** The named exports taken from it. */ + readonly symbols: readonly string[]; + /** + * Whether these are types rather than values. + * + * @remarks + * A type imported as a value compiles under `verbatimModuleSyntax` and + * then fails at run time with no such export, because there is nothing + * there to import. + */ + readonly typeOnly?: boolean; +} + +/** + * Where each runtime lives by default. + * + * @remarks + * Declared rather than written at each emission site, so a specifier appears + * once and redirection has a single place to act. + */ +export const RUNTIME = { + rpc: { + from: '@imqueue/rpc', + symbols: ['classType', 'property'], + }, + validation: { + from: '@imqueue/validation', + symbols: ['validatable', 'validate'], + }, + zod: { + from: 'zod', + symbols: ['z'], + }, + repository: { + from: '@imqueue/pg-prisma', + symbols: ['Repository'], + typeOnly: true, + }, +} as const satisfies Record; + +/** A runtime the generated code can import from. */ +export type RuntimeName = keyof typeof RUNTIME; + +/** Original specifier to the specifier to emit in its place. */ +export type ImportMap = Readonly>; + +/** + * Read an import map from its generator-option spelling. + * + * @remarks + * `"zod=@my-org/runtime, @imqueue/rpc=@my-org/runtime"`. An + * entry naming a specifier this package never emits is a throw rather than a + * no-op: silently ignoring it would leave the consumer believing a redirection + * had been applied when the generated files still point at the original. + * + * @param spec - The option value, or undefined for no redirection. + * @returns The parsed map. + * @throws When an entry is malformed or names an unknown specifier. + * @example + * ```typescript + * parseImportMap('zod=@my-org/runtime'); + * // { zod: '@my-org/runtime' } + * ``` + */ +export function parseImportMap(spec?: string): ImportMap { + const known = new Set( + Object.values(RUNTIME).map(module => module.from as string), + ); + + return Object.fromEntries( + (spec ?? '') + .split(',') + .map(entry => entry.trim()) + .filter(entry => entry.length > 0) + .map(entry => { + const [from, to] = entry.split('=').map(part => part.trim()); + if (!from || !to) { + throw new Error(`imports: "${entry}" is not "="`); + } + if (!known.has(from)) { + throw new Error( + `imports: "${from}" is not a module this generator ` + + `emits (${[...known].sort().join(', ')})`, + ); + } + + return [from, to]; + }), + ); +} + +/** + * Emit the import statements for the runtimes a file uses. + * + * @remarks + * Statements are **merged by resolved specifier**. Redirecting several + * runtimes at one package is the whole point of the option, and emitting one + * import per original specifier would then produce three imports of the same + * module in a file — which is a lint error, and reads as an oversight rather + * than as the redirection it is. Specifiers and symbols are both sorted, so + * the output is stable and a regenerated file diffs cleanly. + * + * @param names - The runtimes this file takes symbols from. + * @param map - Redirections, from {@link parseImportMap}. + * @returns The import statements, newline-terminated, or '' for none. + * @example + * ```typescript + * emitImports(['rpc', 'zod'], { zod: '@my-org/runtime', + * '@imqueue/rpc': '@my-org/runtime' }); + * // "import { classType, property, z } from '@my-org/runtime';\n" + * ``` + */ +export function emitImports( + names: readonly RuntimeName[], + map: ImportMap = {}, +): string { + // Keyed by specifier AND by whether it is a type import: the two cannot + // merge into one statement, and a type merged into a value import is the + // failure this distinction exists to prevent. + const merged = names.reduce>>((acc, name) => { + const module: RuntimeModule = RUNTIME[name]; + const specifier = map[module.from] ?? module.from; + const key = `${module.typeOnly ? 'type' : 'value'} ${specifier}`; + const symbols = acc[key] ?? new Set(); + + module.symbols.forEach(symbol => symbols.add(symbol)); + acc[key] = symbols; + + return acc; + }, {}); + + return Object.keys(merged) + .sort() + .map(key => { + const [kind, ...rest] = key.split(' '); + const specifier = rest.join(' '); + + return ( + `import ${kind === 'type' ? 'type ' : ''}{ ` + + `${[...(merged[key] ?? [])].sort().join(', ')} } ` + + `from '${specifier}';\n` + ); + }) + .join(''); +} diff --git a/src/emit/models.ts b/src/emit/models.ts new file mode 100644 index 0000000..8642038 --- /dev/null +++ b/src/emit/models.ts @@ -0,0 +1,412 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import { type ImportMap, emitImports } from './imports.js'; + +/** How a Postgres codec is spelled in TypeScript. */ +const TS_TYPE: Record = { + 'pg/bool@1': 'boolean', + 'pg/bytea@1': 'string', + 'pg/date-string@1': 'string', + 'pg/float4@1': 'number', + 'pg/float8@1': 'number', + 'pg/int2@1': 'number', + 'pg/int4@1': 'number', + 'pg/int8@1': 'string', + 'pg/json@1': 'Record', + 'pg/jsonb@1': 'Record', + 'pg/numeric@1': 'string', + 'pg/text@1': 'string', + 'pg/timestamp-string@1': 'string', + 'pg/timestamptz-string@1': 'string', + 'pg/uuid@1': 'string', +}; + +interface FieldType { + readonly codecId?: string; + /** A native enum names its type here rather than through a value set. */ + readonly typeParams?: { readonly typeName?: string }; +} + +export interface Field { + readonly nullable?: boolean; + readonly many?: boolean; + readonly type?: FieldType; + readonly valueSet?: { readonly entityName?: string }; +} + +export interface Relation { + readonly cardinality?: string; + readonly to?: { readonly model?: string }; + readonly on?: { + readonly localFields?: readonly string[]; + readonly targetFields?: readonly string[]; + }; +} + +export interface Model { + readonly fields?: Record; + readonly relations?: Record; + readonly storage?: { + readonly table?: string; + readonly fields?: Record; + }; +} + +/** Member names per enum, where the label is not the name. */ +export type EnumNames = Readonly< + Record>> +>; + +export interface EnumDef { + readonly members?: readonly { readonly value?: string }[]; +} + +/** The slice of an emitted `contract.json` the emitter reads. */ +export interface EmitContract { + readonly domain?: { + readonly namespaces?: Record< + string, + { + readonly enum?: Record; + readonly models?: Record; + } + >; + }; + readonly storage?: { + readonly namespaces?: Record< + string, + { + readonly entries?: { + readonly native_enum?: Record< + string, + { readonly members?: readonly string[] } + >; + readonly table?: Record< + string, + { + readonly columns?: Record< + string, + { + readonly valueSet?: { + readonly entityName?: string; + }; + readonly default?: unknown; + } + >; + } + >; + }; + } + >; + }; +} + +/** Everything {@link emitModels} needs. */ +export interface EmitModelsOptions { + /** The emitted contract, read from `contract.json`. */ + contract: EmitContract; + /** Namespace to emit. Defaults to the only one, or `public`. */ + namespace?: string; + /** + * Where the generated file imports its runtime from. + * + * @remarks + * Redirect `@imqueue/rpc` and the rest at one package that re-exports them + * and every service takes a single copy of each — which is what the + * decorators require. See `parseImportMap`. + */ + imports?: ImportMap; + /** + * Names for enum members whose database label is not the name. + * + * @remarks + * `{ AttributeType: { STRING: 'string' } }` emits `STRING: 'string'` + * rather than `string: 'string'`. Prisma 7 spelled this `STRING + * @map("string")` in the schema; the contract records the labels alone, + * so the names are declared by the service and passed in. A member with + * no name here keeps its label, which is the usual case. + */ + enums?: EnumNames; + /** + * Fields kept off the generated surface, as `Model.field`. + * + * @remarks + * The column stays in the contract and in the database — this is about + * what crosses the RPC boundary. A raw upstream payload is stored because + * a failure has to be explainable, and published to nobody. + */ + omit?: readonly string[]; +} + +/** + * Quote a `@property` type string. + * + * @remarks + * An enum renders as a union of single-quoted members, so the surrounding + * quote has to be the other one or the decorator argument does not parse. + */ +export function quoted(value: string): string { + return value.includes("'") ? `"${value}"` : `'${value}'`; +} + +/** The TypeScript spelling of a field, and the `@property` type string. */ +export function typeOf( + field: Field, + enums: Record, + listEnum?: string, +): { ts: string; wire: string } { + // Three spellings, because the contract has three. A PSL enum names its + // value set on the field; a native `pg.enum(...)` names its type in the + // codec's parameters; and a list of either names it only on the storage + // column. Reading one leaves the others as bare strings. + const named = + field.valueSet?.entityName ?? + field.type?.typeParams?.typeName ?? + listEnum; + const members = named ? enums[named]?.members : undefined; + const base = members + ? members.map(member => `'${member.value}'`).join(' | ') + : (TS_TYPE[field.type?.codecId ?? ''] ?? 'unknown'); + // `'A' | 'B'[]` parses as `'A' | ('B'[])`; a union has to be parenthesised + // before the array suffix. + const listed = field.many + ? `${base.includes('|') ? `(${base})` : base}[]` + : base; + const wire = field.many ? `Array<${base}>` : base; + + return { + ts: field.nullable ? `${listed} | null` : listed, + wire, + }; +} + +/** + * Emit the `@imqueue/rpc` model classes for a contract. + * + * @remarks + * Prisma Next emits `contract.d.ts`, which carries the types but not the + * decorated classes: `@classType`/`@property` are what the RPC client + * generator reads, and a type alone is dropped from the generated client with + * no error. So this is emitted here rather than by Prisma, out of the same + * contract. + * + * Every property is optional and nullable-aware, because a DTO crossing the + * queue carries whatever the caller selected rather than the whole row. + * + * @param options - The contract, the namespace and any import redirection. + * @returns The file content. + * @example + * ```typescript + * await writeFile( + * 'src/generated/models.ts', + * emitModels({ contract, imports: parseImportMap(spec) }), + * ); + * ``` + */ +/** The models, enums and storage columns of one namespace. */ +/** Storage columns per physical table. */ +export type StorageTables = Record< + string, + { + columns?: Record< + string, + { valueSet?: { entityName?: string }; default?: unknown } + >; + } +>; + +/** + * Whether the database fills a column in when an insert leaves it out. + * + * @remarks + * A non-nullable column with a default is optional on a create, and demanding + * it is what made `createdAt` a required input. The lookup goes through the + * model's own storage mapping rather than assuming the table is named after + * the model. + */ +export function hasDatabaseDefault( + models: Record, + columnsOf: StorageTables, +): (model: string, field: string) => boolean { + return (model: string, field: string): boolean => { + const storage = models[model]?.storage; + const table = storage?.table ?? model; + const column = storage?.fields?.[field]?.column ?? field; + + return columnsOf[table]?.columns?.[column]?.default !== undefined; + }; +} + +export function namespaceOf( + contract: EmitContract, + namespace?: string, + omit: readonly string[] = [], +): { + models: Record; + enums: Record; + columnsOf: StorageTables; +} { + const namespaces = contract.domain?.namespaces ?? {}; + const chosen = + namespace ?? + (Object.keys(namespaces).length === 1 + ? (Object.keys(namespaces)[0] as string) + : 'public'); + + const entries = contract.storage?.namespaces?.[chosen]?.entries; + // A PSL `enum` block lands in the domain plane; a native Postgres enum + // (`pg.enum(...)`, which `contract infer` produces) lands in the storage + // plane instead. Reading only one leaves every native enum as a bare + // string. + const native = Object.fromEntries( + Object.entries(entries?.native_enum ?? {}).map(([name, definition]) => [ + name, + { members: (definition.members ?? []).map(value => ({ value })) }, + ]), + ); + + // Dropped here rather than in each emitter, so a field kept off the wire + // is off every part of it — the model class, the select, the filter and + // the create input alike. + const hidden = new Set(omit); + const models = Object.fromEntries( + Object.entries(namespaces[chosen]?.models ?? {}).map( + ([name, model]) => [ + name, + { + ...model, + fields: Object.fromEntries( + Object.entries(model.fields ?? {}).filter( + ([field]) => !hidden.has(`${name}.${field}`), + ), + ), + }, + ], + ), + ); + + return { + models, + enums: { ...native, ...namespaces[chosen]?.enum }, + columnsOf: entries?.table ?? {}, + }; +} + +export function emitModels({ + contract, + namespace, + imports = {}, + omit = [], +}: EmitModelsOptions): string { + const { models, enums, columnsOf } = namespaceOf(contract, namespace, omit); + + const classes = Object.entries(models).map(([name, model]) => { + const columns = columnsOf[name]?.columns ?? {}; + const fields = Object.entries(model.fields ?? {}).map( + ([field, definition]) => { + const { ts, wire } = typeOf( + definition, + enums, + columns[field]?.valueSet?.entityName, + ); + + return ( + ` @property(${quoted(wire)}, true)\n` + + ` ${field}?: ${ts};\n` + ); + }, + ); + const relations = Object.entries(model.relations ?? {}).map( + ([field, relation]) => { + const target = relation.to?.model ?? 'unknown'; + const many = relation.cardinality?.endsWith(':N') ?? false; + const wire = many ? `Array<${target}>` : target; + const ts = many ? `${target}[]` : `${target} | null`; + + return ` @property('${wire}', true)\n ${field}?: ${ts};\n`; + }, + ); + + return ( + `@classType()\nexport class ${name} {\n` + + [...fields, ...relations].join('\n') + + '}\n' + ); + }); + + return `${emitImports(['rpc'], imports)}\n${classes.join('\n')}`; +} + +/** + * Emit the enum constants for a contract. + * + * @remarks + * Prisma 7 exposed each enum as an `as const` object on the generated client, + * and `conventions.md`'s no-bare-strings rule leans on it: a member is written + * as `CredentialType.PASSWORD`, never as `'PASSWORD'`. Prisma Next carries the + * members in the contract but publishes no such object, so it is emitted here + * — otherwise every call site that named a member would have to restate the + * literal, which is the drift that rule exists to prevent. + * + * @param options - The contract and the namespace to emit. + * @returns The file content. + */ +export function emitEnums({ + contract, + namespace, + enums: names = {}, +}: Omit): string { + const { models: modelMap, enums } = namespaceOf(contract, namespace); + const models = Object.keys(modelMap); + + // `ModelName` is what a call site names a model by. Prisma 7 published it + // on the client; without it every such site would write the model as a + // bare literal, which is the drift the no-bare-strings rule prevents. + const modelName = + `export const ModelName = {\n${models + .map(name => ` ${name}: '${name}',`) + .join('\n')}\n} as const;\n\n` + + 'export type ModelName = (typeof ModelName)[keyof typeof ModelName];\n'; + + return [ + modelName, + ...Object.entries(enums).map(([name, definition]) => { + const named = Object.entries(names[name] ?? {}); + const nameOf = (label: string): string => + named.find(([, value]) => value === label)?.[0] ?? label; + const members = (definition.members ?? []) + .map( + member => + ` ${nameOf(member.value ?? '')}: '${member.value}',`, + ) + .join('\n'); + + return ( + `export const ${name} = {\n${members}\n} as const;\n\n` + + `export type ${name} = (typeof ${name})[keyof typeof ${name}];\n` + ); + }), + ].join('\n'); +} diff --git a/src/emit/rpc.ts b/src/emit/rpc.ts new file mode 100644 index 0000000..6af388f --- /dev/null +++ b/src/emit/rpc.ts @@ -0,0 +1,487 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import { type ImportMap, emitImports } from './imports.js'; +import { + type EmitContract, + type EmitModelsOptions, + type Model, + hasDatabaseDefault, + namespaceOf, + quoted, + typeOf, +} from './models.js'; +import { type DeriveFields, deriveDataLayer } from '../derive.js'; + +/** Zod suffixes per model per field, as the schema's `@validate` declared. */ +export type ValidationRules = Record>; + +/** Everything {@link emitRpcTypes} needs. */ +export interface EmitRpcOptions extends Omit { + /** + * Validation rules per model per field, as Zod suffixes. + * + * @remarks + * Prisma Next's contract does not carry them — the `/// @validate` doc + * comment the Prisma 7 generator read has no equivalent — so the rules are + * declared by the service and passed in. Omitted, the inputs are emitted + * with no `@validate` at all, which validates nothing: an argument class + * carrying `@validatable()` and no rules passes everything. + */ + validation?: ValidationRules; + /** Where the generated file imports its runtime from. */ + imports?: ImportMap; + /** + * Stamp field names, where a service overrides the defaults. + * + * @remarks + * The same value `dataLayer` is given. A stamped column is filled in below + * the caller, so demanding it on a create would ask for something the + * caller cannot know. + */ + fields?: DeriveFields; +} + +const SHARED = `@classType() +export class PageOptions { + @property('number', true) + skip?: number; + + @property('number', true) + take?: number; + + @property('boolean', true) + withTotal?: boolean; +} + +@classType() +export class BulkCount { + @property('number') + count!: number; +} + +@classType() +export class CountOrderBy { + @property("'asc' | 'desc'", true) + _count?: 'asc' | 'desc'; +} + +@classType() +export class ValueWhere { + @property('unknown', true) + eq?: unknown; + + @property('unknown', true) + not?: unknown; + + @property('Array', true) + in?: unknown[]; + + @property('Array', true) + notIn?: unknown[]; + + @property('unknown', true) + lt?: unknown; + + @property('unknown', true) + lte?: unknown; + + @property('unknown', true) + gt?: unknown; + + @property('unknown', true) + gte?: unknown; + + @property('string', true) + contains?: string; + + @property('string', true) + startsWith?: string; + + @property('string', true) + endsWith?: string; +} +`; + +/** `@property(...)` plus the field line, at one indent. */ +/** + * The Zod type a rule is appended to. + * + * @remarks + * A rule says what is allowed about a value, not what the value is — the + * field's own type says that. `.int().min(1)` on `z.string()` is not a + * narrower string, it is a type error, and it is what a `String` base gives + * every validated number in the contract. + * + * @param ts - The field's TypeScript type. + * @returns The Zod constructor, as source. + */ +function zodBase(ts: string | undefined): string { + if (ts?.startsWith('number')) { + return 'z.number()'; + } + + if (ts?.startsWith('boolean')) { + return 'z.boolean()'; + } + + return 'z.string()'; +} + +function member( + wire: string, + name: string, + ts: string, + optional = true, +): string { + return ( + ` @property(${quoted(wire)}${optional ? ', true' : ''})\n` + + ` ${name}${optional ? '?' : '!'}: ${ts};\n` + ); +} + +function whereClass( + name: string, + model: Model, + fields: Record, +): string { + const logical = ['AND', 'OR', 'NOT'] + .map(key => + member( + `${name}Where | Array<${name}Where>`, + key, + `${name}Where | ${name}Where[]`, + ), + ) + .join('\n'); + const scalars = Object.keys(model.fields ?? {}) + .map(field => + member( + `${fields[field]?.wire} | ValueWhere`, + field, + `${fields[field]?.ts} | ValueWhere`, + ), + ) + .join('\n'); + const relations = Object.entries(model.relations ?? {}) + .map(([field, relation]) => + member( + `${relation.to?.model}Where`, + field, + `${relation.to?.model}Where`, + ), + ) + .join('\n'); + + return `@classType()\nexport class ${name}Where {\n${[ + logical, + scalars, + relations, + ] + .filter(Boolean) + .join('\n')}}\n`; +} + +function selectClass(name: string, model: Model): string { + const scalars = Object.keys(model.fields ?? {}) + .map(field => member('boolean', field, 'boolean')) + .join('\n'); + const relations = Object.entries(model.relations ?? {}) + .map(([field, relation]) => + member( + `boolean | ${relation.to?.model}Select`, + field, + `boolean | ${relation.to?.model}Select`, + ), + ) + .join('\n'); + + return `@classType()\nexport class ${name}Select {\n${[scalars, relations] + .filter(Boolean) + .join('\n')}}\n`; +} + +function orderByClass(name: string, model: Model): string { + const scalars = Object.keys(model.fields ?? {}) + .map(field => member("'asc' | 'desc'", field, "'asc' | 'desc'")) + .join('\n'); + const relations = Object.entries(model.relations ?? {}) + .map(([field, relation]) => + relation.cardinality?.endsWith(':N') + ? member('CountOrderBy', field, 'CountOrderBy') + : member( + `${relation.to?.model}OrderBy`, + field, + `${relation.to?.model}OrderBy`, + ), + ) + .join('\n'); + + return `@classType()\nexport class ${name}OrderBy {\n${[scalars, relations] + .filter(Boolean) + .join('\n')}}\n`; +} + +/** + * The rows a create may carry against a to-many relation. + * + * @remarks + * Emitted once per child model rather than per relation, because the shape + * depends only on what is being inserted. `create` is the only form: Prisma + * Next has no nested writes at all, so the repository performs these as + * separate inserts in one transaction, and connecting an existing row is a + * plain foreign-key update instead. + */ +function nestedClass(child: string): string { + return ( + `@classType()\nexport class ${child}CreateNestedMany {\n` + + member( + `Array<${child}CreateInput>`, + 'create', + `${child}CreateInput[]`, + ) + + '}\n' + ); +} + +function inputClass( + name: string, + kind: 'Create' | 'Update', + model: Model, + fields: Record, + rules: Record, + supplied: (model: string, field: string) => boolean, +): string { + const body = Object.entries(model.fields ?? {}) + .map(([field, definition]) => { + // An update names the row it changes, so `id` is the one required + // field there and optional on a create, where the database makes it. + const required = + kind === 'Update' + ? field === 'id' + : !definition.nullable && + field !== 'id' && + !supplied(name, field); + const rule = rules[field]; + const zod = rule + ? ` @validate(${zodBase(fields[field]?.ts)}${rule}` + + `${required ? '' : '.optional()'})\n` + : ''; + + return ( + zod + + member( + fields[field]?.wire ?? 'unknown', + field, + fields[field]?.ts ?? 'unknown', + !required, + ) + ); + }) + .join('\n'); + + const nested = + kind === 'Create' + ? Object.entries(model.relations ?? {}) + .filter(([, relation]) => + relation.cardinality?.endsWith(':N'), + ) + .map(([field, relation]) => + member( + `${relation.to?.model}CreateNestedMany`, + field, + `${relation.to?.model}CreateNestedMany`, + ), + ) + .join('\n') + : ''; + + return ( + `@classType()\n@validatable()\nexport class ${name}${kind}Input {\n` + + `${[body, nested].filter(Boolean).join('\n')}}\n` + ); +} + +function argClasses(name: string): string { + return [ + `@classType()\n@validatable()\nexport class ${name}CreateArgs {\n` + + ` @validate(${name}CreateInput)\n` + + member(`${name}CreateInput`, 'input', `${name}CreateInput`, false) + + '\n' + + member(`${name}Select`, 'select', `${name}Select`) + + '}\n', + `@classType()\n@validatable()\nexport class ${name}UpdateArgs {\n` + + ` @validate(${name}UpdateInput)\n` + + member(`${name}UpdateInput`, 'input', `${name}UpdateInput`, false) + + '\n' + + member(`${name}Select`, 'select', `${name}Select`) + + '}\n', + `@classType()\nexport class ${name}SingleArgs {\n` + + member(`${name}Where`, 'where', `${name}Where`, false) + + '\n' + + member(`${name}Select`, 'select', `${name}Select`) + + '}\n', + `@classType()\nexport class ${name}ListArgs {\n` + + member(`${name}Where`, 'where', `${name}Where`) + + '\n' + + member(`${name}Select`, 'select', `${name}Select`) + + '\n' + + member(`${name}OrderBy`, 'orderBy', `${name}OrderBy`) + + '\n' + + member('PageOptions', 'options', 'PageOptions') + + '}\n', + `@classType()\nexport class ${name}RemoveBulkArgs {\n` + + member(`${name}Where`, 'where', `${name}Where`, false) + + '}\n', + `@classType()\nexport class ${name}Page {\n` + + member(`Array<${name}>`, 'items', `${name}[]`, false) + + '\n' + + member('number', 'total', 'number | null') + + '}\n', + ].join('\n'); +} + +/** + * Emit the RPC query, input and argument classes for a contract. + * + * @remarks + * These are the shapes a caller sends over the queue, and they have to be + * decorated classes rather than types: `@classType`/`@property` are what the + * client generator reads, and an undecorated type is dropped from the client + * with no error at generation time. + * + * Validation is the one thing not derivable from the contract — see + * `validation`. + * + * @param options - The contract, the validation rules and any redirection. + * @returns The file content. + */ +export function emitRpcTypes({ + contract, + namespace, + validation = {}, + imports = {}, + fields: stampFields = {}, + omit = [], +}: EmitRpcOptions): string { + const { models, enums, columnsOf } = namespaceOf( + contract as EmitContract, + namespace, + omit, + ); + const defaulted = hasDatabaseDefault(models, columnsOf); + const { stamps } = deriveDataLayer({ contract, fields: stampFields }); + + // A foreign key may arrive with the row or come from the parent of a + // nested create, so it is optional either way — which is what lets one + // input type serve both, as it did under Prisma 7. + const foreignKeys = (model: string): Set => + new Set( + Object.values(models[model]?.relations ?? {}) + .filter(relation => relation.cardinality?.endsWith(':1')) + .flatMap(relation => [...(relation.on?.localFields ?? [])]), + ); + + // A create need not carry what something below it writes: a column the + // database defaults, or one the stamp middleware fills in. + const supplied = (model: string, field: string): boolean => { + const storage = models[model]?.storage; + const column = storage?.fields?.[field]?.column ?? field; + const stamped = stamps[storage?.table ?? model] ?? {}; + + return ( + defaulted(model, field) || + foreignKeys(model).has(field) || + column === stamped.updatedAt || + column === stamped.updatedBy || + column === stamped.createdBy + ); + }; + + const body = Object.entries(models).map(([name, model]) => { + const columns = columnsOf[model.storage?.table ?? name]?.columns ?? {}; + const fields = Object.fromEntries( + Object.entries(model.fields ?? {}).map(([field, definition]) => [ + field, + typeOf(definition, enums, columns[field]?.valueSet?.entityName), + ]), + ); + + return [ + whereClass(name, model, fields), + selectClass(name, model), + orderByClass(name, model), + inputClass( + name, + 'Create', + model, + fields, + validation[name] ?? {}, + supplied, + ), + inputClass( + name, + 'Update', + model, + fields, + validation[name] ?? {}, + supplied, + ), + argClasses(name), + ].join('\n'); + }); + + const nestedClasses = [ + ...new Set( + Object.values(models).flatMap(model => + Object.values(model.relations ?? {}) + .filter(relation => relation.cardinality?.endsWith(':N')) + .map(relation => relation.to?.model as string), + ), + ), + ] + .sort() + .map(nestedClass); + + const repositories = + "/** Every model's repository, so an access is total rather than " + + 'possibly undefined. */\nexport interface Repositories {\n' + + Object.keys(models) + .map( + name => + ` ${name.charAt(0).toLowerCase()}${name.slice(1)}: ` + + `Repository<${name}>;`, + ) + .join('\n') + + '\n}\n'; + + return ( + `${emitImports(['rpc', 'validation', 'zod', 'repository'], imports)}` + + `import type {\n${Object.keys(models) + .map(name => ` ${name},`) + .join('\n')}\n} from './models.js';\n\n` + + `${SHARED}\n${repositories}\n${nestedClasses.join('\n')}\n` + + `${body.join('\n')}` + ); +} diff --git a/src/index.ts b/src/index.ts index da0e0e2..2063c38 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,8 +1,8 @@ /*! - * @imqueue/pg-prisma — public API + * Prisma Next (8.x) query middlewares for @imqueue services * * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com + * Copyright (C) 2026 imqueue.com * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by @@ -25,10 +25,21 @@ export * from './access-scope.js'; export * from './archive.js'; export * from './audit.js'; -export * from './authorship.js'; export * from './change-notify.js'; +export * from './data-layer.js'; +export * from './derive.js'; +export * from './emit/imports.js'; +export * from './emit/all.js'; +export * from './emit/models.js'; +export * from './emit/rpc.js'; export * from './iso-dates.js'; -export * from './migrate-down.js'; +export * from './pool.js'; export * from './pretty-sql.js'; -export * from './soft-delete.js'; +export * from './query-log.js'; +export * from './query.js'; +export * from './repository.js'; +export * from './sql-client.js'; export * from './sql-log.js'; +export * from './sql-runner.js'; +export * from './sql-template.js'; +export * from './stamp.js'; diff --git a/src/iso-dates.ts b/src/iso-dates.ts index d9f1c7d..ec88149 100644 --- a/src/iso-dates.ts +++ b/src/iso-dates.ts @@ -1,5 +1,5 @@ /*! - * Prisma result extension: Date → ISO-8601 strings + * @imqueue/pg-prisma — database timestamps as ISO 8601 instants * * I'm Queue Software Project * Copyright (C) 2025 imqueue.com @@ -22,81 +22,134 @@ * to get commercial licensing options. */ -import { Prisma } from '@prisma/client/extension'; +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; /** - * Recursively replace every `Date` with its ISO-8601 string, structure intact. + * Codecs whose values arrive as Postgres' own timestamp text. * - * Exported so it can be tested without a database, like `accessWhere`: what it - * does to a shape is the whole of this extension, and the interesting cases — - * a buffer, a nested date, an array of rows — are all reachable from here. + * @remarks + * Both are pass-through — `decode` hands back the wire string untouched — so + * what a column yields is whatever `SELECT col::text` would print. */ -export function toIsoDates(value: unknown): unknown { - if (value instanceof Date) { - return value.toISOString(); - } - if (Array.isArray(value)) { - return value.map(toIsoDates); - } - /* - * Binary is returned as it came, and this is not an optimisation. - * - * The branch below rebuilds any object by walking `Object.entries`, and a - * `Buffer` walked that way becomes `{ "0": 137, "1": 80, … }` — a plain - * object with one key per byte, which is no longer a buffer, is roughly - * fifty times the size, and fails every `Buffer.isBuffer` check downstream. - * A `Bytes` column read through this extension arrived unusable and the - * failure looked like the row not existing. - * - * `ArrayBuffer.isView` covers `Buffer`, every typed array and `DataView`; - * the buffer itself is checked beside it. None of them can contain a - * `Date`, so there is nothing here to walk for. - */ - if (ArrayBuffer.isView(value) || value instanceof ArrayBuffer) { - return value; - } - if (value !== null && typeof value === 'object') { - const out: Record = {}; - for (const [key, nested] of Object.entries(value)) { - out[key] = toIsoDates(nested); - } +export const TIMESTAMP_CODECS: ReadonlySet = new Set([ + 'pg/timestamptz-string@1', + 'pg/timestamp-string@1', +]); - return out; - } +/** + * `2026-08-14 09:30:00.123+00`, and every shape Postgres prints around it: + * a space for the `T`, the fraction trimmed of trailing zeros or absent + * altogether, and an offset only when the column carries a zone. + */ +const PG_TIMESTAMP = + /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(\.\d+)?([+-]\d{2}(:?\d{2})?)?$/; - return value; +/** The zone the value ends in, if it states one at all. */ +const OFFSET = /([+-])(\d{2}):?(\d{2})?$/; + +/** Everything {@link isoDates} needs. */ +export interface IsoDateOptions { + /** Columns stored under one of {@link TIMESTAMP_CODECS}. */ + columns: ReadonlySet; } /** - * Build the query extension that serializes every `Date` in a query result to an - * ISO-8601 string. + * Read database timestamps back as canonical ISO 8601 instants. * * @remarks - * The generated `@imqueue/rpc` models type Prisma's `DateTime` as `string` (the - * codegen `scalars` config decides this), so a service that returned Prisma's own - * `Date` objects would be handing callers a shape its own types disagree with. - * This extension closes that gap at the boundary rather than at every call site. + * Postgres prints a timestamp as `2026-08-14 09:30:00.123+00` — a space where + * ISO 8601 puts a `T`, `+00` where it puts `Z`, and a fraction stripped of its + * trailing zeros, so `09:30:00.500` comes back as `09:30:00.5` and a whole + * second as no fraction at all. Nothing downstream accepts that: the GraphQL + * `DateTime` scalar rejects it outright, and so does `z.iso.datetime()`. This + * restores the one spelling every boundary agrees on, which is what Prisma 7's + * `scalars = "DateTime:string"` used to produce. + * + * A column that carries no zone is read as UTC rather than as a wall clock. + * Left to `new Date`, such a value is parsed as **local** time, so on a host + * that is not UTC every instant read from the database is silently shifted and + * an expiry compares against the wrong moment — which is why the columns are + * `timestamptz` and this is only the fallback for one that is not. + * + * Sub-millisecond precision does not survive, because a JavaScript instant has + * none to survive into. * - * Conversion walks the whole result recursively — arrays, nested objects and - * relations included — and leaves structure and every non-`Date` value untouched. - * It applies to results only: `Date` values you pass IN as query arguments are - * still handed to Prisma as `Date`. + * Writes need nothing: Postgres parses an ISO 8601 string on its own, so a + * value handed back unchanged can be sent straight back. * - * @returns A Prisma extension to pass to `client.$extends()`. + * Both the column name and the value's shape have to match, so a text column + * that happens to hold something timestamp-like is left alone. + * + * @param options - The columns to convert. + * @returns Middleware converting those columns on every row read. * @example * ```typescript - * const client = new PrismaClient().$extends(isoDates()); + * const middleware = [isoDates({ columns: derived.dates })]; * ``` */ -export function isoDates() { - return Prisma.defineExtension({ +export function isoDates({ columns }: IsoDateOptions): SqlMiddleware { + // Postgres writes the offset as `+00`, which the ISO parser does not + // accept — only the legacy one does, and what that accepts is not + // specified. So the value is spelled out in full before it is parsed. + const iso = (value: string): string => { + const offset = OFFSET.exec(value); + + if (!offset) { + return `${value.replace(' ', 'T')}Z`; + } + + return ( + value.slice(0, offset.index).replace(' ', 'T') + + `${offset[1]}${offset[2]}:${offset[3] ?? '00'}` + ); + }; + + const convert = (value: unknown): unknown => { + if (typeof value !== 'string' || !PG_TIMESTAMP.test(value)) { + return value; + } + + const parsed = new Date(iso(value)); + + // A shape this matches but a calendar rejects — `2026-02-30`, say. + // Handing back the original loses nothing; throwing would fail the + // whole read over one column. + return Number.isNaN(parsed.getTime()) ? value : parsed.toISOString(); + }; + + // Included relations arrive nested, so the walk goes all the way down + // rather than over the top-level columns only. + const walk = (node: unknown): void => { + if (Array.isArray(node)) { + node.forEach(walk); + + return; + } + + if (!node || typeof node !== 'object') { + return; + } + + for (const [key, value] of Object.entries(node)) { + if (value && typeof value === 'object') { + walk(value); + + continue; + } + + if (columns.has(key)) { + (node as Record)[key] = convert(value); + } + } + }; + + return { name: 'iso-dates', - query: { - $allModels: { - async $allOperations({ args, query }) { - return toIsoDates(await query(args)); - }, - }, + familyId: 'sql' as const, + onRow(row: Record): Promise { + walk(row); + + return Promise.resolve(); }, - }); + }; } diff --git a/src/migrate-down.ts b/src/migrate-down.ts deleted file mode 100644 index 60db1c1..0000000 --- a/src/migrate-down.ts +++ /dev/null @@ -1,446 +0,0 @@ -/*! - * Undo applied Prisma migrations (down migrations) - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ - -import { execFileSync } from 'node:child_process'; -import { - cpSync, - existsSync, - mkdtempSync, - readdirSync, - readFileSync, - rmSync, - writeFileSync, -} from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { pathToFileURL } from 'node:url'; -import pg from 'pg'; - -const LOCK = 'migration_lock.toml'; - -/** Everything {@link migrateDown} needs: no globals and no environment are read. */ -export interface MigrateDownOptions { - /** Postgres connection string for the database to roll back. */ - databaseUrl: string; - /** Absolute path to the Prisma migrations directory. */ - migrationsDir: string; - /** Working directory for `prisma migrate diff` (holds prisma.config.ts). */ - projectRoot: string; - /** Number of most-recent applied migrations to roll back (default 1). */ - steps?: number; - /** Only (re)generate missing `down.sql` files; don't apply anything. */ - generateOnly?: boolean; - /** Delete the migration folder after a successful rollback. */ - deleteFolder?: boolean; - /** Report the actions without changing anything. */ - dryRun?: boolean; - /** Progress reporter (defaults to a no-op — the core stays silent). */ - log?: (message: string) => void; -} - -/** What {@link migrateDown} did. */ -export interface MigrateDownResult { - /** Migration names processed, newest first. */ - processed: string[]; -} - -/** Resolved, non-optional config shared by the internal helpers. */ -interface Context { - databaseUrl: string; - migrationsDir: string; - projectRoot: string; - log: (message: string) => void; -} - -/** Migration folder names (timestamp-prefixed), oldest → newest. */ -function migrationDirs(migrationsDir: string): string[] { - return readdirSync(migrationsDir, { withFileTypes: true }) - .filter(entry => entry.isDirectory() && /^\d+_/.test(entry.name)) - .map(entry => entry.name) - .sort(); -} - -/** Applied, not-rolled-back migrations from `_prisma_migrations`, newest first. */ -async function appliedMigrations(client: pg.Client): Promise { - const { rows } = await client.query<{ migration_name: string }>( - `SELECT migration_name FROM "_prisma_migrations" - WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL - ORDER BY started_at DESC`, - ); - - return rows.map(row => row.migration_name); -} - -/** Copy the given migrations (+ lock file) into a fresh temp dir; return its path. */ -function tempMigrations(migrationsDir: string, names: string[]): string { - const dir = mkdtempSync(join(tmpdir(), 'ze-migrate-down-')); - cpSync(join(migrationsDir, LOCK), join(dir, LOCK)); - for (const name of names) { - cpSync(join(migrationsDir, name), join(dir, name), { recursive: true }); - } - - return dir; -} - -/** A throwaway shadow-database URL derived from `base` (same server, new db). */ -function shadowDatabaseUrl(base: string): { url: string; name: string } { - const url = new URL(base); - const name = `ze_migrate_down_shadow_${Date.now()}`; - url.pathname = `/${name}`; - - return { url: url.toString(), name }; -} - -async function withShadowDatabase( - databaseUrl: string, - fn: (shadowUrl: string) => Promise, -): Promise { - const admin = new URL(databaseUrl); - admin.pathname = '/postgres'; - const { url, name } = shadowDatabaseUrl(databaseUrl); - - const client = new pg.Client({ connectionString: admin.toString() }); - await client.connect(); - await client.query(`DROP DATABASE IF EXISTS "${name}"`); - await client.query(`CREATE DATABASE "${name}"`); - try { - return await fn(url); - } finally { - await client.query(`DROP DATABASE IF EXISTS "${name}" WITH (FORCE)`); - await client.end(); - } -} - -/** - * Generate the down SQL for `target`: the diff that takes the schema from the - * state INCLUDING `target` back to the state WITHOUT it. Uses a shadow database - * because migration-set diffs replay migrations. - */ -async function generateDown(ctx: Context, target: string): Promise { - const all = migrationDirs(ctx.migrationsDir); - const withoutTarget = all.filter(name => name < target); - const fromDir = tempMigrations( - ctx.migrationsDir, - all.filter(name => name <= target), - ); - const toDir = withoutTarget.length - ? tempMigrations(ctx.migrationsDir, withoutTarget) - : null; - // "to" is the state without the target: an earlier migration set, or empty - // if the target is the first migration. - const toArgs = toDir ? ['--to-migrations', toDir] : ['--to-empty']; - - try { - return await withShadowDatabase(ctx.databaseUrl, shadowUrl => - Promise.resolve( - execFileSync( - 'npx', - [ - 'prisma', - 'migrate', - 'diff', - '--from-migrations', - fromDir, - ...toArgs, - '--script', - ], - { - cwd: ctx.projectRoot, - encoding: 'utf8', - // Read by prisma.config.ts as datasource.shadowDatabaseUrl. - env: { ...process.env, SHADOW_DATABASE_URL: shadowUrl }, - }, - ), - ), - ); - } finally { - rmSync(fromDir, { recursive: true, force: true }); - if (toDir) { - rmSync(toDir, { recursive: true, force: true }); - } - } -} - -/** Read a folder's `down.sql`, generating and persisting it first if missing. */ -async function resolveDown(ctx: Context, target: string): Promise { - const downPath = join(ctx.migrationsDir, target, 'down.sql'); - if (existsSync(downPath)) { - return readFileSync(downPath, 'utf8'); - } - - ctx.log(` generating ${target}/down.sql …`); - const sql = await generateDown(ctx, target); - writeFileSync(downPath, sql); - ctx.log(` wrote ${downPath} — review it before trusting the rollback`); - - return sql; -} - -interface RollbackFlags { - generateOnly: boolean; - deleteFolder: boolean; - dryRun: boolean; -} - -async function rollbackOne( - ctx: Context, - client: pg.Client, - target: string, - flags: RollbackFlags, -): Promise { - const downSql = await resolveDown(ctx, target); - if (flags.generateOnly) { - return; - } - if (flags.dryRun) { - ctx.log( - ` [dry-run] would apply down.sql and drop record for ${target}`, - ); - - return; - } - - await client.query('BEGIN'); - try { - await client.query(downSql); - await client.query( - `DELETE FROM "_prisma_migrations" WHERE migration_name = $1`, - [target], - ); - await client.query('COMMIT'); - } catch (err) { - await client.query('ROLLBACK'); - throw err; - } - - if (flags.deleteFolder) { - rmSync(join(ctx.migrationsDir, target), { - recursive: true, - force: true, - }); - } - ctx.log(` rolled back ${target}`); -} - -/** - * Roll back the most recently applied Prisma migrations. - * - * @remarks - * Prisma has no native "down", so this reconstructs one. Each migration folder may - * hold a hand-written `down.sql`, which is authoritative and used as-is. When there - * is none, one is generated by asking `prisma migrate diff` for the difference - * between the migration set including the target and the set without it, using a - * throwaway shadow database — because a migration-set diff has to replay the - * migrations somewhere. The generated file is written into the migration folder, - * so it can be reviewed and corrected before it is ever used again. - * - * Rolling one migration back means running its `down.sql` and deleting its - * `_prisma_migrations` row in a single transaction, then optionally removing the - * folder. Migrations are processed newest first, since each down script assumes the - * later ones are already undone. - * - * Generated SQL deserves a sceptical eye: a diff can express a dropped column but - * not the data that was in it, so a generated down script recreates structure and - * cannot restore content. Review the file rather than trusting it, and prefer - * `generateOnly` on a first pass. - * - * Every input is a parameter — the database URL, the paths, the reporter — and no - * global or environment variable is read, so this is usable as a library function. - * It does shell out to `npx prisma migrate diff`, and it creates and drops a - * database on the same server as `databaseUrl` for the shadow. - * - * @param options - Connection string, paths, how many steps, and the mode flags. - * @returns The migration names processed, newest first. - * @throws Error propagated from a failing `down.sql`, after the transaction has - * been rolled back — so a failed step leaves the migration applied rather than - * half-undone. - * @example - * ```typescript - * const { processed } = await migrateDown({ - * databaseUrl: process.env.DATABASE_URL!, - * migrationsDir: join(root, 'prisma', 'migrations'), - * projectRoot: root, - * steps: 1, - * log: console.log, - * }); - * ``` - */ -export async function migrateDown( - options: MigrateDownOptions, -): Promise { - const ctx: Context = { - databaseUrl: options.databaseUrl, - migrationsDir: options.migrationsDir, - projectRoot: options.projectRoot, - log: options.log ?? (() => {}), - }; - const flags: RollbackFlags = { - generateOnly: options.generateOnly ?? false, - deleteFolder: options.deleteFolder ?? false, - dryRun: options.dryRun ?? false, - }; - const steps = options.steps ?? 1; - if (!Number.isInteger(steps) || steps < 1) { - throw new Error('steps must be a positive integer'); - } - - const client = new pg.Client({ connectionString: ctx.databaseUrl }); - await client.connect(); - try { - const applied = await appliedMigrations(client); - const targets = applied.slice(0, steps); - if (targets.length === 0) { - ctx.log('No applied migrations to roll back.'); - - return { processed: [] }; - } - - ctx.log( - `${flags.generateOnly ? 'Generating down.sql for' : 'Rolling back'} ${targets.length} migration(s):`, - ); - // Newest first — each down assumes later migrations are already undone. - for (const target of targets) { - ctx.log(`- ${target}`); - await rollbackOne(ctx, client, target, flags); - } - - return { processed: targets }; - } finally { - await client.end(); - } -} - -/* eslint-disable no-console -- the CLI shim below reports progress to stdout */ - -// This file doubles as a CLI, and it is the only part that reads process.argv or -// writes to the console: -// -// node --import tsx prisma/migrate-down.ts --database-url [options] -// --database-url Postgres connection string (required) -// --steps N roll back the N most recent applied migrations (default 1) -// --generate-only only (re)generate missing down.sql files; don't apply -// --delete-folder also delete the migration folder after rollback -// --dry-run show what would happen; make no changes - -interface CliArgs extends RollbackFlags { - databaseUrl: string; - steps: number; -} - -const VALUE_FLAGS = new Set(['--steps', '--database-url']); - -/** Fold the space-separated `--flag value` form into `--flag=value` so each - * token can be parsed on its own. */ -function normalizeArgs(argv: string[]): string[] { - return argv.reduce((tokens, token) => { - const prev = tokens[tokens.length - 1]; - if (prev !== undefined && VALUE_FLAGS.has(prev)) { - tokens[tokens.length - 1] = `${prev}=${token}`; - - return tokens; - } - tokens.push(token); - - return tokens; - }, []); -} - -function parseArgs(argv: string[]): CliArgs { - const args: CliArgs = { - databaseUrl: '', - steps: 1, - generateOnly: false, - deleteFolder: false, - dryRun: false, - }; - for (const token of normalizeArgs(argv)) { - if (token === '--generate-only') { - args.generateOnly = true; - continue; - } - if (token === '--delete-folder') { - args.deleteFolder = true; - continue; - } - if (token === '--dry-run') { - args.dryRun = true; - continue; - } - if (token.startsWith('--steps=')) { - args.steps = Number(token.slice('--steps='.length)); - continue; - } - if (token.startsWith('--database-url=')) { - args.databaseUrl = token.slice('--database-url='.length); - continue; - } - throw new Error(`Unknown argument: ${token}`); - } - if (!args.databaseUrl) { - throw new Error('--database-url is required'); - } - - return args; -} - -async function cli(): Promise { - const { databaseUrl, steps, generateOnly, deleteFolder, dryRun } = - parseArgs(process.argv.slice(2)); - - await migrateDown({ - databaseUrl, - steps, - generateOnly, - deleteFolder, - dryRun, - migrationsDir: join( - import.meta.dirname, - '..', - '..', - 'prisma', - 'migrations', - ), - projectRoot: join(import.meta.dirname, '..', '..'), - log: message => console.log(message), - }); -} - -// Run the CLI only when this file is executed directly, so importing the module -// (as a library, or from a test) has no side effects. -// -// Deliberately NOT `await cli()`: a top-level await makes this an async module, -// and an async module anywhere in the graph makes the package barrel async too — -// which means `require('@imqueue/pg-prisma')` fails outright with -// ERR_REQUIRE_ASYNC_MODULE on Node >= 22, where requiring ESM otherwise works. -// Handling the rejection here keeps the same CLI contract (message on stderr, -// exit code 1) without imposing that on every consumer of the library. -if ( - process.argv[1] && - import.meta.url === pathToFileURL(process.argv[1]).href -) { - cli().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; - }); -} diff --git a/src/pool.ts b/src/pool.ts new file mode 100644 index 0000000..0eb6d01 --- /dev/null +++ b/src/pool.ts @@ -0,0 +1,165 @@ +/*! + * @imqueue/pg-prisma — a pool that can read an array of enums + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import pg from 'pg'; +import type { Pool, PoolConfig, PoolClient } from 'pg'; + +/** `text[]`, whose wire format an array of enums shares exactly. */ +const TEXT_ARRAY = 1009; + +/** + * `json`, `jsonb`, and their array forms. + * + * @remarks + * The ORM's codec parses what it is handed — `wire => typeof wire === 'string' + * ? JSON.parse(wire) : wire` — but `node-postgres` has parsed it already, so + * the two disagree about whose job it is. An object survives, because the + * codec passes a non-string through. A JSON **string** does not: the driver + * yields `Payment`, the codec parses it again, and `JSON.parse('Payment')` + * throws. Worse, a column holding `"5"` comes back as the number `5` — the + * second parse succeeds and quietly changes the type. + * + * So the driver is told to leave these alone and let the codec do the one + * parse it means to do. + */ +const JSON_TYPES = [114, 3802, 199, 3807]; + +/** Every enum type's array companion, as this database numbers them. */ +const ENUM_ARRAY_OIDS = + "SELECT typarray FROM pg_type WHERE typtype = 'e' AND typarray <> 0"; + +/** What `pg-types` offers, of which only these two are wanted. */ +export interface TypeParsers { + getTypeParser: (oid: number, format?: string) => unknown; + setTypeParser: (oid: number, parser: unknown) => void; +} + +/** + * A connection pool whose arrays of enums and JSON columns can be read. + * + * @remarks + * `node-postgres` parses a value by its type's oid, and it knows only the + * built-in ones. An enum is numbered when it is created, so its array type is + * numbered too, and neither number can be known ahead of time — the driver + * therefore hands back the literal text `{EMAIL,SMS}` where the ORM requires + * an array, and every read of the column fails with `RUNTIME.DECODE_FAILED`. + * A scalar enum is unaffected, because its text *is* its value. + * + * So the oids are asked for, once, and those columns are parsed the way a + * `text[]` is — which is what an array of enums is on the wire. The JSON + * types are corrected at the same time, for the reason on {@link JSON_TYPES}. + * + * The lookup is deferred to the first connection rather than done here, + * because a pool is built where a client is built and that is not a place + * where anything can be awaited. It runs once; a query that arrives while it + * is in flight waits for it rather than starting a second one. + * + * The registry is the one the *runtime* reads, not the one a pool carries: + * the ORM passes its own `types` to every query, and that object falls + * through to `pg-types` for anything it does not handle itself. A parser set + * on the pool is therefore never consulted. + * + * Three things follow from the registry being global. A raw `pg` query in the + * same process reads a JSON column as text and has to parse it itself, which + * is the trade for the ORM reading it correctly. An enum type created + * *after* the first connection is not picked up — which is a migration + * applied to a running process, and migrations run at start, before anything + * connects. And a process holding pools onto two different databases would + * have them share one numbering, which no service here does: a service owns + * one database. + * + * @param config - Pool configuration, as `pg` takes it. + * @param parsers - The registry to register in. Defaults to this package's + * own, which is right whenever there is one copy of `pg` to have; a consumer + * that resolves the ORM through a different copy has to say so, because a + * parser added to the wrong registry is never read. + * @returns The pool. + * @example + * ```typescript + * const db = postgres({ + * contractJson, + * pg: dataPool({ connectionString }), + * }); + * ``` + */ +export function dataPool( + config: PoolConfig, + parsers: TypeParsers = pg.types as unknown as TypeParsers, +): Pool { + const pool = new pg.Pool(config); + + // Bound before the override, so neither the lookup below nor the callback + // form calls itself. + const connect = pool.connect.bind(pool) as { + (): Promise; + (callback: (error: unknown, client?: unknown) => void): void; + }; + const once: { lookup?: Promise } = {}; + + const lookup = async (): Promise => { + const asTextArray = parsers.getTypeParser(TEXT_ARRAY); + const asIs = (value: string): string => value; + const client = await connect(); + + for (const oid of JSON_TYPES) { + parsers.setTypeParser( + oid, + oid === 199 || oid === 3807 ? asTextArray : asIs, + ); + } + + try { + const { rows } = await client.query<{ typarray: string }>( + ENUM_ARRAY_OIDS, + ); + + for (const row of rows) { + parsers.setTypeParser(Number(row.typarray), asTextArray); + } + } finally { + client.release(); + } + }; + + // `Pool.query` calls `connect` with a callback, so both forms are served. + pool.connect = ((callback?: unknown) => { + once.lookup ??= lookup(); + + if (typeof callback !== 'function') { + return once.lookup.then(() => connect()); + } + + const done = callback as (error: unknown, client?: unknown) => void; + + once.lookup + .then(() => connect(done)) + .catch((error: unknown) => { + done(error); + }); + + return undefined; + }) as typeof pool.connect; + + return pool; +} diff --git a/src/query-log.ts b/src/query-log.ts new file mode 100644 index 0000000..eebfb56 --- /dev/null +++ b/src/query-log.ts @@ -0,0 +1,91 @@ +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import { prettifySql } from './pretty-sql.js'; +import { isSqlLogSuppressed } from './sql-log.js'; + +/** Where a query log line goes. */ +export interface QueryLogger { + /** Write one line. */ + log(message: string): void; +} + +/** Everything {@link queryLog} needs. */ +export interface QueryLogOptions { + /** Whether to log at all. */ + log: boolean; + /** + * Whether to log bound parameters as well. + * + * @remarks + * Off by default and worth keeping that way outside local debugging: the + * statement text and its timing are logged either way, while the bound + * parameters carry whatever the caller passed — stored values, user ids — + * and a log file is the least protected place that data can land. + */ + logParams?: boolean; + /** Where the line goes. */ + logger: QueryLogger; +} + +/** + * Build the middleware that logs each statement and how long it took. + * + * @remarks + * Prisma 7 emitted a `query` event and this was a `$on` subscription. Prisma + * Next has no such event and ships no query logger, so it is a middleware — + * which is strictly better placed: `afterQuery` sees the statement as it was + * finally lowered, after every rewrite, rather than as it was written. + * + * `silently()` still suppresses it, so startup DDL stays out of the log. + * + * @param options - Whether to log, whether to include parameters, and where. + * @returns Middleware for the `middleware` array of the `postgres()` factory. + */ +export function queryLog({ + log, + logParams, + logger, +}: QueryLogOptions): SqlMiddleware { + return { + name: 'query-log', + familyId: 'sql' as const, + async afterQuery( + plan: { readonly sql?: string; readonly params?: unknown }, + result: { readonly latencyMs?: number }, + ): Promise { + if (!log || isSqlLogSuppressed() || !plan?.sql) { + return; + } + const params = logParams + ? `\n-- params: ${JSON.stringify(plan.params)}` + : ''; + + logger.log( + `${prettifySql(plan.sql)}${params} (${result?.latencyMs ?? 0}ms)`, + ); + }, + }; +} diff --git a/src/query.ts b/src/query.ts new file mode 100644 index 0000000..a8de8b9 --- /dev/null +++ b/src/query.ts @@ -0,0 +1,356 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import type { AnyExpression } from '@prisma/orm-postgres/relational-core/ast'; +import { + AndExpr, + NotExpr, + OrExpr, +} from '@prisma/orm-postgres/relational-core/ast'; +import type { RelationMap } from './derive.js'; + +/** Sort direction. */ +export type Direction = 'asc' | 'desc'; + +/** The comparison operators a caller may send for one field. */ +export interface FilterOps { + /** Equal to. */ + eq?: unknown; + /** Not equal to. */ + not?: unknown; + /** One of. */ + in?: unknown[]; + /** None of. */ + notIn?: unknown[]; + /** Less than. */ + lt?: unknown; + /** Less than or equal to. */ + lte?: unknown; + /** Greater than. */ + gt?: unknown; + /** Greater than or equal to. */ + gte?: unknown; + /** Contains, as a substring. */ + contains?: string; + /** Begins with. */ + startsWith?: string; + /** Ends with. */ + endsWith?: string; + /** + * `insensitive` to ignore case in the three matchers above. + * + * @remarks + * Prisma 7 spelled it the same way. Somebody typing a name is remembering + * it, not quoting it, so a search that respects case answers "no such + * thing" to a correct question. + */ + mode?: 'default' | 'insensitive'; +} + +/** A filter as it arrives over the wire. */ +export type Where = object; + +/** A projection as it arrives over the wire: `{ id: true, user: { … } }`. */ +export type Select = object; + +/** An ordering as it arrives over the wire: `{ createdAt: 'desc' }`. */ +export type OrderBy = object; + +/** One field of a model, inside a predicate callback. */ +interface Field { + eq(value: unknown): AnyExpression; + neq(value: unknown): AnyExpression; + in(values: unknown[]): AnyExpression; + notIn(values: unknown[]): AnyExpression; + lt(value: unknown): AnyExpression; + lte(value: unknown): AnyExpression; + gt(value: unknown): AnyExpression; + gte(value: unknown): AnyExpression; + like(pattern: string): AnyExpression; + ilike(pattern: string): AnyExpression; + asc(): unknown; + desc(): unknown; +} + +/** One relation of a model, inside a predicate callback. */ +interface RelationField { + some(build: (fields: Fields) => AnyExpression): AnyExpression; +} + +/** The object a predicate callback is handed. */ +export type Fields = Record; + +const LOGICAL = new Set(['AND', 'OR', 'NOT']); + +/** LIKE treats these as wildcards, so a literal one has to be escaped. */ +function escapeLike(value: string): string { + return value.replace(/([\\%_])/g, '\\$1'); +} + +function isPlainObject(value: unknown): value is Record { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +/** Whether a value is an operator bag rather than a plain equality operand. */ +function isOps(value: unknown): value is Record { + return ( + isPlainObject(value) && + Object.keys(value).length > 0 && + Object.keys(value).every(key => key in OPS || key === 'mode') + ); +} + +/** Each wire operator, as the expression it builds on a field. */ +const OPS: Record AnyExpression> = { + eq: (field, operand) => field.eq(operand), + not: (field, operand) => field.neq(operand), + in: (field, operand) => field.in(operand as unknown[]), + notIn: (field, operand) => field.notIn(operand as unknown[]), + lt: (field, operand) => field.lt(operand), + lte: (field, operand) => field.lte(operand), + gt: (field, operand) => field.gt(operand), + gte: (field, operand) => field.gte(operand), + contains: (field, operand) => + field.like(`%${escapeLike(String(operand))}%`), + startsWith: (field, operand) => + field.like(`${escapeLike(String(operand))}%`), + endsWith: (field, operand) => field.like(`%${escapeLike(String(operand))}`), +}; + +/** The same three, ignoring case. */ +const INSENSITIVE: Record< + string, + (field: Field, operand: never) => AnyExpression +> = { + contains: (field, operand) => + field.ilike(`%${escapeLike(String(operand))}%`), + startsWith: (field, operand) => + field.ilike(`${escapeLike(String(operand))}%`), + endsWith: (field, operand) => + field.ilike(`%${escapeLike(String(operand))}`), +}; + +/** AND a list of predicates, collapsing the single case. */ +function all(parts: AnyExpression[]): AnyExpression | undefined { + if (parts.length === 0) { + return undefined; + } + + return parts.length === 1 ? parts[0] : AndExpr.of(parts); +} + +function leaf(field: Field, value: unknown): AnyExpression[] { + if (!isOps(value)) { + return [field.eq(value)]; + } + + // `mode` is not an operator, it says how the others read. + const ops = + (value as FilterOps).mode === 'insensitive' + ? { ...OPS, ...INSENSITIVE } + : OPS; + + return Object.entries(value) + .filter(([op]) => op !== 'mode') + .map(([op, operand]) => + (ops[op] as (f: Field, o: unknown) => AnyExpression)( + field, + operand, + ), + ); +} + +function build( + relations: RelationMap, + model: string, + where: Where, + fields: Fields, +): AnyExpression[] { + return Object.entries(where).flatMap(([key, value]) => { + if (LOGICAL.has(key)) { + const parts = (Array.isArray(value) ? value : [value]).flatMap( + one => all(build(relations, model, one as Where, fields)) ?? [], + ); + if (parts.length === 0) { + return []; + } + if (key === 'OR') { + return [OrExpr.of(parts)]; + } + const conjunction = all(parts) as AnyExpression; + + return key === 'NOT' ? [new NotExpr(conjunction)] : [conjunction]; + } + const relation = relations[model]?.[key]; + if (relation) { + return [ + fields[key]?.some( + nested => + all( + build( + relations, + relation.target, + value as Where, + nested, + ), + ) as AnyExpression, + ) as AnyExpression, + ]; + } + const field = fields[key]; + + return field ? leaf(field, value) : []; + }); +} + +/** + * Turn a wire filter into the predicate callback `.where()` takes. + * + * @remarks + * A relation filters through `some`, matching the wire shape's meaning: a + * condition on a list relation asks whether **any** related row satisfies it. + * + * `contains`, `startsWith` and `endsWith` become `LIKE`, with the operand + * escaped — a caller searching for a literal `%` or `_` would otherwise get a + * wildcard, and the surprise is silent because the query still succeeds. + * + * @param relations - Relations per model, from `deriveDataLayer`. + * @param model - The model the filter is written against. + * @param where - The filter, or undefined for none. + * @returns The callback, or undefined when nothing constrains the query. + * @example + * ```typescript + * const predicate = toPredicate(relations, 'User', { + * email: { contains: '@example.com' }, + * roles: { role: { name: { eq: 'admin' } } }, + * }); + * const rows = await db.orm.public.User.where(predicate!).all(); + * ``` + */ +export function toPredicate( + relations: RelationMap, + model: string, + where: Where | undefined, +): ((fields: Fields) => AnyExpression) | undefined { + if (!where || Object.keys(where).length === 0) { + return undefined; + } + + return (fields: Fields): AnyExpression => + all(build(relations, model, where, fields)) as AnyExpression; +} + +/** A projection split into what `select` takes and what `include` takes. */ +export interface Projection { + /** Scalar field names. */ + fields: string[]; + /** Relations to eager-load, each with its own projection. */ + includes: { name: string; projection: Projection | undefined }[]; +} + +/** + * Split a wire projection into its scalar and relation halves. + * + * @remarks + * Prisma Next separates the two — `.select(...)` names scalar fields and + * `.include(name, branch)` pulls a relation — where the wire shape nests them + * in one object. Splitting here keeps every caller from walking it again. + * + * @param relations - Relations per model, from `deriveDataLayer`. + * @param model - The model the projection is written against. + * @param select - The projection, or undefined for the whole row. + * @returns The split projection, or undefined when nothing was asked for. + */ +export function toProjection( + relations: RelationMap, + model: string, + select: Select | undefined, +): Projection | undefined { + if (!select) { + return undefined; + } + const entries = Object.entries(select); + const relation = (key: string): string | undefined => + relations[model]?.[key]?.target; + const fields = entries + .filter(([key, value]) => value === true && !relation(key)) + .map(([key]) => key); + + // A relation named `true` is the whole related row, not a column to + // select — the distinction the ORM makes between `select` and `include`. + const includes = entries + .filter( + ([key, value]) => + relation(key) && (value === true || isPlainObject(value)), + ) + .map(([key, value]) => ({ + name: key, + projection: + value === true + ? undefined + : toProjection( + relations, + relation(key) ?? model, + value as Select, + ), + })); + + return fields.length > 0 || includes.length > 0 + ? { fields, includes } + : undefined; +} + +/** + * Turn a wire ordering into the callbacks `.orderBy()` takes. + * + * @remarks + * One callback per sort key, because `.orderBy()` accepts a single item and + * additional keys are expressed by chaining it. Returning an array from the + * callback instead fails deep inside parameter collection with an + * unrecognisable error, so the shape is pinned here rather than left to a + * caller to rediscover. + * + * @param orderBy - The ordering, or undefined for none. + * @returns One callback per key, in order; empty when nothing orders it. + * @example + * ```typescript + * toOrdering({ createdAt: 'desc', id: 'asc' }).reduce( + * (query, by) => query.orderBy(by), + * db.orm.public.Session, + * ); + * ``` + */ +export function toOrdering( + orderBy: OrderBy | undefined, +): ((fields: Fields) => unknown)[] { + return Object.entries(orderBy ?? {}) + .filter(([, direction]) => direction === 'asc' || direction === 'desc') + .map( + ([key, direction]) => + (fields: Fields): unknown => + direction === 'desc' + ? fields[key]?.desc() + : fields[key]?.asc(), + ); +} diff --git a/src/repository.ts b/src/repository.ts new file mode 100644 index 0000000..6c2bbcc --- /dev/null +++ b/src/repository.ts @@ -0,0 +1,564 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import type { RelationInfo, RelationMap } from './derive.js'; +import { + type Fields, + type OrderBy, + type Projection, + type Select, + type Where, + toOrdering, + toPredicate, + toProjection, +} from './query.js'; + +/** A page of entities, and the total when one was asked for. */ +export interface Page { + /** The rows on this page. */ + items: Entity[]; + /** The total matching rows, or null when it was not counted. */ + total: number | null; +} + +/** Rows to insert against a relation, as a create input carries them. */ +interface NestedCreate { + create: object[]; +} + +/** How many rows a bulk write affected. */ +export interface BulkCount { + /** The affected row count. */ + count: number; +} + +/** Paging and whether to count. */ +export interface PageOptions { + /** Rows to skip. */ + skip?: number; + /** Rows to take. */ + take?: number; + /** Count the matching rows as well as returning the page. */ + withTotal?: boolean; +} + +/** + * A model as a row that was read whole. + * + * @remarks + * Every field of a generated model is optional, because a projected read + * returns only what it asked for and the type has to describe both. A read + * with no `select` returns all of them, and this is how a call site says so — + * `single>({ where })` rather than a non-null assertion on each + * field afterwards. + * + * It does not make a nullable column non-null: `deletedAt` stays + * `string | null`, because that is the column and not the projection. + */ +export type RowOf = { [Field in keyof Model]-?: Model[Field] }; + +/** + * The CRUD surface exposed for one model. + * + * @remarks + * `Entity` is the model's own row type, which the generated `Repositories` + * supplies — `Repository` — so a call site reads a shape without + * naming one. A read that projects something narrower still says so: + * `single<{ id: string }>({ select: { id: true } })`. + */ +export interface Repository { + /** Insert a row and return it. */ + create(args: { + input: object; + select?: Select; + }): Promise; + /** + * Insert many rows in one statement, and return them. + * + * @remarks + * One `INSERT` rather than one per row, which is what makes copying a + * table's worth of rows a single round trip. A nested relation is not + * accepted here: the rows go in flat. + */ + createBulk(args: { + input: readonly object[]; + select?: Select | undefined; + }): Promise; + /** Update a row by id and return it. */ + update(args: { + input: Input; + select?: Select | undefined; + }): Promise; + /** The first row matching the filter, or null. */ + single(args: { + where?: Where | undefined; + select?: Select | undefined; + orderBy?: OrderBy | undefined; + }): Promise; + /** A page of rows matching the filter. */ + list(args: { + where?: Where | undefined; + select?: Select | undefined; + orderBy?: OrderBy | undefined; + options?: PageOptions | undefined; + }): Promise>; + /** How many rows match the filter. */ + count(args: { where?: Where | undefined }): Promise; + /** + * Insert a row, or update the one already holding its key. + * + * @remarks + * The key is the id unless `conflictOn` names another unique — a queue + * keyed by `(portfolioId, kind, reference)` has no id to upsert on, and + * reading first and then writing is a race the unique index would lose. + */ + upsert>(args: { + input: Input; + conflictOn?: readonly string[] | undefined; + select?: Select | undefined; + }): Promise; + /** Apply the same change to every row matching the filter. */ + updateBulk(args: { where?: Where; input: object }): Promise; + /** Delete every row matching the filter. */ + removeBulk(args: { where?: Where }): Promise; +} + +/** The ORM collection this façade drives. */ +interface Collection { + where(predicate: unknown): Collection; + select(...fields: string[]): Collection; + include(name: string, branch: (b: Collection) => Collection): Collection; + orderBy(by: (fields: Fields) => unknown): Collection; + limit(n: number): Collection; + offset(n: number): Collection; + all(): Promise; + first(): Promise; + create(input: object): Promise; + update(input: object): Promise; + createAll(rows: readonly object[]): Promise; + upsert(args: { + create: object; + update: object; + conflictOn?: Record; + }): Promise; + updateAndCount(input: object): Promise; + deleteAndCount(): Promise; + aggregate( + build: (a: { count(): unknown }) => object, + ): Promise>; +} + +/** Everything {@link repositoriesFor} needs. */ +export interface RepositoryOptions { + /** Relations per model, from `deriveDataLayer`. */ + relations: RelationMap; + /** Namespace the models live in. Defaults to `public`. */ + namespace?: string; +} + +/** Apply a projection's scalar half and its relation branches. */ +function project(query: Collection, projection?: Projection): Collection { + if (!projection) { + return query; + } + const selected = + projection.fields.length > 0 + ? query.select(...projection.fields) + : query; + + return projection.includes.reduce( + (acc, one) => + acc.include(one.name, branch => project(branch, one.projection)), + selected, + ); +} + +/** + * Build the CRUD façade the RPC surface delegates to. + * + * @remarks + * Under Prisma 7 this was generated: five methods for every model, each + * delegating to one of a handful of shared helpers — 800 lines of output that + * said the same thing fourteen times. Prisma Next addresses every model + * through the identical `db.orm..` shape, so the façade is built at + * run time from the contract instead, and there is nothing to regenerate when + * a model is added. + * + * Models are reached by their lower-camel name, as the generated repository + * exposed them: `repository.user`, `repository.roleInheritance`. + * + * Pass the emitted `Repositories` interface as the type argument to make + * access total: without it every lookup is `Repository | undefined`, which is + * noise at each of a service's call sites. + * + * @param db - The client, as returned by the `postgres()` factory. + * @param options - Relations and the namespace to read. + * @returns A repository per model, resolved on access. + * @example + * ```typescript + * const repository = repositoriesFor(db, { relations }); + * + * await repository.user.list({ where: { active: { eq: true } } }); + * ``` + */ +export function repositoriesFor< + // `object`, not `Record`: the emitted `Repositories` + // is an interface with a fixed key per model, and an interface has no + // index signature, so it can never satisfy a Record constraint. + Repositories extends object = Record, +>( + db: { + orm: Record>; + transaction?: Transactional['transaction']; + }, + { relations, namespace = 'public' }: RepositoryOptions, +): Repositories { + const models = db.orm[namespace] ?? {}; + + // A nested create is several inserts, so it has to commit as one. Already + // inside a transaction the client has no `transaction` of its own and the + // work is atomic regardless, so it runs inline. + const atomically = ( + run: (orm: Record) => Promise, + ): Promise => + db.transaction + ? db.transaction(tx => run(tx.orm[namespace] ?? {})) + : run(models); + const modelFor = (accessor: string): string => + accessor.charAt(0).toUpperCase() + accessor.slice(1); + + const repository = ( + model: string, + scope: Record = models, + ): Repository => { + const collection = (): Collection => scope[model] as Collection; + const filtered = (where?: Where): Collection => { + const predicate = toPredicate(relations, model, where); + + return predicate ? collection().where(predicate) : collection(); + }; + + // A write returns every column unless it is told otherwise, so a + // `select` that is dropped here hands the caller whatever the row + // holds — a credential secret among it. Its RETURNING carries scalars + // only (the ORM ignores an `include` on a write), so a select naming a + // relation is satisfied by reading the row back once, which is what + // Prisma 7 did behind `create({ select })`. + const write = async ( + run: (query: Collection) => Promise, + select: Select | undefined, + ): Promise => { + const projection = toProjection(relations, model, select); + const scalars = projection?.includes.length + ? [] + : (projection?.fields ?? []); + const written = await run( + scalars.length > 0 + ? collection().select(...scalars) + : collection(), + ); + + if (!projection?.includes.length) { + return written; + } + + return project( + collection().where({ id: (written as { id: string }).id }), + projection, + ).first(); + }; + + return { + async count(args: { where?: Where | undefined }): Promise { + // Through `list` rather than the ORM's own `count`, so the + // middleware sees the same query shape a read would — a + // count that ignored the soft-delete filter or the access + // scope would answer about rows the caller cannot read. + const { total } = await this.list({ + ...args, + options: { take: 0, withTotal: true }, + }); + + return total ?? 0; + }, + async createBulk(args: { + input: readonly object[]; + select?: Select | undefined; + }): Promise { + if (args.input.length === 0) { + return []; + } + + const projection = toProjection(relations, model, args.select); + const scalars = projection?.includes.length + ? [] + : (projection?.fields ?? []); + + return (await ( + scalars.length > 0 + ? collection().select(...scalars) + : collection() + ).createAll(args.input)) as Entity[]; + }, + async create(args: { + input: object; + select?: Select | undefined; + }): Promise { + const nested = Object.entries(args.input).filter( + ([key, value]) => + relations[model]?.[key]?.isList && + Array.isArray((value as NestedCreate | null)?.create), + ); + + if (nested.length === 0) { + return (await write( + query => query.create(args.input), + args.select, + )) as Entity; + } + + // Prisma Next rejects a relation key on an insert outright, so + // the children are written separately — with the foreign key + // the relation joins on taken from the row just created. + const own = Object.fromEntries( + Object.entries(args.input).filter( + ([key]) => !relations[model]?.[key], + ), + ); + + return (await atomically(async orm => { + const parent = repository(model, orm); + const made = (await parent.create({ + input: own, + })) as Record; + + for (const [key, value] of nested) { + const relation = relations[model]?.[ + key + ] as RelationInfo; + const link = Object.fromEntries( + relation.targetFields.map( + (field: string, index: number) => [ + field, + made[relation.localFields[index] as string], + ], + ), + ); + + for (const child of (value as NestedCreate).create) { + await repository(relation.target, orm).create({ + input: { ...child, ...link }, + }); + } + } + + return args.select + ? ((await repository(model, orm).single({ + where: { id: made.id as string }, + select: args.select, + })) as Entity) + : (made as Entity); + })) as Entity; + }, + async update< + Entity, + Input extends { id: string } = { id: string }, + >(args: { + input: Input; + select?: Select | undefined; + }): Promise { + const { id, ...data } = args.input; + + return (await write( + query => query.where({ id }).update(data), + args.select, + )) as Entity; + }, + async single(args: { + where?: Where | undefined; + select?: Select | undefined; + orderBy?: OrderBy | undefined; + }): Promise { + // Ordered where asked: "the first row matching" is only + // meaningful once the order is stated, and the newest of + // several matches is a common thing to want. + const found = await toOrdering(args.orderBy) + .reduce( + (query, by) => query.orderBy(by), + project( + filtered(args.where), + toProjection(relations, model, args.select), + ), + ) + .first(); + + return (found ?? null) as Entity | null; + }, + async list(args: { + where?: Where | undefined; + select?: Select | undefined; + orderBy?: OrderBy | undefined; + options?: PageOptions | undefined; + }): Promise> { + const { skip, take, withTotal } = args.options ?? {}; + const base = filtered(args.where); + const ordered = toOrdering(args.orderBy).reduce( + (query, by) => query.orderBy(by), + project(base, toProjection(relations, model, args.select)), + ); + const paged = [ + (query: Collection): Collection => + skip === undefined ? query : query.offset(skip), + (query: Collection): Collection => + take === undefined ? query : query.limit(take), + ].reduce((query, step) => step(query), ordered); + + // Counted on the filter, not on the page: the total answers + // "how many match", which paging must not narrow. + const [items, total] = await Promise.all([ + paged.all() as Promise, + withTotal + ? base + .aggregate(a => ({ total: a.count() })) + .then(row => row.total ?? 0) + : Promise.resolve(null), + ]); + + return { items, total }; + }, + async upsert< + Entity, + Input extends object = Record, + >(args: { + input: Input; + conflictOn?: readonly string[] | undefined; + select?: Select | undefined; + }): Promise { + const key = args.conflictOn ?? ['id']; + + // The key identifies the row; everything else is what an + // existing row is updated to. Writing the key again on + // conflict would be a no-op at best. + const rest = Object.fromEntries( + Object.entries(args.input).filter( + ([field]) => !key.includes(field), + ), + ); + + // Keyed rather than blind, so a re-run updates the row it + // created rather than adding a second — which is what makes a + // seed safe to run on every start. + return (await write( + query => + query.upsert({ + create: args.input, + update: rest, + ...(args.conflictOn + ? { + conflictOn: Object.fromEntries( + args.conflictOn.map(field => [ + field, + true, + ]), + ), + } + : {}), + }), + args.select, + )) as Entity; + }, + async updateBulk(args: { + where?: Where; + input: object; + }): Promise { + return { + count: await filtered(args.where).updateAndCount( + args.input, + ), + }; + }, + async removeBulk(args: { where?: Where }): Promise { + return { count: await filtered(args.where).deleteAndCount() }; + }, + }; + }; + + return new Proxy({} as Repositories, { + get: (_target, accessor: string): Repository => + repository(modelFor(accessor)), + has: (_target, accessor: string): boolean => + modelFor(accessor) in models, + ownKeys: (): string[] => + Object.keys(models).map( + model => model.charAt(0).toLowerCase() + model.slice(1), + ), + getOwnPropertyDescriptor: () => ({ + enumerable: true, + configurable: true, + }), + }); +} + +/** A client that can run work inside one transaction. */ +export interface Transactional { + transaction( + run: (tx: { + orm: Record>; + }) => Promise, + ): Promise; +} + +/** + * Repositories bound to a transaction. + * + * @remarks + * Prisma Next has no nested writes — `create` rejects a relation key outright + * — so anything that used to be one nested call is now several, and they have + * to commit or roll back together. The callback's repositories run on the + * transaction's connection; throwing from it rolls back. + * + * @param db - The client to open the transaction on. + * @param options - The same options {@link repositoriesFor} takes. + * @returns A function running work with transaction-bound repositories. + * @example + * ```typescript + * const transaction = transactionFor(db, { relations }); + * + * await transaction(async repository => { + * const user = await repository.user.create({ input }); + * await repository.credential.create({ input: { userId: user.id } }); + * }); + * ``` + */ +export function transactionFor( + db: Transactional, + options: RepositoryOptions, +): ( + run: (repository: Repositories) => Promise, +) => Promise { + return ( + run: (repository: Repositories) => Promise, + ): Promise => + db.transaction(tx => run(repositoriesFor(tx, options))); +} diff --git a/src/soft-delete.ts b/src/soft-delete.ts deleted file mode 100644 index c584487..0000000 --- a/src/soft-delete.ts +++ /dev/null @@ -1,169 +0,0 @@ -/*! - * Prisma soft-delete query extension - * - * I'm Queue Software Project - * Copyright (C) 2025 imqueue.com - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - * - * If you want to use this code in a closed source (commercial) project, you can - * purchase a proprietary commercial license. Please contact us at - * to get commercial licensing options. - */ - -import { Prisma, type PrismaClient } from '@prisma/client/extension'; - -/** - * Which models are soft-deleted, and which column carries the stamp. - * - * @remarks - * Keyed by Prisma model name; the `deletedAt` value is the column that holds the - * deletion timestamp, so it does not have to be literally named `deletedAt`. A - * model absent from this map is untouched — its deletes are real deletes. The - * code generator emits this config from your schema, so it normally comes from - * there rather than being written by hand. - */ -export type SoftDeleteModels = Record; - -/** Everything {@link softDelete} needs to build its extension. */ -export interface SoftDeleteOptions { - /** - * The UNEXTENDED Prisma client, used to reroute deletes into updates. - * - * @remarks - * Passing the extended client here would send the rerouted update back - * through this same extension. - */ - client: PrismaClient; - /** Which models are soft-deleted, and the column holding the stamp. */ - models: SoftDeleteModels; -} - -const accessor = (model: string): string => - model.charAt(0).toLowerCase() + model.slice(1); - -function excludeDeleted( - model: string, - args: unknown, - softDeleteModels: SoftDeleteModels, -): void { - const column = softDeleteModels[model]?.deletedAt; - if (column) { - const withWhere = args as { where?: object }; - withWhere.where = { [column]: null, ...withWhere.where }; - } -} - -/** - * Build the query extension that turns deletes into `deletedAt` stamps and hides - * stamped rows from reads. - * - * @remarks - * For every model listed in `models`, `delete` and `deleteMany` become an - * `update`/`updateMany` that writes the current time into the configured column, - * and the read operations (`findMany`, `findFirst`, `findUnique`, their `OrThrow` - * variants and `count`) gain a `: null` filter. Models not listed pass - * straight through, deletes included. - * - * Deletes themselves also filter on `: null`, so only live rows are - * deletable and an original stamp is never overwritten by a second delete. The - * consequence is worth stating plainly: deleting an already-soft-deleted row - * throws not-found, exactly as deleting a row that was never there does. - * - * `findUnique` works here because Prisma's extended where-unique accepts - * non-unique scalars as extra filters alongside the unique key. - * - * The filter is applied to TOP-LEVEL reads only. A nested `include` or `select` - * that reaches a soft-deleted model through a relation is not intercepted, and it - * DOES return stamped rows; add `where: { deletedAt: null }` to the nested - * relation at those call sites when it matters. - * - * @param input - The unextended client and the per-model column config. - * @returns A Prisma extension to pass to `client.$extends()`. - * @example - * ```typescript - * const base = new PrismaClient(); - * const client = base.$extends(softDelete({ - * client: base, - * models: { User: { deletedAt: 'deletedAt' } }, - * })); - * - * await client.user.delete({ where: { id } }); // stamps, does not remove - * await client.user.findMany(); // stamped rows are absent - * ``` - */ -export function softDelete({ client, models }: SoftDeleteOptions) { - return Prisma.defineExtension({ - name: 'soft-delete', - query: { - $allModels: { - findMany({ model, args, query }) { - excludeDeleted(model, args, models); - return query(args); - }, - findFirst({ model, args, query }) { - excludeDeleted(model, args, models); - return query(args); - }, - findFirstOrThrow({ model, args, query }) { - excludeDeleted(model, args, models); - return query(args); - }, - findUnique({ model, args, query }) { - excludeDeleted(model, args, models); - return query(args); - }, - findUniqueOrThrow({ model, args, query }) { - excludeDeleted(model, args, models); - return query(args); - }, - count({ model, args, query }) { - excludeDeleted(model, args, models); - return query(args); - }, - delete({ model, args, query }) { - const column = models[model]?.deletedAt; - if (!column) { - return query(args); - } - - // Only live rows are deletable — an already-soft-deleted - // row is "absent", so its original stamp is never - // overwritten (matches how reads treat it). - return (client as any)[accessor(model)].update({ - where: { - [column]: null, - ...(args as { where: object }).where, - }, - data: { [column]: new Date() }, - }); - }, - deleteMany({ model, args, query }) { - const column = models[model]?.deletedAt; - if (!column) { - return query(args); - } - - return (client as any)[accessor(model)].updateMany({ - where: { - [column]: null, - ...(args as { where?: object }).where, - }, - data: { [column]: new Date() }, - }); - }, - }, - }, - }); -} diff --git a/src/sql-client.ts b/src/sql-client.ts new file mode 100644 index 0000000..50c894a --- /dev/null +++ b/src/sql-client.ts @@ -0,0 +1,104 @@ +/*! + * @imqueue/pg-prisma — Prisma/Postgres toolkit for @imqueue services + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +/** + * The minimum this package needs of a Postgres connection. + * + * @remarks + * Deliberately not an ORM client. These installers write DDL — trigger + * functions, archive schemas, pg_cron jobs — which is startup work, orthogonal + * to how the application queries afterwards. Prisma Next's client offers + * `db.raw.sql` as a tagged template with a declared row schema, which is the + * wrong shape for arbitrary DDL, and tying the installers to it would make + * them unusable from a migration or a standalone script. + * + * A `pg.Pool` satisfies this as it is, so a consumer passes one directly. + */ +export interface SqlExecutor { + /** + * Run a statement. + * + * @param sql - The statement. DDL cannot take bind parameters, so most + * callers here interpolate a checked identifier rather than binding. + * @param values - Bound parameters, where the statement takes them. + * @returns The result rows. + */ + query( + sql: string, + values?: readonly unknown[], + ): Promise<{ rows: unknown[] }>; +} + +/** A connection held for the length of a transaction. */ +export interface SqlConnection extends SqlExecutor { + /** Returns the connection to its pool. */ + release(): void; +} + +/** An executor that can hand out a dedicated connection. */ +export interface SqlPool extends SqlExecutor { + /** Take a connection out of the pool. */ + connect(): Promise; +} + +/** + * Run `fn` inside a transaction on one connection. + * + * @remarks + * A transaction has to be one connection: issuing `BEGIN` on a pool and the + * statements after it on whatever connection the pool hands out next is the + * classic way to commit nothing and report success. The connection is released + * whatever happens, and a failure rolls back before rethrowing. + * + * @param pool - The pool to take a connection from. + * @param fn - The work to run inside the transaction. + * @returns Whatever `fn` returns. + * @example + * ```typescript + * await withTransaction(pool, async tx => { + * await tx.query('CREATE TABLE ...'); + * }); + * ``` + */ +export async function withTransaction( + pool: SqlPool, + fn: (tx: SqlExecutor) => Promise, +): Promise { + const connection = await pool.connect(); + + try { + await connection.query('BEGIN'); + const result = await fn(connection); + + await connection.query('COMMIT'); + + return result; + } catch (error) { + await connection.query('ROLLBACK').catch(() => undefined); + + throw error; + } finally { + connection.release(); + } +} diff --git a/src/sql-log.ts b/src/sql-log.ts index 6fcf7aa..b9a595d 100644 --- a/src/sql-log.ts +++ b/src/sql-log.ts @@ -60,7 +60,7 @@ export function isSqlLogSuppressed(): boolean { * @returns Whatever `fn` resolves to. * @example * ```typescript - * await silently(() => client.$executeRawUnsafe(startupDdl)); + * await silently(() => pool.query(startupDdl)); * ``` */ export async function silently(fn: () => Promise): Promise { diff --git a/src/sql-runner.ts b/src/sql-runner.ts new file mode 100644 index 0000000..cc3c130 --- /dev/null +++ b/src/sql-runner.ts @@ -0,0 +1,115 @@ +/*! + * @imqueue/pg-prisma — a connection that knows when it is in a transaction + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { SqlExecutor, SqlPool } from './sql-client.js'; +import { withTransaction } from './sql-client.js'; +import type { SqlFragment } from './sql-template.js'; + +/** What {@link sqlRunner} returns. */ +export interface SqlRunner { + /** + * Run a statement, on the open transaction if there is one. + * + * @remarks + * The rows, not the driver's envelope: a raw read is asked for its rows, + * and every call site would otherwise reach through `.rows` to get them. + * + * A fragment carries its own values, so it is passed on its own; text + * takes them as a second argument, the way the driver does. + * + * @param sql - The statement, as text or as a fragment. + * @param values - Bound parameters, where the statement is text. + * @returns The result rows. + */ + query( + sql: string | SqlFragment, + values?: readonly unknown[], + ): Promise; + /** + * Run `fn` inside a transaction, with every `query` it reaches joining it. + * + * @param fn - Work to run in the transaction. + * @returns Whatever `fn` resolves to. + */ + transaction(fn: () => Promise): Promise; +} + +/** + * A `query` and a `transaction` that agree about which connection to use. + * + * @remarks + * A transaction is one connection, so a statement run on the pool while one is + * open is not in it — it commits on its own, and the rollback the caller is + * relying on leaves it behind. Prisma 7 solved this by handing a `tx` client + * to the callback and asking every write to take it as an argument, which + * meant threading it through everything the callback reaches. + * + * Here the connection travels in `AsyncLocalStorage` instead: `query` uses the + * open transaction when there is one and the pool otherwise, so the call sites + * do not change and cannot get it wrong. Nesting joins the outer transaction + * rather than opening a second one, which is what a caller composing two + * operations means by it. + * + * @param pool - The pool to take connections from. + * @returns The pair. + * @example + * ```typescript + * const { query, transaction } = sqlRunner(pool); + * + * await transaction(async () => { + * await query('SET LOCAL lock_timeout = $1', ['5s']); + * await write(); + * }); + * ``` + */ +export function sqlRunner(pool: SqlPool): SqlRunner { + const open = new AsyncLocalStorage(); + + return { + async query( + sql: string | SqlFragment, + values?: readonly unknown[], + ): Promise { + const executor = open.getStore() ?? pool; + const text = typeof sql === 'string' ? sql : sql.text; + const bound = typeof sql === 'string' ? values : sql.values; + const { rows } = + bound === undefined + ? await executor.query(text) + : await executor.query(text, bound); + + return rows as Row[]; + }, + transaction(fn: () => Promise): Promise { + const already = open.getStore(); + + if (already) { + return fn(); + } + + return withTransaction(pool, tx => open.run(tx, fn)); + }, + }; +} diff --git a/src/sql-template.ts b/src/sql-template.ts new file mode 100644 index 0000000..b31e6c4 --- /dev/null +++ b/src/sql-template.ts @@ -0,0 +1,165 @@ +/*! + * @imqueue/pg-prisma — composable SQL fragments + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +/** + * A statement and the values bound into it. + * + * @remarks + * The placeholders are numbered from one across the whole statement, which is + * what composition has to preserve: a fragment written on its own binds `$1`, + * and the same fragment spliced third into another binds whatever comes next. + */ +export interface SqlFragment { + /** The statement, with `$1`-style placeholders. */ + text: string; + /** The values, in placeholder order. */ + values: unknown[]; +} + +/** Whether a value is a fragment rather than something to bind. */ +function isFragment(value: unknown): value is SqlFragment { + return ( + typeof value === 'object' && + value !== null && + typeof (value as SqlFragment).text === 'string' && + Array.isArray((value as SqlFragment).values) + ); +} + +/** + * SQL as a tagged template, with everything interpolated bound. + * + * @remarks + * This is what `Prisma.sql` was, and it exists for the same reason: a column + * cannot be a bind parameter, so a query built from a caller's choices has to + * be assembled — and assembling it by concatenation is how an injection gets + * written. Everything interpolated is bound unless it is itself a fragment, + * in which case it is spliced and its own placeholders renumbered. + * + * Use {@link raw} for the parts that genuinely cannot be bound, and read its + * warning first. + * + * @param strings - The literal parts of the template. + * @param values - What was interpolated between them. + * @returns The statement and its values. + * @example + * ```typescript + * const rows = await pool.query( + * ...toQuery(sql`SELECT * FROM "User" WHERE id = ${id}`), + * ); + * ``` + */ +export function sql( + strings: TemplateStringsArray, + ...values: unknown[] +): SqlFragment { + const parts: string[] = []; + const bound: unknown[] = []; + + strings.forEach((literal, at) => { + parts.push(literal); + + if (at >= values.length) { + return; + } + + const value = values[at]; + + if (isFragment(value)) { + // Its placeholders are numbered from one; here they continue from + // whatever this statement has bound so far. + parts.push( + value.text.replace( + /\$(\d+)/g, + (_, n: string) => `$${Number(n) + bound.length}`, + ), + ); + bound.push(...value.values); + + return; + } + + bound.push(value); + parts.push(`$${bound.length}`); + }); + + return { text: parts.join(''), values: bound }; +} + +/** + * Text spliced in as written, binding nothing. + * + * @remarks + * **The one way to write SQL from a value, and the only unsafe one.** Every + * caller must be passing something from a closed set it controls — a column + * name looked up in a map, a direction that is `ASC` or `DESC` — never a + * string that reached it from outside. + * + * @param text - The SQL, spliced verbatim. + * @returns A fragment binding nothing. + */ +export function raw(text: string): SqlFragment { + return { text, values: [] }; +} + +/** A fragment that contributes nothing, for the empty case. */ +export const EMPTY: SqlFragment = { text: '', values: [] }; + +/** + * Several fragments, one after another. + * + * @param parts - The fragments to join. + * @param separator - What goes between them. Defaults to `, `. + * @returns One fragment, with its placeholders renumbered in order. + * @example + * ```typescript + * sql`WHERE ${join(matches, ' OR ')}`; + * ``` + */ +export function join( + parts: readonly SqlFragment[], + separator = ', ', +): SqlFragment { + return parts.reduce((all, part, at) => { + const shifted = part.text.replace( + /\$(\d+)/g, + (_, n: string) => `$${Number(n) + all.values.length}`, + ); + + return { + text: at === 0 ? shifted : `${all.text}${separator}${shifted}`, + values: [...all.values, ...part.values], + }; + }, EMPTY); +} + +/** + * A fragment as the pair `query` takes. + * + * @param fragment - The statement to run. + * @returns The statement and its values, to spread into `query`. + */ +export function toQuery(fragment: SqlFragment): [string, unknown[]] { + return [fragment.text, fragment.values]; +} diff --git a/src/stamp.ts b/src/stamp.ts new file mode 100644 index 0000000..de8beeb --- /dev/null +++ b/src/stamp.ts @@ -0,0 +1,212 @@ +/*! + * Prisma Next (8.x) soft-delete and authorship query middleware + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import type { SqlMiddleware } from '@prisma/orm-postgres/family-runtime'; +import type { + AnyExpression, + ParamRef, +} from '@prisma/orm-postgres/relational-core/ast'; +import { UpdateAst } from '@prisma/orm-postgres/relational-core/ast'; +import { + conjoin, + filterSelects, + isNull, + param, + qualifierOf, + type DraftPlan, + TIMESTAMP, +} from './ast.js'; +import type { StampColumns, StampTables } from './derive.js'; + +/** Everything {@link stamp} needs to build its middleware. */ +export interface StampOptions { + /** Soft-delete and authorship columns per physical table. */ + tables: StampTables; + /** + * Resolves the id of the actor performing the current write, or null when + * there is none (system or unauthenticated). + */ + getActorId: () => string | null; +} + +/** The stamps this middleware writes; a `ParamRef` is valid in both an + * `INSERT` row and an `UPDATE` assignment, where `AnyExpression` is not. */ +type Assignments = Record; + +/** Whether an assignment sets a value that is not null. */ +function setsValue( + assignments: Readonly>, + column?: string, +): boolean { + if (!column) { + return false; + } + const assigned = assignments[column] as { value?: unknown } | undefined; + + return assigned !== undefined && assigned.value !== null; +} + +/** + * Build the middleware that stamps authorship and turns deletes into stamps. + * + * @remarks + * One middleware rather than two because the two halves are inseparable: a + * soft delete only exists as an `update` **because** this rewrote the `delete` + * into one, and only this knows that the update it is stamping is a deletion. + * Split across two middlewares — as the Prisma 7 extensions were — the second + * has to infer the deletion from the assignments, and the pair has to be + * ordered correctly by every caller, with `deletedBy` silently never written + * when it is not. Neither hazard can be expressed here. + * + * Reads are filtered across the **whole statement**, not just its outermost + * `from`, so a soft-deleted row reached through a relation is excluded too. + * + * A null actor stamps null rather than skipping the column, which is what + * makes a system write distinguishable from one whose author was never + * recorded. + * + * @param input - Per-table columns and the actor resolver. + * @returns Middleware for the `middleware` array of the `postgres()` factory. + */ +export function stamp({ tables, getActorId }: StampOptions): SqlMiddleware { + const author = (column: string): ParamRef => param(getActorId(), column); + + // Bound under the column's own codec: an `INSERT` accepts a parameter but + // not a function call, so the value is produced here — which is also what + // Prisma 7's `@updatedAt` did. + const stampedAt = (columns: StampColumns, column: string): ParamRef => + param(new Date().toISOString(), column, { + codecId: columns.timestampCodec ?? TIMESTAMP.codecId, + }); + + const authored = ( + columns: StampColumns, + deleting: boolean, + ): Assignments => ({ + // Prisma 7 wrote this through `@updatedAt`, which Prisma Next has no + // equivalent of; without it the column is NOT NULL with no default and + // every insert fails. + ...(columns.updatedAt + ? { [columns.updatedAt]: stampedAt(columns, columns.updatedAt) } + : {}), + ...(columns.updatedBy + ? { [columns.updatedBy]: author(columns.updatedBy) } + : {}), + ...(deleting && columns.deletedBy + ? { [columns.deletedBy]: author(columns.deletedBy) } + : {}), + }); + + return { + name: 'stamp', + familyId: 'sql' as const, + async beforeCompile(draft: DraftPlan): Promise { + const ast = draft.ast; + + if (ast.kind === 'select') { + const filtered = filterSelects(ast, (table, qualifier) => { + const column = tables[table]?.deletedAt; + + return column ? isNull(qualifier, column) : null; + }); + + return filtered.changed + ? { ...draft, ast: filtered.ast } + : undefined; + } + + if (ast.kind === 'insert') { + const columns = tables[ast.table.name]; + if (!columns) { + return undefined; + } + const stamps: Assignments = { + ...(columns.createdBy + ? { [columns.createdBy]: author(columns.createdBy) } + : {}), + ...authored(columns, false), + }; + + return { + ...draft, + ast: ast.withRows( + ast.rows.map(row => ({ ...row, ...stamps })), + ), + }; + } + + if (ast.kind === 'update') { + const columns = tables[ast.table.name]; + if (!columns) { + return undefined; + } + // Only a write that sets the column to a real timestamp is a + // deletion. Setting it to null is a restore, and stamping + // `deletedBy` there would name whoever brought the row back. + const deleting = setsValue(ast.set, columns.deletedAt); + const alive = columns.deletedAt + ? isNull(qualifierOf(ast.table), columns.deletedAt) + : undefined; + + return { + ...draft, + ast: ast + .withSet({ ...ast.set, ...authored(columns, deleting) }) + .withWhere( + alive && !deleting + ? conjoin(ast.where, alive) + : ast.where, + ), + }; + } + + if (ast.kind === 'delete') { + const columns = tables[ast.table.name]; + if (!columns?.deletedAt) { + return undefined; + } + const alive = isNull(qualifierOf(ast.table), columns.deletedAt); + + return { + ...draft, + // The RETURNING clause has to survive: it is what hands the + // caller the row it deleted, and what lets `audit` see a + // soft delete at all. + ast: UpdateAst.table(ast.table) + .withSet({ + [columns.deletedAt]: stampedAt( + columns, + columns.deletedAt, + ), + ...authored(columns, true), + }) + .withWhere(conjoin(ast.where, alive)) + .withReturning(ast.returning), + }; + } + + return undefined; + }, + }; +} diff --git a/test/unit/access-scope.spec.ts b/test/unit/access-scope.spec.ts index 900aeef..9f86766 100644 --- a/test/unit/access-scope.spec.ts +++ b/test/unit/access-scope.spec.ts @@ -21,9 +21,10 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ + import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { accessWhere, type AccessScopeResolver } from '../../index.js'; +import { type AccessScopeResolver, scopePredicate } from '../../index.js'; /** Build a resolvers map from plain values (a value → a `() => value` getter). */ const resolvers = ( @@ -31,173 +32,118 @@ const resolvers = ( ): Record => Object.fromEntries(Object.entries(map).map(([k, v]) => [k, () => v])); -test('an unscoped model passes its where through untouched', () => { - const where = { name: 'x' }; - assert.equal(accessWhere(where, undefined, resolvers({})), where); +/** + * Reduce a predicate to the shape these tests are about. + * + * @remarks + * Comparing the AST nodes directly would assert the runtime's internals rather + * than this package's composition, and would break on any upstream field it + * adds. What matters here is the operator tree and the columns it touches. + */ +const shape = (node: unknown): unknown => { + const n = node as { + kind?: string; + exprs?: unknown[]; + op?: string; + left?: { column?: string }; + right?: { + kind?: string; + values?: { value?: unknown }[]; + value?: unknown; + }; + }; + if (n?.kind === 'and' || n?.kind === 'or') { + return { [n.kind]: (n.exprs ?? []).map(shape) }; + } + if (n?.kind === 'binary') { + const right = + n.right?.kind === 'list' + ? (n.right.values ?? []).map(v => v.value) + : n.right?.value; + + return { [`${n.left?.column} ${n.op}`]: right }; + } + + return n?.kind ?? n; +}; + +test('an unscoped model yields no predicate', () => { + assert.equal(scopePredicate('t', undefined, resolvers({})), null); }); -test('a single scalar column becomes an OR-of-one equals, AND-ed on', () => { - const out = accessWhere( - undefined, +test('a single scalar column becomes an OR-of-one equality', () => { + const out = scopePredicate( + 't', { user: ['createdBy'] }, resolvers({ user: 'u1' }), ); - assert.deepEqual(out, { AND: [{ OR: [{ createdBy: 'u1' }] }] }); + assert.deepEqual(shape(out), { or: [{ 'createdBy eq': 'u1' }] }); }); test('several columns for one level are OR-ed (union)', () => { - const out = accessWhere( - undefined, + const out = scopePredicate( + 't', { user: ['createdBy', 'id'] }, resolvers({ user: 'u1' }), ); - assert.deepEqual(out, { - AND: [{ OR: [{ createdBy: 'u1' }, { id: 'u1' }] }], + assert.deepEqual(shape(out), { + or: [{ 'createdBy eq': 'u1' }, { 'id eq': 'u1' }], }); }); test('an array value becomes an IN filter', () => { - const out = accessWhere( - undefined, + const out = scopePredicate( + 't', { portfolio: ['portfolioId'] }, resolvers({ portfolio: ['p1', 'p2'] }), ); - assert.deepEqual(out, { - AND: [{ OR: [{ portfolioId: { in: ['p1', 'p2'] } }] }], - }); + assert.deepEqual(shape(out), { or: [{ 'portfolioId in': ['p1', 'p2'] }] }); }); test('active levels are AND-ed together; each is its own OR group', () => { - const out = accessWhere( - undefined, + const out = scopePredicate( + 't', { user: ['createdBy', 'id'], portfolio: ['portfolioId'] }, resolvers({ user: 'u1', portfolio: ['p1'] }), ); - assert.deepEqual(out, { - AND: [ - { OR: [{ createdBy: 'u1' }, { id: 'u1' }] }, - { OR: [{ portfolioId: { in: ['p1'] } }] }, + assert.deepEqual(shape(out), { + and: [ + { or: [{ 'createdBy eq': 'u1' }, { 'id eq': 'u1' }] }, + { or: [{ 'portfolioId in': ['p1'] }] }, ], }); }); test('an undefined resolver value leaves that level inactive', () => { - const out = accessWhere( - undefined, + const out = scopePredicate( + 't', { user: ['createdBy'], portfolio: ['portfolioId'] }, resolvers({ user: 'u1', portfolio: undefined }), ); - // Only the user level constrains; portfolio is skipped entirely. - assert.deepEqual(out, { AND: [{ OR: [{ createdBy: 'u1' }] }] }); + assert.deepEqual(shape(out), { or: [{ 'createdBy eq': 'u1' }] }); }); -test('all levels inactive returns the where unchanged', () => { - const where = { active: true }; - const out = accessWhere( - where, +test('all levels inactive yields no predicate', () => { + const out = scopePredicate( + 't', { user: ['createdBy'] }, resolvers({ user: undefined }), ); - assert.equal(out, where); + assert.equal(out, null); }); test('a null value denies via an impossible IN ()', () => { - const out = accessWhere( - undefined, + const out = scopePredicate( + 't', { user: ['createdBy', 'id'] }, resolvers({ user: null }), ); - assert.deepEqual(out, { - AND: [{ OR: [{ createdBy: { in: [] } }, { id: { in: [] } }] }], - }); -}); - -test('an empty array also denies (IN of nothing)', () => { - const out = accessWhere( - undefined, - { portfolio: ['portfolioId'] }, - resolvers({ portfolio: [] }), - ); - assert.deepEqual(out, { - AND: [{ OR: [{ portfolioId: { in: [] } }] }], - }); -}); - -test('the caller where is preserved and AND-ed, never replaced', () => { - const out = accessWhere( - { active: true }, - { user: ['createdBy'] }, - resolvers({ user: 'u1' }), - ); - assert.deepEqual(out, { - active: true, - AND: [{ OR: [{ createdBy: 'u1' }] }], + assert.deepEqual(shape(out), { + or: [{ 'createdBy in': [] }, { 'id in': [] }], }); }); -test('a missing resolver for a configured level is skipped', () => { - const out = accessWhere( - undefined, - { user: ['createdBy'], portfolio: ['portfolioId'] }, - resolvers({ user: 'u1' }), // no `portfolio` resolver at all - ); - assert.deepEqual(out, { AND: [{ OR: [{ createdBy: 'u1' }] }] }); -}); - -/* - * The regression the rest of this file exists for. - * - * `update`, `delete` and `findUnique` take a `WhereUniqueInput`, and Prisma - * requires a unique field at its *top level*. Nesting the caller's `where` - * inside `AND` — which this did — left the argument with no unique field, so - * Prisma refused the call instead of scoping it, and every scoped update in - * every service using this extension failed. - */ -test('a unique field stays at the top level, where update needs it', () => { - const out = accessWhere( - { id: 'r1' }, - { portfolio: ['portfolioId'] }, - resolvers({ portfolio: ['p1'] }), - ); - assert.deepEqual(out, { - id: 'r1', - AND: [{ OR: [{ portfolioId: { in: ['p1'] } }] }], - }); -}); - -test("the caller's own AND is conjoined rather than overwritten", () => { - const out = accessWhere( - { id: 'r1', AND: [{ active: true }] }, - { portfolio: ['portfolioId'] }, - resolvers({ portfolio: ['p1'] }), - ); - assert.deepEqual(out, { - id: 'r1', - AND: [{ active: true }, { OR: [{ portfolioId: { in: ['p1'] } }] }], - }); -}); - -test('a single-object AND from the caller is conjoined too', () => { - const out = accessWhere( - { id: 'r1', AND: { active: true } }, - { portfolio: ['portfolioId'] }, - resolvers({ portfolio: ['p1'] }), - ); - assert.deepEqual(out, { - id: 'r1', - AND: [{ active: true }, { OR: [{ portfolioId: { in: ['p1'] } }] }], - }); -}); - -test('a caller condition on a scope column is kept, so it cannot widen', () => { - const out = accessWhere( - { portfolioId: 'p9' }, - { portfolio: ['portfolioId'] }, - resolvers({ portfolio: ['p1'] }), - ); - // Both conditions apply: asking for p9 under a p1 scope matches nothing. - assert.deepEqual(out, { - portfolioId: 'p9', - AND: [{ OR: [{ portfolioId: { in: ['p1'] } }] }], - }); +test('a level named in the config but with no resolver is skipped', () => { + const out = scopePredicate('t', { user: ['createdBy'] }, resolvers({})); + assert.equal(out, null); }); diff --git a/test/unit/barrel.spec.ts b/test/unit/barrel.spec.ts index aedb00e..c9f5aa7 100644 --- a/test/unit/barrel.spec.ts +++ b/test/unit/barrel.spec.ts @@ -38,19 +38,44 @@ const EXPORTS = [ 'CHANGE_NOTIFY_FUNCTION_NAME', 'CHANGE_NOTIFY_SUPPRESS_SETTING', 'CHANGE_NOTIFY_TRIGGER_NAME', + 'EMPTY', + 'RUNTIME', + 'TIMESTAMP_CODECS', 'accessScope', - 'accessWhere', 'audit', - 'authorship', + 'dataLayer', + 'dataPool', + 'deriveDataLayer', + 'emitAll', + 'emitEnums', + 'emitImports', + 'emitModels', + 'emitRpcTypes', + 'hasDatabaseDefault', 'installArchiving', 'installChangeTriggers', 'isSqlLogSuppressed', 'isoDates', - 'migrateDown', + 'join', + 'namespaceOf', + 'parseImportMap', 'prettifySql', + 'queryLog', + 'quoted', + 'raw', + 'repositoriesFor', + 'scopePredicate', 'silently', - 'softDelete', - 'toIsoDates', + 'sql', + 'sqlRunner', + 'stamp', + 'toOrdering', + 'toPredicate', + 'toProjection', + 'toQuery', + 'transactionFor', + 'typeOf', + 'withTransaction', 'withoutChangeNotify', ]; @@ -59,14 +84,15 @@ const EXPORTS = [ // with ERR_REQUIRE_ASYNC_MODULE. Two things put an async module in this graph and // so broke every CJS consumer of the package: // -// * `export * from './codegen.js'` in src/index.ts, which exported nothing at -// all (every `export` in codegen.ts is inside a generated-code template -// string) while pulling in its top-level `await import(...)`; +// * `export * from './codegen.js'` in src/index.ts, which pulled in a +// top-level `await import(...)`; // * `await cli()` at the foot of src/migrate-down.ts. // -// Neither is visible from inside ESM, which is why it went unnoticed. Run in a -// child process because `require` of an async graph poisons nothing but is -// simplest to assert on its own. +// Both modules are gone on Prisma Next — the contract emitter replaced the +// generator and the migration graph replaced the down-migration CLI — so +// neither cause can recur. The guard stays because the failure is invisible +// from inside ESM, and the next module to add a top-level await would +// reintroduce it silently. test('the package barrel is require()-able from CommonJS', () => { const out = execFileSync( process.execPath, @@ -102,27 +128,3 @@ test('the barrel exports the same names to ESM and CommonJS', async () => { assert.deepEqual(cjs, esm); }); - -// migrate-down.ts doubles as a CLI. Rejections used to surface through a -// top-level await; they now go through an explicit .catch(), so pin the contract -// that replaced it — a message on stderr and a non-zero exit. -test('the migrate-down CLI still fails with exit code 1', () => { - let status: number | null = null; - let stderr = ''; - - try { - execFileSync( - process.execPath, - [join(ROOT, 'src', 'migrate-down.js'), '--nope'], - { encoding: 'utf8', stdio: 'pipe' }, - ); - } catch (error) { - const failure = error as { status?: number; stderr?: string }; - - status = failure.status ?? null; - stderr = failure.stderr ?? ''; - } - - assert.equal(status, 1); - assert.match(stderr, /Unknown argument: --nope/); -}); diff --git a/test/unit/change-notify.spec.ts b/test/unit/change-notify.spec.ts index ce64a33..24dd3b6 100644 --- a/test/unit/change-notify.spec.ts +++ b/test/unit/change-notify.spec.ts @@ -35,24 +35,25 @@ interface Installed { table: string; } +/** A fake pool of the shape a `pg.Pool` presents, recording what it is given. */ function client(installed: Installed[] = []) { const statements: string[] = []; const queries: { sql: string; values: unknown[] }[] = []; const executor = { - $executeRawUnsafe: async (sql: string) => { + query: async (sql: string, values: readonly unknown[] = []) => { statements.push(sql.replace(/\s+/g, ' ').trim()); - - return 0; - }, - $queryRawUnsafe: async (sql: string, ...values: unknown[]) => { - queries.push({ sql, values }); + // Only the parameterised reads are of interest to the assertions; + // recording the DDL here too would renumber every index. + if (values.length > 0) { + queries.push({ sql, values: [...values] }); + } const schemas = (values[1] ?? []) as string[]; - return installed.filter(one => - schemas.includes(one.schema), - ) as unknown as T; + return { + rows: installed.filter(one => schemas.includes(one.schema)), + }; }, }; @@ -60,8 +61,7 @@ function client(installed: Installed[] = []) { statements, queries, ...executor, - $transaction: async (fn: (tx: typeof executor) => Promise) => - fn(executor), + connect: async () => ({ ...executor, release: () => undefined }), }; } @@ -159,15 +159,19 @@ describe('withoutChangeNotify()', () => { const db = client(); await withoutChangeNotify(db, async tx => { - await tx.$executeRawUnsafe('INSERT INTO "Term" VALUES (1)'); + await tx.query('INSERT INTO "Term" VALUES (1)'); }); - assert.equal( - db.statements[0], - `SET LOCAL "${CHANGE_NOTIFY_SUPPRESS_SETTING}" = 'on'`, + assert.deepEqual( + db.statements.slice(0, 3), + [ + 'BEGIN', + `SET LOCAL "${CHANGE_NOTIFY_SUPPRESS_SETTING}" = 'on'`, + 'INSERT INTO "Term" VALUES (1)', + ], 'the setting is LOCAL, so it reverts with the transaction', ); - assert.match(db.statements[1] ?? '', /INSERT INTO "Term"/); + assert.equal(db.statements.at(-1), 'COMMIT'); }); it('hands the transaction to the caller, not the outer client', async () => { @@ -192,7 +196,7 @@ describe('withoutChangeNotify()', () => { await withoutChangeNotify(db, async () => undefined, 'app.quiet'); - assert.equal(db.statements[0], `SET LOCAL "app.quiet" = 'on'`); + assert.equal(db.statements[1], `SET LOCAL "app.quiet" = 'on'`); }); }); diff --git a/test/unit/derive.spec.ts b/test/unit/derive.spec.ts new file mode 100644 index 0000000..43eafbf --- /dev/null +++ b/test/unit/derive.spec.ts @@ -0,0 +1,110 @@ +/*! + * @imqueue/pg-prisma — package barrel regression tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { deriveDataLayer } from '../../index.js'; + +const contract = { + domain: { + namespaces: { + tenant: { + models: { + Note: { + fields: { + id: {}, + deletedAt: {}, + createdBy: {}, + updatedBy: {}, + deletedBy: {}, + }, + storage: { + table: 'notes', + fields: { + deletedAt: { column: 'deleted_at' }, + createdBy: { column: 'created_by' }, + }, + }, + }, + Ledger: { + fields: { id: {} }, + storage: { table: 'ledger', fields: {} }, + }, + }, + }, + }, + }, +}; + +test('config is keyed by physical table, not by model', () => { + const layer = deriveDataLayer({ contract }); + assert.deepEqual(Object.keys(layer.stamps), ['notes']); +}); + +// The namespace is read from the contract rather than assumed to be `public`. +// Guessing it would leave every map empty, disabling every middleware with no +// error at all. +test('a namespace other than public is still read', () => { + const layer = deriveDataLayer({ contract }); + assert.equal(layer.audit.notes, 'Note'); + assert.equal(layer.audit.ledger, 'Ledger'); +}); + +test('mapped columns are followed, not assumed', () => { + const layer = deriveDataLayer({ contract }); + assert.deepEqual(layer.stamps.notes, { + deletedAt: 'deleted_at', + createdBy: 'created_by', + updatedBy: 'updatedBy', + deletedBy: 'deletedBy', + }); +}); + +test('a model with no stamp columns is absent rather than empty', () => { + const layer = deriveDataLayer({ contract }); + assert.equal('ledger' in layer.stamps, false); +}); + +test('audit excludes by model name and keys by table', () => { + const layer = deriveDataLayer({ contract, auditExclude: ['Ledger'] }); + assert.deepEqual(layer.audit, { notes: 'Note' }); +}); + +test('scope is translated from model and field to table and column', () => { + const layer = deriveDataLayer({ + contract, + scope: { Note: { tenant: ['createdBy'] } }, + }); + assert.deepEqual(layer.scope, { notes: { tenant: ['created_by'] } }); +}); + +// A mistyped model name would otherwise leave that model unscoped, which is +// the direction that leaks rows rather than the one that denies them. +test('a scope naming an unknown model throws', () => { + assert.throws( + () => + deriveDataLayer({ contract, scope: { Notes: { tenant: ['id'] } } }), + /Notes/, + ); +}); diff --git a/test/unit/emit-models.spec.ts b/test/unit/emit-models.spec.ts new file mode 100644 index 0000000..4e39488 --- /dev/null +++ b/test/unit/emit-models.spec.ts @@ -0,0 +1,172 @@ +/*! + * @imqueue/pg-prisma — package barrel regression tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { emitEnums, emitModels } from '../../src/emit/models.js'; +import { parseImportMap } from '../../src/emit/imports.js'; + +const contract = { + domain: { + namespaces: { + public: { + enum: { + Method: { members: [{ value: 'EMAIL' }, { value: 'SMS' }] }, + }, + models: { + User: { + fields: { + id: { type: { codecId: 'pg/text@1' } }, + age: { + nullable: true, + type: { codecId: 'pg/int4@1' }, + }, + kind: { + type: { codecId: 'pg/text@1' }, + valueSet: { entityName: 'Method' }, + }, + methods: { + many: true, + type: { codecId: 'pg/text@1' }, + }, + }, + relations: { + posts: { + cardinality: '1:N', + to: { model: 'Post' }, + }, + owner: { cardinality: 'N:1', to: { model: 'Org' } }, + }, + }, + }, + }, + }, + }, + storage: { + namespaces: { + public: { + entries: { + table: { + User: { + columns: { + methods: { valueSet: { entityName: 'Method' } }, + }, + }, + }, + }, + }, + }, + }, +}; + +const emitted = emitModels({ contract }); +const line = (field: string): string => + emitted + .split('\n\n') + .find(block => block.includes(`${field}?:`)) + ?.trim() ?? ''; + +test('a scalar maps to its TypeScript spelling', () => { + assert.match(line('id'), /@property\('string', true\)\n {4}id\?: string;/); +}); + +test('a nullable field carries the null', () => { + assert.match(line('age'), /age\?: number \| null;/); +}); + +// The union members are single-quoted, so the decorator argument has to be +// double-quoted or it does not parse. +test('an enum becomes a union, quoted so it parses', () => { + assert.match(line('kind'), /@property\("'EMAIL' \| 'SMS'", true\)/); + assert.match(line('kind'), /kind\?: 'EMAIL' \| 'SMS';/); +}); + +// A list names its value set only on the storage column, so both planes have +// to be read; and `'A' | 'B'[]` parses as `'A' | ('B'[])`. +test('an enum list keeps its union, parenthesised', () => { + assert.match( + line('methods'), + /@property\("Array<'EMAIL' \| 'SMS'>", true\)/, + ); + assert.match(line('methods'), /methods\?: \('EMAIL' \| 'SMS'\)\[\];/); +}); + +test('relations become the related class', () => { + assert.match( + line('posts'), + /@property\('Array', true\)\n {4}posts\?: Post\[\];/, + ); + assert.match(line('owner'), /owner\?: Org \| null;/); +}); + +test('by default the runtime import is unchanged', () => { + assert.ok( + emitted.startsWith( + "import { classType, property } from '@imqueue/rpc';", + ), + ); +}); + +test('the runtime import is redirected when asked', () => { + const out = emitModels({ + contract, + imports: parseImportMap('@imqueue/rpc=@my-org/runtime'), + }); + assert.ok( + out.startsWith( + "import { classType, property } from '@my-org/runtime';", + ), + ); +}); + +// A column stays in the contract and in the database; this is only about what +// crosses the RPC boundary. `lms-gate` keeps a raw upstream payload that way. +test('an omitted field is left off the emitted model', () => { + const out = emitModels({ contract, omit: ['User.age'] }); + + assert.ok(!out.includes('age?:')); + assert.ok(out.includes('id?:')); +}); + +test('omitting names one model only', () => { + const out = emitModels({ contract, omit: ['Other.age'] }); + + assert.ok(out.includes('age?:')); +}); + +// Prisma 7 spelled this `STRING @map("string")`. The contract records the +// label alone, so the name is declared beside it and passed in. +test('an enum member takes the name it is given', () => { + const out = emitEnums({ + contract, + enums: { Method: { EMAIL_ADDRESS: 'EMAIL' } }, + }); + + assert.match(out, /EMAIL_ADDRESS: 'EMAIL',/); + assert.match(out, /SMS: 'SMS',/); +}); + +test('an enum with no names given keeps its labels', () => { + assert.match(emitEnums({ contract }), /EMAIL: 'EMAIL',/); +}); diff --git a/test/unit/emit-rpc.spec.ts b/test/unit/emit-rpc.spec.ts new file mode 100644 index 0000000..be21c3b --- /dev/null +++ b/test/unit/emit-rpc.spec.ts @@ -0,0 +1,70 @@ +/*! + * @imqueue/pg-prisma — RPC emitter tests + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { emitRpcTypes } from '../../index.js'; + +const contract = { + domain: { + namespaces: { + public: { + models: { + User: { + fields: { + id: { type: { codecId: 'pg/text@1' } }, + age: { type: { codecId: 'pg/int4@1' } }, + }, + }, + }, + }, + }, + }, + storage: { + namespaces: { + public: { entries: { table: { User: { columns: {} } } } }, + }, + }, +}; + +// A rule says what is allowed about a value, not what the value is. `.int()` +// on `z.string()` is a type error, and a `String` base gave it to every +// validated number in the contract. +test('a validated number is validated as a number', () => { + const out = emitRpcTypes({ + contract: contract as never, + validation: { User: { age: '.int().min(0)' } }, + }); + + assert.match(out, /@validate\(z\.number\(\)\.int\(\)\.min\(0\)/); +}); + +test('a validated string is still a string', () => { + const out = emitRpcTypes({ + contract: contract as never, + validation: { User: { id: '.min(1)' } }, + }); + + assert.match(out, /@validate\(z\.string\(\)\.min\(1\)/); +}); diff --git a/test/unit/imports.spec.ts b/test/unit/imports.spec.ts new file mode 100644 index 0000000..777f0a1 --- /dev/null +++ b/test/unit/imports.spec.ts @@ -0,0 +1,95 @@ +/*! + * @imqueue/pg-prisma — package barrel regression tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { emitImports, parseImportMap } from '../../src/emit/imports.js'; + +const BASE = '@my-org/runtime'; +const ALL = parseImportMap( + `zod=${BASE}, @imqueue/rpc=${BASE}, @imqueue/validation=${BASE}`, +); + +test('with no map, each runtime keeps its own specifier', () => { + assert.equal( + emitImports(['rpc', 'zod']), + "import { classType, property } from '@imqueue/rpc';\n" + + "import { z } from 'zod';\n", + ); +}); + +// Redirecting several runtimes at one package is the point of the option; +// emitting one import per original specifier would put three imports of the +// same module in a file, which lints as a duplicate. +test('runtimes redirected to one package become one import', () => { + assert.equal( + emitImports(['rpc', 'validation', 'zod'], ALL), + `import { classType, property, validatable, validate, z } from '${BASE}';\n`, + ); +}); + +test('a partial map merges only what it redirects', () => { + assert.equal( + emitImports(['rpc', 'zod'], parseImportMap(`zod=${BASE}`)), + "import { classType, property } from '@imqueue/rpc';\n" + + `import { z } from '${BASE}';\n`, + ); +}); + +test('symbols and specifiers are sorted, so output is stable', () => { + assert.equal( + emitImports(['zod', 'validation', 'rpc'], ALL), + emitImports(['rpc', 'validation', 'zod'], ALL), + ); +}); + +test('naming a runtime twice does not duplicate its symbols', () => { + assert.equal( + emitImports(['rpc', 'rpc']), + "import { classType, property } from '@imqueue/rpc';\n", + ); +}); + +test('no runtimes emits nothing', () => { + assert.equal(emitImports([]), ''); +}); + +test('an empty or absent spec is no redirection', () => { + assert.deepEqual(parseImportMap(), {}); + assert.deepEqual(parseImportMap(' '), {}); +}); + +// A typo here would otherwise leave the generated files pointing at the +// original package while the consumer believes they were redirected — and the +// symptom is a second decorator registry that silently validates nothing. +test('redirecting a module this generator never emits throws', () => { + assert.throws( + () => parseImportMap(`@imqueue/core=${BASE}`), + /@imqueue\/core/, + ); +}); + +test('a malformed entry throws', () => { + assert.throws(() => parseImportMap('zod'), /not "="/); +}); diff --git a/test/unit/iso-dates.spec.ts b/test/unit/iso-dates.spec.ts index 12234b8..300da3b 100644 --- a/test/unit/iso-dates.spec.ts +++ b/test/unit/iso-dates.spec.ts @@ -1,5 +1,5 @@ /*! - * toIsoDates() unit tests + * @imqueue/pg-prisma — ISO date conversion tests * * I'm Queue Software Project * Copyright (C) 2025 imqueue.com @@ -21,75 +21,94 @@ * purchase a proprietary commercial license. Please contact us at * to get commercial licensing options. */ + import assert from 'node:assert/strict'; import { test } from 'node:test'; -import { toIsoDates } from '../../index.js'; +import { isoDates } from '../../index.js'; -test('a date becomes its ISO string', () => { - assert.equal( - toIsoDates(new Date('2026-08-16T11:35:59.323Z')), - '2026-08-16T11:35:59.323Z', +const middleware = isoDates({ columns: new Set(['createdAt', 'expiresAt']) }); +const convert = async (row: object): Promise => { + await middleware.onRow?.( + row as Record, + undefined as never, + undefined as never, ); -}); -test('a date nested in a row is replaced where it sits', () => { + return row; +}; + +test('an offset timestamp becomes a canonical UTC instant', async () => { assert.deepEqual( - toIsoDates({ id: 'a', at: new Date('2026-01-02T03:04:05.000Z') }), - { id: 'a', at: '2026-01-02T03:04:05.000Z' }, + await convert({ createdAt: '2026-08-14 09:30:00.123+00' }), + { + createdAt: '2026-08-14T09:30:00.123Z', + }, ); }); -test('an array of rows keeps its order and its shape', () => { +// Postgres prints `09:30:00.500` as `09:30:00.5` and a whole second with no +// fraction at all, so the width varies with the value. Every boundary +// downstream wants three digits, and a lexicographic comparison of two such +// strings wants them too. +test('a trimmed fraction is padded back to milliseconds', async () => { assert.deepEqual( - toIsoDates([{ at: new Date(0) }, { at: new Date(1000) }]), - [ - { at: '1970-01-01T00:00:00.000Z' }, - { at: '1970-01-01T00:00:01.000Z' }, - ], + await convert({ + createdAt: '2026-08-14 09:30:00.5+00', + expiresAt: '2026-08-14 09:30:00+00', + }), + { + createdAt: '2026-08-14T09:30:00.500Z', + expiresAt: '2026-08-14T09:30:00.000Z', + }, ); }); -/* - * The regression. Walking an object with `Object.entries` turns a buffer into - * `{ "0": 137, "1": 80, … }` — one key per byte, roughly fifty times the size, - * and no longer something `Buffer.isBuffer` recognises. A `Bytes` column read - * through this extension arrived unusable, and the failure looked like the row - * not existing at all. - */ -test('a buffer comes back as the same buffer', () => { - const bytes = Buffer.from([0x89, 0x50, 0x4e, 0x47]); - const out = toIsoDates(bytes); - - assert.equal(out, bytes); - assert.ok(Buffer.isBuffer(out)); +// The value is the same instant whichever zone the server prints it in, so +// the conversion has to do the arithmetic rather than swap the suffix. +test('a non-UTC offset is normalised to UTC', async () => { + assert.deepEqual( + await convert({ createdAt: '2026-08-14 11:30:00.123+02' }), + { + createdAt: '2026-08-14T09:30:00.123Z', + }, + ); }); -test('a buffer inside a row survives the walk', () => { - const bytes = Buffer.from('a logo, more or less'); - const row = toIsoDates({ - name: 'mark.svg', - data: bytes, - at: new Date('2026-08-16T00:00:00.000Z'), - }) as { name: string; data: unknown; at: string }; +// The column type this fallback exists for: without the `Z`, `new Date` reads +// the value as local time and every instant shifts by the host's offset. +test('a timestamp with no zone is read as UTC, not as local time', async () => { + assert.deepEqual(await convert({ createdAt: '2026-08-14 09:30:00.123' }), { + createdAt: '2026-08-14T09:30:00.123Z', + }); +}); - assert.ok(Buffer.isBuffer(row.data)); - assert.equal(row.data, bytes); - assert.equal(row.at, '2026-08-16T00:00:00.000Z'); +test('an included relation is converted too', async () => { + assert.deepEqual( + await convert({ + createdAt: '2026-08-14 09:30:00+00', + sessions: [{ expiresAt: '2026-08-14 10:00:00+00' }], + }), + { + createdAt: '2026-08-14T09:30:00.000Z', + sessions: [{ expiresAt: '2026-08-14T10:00:00.000Z' }], + }, + ); }); -test('every typed array is left alone, not only Buffer', () => { - for (const view of [ - new Uint8Array([1, 2]), - new Int16Array([3]), - new Float64Array([4.5]), - new DataView(new ArrayBuffer(2)), - ]) { - assert.equal(toIsoDates(view), view, view.constructor.name); - } +test('a column that is not a date column is left alone', async () => { + assert.deepEqual(await convert({ note: '2026-08-14 09:30:00+00' }), { + note: '2026-08-14 09:30:00+00', + }); }); -test('a bare ArrayBuffer is left alone too', () => { - const buffer = new ArrayBuffer(4); +// The name matching alone is not enough: a text column named like a date one +// holds whatever a caller put in it. +test('a date column holding something else is left alone', async () => { + assert.deepEqual(await convert({ createdAt: 'yesterday' }), { + createdAt: 'yesterday', + }); +}); - assert.equal(toIsoDates(buffer), buffer); +test('a null date is left alone', async () => { + assert.deepEqual(await convert({ createdAt: null }), { createdAt: null }); }); diff --git a/test/unit/query.spec.ts b/test/unit/query.spec.ts new file mode 100644 index 0000000..3ef5145 --- /dev/null +++ b/test/unit/query.spec.ts @@ -0,0 +1,127 @@ +/*! + * @imqueue/pg-prisma — query translation tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { toPredicate, toProjection } from '../../index.js'; + +const relations = { + UserPermission: { + permission: { + target: 'Permission', + isList: false, + localFields: ['permissionId'], + targetFields: ['id'], + }, + portfolios: { + target: 'UserPermissionPortfolio', + isList: true, + localFields: ['id'], + targetFields: ['userPermissionId'], + }, + }, + UserPermissionPortfolio: {}, + Permission: {}, +}; + +test('toProjection keeps scalars apart from relations', () => { + assert.deepEqual( + toProjection(relations, 'UserPermission', { + id: true, + userId: true, + }), + { fields: ['id', 'userId'], includes: [] }, + ); +}); + +test('toProjection treats a relation named true as a whole include', () => { + assert.deepEqual( + toProjection(relations, 'UserPermission', { permission: true }), + { + fields: [], + includes: [{ name: 'permission', projection: undefined }], + }, + ); +}); + +test('toProjection projects within an included relation', () => { + assert.deepEqual( + toProjection(relations, 'UserPermission', { + portfolios: { portfolioId: true }, + }), + { + fields: [], + includes: [ + { + name: 'portfolios', + projection: { fields: ['portfolioId'], includes: [] }, + }, + ], + }, + ); +}); + +test('toProjection mixes scalars and relations in one select', () => { + assert.deepEqual( + toProjection(relations, 'UserPermission', { + id: true, + permission: true, + portfolios: { portfolioId: true }, + }), + { + fields: ['id'], + includes: [ + { name: 'permission', projection: undefined }, + { + name: 'portfolios', + projection: { fields: ['portfolioId'], includes: [] }, + }, + ], + }, + ); +}); + +test('toProjection is undefined when nothing is selected', () => { + assert.equal( + toProjection(relations, 'UserPermission', undefined), + undefined, + ); + assert.equal(toProjection(relations, 'UserPermission', {}), undefined); +}); + +// Prisma 7 spelled it the same way, and dropping it turns a search that finds +// `Kyiv` for `kyiv` into one that answers "no such thing". +test('mode: insensitive reaches for ILIKE, and mode is not an operator', () => { + const asked: string[] = []; + const field = { + like: () => asked.push('like'), + ilike: () => asked.push('ilike'), + }; + + toPredicate({}, 'User', { + email: { contains: 'Example', mode: 'insensitive' }, + })?.({ email: field } as never); + + assert.deepEqual(asked, ['ilike']); +}); diff --git a/test/unit/sql-template.spec.ts b/test/unit/sql-template.spec.ts new file mode 100644 index 0000000..ab679b5 --- /dev/null +++ b/test/unit/sql-template.spec.ts @@ -0,0 +1,80 @@ +/*! + * @imqueue/pg-prisma — SQL fragment composition tests + * + * I'm Queue Software Project + * Copyright (C) 2026 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { EMPTY, join, raw, sql, toQuery } from '../../index.js'; + +test('an interpolated value is bound, not written in', () => { + const query = sql`SELECT * FROM "User" WHERE id = ${'abc'}`; + + assert.equal(query.text, 'SELECT * FROM "User" WHERE id = $1'); + assert.deepEqual(query.values, ['abc']); +}); + +// The whole point of composing: a fragment binds `$1` on its own, and has to +// bind whatever comes next once it is spliced into something else. +test('a spliced fragment has its placeholders renumbered', () => { + const filter = sql`"key" = ${'k'}`; + const query = sql`SELECT ${1} FROM t WHERE ${filter} AND "id" = ${'i'}`; + + assert.equal(query.text, 'SELECT $1 FROM t WHERE "key" = $2 AND "id" = $3'); + assert.deepEqual(query.values, [1, 'k', 'i']); +}); + +test('join renumbers each part in turn', () => { + const query = sql`WHERE ${join( + [sql`a = ${1}`, sql`b = ${2}`, sql`c = ${3}`], + ' OR ', + )}`; + + assert.equal(query.text, 'WHERE a = $1 OR b = $2 OR c = $3'); + assert.deepEqual(query.values, [1, 2, 3]); +}); + +test('join of nothing is empty', () => { + assert.deepEqual(join([]), EMPTY); +}); + +test('raw text binds nothing', () => { + const query = sql`ORDER BY ${raw('"key" DESC')}, id = ${7}`; + + assert.equal(query.text, 'ORDER BY "key" DESC, id = $1'); + assert.deepEqual(query.values, [7]); +}); + +// A null is a value like any other; it must not become the text `null`. +test('null and undefined are bound', () => { + const query = sql`a = ${null} AND b = ${undefined}`; + + assert.equal(query.text, 'a = $1 AND b = $2'); + assert.deepEqual(query.values, [null, undefined]); +}); + +test('toQuery gives the pair a driver takes', () => { + const [text, values] = toQuery(sql`SELECT ${1}`); + + assert.equal(text, 'SELECT $1'); + assert.deepEqual(values, [1]); +}); diff --git a/test/unit/stamp.spec.ts b/test/unit/stamp.spec.ts new file mode 100644 index 0000000..a01eced --- /dev/null +++ b/test/unit/stamp.spec.ts @@ -0,0 +1,168 @@ +/*! + * @imqueue/pg-prisma — package barrel regression tests + * + * I'm Queue Software Project + * Copyright (C) 2025 imqueue.com + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * If you want to use this code in a closed source (commercial) project, you can + * purchase a proprietary commercial license. Please contact us at + * to get commercial licensing options. + */ + +import assert from 'node:assert/strict'; +import { test } from 'node:test'; +import { + ColumnRef, + DeleteAst, + DerivedTableSource, + InsertAst, + SelectAst, + TableSource, + UpdateAst, +} from '@prisma/orm-postgres/relational-core/ast'; +import { stamp } from '../../index.js'; + +/** A rewritten node, read structurally: these tests assert on shape. */ +type Node = Record; // oxlint-disable-line typescript/no-explicit-any + +const TABLES = { + Session: { + deletedAt: 'deletedAt', + createdBy: 'createdBy', + updatedBy: 'updatedBy', + deletedBy: 'deletedBy', + }, +}; + +const middleware = stamp({ tables: TABLES, getActorId: () => 'u-1' }); + +const run = async (ast: unknown): Promise => + (await middleware.beforeCompile?.({ ast } as never, {} as never))?.ast as + | Node + | undefined; + +/** Column names a predicate tree touches, in order. */ +const columns = (node: Node | undefined): string[] => { + if (!node) { + return []; + } + if (node.kind === 'and' || node.kind === 'or') { + return (node.exprs ?? []).flatMap(columns); + } + if (node.kind === 'binary') { + return columns(node.left); + } + if (node.kind === 'null-check') { + return columns(node.expr); + } + + return node.column ? [node.column] : []; +}; + +const projection = [ + { + kind: 'projection-item', + alias: 'id', + expr: ColumnRef.of('Session', 'id'), + }, +] as never; + +const selectFrom = (source: unknown): unknown => + SelectAst.from(source as never).withProjection(projection); + +test('a plain select gains the soft-delete filter', async () => { + const out = await run(selectFrom(TableSource.named('Session'))); + assert.deepEqual(columns(out?.where), ['deletedAt']); +}); + +test('a select over an unlisted table is untouched', async () => { + assert.equal( + await run(selectFrom(TableSource.named('Portfolio'))), + undefined, + ); +}); + +// The filter has to reach every select in the statement, not only the root. +// Prisma Next compiles a relation read into one statement whose related rows +// come from a nested select, so filtering the root alone returned soft-deleted +// rows through any include, silently and with nothing logged. +test('a select nested inside a derived source is filtered too', async () => { + const inner = selectFrom(TableSource.named('Session')); + const out = await run( + selectFrom(DerivedTableSource.as('t', inner as never)), + ); + assert.deepEqual(columns(out?.from.query.where), ['deletedAt']); +}); + +// Qualifying by the table name when the source carries an alias produces SQL +// Postgres rejects outright. +test('the predicate is qualified by the alias, not the table name', async () => { + const out = await run(selectFrom(TableSource.named('Session', 's'))); + assert.equal(out?.where.expr.table, 's'); +}); + +test('a delete becomes an update that stamps, keeping its returning', async () => { + const out = await run( + DeleteAst.from(TableSource.named('Session')) + .withWhere(ColumnRef.of('Session', 'id') as never) + .withReturning(projection), + ); + assert.equal(out?.kind, 'update'); + assert.deepEqual(Object.keys(out?.set).sort(), [ + 'deletedAt', + 'deletedBy', + 'updatedBy', + ]); + assert.ok(out?.returning, 'the returning clause must survive the rewrite'); + assert.deepEqual(columns(out?.where), ['id', 'deletedAt']); +}); + +test('an insert is stamped with the actor', async () => { + const out = await run( + InsertAst.into(TableSource.named('Session')).withRows([ + { id: ColumnRef.of('Session', 'id') } as never, + ]), + ); + assert.deepEqual(Object.keys(out?.rows[0]).sort(), [ + 'createdBy', + 'id', + 'updatedBy', + ]); +}); + +// Setting the soft-delete column to null restores a row; stamping `deletedBy` +// there would record whoever brought it back as the one who deleted it. +test('restoring a row does not stamp deletedBy', async () => { + const out = await run( + UpdateAst.table(TableSource.named('Session')).withSet({ + deletedAt: { kind: 'param-ref', value: null } as never, + }), + ); + assert.deepEqual(Object.keys(out?.set).sort(), ['deletedAt', 'updatedBy']); +}); + +test('soft-deleting through an update does stamp deletedBy', async () => { + const out = await run( + UpdateAst.table(TableSource.named('Session')).withSet({ + deletedAt: { kind: 'param-ref', value: '2026-01-01' } as never, + }), + ); + assert.deepEqual(Object.keys(out?.set).sort(), [ + 'deletedAt', + 'deletedBy', + 'updatedBy', + ]); +});