From 53ac6428417a5ab6f929d3c7cd0dc8e939ff0e0f Mon Sep 17 00:00:00 2001 From: Jeremy Daly Date: Tue, 16 Jun 2026 11:51:50 -0400 Subject: [PATCH 1/3] feat(compat): add Prisma 7 driver adapter for PostgreSQL and MySQL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds data-api-client/compat/prisma — a drop-in Prisma 7 driver adapter so Prisma Client runs over the Aurora RDS Data API for both engines, via createPrismaPgAdapter / createPrismaMySQLAdapter. The adapter is thin: it holds a core init() client and reuses its query send/retry/result-formatting and beginTransaction/commit/rollback methods. New code is confined to: - prisma-types.ts: columnMetadata.typeName -> Prisma ColumnType mapping (pg + mysql) - prisma-params.ts: $1/? placeholder rewrite, argType -> Data API param building, and the Postgres array ARRAY[...] rewrite (the Data API can't bind array params) - prisma.ts: adapter/transaction/factory classes - errors.ts: mapToPrismaError -> Prisma DriverAdapterError (lazy-requires the optional peer dep @prisma/driver-adapter-utils) Two small core fixes surfaced and are covered by regression tests: - results.ts: nest array-column cells instead of flattening them under hydrateColumnNames:false - params.ts: honor an explicit param-level typeHint Transactions map to the native Data API lifecycle; nested savepoints are rejected. Migrations are documented, not implemented (driver adapters cover the runtime path only — point Prisma's migration url at the Aurora endpoint, like Neon/PlanetScale, or generate SQL offline and apply it over the adapter). --- .gitignore | 7 + README.md | 46 +- integration-tests/prisma-mysql.int.test.ts | 94 + integration-tests/prisma-pg.int.test.ts | 112 ++ integration-tests/prisma/schema-mysql.prisma | 28 + integration-tests/prisma/schema-pg.prisma | 29 + package-lock.json | 1770 ++++++++++++++++-- package.json | 18 + src/compat/errors.test.ts | 24 + src/compat/errors.ts | 38 + src/compat/index.ts | 1 + src/compat/prisma-params.test.ts | 88 + src/compat/prisma-params.ts | 141 ++ src/compat/prisma-types.test.ts | 46 + src/compat/prisma-types.ts | 143 ++ src/compat/prisma.test.ts | 53 + src/compat/prisma.ts | 156 ++ src/params.test.ts | 41 + src/params.ts | 25 +- src/results.test.ts | 27 + src/results.ts | 6 +- src/types.ts | 1 + 22 files changed, 2772 insertions(+), 122 deletions(-) create mode 100644 integration-tests/prisma-mysql.int.test.ts create mode 100644 integration-tests/prisma-pg.int.test.ts create mode 100644 integration-tests/prisma/schema-mysql.prisma create mode 100644 integration-tests/prisma/schema-pg.prisma create mode 100644 src/compat/errors.test.ts create mode 100644 src/compat/prisma-params.test.ts create mode 100644 src/compat/prisma-params.ts create mode 100644 src/compat/prisma-types.test.ts create mode 100644 src/compat/prisma-types.ts create mode 100644 src/compat/prisma.test.ts create mode 100644 src/compat/prisma.ts diff --git a/.gitignore b/.gitignore index 4c0212c..09a5a91 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,13 @@ dump.rdb .vscode .idea +# Prisma generated clients (integration tests) +integration-tests/prisma/generated-*/ + +# Local-only development artifacts (kept out of the repo) +/docs/ +spikes/ + # AI coding assistant instruction files CLAUDE.md WARP.md diff --git a/README.md b/README.md index 3268247..38bafb4 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ > **Using v1.x?** See [README_v1.md](README_v1.md) for v1.x documentation. -The **Data API Client** is a lightweight wrapper that makes working with the Amazon Aurora Serverless Data API incredibly easy. The Data API makes you annotate every field value with its type, both going in and coming back, which gets old fast. This library handles that for you, mapping native JavaScript types to the Data API's format and back automatically. It's basically a [DocumentClient](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html) for the Data API, with clean **transactions** and **automatic retry logic** for scale-to-zero clusters built in. It also gives you drop-in **compatibility layers** for mysql2 and pg, so you can plug the Data API into your favorite ORMs and query builders. Point **Drizzle**, **Kysely**, or **Knex** at it and keep writing the queries you already know. +The **Data API Client** is a lightweight wrapper that makes working with the Amazon Aurora Serverless Data API incredibly easy. The Data API makes you annotate every field value with its type, both going in and coming back, which gets old fast. This library handles that for you, mapping native JavaScript types to the Data API's format and back automatically. It's basically a [DocumentClient](https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html) for the Data API, with clean **transactions** and **automatic retry logic** for scale-to-zero clusters built in. It also gives you drop-in **compatibility layers** for mysql2 and pg, so you can plug the Data API into your favorite ORMs and query builders. Point **Drizzle**, **Kysely**, **Knex**, or **Prisma** at it and keep writing the queries you already know. For more information about the Aurora Serverless Data API, you can review the [official documentation](https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/data-api.html) or read [Aurora Serverless Data API: An (updated) First Look](https://www.jeremydaly.com/aurora-serverless-data-api-a-first-look/) for some more insights on performance. @@ -913,10 +913,52 @@ selects/`distinct`/`pluck`/`first`, the `where` family (`whereIn`, `whereNull`, `returning` (PostgreSQL), `increment`/`decrement`, and `onConflict().merge()` upserts. Streaming via `.stream()` is not supported, since the Data API has no cursor API. +#### Prisma + +A drop-in Prisma 7 driver adapter so Prisma Client runs over the Aurora Data API — for both PostgreSQL and MySQL. + +**Install:** + +You need `@prisma/client` and `prisma` installed in your project. The adapter uses `@prisma/driver-adapter-utils`, which is an optional peer dependency of `data-api-client` — install it alongside: + +```bash +npm install @prisma/client prisma @prisma/driver-adapter-utils +``` + +**Usage (PostgreSQL):** + +```typescript +import { PrismaClient } from '@prisma/client' +import { createPrismaPgAdapter } from 'data-api-client/compat/prisma' + +const adapter = createPrismaPgAdapter({ + secretArn: process.env.SECRET_ARN, + resourceArn: process.env.RESOURCE_ARN, + database: 'mydb' +}) + +const prisma = new PrismaClient({ adapter }) +``` + +Use `createPrismaMySQLAdapter` for MySQL — it takes the same config shape. + +**Limitations:** + +- **Nested transactions** are not supported. They require SQL `SAVEPOINT`s, which the RDS Data API has no primitive for. Top-level interactive transactions (via `prisma.$transaction()`) work correctly. +- **Array parameters**: the Data API cannot bind array parameters directly. The Prisma adapter handles this for PostgreSQL native array columns by rewriting array values to `ARRAY[...]` constructor syntax automatically. The underlying Data API constraint remains — the rewrite is done by the adapter before the query reaches the wire. + +**Migrations:** + +Prisma driver adapters cover the **runtime query path only**. Prisma's Schema Engine (`prisma migrate`, `db push`, `db pull`) requires a direct database connection URL, which the Data API does not provide. This is the same split every serverless driver has — Neon uses a `directUrl`, PlanetScale uses a connection string — driver adapters are for runtime; schema operations need a real connection. + +The recommended approach: Aurora Serverless v2 also exposes a standard PostgreSQL/MySQL cluster endpoint. Point Prisma's migration `url` in `prisma.config.ts` at that direct Aurora endpoint — exactly like Neon's `directUrl` pattern — and use the Data API adapter at runtime only. This requires network access to the cluster endpoint (in-VPC CI, a bastion/tunnel/VPN, or a publicly accessible dev cluster). + +If direct endpoint access is not available: generate migration SQL offline with `prisma migrate diff` (no live database connection needed — use schema-to-schema diffs to avoid the shadow-database requirement) and apply it over the Data API adapter. + **Benefits of Compatibility Layers:** - **Zero code changes** when migrating from mysql2 or pg -- **Full ORM support** (Drizzle, Kysely, Knex) +- **Full ORM support** (Drizzle, Kysely, Knex, Prisma) - **Automatic retry logic** for cluster wake-ups - **Connection pooling simulation** (getConnection, release) - **Both Promise and callback APIs** supported diff --git a/integration-tests/prisma-mysql.int.test.ts b/integration-tests/prisma-mysql.int.test.ts new file mode 100644 index 0000000..eb2f3a5 --- /dev/null +++ b/integration-tests/prisma-mysql.int.test.ts @@ -0,0 +1,94 @@ +/** + * Prisma Client over the Data API (MySQL) integration tests. + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { createPrismaMySQLAdapter } from '../src/compat/prisma' +import { loadConfig, type IntegrationTestConfig } from './setup' +import { PrismaClient } from './prisma/generated-mysql' + +const DDL = ` +DROP TABLE IF EXISTS prisma_it_post; +DROP TABLE IF EXISTS prisma_it_user; +CREATE TABLE prisma_it_user ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(191) UNIQUE NOT NULL, + name VARCHAR(191), + age INT, + active TINYINT(1) NOT NULL DEFAULT 1 +); +CREATE TABLE prisma_it_post ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(191) NOT NULL, + authorId INT NOT NULL, + CONSTRAINT fk_author FOREIGN KEY (authorId) REFERENCES prisma_it_user(id) +)` + +describe('Prisma + Data API (MySQL)', () => { + let config: IntegrationTestConfig + let prisma: PrismaClient + let factoryConfig: { resourceArn: string; secretArn: string; database: string } + + beforeAll(async () => { + config = loadConfig('mysql') + factoryConfig = { resourceArn: config.resourceArn, secretArn: config.secretArn, database: config.database } + const factory = createPrismaMySQLAdapter(factoryConfig) + const setup = await factory.connect() + await setup.executeScript(DDL) + prisma = new PrismaClient({ adapter: factory } as any) + }, 120000) + + afterAll(async () => { + if (prisma) { + const factory = createPrismaMySQLAdapter(factoryConfig) + const td = await factory.connect() + await td.executeScript('DROP TABLE IF EXISTS prisma_it_post; DROP TABLE IF EXISTS prisma_it_user;') + await prisma.$disconnect() + } + }, 60000) + + test('create returns autoincrement id (lastInsertId)', async () => { + const u = await prisma.prismaUser.create({ data: { email: 'a@x.com', name: 'Alice', age: 30 } }) + expect(u.id).toBeGreaterThan(0) + expect(u.active).toBe(true) + }) + + test('findUnique + update', async () => { + await prisma.prismaUser.update({ where: { email: 'a@x.com' }, data: { age: 31 } }) + const found = await prisma.prismaUser.findUnique({ where: { email: 'a@x.com' } }) + expect(found?.age).toBe(31) + }) + + test('where id IN [...]', async () => { + await prisma.prismaUser.create({ data: { email: 'b@x.com', name: 'Bob' } }) + const users = await prisma.prismaUser.findMany({ where: { email: { in: ['a@x.com', 'b@x.com'] } } }) + expect(users.length).toBe(2) + }) + + test('relation create + include', async () => { + const bob = await prisma.prismaUser.findUniqueOrThrow({ where: { email: 'b@x.com' } }) + await prisma.prismaPost.create({ data: { title: 'Hello', authorId: bob.id } }) + const posts = await prisma.prismaPost.findMany({ include: { author: true } }) + expect(posts[0].author.name).toBe('Bob') + }) + + test('interactive transaction commit + rollback', async () => { + await prisma.$transaction(async (tx) => { + await tx.prismaUser.create({ data: { email: 'c@x.com', name: 'Carol' } }) + }) + expect(await prisma.prismaUser.findUnique({ where: { email: 'c@x.com' } })).not.toBeNull() + + await expect( + prisma.$transaction(async (tx) => { + await tx.prismaUser.create({ data: { email: 'd@x.com', name: 'Dave' } }) + throw new Error('boom') + }) + ).rejects.toThrow() + expect(await prisma.prismaUser.findUnique({ where: { email: 'd@x.com' } })).toBeNull() + }) + + test('unique violation surfaces as a Prisma known error', async () => { + await expect(prisma.prismaUser.create({ data: { email: 'a@x.com', name: 'dup' } })).rejects.toMatchObject({ + code: 'P2002' + }) + }) +}) diff --git a/integration-tests/prisma-pg.int.test.ts b/integration-tests/prisma-pg.int.test.ts new file mode 100644 index 0000000..3c6e849 --- /dev/null +++ b/integration-tests/prisma-pg.int.test.ts @@ -0,0 +1,112 @@ +/** + * Prisma Client over the Data API (PostgreSQL) integration tests. + * Tables are created via raw DDL (Data API can't run prisma migrate). + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { createPrismaPgAdapter } from '../src/compat/prisma' +import { loadConfig, type IntegrationTestConfig } from './setup' +import { PrismaClient } from './prisma/generated-pg' + +const DDL = ` +DROP TABLE IF EXISTS prisma_it_post; +DROP TABLE IF EXISTS prisma_it_user; +CREATE TABLE prisma_it_user ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + age INT, + tags TEXT[] NOT NULL DEFAULT '{}', + meta JSONB +); +CREATE TABLE prisma_it_post ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + "authorId" INT NOT NULL REFERENCES prisma_it_user(id) +)` + +describe('Prisma + Data API (PostgreSQL)', () => { + let config: IntegrationTestConfig + let prisma: PrismaClient + + beforeAll(async () => { + config = loadConfig('pg') + const factory = createPrismaPgAdapter({ + resourceArn: config.resourceArn, + secretArn: config.secretArn, + database: config.database + }) + const setup = await factory.connect() + await setup.executeScript(DDL) + prisma = new PrismaClient({ adapter: factory } as any) + }, 120000) + + afterAll(async () => { + if (prisma) { + const factory = createPrismaPgAdapter({ + resourceArn: config.resourceArn, + secretArn: config.secretArn, + database: config.database + }) + const td = await factory.connect() + await td.executeScript('DROP TABLE IF EXISTS prisma_it_post; DROP TABLE IF EXISTS prisma_it_user;') + await prisma.$disconnect() + } + }, 60000) + + test('scalar create + findUnique', async () => { + const u = await prisma.prismaUser.create({ data: { email: 'a@x.com', name: 'Alice', age: 30, meta: { role: 'admin' } } }) + expect(u.id).toBeGreaterThan(0) + const found = await prisma.prismaUser.findUnique({ where: { email: 'a@x.com' } }) + expect(found?.name).toBe('Alice') + expect(found?.meta).toEqual({ role: 'admin' }) + }) + + test('array column write + read', async () => { + const u = await prisma.prismaUser.create({ data: { email: 'b@x.com', name: 'Bob', tags: ['x', 'y', 'z'] } }) + const found = await prisma.prismaUser.findUnique({ where: { id: u.id } }) + expect(found?.tags).toEqual(['x', 'y', 'z']) + }) + + test('where id IN [...]', async () => { + const users = await prisma.prismaUser.findMany({ where: { email: { in: ['a@x.com', 'b@x.com'] } } }) + expect(users.length).toBe(2) + }) + + test('array filter hasSome', async () => { + const users = await prisma.prismaUser.findMany({ where: { tags: { hasSome: ['x', 'nope'] } } }) + expect(users.length).toBe(1) + }) + + test('relation create + include', async () => { + const bob = await prisma.prismaUser.findUniqueOrThrow({ where: { email: 'b@x.com' } }) + await prisma.prismaPost.create({ data: { title: 'Hello', authorId: bob.id } }) + const posts = await prisma.prismaPost.findMany({ include: { author: true } }) + expect(posts[0].author.name).toBe('Bob') + }) + + test('interactive transaction commit', async () => { + await prisma.$transaction(async (tx) => { + await tx.prismaUser.create({ data: { email: 'c@x.com', name: 'Carol' } }) + await tx.prismaUser.update({ where: { email: 'c@x.com' }, data: { age: 40 } }) + }) + const c = await prisma.prismaUser.findUnique({ where: { email: 'c@x.com' } }) + expect(c?.age).toBe(40) + }) + + test('interactive transaction rollback', async () => { + await expect( + prisma.$transaction(async (tx) => { + await tx.prismaUser.create({ data: { email: 'd@x.com', name: 'Dave' } }) + throw new Error('boom') + }) + ).rejects.toThrow() + const d = await prisma.prismaUser.findUnique({ where: { email: 'd@x.com' } }) + expect(d).toBeNull() + }) + + test('unique violation surfaces as a Prisma known error', async () => { + await expect(prisma.prismaUser.create({ data: { email: 'a@x.com', name: 'dup' } })).rejects.toMatchObject({ + code: 'P2002' + }) + }) +}) diff --git a/integration-tests/prisma/schema-mysql.prisma b/integration-tests/prisma/schema-mysql.prisma new file mode 100644 index 0000000..ec6f3fc --- /dev/null +++ b/integration-tests/prisma/schema-mysql.prisma @@ -0,0 +1,28 @@ +generator client { + provider = "prisma-client-js" + output = "./generated-mysql" +} + +datasource db { + provider = "mysql" +} + +model PrismaUser { + id Int @id @default(autoincrement()) + email String @unique + name String? + age Int? + active Boolean @default(true) + posts PrismaPost[] + + @@map("prisma_it_user") +} + +model PrismaPost { + id Int @id @default(autoincrement()) + title String + authorId Int + author PrismaUser @relation(fields: [authorId], references: [id]) + + @@map("prisma_it_post") +} diff --git a/integration-tests/prisma/schema-pg.prisma b/integration-tests/prisma/schema-pg.prisma new file mode 100644 index 0000000..e5b3324 --- /dev/null +++ b/integration-tests/prisma/schema-pg.prisma @@ -0,0 +1,29 @@ +generator client { + provider = "prisma-client-js" + output = "./generated-pg" +} + +datasource db { + provider = "postgresql" +} + +model PrismaUser { + id Int @id @default(autoincrement()) + email String @unique + name String? + age Int? + tags String[] + meta Json? + posts PrismaPost[] + + @@map("prisma_it_user") +} + +model PrismaPost { + id Int @id @default(autoincrement()) + title String + authorId Int + author PrismaUser @relation(fields: [authorId], references: [id]) + + @@map("prisma_it_post") +} diff --git a/package-lock.json b/package-lock.json index adb7799..c3fadd2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,8 @@ }, "devDependencies": { "@aws-sdk/client-rds-data": "^3.1048.0", + "@prisma/client": "^7.8.0", + "@prisma/driver-adapter-utils": "^7.8.0", "@types/node": "^24.6.2", "@types/pg": "^8.15.5", "@types/sqlstring": "^2.3.2", @@ -26,6 +28,7 @@ "kysely": "^0.28.7", "pg": "^8.16.3", "prettier": "^2.6.2", + "prisma": "^7.8.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.1.8" @@ -35,12 +38,16 @@ }, "peerDependencies": { "@aws-sdk/client-rds-data": "^3.1048.0", + "@prisma/driver-adapter-utils": "^7.0.0", "knex": "^3.0.0" }, "peerDependenciesMeta": { "@aws-sdk/client-rds-data": { "optional": true }, + "@prisma/driver-adapter-utils": { + "optional": true + }, "knex": { "optional": true } @@ -400,6 +407,36 @@ "node": ">=18.0.0" } }, + "node_modules/@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "pglite-server": "dist/scripts/server.js" + }, + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, + "node_modules/@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "dev": true, + "license": "Apache-2.0", + "peerDependencies": { + "@electric-sql/pglite": "0.4.1" + } + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -895,6 +932,19 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -914,6 +964,13 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", @@ -994,6 +1051,386 @@ "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", "dev": true }, + "node_modules/@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/client-runtime-utils": "7.8.0" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + } + }, + "node_modules/@prisma/dev/node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + } + }, + "node_modules/@prisma/fetch-engine/node_modules/@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.8.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "7.2.0" + } + }, + "node_modules/@prisma/get-platform/node_modules/@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "engines": { + "bun": ">=1.3.6", + "node": ">=22.0.0" + } + }, + "node_modules/@prisma/streams-local/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@prisma/streams-local/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0", + "pnpm": "8" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", @@ -1410,16 +1847,28 @@ } }, "node_modules/@types/pg": { - "version": "8.15.5", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", - "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, "node_modules/@types/sqlstring": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@types/sqlstring/-/sqlstring-2.3.2.tgz", @@ -1859,8 +2308,6 @@ "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">= 6.0.0" } @@ -1871,6 +2318,13 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, + "node_modules/better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -1900,6 +2354,35 @@ "node": ">=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/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -1918,6 +2401,35 @@ "node": ">=18" } }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "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/colorette": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", @@ -1939,6 +2451,13 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "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/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1960,6 +2479,14 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1983,17 +2510,39 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "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/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=0.10" } }, + "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/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -2015,6 +2564,19 @@ "node": ">=6.0.0" } }, + "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": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/drizzle-orm": { "version": "0.45.2", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", @@ -2140,6 +2702,40 @@ } } }, + "node_modules/effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -2568,6 +3164,36 @@ "node": ">=12.0.0" } }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2602,6 +3228,23 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -2730,6 +3373,23 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -2764,8 +3424,6 @@ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "is-property": "^1.0.2" } @@ -2779,6 +3437,13 @@ "node": ">=8.0.0" } }, + "node_modules/get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "dev": true, + "license": "MIT" + }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -2797,6 +3462,16 @@ "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", "dev": true }, + "node_modules/giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "dev": true, + "license": "MIT", + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -2844,12 +3519,33 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "dev": true, + "license": "MIT" + }, "node_modules/graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "node_modules/graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "dev": true, + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -2862,13 +3558,28 @@ "node": ">= 0.4" } }, + "node_modules/hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "dev": true, + "license": "MIT" + }, "node_modules/iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -2985,9 +3696,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/isexe": { "version": "2.0.0", @@ -2995,6 +3704,16 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "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": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, "node_modules/js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -3404,17 +4123,13 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", "dev": true, - "optional": true, - "peer": true, "engines": { "node": ">=12" } @@ -3424,8 +4139,6 @@ "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", "dev": true, - "optional": true, - "peer": true, "engines": { "bun": ">=1.0.0", "deno": ">=1.30.0", @@ -3507,12 +4220,11 @@ "dev": true }, "node_modules/mysql2": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.2.tgz", - "integrity": "sha512-kFm5+jbwR5mC+lo+3Cy46eHiykWSpUtTLOH3GE+AR7GeLq8PgfJcvpMiyVWk9/O53DjQsqm6a3VOOfq7gYWFRg==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "dev": true, - "optional": true, - "peer": true, + "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -3533,8 +4245,6 @@ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", "dev": true, - "optional": true, - "peer": true, "dependencies": { "lru-cache": "^7.14.1" }, @@ -3576,6 +4286,13 @@ "https://opencollective.com/debug" ] }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -3698,6 +4415,13 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "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.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -3811,6 +4535,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "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/postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -3839,6 +4575,20 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/porsager" + } + }, "node_modules/postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -3902,6 +4652,76 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": "^20.19 || ^22.12 || >=24.0" + }, + "peerDependencies": { + "better-sqlite3": ">=9.0.0", + "typescript": ">=5.4.0" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3922,6 +4742,56 @@ } ] }, + "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/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -3934,6 +4804,26 @@ "node": ">= 10.13.0" } }, + "node_modules/remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/remeda" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -3972,6 +4862,16 @@ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" } }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -4057,8 +4957,14 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "dev": true, - "optional": true, + "license": "MIT", "peer": true }, "node_modules/semver": { @@ -4077,9 +4983,7 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "node_modules/shebang-command": { "version": "2.0.0", @@ -4108,6 +5012,19 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -4391,6 +5308,21 @@ "node": ">=6" } }, + "node_modules/valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">=5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/vitest": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", @@ -5149,6 +6081,17 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } } }, "dependencies": { @@ -5449,6 +6392,26 @@ "integrity": "sha512-iY8yvjE0y651BixKNPgmv1WrQc+GZ142sb0z4gYnChDDY2YqI4P/jsSopBWrKfAt7LOJAkOXt7rC/hms+WclQQ==", "dev": true }, + "@electric-sql/pglite": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", + "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", + "dev": true + }, + "@electric-sql/pglite-socket": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", + "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "dev": true, + "requires": {} + }, + "@electric-sql/pglite-tools": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", + "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "dev": true, + "requires": {} + }, "@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -5691,71 +6654,343 @@ "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", "dev": true }, + "@hono/node-server": { + "version": "1.19.11", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", + "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "dev": true, + "requires": {} + }, "@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", "dev": true }, - "@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "dev": true + }, + "@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "optional": true, + "requires": { + "@tybys/wasm-util": "^0.10.1" + } + }, + "@nodable/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "dev": true + }, + "@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "requires": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + } + }, + "@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true + }, + "@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "requires": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + } + }, + "@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true + }, + "@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true + }, + "@prisma/client": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz", + "integrity": "sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==", + "dev": true, + "requires": { + "@prisma/client-runtime-utils": "7.8.0" + } + }, + "@prisma/client-runtime-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/client-runtime-utils/-/client-runtime-utils-7.8.0.tgz", + "integrity": "sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==", + "dev": true + }, + "@prisma/config": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", + "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "dev": true, + "requires": { + "c12": "3.3.4", + "deepmerge-ts": "7.1.5", + "effect": "3.20.0", + "empathic": "2.0.0" + } + }, + "@prisma/debug": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.8.0.tgz", + "integrity": "sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==", + "dev": true + }, + "@prisma/dev": { + "version": "0.24.3", + "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", + "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "dev": true, + "requires": { + "@electric-sql/pglite": "0.4.1", + "@electric-sql/pglite-socket": "0.1.1", + "@electric-sql/pglite-tools": "0.3.1", + "@hono/node-server": "1.19.11", + "@prisma/get-platform": "7.2.0", + "@prisma/query-plan-executor": "7.2.0", + "@prisma/streams-local": "0.1.2", + "foreground-child": "3.3.1", + "get-port-please": "3.2.0", + "hono": "^4.12.8", + "http-status-codes": "2.3.0", + "pathe": "2.0.3", + "proper-lockfile": "4.1.2", + "remeda": "2.33.4", + "std-env": "3.10.0", + "valibot": "1.2.0", + "zeptomatch": "2.1.0" + }, + "dependencies": { + "std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true + } + } + }, + "@prisma/driver-adapter-utils": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz", + "integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==", + "dev": true, + "requires": { + "@prisma/debug": "7.8.0" + } + }, + "@prisma/engines": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", + "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "dev": true, + "requires": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/fetch-engine": "7.8.0", + "@prisma/get-platform": "7.8.0" + }, + "dependencies": { + "@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "dev": true, + "requires": { + "@prisma/debug": "7.8.0" + } + } + } + }, + "@prisma/engines-version": { + "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", + "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "dev": true + }, + "@prisma/fetch-engine": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", + "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "dev": true, + "requires": { + "@prisma/debug": "7.8.0", + "@prisma/engines-version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", + "@prisma/get-platform": "7.8.0" + }, + "dependencies": { + "@prisma/get-platform": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", + "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "dev": true, + "requires": { + "@prisma/debug": "7.8.0" + } + } + } + }, + "@prisma/get-platform": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", + "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "dev": true, + "requires": { + "@prisma/debug": "7.2.0" + }, + "dependencies": { + "@prisma/debug": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", + "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "dev": true + } + } + }, + "@prisma/query-plan-executor": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", + "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", "dev": true }, - "@napi-rs/wasm-runtime": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", - "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "@prisma/streams-local": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", + "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", "dev": true, - "optional": true, "requires": { - "@tybys/wasm-util": "^0.10.1" + "ajv": "^8.12.0", + "better-result": "^2.7.0", + "env-paths": "^3.0.0", + "proper-lockfile": "^4.1.2" + }, + "dependencies": { + "ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + } + }, + "json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + } } }, - "@nodable/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA==", + "@prisma/studio-core": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", + "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "dev": true, + "requires": { + "@radix-ui/react-toggle": "1.1.10", + "chart.js": "4.5.1" + } + }, + "@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "dev": true }, - "@nodelib/fs.scandir": { - "version": "2.1.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", - "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "dev": true, + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", "dev": true, "requires": { - "@nodelib/fs.stat": "2.0.5", - "run-parallel": "^1.1.9" + "@radix-ui/react-slot": "1.2.3" } }, - "@nodelib/fs.stat": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", - "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", - "dev": true + "@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "dev": true, + "requires": { + "@radix-ui/react-compose-refs": "1.1.2" + } }, - "@nodelib/fs.walk": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", - "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "@radix-ui/react-toggle": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", + "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", "dev": true, "requires": { - "@nodelib/fs.scandir": "2.1.5", - "fastq": "^1.6.0" + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" } }, - "@oxc-project/types": { - "version": "0.133.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", - "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", - "dev": true + "@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "dev": true, + "requires": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + } }, - "@polka/url": { - "version": "1.0.0-next.29", - "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", - "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", - "dev": true + "@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "dev": true, + "requires": { + "@radix-ui/react-use-layout-effect": "1.1.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "dev": true, + "requires": {} }, "@rolldown/binding-android-arm64": { "version": "1.0.3", @@ -6014,9 +7249,9 @@ } }, "@types/pg": { - "version": "8.15.5", - "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.5.tgz", - "integrity": "sha512-LF7lF6zWEKxuT3/OR8wAZGzkg4ENGXFNyiV/JeOt9z5B+0ZVwbql9McqX5c/WStFq1GaGso7H1AzP/qSzmlCKQ==", + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz", + "integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==", "dev": true, "requires": { "@types/node": "*", @@ -6024,6 +7259,16 @@ "pg-types": "^2.2.0" } }, + "@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "peer": true, + "requires": { + "csstype": "^3.2.2" + } + }, "@types/sqlstring": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/@types/sqlstring/-/sqlstring-2.3.2.tgz", @@ -6314,9 +7559,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "balanced-match": { "version": "1.0.0", @@ -6324,6 +7567,12 @@ "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", "dev": true }, + "better-result": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", + "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "dev": true + }, "bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -6349,6 +7598,26 @@ "fill-range": "^7.1.1" } }, + "c12": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", + "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "dev": true, + "requires": { + "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" + } + }, "callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -6361,6 +7630,24 @@ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", "dev": true }, + "chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "dev": true, + "requires": { + "@kurkle/color": "^0.3.0" + } + }, + "chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "requires": { + "readdirp": "^5.0.0" + } + }, "colorette": { "version": "2.0.19", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.19.tgz", @@ -6379,6 +7666,12 @@ "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", "dev": true }, + "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 + }, "convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -6396,6 +7689,13 @@ "which": "^2.0.1" } }, + "csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "peer": true + }, "debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -6411,13 +7711,29 @@ "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", "dev": true }, + "deepmerge-ts": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", + "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "dev": true + }, + "defu": { + "version": "6.1.7", + "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", + "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "dev": true + }, "denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "dev": true, - "optional": true, - "peer": true + "dev": true + }, + "destr": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true }, "detect-libc": { "version": "2.1.2", @@ -6434,6 +7750,12 @@ "esutils": "^2.0.2" } }, + "dotenv": { + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true + }, "drizzle-orm": { "version": "0.45.2", "resolved": "https://registry.npmjs.org/drizzle-orm/-/drizzle-orm-0.45.2.tgz", @@ -6441,6 +7763,28 @@ "dev": true, "requires": {} }, + "effect": { + "version": "3.20.0", + "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", + "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "dev": true, + "requires": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "empathic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", + "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "dev": true + }, + "env-paths": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", + "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "dev": true + }, "es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -6754,6 +8098,21 @@ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", "dev": true }, + "exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "dev": true + }, + "fast-check": { + "version": "3.23.2", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", + "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "dev": true, + "requires": { + "pure-rand": "^6.1.0" + } + }, "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6785,6 +8144,12 @@ "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", "dev": true }, + "fast-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", + "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "dev": true + }, "fast-xml-builder": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.2.0.tgz", @@ -6873,6 +8238,16 @@ "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true }, + "foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "requires": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + } + }, "fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -6897,8 +8272,6 @@ "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", "dev": true, - "optional": true, - "peer": true, "requires": { "is-property": "^1.0.2" } @@ -6909,6 +8282,12 @@ "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true }, + "get-port-please": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", + "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "dev": true + }, "get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -6924,6 +8303,12 @@ "integrity": "sha512-5eDf9fuSXwxBL6q5HX+dhDj+dslFGWzU5thZ9kNKUkcPtaPdatmUFKwHFrLb/uf/WpA4BHET+AX3Scl56cAjpA==", "dev": true }, + "giget": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", + "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "dev": true + }, "glob": { "version": "7.1.6", "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", @@ -6956,12 +8341,30 @@ "type-fest": "^0.20.2" } }, + "graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "grammex": { + "version": "3.1.12", + "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", + "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "dev": true + }, "graphemer": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", "dev": true }, + "graphmatch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", + "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "dev": true + }, "hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -6971,13 +8374,23 @@ "function-bind": "^1.1.2" } }, + "hono": { + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", + "dev": true + }, + "http-status-codes": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", + "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "dev": true + }, "iconv-lite": { "version": "0.7.0", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "dev": true, - "optional": true, - "peer": true, "requires": { "safer-buffer": ">= 2.1.2 < 3.0.0" } @@ -7060,9 +8473,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "isexe": { "version": "2.0.0", @@ -7070,6 +8481,12 @@ "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", "dev": true }, + "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 + }, "js-yaml": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", @@ -7274,25 +8691,19 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", "integrity": "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "lru.min": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.2.tgz", "integrity": "sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "magic-string": { "version": "0.30.21", @@ -7349,12 +8760,10 @@ "dev": true }, "mysql2": { - "version": "3.15.2", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.2.tgz", - "integrity": "sha512-kFm5+jbwR5mC+lo+3Cy46eHiykWSpUtTLOH3GE+AR7GeLq8PgfJcvpMiyVWk9/O53DjQsqm6a3VOOfq7gYWFRg==", + "version": "3.15.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", + "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", "dev": true, - "optional": true, - "peer": true, "requires": { "aws-ssl-profiles": "^1.1.1", "denque": "^2.1.0", @@ -7372,8 +8781,6 @@ "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.3.tgz", "integrity": "sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==", "dev": true, - "optional": true, - "peer": true, "requires": { "lru-cache": "^7.14.1" } @@ -7396,6 +8803,12 @@ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", "dev": true }, + "ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true + }, "once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7482,6 +8895,12 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "dev": true }, + "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 + }, "pg": { "version": "8.16.3", "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", @@ -7570,6 +8989,17 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true }, + "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, + "requires": { + "confbox": "^0.2.4", + "exsolve": "^1.0.8", + "pathe": "^2.0.3" + } + }, "postcss": { "version": "8.5.15", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", @@ -7581,6 +9011,12 @@ "source-map-js": "^1.2.1" } }, + "postgres": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", + "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "dev": true + }, "postgres-array": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", @@ -7620,12 +9056,84 @@ "integrity": "sha512-PkUpF+qoXTqhOeWL9fu7As8LXsIUZ1WYaJiY/a7McAQzxjk82OF0tibkFXVCDImZtWxbvojFjerkiLb0/q8mew==", "dev": true }, + "prisma": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", + "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "dev": true, + "requires": { + "@prisma/config": "7.8.0", + "@prisma/dev": "0.24.3", + "@prisma/engines": "7.8.0", + "@prisma/studio-core": "0.27.3", + "mysql2": "3.15.3", + "postgres": "3.4.7" + } + }, + "proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "dev": true, + "requires": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + }, + "dependencies": { + "signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true + } + } + }, + "pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true + }, "queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", "dev": true }, + "rc9": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", + "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "dev": true, + "requires": { + "defu": "^6.1.6", + "destr": "^2.0.5" + } + }, + "react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "peer": true + }, + "react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "peer": true, + "requires": { + "scheduler": "^0.27.0" + } + }, + "readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true + }, "rechoir": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", @@ -7635,6 +9143,18 @@ "resolve": "^1.20.0" } }, + "remeda": { + "version": "2.33.4", + "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", + "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "dev": true + }, + "require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true + }, "resolve": { "version": "1.22.10", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", @@ -7658,6 +9178,12 @@ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", "dev": true }, + "retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true + }, "reusify": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", @@ -7711,8 +9237,13 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "dev": true, - "optional": true, "peer": true }, "semver": { @@ -7725,9 +9256,7 @@ "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "dev": true, - "optional": true, - "peer": true + "dev": true }, "shebang-command": { "version": "2.0.0", @@ -7750,6 +9279,12 @@ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", "dev": true }, + "signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true + }, "sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -7946,6 +9481,13 @@ } } }, + "valibot": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", + "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "dev": true, + "requires": {} + }, "vitest": { "version": "4.1.8", "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.8.tgz", @@ -8293,6 +9835,16 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true + }, + "zeptomatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", + "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "dev": true, + "requires": { + "grammex": "^3.1.11", + "graphmatch": "^1.1.0" + } } } } diff --git a/package.json b/package.json index cc295a3..46b8e31 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,11 @@ "import": "./dist/compat/knex.js", "require": "./dist/compat/knex.js" }, + "./compat/prisma": { + "types": "./dist/compat/prisma.d.ts", + "import": "./dist/compat/prisma.js", + "require": "./dist/compat/prisma.js" + }, "./compat": { "types": "./dist/compat/index.d.ts", "import": "./dist/compat/index.js", @@ -57,6 +62,12 @@ "test:int:orm:knex": "npm run build && vitest run integration-tests/knex-mysql.int.test.ts integration-tests/knex-pg.int.test.ts integration-tests/knex-mysql-querybuilder.int.test.ts integration-tests/knex-pg-querybuilder.int.test.ts", "test:int:orm:knex:pg": "npm run build && vitest run integration-tests/knex-pg.int.test.ts integration-tests/knex-pg-querybuilder.int.test.ts", "test:int:orm:knex:mysql": "npm run build && vitest run integration-tests/knex-mysql.int.test.ts integration-tests/knex-mysql-querybuilder.int.test.ts", + "test:int:orm:prisma": "npm run build && vitest run integration-tests/prisma-pg.int.test.ts integration-tests/prisma-mysql.int.test.ts", + "test:int:orm:prisma:pg": "npm run build && vitest run integration-tests/prisma-pg.int.test.ts", + "test:int:orm:prisma:mysql": "npm run build && vitest run integration-tests/prisma-mysql.int.test.ts", + "test:int:orm:prisma:coverage": "npm run test:int:orm:prisma:coverage:pg && npm run test:int:orm:prisma:coverage:mysql", + "test:int:orm:prisma:coverage:pg": "npm run build && prisma generate --schema integration-tests/prisma/schema-pg-coverage.prisma && vitest run integration-tests/prisma-pg-coverage.int.test.ts", + "test:int:orm:prisma:coverage:mysql": "npm run build && prisma generate --schema integration-tests/prisma/schema-mysql-coverage.prisma && vitest run integration-tests/prisma-mysql-coverage.int.test.ts", "test-ci": "npm run build && eslint src && vitest run src/", "lint": "eslint src", "prepublishOnly": "npm run build" @@ -82,6 +93,8 @@ }, "devDependencies": { "@aws-sdk/client-rds-data": "^3.1048.0", + "@prisma/client": "^7.8.0", + "@prisma/driver-adapter-utils": "^7.8.0", "@types/node": "^24.6.2", "@types/pg": "^8.15.5", "@types/sqlstring": "^2.3.2", @@ -95,6 +108,7 @@ "kysely": "^0.28.7", "pg": "^8.16.3", "prettier": "^2.6.2", + "prisma": "^7.8.0", "tsx": "^4.20.6", "typescript": "^5.9.3", "vitest": "^4.1.8" @@ -104,6 +118,7 @@ }, "peerDependencies": { "@aws-sdk/client-rds-data": "^3.1048.0", + "@prisma/driver-adapter-utils": "^7.0.0", "knex": "^3.0.0" }, "peerDependenciesMeta": { @@ -112,6 +127,9 @@ }, "knex": { "optional": true + }, + "@prisma/driver-adapter-utils": { + "optional": true } }, "files": [ diff --git a/src/compat/errors.test.ts b/src/compat/errors.test.ts new file mode 100644 index 0000000..f31bc59 --- /dev/null +++ b/src/compat/errors.test.ts @@ -0,0 +1,24 @@ +import { describe, test, expect } from 'vitest' +import { mapToPrismaError } from './errors' + +describe('mapToPrismaError', () => { + test('unique violation -> UniqueConstraintViolation', () => { + const e = mapToPrismaError( + new Error('duplicate key value violates unique constraint "users_email_key"'), + 'pg' + ) as any + expect(e.name).toBe('DriverAdapterError') + expect(e.cause.kind).toBe('UniqueConstraintViolation') + }) + + test('undefined table -> TableDoesNotExist', () => { + const e = mapToPrismaError(new Error('relation "missing" does not exist'), 'pg') as any + expect(e.cause.kind).toBe('TableDoesNotExist') + }) + + test('falls back to provider error with original message', () => { + const e = mapToPrismaError(new Error('something weird'), 'mysql') as any + expect(e.cause.kind).toBe('mysql') + expect(e.cause.message).toContain('something weird') + }) +}) diff --git a/src/compat/errors.ts b/src/compat/errors.ts index 57da41b..01ff849 100644 --- a/src/compat/errors.ts +++ b/src/compat/errors.ts @@ -247,3 +247,41 @@ export function mapToMySQLError(error: any): MySQLError { return mysqlError } + +/** + * Map a Data API error to a Prisma DriverAdapterError. Lazy-requires + * @prisma/driver-adapter-utils (an optional peer dep) for the error class; + * falls back to a shaped plain Error if the package is unavailable. + */ +export function mapToPrismaError(error: any, engine: 'pg' | 'mysql'): Error { + const message: string = error?.message || String(error) + const lower = message.toLowerCase() + + let kind: Record + if (lower.includes('unique constraint') || lower.includes('duplicate')) { + kind = { kind: 'UniqueConstraintViolation' } + } else if (lower.includes('foreign key')) { + kind = { kind: 'ForeignKeyConstraintViolation' } + } else if (lower.includes('not-null') || lower.includes('null value') || lower.includes('cannot be null')) { + kind = { kind: 'NullConstraintViolation' } + } else if ((lower.includes('relation') || lower.includes('table')) && lower.includes('does not exist')) { + kind = { kind: 'TableDoesNotExist' } + } else if (lower.includes('column') && lower.includes('does not exist')) { + kind = { kind: 'ColumnNotFound' } + } else if (engine === 'pg') { + kind = { kind: 'postgres', code: error?.code || '', severity: 'ERROR', message, detail: undefined, column: undefined, hint: undefined } + } else { + kind = { kind: 'mysql', code: Number(error?.code) || 0, message, state: '' } + } + + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const { DriverAdapterError } = require('@prisma/driver-adapter-utils') + return new DriverAdapterError(kind) + } catch { + const fallback = new Error(message) + ;(fallback as any).name = 'DriverAdapterError' + ;(fallback as any).cause = kind + return fallback + } +} diff --git a/src/compat/index.ts b/src/compat/index.ts index 76ef356..028fb92 100644 --- a/src/compat/index.ts +++ b/src/compat/index.ts @@ -16,6 +16,7 @@ export { createMySQLConnection, createMySQLPool } from './mysql2' export type { Connection, Pool, PoolConnection, MySQL2QueryResult } from './mysql2' export { createKnexMySQLClient, createKnexPgClient } from './knex' +export { createPrismaPgAdapter, createPrismaMySQLAdapter } from './prisma' export { mapToPostgresError, mapToMySQLError } from './errors' export type { PostgresError, MySQLError } from './errors' diff --git a/src/compat/prisma-params.test.ts b/src/compat/prisma-params.test.ts new file mode 100644 index 0000000..38f9831 --- /dev/null +++ b/src/compat/prisma-params.test.ts @@ -0,0 +1,88 @@ +import { describe, test, expect } from 'vitest' +import { buildQuery, type PrismaSqlQuery } from './prisma-params' + +const scalar = (scalarType: string) => ({ scalarType, arity: 'scalar' as const }) +const list = (scalarType: string) => ({ scalarType, arity: 'list' as const }) + +describe('buildQuery placeholder rewrite', () => { + test('pg: $n -> :pn with typed params', () => { + const q: PrismaSqlQuery = { + sql: 'SELECT * FROM t WHERE id = $1 AND name = $2', + args: [5, 'alice'], + argTypes: [scalar('int'), scalar('string')] + } + const out = buildQuery(q, 'pg') + expect(out.sql).toBe('SELECT * FROM t WHERE id = :p1 AND name = :p2') + expect(out.parameters).toEqual([ + { name: 'p1', value: { longValue: 5 } }, + { name: 'p2', value: { stringValue: 'alice' } } + ]) + }) + + test('mysql: ? -> :pn sequentially', () => { + const q: PrismaSqlQuery = { + sql: 'SELECT * FROM t WHERE id = ? AND name = ?', + args: [5, 'alice'], + argTypes: [scalar('int'), scalar('string')] + } + const out = buildQuery(q, 'mysql') + expect(out.sql).toBe('SELECT * FROM t WHERE id = :p1 AND name = :p2') + expect(out.parameters.map((p) => p.name)).toEqual(['p1', 'p2']) + }) +}) + +describe('buildQuery typed params', () => { + test('uuid/json/datetime/decimal/bytes/bool/null type hints', () => { + const q: PrismaSqlQuery = { + sql: 'x $1 $2 $3 $4 $5 $6 $7', + args: ['11111111-1111-1111-1111-111111111111', { a: 1 }, new Date('2020-01-02T03:04:05.000Z'), '1.50', Buffer.from('hi'), true, null], + argTypes: [scalar('uuid'), scalar('json'), scalar('datetime'), scalar('decimal'), scalar('bytes'), scalar('boolean'), scalar('string')] + } + const p = buildQuery(q, 'pg').parameters + expect(p[0]).toMatchObject({ typeHint: 'UUID' }) + expect(p[1]).toMatchObject({ typeHint: 'JSON', value: { stringValue: '{"a":1}' } }) + expect(p[2]).toMatchObject({ typeHint: 'TIMESTAMP' }) + expect(p[3]).toMatchObject({ typeHint: 'DECIMAL', value: { stringValue: '1.50' } }) + expect(p[4].value.blobValue).toBeInstanceOf(Buffer) + expect(p[5]).toMatchObject({ value: { booleanValue: true } }) + expect(p[6]).toMatchObject({ value: { isNull: true } }) + }) +}) + +describe('buildQuery pg array rewrite', () => { + test('list arg becomes ARRAY[...] with element params', () => { + const q: PrismaSqlQuery = { + sql: 'INSERT INTO t (tags) VALUES ($1)', + args: [['x', 'y', 'z']], + argTypes: [list('string')] + } + const out = buildQuery(q, 'pg') + expect(out.sql).toBe('INSERT INTO t (tags) VALUES (ARRAY[:p1_0, :p1_1, :p1_2])') + expect(out.parameters.map((p) => p.name)).toEqual(['p1_0', 'p1_1', 'p1_2']) + expect(out.parameters.map((p) => p.value.stringValue)).toEqual(['x', 'y', 'z']) + }) + + test('array detected by VALUE even when arity reports scalar (hasSome quirk)', () => { + const q: PrismaSqlQuery = { + sql: 'SELECT * FROM t WHERE tags && $1', + args: [['x', 'q']], + argTypes: [scalar('string')] + } + const out = buildQuery(q, 'pg') + expect(out.sql).toBe('SELECT * FROM t WHERE tags && ARRAY[:p1_0, :p1_1]') + }) + + test('empty array emits typed empty constructor', () => { + const q: PrismaSqlQuery = { sql: 'x $1', args: [[]], argTypes: [list('int')] } + const out = buildQuery(q, 'pg') + expect(out.sql).toBe('x ARRAY[]::bigint[]') + expect(out.parameters).toEqual([]) + }) + + test('json array value is NOT treated as a pg array', () => { + const q: PrismaSqlQuery = { sql: 'x $1', args: [[1, 2, 3]], argTypes: [scalar('json')] } + const out = buildQuery(q, 'pg') + expect(out.sql).toBe('x :p1') + expect(out.parameters[0]).toMatchObject({ typeHint: 'JSON', value: { stringValue: '[1,2,3]' } }) + }) +}) diff --git a/src/compat/prisma-params.ts b/src/compat/prisma-params.ts new file mode 100644 index 0000000..0b425dd --- /dev/null +++ b/src/compat/prisma-params.ts @@ -0,0 +1,141 @@ +'use strict' + +/** + * Converts Prisma's positional SQL + argTypes into data-api-client native + * passthrough parameters, and rewrites Postgres array params into ARRAY[...] + * constructors (the Data API cannot bind array parameters). + */ + +import type { Engine } from './prisma-types' + +export type Arity = 'scalar' | 'list' +export interface PrismaArgType { + scalarType: string + arity: Arity + dbType?: string +} +export interface PrismaSqlQuery { + sql: string + args: unknown[] + argTypes: PrismaArgType[] +} +export interface DataApiParam { + name: string + value: Record + typeHint?: string + cast?: string +} +export interface BuiltQuery { + sql: string + parameters: DataApiParam[] +} + +function escapeRe(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +// Postgres element-type cast for empty-array constructors. +const PG_EMPTY_ARRAY_CAST: Record = { + int: 'bigint', + bigint: 'bigint', + float: 'double precision', + decimal: 'numeric', + boolean: 'boolean', + uuid: 'uuid', + datetime: 'timestamp', + string: 'text', + enum: 'text' +} + +function buildScalarParam(name: string, value: unknown, argType: PrismaArgType): DataApiParam { + if (value === null || value === undefined) { + return { name, value: { isNull: true } } + } + switch (argType.scalarType) { + case 'int': + return { name, value: { longValue: Number(value) } } + case 'bigint': { + const n = typeof value === 'bigint' ? value : BigInt(value as string | number) + if (n > BigInt(Number.MAX_SAFE_INTEGER) || n < BigInt(Number.MIN_SAFE_INTEGER)) { + return { name, value: { stringValue: n.toString() } } + } + return { name, value: { longValue: Number(n) } } + } + case 'float': + return { name, value: { doubleValue: Number(value) } } + case 'decimal': + return { name, value: { stringValue: String(value) }, typeHint: 'DECIMAL' } + case 'boolean': + return { name, value: { booleanValue: Boolean(value) } } + case 'uuid': + return { name, value: { stringValue: String(value) }, typeHint: 'UUID' } + case 'json': + return { + name, + value: { stringValue: typeof value === 'string' ? value : JSON.stringify(value) }, + typeHint: 'JSON' + } + case 'datetime': { + const d = value instanceof Date ? value : new Date(value as string) + const iso = d.toISOString().replace('T', ' ').replace('Z', '') + return { name, value: { stringValue: iso }, typeHint: 'TIMESTAMP' } + } + case 'bytes': { + const buf = typeof value === 'string' ? Buffer.from(value, 'base64') : Buffer.from(value as Uint8Array) + return { name, value: { blobValue: buf } } + } + case 'enum': + case 'string': + default: + return { name, value: { stringValue: String(value) } } + } +} + +export function buildQuery(query: PrismaSqlQuery, engine: Engine): BuiltQuery { + // First normalize placeholders to :pN. PG uses $n; MySQL uses sequential ?. + let sql: string + if (engine === 'pg') { + sql = query.sql.replace(/\$(\d+)/g, (_m, n) => `:p${n}`) + } else { + let i = 0 + sql = query.sql.replace(/\?/g, () => `:p${++i}`) + } + + const parameters: DataApiParam[] = [] + + query.args.forEach((arg, idx) => { + const argType = query.argTypes[idx] ?? { scalarType: 'unknown', arity: 'scalar' as const } + const name = `p${idx + 1}` + + // Detect array values by VALUE (hasSome reports arity 'scalar' for arrays). + // JSON values may legitimately be JS arrays, so exclude them. + const isPgArray = engine === 'pg' && Array.isArray(arg) && argType.scalarType !== 'json' + + if (isPgArray) { + const elements = arg as unknown[] + const placeholder = `:p${idx + 1}` + if (elements.length === 0) { + const cast = PG_EMPTY_ARRAY_CAST[argType.scalarType] ?? 'text' + sql = sql.replace(new RegExp(escapeRe(placeholder) + '\\b', 'g'), `ARRAY[]::${cast}[]`) + return + } + const names = elements.map((el, k) => { + const pname = `p${idx + 1}_${k}` + parameters.push(buildScalarParam(pname, el, { ...argType, arity: 'scalar' })) + return `:${pname}` + }) + sql = sql.replace(new RegExp(escapeRe(placeholder) + '\\b', 'g'), `ARRAY[${names.join(', ')}]`) + return + } + + const param = buildScalarParam(name, arg, argType) + // PostgreSQL JSONB columns require an explicit ::jsonb cast; the Data API + // passes json values as text so without it Aurora rejects them. + if (engine === 'pg' && argType.scalarType === 'json') { + param.cast = 'jsonb' + } + parameters.push(param) + }) + + return { sql, parameters } +} diff --git a/src/compat/prisma-types.test.ts b/src/compat/prisma-types.test.ts new file mode 100644 index 0000000..30c8e66 --- /dev/null +++ b/src/compat/prisma-types.test.ts @@ -0,0 +1,46 @@ +import { describe, test, expect } from 'vitest' +import { ColumnType, mapColumnType } from './prisma-types' + +describe('mapColumnType (postgres)', () => { + test('maps common scalar pg type names', () => { + expect(mapColumnType('int4', 'pg')).toBe(ColumnType.Int32) + expect(mapColumnType('int8', 'pg')).toBe(ColumnType.Int64) + expect(mapColumnType('float8', 'pg')).toBe(ColumnType.Double) + expect(mapColumnType('numeric', 'pg')).toBe(ColumnType.Numeric) + expect(mapColumnType('bool', 'pg')).toBe(ColumnType.Boolean) + expect(mapColumnType('text', 'pg')).toBe(ColumnType.Text) + expect(mapColumnType('varchar', 'pg')).toBe(ColumnType.Text) + expect(mapColumnType('timestamp', 'pg')).toBe(ColumnType.DateTime) + expect(mapColumnType('uuid', 'pg')).toBe(ColumnType.Uuid) + expect(mapColumnType('jsonb', 'pg')).toBe(ColumnType.Json) + expect(mapColumnType('bytea', 'pg')).toBe(ColumnType.Bytes) + }) + + test('maps pg array type names (underscore prefix)', () => { + expect(mapColumnType('_int4', 'pg')).toBe(ColumnType.Int32Array) + expect(mapColumnType('_text', 'pg')).toBe(ColumnType.TextArray) + expect(mapColumnType('_numeric', 'pg')).toBe(ColumnType.NumericArray) + }) + + test('falls back to Text for unknown pg types', () => { + expect(mapColumnType('some_custom_type', 'pg')).toBe(ColumnType.Text) + }) +}) + +describe('mapColumnType (mysql)', () => { + test('maps common mysql type names (case-insensitive)', () => { + expect(mapColumnType('INT', 'mysql')).toBe(ColumnType.Int32) + expect(mapColumnType('BIGINT', 'mysql')).toBe(ColumnType.Int64) + expect(mapColumnType('DOUBLE', 'mysql')).toBe(ColumnType.Double) + expect(mapColumnType('DECIMAL', 'mysql')).toBe(ColumnType.Numeric) + expect(mapColumnType('VARCHAR', 'mysql')).toBe(ColumnType.Text) + expect(mapColumnType('DATETIME', 'mysql')).toBe(ColumnType.DateTime) + expect(mapColumnType('JSON', 'mysql')).toBe(ColumnType.Json) + expect(mapColumnType('BLOB', 'mysql')).toBe(ColumnType.Bytes) + }) + + test('maps TINYINT(1) to Boolean but TINYINT to Int32', () => { + expect(mapColumnType('TINYINT(1)', 'mysql')).toBe(ColumnType.Boolean) + expect(mapColumnType('TINYINT', 'mysql')).toBe(ColumnType.Int32) + }) +}) diff --git a/src/compat/prisma-types.ts b/src/compat/prisma-types.ts new file mode 100644 index 0000000..a8a6e44 --- /dev/null +++ b/src/compat/prisma-types.ts @@ -0,0 +1,143 @@ +'use strict' + +/** + * Prisma column-type mapping for the Data API compat layer. + * + * `ColumnType` mirrors @prisma/driver-adapter-utils' `ColumnTypeEnum` integer + * values (stable wire constants) so this module carries NO runtime dependency + * on Prisma packages. Keep in sync with that package if Prisma ever changes them. + */ + +export const ColumnType = { + Int32: 0, + Int64: 1, + Float: 2, + Double: 3, + Numeric: 4, + Boolean: 5, + Character: 6, + Text: 7, + Date: 8, + Time: 9, + DateTime: 10, + Json: 11, + Enum: 12, + Bytes: 13, + Uuid: 15, + Int32Array: 64, + Int64Array: 65, + FloatArray: 66, + DoubleArray: 67, + NumericArray: 68, + BooleanArray: 69, + CharacterArray: 70, + TextArray: 71, + DateArray: 72, + TimeArray: 73, + DateTimeArray: 74, + JsonArray: 75, + BytesArray: 77, + UuidArray: 78 +} as const + +export type Engine = 'pg' | 'mysql' + +const PG_ARRAY: Record = { + _int2: ColumnType.Int32Array, + _int4: ColumnType.Int32Array, + _int8: ColumnType.Int64Array, + _float4: ColumnType.FloatArray, + _float8: ColumnType.DoubleArray, + _numeric: ColumnType.NumericArray, + _bool: ColumnType.BooleanArray, + _text: ColumnType.TextArray, + _varchar: ColumnType.TextArray, + _bpchar: ColumnType.TextArray, + _uuid: ColumnType.UuidArray, + _json: ColumnType.JsonArray, + _jsonb: ColumnType.JsonArray, + _timestamp: ColumnType.DateTimeArray, + _timestamptz: ColumnType.DateTimeArray, + _date: ColumnType.DateArray, + _time: ColumnType.TimeArray, + _bytea: ColumnType.BytesArray +} + +const PG_SCALAR: Record = { + int2: ColumnType.Int32, + int4: ColumnType.Int32, + serial: ColumnType.Int32, + int8: ColumnType.Int64, + bigserial: ColumnType.Int64, + float4: ColumnType.Float, + float8: ColumnType.Double, + numeric: ColumnType.Numeric, + money: ColumnType.Numeric, + bool: ColumnType.Boolean, + text: ColumnType.Text, + varchar: ColumnType.Text, + bpchar: ColumnType.Text, + name: ColumnType.Text, + citext: ColumnType.Text, + char: ColumnType.Character, + date: ColumnType.Date, + time: ColumnType.Time, + timetz: ColumnType.Time, + timestamp: ColumnType.DateTime, + timestamptz: ColumnType.DateTime, + json: ColumnType.Json, + jsonb: ColumnType.Json, + uuid: ColumnType.Uuid, + bytea: ColumnType.Bytes +} + +function mapPg(typeName: string): number { + const t = typeName.toLowerCase() + if (t in PG_ARRAY) return PG_ARRAY[t] + if (t in PG_SCALAR) return PG_SCALAR[t] + return ColumnType.Text +} + +function mapMysql(typeName: string): number { + const raw = typeName.toUpperCase() + // TINYINT(1) is MySQL's conventional boolean; bare TINYINT is an integer. + if (raw.startsWith('TINYINT(1)')) return ColumnType.Boolean + const t = raw.replace(/\(.*$/, '') // strip length/precision e.g. VARCHAR(255) + const map: Record = { + TINYINT: ColumnType.Int32, + SMALLINT: ColumnType.Int32, + MEDIUMINT: ColumnType.Int32, + INT: ColumnType.Int32, + INTEGER: ColumnType.Int32, + BIGINT: ColumnType.Int64, + FLOAT: ColumnType.Float, + DOUBLE: ColumnType.Double, + DECIMAL: ColumnType.Numeric, + NUMERIC: ColumnType.Numeric, + BIT: ColumnType.Bytes, + CHAR: ColumnType.Text, + VARCHAR: ColumnType.Text, + TINYTEXT: ColumnType.Text, + TEXT: ColumnType.Text, + MEDIUMTEXT: ColumnType.Text, + LONGTEXT: ColumnType.Text, + ENUM: ColumnType.Enum, + DATE: ColumnType.Date, + TIME: ColumnType.Time, + YEAR: ColumnType.Int32, + DATETIME: ColumnType.DateTime, + TIMESTAMP: ColumnType.DateTime, + JSON: ColumnType.Json, + BINARY: ColumnType.Bytes, + VARBINARY: ColumnType.Bytes, + TINYBLOB: ColumnType.Bytes, + BLOB: ColumnType.Bytes, + MEDIUMBLOB: ColumnType.Bytes, + LONGBLOB: ColumnType.Bytes + } + return t in map ? map[t] : ColumnType.Text +} + +export function mapColumnType(typeName: string, engine: Engine): number { + return engine === 'pg' ? mapPg(typeName) : mapMysql(typeName) +} diff --git a/src/compat/prisma.test.ts b/src/compat/prisma.test.ts new file mode 100644 index 0000000..9c26c88 --- /dev/null +++ b/src/compat/prisma.test.ts @@ -0,0 +1,53 @@ +import { describe, test, expect, vi } from 'vitest' +import { __AdapterForTest } from './prisma' + +// A fake core client capturing the query options it receives. +function fakeCore(queryReturn: any) { + return { + query: vi.fn(async () => queryReturn), + beginTransaction: vi.fn(async () => ({ transactionId: 'tx-1' })), + commitTransaction: vi.fn(async () => ({})), + rollbackTransaction: vi.fn(async () => ({})) + } as any +} + +describe('Adapter.queryRaw', () => { + test('returns columnNames/columnTypes/rows from core result', async () => { + const core = fakeCore({ + records: [[1, 'alice']], + columnMetadata: [ + { label: 'id', typeName: 'int4' }, + { label: 'name', typeName: 'text' } + ] + }) + const adapter = new __AdapterForTest(core, 'pg') + const res = await adapter.queryRaw({ sql: 'SELECT id, name FROM t', args: [], argTypes: [] }) + expect(res.columnNames).toEqual(['id', 'name']) + expect(res.columnTypes).toEqual([0, 7]) // Int32, Text + expect(res.rows).toEqual([[1, 'alice']]) + expect(core.query).toHaveBeenCalledWith( + expect.objectContaining({ hydrateColumnNames: false, includeResultMetadata: true }) + ) + }) +}) + +describe('Adapter.executeRaw', () => { + test('returns numberOfRecordsUpdated', async () => { + const core = fakeCore({ numberOfRecordsUpdated: 3 }) + const adapter = new __AdapterForTest(core, 'pg') + const n = await adapter.executeRaw({ sql: 'DELETE FROM t', args: [], argTypes: [] }) + expect(n).toBe(3) + }) +}) + +describe('Adapter.startTransaction', () => { + test('threads transactionId through queries and commits', async () => { + const core = fakeCore({ records: [], columnMetadata: [] }) + const adapter = new __AdapterForTest(core, 'pg') + const tx = await adapter.startTransaction() + await tx.queryRaw({ sql: 'SELECT 1', args: [], argTypes: [] }) + expect(core.query).toHaveBeenCalledWith(expect.objectContaining({ transactionId: 'tx-1' })) + await tx.commit() + expect(core.commitTransaction).toHaveBeenCalledWith({ transactionId: 'tx-1' }) + }) +}) diff --git a/src/compat/prisma.ts b/src/compat/prisma.ts new file mode 100644 index 0000000..ca185e1 --- /dev/null +++ b/src/compat/prisma.ts @@ -0,0 +1,156 @@ +'use strict' + +/** + * Prisma driver adapter backed by the Aurora RDS Data API. + * + * Thin wrapper: holds an init(config) core client and reuses its query/retry/ + * result-formatting and transaction methods. New logic lives in prisma-types.ts + * (column types) and prisma-params.ts (parameters / array rewrite). + */ + +import { init } from '../client' +import type { DataAPIClient, DataAPIClientConfig } from '../types' +import { mapColumnType, ColumnType, type Engine } from './prisma-types' +import { buildQuery, type PrismaSqlQuery } from './prisma-params' +import { mapToPrismaError } from './errors' + +const PROVIDER: Record = { pg: 'postgres', mysql: 'mysql' } +const ADAPTER_NAME = 'data-api-client' +const NESTED_TX_MESSAGE = 'Nested transactions (savepoints) are not supported over the RDS Data API.' + +interface SqlResultSet { + columnNames: string[] + columnTypes: number[] + rows: unknown[][] + lastInsertId?: string +} + +class Queryable { + readonly provider: 'postgres' | 'mysql' + readonly adapterName = ADAPTER_NAME + + constructor( + protected core: DataAPIClient, + protected engine: Engine, + protected transactionId?: string + ) { + this.provider = PROVIDER[engine] + } + + protected async run(query: PrismaSqlQuery): Promise { + const { sql, parameters } = buildQuery(query, this.engine) + const opts: any = { sql, parameters, hydrateColumnNames: false, includeResultMetadata: true } + if (this.transactionId) opts.transactionId = this.transactionId + try { + return await this.core.query(opts) + } catch (e) { + throw mapToPrismaError(e, this.engine) + } + } + + async queryRaw(query: PrismaSqlQuery): Promise { + const result = await this.run(query) + const meta = result.columnMetadata ?? [] + const columnTypes: number[] = meta.map((m: any) => mapColumnType(m.typeName ?? '', this.engine)) + + // Prisma's query-plan interpreter expects JSON column values to be JSON strings, + // not already-parsed objects. The core client (results.ts) auto-parses JSONB/JSON + // columns via formatRecordValue; re-serialize them so Prisma can parse them itself. + const rawRows: unknown[][] = result.records ?? [] + const rows = rawRows.map((row) => + row.map((cell, i) => { + if (columnTypes[i] === ColumnType.Json && cell !== null && typeof cell !== 'string') { + return JSON.stringify(cell) + } + return cell + }) + ) + + return { + columnNames: meta.map((m: any) => m.label ?? m.name ?? ''), + columnTypes, + rows, + lastInsertId: result.insertId !== undefined ? String(result.insertId) : undefined + } + } + + async executeRaw(query: PrismaSqlQuery): Promise { + const result = await this.run(query) + return result.numberOfRecordsUpdated ?? 0 + } +} + +class DataApiTransaction extends Queryable { + readonly options = { usePhantomQuery: false } + constructor(core: DataAPIClient, engine: Engine, transactionId: string) { + super(core, engine, transactionId) + } + async commit(): Promise { + await this.core.commitTransaction({ transactionId: this.transactionId } as any) + } + async rollback(): Promise { + await this.core.rollbackTransaction({ transactionId: this.transactionId } as any) + } + async createSavepoint(): Promise { + throw new Error(NESTED_TX_MESSAGE) + } + async rollbackToSavepoint(): Promise { + throw new Error(NESTED_TX_MESSAGE) + } + async releaseSavepoint(): Promise { + throw new Error(NESTED_TX_MESSAGE) + } +} + +class DataApiAdapter extends Queryable { + async executeScript(script: string): Promise { + const statements = script + .split(';') + .map((s) => s.trim()) + .filter((s) => s.length > 0) + for (const stmt of statements) { + await this.run({ sql: stmt, args: [], argTypes: [] }) + } + } + async startTransaction(): Promise { + const res = await this.core.beginTransaction() + return new DataApiTransaction(this.core, this.engine, (res as any).transactionId) + } + getConnectionInfo() { + // Cap so Prisma chunks large IN(...) lists under the Data API param ceiling. + return { supportsRelationJoins: false, maxBindValues: 1000 } + } + async dispose(): Promise { + /* nothing to release */ + } +} + +class PrismaDataApiAdapterFactory { + readonly provider: 'postgres' | 'mysql' + readonly adapterName = ADAPTER_NAME + constructor( + private config: DataAPIClientConfig, + private engine: Engine + ) { + this.provider = PROVIDER[engine] + } + async connect(): Promise { + const core = init({ + ...this.config, + engine: this.engine, + // Pass raw strings so Prisma parses dates itself. + formatOptions: { ...(this.config.formatOptions || {}), deserializeDate: false } + } as DataAPIClientConfig) + return new DataApiAdapter(core, this.engine) + } +} + +export function createPrismaPgAdapter(config: DataAPIClientConfig): PrismaDataApiAdapterFactory { + return new PrismaDataApiAdapterFactory(config, 'pg') +} +export function createPrismaMySQLAdapter(config: DataAPIClientConfig): PrismaDataApiAdapterFactory { + return new PrismaDataApiAdapterFactory(config, 'mysql') +} + +// Exposed for unit testing with an injected fake core. +export const __AdapterForTest = DataApiAdapter diff --git a/src/params.test.ts b/src/params.test.ts index 0086f4a..1376fee 100644 --- a/src/params.test.ts +++ b/src/params.test.ts @@ -144,6 +144,16 @@ describe('parameter processing', () => { ]) }) + test('normalizeParams treats a native-format param with typeHint as a single param (regression)', async () => { + // A param like { name: 'p1', value: { stringValue: 'x' }, typeHint: 'UUID' } is a + // *native* named parameter (name + value + optional allowed fields only). + // It must NOT be shredded into three params named 'name', 'value', 'typeHint'. + let result = normalizeParams([{ name: 'p1', value: { stringValue: 'x' }, typeHint: 'UUID' }] as any) + expect(result).toHaveLength(1) + expect((result[0] as any).name).toBe('p1') + expect((result[0] as any).typeHint).toBe('UUID') + }) + describe('formatType', () => { test('stringValue', async () => { let result = formatType('param', 'string val', 'stringValue', undefined, { @@ -421,5 +431,36 @@ describe('parameter processing', () => { { name: 'referenceId', value: { stringValue: '550e8400-e29b-41d4-a716-446655440000' } } ]) }) + + test('explicit typeHint on a native-format param is preserved in output', async () => { + // A param carrying its own typeHint (e.g. from the Prisma compat layer) must have + // that typeHint propagated through processParams → formatParam → formatType. + let { processedParams } = processParams( + 'pg', + 'INSERT INTO events (created_at) VALUES (:p1)', + { p1: { type: 'n_ph' } }, + [{ name: 'p1', value: { stringValue: '2020-01-02 03:04:05' }, typeHint: 'TIMESTAMP' }], + { deserializeDate: false, treatAsLocalDate: false } + ) + expect(processedParams).toHaveLength(1) + const param = processedParams[0] as any + expect(param.name).toBe('p1') + expect(param.typeHint).toBe('TIMESTAMP') + expect(param.value).toEqual({ stringValue: '2020-01-02 03:04:05' }) + }) + + test('explicit typeHint does not affect params without one (no regression)', async () => { + // Params without typeHint must still produce output with no typeHint field. + let { processedParams } = processParams( + 'pg', + 'SELECT * FROM users WHERE id = :id', + { id: { type: 'n_ph' } }, + [{ name: 'id', value: 'hello' }], + { deserializeDate: false, treatAsLocalDate: false } + ) + const param = processedParams[0] as any + expect(param.typeHint).toBeUndefined() + expect(param.value).toEqual({ stringValue: 'hello' }) + }) }) }) diff --git a/src/params.ts b/src/params.ts index 2ac12a8..6b3170b 100644 --- a/src/params.ts +++ b/src/params.ts @@ -99,17 +99,22 @@ export const prepareParams = ( const omit = >(obj: T, values: string[]): Partial => Object.keys(obj).reduce((acc, x) => (values.includes(x) ? acc : Object.assign(acc, { [x]: obj[x] })), {} as Partial) +// Known optional fields on a named parameter (beyond required `name` and `value`). +const NAMED_PARAM_OPTIONAL_KEYS = new Set(['cast', 'typeHint']) + +// Returns true when every key in the object is either `name`, `value`, or a known optional field. +const isNamedParam = (p: object): boolean => { + if (!('name' in p) || typeof (p as any).value === 'undefined') return false + return Object.keys(p).every((k) => k === 'name' || k === 'value' || NAMED_PARAM_OPTIONAL_KEYS.has(k)) +} + // Normalize parameters so that they are all in standard format export const normalizeParams = (params: Parameters[]): (NamedParameter | NamedParameter[])[] => params.reduce( (acc: (NamedParameter | NamedParameter[])[], p: Parameters) => Array.isArray(p) ? acc.concat([normalizeParams(p as unknown as Parameters[]) as unknown as NamedParameter[]]) - : (Object.keys(p).length === 2 && 'name' in p && typeof (p as any).value !== 'undefined') || - (Object.keys(p).length === 3 && - 'name' in p && - typeof (p as any).value !== 'undefined' && - 'cast' in p) + : isNamedParam(p as object) ? acc.concat(p as unknown as NamedParameter) : acc.concat(...splitParams(p as Record)), [] @@ -145,7 +150,7 @@ export const processParams = ( const regex = new RegExp(':' + p.name + '\\b', 'g') sql = sql.replace(regex, `:${p.name}::jsonb`) } - acc.push(formatParam(p.name, p.value, formatOptions)) + acc.push(formatParam(p.name, p.value, formatOptions, p.typeHint)) } else if (row === 0) { const regex = new RegExp('::' + p.name + '\\b', 'g') // Use engine-specific identifier escaping @@ -164,8 +169,12 @@ export const processParams = ( } // Converts parameter to the name/value format -export const formatParam = (n: string, v: ParameterValue, formatOptions: Required): FormattedParameter => - formatType(n, v, getType(v), getTypeHint(v), formatOptions) +export const formatParam = ( + n: string, + v: ParameterValue, + formatOptions: Required, + explicitTypeHint?: string +): FormattedParameter => formatType(n, v, getType(v), explicitTypeHint ?? getTypeHint(v), formatOptions) // Converts object params into name/value format export const splitParams = (p: Record): NamedParameter[] => diff --git a/src/results.test.ts b/src/results.test.ts index eb26f3f..f631b82 100644 --- a/src/results.test.ts +++ b/src/results.test.ts @@ -35,6 +35,33 @@ describe('formatRecords', () => { }) }) +describe('formatRecords - hydrate:false with PostgreSQL array column (regression)', () => { + const formatOptions = { deserializeDate: false, treatAsLocalDate: false } + + test('array column is nested as a single cell, not flattened into the row', () => { + // Simulates a Data API response for: SELECT id, tags FROM t WHERE ... + // where `tags` is a text[] column (typeName starts with '_'). + const records = [ + [ + { longValue: 42 }, + { arrayValue: { stringValues: ['admin', 'editor'] } } + ] + ] + const columns = [ + { label: 'id', typeName: 'INT4' }, + { label: 'tags', typeName: '_text' } + ] + + const result = formatRecords(records as any, columns, false, formatOptions) + + // Row must have exactly 2 cells: the scalar id and the array as a single nested value. + expect(result).toHaveLength(1) + expect(result[0]).toHaveLength(2) + expect(result[0][0]).toBe(42) + expect(result[0][1]).toEqual(['admin', 'editor']) + }) +}) + describe('formatUpdateResults', () => { test('with insertIds', async () => { let { updateResults } = require('#fixtures/sample-batch-insert-response.json') diff --git a/src/results.ts b/src/results.ts index 440ce48..3ca320d 100644 --- a/src/results.ts +++ b/src/results.ts @@ -67,7 +67,7 @@ export const formatRecords = ( if (field.isNull === true) { return hydrate // object if hydrate, else array ? Object.assign(acc, { [fmap[i].label!]: null }) - : acc.concat(null) + : [...acc, null] // If the field is mapped, return the mapped field } else if (fmap[i] && fmap[i].field) { @@ -76,7 +76,7 @@ export const formatRecords = ( const value = formatRecordValue((field as any)[fmap[i].field!], fmap[i].typeName, formatOptions) return hydrate // object if hydrate, else array ? Object.assign(acc, { [fmap[i].label!]: value }) - : acc.concat(value) + : [...acc, value] // Else discover the field type } else { @@ -93,7 +93,7 @@ export const formatRecords = ( const value = formatRecordValue((field as any)[fmap[i].field!], fmap[i].typeName, formatOptions) return hydrate // object if hydrate, else array ? Object.assign(acc, { [fmap[i].label!]: value }) - : acc.concat(value) + : [...acc, value] } }, hydrate ? {} : [] diff --git a/src/types.ts b/src/types.ts index fcafe38..bb78729 100644 --- a/src/types.ts +++ b/src/types.ts @@ -103,6 +103,7 @@ export interface NamedParameter { name: string value: ParameterValue cast?: string + typeHint?: string } // Parameter can be an object of key-value pairs or an array of named parameters From 14e64817f1f6c43466bdf7cf5ed2a8c7145d8926 Mon Sep 17 00:00:00 2001 From: Jeremy Daly Date: Tue, 16 Jun 2026 11:51:50 -0400 Subject: [PATCH 2/3] test(compat/prisma): comprehensive Prisma Client API coverage suites (PG + MySQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opt-in audits exercising the full Prisma Client query surface over the Data API on both engines (179 tests: 99 pg, 80 mysql): CRUD, aggregation/groupBy, all filter operators, pagination/cursor/distinct, select/include/omit, nested writes, relation filters, atomic number updates, JSON, Decimal/BigInt/DateTime, scalar arrays (pg), raw queries, and transactions. Zero adapter bugs — the MySQL gaps (mode:insensitive, createManyAndReturn, disconnect/set on required relations, scalar arrays) are standard Prisma-on-MySQL behavior, identical to the native mysql2 driver. Wired as opt-in scripts (test:int:orm:prisma:coverage[:pg|:mysql]) that generate their own client; the default test:int:orm:prisma stays lean. --- .../prisma-mysql-coverage.int.test.ts | 900 +++++++++++++++ .../prisma-pg-coverage.int.test.ts | 1028 +++++++++++++++++ .../prisma/schema-mysql-coverage.prisma | 34 + .../prisma/schema-pg-coverage.prisma | 35 + 4 files changed, 1997 insertions(+) create mode 100644 integration-tests/prisma-mysql-coverage.int.test.ts create mode 100644 integration-tests/prisma-pg-coverage.int.test.ts create mode 100644 integration-tests/prisma/schema-mysql-coverage.prisma create mode 100644 integration-tests/prisma/schema-pg-coverage.prisma diff --git a/integration-tests/prisma-mysql-coverage.int.test.ts b/integration-tests/prisma-mysql-coverage.int.test.ts new file mode 100644 index 0000000..5383775 --- /dev/null +++ b/integration-tests/prisma-mysql-coverage.int.test.ts @@ -0,0 +1,900 @@ +/** + * Comprehensive Prisma Client API coverage test over the Aurora RDS Data API (MySQL). + * + * This file exercises the full Prisma query API against the Data API adapter + * (createPrismaMySQLAdapter) to audit pass/fail coverage. + * + * Tables are created via raw DDL in beforeAll and dropped in afterAll. + * Credentials must be sourced in the same command: + * source .env.local && npx vitest run integration-tests/prisma-mysql-coverage.int.test.ts + */ + +import { describe, test, expect, beforeAll, afterAll, beforeEach } from 'vitest' +import { createPrismaMySQLAdapter } from '../src/compat/prisma' +import { loadConfig } from './setup' +import { PrismaClient, Prisma } from './prisma/generated-mysql-coverage' + +// --------------------------------------------------------------------------- +// DDL +// --------------------------------------------------------------------------- +const CREATE_DDL = ` +DROP TABLE IF EXISTS prisma_cov_post; +DROP TABLE IF EXISTS prisma_cov_user; +CREATE TABLE prisma_cov_user ( + id INT AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(191) UNIQUE NOT NULL, + name VARCHAR(191), + age INT, + score DECIMAL(10, 2), + balance BIGINT, + active TINYINT(1) NOT NULL DEFAULT 1, + category VARCHAR(191), + meta JSON, + createdAt DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE TABLE prisma_cov_post ( + id INT AUTO_INCREMENT PRIMARY KEY, + title VARCHAR(191) NOT NULL, + views INT NOT NULL DEFAULT 0, + authorId INT NOT NULL, + CONSTRAINT fk_cov_author FOREIGN KEY (authorId) REFERENCES prisma_cov_user(id) +) +` + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +type FactoryConfig = { resourceArn: string; secretArn: string; database: string } +let prisma: PrismaClient +let factoryConfig: FactoryConfig + +// Seed data — inserted once before each top-level describe group. +// We teardown+re-seed within each group so groups are independent. +const SEED_USERS = [ + { email: 'alice@cov.test', name: 'Alice', age: 30, score: new Prisma.Decimal('9.50'), balance: BigInt(1000000), active: true, category: 'admin' }, + { email: 'bob@cov.test', name: 'Bob', age: 25, score: new Prisma.Decimal('7.25'), balance: BigInt(500000), active: true, category: 'user' }, + { email: 'carol@cov.test', name: 'Carol', age: 35, score: new Prisma.Decimal('8.00'), balance: BigInt(250000), active: false, category: 'user' }, + { email: 'dave@cov.test', name: null, age: null, score: null, balance: null, active: true, category: 'admin' }, +] + +async function truncate() { + await prisma.$executeRawUnsafe('DELETE FROM prisma_cov_post') + await prisma.$executeRawUnsafe('DELETE FROM prisma_cov_user') +} + +async function seed() { + await truncate() + for (const u of SEED_USERS) { + await prisma.covUser.create({ data: u }) + } +} + +// --------------------------------------------------------------------------- +// Global setup / teardown +// --------------------------------------------------------------------------- +beforeAll(async () => { + const cfg = loadConfig('mysql') + factoryConfig = { resourceArn: cfg.resourceArn, secretArn: cfg.secretArn, database: cfg.database } + const factory = createPrismaMySQLAdapter(factoryConfig) + const setup = await factory.connect() + await setup.executeScript(CREATE_DDL) + prisma = new PrismaClient({ adapter: factory } as any) +}, 120000) + +afterAll(async () => { + if (prisma) { + const factory = createPrismaMySQLAdapter(factoryConfig) + const td = await factory.connect() + await td.executeScript('DROP TABLE IF EXISTS prisma_cov_post; DROP TABLE IF EXISTS prisma_cov_user;') + await prisma.$disconnect() + } +}, 60000) + +// --------------------------------------------------------------------------- +// CRUD +// --------------------------------------------------------------------------- +describe('CRUD', () => { + beforeEach(seed) + + test('create — returns autoincrement id', async () => { + const u = await prisma.covUser.create({ + data: { email: 'new@cov.test', name: 'New', age: 20, category: 'user' }, + }) + expect(u.id).toBeGreaterThan(0) + expect(u.name).toBe('New') + expect(u.active).toBe(true) // default + }) + + test('createMany — inserts multiple rows', async () => { + await truncate() + const result = await prisma.covUser.createMany({ + data: [ + { email: 'x@cov.test', name: 'X', age: 10, category: 'user' }, + { email: 'y@cov.test', name: 'Y', age: 20, category: 'user' }, + ], + }) + expect(result.count).toBe(2) + }) + + test('createMany — skipDuplicates ignores unique conflicts', async () => { + // alice already exists from seed + const result = await prisma.covUser.createMany({ + data: [ + { email: 'alice@cov.test', name: 'AliceDup', age: 99, category: 'user' }, + { email: 'zzz@cov.test', name: 'ZZZ', age: 5, category: 'user' }, + ], + skipDuplicates: true, + }) + expect(result.count).toBe(1) // only zzz inserted + }) + + test('findUnique — by unique field', async () => { + const u = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' } }) + expect(u?.name).toBe('Alice') + }) + + test('findUniqueOrThrow — throws when not found', async () => { + await expect( + prisma.covUser.findUniqueOrThrow({ where: { email: 'nobody@cov.test' } }) + ).rejects.toMatchObject({ code: 'P2025' }) + }) + + test('findFirst — returns first matching row', async () => { + // MySQL sorts NULLs first in ASC order, so exclude null-age rows. + const u = await prisma.covUser.findFirst({ + where: { active: true, age: { not: null } }, + orderBy: { age: 'asc' }, + }) + expect(u?.email).toBe('bob@cov.test') // Bob age 25, smallest active with non-null age + }) + + test('findFirstOrThrow — throws when not found', async () => { + await expect( + prisma.covUser.findFirstOrThrow({ where: { email: 'nobody@cov.test' } }) + ).rejects.toMatchObject({ code: 'P2025' }) + }) + + test('findMany — returns all rows', async () => { + const users = await prisma.covUser.findMany() + expect(users.length).toBe(4) + }) + + test('update — modifies a field', async () => { + const updated = await prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { age: 99 }, + }) + expect(updated.age).toBe(99) + }) + + test('updateMany — updates multiple rows', async () => { + const result = await prisma.covUser.updateMany({ + where: { category: 'user' }, + data: { active: false }, + }) + expect(result.count).toBeGreaterThanOrEqual(2) + }) + + test('upsert — create path (record does not exist)', async () => { + const u = await prisma.covUser.upsert({ + where: { email: 'upsert-new@cov.test' }, + create: { email: 'upsert-new@cov.test', name: 'UpsertNew', age: 1, category: 'user' }, + update: { age: 99 }, + }) + expect(u.name).toBe('UpsertNew') + expect(u.age).toBe(1) + }) + + test('upsert — update path (record exists)', async () => { + const u = await prisma.covUser.upsert({ + where: { email: 'alice@cov.test' }, + create: { email: 'alice@cov.test', name: 'AliceNew', age: 0, category: 'user' }, + update: { age: 42 }, + }) + expect(u.age).toBe(42) + }) + + test('delete — removes a row', async () => { + await prisma.covUser.delete({ where: { email: 'dave@cov.test' } }) + const found = await prisma.covUser.findUnique({ where: { email: 'dave@cov.test' } }) + expect(found).toBeNull() + }) + + test('deleteMany — removes multiple rows', async () => { + const result = await prisma.covUser.deleteMany({ where: { category: 'user' } }) + expect(result.count).toBeGreaterThanOrEqual(2) + }) + + // createManyAndReturn: Prisma 7.x exposes this method on MySQL but it requires + // the underlying driver to support RETURNING, which MySQL does not. The method + // exists but Prisma throws at query-plan time when used against MySQL. + // We document that the method exists (function) but execution throws. + test('createManyAndReturn — method exists but MySQL lacks RETURNING (EXPECTED-UNSUPPORTED)', async () => { + // The method is present as a function on newer Prisma clients even for MySQL + expect(typeof prisma.covUser.createManyAndReturn).toBe('function') + // Attempting to call it should throw a Prisma error about unsupported feature + await expect( + prisma.covUser.createManyAndReturn({ + data: [{ email: 'cmar@cov.test', name: 'CmarUser', category: 'user' }], + }) + ).rejects.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// Filtering operators +// --------------------------------------------------------------------------- +describe('Filtering operators', () => { + beforeEach(seed) + + test('equals (implicit)', async () => { + const users = await prisma.covUser.findMany({ where: { name: 'Alice' } }) + expect(users.length).toBe(1) + }) + + test('not (scalar)', async () => { + const users = await prisma.covUser.findMany({ where: { name: { not: 'Alice' } } }) + // Bob, Carol, Dave(null) — not returns rows with non-matching values; null handling depends on Prisma + // Dave has name=null, which Prisma's `not` skips (SQL IS NULL != != 'Alice') + expect(users.some((u) => u.name === 'Bob')).toBe(true) + }) + + test('in', async () => { + const users = await prisma.covUser.findMany({ where: { email: { in: ['alice@cov.test', 'bob@cov.test'] } } }) + expect(users.length).toBe(2) + }) + + test('notIn', async () => { + const users = await prisma.covUser.findMany({ where: { email: { notIn: ['alice@cov.test', 'bob@cov.test'] } } }) + expect(users.length).toBe(2) // Carol, Dave + }) + + test('lt', async () => { + const users = await prisma.covUser.findMany({ where: { age: { lt: 30 } } }) + expect(users.every((u) => (u.age ?? 99) < 30)).toBe(true) + }) + + test('lte', async () => { + const users = await prisma.covUser.findMany({ where: { age: { lte: 30 } } }) + expect(users.every((u) => (u.age ?? 99) <= 30)).toBe(true) + }) + + test('gt', async () => { + const users = await prisma.covUser.findMany({ where: { age: { gt: 30 } } }) + expect(users.every((u) => (u.age ?? 0) > 30)).toBe(true) + }) + + test('gte', async () => { + const users = await prisma.covUser.findMany({ where: { age: { gte: 30 } } }) + expect(users.every((u) => (u.age ?? 0) >= 30)).toBe(true) + }) + + test('contains', async () => { + const users = await prisma.covUser.findMany({ where: { name: { contains: 'li' } } }) + expect(users.some((u) => u.name === 'Alice')).toBe(true) + }) + + test('startsWith', async () => { + const users = await prisma.covUser.findMany({ where: { name: { startsWith: 'Al' } } }) + expect(users.length).toBe(1) + expect(users[0].name).toBe('Alice') + }) + + test('endsWith', async () => { + const users = await prisma.covUser.findMany({ where: { name: { endsWith: 'ob' } } }) + expect(users.length).toBe(1) + expect(users[0].name).toBe('Bob') + }) + + test('AND (explicit)', async () => { + const users = await prisma.covUser.findMany({ + where: { AND: [{ age: { gte: 25 } }, { active: true }] }, + }) + expect(users.every((u) => (u.age ?? 0) >= 25 && u.active)).toBe(true) + }) + + test('OR', async () => { + const users = await prisma.covUser.findMany({ + where: { OR: [{ email: 'alice@cov.test' }, { email: 'bob@cov.test' }] }, + }) + expect(users.length).toBe(2) + }) + + test('NOT (top-level)', async () => { + const users = await prisma.covUser.findMany({ + where: { NOT: { email: 'alice@cov.test' } }, + }) + expect(users.every((u) => u.email !== 'alice@cov.test')).toBe(true) + }) + + test('null filter — { field: null }', async () => { + const users = await prisma.covUser.findMany({ where: { name: null } }) + expect(users.every((u) => u.name === null)).toBe(true) + expect(users.length).toBe(1) // Dave + }) + + test('null filter — { field: { not: null } }', async () => { + const users = await prisma.covUser.findMany({ where: { name: { not: null } } }) + expect(users.every((u) => u.name !== null)).toBe(true) + expect(users.length).toBe(3) + }) + + test('nested AND+OR combination', async () => { + const users = await prisma.covUser.findMany({ + where: { + AND: [ + { active: true }, + { OR: [{ category: 'admin' }, { age: { lt: 28 } }] }, + ], + }, + }) + // Alice (active, admin), Bob (active, age 25 < 28), Dave (active, admin, null age) + expect(users.length).toBeGreaterThanOrEqual(2) + }) + + // mode: 'insensitive' is NOT supported on MySQL by Prisma (MySQL is case-insensitive by default + // via collation, but Prisma does not expose the `mode` option for MySQL). + // Prisma throws a validation error when you attempt to use it. + test('mode: insensitive — EXPECTED-UNSUPPORTED on MySQL', async () => { + await expect( + prisma.covUser.findMany({ + where: { + // @ts-expect-error - mode not supported on MySQL + name: { contains: 'alice', mode: 'insensitive' }, + }, + }) + ).rejects.toThrow() + }) +}) + +// --------------------------------------------------------------------------- +// Pagination / sorting +// --------------------------------------------------------------------------- +describe('Pagination and sorting', () => { + beforeEach(seed) + + test('take', async () => { + const users = await prisma.covUser.findMany({ take: 2, orderBy: { id: 'asc' } }) + expect(users.length).toBe(2) + }) + + test('skip', async () => { + const all = await prisma.covUser.findMany({ orderBy: { id: 'asc' } }) + const skipped = await prisma.covUser.findMany({ skip: 1, orderBy: { id: 'asc' } }) + expect(skipped[0].id).toBe(all[1].id) + }) + + test('cursor', async () => { + const all = await prisma.covUser.findMany({ orderBy: { id: 'asc' } }) + const cursor = all[1].id + const page = await prisma.covUser.findMany({ cursor: { id: cursor }, orderBy: { id: 'asc' } }) + expect(page[0].id).toBe(cursor) + }) + + test('orderBy asc', async () => { + const users = await prisma.covUser.findMany({ orderBy: { age: 'asc' }, where: { age: { not: null } } }) + for (let i = 1; i < users.length; i++) { + expect((users[i].age ?? 0) >= (users[i - 1].age ?? 0)).toBe(true) + } + }) + + test('orderBy desc', async () => { + const users = await prisma.covUser.findMany({ orderBy: { age: 'desc' }, where: { age: { not: null } } }) + for (let i = 1; i < users.length; i++) { + expect((users[i].age ?? 99) <= (users[i - 1].age ?? 0)).toBe(true) + } + }) + + test('multi-field orderBy', async () => { + const users = await prisma.covUser.findMany({ orderBy: [{ category: 'asc' }, { age: 'asc' }] }) + expect(users.length).toBe(4) + }) + + test('distinct', async () => { + const rows = await prisma.covUser.findMany({ + select: { category: true }, + distinct: ['category'], + where: { category: { not: null } }, + }) + const cats = rows.map((r) => r.category) + expect(cats.length).toBe(new Set(cats).size) + expect(cats.length).toBe(2) // admin, user + }) +}) + +// --------------------------------------------------------------------------- +// Field selection +// --------------------------------------------------------------------------- +describe('Field selection', () => { + beforeEach(seed) + + test('select (subset of fields)', async () => { + const users = await prisma.covUser.findMany({ select: { id: true, email: true } }) + expect(users[0]).toHaveProperty('id') + expect(users[0]).toHaveProperty('email') + expect(users[0]).not.toHaveProperty('name') + }) + + test('include (relation)', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.create({ data: { title: 'Test Post', authorId: alice.id } }) + const user = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' }, include: { posts: true } }) + expect(Array.isArray(user?.posts)).toBe(true) + expect(user?.posts.length).toBe(1) + }) + + test('omit (exclude a field)', async () => { + const user = await prisma.covUser.findFirst({ omit: { meta: true } }) + expect(user).not.toHaveProperty('meta') + expect(user).toHaveProperty('email') + }) + + test('nested select on relation', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.create({ data: { title: 'Nested Select Post', authorId: alice.id } }) + const posts = await prisma.covPost.findMany({ + select: { + title: true, + author: { select: { name: true } }, + }, + }) + expect(posts[0]).toHaveProperty('title') + expect(posts[0].author).toHaveProperty('name') + expect(posts[0].author).not.toHaveProperty('email') + }) +}) + +// --------------------------------------------------------------------------- +// Aggregation +// --------------------------------------------------------------------------- +describe('Aggregation', () => { + beforeEach(seed) + + test('count — total rows', async () => { + const result = await prisma.covUser.count() + expect(result).toBe(4) + }) + + test('count — with where', async () => { + const result = await prisma.covUser.count({ where: { active: true } }) + expect(result).toBe(3) + }) + + test('aggregate — _count, _avg, _sum, _min, _max', async () => { + const result = await prisma.covUser.aggregate({ + _count: { id: true }, + _avg: { age: true }, + _sum: { age: true }, + _min: { age: true }, + _max: { age: true }, + where: { age: { not: null } }, + }) + expect(result._count.id).toBe(3) + expect(result._avg.age).toBeCloseTo((30 + 25 + 35) / 3, 1) + expect(result._sum.age).toBe(90) + expect(result._min.age).toBe(25) + expect(result._max.age).toBe(35) + }) + + test('groupBy — by category with _count', async () => { + const groups = await prisma.covUser.groupBy({ + by: ['category'], + _count: { id: true }, + where: { category: { not: null } }, + }) + expect(groups.length).toBe(2) // admin, user + const adminGroup = groups.find((g) => g.category === 'admin') + expect(adminGroup?._count.id).toBe(2) + }) + + test('groupBy — with having', async () => { + const groups = await prisma.covUser.groupBy({ + by: ['category'], + _count: { id: true }, + having: { id: { _count: { gt: 1 } } }, + where: { category: { not: null } }, + }) + // Both admin and user have 2 members, so both qualify + expect(groups.length).toBeGreaterThanOrEqual(1) + }) +}) + +// --------------------------------------------------------------------------- +// Atomic number updates +// --------------------------------------------------------------------------- +describe('Atomic number operations', () => { + beforeEach(async () => { + await seed() + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + // Give Alice a post to work with + await prisma.covPost.create({ data: { title: 'Atomic Post', views: 10, authorId: alice.id } }) + }) + + test('increment', async () => { + const post = await prisma.covPost.findFirst({ where: { title: 'Atomic Post' } }) + const updated = await prisma.covPost.update({ + where: { id: post!.id }, + data: { views: { increment: 5 } }, + }) + expect(updated.views).toBe(15) + }) + + test('decrement', async () => { + const post = await prisma.covPost.findFirst({ where: { title: 'Atomic Post' } }) + const updated = await prisma.covPost.update({ + where: { id: post!.id }, + data: { views: { decrement: 3 } }, + }) + expect(updated.views).toBe(7) + }) + + test('multiply', async () => { + const post = await prisma.covPost.findFirst({ where: { title: 'Atomic Post' } }) + const updated = await prisma.covPost.update({ + where: { id: post!.id }, + data: { views: { multiply: 2 } }, + }) + expect(updated.views).toBe(20) + }) + + test('divide', async () => { + const post = await prisma.covPost.findFirst({ where: { title: 'Atomic Post' } }) + const updated = await prisma.covPost.update({ + where: { id: post!.id }, + data: { views: { divide: 2 } }, + }) + expect(updated.views).toBe(5) + }) +}) + +// --------------------------------------------------------------------------- +// JSON field +// --------------------------------------------------------------------------- +describe('JSON field', () => { + beforeEach(seed) + + test('write meta object and read it back', async () => { + const created = await prisma.covUser.create({ + data: { email: 'json@cov.test', name: 'JsonUser', category: 'user', meta: { role: 'admin', level: 3 } }, + }) + expect(created.meta).toEqual({ role: 'admin', level: 3 }) + }) + + test('update meta', async () => { + await prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { meta: { updated: true } }, + }) + const found = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' } }) + expect(found?.meta).toEqual({ updated: true }) + }) + + test('meta null by default', async () => { + const found = await prisma.covUser.findUnique({ where: { email: 'bob@cov.test' } }) + expect(found?.meta).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// Special types +// --------------------------------------------------------------------------- +describe('Special types', () => { + beforeEach(seed) + + test('Decimal round-trip', async () => { + const found = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' } }) + // Prisma returns Decimal as a Decimal.js instance. + // MySQL may strip trailing zeros (9.50 → 9.5), so compare numerically. + expect(found?.score?.toNumber()).toBeCloseTo(9.5, 2) + // Confirm the value is a Prisma Decimal (not a plain number/string) + expect(found?.score).toBeInstanceOf(Prisma.Decimal) + }) + + test('BigInt round-trip', async () => { + const found = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' } }) + expect(found?.balance).toBe(BigInt(1000000)) + }) + + test('DateTime round-trip + date range filter', async () => { + const past = new Date('2020-01-01T00:00:00Z') + const future = new Date('2099-01-01T00:00:00Z') + const users = await prisma.covUser.findMany({ + where: { createdAt: { gte: past, lte: future } }, + }) + expect(users.length).toBe(4) + }) +}) + +// --------------------------------------------------------------------------- +// Relations + nested writes +// --------------------------------------------------------------------------- +describe('Relations and nested writes', () => { + beforeEach(seed) + + test('nested create — user with posts in one call', async () => { + const user = await prisma.covUser.create({ + data: { + email: 'nested@cov.test', + name: 'NestedUser', + category: 'user', + posts: { + create: [{ title: 'Post One' }, { title: 'Post Two' }], + }, + }, + include: { posts: true }, + }) + expect(user.posts.length).toBe(2) + }) + + test('connect — attach an existing user as post author', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + const post = await prisma.covPost.create({ + data: { title: 'Connect Post', author: { connect: { id: alice.id } } }, + }) + expect(post.authorId).toBe(alice.id) + }) + + test('connectOrCreate — creates user if not found, connects if found', async () => { + const post = await prisma.covPost.create({ + data: { + title: 'ConnectOrCreate Post', + author: { + connectOrCreate: { + where: { email: 'coc@cov.test' }, + create: { email: 'coc@cov.test', name: 'CocUser', category: 'user' }, + }, + }, + }, + include: { author: true }, + }) + expect(post.author.email).toBe('coc@cov.test') + }) + + test('nested update — update post title through user', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.create({ data: { title: 'Before Update', authorId: alice.id } }) + const post = await prisma.covPost.findFirst({ where: { title: 'Before Update' } }) + await prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { + posts: { + update: { + where: { id: post!.id }, + data: { title: 'After Update' }, + }, + }, + }, + }) + const updated = await prisma.covPost.findUnique({ where: { id: post!.id } }) + expect(updated?.title).toBe('After Update') + }) + + test('nested upsert — upsert post through user relation', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + const p = await prisma.covPost.create({ data: { title: 'Upsert Post', authorId: alice.id } }) + await prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { + posts: { + upsert: { + where: { id: p.id }, + create: { title: 'Upsert Create' }, + update: { title: 'Upsert Updated' }, + }, + }, + }, + }) + const found = await prisma.covPost.findUnique({ where: { id: p.id } }) + expect(found?.title).toBe('Upsert Updated') + }) + + test('nested delete — delete post through user relation', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + const p = await prisma.covPost.create({ data: { title: 'To Be Deleted', authorId: alice.id } }) + await prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { + posts: { + delete: { id: p.id }, + }, + }, + }) + const found = await prisma.covPost.findUnique({ where: { id: p.id } }) + expect(found).toBeNull() + }) + + test('disconnect — detach is not supported on non-optional 1-M (FK required)', async () => { + // On a required foreign key (authorId NOT NULL), disconnect is not possible. + // Prisma should throw an error. + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + const p = await prisma.covPost.create({ data: { title: 'Disconnect Test', authorId: alice.id } }) + await expect( + prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { + posts: { + // @ts-expect-error - disconnect not allowed on required relation + disconnect: { id: p.id }, + }, + }, + }) + ).rejects.toThrow() + }) + + test('set — replace posts collection', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + // Create two posts + await prisma.covPost.createMany({ + data: [ + { title: 'Set Post 1', authorId: alice.id }, + { title: 'Set Post 2', authorId: alice.id }, + ], + }) + // `set` on required 1-M can only set to existing (already-owned) posts. + // Setting to an empty array disconnects all — but authorId NOT NULL prevents it. + // On MySQL with required FK, `set` is not supported. Document the behavior. + await expect( + prisma.covUser.update({ + where: { email: 'alice@cov.test' }, + data: { + posts: { + // @ts-expect-error - set not supported on required relation + set: [], + }, + }, + }) + ).rejects.toThrow() + }) + + test('relation filter — some', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.create({ data: { title: 'Some Filter Post', authorId: alice.id } }) + const users = await prisma.covUser.findMany({ + where: { posts: { some: { title: { contains: 'Filter' } } } }, + }) + expect(users.some((u) => u.email === 'alice@cov.test')).toBe(true) + }) + + test('relation filter — every', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.createMany({ + data: [ + { title: 'Every Post A', authorId: alice.id }, + { title: 'Every Post B', authorId: alice.id }, + ], + }) + const users = await prisma.covUser.findMany({ + where: { posts: { every: { title: { startsWith: 'Every' } } } }, + }) + // Alice's posts all start with 'Every'; users with no posts also satisfy 'every' + expect(users.length).toBeGreaterThanOrEqual(1) + }) + + test('relation filter — none', async () => { + // Users with no posts + const users = await prisma.covUser.findMany({ + where: { posts: { none: {} } }, + }) + // Bob, Carol, Dave have no posts + expect(users.length).toBeGreaterThanOrEqual(3) + }) + + test('relation filter — is (to-one)', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.create({ data: { title: 'Is Filter Post', authorId: alice.id } }) + const posts = await prisma.covPost.findMany({ + where: { author: { is: { email: 'alice@cov.test' } } }, + }) + expect(posts.every((p) => p.authorId === alice.id)).toBe(true) + }) + + test('relation filter — isNot (to-one)', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + const bob = await prisma.covUser.findUniqueOrThrow({ where: { email: 'bob@cov.test' } }) + await prisma.covPost.create({ data: { title: 'IsNot Post A', authorId: alice.id } }) + await prisma.covPost.create({ data: { title: 'IsNot Post B', authorId: bob.id } }) + const posts = await prisma.covPost.findMany({ + where: { author: { isNot: { email: 'alice@cov.test' } } }, + }) + expect(posts.every((p) => p.authorId !== alice.id)).toBe(true) + }) + + test('include with nested where and orderBy', async () => { + const alice = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + await prisma.covPost.createMany({ + data: [ + { title: 'Nested Where A', views: 10, authorId: alice.id }, + { title: 'Nested Where B', views: 20, authorId: alice.id }, + { title: 'Nested Where C', views: 5, authorId: alice.id }, + ], + }) + const user = await prisma.covUser.findUnique({ + where: { email: 'alice@cov.test' }, + include: { + posts: { + where: { views: { gte: 10 } }, + orderBy: { views: 'desc' }, + }, + }, + }) + expect(user?.posts.length).toBe(2) + expect(user?.posts[0].views).toBeGreaterThanOrEqual(user?.posts[1].views ?? 0) + }) +}) + +// --------------------------------------------------------------------------- +// Raw queries +// --------------------------------------------------------------------------- +describe('Raw queries', () => { + beforeEach(seed) + + test('$queryRaw — tagged template with a param', async () => { + // Use 1 instead of true: MySQL TINYINT(1) active column compares correctly to integer 1. + // Prisma's raw query template maps boolean true to 1 for MySQL but the binding + // goes through the Data API as booleanValue which MySQL may not coerce in WHERE. + const rows = await prisma.$queryRaw<{ cnt: bigint }[]>`SELECT COUNT(*) as cnt FROM prisma_cov_user WHERE active = ${1}` + expect(Number(rows[0].cnt)).toBe(3) + }) + + test('$queryRawUnsafe — string query', async () => { + const rows = await prisma.$queryRawUnsafe<{ email: string }[]>( + 'SELECT email FROM prisma_cov_user WHERE email = ?', + 'alice@cov.test' + ) + expect(rows[0].email).toBe('alice@cov.test') + }) + + test('$executeRaw — tagged template', async () => { + const count = await prisma.$executeRaw`UPDATE prisma_cov_user SET age = ${99} WHERE email = ${'alice@cov.test'}` + expect(count).toBe(1) + }) + + test('$executeRawUnsafe — string query', async () => { + const count = await prisma.$executeRawUnsafe( + 'UPDATE prisma_cov_user SET age = ? WHERE email = ?', + 88, + 'bob@cov.test' + ) + expect(count).toBe(1) + const bob = await prisma.covUser.findUnique({ where: { email: 'bob@cov.test' } }) + expect(bob?.age).toBe(88) + }) +}) + +// --------------------------------------------------------------------------- +// Transactions +// --------------------------------------------------------------------------- +describe('Transactions', () => { + beforeEach(seed) + + test('sequential array form — $transaction([...])', async () => { + const [created, updated] = await prisma.$transaction([ + prisma.covUser.create({ data: { email: 'tx-array@cov.test', name: 'TxArray', category: 'user' } }), + prisma.covUser.update({ where: { email: 'alice@cov.test' }, data: { age: 55 } }), + ]) + expect((created as any).email).toBe('tx-array@cov.test') + expect((updated as any).age).toBe(55) + }) + + test('interactive transaction — commit', async () => { + await prisma.$transaction(async (tx) => { + await tx.covUser.create({ data: { email: 'tx-commit@cov.test', name: 'TxCommit', category: 'user' } }) + await tx.covUser.update({ where: { email: 'alice@cov.test' }, data: { age: 77 } }) + }) + const user = await prisma.covUser.findUnique({ where: { email: 'tx-commit@cov.test' } }) + expect(user).not.toBeNull() + const alice = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' } }) + expect(alice?.age).toBe(77) + }) + + test('interactive transaction — rollback on throw', async () => { + await expect( + prisma.$transaction(async (tx) => { + await tx.covUser.create({ data: { email: 'tx-rollback@cov.test', name: 'TxRollback', category: 'user' } }) + throw new Error('intentional rollback') + }) + ).rejects.toThrow('intentional rollback') + const user = await prisma.covUser.findUnique({ where: { email: 'tx-rollback@cov.test' } }) + expect(user).toBeNull() + }) +}) diff --git a/integration-tests/prisma-pg-coverage.int.test.ts b/integration-tests/prisma-pg-coverage.int.test.ts new file mode 100644 index 0000000..690dd98 --- /dev/null +++ b/integration-tests/prisma-pg-coverage.int.test.ts @@ -0,0 +1,1028 @@ +/** + * Comprehensive Prisma Client query API coverage test over the Aurora RDS Data API (PostgreSQL). + * This test suite audits every major Prisma query API surface area. + * + * Run: source .env.local && npx vitest run integration-tests/prisma-pg-coverage.int.test.ts + */ +import { describe, test, expect, beforeAll, afterAll } from 'vitest' +import { createPrismaPgAdapter } from '../src/compat/prisma' +import { loadConfig, type IntegrationTestConfig } from './setup' +import { PrismaClient, Prisma } from './prisma/generated-pg-coverage' + +const DDL = ` +DROP TABLE IF EXISTS prisma_cov_post; +DROP TABLE IF EXISTS prisma_cov_user; +CREATE TABLE prisma_cov_user ( + id SERIAL PRIMARY KEY, + email TEXT UNIQUE NOT NULL, + name TEXT, + age INT, + score NUMERIC(10,4), + balance BIGINT, + active BOOLEAN NOT NULL DEFAULT TRUE, + category TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + meta JSONB, + "createdAt" TIMESTAMP NOT NULL DEFAULT NOW() +); +CREATE TABLE prisma_cov_post ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + views INT NOT NULL DEFAULT 0, + "authorId" INT REFERENCES prisma_cov_user(id) +) +` + +const DROP_DDL = `DROP TABLE IF EXISTS prisma_cov_post; DROP TABLE IF EXISTS prisma_cov_user` + +describe('Prisma Coverage (PostgreSQL)', () => { + let config: IntegrationTestConfig + let prisma: PrismaClient + let factory: ReturnType + + // Seeded user IDs for tests that need existing records + let aliceId: number + let bobId: number + let carolId: number + let daveId: number + let eveId: number + + beforeAll(async () => { + config = loadConfig('pg') + factory = createPrismaPgAdapter({ + resourceArn: config.resourceArn, + secretArn: config.secretArn, + database: config.database + }) + const setup = await factory.connect() + await setup.executeScript(DDL) + prisma = new PrismaClient({ adapter: factory } as any) + + // Seed varied users + const alice = await prisma.covUser.create({ + data: { + email: 'alice@cov.test', + name: 'Alice', + age: 30, + score: new Prisma.Decimal('9.5'), + balance: BigInt(100000), + category: 'admin', + tags: ['admin', 'editor'], + meta: { role: 'admin', level: 1 }, + active: true + } + }) + aliceId = alice.id + + const bob = await prisma.covUser.create({ + data: { + email: 'bob@cov.test', + name: 'Bob', + age: 25, + score: new Prisma.Decimal('7.0'), + balance: BigInt(50000), + category: 'user', + tags: ['viewer'], + meta: { role: 'user', level: 2 }, + active: true + } + }) + bobId = bob.id + + const carol = await prisma.covUser.create({ + data: { + email: 'carol@cov.test', + name: 'Carol', + age: 35, + score: new Prisma.Decimal('8.25'), + balance: BigInt(75000), + category: 'admin', + tags: ['editor', 'viewer'], + meta: { role: 'admin', level: 3 }, + active: false + } + }) + carolId = carol.id + + const dave = await prisma.covUser.create({ + data: { + email: 'dave@cov.test', + name: 'Dave', + age: null, + score: null, + balance: null, + category: 'user', + tags: [], + meta: null, + active: true + } + }) + daveId = dave.id + + const eve = await prisma.covUser.create({ + data: { + email: 'eve@cov.test', + name: null, + age: 28, + score: new Prisma.Decimal('6.0'), + balance: BigInt(10000), + category: 'user', + tags: ['admin'], + meta: { role: 'user', level: 1 }, + active: true + } + }) + eveId = eve.id + + // Seed posts + await prisma.covPost.createMany({ + data: [ + { title: 'First Post', views: 10, authorId: aliceId }, + { title: 'Second Post', views: 50, authorId: aliceId }, + { title: 'Bob Post', views: 5, authorId: bobId } + ] + }) + }, 120000) + + afterAll(async () => { + if (factory) { + const td = await factory.connect() + await td.executeScript(DROP_DDL) + } + if (prisma) { + await prisma.$disconnect() + } + }, 60000) + + // ───────────────────────────────────────────────────────────────────────────── + // CRUD + // ───────────────────────────────────────────────────────────────────────────── + describe('CRUD', () => { + test('create - basic record creation', async () => { + const u = await prisma.covUser.create({ + data: { email: 'crud-create@cov.test', name: 'CrudCreate', age: 20, category: 'user' } + }) + expect(u.id).toBeGreaterThan(0) + expect(u.email).toBe('crud-create@cov.test') + expect(u.name).toBe('CrudCreate') + await prisma.covUser.delete({ where: { id: u.id } }) + }) + + test('createMany - insert multiple records', async () => { + const result = await prisma.covUser.createMany({ + data: [ + { email: 'many1@cov.test', name: 'Many1', category: 'user' }, + { email: 'many2@cov.test', name: 'Many2', category: 'user' } + ] + }) + expect(result.count).toBe(2) + await prisma.covUser.deleteMany({ where: { email: { in: ['many1@cov.test', 'many2@cov.test'] } } }) + }) + + test('createMany - skipDuplicates ignores conflicts', async () => { + const result = await prisma.covUser.createMany({ + data: [ + { email: 'alice@cov.test', name: 'DupAlice', category: 'user' }, // duplicate + { email: 'skip-unique@cov.test', name: 'NewSkip', category: 'user' } + ], + skipDuplicates: true + }) + expect(result.count).toBe(1) // only the new one + await prisma.covUser.deleteMany({ where: { email: 'skip-unique@cov.test' } }) + }) + + test('createManyAndReturn - returns created records (PG)', async () => { + const records = await (prisma.covUser as any).createManyAndReturn({ + data: [ + { email: 'ret1@cov.test', name: 'Ret1', category: 'user' }, + { email: 'ret2@cov.test', name: 'Ret2', category: 'user' } + ] + }) + expect(Array.isArray(records)).toBe(true) + expect(records.length).toBe(2) + expect(records[0].id).toBeGreaterThan(0) + await prisma.covUser.deleteMany({ where: { email: { in: ['ret1@cov.test', 'ret2@cov.test'] } } }) + }) + + test('findUnique - by unique field', async () => { + const u = await prisma.covUser.findUnique({ where: { email: 'alice@cov.test' } }) + expect(u?.name).toBe('Alice') + }) + + test('findUnique - returns null for missing record', async () => { + const u = await prisma.covUser.findUnique({ where: { email: 'nonexistent@cov.test' } }) + expect(u).toBeNull() + }) + + test('findUniqueOrThrow - returns record', async () => { + const u = await prisma.covUser.findUniqueOrThrow({ where: { email: 'alice@cov.test' } }) + expect(u.name).toBe('Alice') + }) + + test('findUniqueOrThrow - throws for missing record', async () => { + await expect( + prisma.covUser.findUniqueOrThrow({ where: { email: 'ghost@cov.test' } }) + ).rejects.toThrow() + }) + + test('findFirst - returns first matching record', async () => { + const u = await prisma.covUser.findFirst({ where: { category: 'admin' } }) + expect(u).not.toBeNull() + expect(u?.category).toBe('admin') + }) + + test('findFirst - returns null when no match', async () => { + const u = await prisma.covUser.findFirst({ where: { category: 'nonexistent' } }) + expect(u).toBeNull() + }) + + test('findFirstOrThrow - returns record', async () => { + const u = await prisma.covUser.findFirstOrThrow({ where: { category: 'admin' } }) + expect(u.category).toBe('admin') + }) + + test('findFirstOrThrow - throws when no match', async () => { + await expect( + prisma.covUser.findFirstOrThrow({ where: { category: 'nonexistent-xyz' } }) + ).rejects.toThrow() + }) + + test('findMany - returns all matching', async () => { + const users = await prisma.covUser.findMany({ where: { category: 'user' } }) + expect(users.length).toBeGreaterThanOrEqual(3) // bob, dave, eve + any temp + }) + + test('update - single record', async () => { + const u = await prisma.covUser.update({ where: { id: bobId }, data: { age: 26 } }) + expect(u.age).toBe(26) + }) + + test('updateMany - multiple records', async () => { + const result = await prisma.covUser.updateMany({ + where: { category: 'admin' }, + data: { active: true } + }) + expect(result.count).toBeGreaterThanOrEqual(1) + }) + + test('updateManyAndReturn - returns updated records (PG)', async () => { + const records = await (prisma.covUser as any).updateManyAndReturn({ + where: { category: 'admin' }, + data: { active: true } + }) + expect(Array.isArray(records)).toBe(true) + expect(records.length).toBeGreaterThanOrEqual(1) + }) + + test('upsert - create path (record does not exist)', async () => { + const u = await prisma.covUser.upsert({ + where: { email: 'upsert-new@cov.test' }, + create: { email: 'upsert-new@cov.test', name: 'UpsertNew', category: 'user' }, + update: { name: 'UpsertUpdated' } + }) + expect(u.name).toBe('UpsertNew') + await prisma.covUser.delete({ where: { email: 'upsert-new@cov.test' } }) + }) + + test('upsert - update path (record exists)', async () => { + await prisma.covUser.create({ data: { email: 'upsert-existing@cov.test', name: 'UpsertOld', category: 'user' } }) + const u = await prisma.covUser.upsert({ + where: { email: 'upsert-existing@cov.test' }, + create: { email: 'upsert-existing@cov.test', name: 'ShouldNotCreate', category: 'user' }, + update: { name: 'UpsertUpdated' } + }) + expect(u.name).toBe('UpsertUpdated') + await prisma.covUser.delete({ where: { email: 'upsert-existing@cov.test' } }) + }) + + test('delete - single record by unique', async () => { + const tmp = await prisma.covUser.create({ data: { email: 'del@cov.test', name: 'Del', category: 'user' } }) + const deleted = await prisma.covUser.delete({ where: { id: tmp.id } }) + expect(deleted.id).toBe(tmp.id) + const found = await prisma.covUser.findUnique({ where: { id: tmp.id } }) + expect(found).toBeNull() + }) + + test('deleteMany - multiple records', async () => { + await prisma.covUser.createMany({ + data: [ + { email: 'delmany1@cov.test', name: 'DelMany1', category: 'temp' }, + { email: 'delmany2@cov.test', name: 'DelMany2', category: 'temp' } + ] + }) + const result = await prisma.covUser.deleteMany({ where: { category: 'temp' } }) + expect(result.count).toBeGreaterThanOrEqual(2) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Filtering operators + // ───────────────────────────────────────────────────────────────────────────── + describe('Filtering operators', () => { + test('equals (implicit)', async () => { + const u = await prisma.covUser.findMany({ where: { name: 'Alice' } }) + expect(u.length).toBe(1) + expect(u[0].email).toBe('alice@cov.test') + }) + + test('not - not equal', async () => { + const u = await prisma.covUser.findMany({ where: { category: { not: 'admin' } } }) + expect(u.every(x => x.category !== 'admin')).toBe(true) + }) + + test('in - value in list', async () => { + const u = await prisma.covUser.findMany({ where: { email: { in: ['alice@cov.test', 'bob@cov.test'] } } }) + expect(u.length).toBe(2) + }) + + test('notIn - value not in list', async () => { + const u = await prisma.covUser.findMany({ where: { category: { notIn: ['admin'] } } }) + expect(u.every(x => x.category !== 'admin')).toBe(true) + }) + + test('lt - less than', async () => { + const u = await prisma.covUser.findMany({ where: { age: { lt: 30 } } }) + expect(u.every(x => (x.age ?? Infinity) < 30)).toBe(true) + }) + + test('lte - less than or equal', async () => { + const u = await prisma.covUser.findMany({ where: { age: { lte: 30 } } }) + expect(u.every(x => (x.age ?? Infinity) <= 30)).toBe(true) + }) + + test('gt - greater than', async () => { + const u = await prisma.covUser.findMany({ where: { age: { gt: 30 } } }) + expect(u.every(x => (x.age ?? -Infinity) > 30)).toBe(true) + }) + + test('gte - greater than or equal', async () => { + const u = await prisma.covUser.findMany({ where: { age: { gte: 30 } } }) + expect(u.every(x => (x.age ?? -Infinity) >= 30)).toBe(true) + }) + + test('contains - substring match', async () => { + const u = await prisma.covUser.findMany({ where: { name: { contains: 'li' } } }) + expect(u.some(x => x.name?.includes('li'))).toBe(true) + }) + + test('startsWith', async () => { + const u = await prisma.covUser.findMany({ where: { name: { startsWith: 'Ali' } } }) + expect(u.every(x => x.name?.startsWith('Ali'))).toBe(true) + }) + + test('endsWith', async () => { + const u = await prisma.covUser.findMany({ where: { name: { endsWith: 'rol' } } }) + expect(u.every(x => x.name?.endsWith('rol'))).toBe(true) + }) + + test('AND - all conditions must match', async () => { + const u = await prisma.covUser.findMany({ + where: { AND: [{ category: 'admin' }, { active: true }] } + }) + expect(u.every(x => x.category === 'admin' && x.active === true)).toBe(true) + }) + + test('OR - at least one condition matches', async () => { + const u = await prisma.covUser.findMany({ + where: { OR: [{ category: 'admin' }, { age: { lt: 26 } }] } + }) + expect(u.length).toBeGreaterThanOrEqual(2) + }) + + test('NOT - negation of condition', async () => { + const u = await prisma.covUser.findMany({ + where: { NOT: { category: 'admin' } } + }) + expect(u.every(x => x.category !== 'admin')).toBe(true) + }) + + test('null filter - field is null', async () => { + const u = await prisma.covUser.findMany({ where: { name: null } }) + expect(u.every(x => x.name === null)).toBe(true) + expect(u.length).toBeGreaterThanOrEqual(1) // Eve has null name + }) + + test('null filter - field is not null', async () => { + const u = await prisma.covUser.findMany({ where: { name: { not: null } } }) + expect(u.every(x => x.name !== null)).toBe(true) + }) + + test('mode insensitive - case-insensitive contains (PG)', async () => { + const u = await prisma.covUser.findMany({ + where: { name: { contains: 'alice', mode: 'insensitive' } } + }) + expect(u.some(x => x.email === 'alice@cov.test')).toBe(true) + }) + + test('nested combination - AND + OR', async () => { + const u = await prisma.covUser.findMany({ + where: { + AND: [ + { active: true }, + { OR: [{ category: 'admin' }, { age: { lt: 26 } }] } + ] + } + }) + expect(u.length).toBeGreaterThanOrEqual(1) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Pagination / Sorting + // ───────────────────────────────────────────────────────────────────────────── + describe('Pagination and sorting', () => { + test('take - limit results', async () => { + const u = await prisma.covUser.findMany({ take: 2 }) + expect(u.length).toBe(2) + }) + + test('skip - offset results', async () => { + const all = await prisma.covUser.findMany({ orderBy: { id: 'asc' } }) + const skipped = await prisma.covUser.findMany({ orderBy: { id: 'asc' }, skip: 1 }) + expect(skipped[0].id).toBe(all[1].id) + }) + + test('cursor - cursor-based pagination', async () => { + const first = await prisma.covUser.findMany({ orderBy: { id: 'asc' }, take: 1 }) + const afterFirst = await prisma.covUser.findMany({ + orderBy: { id: 'asc' }, + cursor: { id: first[0].id }, + skip: 1, + take: 2 + }) + expect(afterFirst.length).toBeLessThanOrEqual(2) + expect(afterFirst.every(u => u.id > first[0].id)).toBe(true) + }) + + test('orderBy asc', async () => { + const u = await prisma.covUser.findMany({ orderBy: { age: 'asc' }, where: { age: { not: null } } }) + const ages = u.map(x => x.age).filter(a => a != null) as number[] + expect(ages).toEqual([...ages].sort((a, b) => a - b)) + }) + + test('orderBy desc', async () => { + const u = await prisma.covUser.findMany({ orderBy: { age: 'desc' }, where: { age: { not: null } } }) + const ages = u.map(x => x.age).filter(a => a != null) as number[] + expect(ages).toEqual([...ages].sort((a, b) => b - a)) + }) + + test('multi-field orderBy', async () => { + const u = await prisma.covUser.findMany({ + orderBy: [{ category: 'asc' }, { name: 'asc' }] + }) + expect(u.length).toBeGreaterThan(0) + // Verify outer sort by category + const cats = u.map(x => x.category ?? '').filter(Boolean) + expect(cats).toEqual([...cats].sort()) + }) + + test('orderBy with nulls last', async () => { + const u = await prisma.covUser.findMany({ + orderBy: { age: { sort: 'asc', nulls: 'last' } } + }) + // nulls should come last + const lastFew = u.slice(-2) + const hasNull = lastFew.some(x => x.age === null) + expect(hasNull).toBe(true) + }) + + test('distinct - unique values', async () => { + const u = await prisma.covUser.findMany({ + distinct: ['category'], + orderBy: { category: 'asc' } + }) + const cats = u.map(x => x.category) + const uniqueCats = [...new Set(cats)] + expect(cats.length).toBe(uniqueCats.length) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Field selection + // ───────────────────────────────────────────────────────────────────────────── + describe('Field selection', () => { + test('select - subset of fields', async () => { + const u = await prisma.covUser.findUnique({ + where: { email: 'alice@cov.test' }, + select: { id: true, email: true } + }) + expect(u?.id).toBeDefined() + expect(u?.email).toBe('alice@cov.test') + // @ts-expect-error: name should not be on this type + expect((u as any).name).toBeUndefined() + }) + + test('include - load relation', async () => { + const u = await prisma.covUser.findUnique({ + where: { email: 'alice@cov.test' }, + include: { posts: true } + }) + expect(u?.posts).toBeDefined() + expect(u?.posts.length).toBeGreaterThanOrEqual(2) + }) + + test('omit - exclude specific field', async () => { + const u = await (prisma.covUser as any).findUnique({ + where: { email: 'alice@cov.test' }, + omit: { meta: true } + }) + expect(u?.email).toBe('alice@cov.test') + expect((u as any).meta).toBeUndefined() + }) + + test('nested select on relation', async () => { + const u = await prisma.covUser.findUnique({ + where: { email: 'alice@cov.test' }, + select: { + name: true, + posts: { select: { title: true } } + } + }) + expect(u?.name).toBe('Alice') + expect(u?.posts[0]).toHaveProperty('title') + // @ts-expect-error: views should not be present + expect((u?.posts[0] as any).views).toBeUndefined() + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Aggregation + // ───────────────────────────────────────────────────────────────────────────── + describe('Aggregation', () => { + test('count - total records', async () => { + const n = await prisma.covUser.count() + expect(n).toBeGreaterThanOrEqual(5) + }) + + test('count - with where filter', async () => { + const n = await prisma.covUser.count({ where: { category: 'admin' } }) + expect(n).toBeGreaterThanOrEqual(2) + }) + + test('aggregate _count', async () => { + const agg = await prisma.covUser.aggregate({ _count: { _all: true } }) + expect(agg._count._all).toBeGreaterThanOrEqual(5) + }) + + test('aggregate _avg', async () => { + const agg = await prisma.covUser.aggregate({ + _avg: { age: true }, + where: { age: { not: null } } + }) + expect(agg._avg.age).not.toBeNull() + }) + + test('aggregate _sum', async () => { + const agg = await prisma.covUser.aggregate({ + _sum: { age: true }, + where: { age: { not: null } } + }) + expect(agg._sum.age).not.toBeNull() + }) + + test('aggregate _min', async () => { + const agg = await prisma.covUser.aggregate({ + _min: { age: true }, + where: { age: { not: null } } + }) + expect(agg._min.age).not.toBeNull() + }) + + test('aggregate _max', async () => { + const agg = await prisma.covUser.aggregate({ + _max: { age: true }, + where: { age: { not: null } } + }) + expect(agg._max.age).not.toBeNull() + }) + + test('groupBy by category with _count', async () => { + const groups = await prisma.covUser.groupBy({ + by: ['category'], + _count: { _all: true } + }) + expect(groups.length).toBeGreaterThanOrEqual(2) + const adminGroup = groups.find(g => g.category === 'admin') + expect(adminGroup?._count._all).toBeGreaterThanOrEqual(2) + }) + + test('groupBy with having clause', async () => { + const groups = await prisma.covUser.groupBy({ + by: ['category'], + _count: { _all: true }, + having: { category: { not: null } } + }) + expect(groups.every(g => g.category !== null)).toBe(true) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Atomic number updates + // ───────────────────────────────────────────────────────────────────────────── + describe('Atomic number updates', () => { + let postId: number + + beforeAll(async () => { + const p = await prisma.covPost.findFirst({ where: { authorId: aliceId } }) + postId = p!.id + }) + + test('increment', async () => { + const before = await prisma.covPost.findUnique({ where: { id: postId } }) + const after = await prisma.covPost.update({ + where: { id: postId }, + data: { views: { increment: 5 } } + }) + expect(after.views).toBe((before!.views ?? 0) + 5) + }) + + test('decrement', async () => { + const before = await prisma.covPost.findUnique({ where: { id: postId } }) + const after = await prisma.covPost.update({ + where: { id: postId }, + data: { views: { decrement: 2 } } + }) + expect(after.views).toBe((before!.views ?? 0) - 2) + }) + + test('multiply', async () => { + const before = await prisma.covPost.findUnique({ where: { id: postId } }) + const after = await prisma.covPost.update({ + where: { id: postId }, + data: { views: { multiply: 2 } } + }) + expect(after.views).toBe((before!.views ?? 0) * 2) + }) + + test('divide', async () => { + const before = await prisma.covPost.findUnique({ where: { id: postId } }) + const after = await prisma.covPost.update({ + where: { id: postId }, + data: { views: { divide: 2 } } + }) + expect(after.views).toBe(Math.floor((before!.views ?? 0) / 2)) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Scalar arrays (PG) + // ───────────────────────────────────────────────────────────────────────────── + describe('Scalar arrays (PG)', () => { + test('create with tags array', async () => { + const u = await prisma.covUser.create({ + data: { email: 'arr-create@cov.test', tags: ['a', 'b', 'c'], category: 'user' } + }) + expect(u.tags).toEqual(['a', 'b', 'c']) + await prisma.covUser.delete({ where: { id: u.id } }) + }) + + test('read tags array (Alice)', async () => { + const u = await prisma.covUser.findUnique({ where: { id: aliceId } }) + expect(u?.tags).toEqual(['admin', 'editor']) + }) + + test('filter has - array contains element', async () => { + const u = await prisma.covUser.findMany({ where: { tags: { has: 'admin' } } }) + expect(u.some(x => x.id === aliceId)).toBe(true) + }) + + test('filter hasEvery - array contains all elements', async () => { + const u = await prisma.covUser.findMany({ where: { tags: { hasEvery: ['admin', 'editor'] } } }) + expect(u.every(x => x.tags.includes('admin') && x.tags.includes('editor'))).toBe(true) + }) + + test('filter hasSome - array contains any element', async () => { + const u = await prisma.covUser.findMany({ where: { tags: { hasSome: ['editor', 'nope'] } } }) + expect(u.every(x => x.tags.includes('editor'))).toBe(true) + }) + + test('filter isEmpty - empty array', async () => { + const u = await prisma.covUser.findMany({ where: { tags: { isEmpty: true } } }) + expect(u.some(x => x.id === daveId)).toBe(true) // Dave has [] + }) + + test('update set - replace array', async () => { + await prisma.covUser.update({ + where: { id: bobId }, + data: { tags: { set: ['new-tag'] } } + }) + const u = await prisma.covUser.findUnique({ where: { id: bobId } }) + expect(u?.tags).toEqual(['new-tag']) + }) + + test('update push - append to array', async () => { + await prisma.covUser.update({ + where: { id: bobId }, + data: { tags: { push: ['pushed'] } } + }) + const u = await prisma.covUser.findUnique({ where: { id: bobId } }) + expect(u?.tags).toContain('pushed') + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // JSON field + // ───────────────────────────────────────────────────────────────────────────── + describe('JSON field', () => { + test('write meta object and read it back', async () => { + const u = await prisma.covUser.findUnique({ where: { id: aliceId } }) + expect(typeof u?.meta).toBe('object') + expect((u?.meta as any)?.role).toBe('admin') + }) + + test('update meta', async () => { + await prisma.covUser.update({ + where: { id: aliceId }, + data: { meta: { role: 'superadmin', level: 99 } } + }) + const u = await prisma.covUser.findUnique({ where: { id: aliceId } }) + expect((u?.meta as any)?.role).toBe('superadmin') + expect((u?.meta as any)?.level).toBe(99) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Special types + // ───────────────────────────────────────────────────────────────────────────── + describe('Special types', () => { + test('Decimal round-trip (score)', async () => { + const u = await prisma.covUser.findUnique({ where: { id: carolId } }) + // Prisma returns a Decimal; trailing zeros may be stripped by toString() + expect(u?.score).not.toBeNull() + expect(parseFloat(u?.score?.toString() ?? '0')).toBeCloseTo(8.25, 4) + }) + + test('BigInt round-trip (balance)', async () => { + const u = await prisma.covUser.findUnique({ where: { id: aliceId } }) + expect(u?.balance).toBe(BigInt(100000)) + }) + + test('DateTime round-trip (createdAt)', async () => { + const u = await prisma.covUser.findUnique({ where: { id: aliceId } }) + expect(u?.createdAt).toBeInstanceOf(Date) + }) + + test('DateTime where filter - date range', async () => { + const past = new Date(Date.now() - 1000 * 60 * 60) // 1 hour ago + const future = new Date(Date.now() + 1000 * 60 * 60) // 1 hour ahead + const u = await prisma.covUser.findMany({ + where: { + createdAt: { gte: past, lte: future } + } + }) + expect(u.length).toBeGreaterThanOrEqual(5) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Relations and nested writes + // ───────────────────────────────────────────────────────────────────────────── + describe('Relations and nested writes', () => { + test('nested create - create user with posts', async () => { + const u = await prisma.covUser.create({ + data: { + email: 'nested-create@cov.test', + name: 'NestedCreate', + category: 'user', + posts: { + create: [{ title: 'Nested Post 1' }, { title: 'Nested Post 2' }] + } + }, + include: { posts: true } + }) + expect(u.posts.length).toBe(2) + await prisma.covPost.deleteMany({ where: { authorId: u.id } }) + await prisma.covUser.delete({ where: { id: u.id } }) + }) + + test('connect - associate existing post with user', async () => { + const tmpPost = await prisma.covPost.create({ data: { title: 'Tmp Post', authorId: bobId } }) + const u = await prisma.covUser.update({ + where: { id: carolId }, + data: { posts: { connect: { id: tmpPost.id } } }, + include: { posts: true } + }) + expect(u.posts.some(p => p.id === tmpPost.id)).toBe(true) + await prisma.covPost.delete({ where: { id: tmpPost.id } }) + }) + + test('connectOrCreate - connect if exists, create if not', async () => { + // This is on the to-one side: creating a post and connecting to Alice + const u = await prisma.covPost.create({ + data: { + title: 'ConnOrCreate Post', + author: { + connectOrCreate: { + where: { email: 'alice@cov.test' }, + create: { email: 'alice@cov.test', name: 'AliceNew', category: 'admin' } + } + } + }, + include: { author: true } + }) + expect(u.author.email).toBe('alice@cov.test') + await prisma.covPost.delete({ where: { id: u.id } }) + }) + + test('disconnect - remove relation (to-many, optional FK)', async () => { + // disconnect sets authorId to NULL on the post (optional relation) + const tmpPost = await prisma.covPost.create({ data: { title: 'Disconnect Post', authorId: aliceId } }) + await prisma.covUser.update({ + where: { id: aliceId }, + data: { posts: { disconnect: { id: tmpPost.id } } } + }) + const u = await prisma.covUser.findUnique({ where: { id: aliceId }, include: { posts: true } }) + expect(u?.posts.some(p => p.id === tmpPost.id)).toBe(false) + // Cleanup orphaned post + await prisma.covPost.delete({ where: { id: tmpPost.id } }) + }) + + test('set - replace all related posts (optional FK)', async () => { + // set disconnects posts not in the list (sets their authorId to NULL) + const p1 = await prisma.covPost.create({ data: { title: 'Set Post 1', authorId: daveId } }) + const p2 = await prisma.covPost.create({ data: { title: 'Set Post 2', authorId: daveId } }) + await prisma.covUser.update({ + where: { id: daveId }, + data: { posts: { set: [{ id: p1.id }] } } + }) + const u = await prisma.covUser.findUnique({ where: { id: daveId }, include: { posts: true } }) + expect(u?.posts.length).toBe(1) + expect(u?.posts[0].id).toBe(p1.id) + await prisma.covPost.deleteMany({ where: { id: { in: [p1.id, p2.id] } } }) + }) + + test('nested update - update related post via user', async () => { + const p = await prisma.covPost.findFirst({ where: { authorId: aliceId } }) + await prisma.covUser.update({ + where: { id: aliceId }, + data: { + posts: { update: { where: { id: p!.id }, data: { title: 'Updated Title' } } } + } + }) + const updated = await prisma.covPost.findUnique({ where: { id: p!.id } }) + expect(updated?.title).toBe('Updated Title') + }) + + test('nested upsert - upsert related record', async () => { + const p = await prisma.covPost.findFirst({ where: { authorId: aliceId } }) + await prisma.covUser.update({ + where: { id: aliceId }, + data: { + posts: { + upsert: { + where: { id: p!.id }, + update: { title: 'Upserted Title' }, + create: { title: 'Would Be New' } + } + } + } + }) + const upserted = await prisma.covPost.findUnique({ where: { id: p!.id } }) + expect(upserted?.title).toBe('Upserted Title') + }) + + test('nested delete - delete related post via user', async () => { + const tmpPost = await prisma.covPost.create({ data: { title: 'To Delete', authorId: aliceId } }) + await prisma.covUser.update({ + where: { id: aliceId }, + data: { posts: { delete: { id: tmpPost.id } } } + }) + const deleted = await prisma.covPost.findUnique({ where: { id: tmpPost.id } }) + expect(deleted).toBeNull() + }) + + test('relation filter some - users with at least one matching post', async () => { + const u = await prisma.covUser.findMany({ + where: { posts: { some: { views: { gt: 0 } } } } + }) + expect(u.length).toBeGreaterThanOrEqual(1) + }) + + test('relation filter every - users where all posts match', async () => { + const u = await prisma.covUser.findMany({ + where: { posts: { every: { views: { gte: 0 } } } } + }) + expect(u.length).toBeGreaterThanOrEqual(1) + }) + + test('relation filter none - users with no posts matching', async () => { + const u = await prisma.covUser.findMany({ + where: { posts: { none: { views: { gt: 1000 } } } } + }) + expect(u.length).toBeGreaterThanOrEqual(1) + }) + + test('to-one is - filter by exact related record', async () => { + const posts = await prisma.covPost.findMany({ + where: { author: { is: { email: 'alice@cov.test' } } } + }) + expect(posts.every(p => p.authorId === aliceId)).toBe(true) + }) + + test('to-one isNot - filter excluding related record', async () => { + const posts = await prisma.covPost.findMany({ + where: { author: { isNot: { email: 'alice@cov.test' } } } + }) + expect(posts.every(p => p.authorId !== aliceId)).toBe(true) + }) + + test('include with nested where', async () => { + const u = await prisma.covUser.findUnique({ + where: { id: aliceId }, + include: { + posts: { where: { views: { gt: 5 } } } + } + }) + expect(u?.posts.every(p => p.views > 5)).toBe(true) + }) + + test('include with nested orderBy', async () => { + const u = await prisma.covUser.findUnique({ + where: { id: aliceId }, + include: { + posts: { orderBy: { views: 'desc' } } + } + }) + const views = u?.posts.map(p => p.views) ?? [] + expect(views).toEqual([...views].sort((a, b) => b - a)) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Raw queries + // ───────────────────────────────────────────────────────────────────────────── + describe('Raw queries', () => { + test('$queryRaw - tagged template with param', async () => { + const results = await prisma.$queryRaw<{ total: bigint }[]>` + SELECT COUNT(*)::bigint AS total FROM prisma_cov_user WHERE category = ${'admin'} + ` + expect(Array.isArray(results)).toBe(true) + expect(Number(results[0].total)).toBeGreaterThanOrEqual(2) + }) + + test('$queryRawUnsafe - string SQL with params', async () => { + const results = await prisma.$queryRawUnsafe<{ id: number }[]>( + 'SELECT id FROM prisma_cov_user WHERE email = $1', + 'alice@cov.test' + ) + expect(Array.isArray(results)).toBe(true) + expect(results[0].id).toBe(aliceId) + }) + + test('$executeRaw - tagged template', async () => { + const count = await prisma.$executeRaw` + UPDATE prisma_cov_user SET active = true WHERE category = ${'admin'} + ` + expect(typeof count).toBe('number') + expect(count).toBeGreaterThanOrEqual(1) + }) + + test('$executeRawUnsafe - string SQL with params', async () => { + const count = await prisma.$executeRawUnsafe( + 'UPDATE prisma_cov_user SET active = true WHERE id = $1', + aliceId + ) + expect(typeof count).toBe('number') + expect(count).toBeGreaterThanOrEqual(1) + }) + }) + + // ───────────────────────────────────────────────────────────────────────────── + // Transactions + // ───────────────────────────────────────────────────────────────────────────── + describe('Transactions', () => { + test('sequential array form - all succeed', async () => { + const [u1, u2] = await prisma.$transaction([ + prisma.covUser.create({ data: { email: 'txarr1@cov.test', name: 'TxArr1', category: 'user' } }), + prisma.covUser.create({ data: { email: 'txarr2@cov.test', name: 'TxArr2', category: 'user' } }) + ]) + expect(u1.email).toBe('txarr1@cov.test') + expect(u2.email).toBe('txarr2@cov.test') + await prisma.covUser.deleteMany({ where: { email: { in: ['txarr1@cov.test', 'txarr2@cov.test'] } } }) + }) + + test('interactive transaction - commit', async () => { + await prisma.$transaction(async (tx) => { + const u = await tx.covUser.create({ + data: { email: 'tx-commit@cov.test', name: 'TxCommit', category: 'user' } + }) + await tx.covUser.update({ where: { id: u.id }, data: { age: 99 } }) + }) + const u = await prisma.covUser.findUnique({ where: { email: 'tx-commit@cov.test' } }) + expect(u?.age).toBe(99) + await prisma.covUser.delete({ where: { email: 'tx-commit@cov.test' } }) + }) + + test('interactive transaction - rollback on error', async () => { + await expect( + prisma.$transaction(async (tx) => { + await tx.covUser.create({ data: { email: 'tx-rollback@cov.test', name: 'TxRollback', category: 'user' } }) + throw new Error('Intentional rollback') + }) + ).rejects.toThrow('Intentional rollback') + + const u = await prisma.covUser.findUnique({ where: { email: 'tx-rollback@cov.test' } }) + expect(u).toBeNull() + }) + }) +}) diff --git a/integration-tests/prisma/schema-mysql-coverage.prisma b/integration-tests/prisma/schema-mysql-coverage.prisma new file mode 100644 index 0000000..0dd87f4 --- /dev/null +++ b/integration-tests/prisma/schema-mysql-coverage.prisma @@ -0,0 +1,34 @@ +generator client { + provider = "prisma-client-js" + output = "./generated-mysql-coverage" +} + +datasource db { + provider = "mysql" +} + +model CovUser { + id Int @id @default(autoincrement()) + email String @unique @db.VarChar(191) + name String? @db.VarChar(191) + age Int? + score Decimal? @db.Decimal(10, 2) + balance BigInt? + active Boolean @default(true) + category String? @db.VarChar(191) + meta Json? + createdAt DateTime @default(now()) + posts CovPost[] + + @@map("prisma_cov_user") +} + +model CovPost { + id Int @id @default(autoincrement()) + title String @db.VarChar(191) + views Int @default(0) + authorId Int + author CovUser @relation(fields: [authorId], references: [id]) + + @@map("prisma_cov_post") +} diff --git a/integration-tests/prisma/schema-pg-coverage.prisma b/integration-tests/prisma/schema-pg-coverage.prisma new file mode 100644 index 0000000..d6f35d0 --- /dev/null +++ b/integration-tests/prisma/schema-pg-coverage.prisma @@ -0,0 +1,35 @@ +generator client { + provider = "prisma-client-js" + output = "./generated-pg-coverage" +} + +datasource db { + provider = "postgresql" +} + +model CovUser { + id Int @id @default(autoincrement()) + email String @unique + name String? + age Int? + score Decimal? + balance BigInt? + active Boolean @default(true) + category String? + tags String[] + meta Json? + createdAt DateTime @default(now()) + posts CovPost[] + + @@map("prisma_cov_user") +} + +model CovPost { + id Int @id @default(autoincrement()) + title String + views Int @default(0) + authorId Int? + author CovUser? @relation(fields: [authorId], references: [id]) + + @@map("prisma_cov_post") +} From 62ad50484de55e7f85e8e43899c6ba7b5728208c Mon Sep 17 00:00:00 2001 From: Jeremy Daly Date: Tue, 16 Jun 2026 11:51:50 -0400 Subject: [PATCH 3/3] chore: stop tracking CLAUDE.md (already in .gitignore) CLAUDE.md is listed under the AI-instruction-files block in .gitignore but was still tracked, unlike WARP.md/.cursorrules/etc. Untrack it to match that policy; the file stays on disk locally. --- CLAUDE.md | 631 ------------------------------------------------------ 1 file changed, 631 deletions(-) delete mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 1f84f34..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,631 +0,0 @@ -# CLAUDE.md - Context for AI-Assisted Development - -## Project Overview - -**data-api-client** is a lightweight wrapper for the Amazon Aurora Serverless Data API that simplifies database interactions by abstracting away field value type annotations. It acts as a "DocumentClient" equivalent for the RDS Data API. - -- **Package Name**: data-api-client -- **Current Version**: 2.3.0 -- **Author**: Jeremy Daly -- **License**: MIT -- **Repository**: https://github.com/jeremydaly/data-api-client - -## Project Status - -Version 2.x (stable) supports: -- New RDS Data API for Aurora Serverless v2 and Aurora provisioned database instances -- Amazon Aurora PostgreSQL-Compatible Edition enhancements (default engine is now `'pg'`) -- AWS SDK v3 (migrated from v2) -- Full TypeScript implementation with type definitions -- Drop-in driver compat layers (`pg`, `mysql2`) plus a Knex adapter (`compat/knex`), with Drizzle, Kysely, and Knex support -- Automatic retries for Aurora Serverless v2 scale-to-zero wake-ups - -## Architecture - -### TypeScript Source with CommonJS Output -The library is written in TypeScript and compiled to CommonJS JavaScript for backward compatibility. - -**Modular Source Structure**: -- `src/index.ts` - Entry point (exports `init` from client.ts) -- `src/client.ts` - Client initialization and configuration -- `src/types.ts` - TypeScript type definitions and interfaces -- `src/params.ts` - Parameter parsing, normalization, and processing -- `src/query.ts` - Query execution logic -- `src/results.ts` - Result formatting and record processing, including array value parsing -- `src/transaction.ts` - Transaction management -- `src/utils.ts` - Utility functions for SQL parsing, type detection, and date handling -- `src/pg-escape.ts` - Internal PostgreSQL identifier/string escaping (no external dependency) -- `src/retry.ts` - Automatic retry logic for scale-to-zero cluster wake-ups (see Retry Behavior) -- `src/compat/` - Drop-in driver compatibility layers (see Driver Compatibility Layers) - - `compat/pg.ts` - node-postgres (`pg`) compatible client/pool - - `compat/mysql2.ts` - mysql2 compatible connection/pool - - `compat/errors.ts` - Maps Data API errors to pg/mysql2 error shapes - - `compat/index.ts` - Compat layer exports -- Compiled output in `dist/`: `index.js`, `index.d.ts`, `types.js`, `types.d.ts`, plus `dist/compat/*` - -**Key architectural decisions**: -1. **TypeScript with build step** - Written in TypeScript, compiled to JavaScript -2. **Modular architecture** - Code split into focused modules for maintainability -3. **Full type safety** - Comprehensive type definitions for all APIs -4. **CommonJS output** - Maintains backward compatibility with existing users -5. **AWS SDK v3** - Uses modular @aws-sdk/client-rds-data package (peer dependency) -6. **Minimal dependencies** - Only `sqlstring` in production (PostgreSQL escaping is handled internally) -7. **Functional approach** - Uses closures and function composition -8. **Command-based wrapper** - Uses AWS SDK v3 Commands with client.send() pattern - -### Core Components - -#### 1. Initialization (`init` function in client.ts) -- Creates a configuration object with defaults -- Instantiates RDSDataClient from AWS SDK v3 -- Returns public API: `query()`, `transaction()`, and command-based RDS methods - -#### 2. Query Execution (`query` function in query.ts) -- Parses SQL and parameters -- Detects batch vs single queries -- Handles parameter type conversion -- Formats results with/without column names -- Supports transactions via rollback context - -#### 3. Transaction Management (transaction.ts) -- `transaction()` - Creates transaction object with method chaining -- `commit()` - Executes queries within a transaction -- Automatic rollback on error - -#### 4. Type System (types.ts) -Supported types: -- `stringValue` - String, Date (converted to timestamp) -- `booleanValue` - Boolean -- `longValue` - Integer -- `doubleValue` - Float -- `isNull` - Null -- `blobValue` - Buffer -- `arrayValue` - Array support (Data API v2) -- `structValue` - For Postgres - -#### 5. Special Features - -**Named Identifiers** (params.ts) -- Uses `::` prefix for dynamic table/column names -- Auto-escapes using engine-specific escaping: - - PostgreSQL: Internal `ident()` function produces `"identifier"` (from src/pg-escape.ts) - - MySQL: `sqlstring.escapeId()` produces `` `identifier` `` -- Example: `::tableName` becomes `` `table_value` `` (MySQL) or `"table_value"` (PostgreSQL) - -**Named Parameters** -- Uses `:` prefix for query parameters -- Example: `:id` maps to `{ id: 123 }` - -**Type Casting** (params.ts) -- Supports explicit type casting for Postgres/MySQL -- Example: `{ name: 'id', value: uuid, cast: 'uuid' }` -- PostgreSQL format: `:id::uuid` -- MySQL format: `CAST(:id AS uuid)` -- **Automatic JSONB casting (NEW)**: Plain JavaScript objects are automatically detected and cast as `::jsonb` in PostgreSQL queries - - Detects plain objects (not Buffers, Dates, Arrays, or Data API objects) - - Automatically serializes to JSON string - - Appends `::jsonb` cast to parameter - - Adds `JSON` typeHint for Data API - - Explicit casts take precedence over automatic casting - -**Date Handling** (utils.ts, results.ts) -- `formatOptions.deserializeDate` - Auto-parse date strings to Date objects (default: true) -- `formatOptions.treatAsLocalDate` - Treat dates as local time instead of UTC (default: false) -- Default format: `YYYY-MM-DD HH:MM:SS[.FFF]` - -**Array Handling** (results.ts) -- PostgreSQL arrays are automatically converted from Data API `arrayValue` format to native JavaScript arrays -- Supports all primitive array types: `stringValues`, `longValues`, `doubleValues`, `booleanValues` -- Handles nested/multidimensional arrays recursively -- Array parameters must use workarounds (see Known Limitations) - -## Driver Compatibility Layers (`src/compat/`) - -In addition to the native `dataApiClient(...)` interface, the library ships drop-in -adapters that mimic the popular Node database drivers so ORMs and query builders can -run on the Data API unchanged. These are published as subpath exports (see Package -Entry Points) and are exercised by the ORM integration tests. - -- **`data-api-client/compat/pg`** — `createPgClient` / `createPgPool` expose a - node-postgres-shaped client (`query()` returning `{ rows, rowCount, command, fields }`, - `EventEmitter`, `connect`/`end`). Targets Drizzle and Kysely Postgres dialects. -- **`data-api-client/compat/mysql2`** — `createMySQLConnection` / `createMySQLPool` - expose a mysql2-shaped connection (`query()` returning `[rows, fields]`, - `PoolConnection.release()`). Set `namedPlaceholders: true` in config to enable - `:name` placeholder syntax mysql2 callers expect. -- **`data-api-client/compat/knex`** — `createKnexMySQLClient` / `createKnexPgClient` - return custom Knex `client` classes (subclasses of Knex's mysql2/pg dialects with - `_driver()` overridden) so Knex runs over the Data API. `knex` is an optional peer - dependency, lazy-`require`d. See ORM support status for the transaction caveat. -- **`data-api-client/compat/errors`** — `mapToPostgresError` / `mapToMySQLError` - translate Data API exceptions into pg/mysql2-shaped error objects (code, etc.) so - ORM error handling behaves as expected. - -**ORM support status**: Drizzle and Kysely work because they accept an injected -driver/dialect and call its `query()` methods, so a look-alike pool/client suffices. -**Knex is supported via `compat/knex`** using a different mechanism: Knex *constructs* -its own driver rather than accepting an injected pool, so the helpers subclass Knex's -mysql2/pg dialect and override the single `_driver()` method to hand Knex a Data -API-backed connection. Both engines are covered by real integration tests -(`integration-tests/knex-{mysql,pg}.int.test.ts`); `test:int:orm:knex` runs both -(with `:pg`/`:mysql` variants). - -**Transactions (all SQL-driven callers)**: ORMs/Knex drive transactions by issuing -literal `BEGIN`/`COMMIT`/`ROLLBACK` SQL on the connection. Both compat layers intercept -these via `src/compat/transaction-sql.ts` (`classifyTransactionControl`) and map them to -the Data API lifecycle (`beginTransaction`/`commitTransaction`/`rollbackTransaction`), -threading `transactionId` onto subsequent queries on that connection. This works because -the caller runs a whole transaction on one acquired connection, and `transactionId` is a -per-connection-instance closure. Knex `db.transaction()` commits and rolls back correctly. - -**Limitation — nested transactions**: SQL `SAVEPOINT`/`RELEASE`/`ROLLBACK TO SAVEPOINT` -have no Data API primitive, so the classifier throws a clear error for them. Top-level -transactions work; nested ones (e.g. `trx.transaction(...)`) are rejected. Covered by -`nested transactions (savepoints) are rejected` tests in both Knex suites. - -**mysql2 callback note**: the mysql2 compat `query()` accepts the -`query(config, undefined, callback)` form (params undefined, callback as 3rd arg) that -Knex uses for bindings-less transaction statements — see the callback-parsing in -`createMySQLConnection`. Missing this caused Knex transactions to hang. - -When adding features, keep the native client and compat layers in sync — a behavior -change in `query.ts`/`results.ts` usually needs matching compat tests. - -## Retry Behavior (`src/retry.ts`) - -`withRetry()` wraps every command send (`query()` and the raw `executeStatement` etc. -methods) to survive Aurora Serverless v2 **scale-to-zero wake-ups**. Enabled by default. - -- **`DatabaseResumingException`** (cluster waking) → retried with wake-up-tuned delays - (`0, 2, 5, 10, 15, 20, 25, 30, 35, 40s`), capped by `retryOptions.maxRetries` (default **9**) -- **Transient connection errors** (e.g. "Communications link failure", `StatementTimeoutException`) - → 3 quick retries with exponential backoff (`0, 2, 4s`) -- **`retryOptions.retryableErrors`** → custom error codes/names to also retry with wake-up delays -- Disable with `retryOptions: { enabled: false }` - -## Configuration Options - -```javascript -{ - // Required - secretArn: string, // ARN of Secrets Manager secret - resourceArn: string, // ARN of Aurora Serverless cluster - - // Optional - database: string, // Default database name - engine: 'mysql'|'pg', // Database engine (default: 'pg') - hydrateColumnNames: boolean, // Return objects vs arrays (default: true) - namedPlaceholders: boolean, // Enable :name placeholders for mysql2 compat layer (default: false) - options: object, // Passed to RDSDataClient constructor - client: RDSDataClient, // Custom RDSDataClient instance (for X-Ray, etc.) - - formatOptions: { - deserializeDate: boolean, // Parse date strings (default: true) - treatAsLocalDate: boolean // Use local time (default: false) - }, - - retryOptions: { // Scale-to-zero wake-up retries (see Retry Behavior) - enabled: boolean, // default: true - maxRetries: number, // default: 9 - retryableErrors: string[] // extra error codes/names to retry (default: []) - }, - - // Deprecated (set in options instead) - region: string, - sslEnabled: boolean, // Note: TLS is always enabled in SDK v3, use options.endpoint for local dev - keepAlive: boolean // Note: Use AWS_NODEJS_CONNECTION_REUSE_ENABLED env var instead -} -``` - -## Testing - -- **Framework**: Vitest (migrated from Jest) -- **Test structure**: - - Unit tests: `src/*.test.ts` (colocated with source) - - Integration tests: `integration-tests/*.int.test.ts`, grouped into three suites: - - **core**: `mysql.int.test.ts`, `postgres.int.test.ts` (native client against each engine) - - **compat**: `pg-compat.int.test.ts`, `mysql2-compat.int.test.ts` (driver compat layers) - - **orm**: `drizzle-{pg,mysql}`, `kysely-{pg,mysql}`, `knex-{mysql,pg}` (all working, including transactions; nested transactions rejected — see Driver Compatibility Layers) - - See `integration-tests/INTEGRATION_TESTING.md` for the Aurora Serverless v2 CloudFormation setup (`infra/`) -- **Config**: `vitest.config.mjs` (ES module format, requires `.mjs` extension) -- **IMPORTANT**: Integration tests read AWS credentials/ARNs from `process.env`; nothing auto-loads `.env.local` (vitest config does not). You must `source .env.local` **in the same command** as the test run, because shell env vars do not persist across separate commands — e.g. `source .env.local && npm run test:int:orm:knex`. Sourcing in one step and running tests in another (or a fresh terminal / new `!` command) will not work. -- **Sample data**: `fixtures/sample-*-response.json` files (imported via `#fixtures/*` alias) -- **Run tests** (script names match the suite grouping above): - - `npm test` / `npm run test:unit` - Build + run unit tests - - `npm run test:int:core` - Native client integration tests (both engines; `:mysql` / `:pg` variants) - - `npm run test:int:compat` - Driver compat-layer integration tests (`:pg` / `:mysql` variants) - - `npm run test:int:orm:kysely` / `:drizzle` / `:knex` - ORM integration tests (engine variants available) - - `npm run test-ci` - Build + lint + run unit tests (for CI) - - For manual runs: `source .env.local && npx vitest run integration-tests/` -- **Global test functions**: Enabled via `globals: true` in config (no need to import describe/test/expect) -- **Test helpers**: Integration tests use `setup.ts` for database setup -- **Integration test credentials**: Stored in `.env.local` (not in git): - - `MYSQL_RESOURCE_ARN`, `MYSQL_SECRET_ARN`, `MYSQL_DATABASE` - - `POSTGRES_RESOURCE_ARN`, `POSTGRES_SECRET_ARN`, `POSTGRES_DATABASE` - - `AWS_REGION` -- **PostgreSQL integration tests** cover: - - All PostgreSQL data types (numeric, string, boolean, date/time, binary, JSON, UUID, network, range) - - Array types with multiple workaround approaches - - Transactions, batch operations, type casting - - Known Data API limitations documented with `.fails()` tests in final section - -## Code Style - -### ESLint Configuration -- Base config: `eslint:recommended`, `prettier` -- TypeScript override for `*.ts` files: - - Parser: `@typescript-eslint/parser` - - Extends: `plugin:@typescript-eslint/recommended` - - Project: `tsconfig.eslint.json` -- Environment: ES6, Node.js, Jest -- Key rules: - - Unix line breaks - - Single quotes (template literals allowed) - - No semicolons - - ECMAScript 2018 - - `@typescript-eslint/no-explicit-any`: off - - `@typescript-eslint/no-unused-vars`: error (ignore args starting with `_`) - -### Prettier Configuration -- No trailing commas -- No semicolons -- Single quotes -- 120 character line width - -## Dependencies - -### Production -- **sqlstring** (^2.3.2) - SQL identifier escaping and string formatting (MySQL) -- **Note**: PostgreSQL escaping is handled by internal `src/pg-escape.ts` module (no external dependency) - -### Development -- **@aws-sdk/client-rds-data** (^3.1048.0) - AWS SDK v3 RDS Data API client (also peer dep) -- **typescript** (^5.9.3) - TypeScript compiler -- **@types/node** (^24.6.2) - Node.js type definitions -- **@types/sqlstring** (^2.3.2) - sqlstring type definitions -- **@types/pg** (^8.15.5) - pg type definitions (for compat layer) -- **@typescript-eslint/parser** (^8.45.0) - TypeScript ESLint parser -- **@typescript-eslint/eslint-plugin** (^8.45.0) - TypeScript ESLint rules -- **eslint** (^8.12.0) + plugins - Linting -- **vitest** (^4.1.8) - Testing framework -- **@vitest/ui** (^4.1.8) - Vitest UI -- **prettier** (^2.6.2) - Code formatting -- **tsx** (^4.20.6) - TypeScript execution engine -- **pg**, **drizzle-orm**, **kysely**, **knex** - ORM/driver targets used only by compat integration tests - -### Peer Dependencies -- **@aws-sdk/client-rds-data** (^3.1048.0) - Optional peer dependency (available in Lambda runtime or installed by user) - -## Common Patterns - -### Parameter Normalization -The library accepts parameters in multiple formats: -```javascript -// As second argument (object) -query('SELECT * FROM users WHERE id = :id', { id: 123 }) - -// As second argument (array of objects) -query('SELECT * FROM users WHERE id = :id', [{ id: 123 }]) - -// In config object -query({ sql: 'SELECT...', parameters: { id: 123 } }) - -// Native Data API format (passed through) -query('SELECT...', [{ name: 'id', value: { longValue: 123 } }]) - -// Batch format (nested arrays) -query('UPDATE...', [[{ id: 1 }], [{ id: 2 }]]) -``` - -### Result Formatting -```javascript -// With hydrateColumnNames: true (default) -{ records: [{ id: 1, name: 'Alice' }] } - -// With hydrateColumnNames: false -{ records: [[1, 'Alice']] } - -// INSERT queries -{ insertId: 42 } - -// UPDATE/DELETE queries -{ numberOfRecordsUpdated: 5 } - -// PostgreSQL arrays (automatically converted to native JavaScript arrays) -{ records: [{ tags: ['admin', 'editor', 'viewer'] }] } -``` - -## Known Limitations and Workarounds - -### Array Parameters (RDS Data API Limitation) -The RDS Data API does **not support binding array parameters** directly. Attempts to use `arrayValue` parameters result in `ValidationException: Array parameters are not supported`. - -**Workarounds for PostgreSQL Arrays**: - -1. **CSV string with `string_to_array()`**: -```javascript -// Convert to array in SQL -query('INSERT INTO table (int_array) VALUES (string_to_array(:csv, \',\')::int[])', { - csv: '1,2,3' -}) -``` - -2. **PostgreSQL array literal syntax**: -```javascript -// Pass array literal as string -query('INSERT INTO table (text_array) VALUES (:literal::text[])', { - literal: '{"admin","editor","viewer"}' -}) -``` - -3. **ARRAY[] constructor with individual parameters**: -```javascript -// Good for fixed, small arrays -query('INSERT INTO table (text_array) VALUES (ARRAY[:tag1, :tag2, :tag3])', { - tag1: 'blue', - tag2: 'sale', - tag3: 'featured' -}) -``` - -**Array Results**: Despite parameter limitations, array results ARE supported. PostgreSQL arrays in query results are automatically converted to native JavaScript arrays. - -### Other Limitations - -1. **No dynamic identifiers in native API** - Fixed with `::` prefix in this library -2. **Batch operations limited** - Only INSERT, UPDATE, DELETE -3. **No record counts in batch** - Batch operations don't return `numberOfRecordsUpdated` -4. **MACADDR type unsupported** - Network MACADDR type not supported by Data API -5. **Some range types unsupported** - INT8RANGE, DATERANGE, TSRANGE have casting issues -6. **Multidimensional arrays** - Limited support for arrays with more than one dimension - -## Development Workflow - -### Making Changes -1. Edit TypeScript source in `src/*.ts` -2. Add/update tests in `src/*.test.ts` or `integration-tests/*.test.ts` -3. Run `npm run build` to compile TypeScript -4. Run `npm run lint` to check code style (lints TypeScript source) -5. Run `npm test` to verify tests pass (builds + runs Vitest) -6. Update README.md if adding features - -### TypeScript Development Notes -- Source code is in `src/` directory (TypeScript) -- Tests are colocated in `src/*.test.ts` (TypeScript) -- Integration tests in `integration-tests/*.test.ts` -- Test fixtures in `fixtures/` directory, imported via `#fixtures/*` alias -- Compiled output goes to `dist/` directory -- Type definitions are automatically generated in `dist/` -- ESLint has TypeScript-specific rules for `.ts` files -- Vitest config uses ES modules (`.mjs`) while output is CommonJS -- Package.json `imports` field provides `#fixtures/*` alias for cleaner imports - -### Build Process -1. `npm run prebuild` - Cleans dist directory -2. `npm run build` - Compiles TypeScript with `tsc` -3. Output directory: `dist/` (all compiled files) -4. Package points to `dist/index.js` (main) and `dist/index.d.ts` (types) - -### Release Process - -1. **Bump version**: `npm version patch|minor|major --no-git-tag-version` -2. **Commit version bump**: `git add package.json package-lock.json && git commit -m "chore: bump version to "` -3. **Create annotated tag**: `git tag -a v -m "Release v"` -4. **Push commit and tag**: `git push origin main && git push origin v` -5. **Create GitHub release**: Use `gh release create v --draft` with release notes -6. **Publish release**: Publishing the GitHub release triggers the npm publish workflow automatically - -### npm Publishing (OIDC Trusted Publishers) - -Publishing to npm uses OIDC Trusted Publishers (no long-lived npm tokens). Configuration is in `.github/workflows/publish.yml`. - -- **Permissions**: `id-token: write` must be at **top level** of the workflow (not job level) -- **Node version**: Node 24+ (for npm with built-in OIDC support) -- **Registry**: `registry-url: 'https://registry.npmjs.org'` on `actions/setup-node` -- **Auth**: No `NODE_AUTH_TOKEN` needed — OIDC handles authentication -- **Provenance**: Automatic with trusted publishing (no `--provenance` flag needed) -- **Triggers**: `release: [published]` (automatic) and `workflow_dispatch` (manual) -- **Trusted publisher** must be configured on npmjs.com package settings (org, repo, workflow filename) - -### CI/CD -- Tests can be run via `npm run test-ci` (linting + tests) -- Publish workflow runs lint, build, unit tests, and integration tests before publishing - -## Key Files - -``` -. -├── src/ -│ ├── index.ts # Entry point (exports init from client.ts) -│ ├── client.ts # Client initialization and configuration -│ ├── types.ts # Type definitions and interfaces -│ ├── params.ts # Parameter parsing, normalization, and processing -│ ├── query.ts # Query execution logic -│ ├── results.ts # Result formatting and record processing -│ ├── transaction.ts # Transaction management -│ ├── utils.ts # Utility functions -│ ├── retry.ts # Scale-to-zero wake-up retry logic -│ ├── pg-escape.ts # Internal PostgreSQL escaping utilities (no external deps) -│ ├── compat/ # Drop-in driver compatibility layers -│ │ ├── index.ts # Compat exports -│ │ ├── pg.ts # node-postgres (pg) compatible client/pool -│ │ ├── mysql2.ts # mysql2 compatible connection/pool -│ │ └── errors.ts # Data API → pg/mysql2 error mapping -│ └── *.test.ts # Colocated unit tests (params, query, results, utils, retry, pg-escape) -├── integration-tests/ -│ ├── setup.ts # Integration test setup -│ ├── INTEGRATION_TESTING.md # Aurora Serverless v2 setup guide -│ ├── mysql.int.test.ts # MySQL native client tests -│ ├── postgres.int.test.ts # PostgreSQL native client tests (comprehensive) -│ ├── pg-compat.int.test.ts # pg compat layer tests -│ ├── mysql2-compat.int.test.ts # mysql2 compat layer tests -│ └── {drizzle,kysely,knex}-*.int.test.ts # ORM integration tests -├── infra/ # CloudFormation for integration-test Aurora clusters -├── dist/ # Compiled output (gitignored, distributed in npm) -│ ├── index.js # Compiled main file -│ ├── index.d.ts # Type definitions -│ ├── types.js # Compiled types (runtime) -│ ├── types.d.ts # Exported type definitions -│ └── [other compiled files] -├── fixtures/ -│ └── sample-*-response.json # Mock API response fixtures (imported via #fixtures/*) -├── tsconfig.json # TypeScript configuration -├── tsconfig.eslint.json # TypeScript ESLint configuration -├── vitest.config.mjs # Vitest configuration (ES module, requires .mjs) -├── package.json # NPM config (main: dist/index.js; subpath exports: ./compat, ./compat/pg, ./compat/mysql2, ./types) -├── package-lock.json # Dependency lock file -├── README.md # User documentation -├── CLAUDE.md # AI development context (this file) -├── LICENSE # MIT license -├── .eslintrc.json # Linting rules (with TypeScript support) -├── .eslintignore # Files ignored by ESLint -├── .prettierrc.json # Code formatting -└── .gitignore # Git ignore (dist/ is gitignored but included in npm package) -``` - -## Important Implementation Notes - -### Parameter Processing Flow -1. `parseParams()` - Extract parameters from arguments -2. `normalizeParams()` - Convert to standard format -3. `processParams()` - Type conversion and SQL escaping (engine-specific) -4. Send to AWS RDS Data API -5. `formatResults()` - Convert response to user-friendly format - - `flattenArrayValue()` - Converts Data API arrayValue structure to native JavaScript arrays - -### Result Formatting Flow -1. Check for `arrayValue` in field → convert to native array using `flattenArrayValue()` -2. Handle Uint8Array → convert to Buffer -3. Apply type-specific formatting (dates, JSON, etc.) -4. Return formatted value - -### Transaction Flow -1. Call `beginTransaction()` - Get `transactionId` -2. Execute queries with `transactionId` via `executeStatement()` -3. On error: `rollbackTransaction()` with optional callback -4. On success: `commitTransaction()` -5. Return array of all query results + transaction status - -### Type Detection Logic -```javascript -typeof 'string' → stringValue -typeof boolean → booleanValue -integer → longValue -float → doubleValue -null → isNull (true) -Date object → stringValue + typeHint: 'TIMESTAMP' -Buffer → blobValue -Plain JavaScript object → stringValue + typeHint: 'JSON' + auto ::jsonb cast (PostgreSQL only) -{[supportedType]: value} → pass through -``` - -## TypeScript Type System - -The project exports comprehensive TypeScript types: - -**Main Types**: -- `DataAPIClientConfig` - Configuration options -- `DataAPIClient` - Main client interface -- `QueryOptions` - Query configuration -- `QueryResult` - Generic query results -- `Transaction` - Transaction builder interface -- `Parameters` - Parameter types (object or array) -- `FormatOptions` - Date formatting options -- `SupportedType` - Data API value types - -**Usage with TypeScript**: -```typescript -import dataApiClient from 'data-api-client' -import type { DataAPIClientConfig, QueryResult } from 'data-api-client/types' - -const config: DataAPIClientConfig = { - secretArn: 'arn:...', - resourceArn: 'arn:...', - database: 'mydb' -} - -const client = dataApiClient(config) - -interface User { - id: number - name: string - email: string -} - -const result: QueryResult = await client.query( - 'SELECT * FROM users WHERE id = :id', - { id: 123 } -) -``` - -## AWS SDK v3 Migration - -The project uses AWS SDK v3: - -**Key Changes from v2**: -- **Package**: Changed from `aws-sdk` to modular `@aws-sdk/client-rds-data` -- **Client**: `RDSDataService` → `RDSDataClient` -- **API Pattern**: `.promise()` callbacks → Command pattern with `client.send(new Command(params))` -- **Imports**: Modular imports from `@aws-sdk/client-rds-data` -- **Configuration**: Custom client via `client` param instead of `AWS` param - -**Benefits**: -- Smaller bundle size (only imports what's needed) -- Better tree-shaking support -- Faster cold starts in Lambda -- Modern async/await pattern -- TypeScript-first design - -**Migration for Users**: -```typescript -// SDK v3 (recommended): -import { RDSDataClient } from '@aws-sdk/client-rds-data' -const rdsClient = new RDSDataClient({ region: 'us-east-1' }) -const client = dataApiClient({ - client: rdsClient, -}) - -// Or use options (simpler): -const client = dataApiClient({ - options: { region: 'us-east-1' } -}) -``` - -## Useful Commands - -```bash -npm run build # Compile TypeScript to JavaScript -npm run build:watch # Compile TypeScript in watch mode -npm test # Build + run unit tests -npm run test:unit # Build + run unit tests -npm run test:int:core # Build + run native client integration tests (requires .env.local) -npm run test:int:compat # Build + run driver compat-layer integration tests -npm run test:int:orm:kysely # Build + run Kysely ORM integration tests (also :drizzle, :knex) -npm run test-ci # Build + lint + tests (for CI) -npm run lint # Run ESLint on TypeScript source -vitest # Run tests in watch mode (interactive) -vitest run # Run tests once -vitest --coverage # Run tests with coverage report -tsc --noEmit # Type-check without emitting files -``` - -## Contact & Support - -- Author: Jeremy Daly (@jeremy_daly on Twitter) -- Issues: https://github.com/jeremydaly/data-api-client/issues -- Primary use case: AWS Lambda with Aurora Serverless - -## Performance Notes - -- **Connection Reuse**: Set `AWS_NODEJS_CONNECTION_REUSE_ENABLED=1` for best performance -- **Batch Operations**: Use batch queries for multiple similar operations (3-5x faster) -- **Transaction Overhead**: ~50-100ms additional latency for transaction setup/commit -- **Lambda Cold Starts**: AWS SDK initialization adds ~200-300ms on cold starts - -## Security Considerations - -- Never log SQL with parameters (potential credential exposure) -- Secrets Manager ARN restricts database access -- Data API doesn't support resource-level ARNs (uses `Resource: "*"`) -- SQL injection protection via parameterized queries and identifier escaping -- No direct VPC access required (security through IAM + Secrets Manager)