diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d21b5a4 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,193 @@ +# AGENTS.md + +## Purpose + +This file gives coding agents a fast, reliable workflow for contributing to `@8monkey/no-orm`. + +## Project Snapshot + +- Runtime: Bun + TypeScript (ESM). +- Library type: Tiny, schema-first persistence core for TypeScript libraries. +- Not a query builder, migration framework, or full ORM runtime. +- Designed to be embedded inside other libraries. + +## Technical Design Priorities + +1. Simple, clean, concise, and easy-to-read / maintain code. +2. Tiny footprint — fewer files, fewer lines, fewer abstractions. +3. Modular and tree-shakable with separate entrypoints per adapter. +4. Prefer clarity by default, but accept targeted complexity in hot paths when measurable. +5. Runtime-agnostic across Bun, Node.js, Deno, and edge runtimes. + +If priorities conflict, apply this order: + +1. Public API compatibility +2. Runtime portability +3. Readability and style consistency +4. Hot-path performance + +## Repository Map + +``` +src/ + types.ts Schema, Adapter interface, Where/SortBy/Cursor types + index.ts Public entrypoint (re-exports types.ts) + adapters/ + memory.ts MemoryAdapter (LRU-cache-backed) + postgres.ts PostgresAdapter (Autonomous SQL + Execution) + sqlite.ts SqliteAdapter (Autonomous SQL + Execution) + utils/ + common.ts Shared PK, pagination, and value helpers + sql.ts QueryExecutor interface and toRow helper (shared SQL logic) +``` + +Each adapter file is self-contained: SQL building, driver detection, executor factories, and adapter class all live together. + +## Local Commands + +- Install deps: `bun install` +- Build: `bun run build` +- Type check: `bun run typecheck` (runs oxlint with `--type-check`) +- Test: `bun test` +- Lint: `bun run lint` +- Format: `bun run format` +- Full check: `bun run check` (lint + typecheck) +- Do not run `bun run clean` unless explicitly requested (`git clean -fdx`). + +## Change Workflow + +1. Read the touched feature area first. +2. Keep edits minimal and localized; avoid broad refactors unless asked. +3. **No Rearrangement**: Do not move existing classes, methods, or functions to different positions within a file. Maintaining the original order is required to ensure clean git diffs and facilitate efficient code reviews. +4. Retain existing architectural and defensive comments that explain "why" (e.g. sequential DDL, driver detection order, V8 optimizations). +5. Update related tests when behavior changes. +6. Run `bun run check` and `bun test` before considering a change done. +7. If formatting/linting is impacted, run `bun run format` and `bun run lint`. + +- Update this file with new "Lessons Learned" or "Mistakes to Avoid" if a significant architectural shift or subtle bug is encountered. + +## TypeScript Rules + +### Use `unknown` over `any` + +All internal method signatures must use `unknown` or concrete types, never `any`. The `Where`, `Cursor`, and `SortBy` types default to `Record` — internal helpers accept this default form. Public adapter methods use the generic `Where` form. + +### eslint-disable comments require justification + +When a type assertion is unavoidable (e.g. `RowData -> T` at adapter boundaries), use `eslint-disable-next-line` with a short reason: + +```ts +// eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T +return res as T; +``` + +Never add blanket eslint-disable at the file level. Each suppression must be on the specific line and explain why it's safe. + +### Prefer `unknown` narrowing over type assertions + +Use `"key" in obj` checks and `typeof` guards to narrow before accessing. Only assert when the type system provably can't express the relationship (e.g. generic `T` at adapter boundaries, structurally-typed multi-driver factories). + +### Do not modify `.oxlintrc.json` + +The linter config is intentionally strict (pedantic + suspicious + correctness + perf as error). Do not add rule overrides. Fix the code to satisfy the rules, or add a targeted `eslint-disable-next-line` with justification. + +## Code Style Rules + +### No object spreads in hot paths + +In the memory adapter, every CRUD operation is a hot path. Use `Object.assign({}, source)` or `Object.assign({}, a, b)` instead of `{ ...source }` or `{ ...a, ...b }`. Object spreads generate more code in transpiled output and can be slower in tight loops. + +### Avoid `delete` on objects + +Deleting properties deoptimizes V8/JSC hidden classes. Set to `undefined` or construct a new object. + +### Avoid `await` in synchronous code + +The memory adapter methods are synchronous. Return `Promise.resolve(value)` instead of marking them `async` and using `await Promise.resolve()`. This avoids unnecessary microtask scheduling overhead. + +### Use `for` loops over iterators in performance-sensitive code + +Prefer `for (let i = 0; i < arr.length; i++)` over `for...of` in adapter internals. The indexed form avoids iterator protocol overhead. + +## Architecture Notes + +### Adapter boundary is the one place where `as T` casts are acceptable + +Storage holds `Record` (RowData) but the adapter interface promises `T`. The cast from `RowData -> T` happens in `applySelect` (memory) and `toRow` (sql adapters). Keep this boundary thin and document it. + +### SQL logic is autonomous + +Each SQL adapter class (`PostgresAdapter`, `SqliteAdapter`) implements the `Adapter` interface by owning its SQL generation and execution flow. This significantly reduces abstraction leaks, improves readability, and allows for database-specific optimizations (like `RETURNING` clauses). Shared domain logic (PKs, pagination AST) lives in `common.ts`. + +### QueryExecutor is the driver abstraction + +Each database driver (pg Pool, postgres.js, Bun SQL, better-sqlite3, bun:sqlite, async sqlite) gets wrapped into a `QueryExecutor` (localized to each adapter) with uniform `all`/`get`/`run`/`transaction` methods. The executor factory lives in the adapter file next to its SQL syntax helpers. + +## Dependency Rules + +### All database drivers are optional peer dependencies + +Users only install what they use. The separate entrypoints (`@8monkey/no-orm/adapters/sqlite`, etc.) mean unused driver imports are never evaluated. + +| Peer dependency | Required by | +| -------------------- | -------------------------------------- | +| `lru-cache` | `MemoryAdapter` | +| `better-sqlite3` | `SqliteAdapter` | +| `pg` | `PostgresAdapter` (pg driver) | +| `postgres` | `PostgresAdapter` (postgres.js driver) | +| `sqlite` / `sqlite3` | `SqliteAdapter` (async driver) | + +Bun SQL and bun:sqlite require no extra dependencies — types come from `@types/bun`. + +### devDependencies include all peer deps for type-checking + +Every optional peer dep that provides types must also be in `devDependencies` so that `bun run typecheck` resolves all imports. This includes `lru-cache`, `postgres`, and `sqlite`. The `@types/*` packages cover `pg`, `better-sqlite3`, and `sqlite3`. + +## Schema Rules + +### v1 schema is intentionally minimal + +Supported field types: `string`, `number`, `boolean`, `timestamp`, `json`, `json[]`. No defaults, foreign key fields are just primitive types. No relations or automated joins. + +### Validations are out of scope for v1 + +The code includes only defensive guards (missing PK fields, PK update rejection, JSON path SQL injection prevention). It does not validate schemas at construction time, enforce field types on insert, or check string max lengths. Do not add schema validation unless explicitly requested. + +### All Adapter interface methods are non-optional + +`migrate()`, `transaction()`, `upsert()`, `deleteMany()`, and `count()` are all required. All three adapters implement them. The `Adapter` interface reflects this — no `?` markers. + +### migrate() takes no arguments + +The schema is passed to the adapter constructor. `migrate()` uses `this.schema` to bootstrap storage. This differs from the original spec in issue #3 which had `migrate(args: { schema })`. + +## Testing Expectations + +- Prefer focused tests close to the changed code. +- Unit tests go in `src/**/*.test.ts`. +- Cover: CRUD lifecycle, composite primary keys, select projection, all operators (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `not_in`), logical composition (`and`, `or`), null handling, JSON path filters, pagination (offset + cursor), sorting with nulls, upsert (insert/update/predicated), updateMany, deleteMany, count, transactions, LRU eviction, duplicate key rejection. +- When adding a new adapter, add integration tests exercising the full operation set. + +## Guardrails + +- Do not remove or rename public exports without explicit request. +- Do not add new runtime dependencies. All database drivers must be optional peer deps. +- Do not modify `.oxlintrc.json` or `tsconfig.json` without explicit request. +- Keep comments concise and only where intent is non-obvious. + +## PR/Commit Checklist + +- [ ] Change is scoped to requested behavior. +- [ ] Types compile (`bun run typecheck`) with zero errors. +- [ ] Lint passes (`bun run lint`) with zero errors. +- [ ] Tests pass (`bun test`). +- [ ] No new `any` types introduced. +- [ ] No new `eslint-disable` without per-line justification comment. +- [ ] No object spreads introduced in adapter hot paths. +- [ ] No dead code (unused exports, unreachable branches). +- [ ] README updated if public API changed. + +## Lessons Learned & Mistakes to Avoid + +- **V8 hot paths**: Avoid object spreads and `delete` in adapter CRUD loops to maintain peak performance (hidden class stability). +- **Unified Logic**: Shared logic for keyset pagination (criteria building) and JSON path extraction should live in `common.ts` to ensure consistency between Memory and SQL adapters. diff --git a/README.md b/README.md index 8a81c2a..018ce8b 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"); +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 }, + }); +}); +``` + +SQLite and Postgres support nested transactions via savepoints. + ## 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..a461afa 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.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": {}, - "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.0.0" + }, + "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..105978f --- /dev/null +++ b/src/adapters/memory.test.ts @@ -0,0 +1,682 @@ +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; + type Item = 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 }, + }); + + expect(() => + adapter.create({ + model: "users", + data: { id: "u1", name: "Bob", age: 30, is_active: true, metadata: null }, + }), + ).toThrow("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<"items", Item>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { age: 26 }, + }); + + const updated = await adapter.find<"users", User>({ + 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 }, + }); + + expect(() => + adapter.update<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { id: "u2" }, + }), + ).toThrow("Primary key updates are not supported."); + }); + + it("should return null when updating non-existent record", async () => { + const result = await adapter.update<"users", User>({ + 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<"users", User>({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + data: { age: 99 }, + }); + expect(count).toBe(2); + + const alice = await adapter.find<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(alice?.age).toBe(99); + + const charlie = await adapter.find<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + model: "users", + create: userData, + update: { age: 30 }, + }); + + let found = await adapter.find<"users", User>({ + 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<"users", User>({ + model: "users", + create: userData, + update: { age: 31 }, + }); + + found = await adapter.find<"users", User>({ + 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<"users", User>({ + model: "users", + create: userData, + update: { age: 30 }, + where: { field: "age", op: "gt", value: 40 }, + }); + + let found = await adapter.find<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.age).toBe(25); + + // Condition passes, update happens + await adapter.upsert<"users", User>({ + model: "users", + create: userData, + update: { age: 30 }, + where: { field: "age", op: "lt", value: 40 }, + }); + + found = await adapter.find<"users", User>({ + 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", () => { + // 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; + + expect(() => + adapter.upsert({ + model: "users", + create: invalidData, + update: { age: 21 }, + }), + ).toThrow("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: { age: 20, id: "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..ab96723 --- /dev/null +++ b/src/adapters/memory.ts @@ -0,0 +1,445 @@ +import { LRUCache } from "lru-cache"; + +import type { Adapter, Cursor, InferModel, Schema, Select, SortBy, Where } from "../types"; +import { + assertNoPrimaryKeyUpdates, + getNestedValue, + getPaginationFilter, + getPrimaryKeyFields, + getPrimaryKeyValues, +} from "./utils/common"; + +type RowData = Record; + +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)[]; + 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 { + return fn(this); + } + + create = InferModel>(args: { + model: K; + data: T; + select?: Select; + }): Promise { + const { model, data, select } = args; + const pkIndex = this.pkIndexes.get(model)!; + const pkValue = this.getPrimaryKeyString(model, data); + + if (pkIndex.has(pkValue)) { + throw new Error(`Record with primary key ${pkValue} already exists in ${model}`); + } + + const record: RowData = Object.assign({}, data); + const heap = this.tables.get(model)!; + + // Add to storage + const index = heap.length; + heap.push(record); + pkIndex.set(pkValue, record); + this.indexMap.set(record, index); + + // Add to global LRU for eviction tracking + this.globalLRU.set(record, model); + + return Promise.resolve(this.applySelect(record, select)); + } + + find = InferModel>(args: { + model: K; + where: Where; + select?: Select; + }): Promise { + const { model, where, select } = args; + + // Fast path: PK lookup + const primaryKeyFields = getPrimaryKeyFields(this.schema[model]!); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- checking for field-based clause + const w = where as { field?: string; op?: string; value?: unknown }; + if ( + w.field !== undefined && + primaryKeyFields.length === 1 && + w.field === primaryKeyFields[0] && + w.op === "eq" + ) { + const pkValue = String(w.value); + 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.applySelect(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.applySelect(value, select)); + } + } + return Promise.resolve(null); + } + + findMany = InferModel>(args: { + model: K; + where?: Where; + select?: Select; + sortBy?: SortBy[]; + limit?: number; + offset?: number; + cursor?: Cursor; + }): Promise { + 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.applyCursor(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: T[] = []; + for (let i = start; i < end && i < out.length; i++) { + const r = out[i]!; + this.globalLRU.get(r); // Touch for LRU + final.push(this.applySelect(r, select)); + } + return Promise.resolve(final); + } + + update = InferModel>(args: { + model: K; + where: Where; + data: Partial; + }): Promise { + const { model, where, data } = args; + assertNoPrimaryKeyUpdates(this.schema[model]!, data); + 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, data); + this.globalLRU.set(updated, model); // Update in LRU + return Promise.resolve(this.applySelect(updated)); + } + } + return Promise.resolve(null); + } + + updateMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where; data: Partial }): Promise { + const { model, where, data } = args; + assertNoPrimaryKeyUpdates(this.schema[model]!, data); + 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, data); + this.globalLRU.set(value, model); // Update in LRU + count++; + } + } + return Promise.resolve(count); + } + + upsert = InferModel>(args: { + model: K; + create: T; + update: Partial; + where?: Where; + select?: Select; + }): Promise { + const { model, create, update, where, select } = args; + const pkValue = this.getPrimaryKeyString(model, create); + const existing = this.pkIndexes.get(model)!.get(pkValue); + + if (existing !== undefined) { + if (this.matchesWhere(where, existing)) { + const updated: RowData = Object.assign(existing, update); + this.globalLRU.set(updated, model); + return Promise.resolve(this.applySelect(updated, select)); + } + this.globalLRU.get(existing); + return Promise.resolve(this.applySelect(existing, select)); + } + + return this.create({ model, data: create, select }); + } + + delete = InferModel>(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< + K extends keyof S & string, + T extends Record = InferModel, + >(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 = InferModel>(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 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 + const lastRow = heap.at(-1)!; + heap[idx] = lastRow; + this.indexMap.set(lastRow, idx); + heap.pop(); + + // Cleanup indexes + this.indexMap.delete(row); + const pkValue = this.getPrimaryKeyString(model, row); + pkIndex.delete(pkValue); + } + + private getPrimaryKeyString(modelName: string, data: Record): string { + const modelSpec = this.schema[modelName as keyof S & string]!; + const primaryKeyValues = getPrimaryKeyValues(modelSpec, data); + const primaryKeyFields = getPrimaryKeyFields(modelSpec); + let res = ""; + for (let i = 0; i < primaryKeyFields.length; i++) { + if (i > 0) res += "|"; + const val = primaryKeyValues[primaryKeyFields[i]!]; + if (val !== null && val !== undefined) { + if (typeof val === "object") { + res += JSON.stringify(val); + } else if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") { + res += String(val); + } + } + } + return res; + } + + private matchesWhere>( + where: Where | undefined, + record: RowData, + ): boolean { + if (where === undefined) return true; + return this.evaluateWhere(where, record); + } + + private evaluateWhere>( + where: Where, + record: RowData, + ): boolean { + if ("and" in where) { + const and = (where as { and: Where[] }).and; + for (let i = 0; i < and.length; i++) { + if (!this.evaluateWhere(and[i]!, record)) return false; + } + return true; + } + if ("or" in where) { + const or = (where as { or: Where[] }).or; + for (let i = 0; i < or.length; i++) { + if (this.evaluateWhere(or[i]!, record)) return true; + } + return false; + } + + const recordVal = getNestedValue(record, where.field, where.path); + + switch (where.op) { + case "eq": + return recordVal === where.value; + case "ne": + return recordVal !== where.value; + case "gt": + return compareValues(recordVal, where.value) > 0; + case "gte": + return compareValues(recordVal, where.value) >= 0; + case "lt": + return compareValues(recordVal, where.value) < 0; + case "lte": + return compareValues(recordVal, where.value) <= 0; + case "in": + return Array.isArray(where.value) && where.value.includes(recordVal); + case "not_in": + return Array.isArray(where.value) && !where.value.includes(recordVal); + } + return false; + } + + private applySelect>(record: RowData, select?: Select): T { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- record matches shape of T + if (select === undefined) return Object.assign({}, record) as T; + const res: RowData = {}; + for (let i = 0; i < select.length; i++) { + const k = select[i]!; + res[k as string] = record[k as string] ?? null; + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- projection matches T + return res as T; + } + + private applyCursor>( + 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.evaluateWhere(paginationWhere, record)) { + filtered.push(record); + } + } + return filtered; + } + + private applySort>( + results: RowData[], + sortBy: SortBy[], + ): RowData[] { + const sorted = results.slice(); + // eslint-disable-next-line unicorn/no-array-sort -- sorting a shallow copy + 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..82bc454 --- /dev/null +++ b/src/adapters/postgres.ts @@ -0,0 +1,760 @@ +import type { Client as PgClient, Pool as PgPool, PoolClient as PgPoolClient } from "pg"; +import type postgres from "postgres"; + +import type { + Adapter, + Field, + InferModel, + Schema, + Select, + SortBy, + Where, + Cursor, + Model, +} from "../types"; +import { + assertNoPrimaryKeyUpdates, + buildPrimaryKeyFilter, + getPaginationFilter, + getPrimaryKeyFields, + getPrimaryKeyValues, +} from "./utils/common"; +import { + type QueryExecutor, + isQueryExecutor, + toRow, + toDbRow, + type Fragment, + type QuotedSchema, + createQuotedSchema, + join, + wrap, +} from "./utils/sql"; + +type PostgresJsSql = postgres.Sql; +type TransactionSql = postgres.TransactionSql; + +export type PostgresDriver = PgClient | PgPool | PgPoolClient | PostgresJsSql | TransactionSql; + +/** + * 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 PG Syntax Helpers --- + +const quote = (s: string) => `"${s.replaceAll('"', '""')}"`; + +function mapFieldType(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, + quoteFn: (s: string) => string = quote, +): Fragment { + const quoted = quoteFn(fieldName); + if (!path || path.length === 0) return { strings: [quoted], params: [] }; + 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 isNumeric = typeof value === "number"; + const isBoolean = typeof value === "boolean"; + + const strings = [ + `jsonb_extract_path_text(${quoted}, `, + // eslint-disable-next-line unicorn/no-new-array -- creating array of specific length for placeholders + ...new Array(path.length - 1).fill(", "), + ")", + ]; + if (isNumeric) { + strings[0] = "(" + strings[0]!; + strings[strings.length - 1] += ")::double precision"; + } else if (isBoolean) { + strings[0] = "(" + strings[0]!; + strings[strings.length - 1] += ")::boolean"; + } + return { strings, params: path }; +} + +function toWhereRecursive( + model: Model, + where: Where, + quoteFn: (s: string) => string = quote, +): Fragment { + if ("and" in where) { + const parts: Fragment[] = []; + for (let i = 0; i < where.and.length; i++) { + parts.push(wrap(toWhereRecursive(model, where.and[i]!, quoteFn), "(", ")")); + } + return join(parts, " AND "); + } + + if ("or" in where) { + const parts: Fragment[] = []; + for (let i = 0; i < where.or.length; i++) { + parts.push(wrap(toWhereRecursive(model, where.or[i]!, quoteFn), "(", ")")); + } + return join(parts, " OR "); + } + + const expr = toColumnExpr(model, where.field as string, where.path, where.value, quoteFn); + const val = where.value; + + switch (where.op) { + case "eq": + if (val === null) return wrap(expr, "", " IS NULL"); + return join([expr, { strings: [" = ", ""], params: [val] }], ""); + case "ne": + if (val === null) return wrap(expr, "", " IS NOT NULL"); + return join([expr, { strings: [" != ", ""], params: [val] }], ""); + case "gt": + return join([expr, { strings: [" > ", ""], params: [val] }], ""); + case "gte": + return join([expr, { strings: [" >= ", ""], params: [val] }], ""); + case "lt": + return join([expr, { strings: [" < ", ""], params: [val] }], ""); + case "lte": + return join([expr, { strings: [" <= ", ""], params: [val] }], ""); + case "in": { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- val cast to unknown array for in operator + const vArr = val as unknown[]; + if (!Array.isArray(vArr) || vArr.length === 0) return { strings: ["1=0"], params: [] }; + const inFrag: Fragment = { + // eslint-disable-next-line unicorn/no-new-array -- creating array of specific length for placeholders + strings: [" IN (", ...new Array(vArr.length - 1).fill(", "), ")"], + params: vArr, + }; + return join([expr, inFrag], ""); + } + case "not_in": { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- val cast to unknown array for not_in operator + const vArr = val as unknown[]; + if (!Array.isArray(vArr) || vArr.length === 0) return { strings: ["1=1"], params: [] }; + const inFrag: Fragment = { + // eslint-disable-next-line unicorn/no-new-array -- creating array of specific length for placeholders + strings: [" NOT IN (", ...new Array(vArr.length - 1).fill(", "), ")"], + params: vArr, + }; + return join([expr, inFrag], ""); + } + default: + throw new Error(`Unsupported operator: ${String((where as Record)["op"])}`); + } +} + +function toWhere( + model: Model, + where?: Where, + cursor?: Cursor, + sortBy?: SortBy[], + quoteFn: (s: string) => string = quote, +): Fragment { + const parts: Fragment[] = []; + + if (where) { + parts.push(wrap(toWhereRecursive(model, where, quoteFn), "(", ")")); + } + + if (cursor) { + const paginationWhere = getPaginationFilter(cursor, sortBy); + if (paginationWhere) { + parts.push(wrap(toWhereRecursive(model, paginationWhere, quoteFn), "(", ")")); + } + } + + return parts.length > 0 ? join(parts, " AND ") : { strings: ["1=1"], params: [] }; +} + +// --- Driver detection --- + +function isBunSql(driver: PostgresDriver): boolean { + return "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; +} + +// --- Executor factories --- + +function createPostgresJsExecutor( + sql: postgres.Sql | postgres.TransactionSql, + inTransaction = false, +): QueryExecutor { + const runQuery = (query: Fragment) => { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- constructing TemplateStringsArray for driver call + const strings = query.strings as string[] & { raw: string[] }; + strings.raw = query.strings; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion, typescript-eslint/no-unsafe-return -- calling driver as tagged template function to avoid .unsafe() + const run = sql as ( + strings: TemplateStringsArray, + ...params: unknown[] + ) => Promise[]>; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- TemplateStringsArray is required by the driver's tagged template signature + return run(strings as unknown as TemplateStringsArray, ...query.params); + }; + + return { + all: (query) => { + return runQuery(query); + }, + get: async (query) => { + const rows = await runQuery(query); + return rows[0]; + }, + run: async (query) => { + // eslint-disable-next-line typescript-eslint/no-unsafe-assignment -- driver returns result with count/affectedRows + const rows = await runQuery(query); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- postgres.js returns result with .count + const r = rows as unknown as { count?: number }; + return { changes: r.count ?? 0 }; + }, + transaction: (fn: (executor: QueryExecutor) => Promise) => { + if ("begin" in sql) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- T matches return type of fn + return sql.begin((tx) => fn(createPostgresJsExecutor(tx, true))) as Promise; + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- T matches return type of fn + return sql.savepoint((tx) => fn(createPostgresJsExecutor(tx, true))) as Promise; + }, + inTransaction, + }; +} + +function createBunSqlExecutor( + driver: Record, + inTransaction = false, +): QueryExecutor { + // Bun SQL driver is a callable function that also has a .transaction() method + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver is structurally checked in isBunSql + const bunSql = driver as unknown as (( + strings: TemplateStringsArray, + ...params: unknown[] + ) => Promise[]>) & { + transaction: (fn: (tx: Record) => Promise) => Promise; + }; + + const runQuery = (query: Fragment) => { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- constructing TemplateStringsArray for driver call + const strings = query.strings as string[] & { raw: string[] }; + strings.raw = query.strings; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver call expects TemplateStringsArray + return bunSql(strings as unknown as TemplateStringsArray, ...query.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); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver result has count/affectedRows/command + const r = rows as unknown as { affectedRows?: number; count?: number; command?: string }; + let changes = r.affectedRows ?? r.count ?? 0; + if (changes === 0 && r.command !== undefined && r.command.startsWith("OK ")) { + const parsed = parseInt(r.command.slice(3), 10); + if (!isNaN(parsed)) changes = parsed; + } + return { changes }; + }, + transaction: (fn: (executor: QueryExecutor) => Promise) => + bunSql.transaction((tx) => fn(createBunSqlExecutor(tx, true))), + inTransaction, + }; +} + +function createPgExecutor( + driver: PgClient | PgPool | PgPoolClient, + inTransaction = false, +): QueryExecutor { + const cache = new Map(); + let statementCount = 0; + + function getQuery(query: Fragment) { + // pg needs a single string with $1, $2 placeholders + const text = query.strings.reduce( + (acc, s, i) => acc + s + (i < query.params.length ? "$" + (i + 1) : ""), + "", + ); + const values = query.params; + + let name = cache.get(text); + if (name === undefined) { + if (cache.size >= MAX_CACHED_STATEMENTS) { + const first = cache.keys().next(); + if (first.done !== true) cache.delete(first.value); + } + name = `q_${statementCount++}`; + cache.set(text, name); + } + return { name, text, values }; + } + + return { + all: async (q) => { + const res = await driver.query>(getQuery(q)); + return res.rows; + }, + get: async (q) => { + const res = await driver.query>(getQuery(q)); + return res.rows[0]; + }, + run: async (q) => { + const res = await driver.query(getQuery(q)); + return { changes: res.rowCount ?? 0 }; + }, + transaction: async (fn) => { + const isPool = "connect" in driver && !("release" in driver); + if (isPool) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver is guaranteed to be PgPool by isPool check + const client = await (driver as PgPool).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 { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver is structurally checked in isBunSql + if (isBunSql(driver)) return createBunSqlExecutor(driver as unknown as Record); + if (isPostgresJs(driver)) return createPostgresJsExecutor(driver); + if (isPg(driver)) return createPgExecutor(driver); + throw new Error("Unsupported Postgres driver."); +} + +// --- Adapter --- + +/** + * Postgres Adapter for no-orm. + * + * Notes: + * - Sequential DDL: Create tables first, then indexes. + * - SQLite stores JSON as text; Postgres stores JSON as jsonb. + * - Driver support: node-postgres (pg), postgres.js, and Bun.SQL. + * - upsert always conflicts on the Primary Key. + * - Optional where in upsert acts as a predicate -- record is only updated if condition is met. + * - Primary-key updates are rejected to keep adapter behavior consistent. + * - number and timestamp use standard JavaScript Number. bigint is not supported in v1. + */ +export class PostgresAdapter implements Adapter { + private executor: QueryExecutor; + + constructor( + private schema: S, + driver: PostgresDriver | QueryExecutor, + private quoted: QuotedSchema = createQuotedSchema(schema, quote), + ) { + this.executor = isQueryExecutor(driver) ? driver : createPostgresExecutor(driver); + } + + private getQuotedModel(name: keyof S): string { + const key = String(name); + return this.quoted.models[key] ?? quote(key); + } + + private getQuotedField(model: keyof S, field: string): string { + return this.quoted.fields[String(model)]?.[field] ?? quote(field); + } + + async migrate(): Promise { + const models = Object.entries(this.schema); + + // Create tables first, then indexes — indexes depend on tables existing. + // DDL must be sequential: some drivers don't support concurrent DDL on one connection. + for (let i = 0; i < models.length; i++) { + const [name, model] = models[i]!; + const fields = Object.entries(model.fields); + const columnParts: string[] = []; + for (let j = 0; j < fields.length; j++) { + const [fieldName, field] = fields[j]!; + const type = mapFieldType(field); + const nullable = field.nullable === true ? "" : " NOT NULL"; + columnParts.push(`${this.getQuotedField(name as keyof S, fieldName)} ${type}${nullable}`); + } + const primaryKeyFields = getPrimaryKeyFields(model); + const pk = `PRIMARY KEY (${primaryKeyFields.map((f) => this.getQuotedField(name as keyof S, f)).join(", ")})`; + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run({ + strings: [ + `CREATE TABLE IF NOT EXISTS ${this.getQuotedModel(name as keyof S)} (${columnParts.join(", ")}, ${pk})`, + ], + params: [], + }); + } + + // Now create indexes + 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 = fields.map( + (f) => + `${this.getQuotedField(name as keyof S, f)}${idx.order ? ` ${idx.order.toUpperCase()}` : ""}`, + ); + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run({ + strings: [ + `CREATE INDEX IF NOT EXISTS ${quote(`idx_${name}_${j}`)} ON ${this.getQuotedModel(name as keyof S)} (${formatted.join(", ")})`, + ], + params: [], + }); + } + } + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + if (this.executor.inTransaction) return fn(this); + return this.executor.transaction((exec) => + fn(new PostgresAdapter(this.schema, exec, this.quoted)), + ); + } + + async create< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; data: T; select?: Select }): Promise { + const { model: modelName, data, select } = args; + const model = this.schema[modelName]!; + const input = toDbRow(model, data); + const fields = Object.keys(input); + const sqlFields = fields.map((f) => this.getQuotedField(modelName, f)).join(", "); + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + + const strings = [`INSERT INTO ${this.getQuotedModel(modelName)} (${sqlFields}) VALUES (`]; + for (let i = 1; i < fields.length; i++) strings.push(", "); + strings.push(`) RETURNING ${sqlSelect}`); + const params = fields.map((f) => input[f]); + + const row = await this.executor.get({ strings, params }); + if (row === undefined || row === null) throw new Error("Failed to insert record"); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return toRow(model, row, select); + } + + async find< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where; select?: Select }): Promise { + const { model: modelName, where, select } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- Where matches model fields + const built = toWhere(model, where, undefined, undefined, quoter); + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + + const query = wrap( + built, + `SELECT ${sqlSelect} FROM ${this.getQuotedModel(modelName)} WHERE `, + " LIMIT 1", + ); + + const row = await this.executor.get(query); + if (row === undefined || row === null) return null; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- select matches model fields at runtime + return toRow(model, row, select); + } + + async findMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { + model: K; + where?: Where; + select?: Select; + sortBy?: SortBy[]; + limit?: number; + offset?: number; + cursor?: Cursor; + }): Promise { + const { model: modelName, where, select, sortBy, limit, offset, cursor } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, cursor, sortBy, quoter); + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + + const query = wrap( + built, + `SELECT ${sqlSelect} FROM ${this.getQuotedModel(modelName)} WHERE `, + "", + ); + + if (sortBy && sortBy.length > 0) { + query.strings[query.strings.length - 1] += " ORDER BY "; + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + const expr = toColumnExpr(model, s.field, s.path, undefined, quoter); + const dir = (s.direction ?? "asc").toUpperCase(); + if (i > 0) query.strings[query.strings.length - 1] += ", "; + query.strings[query.strings.length - 1] += expr.strings[0]!; + for (let j = 1; j < expr.strings.length; j++) { + query.strings.push(expr.strings[j]!); + } + for (let j = 0; j < expr.params.length; j++) { + query.params.push(expr.params[j]); + } + query.strings[query.strings.length - 1] += ` ${dir}`; + } + } + if (limit !== undefined) { + query.strings[query.strings.length - 1] += " LIMIT "; + query.strings.push(""); + query.params.push(limit); + } + if (offset !== undefined) { + query.strings[query.strings.length - 1] += " OFFSET "; + query.strings.push(""); + query.params.push(offset); + } + const rows = await this.executor.all(query); + + const result: T[] = []; + for (let i = 0; i < rows.length; i++) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + result.push(toRow(model, rows[i]!, select)); + } + return result; + } + + async update< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; data: Partial; where: Where; select?: Select }): Promise { + const { model: modelName, data, where } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = toDbRow(model, data); + const fields = Object.keys(input); + + if (fields.length === 0) return this.find({ model: modelName, where, select: undefined }); + + const setParts: Fragment[] = []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]!; + setParts.push({ + strings: [`${this.getQuotedField(modelName, f)} = `, ""], + params: [input[f]], + }); + } + const setFrag = join(setParts, ", "); + + const quoter = (f: string) => this.getQuotedField(modelName, f); + const whereFrag = toWhere(model, where, undefined, undefined, quoter); + const query = join( + [wrap(setFrag, `UPDATE ${this.getQuotedModel(modelName)} SET `, ""), whereFrag], + " WHERE ", + ); + query.strings[query.strings.length - 1] += " RETURNING *"; + + const row = await this.executor.get(query); + if (row === undefined || row === null) return this.find({ model: modelName, where }); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return toRow(model, row); + } + + async updateMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where; data: Partial }): Promise { + const { model: modelName, where, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = toDbRow(model, data); + const fields = Object.keys(input); + if (fields.length === 0) return 0; + + const setParts: Fragment[] = []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]!; + setParts.push({ + strings: [`${this.getQuotedField(modelName, f)} = `, ""], + params: [input[f]], + }); + } + const setFrag = join(setParts, ", "); + + const quoter = (f: string) => this.getQuotedField(modelName, f); + const whereFrag = toWhere(model, where, undefined, undefined, quoter); + const query = join( + [wrap(setFrag, `UPDATE ${this.getQuotedModel(modelName)} SET `, ""), whereFrag], + " WHERE ", + ); + + const res = await this.executor.run(query); + return res.changes; + } + + async upsert< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { + model: K; + create: T; + update: Partial; + where?: Where; + select?: Select; + }): Promise { + const { model: modelName, create: cData, update: uData, where, select } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, uData); + + const insertRow = toDbRow(model, cData); + const cFields = Object.keys(insertRow); + const updateRow = toDbRow(model, uData); + const uFields = Object.keys(updateRow); + const primaryKeyFields = getPrimaryKeyFields(model); + + const sqlFields = cFields.map((f) => this.getQuotedField(modelName, f)).join(", "); + const sqlConflict = primaryKeyFields.map((f) => this.getQuotedField(modelName, f)).join(", "); + + let query: Fragment = { + strings: [`INSERT INTO ${this.getQuotedModel(modelName)} (${sqlFields}) VALUES (`], + params: [], + }; + for (let i = 0; i < cFields.length; i++) { + if (i > 0) query.strings[query.strings.length - 1] += ", "; + query.params.push(insertRow[cFields[i]!]); + query.strings.push(""); + } + query.strings[query.strings.length - 1] += `) ON CONFLICT (${sqlConflict}) `; + + if (uFields.length > 0) { + const setParts: Fragment[] = []; + for (let i = 0; i < uFields.length; i++) { + const f = uFields[i]!; + setParts.push({ + strings: [`${this.getQuotedField(modelName, f)} = `, ""], + params: [updateRow[f]], + }); + } + const setFrag = join(setParts, ", "); + query.strings[query.strings.length - 1] += "DO UPDATE SET "; + query = join([query, setFrag], ""); + + if (where) { + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + query.strings[query.strings.length - 1] += " WHERE "; + query = join([query, built], ""); + } + } else { + query.strings[query.strings.length - 1] += "DO NOTHING"; + } + + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + query.strings[query.strings.length - 1] += ` RETURNING ${sqlSelect}`; + + const row = await this.executor.get(query); + if (row !== undefined && row !== null) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- select matches model fields at runtime + return toRow(model, row, select); + } + + const existing = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, cData)), + select, + }); + if (existing === null) throw new Error("Failed to refetch record after upsert"); + return existing; + } + + async delete< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where }): Promise { + const { model: modelName, where } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const query = wrap(built, `DELETE FROM ${this.getQuotedModel(modelName)} WHERE `, ""); + await this.executor.run(query); + } + + async deleteMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where }): Promise { + const { model: modelName, where } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const query = wrap(built, `DELETE FROM ${this.getQuotedModel(modelName)} WHERE `, ""); + const res = await this.executor.run(query); + return res.changes; + } + + async count< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where }): Promise { + const { model: modelName, where } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const query = wrap( + built, + `SELECT COUNT(*) as count FROM ${this.getQuotedModel(modelName)} WHERE `, + "", + ); + const row = await this.executor.get(query); + const count = row?.["count"]; + return count === undefined || count === null ? 0 : Number(count); + } +} diff --git a/src/adapters/sqlite.test.ts b/src/adapters/sqlite.test.ts new file mode 100644 index 0000000..4e0771f --- /dev/null +++ b/src/adapters/sqlite.test.ts @@ -0,0 +1,898 @@ +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" }, + 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, + metadata: { theme: "dark" }, + tags: ["admin"], + }; + await adapter.create({ model: "users", data: user }); + + const found = await adapter.find<"users", User>({ + 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<"users", User>({ + 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 }, + }); + + expect(() => + adapter.update<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { id: "u2" }, + }), + ).toThrow("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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "n1" }, + data: { age: 40 }, + }); + throw new Error("Inner fail"); + }); + } catch { + // expected + } + }); + + const found = await adapter.find<"users", User>({ + 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<"users", User>({ + model: "users", + sortBy: [ + { field: "age", direction: "asc" }, + { field: "name", direction: "desc" }, + ], + cursor: { + after: { age: 30, name: "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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + model: "users", + sortBy: [{ field: "age", direction: "asc" }], + cursor: { after: { age: 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<"users", User>({ + model: "users", + sortBy: [{ field: "age", direction: "desc" }], + cursor: { after: { age: 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<"users", User>({ + 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<"users", User>({ + 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<"users", User>({ + model: "users", + where: { field: "is_active", op: "in", value: [true] }, + }); + expect(users).toHaveLength(2); + }); + }); + + 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<"users", User>({ + 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; + + type OrderItem = { order_id: string; item_id: string; quantity: number; price: number }; + + it("should handle composite primary key operations", async () => { + const compAdapter = new SqliteAdapter(compositeSchema, db); + await compAdapter.migrate(); + + await compAdapter.create<"order_items", OrderItem>({ + model: "order_items", + data: { order_id: "o1", item_id: "i1", quantity: 2, price: 10 }, + }); + + const found = await compAdapter.find<"order_items", OrderItem>({ + model: "order_items", + where: { field: "order_id", op: "eq", value: "o1" }, + }); + expect(found?.["quantity"]).toBe(2); + + await compAdapter.update<"order_items", OrderItem>({ + model: "order_items", + where: { field: "order_id", op: "eq", value: "o1" }, + data: { quantity: 5 }, + }); + + const updated = await compAdapter.find<"order_items", OrderItem>({ + 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<"users", User>({ + 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<"users", User>({ + model: "users", + create: data, + update: { age: 26 }, + }); + + let found = await adapter.find<"users", User>({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + }); + expect(found?.age).toBe(25); + + // Update + await adapter.upsert<"users", User>({ + model: "users", + create: data, + update: { age: 26 }, + }); + + found = await adapter.find<"users", User>({ + 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<"users", User>({ + model: "users", + create: data, + update: { age: 30 }, + where: { field: "age", op: "gt", value: 50 }, + }); + + let found = await adapter.find<"users", User>({ + 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<"users", User>({ + model: "users", + create: data, + update: { age: 30 }, + where: { field: "age", op: "lt", value: 50 }, + }); + + found = await adapter.find<"users", User>({ + 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..cd4035e --- /dev/null +++ b/src/adapters/sqlite.ts @@ -0,0 +1,687 @@ +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, + InferModel, + Schema, + Select, + SortBy, + Where, + Cursor, + Model, +} from "../types"; +import { + assertNoPrimaryKeyUpdates, + buildPrimaryKeyFilter, + getPaginationFilter, + getPrimaryKeyFields, + getPrimaryKeyValues, +} from "./utils/common"; +import { + type QueryExecutor, + isQueryExecutor, + toRow, + toDbRow, + type Fragment, + type QuotedSchema, + createQuotedSchema, + join, + wrap, +} 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 quote = (s: string) => `"${s.replaceAll('"', '""')}"`; + +const mapSqliteValue = (val: unknown, spec: Field) => { + if (spec.type === "boolean") return val === true ? 1 : 0; + return val; +}; + +function mapFieldType(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 serializeJsonPath(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[], + quoteFn: (s: string) => string = quote, +): Fragment { + const quoted = quoteFn(fieldName); + if (!path || path.length === 0) return { strings: [quoted], params: [] }; + 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 { strings: [`json_extract(${quoted}, ?)`], params: [serializeJsonPath(path)] }; +} + +function toWhereRecursive( + model: Model, + where: Where, + quoteFn: (s: string) => string = quote, +): Fragment { + if ("and" in where) { + const parts: Fragment[] = []; + for (let i = 0; i < where.and.length; i++) { + parts.push(wrap(toWhereRecursive(model, where.and[i]!, quoteFn), "(", ")")); + } + return join(parts, " AND "); + } + + if ("or" in where) { + const parts: Fragment[] = []; + for (let i = 0; i < where.or.length; i++) { + parts.push(wrap(toWhereRecursive(model, where.or[i]!, quoteFn), "(", ")")); + } + return join(parts, " OR "); + } + + const expr = toColumnExpr(model, where.field as string, where.path, quoteFn); + const val = where.value; + const mappedVal = typeof val === "boolean" ? (val ? 1 : 0) : val; + + switch (where.op) { + case "eq": + if (val === null) return wrap(expr, "", " IS NULL"); + return join([expr, { strings: [" = ", ""], params: [mappedVal] }], ""); + case "ne": + if (val === null) return wrap(expr, "", " IS NOT NULL"); + return join([expr, { strings: [" != ", ""], params: [mappedVal] }], ""); + case "gt": + return join([expr, { strings: [" > ", ""], params: [mappedVal] }], ""); + case "gte": + return join([expr, { strings: [" >= ", ""], params: [mappedVal] }], ""); + case "lt": + return join([expr, { strings: [" < ", ""], params: [mappedVal] }], ""); + case "lte": + return join([expr, { strings: [" <= ", ""], params: [mappedVal] }], ""); + case "in": { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- val cast to unknown array for in operator + const vArr = val as unknown[]; + if (!Array.isArray(vArr) || vArr.length === 0) return { strings: ["1=0"], params: [] }; + const inParams = vArr.map((v): unknown => (typeof v === "boolean" ? (v ? 1 : 0) : v)); + const inFrag: Fragment = { + // eslint-disable-next-line unicorn/no-new-array -- creating array of specific length for placeholders + strings: [" IN (", ...new Array(vArr.length - 1).fill(", "), ")"], + params: inParams, + }; + return join([expr, inFrag], ""); + } + case "not_in": { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- val cast to unknown array for not_in operator + const vArr = val as unknown[]; + if (!Array.isArray(vArr) || vArr.length === 0) return { strings: ["1=1"], params: [] }; + const inParams = vArr.map((v): unknown => (typeof v === "boolean" ? (v ? 1 : 0) : v)); + const inFrag: Fragment = { + // eslint-disable-next-line unicorn/no-new-array -- creating array of specific length for placeholders + strings: [" NOT IN (", ...new Array(vArr.length - 1).fill(", "), ")"], + params: inParams, + }; + return join([expr, inFrag], ""); + } + default: + throw new Error(`Unsupported operator: ${String((where as Record)["op"])}`); + } +} + +function toWhere( + model: Model, + where?: Where, + cursor?: Cursor, + sortBy?: SortBy[], + quoteFn: (s: string) => string = quote, +): Fragment { + const parts: Fragment[] = []; + + if (where) { + parts.push(wrap(toWhereRecursive(model, where, quoteFn), "(", ")")); + } + + if (cursor) { + const paginationWhere = getPaginationFilter(cursor, sortBy); + if (paginationWhere) { + parts.push(wrap(toWhereRecursive(model, paginationWhere, quoteFn), "(", ")")); + } + } + + return parts.length > 0 ? join(parts, " AND ") : { strings: ["1=1"], params: [] }; +} + +// --- Driver detection and executors --- + +function isSyncSqlite(driver: SqliteDriver): driver is BunDatabase | BetterSqlite3Database { + return "prepare" in driver && !("all" in driver); +} + +type SyncStatement = { + all(...params: unknown[]): unknown[]; + get(...params: unknown[]): unknown; + run(...params: unknown[]): { changes: number }; +}; + +interface SyncDriver { + prepare(sql: string): SyncStatement; +} + +function createSyncSqliteExecutor(driver: SyncDriver, inTransaction = false): QueryExecutor { + const cache = new Map(); + + function getStmt(sql: string): SyncStatement { + let stmt = cache.get(sql); + if (stmt === undefined) { + if (cache.size >= MAX_CACHED_STATEMENTS) { + const first = cache.keys().next(); + if (first.done !== true) cache.delete(first.value); + } + stmt = driver.prepare(sql); + cache.set(sql, stmt); + } + return stmt; + } + + return { + all: (query) => { + const { strings, params } = query; + const sql = strings.join("?"); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver result row matches Record shape + return Promise.resolve(getStmt(sql).all(...params) as Record[]); + }, + get: (query) => { + const { strings, params } = query; + const sql = strings.join("?"); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver returns either a row object or undefined + return Promise.resolve(getStmt(sql).get(...params) as Record | undefined); + }, + run: (query) => { + const { strings, params } = query; + const sql = strings.join("?"); + const res = getStmt(sql).run(...params); + return Promise.resolve({ changes: res.changes }); + }, + transaction: async (fn) => { + getStmt("BEGIN").run(); + try { + const res = await fn(createSyncSqliteExecutor(driver, true)); + getStmt("COMMIT").run(); + return res; + } catch (e) { + getStmt("ROLLBACK").run(); + throw e; + } + }, + inTransaction, + }; +} + +function createAsyncSqliteExecutor(driver: SqliteDatabase, inTransaction = false): QueryExecutor { + return { + // eslint-disable-next-line typescript-eslint/no-unsafe-return -- async driver returns rows + all: (query) => driver.all(query.strings.join("?"), query.params), + // eslint-disable-next-line typescript-eslint/no-unsafe-return -- async driver returns row + get: (query) => driver.get(query.strings.join("?"), query.params), + run: async (query) => { + const res = await driver.run(query.strings.join("?"), query.params); + return { changes: res.changes ?? 0 }; + }, + transaction: async (fn) => { + await driver.run("BEGIN"); + try { + const res = await fn(createAsyncSqliteExecutor(driver, true)); + await driver.run("COMMIT"); + return res; + } catch (e) { + await driver.run("ROLLBACK"); + throw e; + } + }, + inTransaction, + }; +} + +function createSqliteExecutor(driver: SqliteDriver): QueryExecutor { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver is structurally checked in isSyncSqlite + if (isSyncSqlite(driver)) return createSyncSqliteExecutor(driver as unknown as SyncDriver); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver is structurally checked + return createAsyncSqliteExecutor(driver as SqliteDatabase); +} + +// --- Adapter --- + +/** + * SQLite Adapter for no-orm. + * + * Notes: + * - upsert always conflicts on the Primary Key. + * - Optional where in upsert acts as a predicate -- record is only updated if condition is met. + * - Primary-key updates are rejected to keep adapter behavior consistent. + * - SQLite stores JSON as text; Postgres stores JSON as jsonb. + * - number and timestamp use standard JavaScript Number. bigint is not supported in v1. + * - DDL must be sequential: some drivers don't support concurrent DDL on one connection. + */ +export class SqliteAdapter implements Adapter { + private executor: QueryExecutor; + + constructor( + private schema: S, + driver: SqliteDriver | QueryExecutor, + private quoted: QuotedSchema = createQuotedSchema(schema, quote), + ) { + this.executor = isQueryExecutor(driver) ? driver : createSqliteExecutor(driver); + } + + private getQuotedModel(name: keyof S): string { + const key = String(name); + return this.quoted.models[key] ?? quote(key); + } + + private getQuotedField(model: keyof S, field: string): string { + return this.quoted.fields[String(model)]?.[field] ?? quote(field); + } + + async migrate(): Promise { + const models = Object.entries(this.schema); + + // Create tables first, then indexes — indexes depend on tables existing. + // DDL must be sequential: some drivers don't support concurrent DDL on one connection. + for (let i = 0; i < models.length; i++) { + const [name, model] = models[i]!; + const fields = Object.entries(model.fields); + const columns = fields.map( + ([fname, f]) => + `${this.getQuotedField(name as keyof S, fname)} ${mapFieldType(f)}${f.nullable === true ? "" : " NOT NULL"}`, + ); + const primaryKeyFields = getPrimaryKeyFields(model); + const pk = `PRIMARY KEY (${primaryKeyFields.map((f) => this.getQuotedField(name as keyof S, f)).join(", ")})`; + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run({ + strings: [ + `CREATE TABLE IF NOT EXISTS ${this.getQuotedModel(name as keyof S)} (${columns.join(", ")}, ${pk})`, + ], + params: [], + }); + } + + // Now create indexes + 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 = fields.map( + (f) => + `${this.getQuotedField(name as keyof S, f)}${idx.order ? ` ${idx.order.toUpperCase()}` : ""}`, + ); + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run({ + strings: [ + `CREATE INDEX IF NOT EXISTS ${quote(`idx_${name}_${j}`)} ON ${this.getQuotedModel(name as keyof S)} (${formatted.join(", ")})`, + ], + params: [], + }); + } + } + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + if (this.executor.inTransaction) return fn(this); + return this.executor.transaction((exec) => + fn(new SqliteAdapter(this.schema, exec, this.quoted)), + ); + } + + async create< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; data: T; select?: Select }): Promise { + const { model: modelName, data, select } = args; + const model = this.schema[modelName]!; + const input = toDbRow(model, data, mapSqliteValue); + const fields = Object.keys(input); + const sqlFields = fields.map((f) => this.getQuotedField(modelName, f)).join(", "); + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + + const strings = [`INSERT INTO ${this.getQuotedModel(modelName)} (${sqlFields}) VALUES (`]; + for (let i = 1; i < fields.length; i++) strings.push(", "); + strings.push(`) RETURNING ${sqlSelect}`); + const params = fields.map((f) => input[f]); + + const row = await this.executor.get({ strings, params }); + 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; + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return toRow(model, row, select); + } + + async find< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where; select?: Select }): Promise { + const { model: modelName, where, select } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + + const query = wrap( + built, + `SELECT ${sqlSelect} FROM ${this.getQuotedModel(modelName)} WHERE `, + " LIMIT 1", + ); + + const row = await this.executor.get(query); + if (row === undefined || row === null) return null; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- select matches model fields at runtime + return toRow(model, row, select); + } + + async findMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { + model: K; + where?: Where; + select?: Select; + sortBy?: SortBy[]; + limit?: number; + offset?: number; + cursor?: Cursor; + }): Promise { + const { model: modelName, where, select, sortBy, limit, offset, cursor } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, cursor, sortBy, quoter); + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + + const query = wrap( + built, + `SELECT ${sqlSelect} FROM ${this.getQuotedModel(modelName)} WHERE `, + "", + ); + + if (sortBy && sortBy.length > 0) { + query.strings[query.strings.length - 1] += " ORDER BY "; + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + const expr = toColumnExpr(model, s.field, s.path, quoter); + const dir = (s.direction ?? "asc").toUpperCase(); + if (i > 0) query.strings[query.strings.length - 1] += ", "; + query.strings[query.strings.length - 1] += expr.strings[0]!; + for (let j = 1; j < expr.strings.length; j++) { + query.strings.push(expr.strings[j]!); + } + for (let j = 0; j < expr.params.length; j++) { + query.params.push(expr.params[j]); + } + query.strings[query.strings.length - 1] += ` ${dir}`; + } + } + if (limit !== undefined) { + query.strings[query.strings.length - 1] += " LIMIT "; + query.strings.push(""); + query.params.push(limit); + } + if (offset !== undefined) { + query.strings[query.strings.length - 1] += " OFFSET "; + query.strings.push(""); + query.params.push(offset); + } + const rows = await this.executor.all(query); + + const result: T[] = []; + for (let i = 0; i < rows.length; i++) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + result.push(toRow(model, rows[i]!, select)); + } + return result; + } + + async update< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; data: Partial; where: Where; select?: Select }): Promise { + const { model: modelName, data, where } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = toDbRow(model, data, mapSqliteValue); + const fields = Object.keys(input); + + if (fields.length === 0) return this.find({ model: modelName, where, select: undefined }); + + const setParts: Fragment[] = []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]!; + setParts.push({ + strings: [`${this.getQuotedField(modelName, f)} = `, ""], + params: [input[f]], + }); + } + const setFrag = join(setParts, ", "); + + const quoter = (f: string) => this.getQuotedField(modelName, f); + const whereFrag = toWhere(model, where, undefined, undefined, quoter); + const query = join( + [wrap(setFrag, `UPDATE ${this.getQuotedModel(modelName)} SET `, ""), whereFrag], + " WHERE ", + ); + query.strings[query.strings.length - 1] += " RETURNING *"; + + const row = await this.executor.get(query); + if (row === undefined || row === null) return this.find({ model: modelName, where }); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return toRow(model, row); + } + + async updateMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where; data: Partial }): Promise { + const { model: modelName, where, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = toDbRow(model, data, mapSqliteValue); + const fields = Object.keys(input); + if (fields.length === 0) return 0; + + const setParts: Fragment[] = []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]!; + setParts.push({ + strings: [`${this.getQuotedField(modelName, f)} = `, ""], + params: [input[f]], + }); + } + const setFrag = join(setParts, ", "); + + const quoter = (f: string) => this.getQuotedField(modelName, f); + const whereFrag = toWhere(model, where, undefined, undefined, quoter); + const query = join( + [wrap(setFrag, `UPDATE ${this.getQuotedModel(modelName)} SET `, ""), whereFrag], + " WHERE ", + ); + + const res = await this.executor.run(query); + return res.changes; + } + + async upsert< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { + model: K; + create: T; + update: Partial; + where?: Where; + select?: Select; + }): Promise { + const { model: modelName, create: cData, update: uData, where, select } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, uData); + + const insertRow = toDbRow(model, cData, mapSqliteValue); + const cFields = Object.keys(insertRow); + const updateRow = toDbRow(model, uData, mapSqliteValue); + const uFields = Object.keys(updateRow); + const primaryKeyFields = getPrimaryKeyFields(model); + + const sqlFields = cFields.map((f) => this.getQuotedField(modelName, f)).join(", "); + const sqlConflict = primaryKeyFields.map((f) => this.getQuotedField(modelName, f)).join(", "); + + const strings = [`INSERT INTO ${this.getQuotedModel(modelName)} (${sqlFields}) VALUES (`]; + const params = []; + + for (let i = 0; i < cFields.length; i++) { + if (i > 0) strings.push(", "); + params.push(insertRow[cFields[i]!]); + } + strings.push(`) ON CONFLICT (${sqlConflict}) `); + + let query: Fragment = { strings, params }; + + if (uFields.length > 0) { + const setParts: Fragment[] = []; + for (let i = 0; i < uFields.length; i++) { + const f = uFields[i]!; + setParts.push({ + strings: [`${this.getQuotedField(modelName, f)} = `, ""], + params: [updateRow[f]], + }); + } + const setFrag = join(setParts, ", "); + query.strings[query.strings.length - 1] += "DO UPDATE SET "; + query = join([query, setFrag], ""); + + if (where) { + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + query.strings[query.strings.length - 1] += " WHERE "; + query = join([query, built], ""); + } + } else { + query.strings[query.strings.length - 1] += "DO NOTHING"; + } + + const sqlSelect = select + ? select.map((s) => this.getQuotedField(modelName, s)).join(", ") + : "*"; + query.strings[query.strings.length - 1] += ` RETURNING ${sqlSelect}`; + + const row = await this.executor.get(query); + if (row !== undefined && row !== null) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- select matches model fields at runtime + return toRow(model, row, select); + } + + const existing = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, cData)), + select, + }); + if (existing === null) throw new Error("Failed to refetch record after upsert"); + return existing; + } + + async delete< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where }): Promise { + const { model: modelName, where } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const query = wrap(built, `DELETE FROM ${this.getQuotedModel(modelName)} WHERE `, ""); + await this.executor.run(query); + } + + async deleteMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where }): Promise { + const { model: modelName, where } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const query = wrap(built, `DELETE FROM ${this.getQuotedModel(modelName)} WHERE `, ""); + const res = await this.executor.run(query); + return res.changes; + } + + async count< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where }): Promise { + const { model: modelName, where } = args; + const model = this.schema[modelName]!; + const quoter = (f: string) => this.getQuotedField(modelName, f); + const built = toWhere(model, where, undefined, undefined, quoter); + const query = wrap( + built, + `SELECT COUNT(*) as count FROM ${this.getQuotedModel(modelName)} WHERE `, + "", + ); + const row = await this.executor.get(query); + const count = row?.["count"]; + return count === undefined || count === null ? 0 : Number(count); + } +} diff --git a/src/adapters/utils/common.ts b/src/adapters/utils/common.ts new file mode 100644 index 0000000..1ad8023 --- /dev/null +++ b/src/adapters/utils/common.ts @@ -0,0 +1,148 @@ +import type { Cursor, FieldName, Model, SortBy, Where } from "../../types"; + +// --- Schema & Logic Helpers --- + +export function getPrimaryKeyFields(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 primaryKeyFields = getPrimaryKeyFields(model); + const values: Record = {}; + for (let i = 0; i < primaryKeyFields.length; i++) { + const field = primaryKeyFields[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 primaryKeyFields = getPrimaryKeyFields(model); + if (primaryKeyFields.length === 1) { + const field = primaryKeyFields[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 < primaryKeyFields.length; i++) { + const field = primaryKeyFields[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 primaryKeyFields = getPrimaryKeyFields(model); + for (let i = 0; i < primaryKeyFields.length; i++) { + const field = primaryKeyFields[i]!; + if (data[field] !== undefined) { + 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 --- + +/** + * 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 (typeof val !== "object" || val === null || Array.isArray(val)) return undefined; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- val is checked to be an object and not null above + val = (val as Record)[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 cursorValues = cursor.after as Record; + 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({ + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- criteria field is guaranteed to be in T + field: prev.field as FieldName, + path: prev.path, + op: "eq", + value: cursorValues[prev.field], + }); + } + const curr = criteria[i]!; + andClauses.push({ + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- criteria field is guaranteed to be in T + field: curr.field as FieldName, + path: curr.path, + op: curr.direction === "desc" ? "lt" : "gt", + value: cursorValues[curr.field], + }); + 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[], +): { field: string; direction: "asc" | "desc"; path?: string[] }[] { + const cursorValues = cursor.after as Record; + const criteria = []; + if (sortBy !== undefined && sortBy.length > 0) { + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + if (cursorValues[s.field] !== undefined) { + criteria.push({ field: s.field, direction: s.direction ?? "asc", path: s.path }); + } + } + } else { + const keys = Object.keys(cursorValues); + for (let i = 0; i < keys.length; i++) { + criteria.push({ field: keys[i]!, direction: "asc" as const, path: undefined }); + } + } + return criteria; +} diff --git a/src/adapters/utils/sql.ts b/src/adapters/utils/sql.ts new file mode 100644 index 0000000..ed44e6f --- /dev/null +++ b/src/adapters/utils/sql.ts @@ -0,0 +1,161 @@ +import type { Field, Model, Schema, Select } from "../../types"; +import { mapNumeric } from "./common"; + +/** + * A Fragment keeps SQL logic and dynamic data separate to prevent injection. + * It is structured to be compatible with TemplateStringsArray for safe driver calls. + */ +export interface Fragment { + strings: string[]; + params: unknown[]; +} + +/** Shared contracts for SQL executors */ +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 type QuotedSchema = { + models: Record; + fields: Record>; +}; + +export function isQueryExecutor(obj: unknown): obj is QueryExecutor { + if (typeof obj !== "object" || obj === null) return false; + return ( + "all" in obj && + "run" in obj && + typeof (obj as Record)["all"] === "function" && + typeof (obj as Record)["run"] === "function" + ); +} + +/** + * Maps a raw database row to the inferred model type T. + * Handles JSON parsing, boolean conversion, and numeric mapping. + */ +export function toRow>( + model: Model, + row: Record, + select?: Select, +): T { + const fields = model.fields; + const res: Record = {}; + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- select fields are strings + const keys = (select as readonly string[]) ?? Object.keys(row); + + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const val = row[k]; + const spec = fields[k]; + if (spec === undefined || val === undefined || val === null) { + res[k] = val; + continue; + } + if (spec.type === "json" || spec.type === "json[]") { + res[k] = typeof val === "string" ? JSON.parse(val) : val; + } else if (spec.type === "boolean") { + // Postgres returns boolean, SQLite returns 1/0 + res[k] = val === true || val === 1; + } else if (spec.type === "number" || spec.type === "timestamp") { + res[k] = mapNumeric(val); + } else { + res[k] = val; + } + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return res as T; +} + +/** + * Prepares a data object for database insertion/update. + * Handles JSON stringification and optional adapter-specific mapping. + */ +export function toDbRow( + model: Model, + data: Record, + mapValue?: (val: unknown, field: Field) => unknown, +): Record { + const fields = model.fields; + const res: Record = {}; + const keys = Object.keys(data); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const val = data[k]; + const spec = fields[k]; + if (val === undefined) continue; + + if (val === null) { + res[k] = null; + continue; + } + + if (spec === undefined) { + res[k] = val; + continue; + } + + let processed = val; + if (spec.type === "json" || spec.type === "json[]") { + processed = JSON.stringify(val); + } + + res[k] = mapValue ? mapValue(processed, spec) : processed; + } + return res; +} + +/** + * Concatenates multiple fragments with a separator. + */ +export function join(fragments: Fragment[], separator: string): Fragment { + if (fragments.length === 0) return { strings: [""], params: [] }; + + const strings = [...fragments[0]!.strings]; + const params = [...fragments[0]!.params]; + + for (let i = 1; i < fragments.length; i++) { + const f = fragments[i]!; + strings[strings.length - 1] += separator + f.strings[0]; + for (let j = 1; j < f.strings.length; j++) { + strings.push(f.strings[j]!); + } + for (let j = 0; j < f.params.length; j++) { + params.push(f.params[j]); + } + } + + return { strings, params }; +} + +/** + * Wraps a fragment with a prefix and suffix. + */ +export function wrap(fragment: Fragment, prefix: string, suffix: string): Fragment { + const strings = [...fragment.strings]; + strings[0] = prefix + strings[0]!; + strings[strings.length - 1] += suffix; + return { strings, params: [...fragment.params] }; +} + +export function createQuotedSchema(schema: Schema, quote: (s: string) => string): QuotedSchema { + const quoted: QuotedSchema = { models: {}, fields: {} }; + const models = Object.keys(schema); + for (let i = 0; i < models.length; i++) { + const modelName = models[i]!; + quoted.models[modelName] = quote(modelName); + + const fields = Object.keys(schema[modelName]!.fields); + const quotedFields: Record = {}; + for (let j = 0; j < fields.length; j++) { + const fieldName = fields[j]!; + quotedFields[fieldName] = quote(fieldName); + } + quoted.fields[modelName] = quotedFields; + } + return quoted; +} 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..7a05893 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,201 @@ +/** + * 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 = InferModel>(args: { + model: K; + data: T; + select?: Select; + }): Promise; + + /** + * 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 = InferModel>(args: { + model: K; + where: Where; + data: Partial; + }): Promise; + + /** + * Updates multiple records matching the 'where' clause. + * Primary key fields in 'data' are forbidden or ignored. + * @returns The number of records updated. + */ + updateMany< + K extends keyof S & string, + T extends Record = InferModel, + >(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 = InferModel>(args: { + model: K; + create: T; + update: Partial; + where?: Where; + select?: Select; + }): Promise; + + /** + * Deletes a single record matching the 'where' clause. + */ + delete = InferModel>(args: { + model: K; + where: Where; + }): Promise; + + /** + * Deletes multiple records matching the 'where' clause. + * @returns The number of records deleted. + */ + deleteMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { + model: K; + where?: Where; + }): Promise; + + /** + * Finds the first record matching the 'where' clause. + */ + find = InferModel>(args: { + model: K; + where: Where; + select?: Select; + }): Promise; + + /** + * Finds all records matching the 'where' clause with sorting and pagination support. + */ + findMany = InferModel>(args: { + model: K; + where?: Where; + select?: Select; + sortBy?: SortBy[]; + limit?: number; + offset?: number; + cursor?: Cursor; + }): Promise; + + /** + * Returns the count of records matching the 'where' clause. + */ + count = InferModel>(args: { + model: K; + where?: Where; + }): Promise; +} + +export type FieldName = Extract; + +export type Select = ReadonlyArray>; + +export type Where> = + | { + field: FieldName; + path?: string[]; + op: "eq" | "ne" | "gt" | "gte" | "lt" | "lte"; + value: unknown; + } + | { + field: FieldName; + path?: string[]; + op: "in" | "not_in"; + value: unknown[]; + } + | { + and: Where[]; + } + | { + or: Where[]; + }; + +export interface SortBy> { + field: FieldName; + path?: string[]; + direction?: "asc" | "desc"; +} + +export interface Cursor> { + after: Partial<{ [K in FieldName]: 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",