diff --git a/.oxlintrc.json b/.oxlintrc.json index fe7aa9b..e4df111 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,7 +15,8 @@ "max-classes-per-file": "off", "max-lines": "off", "max-dependencies": "off", - "no-inline-comments": "off" + "no-inline-comments": "off", + "unicorn/no-array-sort": "off" }, "ignorePatterns": ["dist/**", "node_modules/**", "e2e/**"] } diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..a400b96 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# AGENTS.md + +## Purpose + +This file defines the architectural principles and implementation standards for contributing to `@8monkey/no-orm`. + +## Core Principles + +1. **Tiny Persistence Core**: We prioritize a small footprint. Avoid adding new abstractions or runtime dependencies. +2. **Schema-First & Static**: The schema is the source of truth. Type inference must be zero-cost. +3. **Runtime-Agnostic**: All code must run across Bun, Node.js, Deno, and Edge environments. +4. **Performance by Default**: We accept targeted complexity in internal hot paths (CRUD loops) to ensure the library adds minimal overhead to the underlying drivers. + +## Architecture + +- **The Adapter Pattern**: Every storage engine implements the `Adapter` interface. Adapters are stateful regarding their `schema` (passed at construction). +- **Autonomous Orchestration**: Each adapter owns its specific syntax orchestration (e.g., SQL template assembly, `RETURNING`, `ON CONFLICT`). +- **Shared Atomic Logic**: Shared logic for primary keys, pagination AST, and value helpers lives in `utils/common.ts`. SQL adapters utilize `utils/sql.ts` for atomic clause generation (`where`, `set`, `sort`). +- **QueryExecutor Abstraction**: Database drivers are wrapped in a `QueryExecutor` with a uniform interface (`all`/`get`/`run`/`transaction`). This allows the same adapter logic to support multiple drivers (e.g., `pg`, `postgres.js`, `bun:sqlite`). + +## Implementation Standards + +### TypeScript & Type Safety + +- **Use `unknown` over `any`**: All internal signatures must use `unknown` or concrete types. Use narrowing (guards/typeof) before access. +- **Justified Assertions**: Use `eslint-disable-next-line` with a short, specific reason for unavoidable type assertions (e.g., at adapter boundaries where `RowData -> T`). +- **Strict Linting**: Do not modify `.oxlintrc.json`. Fix the code to satisfy the rules. + +### High-Performance Internals + +These rules apply to all internal adapter methods and shared utility loops: + +- **No Object Spreads**: Use `Object.assign({}, ...)` instead of `{ ... }` to ensure hidden class stability and reduce transpilation overhead in hot paths. +- **Avoid `delete`**: Do not delete properties from objects (deoptimizes V8/JSC). Set to `undefined` or construct a new object. +- **Indexed Loops**: Prefer `for (let i = 0; i < arr.length; i++)` over `for...of` or `.forEach` to avoid iterator protocol overhead. +- **Sync Efficiency**: If an operation is naturally synchronous (like the `MemoryAdapter`), return `Promise.resolve(value)` instead of marking the method `async` to avoid unnecessary microtask scheduling. + +## Dependency Strategy + +- **Optional Peer Dependencies**: All database drivers (e.g., `pg`, `better-sqlite3`, `lru-cache`) are optional peer dependencies. Users only install what they use. +- **Driver Portability**: Ensure that unused driver imports are never evaluated (utilize separate entrypoints or dynamic checks). +- **Type Resolution**: All peer dependencies must have corresponding types in `devDependencies` to ensure `bun run typecheck` passes. + +## Change Workflow + +1. **Minimal Edits**: Keep changes localized. Avoid broad refactors unless explicitly requested. +2. **Order Preservation**: Do not rearrange existing classes or methods. Maintaining original order ensures clean git diffs and efficient review. +3. **Explain the "Why"**: Retain or add comments explaining architectural choices (e.g., sequential DDL, driver detection order). +4. **Verification**: + - Check `package.json` for current scripts. + - Run linting, type-checking, and tests (`bun test`) before finishing. + - Do not run `bun run clean` unless explicitly requested (`git clean -fdx`). + +## PR/Commit Checklist + +- [ ] Change is scoped to requested behavior. +- [ ] Types compile with zero errors. +- [ ] Lint/Format passes. +- [ ] Tests pass. +- [ ] No new `any` types or unjustified `eslint-disable`. +- [ ] No object spreads or `delete` in adapter hot paths. +- [ ] README updated if public API changed. diff --git a/README.md b/README.md index 8a81c2a..f85b31d 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,239 @@ -# no-orm +# @8monkey/no-orm -A tiny, database-independent persistence core for TypeScript libraries. No heavy abstractions, just the primitives. +A tiny, schema-first persistence core for TypeScript libraries. -## Features +`no-orm` is intentionally small: -- **Canonical Schema**: One portable schema representation for any database. -- **Type Inference**: Derive TypeScript types directly from your schema. -- **Adapter-Based**: Small, generic execution contract for multiple backends. +- one canonical schema shape +- inferred TypeScript model types +- adapter-based persistence +- minimal CRUD, filtering, ordering, pagination, and transactions + +It is not a query builder, migration framework, or full ORM runtime. ## Installation ```bash +npm install @8monkey/no-orm +# or bun add @8monkey/no-orm ``` -## Usage - -### 1. Define your Schema +## Define a Schema -```typescript -import { Schema } from "@8monkey/no-orm"; +```ts +import type { InferModel, Schema } from "@8monkey/no-orm"; export const schema = { - conversations: { + users: { fields: { - id: { type: { type: "string", max: 255 } }, - created_at: { type: { type: "timestamp" } }, - metadata: { type: { type: "json" }, nullable: true }, + id: { type: "string" }, + name: { type: "string", max: 255 }, + age: { type: "number" }, + is_active: { type: "boolean" }, + metadata: { type: "json", nullable: true }, + tags: { type: "json[]", nullable: true }, + created_at: { type: "timestamp" }, }, - primaryKey: { - fields: ["id"], - }, - indexes: [ - { - fields: [ - { field: "created_at", order: "desc" }, - { field: "id", order: "desc" }, - ], - }, - ], + primaryKey: "id", + indexes: [{ field: "created_at", order: "desc" }], }, } as const satisfies Schema; + +type User = InferModel; ``` -### 2. Infer Types +## Choose an Adapter -```typescript -import { InferModel } from "@8monkey/no-orm"; +### SQLite -export type Conversation = InferModel; -// Result: { id: string; created_at: number; metadata: Record | null; } +```ts +import { Database } from "bun:sqlite"; +import { SqliteAdapter } from "@8monkey/no-orm/adapters/sqlite"; + +const db = new Database("data.db"); // or ":memory:" for an in-process database +const adapter = new SqliteAdapter(schema, db); + +await adapter.migrate(); ``` -### 3. Use an Adapter +### Postgres + +```ts +import postgres from "postgres"; // or import { Pool } from "pg" +import { PostgresAdapter } from "@8monkey/no-orm/adapters/postgres"; -```typescript -import { Adapter } from "@8monkey/no-orm"; -// Import a concrete adapter (e.g., @8monkey/no-orm-sqlite) +const sql = postgres(process.env.POSTGRES_URL!); +const adapter = new PostgresAdapter(schema, sql); -const adapter: Adapter = new SqliteAdapter({ schema, db }); +await adapter.migrate(); +``` + +### Memory + +In-memory storage for testing or temporary data. + +```ts +import { MemoryAdapter } from "@8monkey/no-orm/adapters/memory"; + +const adapter = new MemoryAdapter(schema, { maxItems: 100 }); +await adapter.migrate(); +``` -// Minimal Schema Bootstrap -await adapter.migrate({ schema }); +## CRUD -// Create a record -const conv = await adapter.create({ - model: "conversations", +```ts +// Create +const created = await adapter.create({ + model: "users", data: { - id: "conv_123", - created_at: Date.now(), + id: "u1", + name: "Alice", + age: 30, + is_active: true, metadata: { theme: "dark" }, + tags: ["admin"], + created_at: Date.now(), }, }); -// Find many with filters -const results = await adapter.findMany({ - model: "conversations", +// Find one +const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, +}); + +// Find many +const users = await adapter.findMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + sortBy: [{ field: "created_at", direction: "desc" }], + limit: 20, +}); + +// Update one +const updated = await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { age: 31 }, +}); + +// Update many +const updatedCount = await adapter.updateMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + data: { age: 99 }, +}); + +// Delete one +await adapter.delete({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, +}); + +// Delete many +const deletedCount = await adapter.deleteMany({ + model: "users", + where: { field: "is_active", op: "eq", value: false }, +}); + +// Count +const total = await adapter.count({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, +}); + +// Upsert - insert or update by primary key +const user = await adapter.upsert({ + model: "users", + create: { id: "u1", name: "Alice", age: 30, is_active: true, created_at: Date.now() }, + update: { age: 31 }, + // Optional: only update if predicate is met + where: { field: "is_active", op: "eq", value: true }, +}); +``` + +## Filtering + +All operations accept a `where` clause: + +```ts +// Operators +where: { field: "age", op: "eq", value: 30 } +where: { field: "age", op: "ne", value: null } +where: { field: "age", op: "gt", value: 18 } +where: { field: "age", op: "gte", value: 18 } +where: { field: "age", op: "lt", value: 65 } +where: { field: "age", op: "lte", value: 65 } +where: { field: "status", op: "in", value: ["active", "pending"] } +where: { field: "status", op: "not_in", value: ["banned"] } + +// Combine with and/or +where: { + and: [ + { field: "age", op: "gte", value: 18 }, + { field: "is_active", op: "eq", value: true }, + ], +} +``` + +## JSON Paths + +Filter nested JSON fields using `path`: + +```ts +const darkUsers = await adapter.findMany({ + model: "users", where: { - field: "created_at", - op: "gt", - value: Date.now() - 86400000, + field: "metadata", + path: ["preferences", "theme"], + op: "eq", + value: "dark", + }, +}); +``` + +## Pagination + +```ts +// Offset pagination +const page = await adapter.findMany({ + model: "users", + sortBy: [{ field: "created_at", direction: "desc" }], + limit: 20, + offset: 40, +}); + +// Cursor pagination (keyset) +const cursorPage = await adapter.findMany({ + model: "users", + sortBy: [{ field: "created_at", direction: "desc" }], + limit: 20, + cursor: { + after: { created_at: 1699900000000, id: "u20" }, }, - limit: 10, }); ``` +## Transactions + +```ts +await adapter.transaction(async (tx) => { + await tx.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 28, is_active: true, created_at: Date.now() }, + }); + + await tx.update({ + model: "users", + where: { field: "id", op: "eq", value: "u2" }, + data: { age: 29 }, + }); +}); +``` + +Nested calls to `transaction()` join the existing transaction. + ## License MIT diff --git a/bun.lock b/bun.lock index cd4ff65..e24d852 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,43 @@ "": { "name": "@8monkey/no-orm", "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.12", + "@types/pg": "^8.11.11", + "@types/sqlite3": "^5.1.0", + "lru-cache": "^11.0.0", "oxfmt": "^0.44.0", "oxlint": "^1.59.0", "oxlint-tsgolint": "^0.20.0", + "postgres": "^3.4.5", + "sqlite": "^5.1.1", "typescript": "^6.0.2", }, + "peerDependencies": { + "better-sqlite3": "^11.0.0", + "lru-cache": "^11.0.0", + "pg": "^8.0.0", + "postgres": "^3.4.0", + "sqlite": "^5.0.0", + "sqlite3": "^5.0.0", + }, + "optionalPeers": [ + "better-sqlite3", + "lru-cache", + "pg", + "postgres", + "sqlite", + "sqlite3", + ], }, }, "packages": { + "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], + + "@npmcli/fs": ["@npmcli/fs@1.1.1", "", { "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" } }, "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ=="], + + "@npmcli/move-file": ["@npmcli/move-file@1.1.2", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="], @@ -102,22 +130,306 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA=="], + "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], + + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "@types/sqlite3": ["@types/sqlite3@5.1.0", "", { "dependencies": { "sqlite3": "*" } }, "sha512-w25Gd6OzcN0Sb6g/BO7cyee0ugkiLgonhgGYfG+H0W9Ub6PUsC2/4R+KXy2tc80faPIWO3Qytbvr8gP1fU4siA=="], + + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + + "are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@4.0.1", "", { "dependencies": { "@tootallnate/once": "1", "agent-base": "6", "debug": "4" } }, "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + + "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], + + "minipass-fetch": ["minipass-fetch@1.4.1", "", { "dependencies": { "minipass": "^3.1.0", "minipass-sized": "^1.0.3", "minizlib": "^2.0.0" }, "optionalDependencies": { "encoding": "^0.1.12" } }, "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-gyp": ["node-gyp@8.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^9.1.0", "nopt": "^5.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w=="], + + "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="], + + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="], "oxlint": ["oxlint@1.59.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.59.0", "@oxlint/binding-android-arm64": "1.59.0", "@oxlint/binding-darwin-arm64": "1.59.0", "@oxlint/binding-darwin-x64": "1.59.0", "@oxlint/binding-freebsd-x64": "1.59.0", "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", "@oxlint/binding-linux-arm-musleabihf": "1.59.0", "@oxlint/binding-linux-arm64-gnu": "1.59.0", "@oxlint/binding-linux-arm64-musl": "1.59.0", "@oxlint/binding-linux-ppc64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-musl": "1.59.0", "@oxlint/binding-linux-s390x-gnu": "1.59.0", "@oxlint/binding-linux-x64-gnu": "1.59.0", "@oxlint/binding-linux-x64-musl": "1.59.0", "@oxlint/binding-openharmony-arm64": "1.59.0", "@oxlint/binding-win32-arm64-msvc": "1.59.0", "@oxlint/binding-win32-ia32-msvc": "1.59.0", "@oxlint/binding-win32-x64-msvc": "1.59.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw=="], "oxlint-tsgolint": ["oxlint-tsgolint@0.20.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.20.0", "@oxlint-tsgolint/darwin-x64": "0.20.0", "@oxlint-tsgolint/linux-arm64": "0.20.0", "@oxlint-tsgolint/linux-x64": "0.20.0", "@oxlint-tsgolint/win32-arm64": "0.20.0", "@oxlint-tsgolint/win32-x64": "0.20.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ=="], + "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@6.2.1", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ=="], + + "sqlite": ["sqlite@5.1.1", "", {}, "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q=="], + + "sqlite3": ["sqlite3@5.1.7", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1", "tar": "^6.1.11" }, "optionalDependencies": { "node-gyp": "8.x" } }, "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog=="], + + "ssri": ["ssri@8.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unique-filename": ["unique-filename@1.1.1", "", { "dependencies": { "unique-slug": "^2.0.0" } }, "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ=="], + + "unique-slug": ["unique-slug@2.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "cacache/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "make-fetch-happen/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], } } diff --git a/package.json b/package.json index d82aec9..4294d99 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,18 @@ ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" + }, + "./adapters/memory": { + "types": "./dist/adapters/memory.d.ts", + "import": "./dist/adapters/memory.js" + }, + "./adapters/sqlite": { + "types": "./dist/adapters/sqlite.d.ts", + "import": "./dist/adapters/sqlite.js" + }, + "./adapters/postgres": { + "types": "./dist/adapters/postgres.d.ts", + "import": "./dist/adapters/postgres.js" } }, "scripts": { @@ -47,14 +59,45 @@ "check": "bun lint && bun typecheck", "fix": "bun lint:staged && bun format:staged" }, - "dependencies": {}, "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.12", + "@types/pg": "^8.0.0", + "@types/sqlite3": "^5.1.0", + "lru-cache": "^11.0.0", "oxfmt": "^0.44.0", "oxlint": "^1.59.0", "oxlint-tsgolint": "^0.20.0", + "postgres": "^3.4.5", + "sqlite": "^5.1.1", "typescript": "^6.0.2" }, - "peerDependencies": {}, - "peerDependenciesMeta": {} + "peerDependencies": { + "better-sqlite3": "^11.0.0", + "lru-cache": "^11.0.0", + "pg": "^8.0.0", + "postgres": "^3.4.0", + "sqlite": "^5.0.0", + "sqlite3": "^5.1.7" + }, + "peerDependenciesMeta": { + "better-sqlite3": { + "optional": true + }, + "lru-cache": { + "optional": true + }, + "pg": { + "optional": true + }, + "postgres": { + "optional": true + }, + "sqlite": { + "optional": true + }, + "sqlite3": { + "optional": true + } + } } diff --git a/src/adapters/memory.test.ts b/src/adapters/memory.test.ts new file mode 100644 index 0000000..74285b4 --- /dev/null +++ b/src/adapters/memory.test.ts @@ -0,0 +1,707 @@ +import { describe, expect, it, beforeEach } from "bun:test"; + +import type { Schema, InferModel } from "../types"; +import { MemoryAdapter } from "./memory"; + +describe("MemoryAdapter", () => { + const schema = { + users: { + fields: { + id: { type: "string" }, + name: { type: "string" }, + age: { type: "number" }, + is_active: { type: "boolean" }, + metadata: { type: "json", nullable: true }, + tags: { type: "json[]", nullable: true }, + }, + primaryKey: "id", + }, + items: { + fields: { + group_id: { type: "string" }, + item_id: { type: "string" }, + value: { type: "number" }, + created_at: { type: "timestamp" }, + }, + primaryKey: ["group_id", "item_id"], + }, + } as const satisfies Schema; + + type User = InferModel; + + let adapter: MemoryAdapter; + + beforeEach(async () => { + adapter = new MemoryAdapter(schema); + await adapter.migrate(); + }); + + // --- Create & Find --- + + it("should create and find a record", async () => { + const userData: User = { + id: "u1", + name: "Alice", + age: 25, + is_active: true, + metadata: { theme: "dark" }, + }; + + await adapter.create({ model: "users", data: userData }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + + expect(found).toEqual(userData); + }); + + it("should reject duplicate primary keys", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + + try { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Bob", age: 30, is_active: true, metadata: null }, + }); + expect.unreachable("create should reject duplicate primary keys"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message).toContain("already exists"); + } + }); + + it("should return null for find with no match", async () => { + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "nonexistent" }, + }); + expect(found).toBeNull(); + }); + + // --- Composite primary keys --- + + it("should support composite primary keys", async () => { + await adapter.create({ + model: "items", + data: { group_id: "g1", item_id: "i1", value: 10, created_at: 1000 }, + }); + await adapter.create({ + model: "items", + data: { group_id: "g1", item_id: "i2", value: 20, created_at: 2000 }, + }); + + const found = await adapter.find({ + model: "items", + where: { + and: [ + { field: "group_id", op: "eq", value: "g1" }, + { field: "item_id", op: "eq", value: "i2" }, + ], + }, + }); + expect(found?.["value"]).toBe(20); + + const all = await adapter.findMany({ model: "items" }); + expect(all).toHaveLength(2); + }); + + // --- Select projection --- + + it("should project fields with select", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + select: ["id", "name"], + }); + + expect(found?.["id"]).toBe("u1"); + expect(found?.["name"]).toBe("Alice"); + expect(Object.keys(found!)).toEqual(["id", "name"]); + }); + + // --- FindMany --- + + it("should find multiple records with filters", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: false, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: true, metadata: null }, + }); + + const actives = await adapter.findMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + sortBy: [{ field: "age", direction: "asc" }], + }); + + expect(actives).toHaveLength(2); + expect(actives[0]?.["name"]).toBe("Alice"); + expect(actives[1]?.["name"]).toBe("Charlie"); + }); + + it("should return empty array when no records match", async () => { + const results = await adapter.findMany({ + model: "users", + where: { field: "age", op: "gt", value: 1000 }, + }); + expect(results).toHaveLength(0); + }); + + it("should support offset pagination", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "User1", age: 10, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "User2", age: 20, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u3", name: "User3", age: 30, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u4", name: "User4", age: 40, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u5", name: "User5", age: 50, is_active: true, metadata: null }, + }); + + const page = await adapter.findMany({ + model: "users", + sortBy: [{ field: "age", direction: "asc" }], + limit: 2, + offset: 2, + }); + expect(page).toHaveLength(2); + expect(page[0]?.["age"]).toBe(30); + expect(page[1]?.["age"]).toBe(40); + }); + + it("should support in/not_in operators", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: true, metadata: null }, + }); + + const inResult = await adapter.findMany({ + model: "users", + where: { field: "name", op: "in", value: ["Alice", "Charlie"] }, + }); + expect(inResult).toHaveLength(2); + + const notInResult = await adapter.findMany({ + model: "users", + where: { field: "name", op: "not_in", value: ["Alice", "Charlie"] }, + }); + expect(notInResult).toHaveLength(1); + expect(notInResult[0]?.["name"]).toBe("Bob"); + }); + + // --- JSON path filters --- + + it("should support nested JSON path filters", async () => { + await adapter.create({ + model: "users", + data: { + id: "u1", + name: "Alice", + age: 25, + is_active: true, + metadata: { settings: { theme: "dark" } }, + }, + }); + await adapter.create({ + model: "users", + data: { + id: "u2", + name: "Bob", + age: 30, + is_active: true, + metadata: { settings: { theme: "light" } }, + }, + }); + + const darkThemeUsers = await adapter.findMany({ + model: "users", + where: { field: "metadata", path: ["settings", "theme"], op: "eq", value: "dark" }, + }); + + expect(darkThemeUsers).toHaveLength(1); + expect(darkThemeUsers[0]?.["name"]).toBe("Alice"); + }); + + // --- Update --- + + it("should update a record", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + + await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { age: 26 }, + }); + + const updated = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + + expect(updated?.["age"]).toBe(26); + }); + + it("should reject primary key updates", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + + try { + await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { id: "u2" }, + }); + expect.unreachable("update should reject primary key changes"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message).toBe("Primary key updates are not supported."); + } + }); + + it("should return null when updating non-existent record", async () => { + const result = await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "nonexistent" }, + data: { age: 99 }, + }); + expect(result).toBeNull(); + }); + + // --- UpdateMany --- + + it("should update multiple records", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: false, metadata: null }, + }); + + const count = await adapter.updateMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + data: { age: 99 }, + }); + expect(count).toBe(2); + + const alice = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(alice?.["age"]).toBe(99); + + const charlie = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u3" }, + }); + expect(charlie?.["age"]).toBe(35); // unchanged + }); + + // --- Delete --- + + it("should delete a record", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + + await adapter.delete({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + + expect(found).toBeNull(); + }); + + // --- DeleteMany --- + + it("should delete multiple records", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: false, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: true, metadata: null }, + }); + + const count = await adapter.deleteMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + }); + expect(count).toBe(2); + + const remaining = await adapter.findMany({ model: "users" }); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.["name"]).toBe("Bob"); + }); + + // --- Count --- + + it("should count records", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: false, metadata: null }, + }); + + const total = await adapter.count({ model: "users" }); + expect(total).toBe(2); + + const actives = await adapter.count({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + }); + expect(actives).toBe(1); + }); + + // --- Transaction --- + + it("should support transaction passthrough", async () => { + await adapter.transaction(async (tx) => { + await tx.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.["name"]).toBe("Alice"); + }); + + // --- Logical operators --- + + it("should support complex logical operators", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: false, metadata: null }, + }); + + const results = await adapter.findMany({ + model: "users", + where: { + or: [ + { field: "age", op: "gt", value: 28 }, + { field: "name", op: "eq", value: "Alice" }, + ], + }, + }); + + expect(results).toHaveLength(2); + }); + + // --- Null handling --- + + it("should filter by null equality (op: eq, value: null)", async () => { + await adapter.create({ + model: "users", + data: { id: "u4", name: "NullUser", age: 40, is_active: true, metadata: null, tags: null }, + }); + const users = await adapter.findMany({ + model: "users", + where: { field: "metadata", op: "eq", value: null }, + }); + expect(users.find((u) => u["id"] === "u4")).toBeDefined(); + }); + + it("should filter by null inequality (op: ne, value: null)", async () => { + await adapter.create({ + model: "users", + data: { id: "u4", name: "NullUser", age: 40, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { + id: "u5", + name: "NotNullUser", + age: 40, + is_active: true, + metadata: { has_data: true }, + }, + }); + const users = await adapter.findMany({ + model: "users", + where: { field: "metadata", op: "ne", value: null }, + }); + expect(users.find((u) => u["id"] === "u5")).toBeDefined(); + expect(users.find((u) => u["id"] === "u4")).toBeUndefined(); + }); + + // --- Upsert --- + + describe("Upsert", () => { + it("should handle upsert correctly (insert and update)", async () => { + const userData: User = { + id: "u1", + name: "Alice", + age: 25, + is_active: true as boolean, + metadata: null, + }; + + // 1. Insert because it doesn't exist + await adapter.upsert({ + model: "users", + create: userData, + update: { age: 30 }, + }); + + let found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.["age"]).toBe(25); // Should have used 'create' data + + // 2. Update because it exists + await adapter.upsert({ + model: "users", + create: userData, + update: { age: 31 }, + }); + + found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.["age"]).toBe(31); // Should have used 'update' data + }); + + it("should support predicated upsert", async () => { + const userData: User = { + id: "u1", + name: "Alice", + age: 25, + is_active: true as boolean, + metadata: null, + }; + + await adapter.create({ model: "users", data: userData }); + + // Condition fails, no update + await adapter.upsert({ + model: "users", + create: userData, + update: { age: 30 }, + where: { field: "age", op: "gt", value: 40 }, + }); + + let found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.["age"]).toBe(25); + + // Condition passes, update happens + await adapter.upsert({ + model: "users", + create: userData, + update: { age: 30 }, + where: { field: "age", op: "lt", value: 40 }, + }); + + found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.["age"]).toBe(30); + }); + + it("should throw error if primary key is missing in 'create' data", async () => { + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- deliberate invalid data for error case + const invalidData = { + name: "Missing ID", + age: 20, + } as unknown as User; + + try { + await adapter.upsert({ + model: "users", + create: invalidData, + update: { age: 21 }, + }); + expect.unreachable("upsert should reject missing primary key data"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message).toBe("Missing primary key field: id"); + } + }); + }); + + // --- Sorting --- + + it("should sort records with null values", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: { theme: "dark" } }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: true, metadata: null }, + }); + + const results = await adapter.findMany({ + model: "users", + sortBy: [{ field: "metadata", direction: "asc" }], + }); + + expect(results).toHaveLength(2); + expect(results[0]?.["id"]).toBe("u2"); // null should come first in asc + expect(results[1]?.["id"]).toBe("u1"); + }); + + // --- Pagination --- + + it("should support keyset pagination", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 20, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 20, is_active: true, metadata: null }, + }); + await adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 30, is_active: true, metadata: null }, + }); + + // Page 1 + const p1 = await adapter.findMany({ + model: "users", + sortBy: [ + { field: "age", direction: "asc" }, + { field: "id", direction: "asc" }, + ], + limit: 2, + }); + expect(p1).toHaveLength(2); + expect(p1[0]?.["id"]).toBe("u1"); + expect(p1[1]?.["id"]).toBe("u2"); + + // Page 2 + const p2 = await adapter.findMany({ + model: "users", + sortBy: [ + { field: "age", direction: "asc" }, + { field: "id", direction: "asc" }, + ], + cursor: { + after: [ + { field: "age", value: 20 }, + { field: "id", value: "u2" }, + ], + }, + }); + expect(p2).toHaveLength(1); + expect(p2[0]?.["id"]).toBe("u3"); + }); + + // --- LRU eviction --- + + it("should evict oldest entries when maxItems is exceeded", async () => { + const smallAdapter = new MemoryAdapter(schema, { maxItems: 2 }); + await smallAdapter.migrate(); + + await smallAdapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null }, + }); + await smallAdapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: true, metadata: null }, + }); + await smallAdapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: true, metadata: null }, + }); + + // u1 should have been evicted (maxSize=2) + const u1 = await smallAdapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(u1).toBeNull(); + + // u2 and u3 should still exist + const u3 = await smallAdapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u3" }, + }); + expect(u3).not.toBeNull(); + }); +}); diff --git a/src/adapters/memory.ts b/src/adapters/memory.ts new file mode 100644 index 0000000..82345e7 --- /dev/null +++ b/src/adapters/memory.ts @@ -0,0 +1,474 @@ +import { LRUCache } from "lru-cache"; + +import type { Adapter, Cursor, FieldName, InferModel, Schema, SortBy, Where } from "../types"; +import { + assertNoPrimaryKeyUpdates, + getNestedValue, + getPaginationFilter, + getPrimaryKeyFieldNames, + getPrimaryKeyValues, + walkWhere, + type Project, + type RowData, +} from "./utils/common"; + +const DEFAULT_MAX_ITEMS = 1000; + +export interface MemoryAdapterOptions { + maxItems?: number; +} + +/** + * In-memory adapter with bounded global storage and high-performance indexed scans. + * + * Technical Design: + * - Table Storage: Per-table arrays (Heaps) allow for O(1) indexed scans. + * - PK Index: Per-table Maps for O(1) primary key lookups. + * - Global Eviction: A single LRUCache tracks all rows across all tables to enforce maxItems. + * - O(1) Removals: Uses an index map and swap-and-pop to remove evicted rows without array shifts. + */ +export class MemoryAdapter implements Adapter { + private tables = new Map(); + private pkIndexes = new Map>(); + private indexMap = new Map(); + private globalLRU: LRUCache; + + constructor( + private schema: S, + private options?: MemoryAdapterOptions, + ) { + this.globalLRU = new LRUCache({ + max: this.options?.maxItems ?? DEFAULT_MAX_ITEMS, + dispose: (model, row, reason) => { + if (reason === "evict" || reason === "set") { + this.removeFromTable(row, model); + } + }, + }); + + const keys = Object.keys(this.schema) as (keyof S & string)[]; + for (let i = 0; i < keys.length; i++) { + const key = keys[i]!; + this.tables.set(key, []); + this.pkIndexes.set(key, new Map()); + } + } + + migrate(): Promise { + return Promise.resolve(); + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + // Transactions are not supported; operations are executed directly without rollback on error. + return fn(this); + } + + create> = never>(args: { + model: K; + data: InferModel; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>> { + type Row = InferModel; + const { model, data, select } = args; + this.assertNoUnknownFields(model, data as Record); + const pkIndex = this.pkIndexes.get(model)!; + const pkValue = this.getPrimaryKeyHash(model, data as Record); + + if (pkIndex.has(pkValue)) { + return Promise.reject( + new Error(`Record with primary key ${pkValue} already exists in ${model}`), + ); + } + + const record: RowData = Object.assign({}, data); + const heap = this.tables.get(model)!; + + const index = heap.length; + heap.push(record); + pkIndex.set(pkValue, record); + this.indexMap.set(record, index); + this.globalLRU.set(record, model); + + return Promise.resolve(this.mapFromRecord(record, select)); + } + + find> = never>(args: { + model: K; + where: Where>; + select?: readonly F[]; + }): Promise<([F] extends [never] ? InferModel : Pick, F>) | null> { + type Row = InferModel; + const { model, where, select } = args; + + // Fast path: PK lookup + const primaryKeyFieldNames = getPrimaryKeyFieldNames(this.schema[model]!); + if ( + "field" in where && + primaryKeyFieldNames.length === 1 && + where.field === primaryKeyFieldNames[0] && + where.op === "eq" + ) { + const pkValue = JSON.stringify([where.value ?? null]); + const row = this.pkIndexes.get(model)!.get(pkValue); + if (row && this.matchesWhere(where, row)) { + this.globalLRU.get(row); // Touch for LRU + return Promise.resolve(this.mapFromRecord(row, select)); + } + } + + const heap = this.tables.get(model)!; + for (let i = 0; i < heap.length; i++) { + const value = heap[i]!; + if (this.matchesWhere(where, value)) { + this.globalLRU.get(value); // Touch for LRU + return Promise.resolve(this.mapFromRecord(value, select)); + } + } + return Promise.resolve(null); + } + + findMany> = never>(args: { + model: K; + where?: Where>; + select?: readonly F[]; + sortBy?: SortBy>[]; + limit?: number; + offset?: number; + cursor?: Cursor>; + }): Promise<([F] extends [never] ? InferModel : Pick, F>)[]> { + type Row = InferModel; + const { model, where, select, sortBy, limit, offset, cursor } = args; + const heap = this.tables.get(model)!; + + const results: RowData[] = []; + for (let i = 0; i < heap.length; i++) { + const value = heap[i]!; + if (this.matchesWhere(where, value)) { + results.push(value); + } + } + + let out: RowData[] = results; + if (cursor !== undefined) { + out = this.filterByCursor(out, cursor, sortBy); + } + + if (sortBy !== undefined && sortBy.length > 0) { + out = this.applySort(out, sortBy); + } + + const start = offset ?? 0; + const end = limit === undefined ? out.length : start + limit; + const final: Project[] = []; + for (let i = start; i < end && i < out.length; i++) { + const r = out[i]!; + this.globalLRU.get(r); // Touch for LRU + final.push(this.mapFromRecord(r, select)); + } + return Promise.resolve(final); + } + + private definedPatch(data: Record): RowData { + const patch: RowData = {}; + const keys = Object.keys(data); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]!; + if (data[key] !== undefined) patch[key] = data[key]; + } + return patch; + } + + /** + * Updates the first record matching the criteria. Primary key updates are rejected. + */ + update(args: { + model: K; + where: Where>; + data: Partial>; + }): Promise | null> { + type Row = InferModel; + const { model, where, data } = args; + const patch = this.definedPatch(data as Record); + assertNoPrimaryKeyUpdates(this.schema[model]!, patch); + this.assertNoUnknownFields(model, patch); + const heap = this.tables.get(model)!; + + for (let i = 0; i < heap.length; i++) { + const value = heap[i]!; + if (this.matchesWhere(where, value)) { + const updated: RowData = Object.assign(value, patch); + this.globalLRU.get(updated); // Touch for LRU + return Promise.resolve(this.mapFromRecord(updated)); + } + } + return Promise.resolve(null); + } + + /** + * Updates all records matching the criteria. Primary key updates are rejected. + */ + updateMany(args: { + model: K; + where?: Where>; + data: Partial>; + }): Promise { + const { model, where, data } = args; + const patch = this.definedPatch(data as Record); + assertNoPrimaryKeyUpdates(this.schema[model]!, patch); + this.assertNoUnknownFields(model, patch); + if (Object.keys(patch).length === 0) return Promise.resolve(0); + const heap = this.tables.get(model)!; + + let count = 0; + for (let i = 0; i < heap.length; i++) { + const value = heap[i]!; + if (this.matchesWhere(where, value)) { + Object.assign(value, patch); + this.globalLRU.get(value); // Touch for LRU + count++; + } + } + return Promise.resolve(count); + } + + /** + * Performs an atomic insert-or-update. + * + * Conflicts are always handled on the Primary Key. If `where` is provided, the record + * is only updated if the condition is met (acting as a predicate). Primary key + * updates are rejected. + */ + upsert> = never>(args: { + model: K; + create: InferModel; + update: Partial>; + where?: Where>; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>> { + type Row = InferModel; + const { model, create, update, where, select } = args; + const patch = this.definedPatch(update as Record); + assertNoPrimaryKeyUpdates(this.schema[model]!, patch); + this.assertNoUnknownFields(model, create as Record); + this.assertNoUnknownFields(model, patch); + const pkValue = this.getPrimaryKeyHash(model, create as Record); + const existing = this.pkIndexes.get(model)!.get(pkValue); + + if (existing !== undefined) { + if (this.matchesWhere(where, existing)) { + const updated: RowData = Object.assign(existing, patch); + this.globalLRU.get(updated); // Touch for LRU + return Promise.resolve(this.mapFromRecord(updated, select)); + } + this.globalLRU.get(existing); + return Promise.resolve(this.mapFromRecord(existing, select)); + } + + return this.create({ model, data: create, select }); + } + + delete(args: { + model: K; + where: Where>; + }): Promise { + const { model, where } = args; + const heap = this.tables.get(model)!; + + for (let i = 0; i < heap.length; i++) { + const value = heap[i]!; + if (this.matchesWhere(where, value)) { + this.globalLRU.delete(value); + this.removeFromTable(value, model); + return Promise.resolve(); + } + } + return Promise.resolve(); + } + + deleteMany(args: { + model: K; + where?: Where>; + }): Promise { + const { model, where } = args; + const heap = this.tables.get(model)!; + const toDelete: RowData[] = []; + + for (let i = 0; i < heap.length; i++) { + const value = heap[i]!; + if (this.matchesWhere(where, value)) { + toDelete.push(value); + } + } + for (let i = 0; i < toDelete.length; i++) { + const row = toDelete[i]!; + this.globalLRU.delete(row); + this.removeFromTable(row, model); + } + return Promise.resolve(toDelete.length); + } + + count(args: { + model: K; + where?: Where>; + }): Promise { + const { model, where } = args; + const heap = this.tables.get(model)!; + + if (where === undefined) { + return Promise.resolve(heap.length); + } + + let count = 0; + for (let i = 0; i < heap.length; i++) { + if (this.matchesWhere(where, heap[i]!)) count++; + } + return Promise.resolve(count); + } + + // --- Private helpers --- + + private assertNoUnknownFields(model: keyof S & string, data: Record): void { + const knownFields = this.schema[model]!.fields; + const keys = Object.keys(data); + for (let i = 0; i < keys.length; i++) { + const key = keys[i]!; + if (!(key in knownFields)) { + throw new Error(`Unknown field "${key}" in model "${model}"`); + } + } + } + + private removeFromTable(row: RowData, model: keyof S & string) { + const heap = this.tables.get(model); + const pkIndex = this.pkIndexes.get(model); + if (!heap || !pkIndex) return; + + const idx = this.indexMap.get(row); + if (idx === undefined) return; + + // Swap-and-pop + if (idx !== heap.length - 1) { + const lastRow = heap.at(-1)!; + heap[idx] = lastRow; + this.indexMap.set(lastRow, idx); + } + heap.pop(); + + this.indexMap.delete(row); + const pkValue = this.getPrimaryKeyHash(model, row); + pkIndex.delete(pkValue); + } + + private getPrimaryKeyHash(modelName: keyof S & string, data: Record): string { + const modelSpec = this.schema[modelName]!; + const primaryKeyValues = getPrimaryKeyValues(modelSpec, data); + const primaryKeyFieldNames = getPrimaryKeyFieldNames(modelSpec); + const tuple: unknown[] = []; + for (let i = 0; i < primaryKeyFieldNames.length; i++) { + tuple.push(primaryKeyValues[primaryKeyFieldNames[i]!] ?? null); + } + return JSON.stringify(tuple); + } + + private matchesWhere>( + where: Where | undefined, + record: RowData, + ): boolean { + if (where === undefined) return true; + return walkWhere(where, { + and: (children) => children.every(Boolean), + or: (children) => children.some(Boolean), + leaf: (c) => { + const recordVal = getNestedValue(record, c.field, c.path); + const opStr: string = c.op; + switch (c.op) { + case "eq": + return recordVal === c.value; + case "ne": + return recordVal !== c.value; + case "gt": + return compareValues(recordVal, c.value) > 0; + case "gte": + return compareValues(recordVal, c.value) >= 0; + case "lt": + return compareValues(recordVal, c.value) < 0; + case "lte": + return compareValues(recordVal, c.value) <= 0; + case "in": + return c.value.includes(recordVal); + case "not_in": + return !c.value.includes(recordVal); + default: + throw new Error(`Unsupported operator: ${opStr}`); + } + }, + }); + } + + private mapFromRecord = never>( + record: RowData, + select?: readonly F[], + ): Project { + let res: RowData; + if (select === undefined) { + res = Object.assign({}, record); + } else { + res = {}; + for (let i = 0; i < select.length; i++) { + const k = select[i]!; + res[k] = record[k] ?? null; + } + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- adapter storage rows match the requested schema row or projection type + return res as Project; + } + + private filterByCursor( + results: RowData[], + cursor: Cursor, + sortBy?: SortBy[], + ): RowData[] { + const paginationWhere = getPaginationFilter(cursor, sortBy); + if (!paginationWhere) return results; + + const filtered: RowData[] = []; + for (let i = 0; i < results.length; i++) { + const record = results[i]!; + if (this.matchesWhere(paginationWhere, record)) { + filtered.push(record); + } + } + return filtered; + } + + private applySort(results: RowData[], sortBy: SortBy[]): RowData[] { + const sorted = results.slice(); + sorted.sort((a, b) => { + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + const valA = getNestedValue(a, s.field, s.path); + const valB = getNestedValue(b, s.field, s.path); + if (valA === valB) continue; + const comparison = compareValues(valA, valB); + if (comparison === 0) continue; + return s.direction === "desc" ? -comparison : comparison; + } + return 0; + }); + return sorted; + } +} + +function compareValues(left: unknown, right: unknown): number { + if (left === right) return 0; + if (left === undefined || left === null) return -1; + if (right === undefined || right === null) return 1; + if (typeof left !== typeof right) return 0; + if (typeof left === "string" && typeof right === "string") { + return left < right ? -1 : left > right ? 1 : 0; + } + if (typeof left === "number" && typeof right === "number") { + return left < right ? -1 : left > right ? 1 : 0; + } + return 0; +} diff --git a/src/adapters/postgres.ts b/src/adapters/postgres.ts new file mode 100644 index 0000000..2499863 --- /dev/null +++ b/src/adapters/postgres.ts @@ -0,0 +1,537 @@ +import type { SQL as BunSQL } from "bun"; +import type { Client as PgClient, Pool as PgPool, PoolClient as PgPoolClient } from "pg"; +import type postgres from "postgres"; + +import type { + Adapter, + Field, + FieldName, + InferModel, + Schema, + SortBy, + Where, + Cursor, + Model, +} from "../types"; +import { + assertNoPrimaryKeyUpdates, + buildPrimaryKeyFilter, + fnv1aHash, + getPrimaryKeyFieldNames, + getPrimaryKeyValues, + mapNumeric, + type Project, + type RowData, +} from "./utils/common"; +import { + type Fragment, + type QueryExecutor, + isQueryExecutor, + id, + extractFields, + where, + set, + sort, + stringifyJsonParam, + toNumberedParams, + selectSql, + insertSql, + updateSql, + deleteSql, + upsertSql, + countSql, + migrateSqls, +} from "./utils/sql"; + +type PostgresJsSql = postgres.Sql; +type TransactionSql = postgres.TransactionSql; + +export type PostgresDriver = + | PgClient + | PgPool + | PgPoolClient + | PostgresJsSql + | TransactionSql + | BunSQL; + +// --- Internal PG Syntax Helpers --- + +function mapFromRecord = never>( + model: Model, + record: RowData, +): Project { + const fields = model.fields; + const keys = Object.keys(record); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const field = fields[k]; + if (field?.type === "timestamp" && typeof record[k] === "string") { + record[k] = mapNumeric(record[k]); + } + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- DB returned only the selected columns; body only coerces field types + return record as Project; +} + +function sqlType(field: Field): string { + switch (field.type) { + case "string": + return field.max === undefined ? "TEXT" : `VARCHAR(${field.max})`; + case "number": + return "DOUBLE PRECISION"; + case "boolean": + return "BOOLEAN"; + case "timestamp": + return "BIGINT"; + case "json": + case "json[]": + return "JSONB"; + default: + return "TEXT"; + } +} + +function toColumnExpr(model: Model, fieldName: string, path?: string[], value?: unknown): Fragment { + if (!path || path.length === 0) return id(fieldName); + const field = model.fields[fieldName]; + if (field?.type !== "json" && field?.type !== "json[]") { + throw new Error(`Cannot use JSON path on non-JSON field: ${fieldName}`); + } + + const hint: unknown = Array.isArray(value) ? value[0] : value; + const isNumeric = typeof hint === "number"; + const isBoolean = typeof hint === "boolean"; + + // Path elements are schema-defined identifiers; safe to inline as SQL string literals. + let pathStr = id(fieldName).text; + for (let i = 0; i < path.length; i++) pathStr += `, '${path[i]!}'`; + + let text = `jsonb_extract_path_text(${pathStr})`; + if (isNumeric) text = `(${text})::double precision`; + else if (isBoolean) text = `(${text})::boolean`; + return { text, params: [] }; +} + +// --- Driver detection --- + +function isBunSql(driver: PostgresDriver): driver is BunSQL { + return typeof driver === "function" && "unsafe" in driver && "transaction" in driver; +} + +function isPostgresJs(driver: PostgresDriver): driver is PostgresJsSql { + return "unsafe" in driver && "begin" in driver; +} + +function isPg(driver: PostgresDriver): driver is PgClient | PgPool | PgPoolClient { + return "query" in driver; +} + +const isPgPool = (d: PgClient | PgPool | PgPoolClient): d is PgPool => + "connect" in d && !("release" in d); + +// --- Driver result types --- +// postgres.js and Bun SQL attach metadata (affected row count, command) to the result array object itself. +type PostgresJsResult = Record[] & { count?: number }; +type BunSqlResult = Record[] & { + affectedRows?: number; + count?: number; + command?: string; +}; + +// --- Executor factories --- + +function toTaggedArgs(query: Fragment): [TemplateStringsArray, ...unknown[]] { + const split = query.text.split("?"); + const parts = Object.assign(split, { raw: split.slice() as readonly string[] }); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- constructing TemplateStringsArray manually from split; shape is structurally identical + return [parts as unknown as TemplateStringsArray, ...query.params]; +} + +function createPostgresJsExecutor( + driver: postgres.Sql | postgres.TransactionSql, + inTransaction = false, +): QueryExecutor { + const runQuery = (query: Fragment): Promise => { + const [strings, ...params] = toTaggedArgs(query); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- calling driver as tagged template to use native parameterization; result has .count on the array object + const run = driver as (s: TemplateStringsArray, ...p: unknown[]) => Promise; + return run(strings, ...params); + }; + + return { + all: (query) => runQuery(query), + get: async (query) => { + const rows = await runQuery(query); + return rows[0]; + }, + run: async (query) => { + const rows = await runQuery(query); + return { changes: rows.count ?? 0 }; + }, + transaction: (fn: (executor: QueryExecutor) => Promise) => { + // PostgresAdapter.transaction() short-circuits nested calls so we only enter here outside a transaction. + if ("begin" in driver) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- T matches return type of fn + return driver.begin((tx) => fn(createPostgresJsExecutor(tx, true))) as Promise; + } + throw new Error("Transaction not supported by driver (begin missing)"); + }, + inTransaction, + }; +} + +function createBunSqlExecutor(bunSql: BunSQL, inTransaction = false): QueryExecutor { + const runQuery = (query: Fragment): Promise => { + const [strings, ...params] = toTaggedArgs(query); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- bun:sql result has affectedRows/count/command on the array object + return bunSql(strings, ...params) as Promise; + }; + + return { + all: (query) => runQuery(query), + get: async (query) => { + const rows = await runQuery(query); + return rows[0]; + }, + run: async (query) => { + const rows = await runQuery(query); + let changes = rows.affectedRows ?? rows.count ?? 0; + if (changes === 0 && rows.command !== undefined && rows.command.startsWith("OK ")) { + const parsed = parseInt(rows.command.slice(3), 10); + if (!isNaN(parsed)) changes = parsed; + } + return { changes }; + }, + transaction: (fn: (executor: QueryExecutor) => Promise) => + bunSql.transaction((tx) => fn(createBunSqlExecutor(tx as BunSQL, true))), + inTransaction, + }; +} + +function createPgExecutor( + driver: PgClient | PgPool | PgPoolClient, + inTransaction = false, +): QueryExecutor { + function getPrepared(query: Fragment) { + const { text, values: rawValues } = toNumberedParams(query); + const values = rawValues.map((v) => stringifyJsonParam(v)); + const name = `q_${fnv1aHash(text)}`; + return { name, text, values }; + } + + return { + all: async (q) => { + const res = await driver.query>(getPrepared(q)); + return res.rows; + }, + get: async (q) => { + const res = await driver.query>(getPrepared(q)); + return res.rows[0]; + }, + run: async (q) => { + const res = await driver.query(getPrepared(q)); + return { changes: res.rowCount ?? 0 }; + }, + transaction: async (fn) => { + if (isPgPool(driver)) { + const client = await driver.connect(); + try { + await client.query("BEGIN"); + const res = await fn(createPgExecutor(client, true)); + await client.query("COMMIT"); + return res; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } + await driver.query("BEGIN"); + try { + const res = await fn(createPgExecutor(driver, true)); + await driver.query("COMMIT"); + return res; + } catch (e) { + await driver.query("ROLLBACK"); + throw e; + } + }, + inTransaction, + }; +} + +function createPostgresExecutor(driver: PostgresDriver): QueryExecutor { + if (isBunSql(driver)) return createBunSqlExecutor(driver); + if (isPostgresJs(driver)) return createPostgresJsExecutor(driver); + if (isPg(driver)) return createPgExecutor(driver); + throw new Error("Unsupported Postgres driver."); +} + +// --- Adapter --- + +/** + * Postgres Adapter for no-orm. + */ +export class PostgresAdapter implements Adapter { + private executor: QueryExecutor; + + constructor( + private schema: S, + driver: PostgresDriver | QueryExecutor, + ) { + this.executor = isQueryExecutor(driver) ? driver : createPostgresExecutor(driver); + } + + async migrate(): Promise { + const stmts = migrateSqls(this.schema, { sqlType }); + await this.executor.transaction(async (exec) => { + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + for (let i = 0; i < stmts.length; i++) await exec.run(stmts[i]!); + }); + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + if (this.executor.inTransaction) return fn(this); + return this.executor.transaction((exec) => fn(new PostgresAdapter(this.schema, exec))); + } + + async create> = never>(args: { + model: K; + data: InferModel; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>> { + type Row = InferModel; + const { model: modelName, data, select } = args; + const model = this.schema[modelName]!; + const { fields, values } = extractFields(data as Record); + const query = insertSql({ table: modelName, fields, values, returning: select }); + + const row = await this.executor.get(query); + if (row === undefined || row === null) throw new Error("Failed to insert record"); + return mapFromRecord(model, row); + } + + async find> = never>(args: { + model: K; + where: Where>; + select?: readonly F[]; + }): Promise<([F] extends [never] ? InferModel : Pick, F>) | null> { + type Row = InferModel; + const { model: modelName, select } = args; + const model = this.schema[modelName]!; + const query = selectSql({ + table: modelName, + select, + where: where(args.where, { model, columnExpr: toColumnExpr }), + limit: 1, + }); + + const row = await this.executor.get(query); + if (row === undefined || row === null) return null; + return mapFromRecord(model, row); + } + + async findMany> = never>(args: { + model: K; + where?: Where>; + select?: readonly F[]; + sortBy?: SortBy>[]; + limit?: number; + offset?: number; + cursor?: Cursor>; + }): Promise<([F] extends [never] ? InferModel : Pick, F>)[]> { + type Row = InferModel; + const { model: modelName, select, sortBy, limit, offset, cursor } = args; + const model = this.schema[modelName]!; + const query = selectSql({ + table: modelName, + select, + where: where(args.where, { model, columnExpr: toColumnExpr, cursor, sortBy }), + orderBy: sortBy && sortBy.length > 0 ? sort(model, sortBy, toColumnExpr) : undefined, + limit, + offset, + }); + const rows = await this.executor.all(query); + + const result: Project[] = []; + for (let i = 0; i < rows.length; i++) { + result.push(mapFromRecord(model, rows[i]!)); + } + return result; + } + + /** + * Updates the first record matching the criteria. Primary key updates are rejected. + */ + async update(args: { + model: K; + where: Where>; + data: Partial>; + }): Promise | null> { + type Row = InferModel; + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const dataRecord = data as Record; + + if (!Object.keys(dataRecord).some((k) => dataRecord[k] !== undefined)) + return this.find({ model: modelName, where: args.where, select: undefined }); + + const innerWhere = where(args.where, { model, columnExpr: toColumnExpr }); + const query = updateSql({ + table: modelName, + set: set(dataRecord), + where: { + text: `ctid = (SELECT ctid FROM ${id(modelName).text} WHERE ${innerWhere.text} LIMIT 1)`, + params: innerWhere.params, + }, + returning: true, + }); + + const row = await this.executor.get(query); + if (row === undefined || row === null) + return this.find({ model: modelName, where: args.where }); + return mapFromRecord(model, row); + } + + /** + * Updates all records matching the criteria. Primary key updates are rejected. + */ + async updateMany(args: { + model: K; + where?: Where>; + data: Partial>; + }): Promise { + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const dataRecord = data as Record; + if (!Object.keys(dataRecord).some((k) => dataRecord[k] !== undefined)) return 0; + + const query = updateSql({ + table: modelName, + set: set(dataRecord), + where: where(args.where, { model, columnExpr: toColumnExpr }), + }); + + const res = await this.executor.run(query); + return res.changes; + } + + /** + * Performs an atomic insert-or-update. + * + * Conflicts are always handled on the Primary Key. If `where` is provided, the record + * is only updated if the condition is met (acting as a predicate). Primary key + * updates are rejected. + */ + async upsert> = never>(args: { + model: K; + create: InferModel; + update: Partial>; + where?: Where>; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>> { + type Row = InferModel; + const { model: modelName, create: createData, update: updateData, select } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, updateData); + + const { fields: createFields, values: createValues } = extractFields( + createData as Record, + ); + + const rawUpdate = updateData as Record; + const hasUpdateFields = Object.keys(rawUpdate).some((k) => rawUpdate[k] !== undefined); + const primaryKeyFieldNames = getPrimaryKeyFieldNames(model); + + const qualifiedColumnExpr = ( + m: Model, + fieldName: string, + path?: string[], + value?: unknown, + ): Fragment => { + if (!path || path.length === 0) + return { text: `${id(modelName).text}.${id(fieldName).text}`, params: [] }; + return toColumnExpr(m, fieldName, path, value); + }; + + let onConflict: Fragment; + if (!hasUpdateFields) { + onConflict = { text: "DO NOTHING", params: [] }; + } else if (args.where) { + const updateSet = set(rawUpdate); + const updateWhere = where(args.where, { model, columnExpr: qualifiedColumnExpr }); + const params = updateSet.params.slice(); + for (let i = 0; i < updateWhere.params.length; i++) params.push(updateWhere.params[i]); + onConflict = { text: `DO UPDATE SET ${updateSet.text} WHERE ${updateWhere.text}`, params }; + } else { + const updateSet = set(rawUpdate); + onConflict = { text: `DO UPDATE SET ${updateSet.text}`, params: updateSet.params }; + } + + const query = upsertSql({ + table: modelName, + fields: createFields, + values: createValues, + conflictColumns: primaryKeyFieldNames, + onConflict, + returning: select, + }); + + const row = await this.executor.get(query); + if (row !== undefined && row !== null) { + return mapFromRecord(model, row); + } + + const existing = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, createData)), + select, + }); + if (existing === null) throw new Error("Failed to refetch record after upsert"); + return existing; + } + + async delete(args: { + model: K; + where: Where>; + }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = deleteSql({ + table: modelName, + where: where(args.where, { model, columnExpr: toColumnExpr }), + }); + await this.executor.run(query); + } + + async deleteMany(args: { + model: K; + where?: Where>; + }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = deleteSql({ + table: modelName, + where: where(args.where, { model, columnExpr: toColumnExpr }), + }); + const res = await this.executor.run(query); + return res.changes; + } + + async count(args: { + model: K; + where?: Where>; + }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = countSql({ + table: modelName, + where: where(args.where, { model, columnExpr: toColumnExpr }), + }); + const row = await this.executor.get(query); + return Number(row?.["count"] ?? 0); + } +} diff --git a/src/adapters/sqlite.test.ts b/src/adapters/sqlite.test.ts new file mode 100644 index 0000000..fa9ff98 --- /dev/null +++ b/src/adapters/sqlite.test.ts @@ -0,0 +1,992 @@ +import { Database } from "bun:sqlite"; +import { describe, expect, it, beforeEach } from "bun:test"; + +import type { Schema, InferModel } from "../types"; +import { SqliteAdapter } from "./sqlite"; + +const schema = { + users: { + fields: { + id: { type: "string" }, + name: { type: "string" }, + age: { type: "number" }, + is_active: { type: "boolean" }, + is_verified: { type: "boolean", nullable: true }, + metadata: { type: "json", nullable: true }, + tags: { type: "json[]", nullable: true }, + }, + primaryKey: "id", + indexes: [{ field: "name" }, { field: "age" }], + }, +} as const satisfies Schema; + +type User = InferModel; + +describe("SqliteAdapter", () => { + let db: Database; + let adapter: SqliteAdapter; + + beforeEach(async () => { + db = new Database(":memory:"); + adapter = new SqliteAdapter(schema, db); + await adapter.migrate(); + }); + + describe("Basic CRUD", () => { + it("should create and find a record", async () => { + const user: User = { + id: "u1", + name: "Alice", + age: 30, + is_active: true, + is_verified: null, + metadata: { theme: "dark" }, + tags: ["admin"], + }; + await adapter.create({ model: "users", data: user }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found).toEqual(user); + }); + + it("should update a record and refetch correctly", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 30, is_active: true, metadata: null, tags: null }, + }); + const updated = await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { age: 31 }, + }); + expect(updated?.age).toBe(31); + }); + + it("should reject primary key updates", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 30, is_active: true, metadata: null, tags: null }, + }); + + try { + await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { id: "u2" }, + }); + expect.unreachable("update should reject primary key changes"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message).toBe("Primary key updates are not supported."); + } + }); + + it("should surface unknown write fields as database errors", async () => { + try { + await adapter.create({ + model: "users", + data: { + id: "u1", + name: "Alice", + age: 30, + is_active: true, + metadata: null, + tags: null, + nickname: "Al", + } as User & { nickname: string }, + }); + expect.unreachable("create should fail for unknown columns"); + } catch (error) { + expect(error).toBeInstanceOf(Error); + if (!(error instanceof Error)) { + throw error; + } + expect(error.message).toMatch(/nickname/i); + } + }); + + it("should delete a record", async () => { + await adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 30, is_active: true, metadata: null, tags: null }, + }); + await adapter.delete({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found).toBeNull(); + }); + }); + + describe("Filtering and Sorting", () => { + beforeEach(async () => { + await Promise.all([ + adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: false, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: true, metadata: null, tags: null }, + }), + ]); + }); + + it("should filter with 'in' operator", async () => { + const users = await adapter.findMany({ + model: "users", + where: { field: "age", op: "in", value: [25, 35] }, + }); + expect(users).toHaveLength(2); + }); + + it("should handle empty 'in' list gracefully", async () => { + const users = await adapter.findMany({ + model: "users", + where: { field: "age", op: "in", value: [] }, + }); + expect(users).toHaveLength(0); + }); + + it("should handle complex AND / OR where clauses", async () => { + const found = await adapter.findMany({ + model: "users", + where: { + or: [ + { + and: [ + { field: "age", op: "gte", value: 30 }, + { field: "is_active", op: "eq", value: true }, + ], + }, + { field: "name", op: "eq", value: "Bob" }, + ], + }, + }); + + expect(found).toHaveLength(2); + expect(found.map((f) => f.name)).toContain("Bob"); + expect(found.map((f) => f.name)).toContain("Charlie"); + }); + + it("should sort records", async () => { + const users = await adapter.findMany({ + model: "users", + sortBy: [{ field: "age", direction: "desc" }], + }); + expect(users[0]?.id).toBe("u3"); + }); + + it("should filter by null equality (IS NULL)", async () => { + await adapter.create({ + model: "users", + data: { id: "u4", name: "NullUser", age: 40, is_active: true, metadata: null, tags: null }, + }); + const users = await adapter.findMany({ + model: "users", + where: { field: "metadata", op: "eq", value: null }, + }); + // u1, u2, u3 in beforeEach also have metadata: null + expect(users.length).toBeGreaterThanOrEqual(1); + expect(users.find((u) => u.id === "u4")).toBeDefined(); + }); + + it("should filter by null inequality (IS NOT NULL)", async () => { + await adapter.create({ + model: "users", + data: { + id: "u5", + name: "NotNullUser", + age: 40, + is_active: true, + metadata: { has_data: true }, + tags: null, + }, + }); + const users = await adapter.findMany({ + model: "users", + where: { field: "metadata", op: "ne", value: null }, + }); + expect(users.find((u) => u.id === "u5")).toBeDefined(); + expect(users.find((u) => u.id === "u1")).toBeUndefined(); + }); + + it("should sort records with null values", async () => { + await adapter.create({ + model: "users", + data: { + id: "sn1", + name: "Alice", + age: 25, + is_active: true, + metadata: { theme: "dark" }, + tags: null, + }, + }); + await adapter.create({ + model: "users", + data: { id: "sn2", name: "Bob", age: 30, is_active: true, metadata: null, tags: null }, + }); + + const results = await adapter.findMany({ + model: "users", + where: { field: "id", op: "in", value: ["sn1", "sn2"] }, + sortBy: [{ field: "metadata", direction: "asc" }], + }); + + expect(results).toHaveLength(2); + expect(results[0]?.["id"]).toBe("sn2"); // null should come first in SQLite ASC + expect(results[1]?.["id"]).toBe("sn1"); + }); + }); + + describe("JSON Path Filtering", () => { + it("should handle nested JSON path filtering", async () => { + await adapter.create({ + model: "users", + data: { + id: "j1", + name: "User1", + age: 20, + is_active: true, + metadata: { theme: "dark", window: { width: 800 } }, + tags: null, + }, + }); + await adapter.create({ + model: "users", + data: { + id: "j2", + name: "User2", + age: 20, + is_active: true, + metadata: { theme: "light", window: { width: 1024 } }, + tags: null, + }, + }); + await adapter.create({ + model: "users", + data: { + id: "j3", + name: "User3", + age: 20, + is_active: true, + metadata: { theme: "dark", window: { width: 1920 } }, + tags: null, + }, + }); + + // 1. Exact match on nested string (theme = 'dark') + const darkUsers = await adapter.findMany({ + model: "users", + where: { field: "metadata", path: ["theme"], op: "eq", value: "dark" }, + }); + expect(darkUsers).toHaveLength(2); + + // 2. Numeric operator on deeply nested number (window.width > 900) + const wideUsers = await adapter.findMany({ + model: "users", + where: { field: "metadata", path: ["window", "width"], op: "gt", value: 900 }, + }); + expect(wideUsers).toHaveLength(2); + }); + }); + + describe("Transactions", () => { + it("should commit successful transactions", async () => { + await adapter.transaction(async (tx) => { + await tx.create({ + model: "users", + data: { id: "t1", name: "TxUser1", age: 20, is_active: true, metadata: null, tags: null }, + }); + }); + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "t1" }, + }); + expect(found).not.toBeNull(); + }); + + it("should rollback failed transactions", async () => { + try { + await adapter.transaction(async (tx) => { + await tx.create({ + model: "users", + data: { + id: "t1", + name: "TxUser1", + age: 20, + is_active: true, + metadata: null, + tags: null, + }, + }); + throw new Error("Failure"); + }); + } catch { + // expected + } + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "t1" }, + }); + expect(found).toBeNull(); + }); + + it("should flatten nested transactions (no nested rollback support)", async () => { + await adapter.transaction(async (outer) => { + await outer.create({ + model: "users", + data: { id: "n1", name: "Outer1", age: 20, is_active: true, metadata: null, tags: null }, + }); + + try { + await outer.transaction(async (inner) => { + await inner.update({ + model: "users", + where: { field: "id", op: "eq", value: "n1" }, + data: { age: 40 }, + }); + throw new Error("Inner fail"); + }); + } catch { + // expected + } + }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "n1" }, + }); + // Age is 40 because nested transactions are flattened; the inner update + // is part of the outer transaction and is NOT rolled back when the + // inner block throws. + expect(found?.age).toBe(40); + }); + }); + + describe("Pagination", () => { + it("should handle multi-field keyset pagination correctly", async () => { + await adapter.create({ + model: "users", + data: { id: "m1", name: "A", age: 30, is_active: true, metadata: null, tags: null }, + }); + await adapter.create({ + model: "users", + data: { id: "m2", name: "B", age: 30, is_active: true, metadata: null, tags: null }, + }); + await adapter.create({ + model: "users", + data: { id: "m3", name: "C", age: 30, is_active: true, metadata: null, tags: null }, + }); + await adapter.create({ + model: "users", + data: { id: "m4", name: "A", age: 31, is_active: true, metadata: null, tags: null }, + }); + await adapter.create({ + model: "users", + data: { id: "m5", name: "B", age: 31, is_active: true, metadata: null, tags: null }, + }); + + const result = await adapter.findMany({ + model: "users", + sortBy: [ + { field: "age", direction: "asc" }, + { field: "name", direction: "desc" }, + ], + cursor: { + after: [ + { field: "age", value: 30 }, + { field: "name", value: "B" }, + ], + }, + limit: 3, + }); + + expect(result).toHaveLength(3); + expect(result[0]?.id).toBe("m1"); + expect(result[1]?.id).toBe("m5"); + expect(result[2]?.id).toBe("m4"); + }); + + describe("Seeded Pagination", () => { + beforeEach(async () => { + const creations = []; + for (let i = 1; i <= 5; i++) { + creations.push( + adapter.create({ + model: "users", + data: { + id: `p${i}`, + name: `User ${i}`, + age: 20 + i, + is_active: true, + metadata: null, + tags: null, + }, + }), + ); + } + await Promise.all(creations); + }); + + it("should respect limit and offset", async () => { + const page1 = await adapter.findMany({ + model: "users", + sortBy: [{ field: "age", direction: "asc" }], + limit: 2, + offset: 0, + }); + expect(page1).toHaveLength(2); + expect(page1[0]?.id).toBe("p1"); + + const page2 = await adapter.findMany({ + model: "users", + sortBy: [{ field: "age", direction: "asc" }], + limit: 2, + offset: 2, + }); + expect(page2).toHaveLength(2); + expect(page2[0]?.id).toBe("p3"); + }); + + it("should handle cursor pagination ascending", async () => { + const result = await adapter.findMany({ + model: "users", + sortBy: [{ field: "age", direction: "asc" }], + cursor: { after: [{ field: "age", value: 22 }] }, + limit: 2, + }); + + expect(result).toHaveLength(2); + expect(result[0]?.id).toBe("p3"); + }); + + it("should handle cursor pagination descending", async () => { + const result = await adapter.findMany({ + model: "users", + sortBy: [{ field: "age", direction: "desc" }], + cursor: { after: [{ field: "age", value: 24 }] }, + limit: 2, + }); + + expect(result).toHaveLength(2); + expect(result[0]?.id).toBe("p3"); + }); + }); + }); + + describe("Boolean Filtering", () => { + beforeEach(async () => { + await adapter.create({ + model: "users", + data: { id: "b1", name: "Active1", age: 20, is_active: true, metadata: null, tags: null }, + }); + await adapter.create({ + model: "users", + data: { + id: "b2", + name: "Inactive1", + age: 20, + is_active: false, + metadata: null, + tags: null, + }, + }); + await adapter.create({ + model: "users", + data: { id: "b3", name: "Active2", age: 30, is_active: true, metadata: null, tags: null }, + }); + }); + + it("should filter by boolean eq true", async () => { + const users = await adapter.findMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + }); + expect(users).toHaveLength(2); + // oxlint-disable-next-line unicorn/no-array-sort -- sorting IDs for comparison + expect(users.map((u) => u["id"]).sort()).toEqual(["b1", "b3"]); + }); + + it("should filter by boolean eq false", async () => { + const users = await adapter.findMany({ + model: "users", + where: { field: "is_active", op: "eq", value: false }, + }); + expect(users).toHaveLength(1); + expect(users[0]?.["id"]).toBe("b2"); + }); + + it("should filter by boolean in list", async () => { + const users = await adapter.findMany({ + model: "users", + where: { field: "is_active", op: "in", value: [true] }, + }); + expect(users).toHaveLength(2); + }); + }); + + describe("Nullable Boolean Fields", () => { + it("should preserve null for nullable boolean fields on create", async () => { + await adapter.create({ + model: "users", + data: { + id: "nb1", + name: "NullableBoolTest", + age: 25, + is_active: true, + is_verified: null, + metadata: null, + tags: null, + }, + }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "nb1" }, + }); + expect(found?.["is_verified"]).toBeNull(); + }); + + it("should preserve null for nullable boolean fields on update", async () => { + await adapter.create({ + model: "users", + data: { + id: "nb2", + name: "UpdateNullBool", + age: 25, + is_active: true, + is_verified: true, + metadata: null, + tags: null, + }, + }); + + await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "nb2" }, + data: { is_verified: null }, + }); + + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "nb2" }, + }); + expect(found?.["is_verified"]).toBeNull(); + }); + + it("should filter by nullable boolean null equality (IS NULL)", async () => { + await adapter.create({ + model: "users", + data: { + id: "nb3", + name: "NullBoolFilter", + age: 25, + is_active: true, + is_verified: null, + metadata: null, + tags: null, + }, + }); + await adapter.create({ + model: "users", + data: { + id: "nb4", + name: "VerifiedUser", + age: 30, + is_active: true, + is_verified: true, + metadata: null, + tags: null, + }, + }); + + const users = await adapter.findMany({ + model: "users", + where: { field: "is_verified", op: "eq", value: null }, + }); + expect(users.find((u) => u["id"] === "nb3")).toBeDefined(); + expect(users.find((u) => u["id"] === "nb4")).toBeUndefined(); + }); + }); + + describe("Count", () => { + beforeEach(async () => { + await Promise.all([ + adapter.create({ + model: "users", + data: { id: "c1", name: "Alice", age: 25, is_active: true, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "c2", name: "Bob", age: 30, is_active: false, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "c3", name: "Charlie", age: 35, is_active: true, metadata: null, tags: null }, + }), + ]); + }); + + it("should count all records", async () => { + const count = await adapter.count({ model: "users" }); + expect(count).toBe(3); + }); + + it("should count with where clause", async () => { + const count = await adapter.count({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + }); + expect(count).toBe(2); + }); + + it("should count with complex where clause", async () => { + const count = await adapter.count({ + model: "users", + where: { + and: [ + { field: "age", op: "gte", value: 30 }, + { field: "is_active", op: "eq", value: true }, + ], + }, + }); + expect(count).toBe(1); + }); + + it("should count with no matches", async () => { + const count = await adapter.count({ + model: "users", + where: { field: "age", op: "gt", value: 100 }, + }); + expect(count).toBe(0); + }); + }); + + describe("DeleteMany", () => { + beforeEach(async () => { + await Promise.all([ + adapter.create({ + model: "users", + data: { id: "d1", name: "Alice", age: 25, is_active: true, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "d2", name: "Bob", age: 30, is_active: false, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "d3", name: "Charlie", age: 35, is_active: true, metadata: null, tags: null }, + }), + ]); + }); + + it("should delete all records with no where clause", async () => { + const deleted = await adapter.deleteMany({ model: "users" }); + expect(deleted).toBe(3); + const count = await adapter.count({ model: "users" }); + expect(count).toBe(0); + }); + + it("should delete matching records", async () => { + const deleted = await adapter.deleteMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + }); + expect(deleted).toBe(2); + const remaining = await adapter.findMany({ model: "users" }); + expect(remaining).toHaveLength(1); + expect(remaining[0]?.id).toBe("d2"); + }); + + it("should return 0 when no matches", async () => { + const deleted = await adapter.deleteMany({ + model: "users", + where: { field: "age", op: "gt", value: 100 }, + }); + expect(deleted).toBe(0); + }); + }); + + describe("UpdateMany", () => { + beforeEach(async () => { + await Promise.all([ + adapter.create({ + model: "users", + data: { id: "u1", name: "Alice", age: 25, is_active: true, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 30, is_active: false, metadata: null, tags: null }, + }), + adapter.create({ + model: "users", + data: { id: "u3", name: "Charlie", age: 35, is_active: true, metadata: null, tags: null }, + }), + ]); + }); + + it("should update all records with no where clause", async () => { + const updated = await adapter.updateMany({ + model: "users", + data: { age: 99 }, + }); + expect(updated).toBe(3); + const users = await adapter.findMany({ model: "users" }); + expect(users.every((u) => u.age === 99)).toBe(true); + }); + + it("should update matching records", async () => { + const updated = await adapter.updateMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + data: { age: 100 }, + }); + expect(updated).toBe(2); + const users = await adapter.findMany({ model: "users" }); + const actives = users.filter((u) => u["is_active"]); + const inactive = users.find((u) => !u["is_active"]); + expect(actives.every((u) => u["age"] === 100)).toBe(true); + expect(inactive?.["age"]).toBe(30); + }); + + it("should return 0 when no matches", async () => { + const updated = await adapter.updateMany({ + model: "users", + where: { field: "age", op: "gt", value: 100 }, + data: { age: 0 }, + }); + expect(updated).toBe(0); + }); + + it("should do nothing with empty data", async () => { + const updated = await adapter.updateMany({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: {}, + }); + expect(updated).toBe(0); + }); + }); + + describe("Migration Idempotency", () => { + it("should be idempotent when running migrate twice", async () => { + await adapter.migrate(); + await adapter.migrate(); + const count = await adapter.count({ model: "users" }); + expect(count).toBe(0); + }); + + it("should preserve data when running migrate twice", async () => { + await adapter.create({ + model: "users", + data: { id: "m1", name: "Test", age: 20, is_active: true, metadata: null, tags: null }, + }); + await adapter.migrate(); + await adapter.migrate(); + const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "m1" }, + }); + expect(found?.["name"]).toBe("Test"); + }); + }); + + describe("Composite Primary Key", () => { + const compositeSchema = { + order_items: { + fields: { + order_id: { type: "string" }, + item_id: { type: "string" }, + quantity: { type: "number" }, + price: { type: "number" }, + }, + primaryKey: ["order_id", "item_id"], + }, + } satisfies Schema; + + it("should handle composite primary key operations", async () => { + const compAdapter = new SqliteAdapter(compositeSchema, db); + await compAdapter.migrate(); + + await compAdapter.create({ + model: "order_items", + data: { order_id: "o1", item_id: "i1", quantity: 2, price: 10 }, + }); + + const found = await compAdapter.find({ + model: "order_items", + where: { field: "order_id", op: "eq", value: "o1" }, + }); + expect(found?.["quantity"]).toBe(2); + + await compAdapter.update({ + model: "order_items", + where: { field: "order_id", op: "eq", value: "o1" }, + data: { quantity: 5 }, + }); + + const updated = await compAdapter.find({ + model: "order_items", + where: { field: "order_id", op: "eq", value: "o1" }, + }); + expect(updated?.["quantity"]).toBe(5); + }); + }); + + describe("JSON Array Filtering", () => { + it("should filter by json array field", async () => { + await adapter.create({ + model: "users", + data: { + id: "j1", + name: "User1", + age: 20, + is_active: true, + metadata: null, + tags: ["admin", "vip"], + }, + }); + await adapter.create({ + model: "users", + data: { + id: "j2", + name: "User2", + age: 20, + is_active: true, + metadata: null, + tags: ["user"], + }, + }); + + const users = await adapter.findMany({ + model: "users", + where: { field: "tags", path: ["0"], op: "eq", value: "admin" }, + }); + expect(users).toHaveLength(1); + expect(users[0]?.["id"]).toBe("j1"); + }); + }); + + describe("Minimal Schema Model", () => { + const minimalSchema = { + minimal_table: { + fields: { + id: { type: "string" }, + }, + primaryKey: "id", + }, + } satisfies Schema; + + it("should handle minimal model with single field", async () => { + const minAdapter = new SqliteAdapter(minimalSchema, db); + await minAdapter.migrate(); + + await minAdapter.create({ + model: "minimal_table", + data: { id: "e1" }, + }); + + const found = await minAdapter.find({ + model: "minimal_table", + where: { field: "id", op: "eq", value: "e1" }, + }); + expect(found?.["id"]).toBe("e1"); + }); + }); + + describe("Upsert", () => { + it("should handle upsert correctly", async () => { + const data: User = { + id: "u1", + name: "Alice", + age: 25, + is_active: true, + metadata: null, + tags: null, + }; + + // Insert + await adapter.upsert({ + model: "users", + create: data, + update: { age: 26 }, + }); + + let found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.age).toBe(25); + + // Update + await adapter.upsert({ + model: "users", + create: data, + update: { age: 26 }, + }); + + found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.age).toBe(26); + }); + + it("should handle predicated upsert", async () => { + const data: User = { + id: "u1", + name: "Alice", + age: 25, + is_active: true, + metadata: null, + tags: null, + }; + + await adapter.create({ model: "users", data }); + + // Update should NOT happen if where condition is false + await adapter.upsert({ + model: "users", + create: data, + update: { age: 30 }, + where: { field: "age", op: "gt", value: 50 }, + }); + + let found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.age).toBe(25); + + // Update SHOULD happen if where condition is true + await adapter.upsert({ + model: "users", + create: data, + update: { age: 30 }, + where: { field: "age", op: "lt", value: 50 }, + }); + + found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.age).toBe(30); + }); + }); +}); diff --git a/src/adapters/sqlite.ts b/src/adapters/sqlite.ts new file mode 100644 index 0000000..814277e --- /dev/null +++ b/src/adapters/sqlite.ts @@ -0,0 +1,531 @@ +import type { Database as BunDatabase } from "bun:sqlite"; + +import type { Database as BetterSqlite3Database } from "better-sqlite3"; +import type { Database as SqliteDatabase } from "sqlite"; + +import type { + Adapter, + Field, + FieldName, + InferModel, + Schema, + SortBy, + Where, + Cursor, + Model, +} from "../types"; +import { + assertNoPrimaryKeyUpdates, + buildPrimaryKeyFilter, + getPrimaryKeyFieldNames, + getPrimaryKeyValues, + mapNumeric, + type Project, + type RowData, +} from "./utils/common"; +import { + type Fragment, + type QueryExecutor, + isQueryExecutor, + id, + extractFields, + where, + set, + sort, + selectSql, + insertSql, + updateSql, + deleteSql, + upsertSql, + countSql, + migrateSqls, +} from "./utils/sql"; + +export type SqliteDriver = SqliteDatabase | BunDatabase | BetterSqlite3Database; + +/** + * Limits the number of prepared statement objects kept in memory to prevent leaks + * while allowing statement reuse for performance. + */ +const MAX_CACHED_STATEMENTS = 100; + +// --- Internal SQLite Syntax Helpers --- + +const mapSqliteValue = (val: unknown, field?: Field) => { + if (val === null || val === undefined) return val; + if (field?.type === "boolean" || (field === undefined && typeof val === "boolean")) { + return val === true ? 1 : 0; + } + if (typeof val === "object") { + return JSON.stringify(val); + } + return val; +}; + +function mapFromRecord = never>( + model: Model, + record: RowData, +): Project { + const fields = model.fields; + const keys = Object.keys(record); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const field = fields[k]; + if (field === undefined || record[k] === null || record[k] === undefined) continue; + + if (field.type === "json" || field.type === "json[]") { + record[k] = typeof record[k] === "string" ? JSON.parse(record[k]) : record[k]; + } else if (field.type === "boolean") { + record[k] = record[k] === 1 || record[k] === true; + } else if (field.type === "number" || field.type === "timestamp") { + record[k] = mapNumeric(record[k]); + } + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- DB returned only the selected columns; body only coerces field types + return record as Project; +} + +function sqlType(field: Field): string { + switch (field.type) { + case "string": + return field.max === undefined ? "TEXT" : `VARCHAR(${field.max})`; + case "number": + return "REAL"; + case "boolean": + case "timestamp": + return "INTEGER"; + case "json": + case "json[]": + return "TEXT"; + default: + return "TEXT"; + } +} + +function toJsonPath(path: string[]): string { + let jsonPath = "$"; + for (let i = 0; i < path.length; i++) { + const segment = path[i]!; + let isIndex = true; + if (segment.length === 0) isIndex = false; + else { + for (let j = 0; j < segment.length; j++) { + const c = segment.codePointAt(j); + if (c === undefined || c < 48 || c > 57) { + isIndex = false; + break; + } + } + } + if (isIndex) jsonPath += `[${segment}]`; + else jsonPath += `.${segment}`; + } + return jsonPath; +} + +function toColumnExpr(model: Model, fieldName: string, path?: string[]): Fragment { + if (!path || path.length === 0) return id(fieldName); + const field = model.fields[fieldName]; + if (field?.type !== "json" && field?.type !== "json[]") { + throw new Error(`Cannot use JSON path on non-JSON field: ${fieldName}`); + } + return { text: `json_extract(${id(fieldName).text}, ?)`, params: [toJsonPath(path)] }; +} + +// --- Driver detection and executors --- + +function isBunSqlite(driver: SqliteDriver): driver is BunDatabase { + return "query" in driver && !("all" in driver); +} + +function isBetterSqlite3(driver: SqliteDriver): driver is BetterSqlite3Database { + return "prepare" in driver && !("all" in driver) && !("query" in driver); +} + +type SyncStatement = { + all(...params: unknown[]): Record[]; + get(...params: unknown[]): Record | undefined; + run(...params: unknown[]): { changes: number }; +}; + +function createBunSqliteExecutor(driver: BunDatabase, inTransaction = false): QueryExecutor { + function getPrepared(sqlStr: string): SyncStatement { + // driver.query() caches at the BunDatabase level — no manual Map needed + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- bun:sqlite Statement structurally matches SyncStatement; run() returns {changes} at runtime despite void TypeScript type + return driver.query(sqlStr) as unknown as SyncStatement; + } + + return { + all: (query: Fragment) => Promise.resolve(getPrepared(query.text).all(...query.params)), + get: (query: Fragment) => Promise.resolve(getPrepared(query.text).get(...query.params)), + run: (query: Fragment) => { + const res = getPrepared(query.text).run(...query.params); + return Promise.resolve({ changes: res.changes }); + }, + transaction: async (fn) => { + getPrepared("BEGIN").run(); + try { + const result = await fn(createBunSqliteExecutor(driver, true)); + getPrepared("COMMIT").run(); + return result; + } catch (e) { + getPrepared("ROLLBACK").run(); + throw e; + } + }, + inTransaction, + }; +} + +function createBetterSqlite3Executor( + driver: BetterSqlite3Database, + inTransaction = false, + cache = new Map(), +): QueryExecutor { + function getPrepared(sqlStr: string): SyncStatement { + let stmt = cache.get(sqlStr); + if (stmt === undefined) { + if (cache.size >= MAX_CACHED_STATEMENTS) { + const first = cache.keys().next(); + if (first.done !== true) cache.delete(first.value); + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- better-sqlite3 Statement structurally matches SyncStatement + stmt = driver.prepare(sqlStr) as unknown as SyncStatement; + cache.set(sqlStr, stmt); + } + return stmt; + } + + return { + all: (query: Fragment) => Promise.resolve(getPrepared(query.text).all(...query.params)), + get: (query: Fragment) => Promise.resolve(getPrepared(query.text).get(...query.params)), + run: (query: Fragment) => { + const res = getPrepared(query.text).run(...query.params); + return Promise.resolve({ changes: res.changes }); + }, + transaction: async (fn) => { + getPrepared("BEGIN").run(); + try { + const result = await fn(createBetterSqlite3Executor(driver, true, cache)); + getPrepared("COMMIT").run(); + return result; + } catch (e) { + getPrepared("ROLLBACK").run(); + throw e; + } + }, + inTransaction, + }; +} + +function createSqliteExecutor(driver: SqliteDatabase, inTransaction = false): QueryExecutor { + return { + all: (query: Fragment) => driver.all(query.text, query.params), + get: (query: Fragment) => driver.get(query.text, query.params), + run: async (query: Fragment) => { + const res = + query.params.length === 0 + ? await driver.run(query.text) + : await driver.run(query.text, query.params); + return { changes: res.changes ?? 0 }; + }, + transaction: async (fn) => { + await driver.run("BEGIN"); + try { + const res = await fn(createSqliteExecutor(driver, true)); + await driver.run("COMMIT"); + return res; + } catch (e) { + await driver.run("ROLLBACK"); + throw e; + } + }, + inTransaction, + }; +} + +function createExecutor(driver: SqliteDriver): QueryExecutor { + if (isBunSqlite(driver)) return createBunSqliteExecutor(driver); + if (isBetterSqlite3(driver)) return createBetterSqlite3Executor(driver); + return createSqliteExecutor(driver); +} + +// --- Adapter --- + +/** + * SQLite Adapter for no-orm. + */ +export class SqliteAdapter implements Adapter { + private executor: QueryExecutor; + + constructor( + private schema: S, + driver: SqliteDriver | QueryExecutor, + ) { + this.executor = isQueryExecutor(driver) ? driver : createExecutor(driver); + } + + async migrate(): Promise { + const stmts = migrateSqls(this.schema, { sqlType }); + await this.executor.transaction(async (exec) => { + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + for (let i = 0; i < stmts.length; i++) await exec.run(stmts[i]!); + }); + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + if (this.executor.inTransaction) return fn(this); + return this.executor.transaction((exec) => fn(new SqliteAdapter(this.schema, exec))); + } + + async create> = never>(args: { + model: K; + data: InferModel; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>> { + type Row = InferModel; + const { model: modelName, data, select } = args; + const model = this.schema[modelName]!; + const { fields, values } = extractFields(data as Record, mapSqliteValue); + const query = insertSql({ table: modelName, fields, values, returning: select }); + + const row = await this.executor.get(query); + if (row === undefined || row === null) { + const res = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, data)), + select, + }); + if (!res) throw new Error("Failed to insert record"); + return res; + } + return mapFromRecord(model, row); + } + + async find> = never>(args: { + model: K; + where: Where>; + select?: readonly F[]; + }): Promise<([F] extends [never] ? InferModel : Pick, F>) | null> { + type Row = InferModel; + const { model: modelName, select } = args; + const model = this.schema[modelName]!; + const query = selectSql({ + table: modelName, + select, + where: where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue }), + limit: 1, + }); + + const row = await this.executor.get(query); + if (row === undefined || row === null) return null; + return mapFromRecord(model, row); + } + + async findMany> = never>(args: { + model: K; + where?: Where>; + select?: readonly F[]; + sortBy?: SortBy>[]; + limit?: number; + offset?: number; + cursor?: Cursor>; + }): Promise<([F] extends [never] ? InferModel : Pick, F>)[]> { + type Row = InferModel; + const { model: modelName, select, sortBy, limit, offset, cursor } = args; + const model = this.schema[modelName]!; + const query = selectSql({ + table: modelName, + select, + where: where(args.where, { + model, + columnExpr: toColumnExpr, + mapValue: mapSqliteValue, + cursor, + sortBy, + }), + orderBy: sortBy && sortBy.length > 0 ? sort(model, sortBy, toColumnExpr) : undefined, + limit, + offset, + }); + const rows = await this.executor.all(query); + + const result: Project[] = []; + for (let i = 0; i < rows.length; i++) { + result.push(mapFromRecord(model, rows[i]!)); + } + return result; + } + + /** + * Updates the first record matching the criteria. Primary key updates are rejected. + */ + async update(args: { + model: K; + where: Where>; + data: Partial>; + }): Promise | null> { + type Row = InferModel; + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const dataRecord = data as Record; + + if (!Object.keys(dataRecord).some((k) => dataRecord[k] !== undefined)) + return this.find({ model: modelName, where: args.where, select: undefined }); + + const innerWhere = where(args.where, { + model, + columnExpr: toColumnExpr, + mapValue: mapSqliteValue, + }); + const query = updateSql({ + table: modelName, + set: set(dataRecord, (v) => mapSqliteValue(v)), + where: { + text: `rowid = (SELECT rowid FROM ${id(modelName).text} WHERE ${innerWhere.text} LIMIT 1)`, + params: innerWhere.params, + }, + returning: true, + }); + + const row = await this.executor.get(query); + if (row === undefined || row === null) + return this.find({ model: modelName, where: args.where }); + return mapFromRecord(model, row); + } + + /** + * Updates all records matching the criteria. Primary key updates are rejected. + */ + async updateMany(args: { + model: K; + where?: Where>; + data: Partial>; + }): Promise { + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const dataRecord = data as Record; + if (!Object.keys(dataRecord).some((k) => dataRecord[k] !== undefined)) return 0; + + const query = updateSql({ + table: modelName, + set: set(dataRecord, (v) => mapSqliteValue(v)), + where: where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue }), + }); + + const res = await this.executor.run(query); + return res.changes; + } + + /** + * Performs an atomic insert-or-update. + * + * Conflicts are always handled on the Primary Key. If `where` is provided, the record + * is only updated if the condition is met (acting as a predicate). Primary key + * updates are rejected. + */ + async upsert> = never>(args: { + model: K; + create: InferModel; + update: Partial>; + where?: Where>; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>> { + type Row = InferModel; + const { model: modelName, create: createData, update: updateData, select } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, updateData); + + const { fields: createFields, values: createValues } = extractFields( + createData as Record, + mapSqliteValue, + ); + + const rawUpdate = updateData as Record; + const hasUpdateFields = Object.keys(rawUpdate).some((k) => rawUpdate[k] !== undefined); + const primaryKeyFieldNames = getPrimaryKeyFieldNames(model); + + let onConflict: Fragment; + if (!hasUpdateFields) { + onConflict = { text: "DO NOTHING", params: [] }; + } else if (args.where) { + const updateSet = set(rawUpdate, (v) => mapSqliteValue(v)); + const updateWhere = where(args.where, { + model, + columnExpr: toColumnExpr, + mapValue: mapSqliteValue, + }); + const params = updateSet.params.slice(); + for (let i = 0; i < updateWhere.params.length; i++) params.push(updateWhere.params[i]); + onConflict = { text: `DO UPDATE SET ${updateSet.text} WHERE ${updateWhere.text}`, params }; + } else { + const updateSet = set(rawUpdate, (v) => mapSqliteValue(v)); + onConflict = { text: `DO UPDATE SET ${updateSet.text}`, params: updateSet.params }; + } + + const query = upsertSql({ + table: modelName, + fields: createFields, + values: createValues, + conflictColumns: primaryKeyFieldNames, + onConflict, + returning: select, + }); + + const row = await this.executor.get(query); + if (row !== undefined && row !== null) { + return mapFromRecord(model, row); + } + + const existing = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, createData)), + select, + }); + if (existing === null) throw new Error("Failed to refetch record after upsert"); + return existing; + } + + async delete(args: { + model: K; + where: Where>; + }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = deleteSql({ + table: modelName, + where: where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue }), + }); + await this.executor.run(query); + } + + async deleteMany(args: { + model: K; + where?: Where>; + }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = deleteSql({ + table: modelName, + where: where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue }), + }); + const res = await this.executor.run(query); + return res.changes; + } + + async count(args: { + model: K; + where?: Where>; + }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = countSql({ + table: modelName, + where: where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue }), + }); + const row = await this.executor.get(query); + return Number(row?.["count"] ?? 0); + } +} diff --git a/src/adapters/utils/common.ts b/src/adapters/utils/common.ts new file mode 100644 index 0000000..a225fb3 --- /dev/null +++ b/src/adapters/utils/common.ts @@ -0,0 +1,233 @@ +import type { Cursor, FieldName, Model, SortBy, Where } from "../../types"; + +export type RowData = Record; +export type Project> = [F] extends [never] ? T : Pick; + +export type WhereLeaf> = Extract, { field: unknown }>; + +export interface WhereVisitor> { + leaf: (node: WhereLeaf) => R; + and: (children: R[]) => R; + or: (children: R[]) => R; +} + +type PaginationCriterion = { + field: FieldName; + direction: "asc" | "desc"; + path?: string[]; + value: unknown; +}; + +/** + * Iterative fold over a Where AST. The traversal is backend-agnostic; + * adapters supply per-node callbacks that produce their own result type + * (Sql fragment, boolean, MongoDB filter object, etc.). + */ +export function walkWhere>( + clause: Where, + visitor: WhereVisitor, +): R { + const stack: { clause: Where; processed: boolean }[] = [{ clause, processed: false }]; + const results: R[] = []; + + while (stack.length > 0) { + const item = stack.pop()!; + const c = item.clause; + + if ("and" in c || "or" in c) { + const children = "and" in c ? c.and : c.or; + + if (item.processed) { + const parts: R[] = []; + for (let i = 0; i < children.length; i++) parts.push(results.pop()!); + parts.reverse(); + results.push("and" in c ? visitor.and(parts) : visitor.or(parts)); + } else { + stack.push({ clause: c, processed: true }); + for (let i = children.length - 1; i >= 0; i--) { + stack.push({ clause: children[i]!, processed: false }); + } + } + continue; + } + + results.push(visitor.leaf(c)); + } + + return results[0]!; +} + +// --- Schema & Logic Helpers --- + +export function getPrimaryKeyFieldNames(model: Model): string[] { + return Array.isArray(model.primaryKey) ? model.primaryKey : [model.primaryKey]; +} + +/** + * Extracts primary key values from a data object based on the model schema. + */ +export function getPrimaryKeyValues( + model: Model, + data: Record, +): Record { + const primaryKeyFieldNames = getPrimaryKeyFieldNames(model); + const values: Record = {}; + for (let i = 0; i < primaryKeyFieldNames.length; i++) { + const field = primaryKeyFieldNames[i]!; + if (!(field in data)) { + throw new Error(`Missing primary key field: ${field}`); + } + values[field] = data[field]; + } + return values; +} + +/** + * Builds a 'Where' filter targeting the primary key of a specific record. + */ +export function buildPrimaryKeyFilter>( + model: Model, + source: Record, +): Where { + const primaryKeyFieldNames = getPrimaryKeyFieldNames(model); + if (primaryKeyFieldNames.length === 1) { + const field = primaryKeyFieldNames[0]!; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- field name from schema is guaranteed to be in T + return { field: field as FieldName, op: "eq" as const, value: source[field] }; + } + + const clauses: Where[] = []; + for (let i = 0; i < primaryKeyFieldNames.length; i++) { + const field = primaryKeyFieldNames[i]!; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- field name from schema is guaranteed to be in T + clauses.push({ field: field as FieldName, op: "eq" as const, value: source[field] }); + } + return { and: clauses }; +} + +export function assertNoPrimaryKeyUpdates(model: Model, data: Record): void { + const primaryKeyFieldNames = getPrimaryKeyFieldNames(model); + for (let i = 0; i < primaryKeyFieldNames.length; i++) { + const field = primaryKeyFieldNames[i]!; + if (Object.prototype.hasOwnProperty.call(data, field)) { + throw new Error("Primary key updates are not supported."); + } + } +} + +/** + * Maps database numeric values to JS numbers. + */ +export function mapNumeric(value: unknown): number | null { + return value === null || value === undefined ? null : Number(value); +} + +// --- Value & Comparison Helpers --- + +function isRecord(val: unknown): val is Record { + return typeof val === "object" && val !== null; +} + +/** + * Extracts a value from a record, supporting nested JSON paths. + */ +export function getNestedValue( + record: Record, + field: string, + path?: string[], +): unknown { + let val: unknown = record[field]; + if (path !== undefined && path.length > 0) { + for (let i = 0; i < path.length; i++) { + if (!isRecord(val)) return undefined; + val = val[path[i]!]; + } + } + return val; +} + +export function getPaginationFilter>( + cursor: Cursor, + sortBy?: SortBy[], +): Where | undefined { + const criteria = getPaginationCriteria(cursor, sortBy); + if (criteria.length === 0) return undefined; + + const orClauses: Where[] = []; + + for (let i = 0; i < criteria.length; i++) { + const andClauses: Where[] = []; + for (let j = 0; j < i; j++) { + const prev = criteria[j]!; + andClauses.push({ + field: prev.field, + path: prev.path, + op: "eq", + value: prev.value, + }); + } + const curr = criteria[i]!; + andClauses.push({ + field: curr.field, + path: curr.path, + op: curr.direction === "desc" ? "lt" : "gt", + value: curr.value, + }); + orClauses.push({ and: andClauses }); + } + + return orClauses.length === 1 ? orClauses[0] : { or: orClauses }; +} + +/** + * Normalizes pagination criteria from a cursor and optional sort parameters. + */ +export function getPaginationCriteria>( + cursor: Cursor, + sortBy?: SortBy[], +): PaginationCriterion[] { + const criteria: PaginationCriterion[] = []; + if (sortBy !== undefined && sortBy.length > 0) { + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + let found: (typeof cursor.after)[number] | undefined; + for (let j = 0; j < cursor.after.length; j++) { + const e = cursor.after[j]!; + if (e.field === s.field && pathsEqual(e.path, s.path)) { + found = e; + break; + } + } + if (found !== undefined) { + criteria.push({ + field: s.field, + direction: s.direction ?? "asc", + path: s.path, + value: found.value, + }); + } + } + } else { + for (let i = 0; i < cursor.after.length; i++) { + const e = cursor.after[i]!; + criteria.push({ field: e.field, direction: "asc", path: e.path, value: e.value }); + } + } + return criteria; +} + +function pathsEqual(a: string[] | undefined, b: string[] | undefined): boolean { + if (a === undefined && b === undefined) return true; + if (a === undefined || b === undefined) return false; + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +export function fnv1aHash(s: string): string { + let h = 2166136261; + for (let i = 0; i < s.length; i++) h = Math.imul(h ^ (s.codePointAt(i) ?? 0), 16777619); + return Math.abs(h).toString(16).padStart(8, "0"); +} diff --git a/src/adapters/utils/sql.test.ts b/src/adapters/utils/sql.test.ts new file mode 100644 index 0000000..9b7cbc4 --- /dev/null +++ b/src/adapters/utils/sql.test.ts @@ -0,0 +1,352 @@ +import { describe, expect, it } from "bun:test"; + +import type { Model } from "../../types"; +import { + type Fragment, + id, + join, + placeholders, + set, + sort, + stringifyJsonParam, + toNumberedParams, + where, +} from "./sql"; + +const model: Model = { + fields: { + id: { type: "string" }, + name: { type: "string" }, + age: { type: "number" }, + is_active: { type: "boolean" }, + }, + primaryKey: "id", +}; + +const columnExpr = (_model: Model, fieldName: string) => id(fieldName); +const boolToInt = (v: unknown) => (typeof v === "boolean" ? (v ? 1 : 0) : v); + +function compile(f: Fragment): { sql: string; params: unknown[] } { + return { sql: f.text, params: f.params }; +} + +describe("toNumberedParams", () => { + it("returns the text unchanged when there are no params", () => { + expect(toNumberedParams({ text: "SELECT 1", params: [] })).toEqual({ + text: "SELECT 1", + values: [], + }); + }); + + it("replaces ? with $1, $2, ...", () => { + expect(toNumberedParams({ text: "a = ? AND b = ?", params: [1, 2] })).toEqual({ + text: "a = $1 AND b = $2", + values: [1, 2], + }); + }); + + it("preserves params array reference", () => { + const params = [42]; + const result = toNumberedParams({ text: "id = ?", params }); + expect(result.values).toBe(params); + }); +}); + +describe("id", () => { + it("quotes a single identifier", () => { + expect(compile(id("users"))).toEqual({ sql: '"users"', params: [] }); + }); + + it("joins an array of identifiers with commas", () => { + expect(compile(id(["a", "b", "c"]))).toEqual({ sql: '"a", "b", "c"', params: [] }); + }); + + it("returns empty for an empty string", () => { + expect(compile(id(""))).toEqual({ sql: "", params: [] }); + }); + + it("returns empty for an empty array", () => { + expect(compile(id([]))).toEqual({ sql: "", params: [] }); + }); + + it("accepts a custom quote character", () => { + expect(compile(id("tbl", "`"))).toEqual({ sql: "`tbl`", params: [] }); + }); +}); + +describe("placeholders", () => { + it("returns empty for no values", () => { + expect(compile(placeholders([]))).toEqual({ sql: "", params: [] }); + }); + + it("returns a single placeholder", () => { + expect(compile(placeholders(["x"]))).toEqual({ sql: "?", params: ["x"] }); + }); + + it("returns comma-separated placeholders", () => { + expect(compile(placeholders([1, 2, 3]))).toEqual({ sql: "?, ?, ?", params: [1, 2, 3] }); + }); +}); + +describe("join", () => { + it("returns empty for no fragments", () => { + expect(compile(join([], ", "))).toEqual({ sql: "", params: [] }); + }); + + it("returns the single fragment unchanged", () => { + expect(compile(join([{ text: "a = ?", params: [1] }], ", "))).toEqual({ + sql: "a = ?", + params: [1], + }); + }); + + it("joins multiple fragments with separator and merges params", () => { + expect( + compile( + join( + [ + { text: "a = ?", params: [1] }, + { text: "b = ?", params: [2] }, + { text: "c", params: [] }, + ], + " AND ", + ), + ), + ).toEqual({ sql: "a = ? AND b = ? AND c", params: [1, 2] }); + }); +}); + +describe("set", () => { + it("throws for empty data", () => { + expect(() => set({})).toThrow(); + }); + + it("produces a single assignment", () => { + expect(compile(set({ name: "Alice" }))).toEqual({ sql: '"name" = ?', params: ["Alice"] }); + }); + + it("produces multiple comma-separated assignments", () => { + expect(compile(set({ name: "Alice", age: 30 }))).toEqual({ + sql: '"name" = ?, "age" = ?', + params: ["Alice", 30], + }); + }); +}); + +describe("sort", () => { + it("throws for empty sortBy", () => { + expect(() => sort(model, [], columnExpr)).toThrow(); + }); + + it("produces ASC", () => { + expect(compile(sort(model, [{ field: "age", direction: "asc" }], columnExpr))).toEqual({ + sql: '"age" ASC', + params: [], + }); + }); + + it("produces DESC", () => { + expect(compile(sort(model, [{ field: "name", direction: "desc" }], columnExpr)).sql).toBe( + '"name" DESC', + ); + }); + + it("handles multiple fields", () => { + expect( + compile( + sort( + model, + [ + { field: "age", direction: "desc" }, + { field: "name", direction: "asc" }, + ], + columnExpr, + ), + ).sql, + ).toBe('"age" DESC, "name" ASC'); + }); +}); + +describe("where", () => { + it("returns 1=1 for undefined clause", () => { + expect(compile(where(undefined, { model, columnExpr }))).toEqual({ sql: "1=1", params: [] }); + }); + + it("eq operator with value", () => { + expect(compile(where({ field: "id", op: "eq", value: "u1" }, { model, columnExpr }))).toEqual({ + sql: '("id" = ?)', + params: ["u1"], + }); + }); + + it("eq operator with null produces IS NULL", () => { + expect(compile(where({ field: "id", op: "eq", value: null }, { model, columnExpr }))).toEqual({ + sql: '("id" IS NULL)', + params: [], + }); + }); + + it("ne operator with value", () => { + expect(compile(where({ field: "age", op: "ne", value: 0 }, { model, columnExpr }))).toEqual({ + sql: '("age" != ?)', + params: [0], + }); + }); + + it("ne operator with null produces IS NOT NULL", () => { + expect(compile(where({ field: "age", op: "ne", value: null }, { model, columnExpr }))).toEqual({ + sql: '("age" IS NOT NULL)', + params: [], + }); + }); + + it("gt operator", () => { + expect(compile(where({ field: "age", op: "gt", value: 18 }, { model, columnExpr }))).toEqual({ + sql: '("age" > ?)', + params: [18], + }); + }); + + it("gte operator", () => { + expect(compile(where({ field: "age", op: "gte", value: 18 }, { model, columnExpr })).sql).toBe( + '("age" >= ?)', + ); + }); + + it("lt operator", () => { + expect(compile(where({ field: "age", op: "lt", value: 65 }, { model, columnExpr })).sql).toBe( + '("age" < ?)', + ); + }); + + it("lte operator", () => { + expect(compile(where({ field: "age", op: "lte", value: 65 }, { model, columnExpr })).sql).toBe( + '("age" <= ?)', + ); + }); + + it("in operator with values", () => { + expect( + compile(where({ field: "age", op: "in", value: [25, 30] }, { model, columnExpr })), + ).toEqual({ sql: '("age" IN (?, ?))', params: [25, 30] }); + }); + + it("in operator with empty array produces 1=0", () => { + expect(compile(where({ field: "age", op: "in", value: [] }, { model, columnExpr }))).toEqual({ + sql: "(1=0)", + params: [], + }); + }); + + it("not_in operator with values", () => { + expect( + compile(where({ field: "age", op: "not_in", value: [25, 30] }, { model, columnExpr })), + ).toEqual({ sql: '("age" NOT IN (?, ?))', params: [25, 30] }); + }); + + it("not_in operator with empty array produces 1=1", () => { + expect( + compile(where({ field: "age", op: "not_in", value: [] }, { model, columnExpr })).sql, + ).toBe("(1=1)"); + }); + + it("and clause", () => { + expect( + compile( + where( + { + and: [ + { field: "age", op: "gt", value: 18 }, + { field: "is_active", op: "eq", value: true }, + ], + }, + { model, columnExpr }, + ), + ), + ).toEqual({ sql: '(("age" > ?) AND ("is_active" = ?))', params: [18, true] }); + }); + + it("or clause", () => { + expect( + compile( + where( + { + or: [ + { field: "age", op: "lt", value: 18 }, + { field: "age", op: "gt", value: 65 }, + ], + }, + { model, columnExpr }, + ), + ), + ).toEqual({ sql: '(("age" < ?) OR ("age" > ?))', params: [18, 65] }); + }); + + it("nested and inside or", () => { + const result = compile( + where( + { + or: [ + { + and: [ + { field: "age", op: "gt", value: 18 }, + { field: "is_active", op: "eq", value: true }, + ], + }, + { field: "name", op: "eq", value: "admin" }, + ], + }, + { model, columnExpr }, + ), + ); + expect(result.sql).toBe('((("age" > ?) AND ("is_active" = ?)) OR ("name" = ?))'); + expect(result.params).toEqual([18, true, "admin"]); + }); + + it("mapValue is applied to leaf values", () => { + const result = compile( + where( + { field: "is_active", op: "eq", value: true }, + { model, columnExpr, mapValue: boolToInt }, + ), + ); + expect(result.params).toEqual([1]); + }); +}); + +describe("stringifyJsonParam", () => { + it("converts plain objects to JSON strings", () => { + expect(stringifyJsonParam({ a: 1, b: 2 })).toBe('{"a":1,"b":2}'); + }); + + it("converts arrays to JSON strings", () => { + expect(stringifyJsonParam([1, 2, 3])).toBe("[1,2,3]"); + }); + + it("passes null through unchanged", () => { + expect(stringifyJsonParam(null)).toBeNull(); + }); + + it("passes Date through unchanged", () => { + const d = new Date("2025-01-15T10:30:00Z"); + expect(stringifyJsonParam(d)).toBe(d); + }); + + it("passes Uint8Array through unchanged", () => { + const u = new Uint8Array([1, 2, 3]); + expect(stringifyJsonParam(u)).toBe(u); + }); + + it("passes strings through unchanged", () => { + expect(stringifyJsonParam("hello")).toBe("hello"); + }); + + it("passes numbers through unchanged", () => { + expect(stringifyJsonParam(42)).toBe(42); + }); + + it("passes booleans through unchanged", () => { + expect(stringifyJsonParam(true)).toBe(true); + expect(stringifyJsonParam(false)).toBe(false); + }); +}); diff --git a/src/adapters/utils/sql.ts b/src/adapters/utils/sql.ts new file mode 100644 index 0000000..514be14 --- /dev/null +++ b/src/adapters/utils/sql.ts @@ -0,0 +1,396 @@ +import type { Cursor, Field, Model, Schema, SortBy, Where } from "../../types"; +import { getPaginationFilter, getPrimaryKeyFieldNames, walkWhere } from "./common"; + +export type Fragment = { text: string; params: unknown[] }; + +/** + * Safely quotes a SQL identifier or comma-separated list of identifiers. + */ +export function id(val: string | readonly string[], quoteChar = '"'): Fragment { + if (val === "" || (Array.isArray(val) && val.length === 0)) return { text: "", params: [] }; + + if (typeof val === "string") { + return { text: quoteChar + val + quoteChar, params: [] }; + } + + let text = ""; + for (let i = 0; i < val.length; i++) { + if (i > 0) text += ", "; + text += quoteChar + val[i]! + quoteChar; + } + return { text, params: [] }; +} + +/** + * Generates a comma-separated list of ? placeholders for values. + */ +export function placeholders(values: unknown[]): Fragment { + if (values.length === 0) return { text: "", params: [] }; + let text = "?"; + for (let i = 1; i < values.length; i++) text += ", ?"; + return { text, params: values }; +} + +/** + * Concatenates multiple fragments with a separator, merging params in order. + */ +export function join(fragments: Fragment[], separator: string): Fragment { + if (fragments.length === 0) return { text: "", params: [] }; + + let text = fragments[0]!.text; + const params: unknown[] = fragments[0]!.params.slice(); + + for (let i = 1; i < fragments.length; i++) { + const f = fragments[i]!; + text += separator + f.text; + for (let j = 0; j < f.params.length; j++) params.push(f.params[j]); + } + + return { text, params }; +} + +/** + * Converts ? placeholders to $1, $2, ... for pg-style drivers. + * Uses split instead of regex for performance. + */ +export function toNumberedParams(f: Fragment): { text: string; values: unknown[] } { + const parts = f.text.split("?"); + if (parts.length === 1) return { text: f.text, values: f.params }; + let text = parts[0]!; + for (let i = 1; i < parts.length; i++) text += "$" + i + parts[i]!; + return { text, values: f.params }; +} + +export interface QueryExecutor { + all(query: Fragment): Promise[]>; + get(query: Fragment): Promise | undefined | null>; + run(query: Fragment): Promise<{ changes: number }>; + transaction(fn: (executor: QueryExecutor) => Promise): Promise; + readonly inTransaction: boolean; +} + +export function isQueryExecutor(obj: unknown): obj is QueryExecutor { + if (typeof obj !== "object" || obj === null) return false; + return ( + "all" in obj && + typeof obj.all === "function" && + "get" in obj && + typeof obj.get === "function" && + "run" in obj && + typeof obj.run === "function" && + "transaction" in obj && + typeof obj.transaction === "function" + ); +} + +export type ColumnExprFn = ( + model: Model, + fieldName: string, + path?: string[], + value?: unknown, +) => Fragment; +export type MapValueFn = (val: unknown, field?: Field) => unknown; + +export interface WhereOptions { + model: Model; + columnExpr: ColumnExprFn; + mapValue?: MapValueFn; +} + +function buildWhere(clause: Where, options: WhereOptions): Fragment { + return walkWhere(clause, { + and: (children) => { + const parts: Fragment[] = []; + for (let i = 0; i < children.length; i++) { + parts.push({ text: `(${children[i]!.text})`, params: children[i]!.params }); + } + return join(parts, " AND "); + }, + or: (children) => { + const parts: Fragment[] = []; + for (let i = 0; i < children.length; i++) { + parts.push({ text: `(${children[i]!.text})`, params: children[i]!.params }); + } + return join(parts, " OR "); + }, + leaf: (c) => { + const expr = options.columnExpr(options.model, c.field as string, c.path, c.value); + const field = options.model.fields[c.field as string]; + const mapped = options.mapValue ? options.mapValue(c.value, field) : c.value; + + switch (c.op) { + case "eq": { + if (c.value === null) return { text: `${expr.text} IS NULL`, params: expr.params }; + const params = expr.params.slice(); + params.push(mapped); + return { text: `${expr.text} = ?`, params }; + } + case "ne": { + if (c.value === null) return { text: `${expr.text} IS NOT NULL`, params: expr.params }; + const params = expr.params.slice(); + params.push(mapped); + return { text: `${expr.text} != ?`, params }; + } + case "gt": { + const params = expr.params.slice(); + params.push(mapped); + return { text: `${expr.text} > ?`, params }; + } + case "gte": { + const params = expr.params.slice(); + params.push(mapped); + return { text: `${expr.text} >= ?`, params }; + } + case "lt": { + const params = expr.params.slice(); + params.push(mapped); + return { text: `${expr.text} < ?`, params }; + } + case "lte": { + const params = expr.params.slice(); + params.push(mapped); + return { text: `${expr.text} <= ?`, params }; + } + case "in": { + if (c.value.length === 0) return { text: "1=0", params: [] }; + let vals: unknown[] = c.value; + if (options.mapValue) { + vals = []; + for (let i = 0; i < c.value.length; i++) vals.push(options.mapValue(c.value[i], field)); + } + const ph = placeholders(vals); + const params = expr.params.slice(); + for (let i = 0; i < vals.length; i++) params.push(vals[i]); + return { text: `${expr.text} IN (${ph.text})`, params }; + } + case "not_in": { + if (c.value.length === 0) return { text: "1=1", params: [] }; + let vals: unknown[] = c.value; + if (options.mapValue) { + vals = []; + for (let i = 0; i < c.value.length; i++) vals.push(options.mapValue(c.value[i], field)); + } + const ph = placeholders(vals); + const params = expr.params.slice(); + for (let i = 0; i < vals.length; i++) params.push(vals[i]); + return { text: `${expr.text} NOT IN (${ph.text})`, params }; + } + default: + throw new Error("Unsupported where operator"); + } + }, + }); +} + +export function where( + clause: Where | undefined, + options: WhereOptions & { cursor?: Cursor; sortBy?: SortBy[] }, +): Fragment { + const parts: Fragment[] = []; + + if (clause) { + const bw = buildWhere(clause, options); + parts.push({ text: `(${bw.text})`, params: bw.params }); + } + + if (options.cursor) { + const paginationWhere = getPaginationFilter(options.cursor, options.sortBy); + if (paginationWhere) { + const bw = buildWhere(paginationWhere, options); + parts.push({ text: `(${bw.text})`, params: bw.params }); + } + } + + return parts.length > 0 ? join(parts, " AND ") : { text: "1=1", params: [] }; +} + +export function set( + data: Record, + mapValue?: (val: unknown) => unknown, + quoteChar = '"', +): Fragment { + const fields = Object.keys(data); + const parts: Fragment[] = []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]!; + const val = data[f]; + if (val === undefined) continue; + const mapped = mapValue ? mapValue(val) : val; + parts.push({ text: `${id(f, quoteChar).text} = ?`, params: [mapped] }); + } + if (parts.length === 0) throw new Error("set() called with empty data"); + return join(parts, ", "); +} + +export function sort(model: Model, sortBy: SortBy[], columnExpr: ColumnExprFn): Fragment { + if (sortBy.length === 0) throw new Error("sort() called with empty sortBy"); + const parts: Fragment[] = []; + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + const typeValue: unknown = + s.type === "number" || s.type === "timestamp" ? 0 : s.type === "boolean" ? true : undefined; + const expr = columnExpr(model, s.field as string, s.path, typeValue); + const dir = (s.direction ?? "asc").toUpperCase(); + parts.push({ text: `${expr.text} ${dir}`, params: expr.params }); + } + return join(parts, ", "); +} + +export function stringifyJsonParam(v: unknown): unknown { + return v !== null && typeof v === "object" && !(v instanceof Date) && !(v instanceof Uint8Array) + ? JSON.stringify(v) + : v; +} + +export function extractFields( + data: Record, + mapValue?: (val: unknown) => unknown, +): { fields: string[]; values: unknown[] } { + const keys = Object.keys(data); + const fields: string[] = []; + const values: unknown[] = []; + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const val = data[k]; + if (val === undefined) continue; + fields.push(k); + values.push(mapValue === undefined ? val : mapValue(val)); + } + return { fields, values }; +} + +export function selectSql(opts: { + table: string; + select: readonly string[] | undefined; + where: Fragment; + orderBy?: Fragment; + limit?: number; + offset?: number; +}): Fragment { + const colsText = opts.select && opts.select.length > 0 ? id(opts.select).text : "*"; + let text = `SELECT ${colsText} FROM ${id(opts.table).text} WHERE ${opts.where.text}`; + const params = opts.where.params.slice(); + if (opts.orderBy !== undefined) { + text += ` ORDER BY ${opts.orderBy.text}`; + for (let i = 0; i < opts.orderBy.params.length; i++) params.push(opts.orderBy.params[i]); + } + if (opts.limit !== undefined) text += ` LIMIT ${opts.limit}`; + if (opts.offset !== undefined) text += ` OFFSET ${opts.offset}`; + return { text, params }; +} + +export function insertSql(opts: { + table: string; + fields: readonly string[]; + values: unknown[]; + returning: readonly string[] | undefined; +}): Fragment { + const returningCols = opts.returning && opts.returning.length > 0 ? id(opts.returning).text : "*"; + const ph = placeholders(opts.values); + return { + text: `INSERT INTO ${id(opts.table).text} (${id(opts.fields).text}) VALUES (${ph.text}) RETURNING ${returningCols}`, + params: opts.values, + }; +} + +export function updateSql(opts: { + table: string; + set: Fragment; + where: Fragment; + returning?: boolean; +}): Fragment { + const text = `UPDATE ${id(opts.table).text} SET ${opts.set.text} WHERE ${opts.where.text}${opts.returning === true ? " RETURNING *" : ""}`; + const params = opts.set.params.slice(); + for (let i = 0; i < opts.where.params.length; i++) params.push(opts.where.params[i]); + return { text, params }; +} + +export function deleteSql(opts: { table: string; where: Fragment }): Fragment { + return { + text: `DELETE FROM ${id(opts.table).text} WHERE ${opts.where.text}`, + params: opts.where.params, + }; +} + +export function upsertSql(opts: { + table: string; + fields: readonly string[]; + values: unknown[]; + conflictColumns: readonly string[]; + onConflict: Fragment; + returning: readonly string[] | undefined; +}): Fragment { + const returningCols = opts.returning && opts.returning.length > 0 ? id(opts.returning).text : "*"; + const ph = placeholders(opts.values); + const params = ph.params.slice(); + for (let i = 0; i < opts.onConflict.params.length; i++) params.push(opts.onConflict.params[i]); + return { + text: `INSERT INTO ${id(opts.table).text} (${id(opts.fields).text}) VALUES (${ph.text}) ON CONFLICT (${id(opts.conflictColumns).text}) ${opts.onConflict.text} RETURNING ${returningCols}`, + params, + }; +} + +export function countSql(opts: { table: string; where: Fragment }): Fragment { + return { + text: `SELECT COUNT(*) as count FROM ${id(opts.table).text} WHERE ${opts.where.text}`, + params: opts.where.params, + }; +} + +export function migrateSqls( + schema: Schema, + opts: { + sqlType: (field: Field) => string; + quoteChar?: string; + indexIfNotExists?: boolean; + }, +): Fragment[] { + const q = opts.quoteChar ?? '"'; + const ifNotExists = opts.indexIfNotExists ?? true; + const models = Object.entries(schema); + const stmts: Fragment[] = []; + + for (let i = 0; i < models.length; i++) { + const [name, model] = models[i]!; + const fields = Object.entries(model.fields); + const columnsList: Fragment[] = []; + for (let j = 0; j < fields.length; j++) { + const [fname, f] = fields[j]!; + columnsList.push({ + text: `${id(fname, q).text} ${opts.sqlType(f)}${f.nullable === true ? "" : " NOT NULL"}`, + params: [], + }); + } + const pkFields = getPrimaryKeyFieldNames(model); + const pkText = `PRIMARY KEY (${id(pkFields, q).text})`; + const colsText = join(columnsList, ", ").text; + stmts.push({ + text: `CREATE TABLE IF NOT EXISTS ${id(name, q).text} (${colsText}, ${pkText})`, + params: [], + }); + } + + for (let i = 0; i < models.length; i++) { + const [name, model] = models[i]!; + if (!model.indexes) continue; + for (let j = 0; j < model.indexes.length; j++) { + const idx = model.indexes[j]!; + const fields = Array.isArray(idx.field) ? idx.field : [idx.field]; + const formatted: Fragment[] = []; + for (let k = 0; k < fields.length; k++) { + const f = fields[k]!; + formatted.push({ + text: `${id(f, q).text}${idx.order ? ` ${idx.order.toUpperCase()}` : ""}`, + params: [], + }); + } + const ifne = ifNotExists ? "IF NOT EXISTS " : ""; + stmts.push({ + text: `CREATE INDEX ${ifne}${id(`idx_${name}_${j}`, q).text} ON ${id(name, q).text} (${join(formatted, ", ").text})`, + params: [], + }); + } + } + + return stmts; +} diff --git a/src/core.ts b/src/core.ts deleted file mode 100644 index 814d2d9..0000000 --- a/src/core.ts +++ /dev/null @@ -1,151 +0,0 @@ -/** - * no-orm Core v1: Canonical Schema and Adapter Specification - */ - -// --- SCHEMA SPEC V1 (#2) --- - -export type Schema = Record; - -export interface Model { - fields: Record; - primaryKey: { - fields: [string, ...string[]]; - }; - indexes?: Index[]; -} - -export interface Field { - type: FieldType; - nullable?: boolean; -} - -export type FieldType = - | { type: "string"; max?: number } - | { type: "number" } - | { type: "boolean" } - | { type: "timestamp" } - | { type: "json" }; - -export interface Index { - fields: [IndexField, ...IndexField[]]; -} - -export interface IndexField { - field: string; - order?: "asc" | "desc"; -} - -// --- TYPE INFERENCE V1 (#1) --- - -export type InferModel = { - [K in keyof M["fields"]]: M["fields"][K]["nullable"] extends true - ? ResolveTSValue | null - : ResolveTSValue; -}; - -type ResolveTSValue = T["type"] extends "string" - ? string - : T["type"] extends "number" - ? number - : T["type"] extends "boolean" - ? boolean - : T["type"] extends "timestamp" - ? number - : T["type"] extends "json" - ? Record // Note: Defaults to object record, may need casting for JSON arrays - : never; - -// --- ADAPTER SPEC V1 (#3) --- - -export interface Adapter { - migrate?(args: { schema: Schema }): Promise; - - transaction?(fn: (tx: Adapter) => Promise): Promise; - - create>(args: { - model: string; - data: T; - select?: Select; - }): Promise; - - update>(args: { - model: string; - where: Where; - data: Partial; - }): Promise; - - updateMany>(args: { - model: string; - where?: Where; - data: Partial; - }): Promise; - - upsert?>(args: { - model: string; - where: Where; - create: T; - update: Partial; - select?: Select; - }): Promise; - - delete>(args: { model: string; where: Where }): Promise; - - deleteMany?>(args: { - model: string; - where?: Where; - }): Promise; - - find>(args: { - model: string; - where: Where; - select?: Select; - }): Promise; - - findMany>(args: { - model: string; - where?: Where; - select?: Select; - sortBy?: SortBy[]; - limit?: number; - offset?: number; - cursor?: Cursor; - }): Promise; - - count?>(args: { model: string; where?: Where }): Promise; -} - -export type FieldName = Extract; - -export type Select = ReadonlyArray>; - -export type Where> = - | { - field: FieldName; - op: "eq" | "ne"; - value: unknown; - } - | { - field: FieldName; - op: "gt" | "gte" | "lt" | "lte"; - value: unknown; - } - | { - field: FieldName; - op: "in" | "not_in"; - value: unknown[]; - } - | { - and: Where[]; - } - | { - or: Where[]; - }; - -export interface SortBy> { - field: FieldName; - direction?: "asc" | "desc"; -} - -export interface Cursor> { - after: Partial, unknown>>; -} diff --git a/src/index.ts b/src/index.ts index 8d119de..eea524d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1 @@ -export * from "./core"; +export * from "./types"; diff --git a/src/core.test.ts b/src/types.test.ts similarity index 68% rename from src/core.test.ts rename to src/types.test.ts index 0444fd8..07657af 100644 --- a/src/core.test.ts +++ b/src/types.test.ts @@ -1,21 +1,19 @@ import { describe, expect, it } from "bun:test"; -import type { InferModel, Schema } from "./core"; +import type { InferModel, Schema } from "./types"; describe("no-orm core", () => { it("should infer correct types for a schema", () => { const schema = { users: { fields: { - id: { type: { type: "string" } }, - age: { type: { type: "number" } }, - is_active: { type: { type: "boolean" } }, - created_at: { type: { type: "timestamp" } }, - metadata: { type: { type: "json" }, nullable: true }, - }, - primaryKey: { - fields: ["id"], + id: { type: "string" }, + age: { type: "number" }, + is_active: { type: "boolean" }, + created_at: { type: "timestamp" }, + metadata: { type: "json", nullable: true }, }, + primaryKey: "id", }, } as const satisfies Schema; @@ -51,18 +49,18 @@ describe("no-orm core", () => { const schema = { conversations: { fields: { - id: { type: { type: "string", max: 255 } }, - created_at: { type: { type: "timestamp" } }, + id: { type: "string", max: 255 }, + created_at: { type: "timestamp" }, }, - primaryKey: { fields: ["id"] }, + primaryKey: "id", }, messages: { fields: { - id: { type: { type: "string", max: 255 } }, - conversation_id: { type: { type: "string", max: 255 } }, - content: { type: { type: "string" } }, + id: { type: "string", max: 255 }, + conversation_id: { type: "string", max: 255 }, + content: { type: "string" }, }, - primaryKey: { fields: ["id"] }, + primaryKey: "id", }, } as const satisfies Schema; diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..b3ca541 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,198 @@ +/** + * no-orm Core v1: Canonical Schema and Adapter Specification + */ + +// --- SCHEMA SPEC V1 --- + +export type Schema = Record; + +export interface Model { + fields: Record; + primaryKey: string | string[]; + indexes?: Index[]; +} + +export interface Field { + type: FieldType; + nullable?: boolean; + max?: number; // Only for string +} + +export type FieldType = "string" | "number" | "boolean" | "timestamp" | "json" | "json[]"; +// Note: "number" and "timestamp" intentionally exclude bigint support in v1 to keep the core tiny. + +export interface Index { + field: string | string[]; + order?: "asc" | "desc"; +} + +// --- TYPE INFERENCE V1 --- + +export type InferModel = { + [K in keyof M["fields"] as M["fields"][K]["nullable"] extends true ? K : never]?: ResolveTSValue< + M["fields"][K]["type"] + > | null; +} & { + [K in keyof M["fields"] as M["fields"][K]["nullable"] extends true ? never : K]: ResolveTSValue< + M["fields"][K]["type"] + >; +}; + +type ResolveTSValue = T extends "string" + ? string + : T extends "number" + ? number + : T extends "boolean" + ? boolean + : T extends "timestamp" + ? number + : T extends "json" + ? Record // Note: Defaults to object record, may need casting for JSON arrays + : T extends "json[]" + ? unknown[] + : never; + +// --- ADAPTER SPEC V1 --- + +export interface Adapter { + /** + * Initializes the database schema. Should be idempotent. + */ + migrate(): Promise; + + /** + * Executes a callback within a database transaction. + * Implementation may vary by adapter (e.g., in-memory vs SQL). + */ + transaction(fn: (tx: Adapter) => Promise): Promise; + + /** + * Inserts a new record. + * @throws Error if a record with the same primary key already exists. + */ + create> = never>(args: { + model: K; + data: InferModel; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>>; + + /** + * Updates a single record matching the mandatory 'where' clause. + * Primary key fields in 'data' are forbidden or ignored to prevent identity swaps. + * @returns The updated record, or null if no record matched 'where'. + */ + update(args: { + model: K; + where: Where>; + data: Partial>; + }): Promise | null>; + + /** + * Updates multiple records matching the 'where' clause. + * Primary key fields in 'data' are forbidden or ignored. + * @returns The number of records updated. + */ + updateMany(args: { + model: K; + where?: Where>; + data: Partial>; + }): Promise; + + /** + * Atomic insert-or-update. + * Uses the primary key extracted from 'create' to check for existence. + * If the record exists, 'update' is applied only if it satisfies the optional 'where' predicate. + * If the record does not exist, 'create' is applied. + */ + upsert> = never>(args: { + model: K; + create: InferModel; + update: Partial>; + where?: Where>; + select?: readonly F[]; + }): Promise<[F] extends [never] ? InferModel : Pick, F>>; + + /** + * Deletes a single record matching the 'where' clause. + */ + delete(args: { + model: K; + where: Where>; + }): Promise; + + /** + * Deletes multiple records matching the 'where' clause. + * @returns The number of records deleted. + */ + deleteMany(args: { + model: K; + where?: Where>; + }): Promise; + + /** + * Finds the first record matching the 'where' clause. + */ + find> = never>(args: { + model: K; + where: Where>; + select?: readonly F[]; + }): Promise<([F] extends [never] ? InferModel : Pick, F>) | null>; + + /** + * Finds all records matching the 'where' clause with sorting and pagination support. + */ + findMany> = never>(args: { + model: K; + where?: Where>; + select?: readonly F[]; + sortBy?: SortBy>[]; + limit?: number; + offset?: number; + cursor?: Cursor>; + }): Promise<([F] extends [never] ? InferModel : Pick, F>)[]>; + + /** + * Returns the count of records matching the 'where' clause. + */ + count(args: { + model: K; + where?: Where>; + }): Promise; +} + +export type FieldName = Extract; + +export type Select = ReadonlyArray>; + +export type Where> = + | { + field: FieldName; + path?: string[]; + type?: FieldType; + op: "eq" | "ne" | "gt" | "gte" | "lt" | "lte"; + value: unknown; + } + | { + field: FieldName; + path?: string[]; + type?: FieldType; + op: "in" | "not_in"; + value: unknown[]; + } + | { + and: Where[]; + } + | { + or: Where[]; + }; + +export interface SortBy> { + field: FieldName; + path?: string[]; + type?: FieldType; + direction?: "asc" | "desc"; +} + +export interface Cursor> { + after: Array<{ field: FieldName; path?: string[]; value: unknown }>; +} diff --git a/tsconfig.json b/tsconfig.json index 5bb77ea..97c14f5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "compilerOptions": { // Environment setup & latest features "types": ["bun"], - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2022"], "target": "ES2022", "module": "Preserve", "moduleDetection": "force",