diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..059ad1b --- /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, toRow helper, and shared SQL clause builders (where, set, sort) +``` + +Each adapter file is self-contained for driver detection and executor factories, but relies on `utils/sql.ts` for atomic clause generation. + +## 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 orchestration (template assembly, `RETURNING`, `ON CONFLICT`), while relying on `utils/sql.ts` for atomic clause generation (`where`, `set`, `sort`). This significantly reduces abstraction leaks, improves readability, and allows for database-specific optimizations. 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..f85b31d 100644 --- a/README.md +++ b/README.md @@ -1,90 +1,239 @@ -# no-orm +# @8monkey/no-orm -A tiny, database-independent persistence core for TypeScript libraries. No heavy abstractions, just the primitives. +A tiny, schema-first persistence core for TypeScript libraries. -## Features +`no-orm` is intentionally small: -- **Canonical Schema**: One portable schema representation for any database. -- **Type Inference**: Derive TypeScript types directly from your schema. -- **Adapter-Based**: Small, generic execution contract for multiple backends. +- one canonical schema shape +- inferred TypeScript model types +- adapter-based persistence +- minimal CRUD, filtering, ordering, pagination, and transactions + +It is not a query builder, migration framework, or full ORM runtime. ## Installation ```bash +npm install @8monkey/no-orm +# or bun add @8monkey/no-orm ``` -## Usage - -### 1. Define your Schema +## Define a Schema -```typescript -import { Schema } from "@8monkey/no-orm"; +```ts +import type { InferModel, Schema } from "@8monkey/no-orm"; export const schema = { - conversations: { + users: { fields: { - id: { type: { type: "string", max: 255 } }, - created_at: { type: { type: "timestamp" } }, - metadata: { type: { type: "json" }, nullable: true }, + id: { type: "string" }, + name: { type: "string", max: 255 }, + age: { type: "number" }, + is_active: { type: "boolean" }, + metadata: { type: "json", nullable: true }, + tags: { type: "json[]", nullable: true }, + created_at: { type: "timestamp" }, }, - primaryKey: { - fields: ["id"], - }, - indexes: [ - { - fields: [ - { field: "created_at", order: "desc" }, - { field: "id", order: "desc" }, - ], - }, - ], + primaryKey: "id", + indexes: [{ field: "created_at", order: "desc" }], }, } as const satisfies Schema; + +type User = InferModel; ``` -### 2. Infer Types +## Choose an Adapter -```typescript -import { InferModel } from "@8monkey/no-orm"; +### SQLite -export type Conversation = InferModel; -// Result: { id: string; created_at: number; metadata: Record | null; } +```ts +import { Database } from "bun:sqlite"; +import { SqliteAdapter } from "@8monkey/no-orm/adapters/sqlite"; + +const db = new Database("data.db"); // or ":memory:" for an in-process database +const adapter = new SqliteAdapter(schema, db); + +await adapter.migrate(); ``` -### 3. Use an Adapter +### Postgres + +```ts +import postgres from "postgres"; // or import { Pool } from "pg" +import { PostgresAdapter } from "@8monkey/no-orm/adapters/postgres"; -```typescript -import { Adapter } from "@8monkey/no-orm"; -// Import a concrete adapter (e.g., @8monkey/no-orm-sqlite) +const sql = postgres(process.env.POSTGRES_URL!); +const adapter = new PostgresAdapter(schema, sql); -const adapter: Adapter = new SqliteAdapter({ schema, db }); +await adapter.migrate(); +``` + +### Memory + +In-memory storage for testing or temporary data. + +```ts +import { MemoryAdapter } from "@8monkey/no-orm/adapters/memory"; + +const adapter = new MemoryAdapter(schema, { maxItems: 100 }); +await adapter.migrate(); +``` -// Minimal Schema Bootstrap -await adapter.migrate({ schema }); +## CRUD -// Create a record -const conv = await adapter.create({ - model: "conversations", +```ts +// Create +const created = await adapter.create({ + model: "users", data: { - id: "conv_123", - created_at: Date.now(), + id: "u1", + name: "Alice", + age: 30, + is_active: true, metadata: { theme: "dark" }, + tags: ["admin"], + created_at: Date.now(), }, }); -// Find many with filters -const results = await adapter.findMany({ - model: "conversations", +// Find one +const found = await adapter.find({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, +}); + +// Find many +const users = await adapter.findMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + sortBy: [{ field: "created_at", direction: "desc" }], + limit: 20, +}); + +// Update one +const updated = await adapter.update({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, + data: { age: 31 }, +}); + +// Update many +const updatedCount = await adapter.updateMany({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, + data: { age: 99 }, +}); + +// Delete one +await adapter.delete({ + model: "users", + where: { field: "id", op: "eq", value: "u1" }, +}); + +// Delete many +const deletedCount = await adapter.deleteMany({ + model: "users", + where: { field: "is_active", op: "eq", value: false }, +}); + +// Count +const total = await adapter.count({ + model: "users", + where: { field: "is_active", op: "eq", value: true }, +}); + +// Upsert - insert or update by primary key +const user = await adapter.upsert({ + model: "users", + create: { id: "u1", name: "Alice", age: 30, is_active: true, created_at: Date.now() }, + update: { age: 31 }, + // Optional: only update if predicate is met + where: { field: "is_active", op: "eq", value: true }, +}); +``` + +## Filtering + +All operations accept a `where` clause: + +```ts +// Operators +where: { field: "age", op: "eq", value: 30 } +where: { field: "age", op: "ne", value: null } +where: { field: "age", op: "gt", value: 18 } +where: { field: "age", op: "gte", value: 18 } +where: { field: "age", op: "lt", value: 65 } +where: { field: "age", op: "lte", value: 65 } +where: { field: "status", op: "in", value: ["active", "pending"] } +where: { field: "status", op: "not_in", value: ["banned"] } + +// Combine with and/or +where: { + and: [ + { field: "age", op: "gte", value: 18 }, + { field: "is_active", op: "eq", value: true }, + ], +} +``` + +## JSON Paths + +Filter nested JSON fields using `path`: + +```ts +const darkUsers = await adapter.findMany({ + model: "users", where: { - field: "created_at", - op: "gt", - value: Date.now() - 86400000, + field: "metadata", + path: ["preferences", "theme"], + op: "eq", + value: "dark", + }, +}); +``` + +## Pagination + +```ts +// Offset pagination +const page = await adapter.findMany({ + model: "users", + sortBy: [{ field: "created_at", direction: "desc" }], + limit: 20, + offset: 40, +}); + +// Cursor pagination (keyset) +const cursorPage = await adapter.findMany({ + model: "users", + sortBy: [{ field: "created_at", direction: "desc" }], + limit: 20, + cursor: { + after: { created_at: 1699900000000, id: "u20" }, }, - limit: 10, }); ``` +## Transactions + +```ts +await adapter.transaction(async (tx) => { + await tx.create({ + model: "users", + data: { id: "u2", name: "Bob", age: 28, is_active: true, created_at: Date.now() }, + }); + + await tx.update({ + model: "users", + where: { field: "id", op: "eq", value: "u2" }, + data: { age: 29 }, + }); +}); +``` + +Nested calls to `transaction()` join the existing transaction. + ## License MIT diff --git a/bun.lock b/bun.lock index cd4ff65..e24d852 100644 --- a/bun.lock +++ b/bun.lock @@ -5,15 +5,43 @@ "": { "name": "@8monkey/no-orm", "devDependencies": { + "@types/better-sqlite3": "^7.6.13", "@types/bun": "^1.3.12", + "@types/pg": "^8.11.11", + "@types/sqlite3": "^5.1.0", + "lru-cache": "^11.0.0", "oxfmt": "^0.44.0", "oxlint": "^1.59.0", "oxlint-tsgolint": "^0.20.0", + "postgres": "^3.4.5", + "sqlite": "^5.1.1", "typescript": "^6.0.2", }, + "peerDependencies": { + "better-sqlite3": "^11.0.0", + "lru-cache": "^11.0.0", + "pg": "^8.0.0", + "postgres": "^3.4.0", + "sqlite": "^5.0.0", + "sqlite3": "^5.0.0", + }, + "optionalPeers": [ + "better-sqlite3", + "lru-cache", + "pg", + "postgres", + "sqlite", + "sqlite3", + ], }, }, "packages": { + "@gar/promisify": ["@gar/promisify@1.1.3", "", {}, "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw=="], + + "@npmcli/fs": ["@npmcli/fs@1.1.1", "", { "dependencies": { "@gar/promisify": "^1.0.1", "semver": "^7.3.5" } }, "sha512-8KG5RD0GVP4ydEzRn/I4BNDuxDtqVbOdm8675T49OIG/NGhaK0pjPX7ZcDlvKYbA+ulvVK3ztfcF4uBdOxuJbQ=="], + + "@npmcli/move-file": ["@npmcli/move-file@1.1.2", "", { "dependencies": { "mkdirp": "^1.0.4", "rimraf": "^3.0.2" } }, "sha512-1SUf/Cg2GzGDyaf15aR9St9TWlb+XvbZXWpDx8YKs7MLzMH/BCeopv+y9vzrzgkfykCGuWOlSu3mZhj2+FQcrg=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.44.0", "", { "os": "android", "cpu": "arm" }, "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ=="], "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.44.0", "", { "os": "android", "cpu": "arm64" }, "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A=="], @@ -102,22 +130,306 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.59.0", "", { "os": "win32", "cpu": "x64" }, "sha512-xkE7puteDS/vUyRngLXW0t8WgdWoS/tfxXjhP/P7SMqPDx+hs44SpssO3h3qmTqECYEuXBUPzcAw5257Ka+ofA=="], + "@tootallnate/once": ["@tootallnate/once@1.1.2", "", {}, "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw=="], + + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + "@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], + "@types/pg": ["@types/pg@8.20.0", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow=="], + + "@types/sqlite3": ["@types/sqlite3@5.1.0", "", { "dependencies": { "sqlite3": "*" } }, "sha512-w25Gd6OzcN0Sb6g/BO7cyee0ugkiLgonhgGYfG+H0W9Ub6PUsC2/4R+KXy2tc80faPIWO3Qytbvr8gP1fU4siA=="], + + "abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="], + + "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], + + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + + "aggregate-error": ["aggregate-error@3.1.0", "", { "dependencies": { "clean-stack": "^2.0.0", "indent-string": "^4.0.0" } }, "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "aproba": ["aproba@2.1.0", "", {}, "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew=="], + + "are-we-there-yet": ["are-we-there-yet@3.0.1", "", { "dependencies": { "delegates": "^1.0.0", "readable-stream": "^3.6.0" } }, "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], + + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + "bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + "cacache": ["cacache@15.3.0", "", { "dependencies": { "@npmcli/fs": "^1.0.0", "@npmcli/move-file": "^1.0.1", "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "glob": "^7.1.4", "infer-owner": "^1.0.4", "lru-cache": "^6.0.0", "minipass": "^3.1.1", "minipass-collect": "^1.0.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.2", "mkdirp": "^1.0.3", "p-map": "^4.0.0", "promise-inflight": "^1.0.1", "rimraf": "^3.0.2", "ssri": "^8.0.1", "tar": "^6.0.2", "unique-filename": "^1.1.1" } }, "sha512-VVdYzXEn+cnbXpFgWs5hTT7OScegHVmLhJIR8Ufqk3iFD6A6j5iSX1KuBTfNEv4tdJWE2PzA6IVFtcLC7fN9wQ=="], + + "chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "clean-stack": ["clean-stack@2.2.0", "", {}, "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A=="], + + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "console-control-strings": ["console-control-strings@1.1.0", "", {}, "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "delegates": ["delegates@1.0.0", "", {}, "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "encoding": ["encoding@0.1.13", "", { "dependencies": { "iconv-lite": "^0.6.2" } }, "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], + + "err-code": ["err-code@2.0.3", "", {}, "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA=="], + + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + + "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="], + + "gauge": ["gauge@4.0.4", "", { "dependencies": { "aproba": "^1.0.3 || ^2.0.0", "color-support": "^1.1.3", "console-control-strings": "^1.1.0", "has-unicode": "^2.0.1", "signal-exit": "^3.0.7", "string-width": "^4.2.3", "strip-ansi": "^6.0.1", "wide-align": "^1.1.5" } }, "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg=="], + + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "glob": ["glob@7.2.3", "", { "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", "inherits": "2", "minimatch": "^3.1.1", "once": "^1.3.0", "path-is-absolute": "^1.0.0" } }, "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-unicode": ["has-unicode@2.0.1", "", {}, "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ=="], + + "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + + "http-proxy-agent": ["http-proxy-agent@4.0.1", "", { "dependencies": { "@tootallnate/once": "1", "agent-base": "6", "debug": "4" } }, "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg=="], + + "https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + + "iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "infer-owner": ["infer-owner@1.0.4", "", {}, "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A=="], + + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-lambda": ["is-lambda@1.0.1", "", {}, "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], + + "make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimatch": ["minimatch@3.1.5", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "minipass-collect": ["minipass-collect@1.0.2", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA=="], + + "minipass-fetch": ["minipass-fetch@1.4.1", "", { "dependencies": { "minipass": "^3.1.0", "minipass-sized": "^1.0.3", "minizlib": "^2.0.0" }, "optionalDependencies": { "encoding": "^0.1.12" } }, "sha512-CGH1eblLq26Y15+Azk7ey4xh0J/XfJfrCox5LDJiKqI2Q2iwOLOKrlmIaODiSQS8d18jalF6y2K2ePUm0CmShw=="], + + "minipass-flush": ["minipass-flush@1.0.7", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-TbqTz9cUwWyHS2Dy89P3ocAGUGxKjjLuR9z8w4WUTGAVgEj17/4nhgo2Du56i0Fm3Pm30g4iA8Lcqctc76jCzA=="], + + "minipass-pipeline": ["minipass-pipeline@1.2.4", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A=="], + + "minipass-sized": ["minipass-sized@1.0.3", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g=="], + + "minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "negotiator": ["negotiator@0.6.4", "", {}, "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w=="], + + "node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "node-gyp": ["node-gyp@8.4.1", "", { "dependencies": { "env-paths": "^2.2.0", "glob": "^7.1.4", "graceful-fs": "^4.2.6", "make-fetch-happen": "^9.1.0", "nopt": "^5.0.0", "npmlog": "^6.0.0", "rimraf": "^3.0.2", "semver": "^7.3.5", "tar": "^6.1.2", "which": "^2.0.2" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-olTJRgUtAb/hOXG0E93wZDs5YiJlgbXxTwQAFHyNlRsXQnYzUaF2aGgujZbw+hR8aF4ZG/rST57bWMWD16jr9w=="], + + "nopt": ["nopt@5.0.0", "", { "dependencies": { "abbrev": "1" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ=="], + + "npmlog": ["npmlog@6.0.2", "", { "dependencies": { "are-we-there-yet": "^3.0.0", "console-control-strings": "^1.1.0", "gauge": "^4.0.3", "set-blocking": "^2.0.0" } }, "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "oxfmt": ["oxfmt@0.44.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.44.0", "@oxfmt/binding-android-arm64": "0.44.0", "@oxfmt/binding-darwin-arm64": "0.44.0", "@oxfmt/binding-darwin-x64": "0.44.0", "@oxfmt/binding-freebsd-x64": "0.44.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", "@oxfmt/binding-linux-arm64-gnu": "0.44.0", "@oxfmt/binding-linux-arm64-musl": "0.44.0", "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", "@oxfmt/binding-linux-riscv64-musl": "0.44.0", "@oxfmt/binding-linux-s390x-gnu": "0.44.0", "@oxfmt/binding-linux-x64-gnu": "0.44.0", "@oxfmt/binding-linux-x64-musl": "0.44.0", "@oxfmt/binding-openharmony-arm64": "0.44.0", "@oxfmt/binding-win32-arm64-msvc": "0.44.0", "@oxfmt/binding-win32-ia32-msvc": "0.44.0", "@oxfmt/binding-win32-x64-msvc": "0.44.0" }, "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w=="], "oxlint": ["oxlint@1.59.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.59.0", "@oxlint/binding-android-arm64": "1.59.0", "@oxlint/binding-darwin-arm64": "1.59.0", "@oxlint/binding-darwin-x64": "1.59.0", "@oxlint/binding-freebsd-x64": "1.59.0", "@oxlint/binding-linux-arm-gnueabihf": "1.59.0", "@oxlint/binding-linux-arm-musleabihf": "1.59.0", "@oxlint/binding-linux-arm64-gnu": "1.59.0", "@oxlint/binding-linux-arm64-musl": "1.59.0", "@oxlint/binding-linux-ppc64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-gnu": "1.59.0", "@oxlint/binding-linux-riscv64-musl": "1.59.0", "@oxlint/binding-linux-s390x-gnu": "1.59.0", "@oxlint/binding-linux-x64-gnu": "1.59.0", "@oxlint/binding-linux-x64-musl": "1.59.0", "@oxlint/binding-openharmony-arm64": "1.59.0", "@oxlint/binding-win32-arm64-msvc": "1.59.0", "@oxlint/binding-win32-ia32-msvc": "1.59.0", "@oxlint/binding-win32-x64-msvc": "1.59.0" }, "peerDependencies": { "oxlint-tsgolint": ">=0.18.0" }, "optionalPeers": ["oxlint-tsgolint"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-0xBLeGGjP4vD9pygRo8iuOkOzEU1MqOnfiOl7KYezL/QvWL8NUg6n03zXc7ZVqltiOpUxBk2zgHI3PnRIEdAvw=="], "oxlint-tsgolint": ["oxlint-tsgolint@0.20.0", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "0.20.0", "@oxlint-tsgolint/darwin-x64": "0.20.0", "@oxlint-tsgolint/linux-arm64": "0.20.0", "@oxlint-tsgolint/linux-x64": "0.20.0", "@oxlint-tsgolint/win32-arm64": "0.20.0", "@oxlint-tsgolint/win32-x64": "0.20.0" }, "bin": { "tsgolint": "bin/tsgolint.js" } }, "sha512-/Uc9TQyN1l8w9QNvXtVHYtz+SzDJHKpb5X0UnHodl0BVzijUPk0LPlDOHAvogd1UI+iy9ZSF6gQxEqfzUxCULQ=="], + "p-map": ["p-map@4.0.0", "", { "dependencies": { "aggregate-error": "^3.0.0" } }, "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ=="], + + "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-protocol": ["pg-protocol@1.13.0", "", {}, "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "postgres": ["postgres@3.4.9", "", {}, "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw=="], + + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + + "promise-inflight": ["promise-inflight@1.0.1", "", {}, "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g=="], + + "promise-retry": ["promise-retry@2.0.1", "", { "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" } }, "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rimraf": ["rimraf@3.0.2", "", { "dependencies": { "glob": "^7.1.3" }, "bin": { "rimraf": "bin.js" } }, "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + + "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], + + "socks": ["socks@2.8.7", "", { "dependencies": { "ip-address": "^10.0.1", "smart-buffer": "^4.2.0" } }, "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A=="], + + "socks-proxy-agent": ["socks-proxy-agent@6.2.1", "", { "dependencies": { "agent-base": "^6.0.2", "debug": "^4.3.3", "socks": "^2.6.2" } }, "sha512-a6KW9G+6B3nWZ1yB8G7pJwL3ggLy1uTzKAgCb7ttblwqdz9fMGJUuTy3uFzEP48FAs9FLILlmzDlE2JJhVQaXQ=="], + + "sqlite": ["sqlite@5.1.1", "", {}, "sha512-oBkezXa2hnkfuJwUo44Hl9hS3er+YFtueifoajrgidvqsJRQFpc5fKoAkAor1O5ZnLoa28GBScfHXs8j0K358Q=="], + + "sqlite3": ["sqlite3@5.1.7", "", { "dependencies": { "bindings": "^1.5.0", "node-addon-api": "^7.0.0", "prebuild-install": "^7.1.1", "tar": "^6.1.11" }, "optionalDependencies": { "node-gyp": "8.x" } }, "sha512-GGIyOiFaG+TUra3JIfkI/zGP8yZYLPQ0pl1bH+ODjiX57sPhrLU5sQJn1y9bDKZUFYkX1crlrPfSYt0BKKdkog=="], + + "ssri": ["ssri@8.0.1", "", { "dependencies": { "minipass": "^3.1.1" } }, "sha512-97qShzy1AiyxvPNIkLWoGua7xoQzzPjQ0HAH4B0rWKo7SZ6USuPcrUiAFrws0UH8RrbWmgq3LMTObhPIHbbBeQ=="], + + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + + "tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "tinypool": ["tinypool@2.1.0", "", {}, "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw=="], + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "typescript": ["typescript@6.0.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ=="], "undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="], + + "unique-filename": ["unique-filename@1.1.1", "", { "dependencies": { "unique-slug": "^2.0.0" } }, "sha512-Vmp0jIp2ln35UTXuryvjzkjGdRyf9b2lTXuSYUiPmzRcl3FDtYqAwOnTJkAngD9SWhnoJzDbTKwaOrZ+STtxNQ=="], + + "unique-slug": ["unique-slug@2.0.2", "", { "dependencies": { "imurmurhash": "^0.1.4" } }, "sha512-zoWr9ObaxALD3DOPfjPSqxt4fnZiWblxHIgeWqW8x7UqDzEtHEQLzji2cuJYQFCU6KmoJikOYAZlrTHHebjx2w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wide-align": ["wide-align@1.1.5", "", { "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } }, "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + + "yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + + "cacache/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "cacache/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "make-fetch-happen/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], + + "make-fetch-happen/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-collect/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-fetch/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-flush/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-pipeline/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minipass-sized/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "ssri/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + + "tar-fs/chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], } } diff --git a/package.json b/package.json index d82aec9..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..1cf73e8 --- /dev/null +++ b/src/adapters/memory.ts @@ -0,0 +1,442 @@ +import { LRUCache } from "lru-cache"; + +import type { Adapter, Cursor, InferModel, Schema, Select, SortBy, Where } from "../types"; +import { + assertNoPrimaryKeyUpdates, + getNestedValue, + getPaginationFilter, + getPrimaryKeyFields, + getPrimaryKeyValues, + walkWhere, +} 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]!); + if ( + "field" in where && + primaryKeyFields.length === 1 && + where.field === primaryKeyFields[0] && + where.op === "eq" + ) { + const pkValue = String(where.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); + } + + /** + * Updates the first record matching the criteria. Primary key updates are rejected. + */ + 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); + } + + /** + * Updates all records matching the criteria. Primary key updates are rejected. + */ + 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); + } + + /** + * Performs an atomic insert-or-update. + * + * Conflicts are always handled on the Primary Key. If `where` is provided, the record + * is only updated if the condition is met (acting as a predicate). Primary key + * updates are rejected. + */ + upsert = 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 walkWhere(where, { + and: (children) => children.every(Boolean), + or: (children) => children.some(Boolean), + leaf: (c) => { + const recordVal = getNestedValue(record, c.field, c.path); + switch (c.op) { + case "eq": + return recordVal === c.value; + case "ne": + return recordVal !== c.value; + case "gt": + return compareValues(recordVal, c.value) > 0; + case "gte": + return compareValues(recordVal, c.value) >= 0; + case "lt": + return compareValues(recordVal, c.value) < 0; + case "lte": + return compareValues(recordVal, c.value) <= 0; + case "in": + return c.value.includes(recordVal); + case "not_in": + return !c.value.includes(recordVal); + default: + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- accessing op for error message + throw new Error(`Unsupported operator: ${String((c as Record)["op"])}`); + } + }, + }); + } + + 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.matchesWhere(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..eacf991 --- /dev/null +++ b/src/adapters/postgres.ts @@ -0,0 +1,573 @@ +import { createHash } from "node:crypto"; + +import type { SQL as BunSQL } from "bun"; +import type { Client as PgClient, Pool as PgPool, PoolClient as PgPoolClient } from "pg"; +import type postgres from "postgres"; + +import type { + Adapter, + Field, + InferModel, + Schema, + Select, + SortBy, + Where, + Cursor, + Model, +} from "../types"; +import { + assertNoPrimaryKeyUpdates, + buildPrimaryKeyFilter, + getPrimaryKeyFields, + getPrimaryKeyValues, + mapNumeric, +} from "./utils/common"; +import { + type QueryExecutor, + isQueryExecutor, + Sql, + sql, + raw, + idList, + paramList, + where, + set, + sort, +} from "./utils/sql"; + +type PostgresJsSql = postgres.Sql; +type TransactionSql = postgres.TransactionSql; + +export type PostgresDriver = + | PgClient + | PgPool + | PgPoolClient + | PostgresJsSql + | TransactionSql + | BunSQL; + +// --- Internal PG Syntax Helpers --- + +const ident = (s: string) => raw(`"${s}"`); +const selectCols = (select?: readonly string[]) => (select ? idList(select) : raw("*")); + +function mapFromRecord>( + model: Model, + record: Record, +): T { + const fields = model.fields; + const keys = Object.keys(record); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const field = fields[k]; + if (field?.type === "timestamp" && typeof record[k] === "string") { + record[k] = mapNumeric(record[k]); + } + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return record as T; +} + +function mapToRecord(model: Model, data: Record): 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]; + if (val === undefined) continue; + const field = fields[k]; + // Postgres drivers handle JS objects for jsonb, but json[] often requires stringification + if (field?.type === "json[]" && val !== null) { + res[k] = JSON.stringify(val); + } else { + res[k] = val; + } + } + return res; +} + +function sqlType(field: Field): string { + switch (field.type) { + case "string": + return field.max === undefined ? "TEXT" : `VARCHAR(${field.max})`; + case "number": + return "DOUBLE PRECISION"; + case "boolean": + return "BOOLEAN"; + case "timestamp": + return "BIGINT"; + case "json": + case "json[]": + return "JSONB"; // Postgres stores JSON as binary jsonb for efficiency + default: + return "TEXT"; + } +} + +function toColumnExpr(model: Model, fieldName: string, path?: string[], value?: unknown): Sql { + if (!path || path.length === 0) return ident(fieldName); + const field = model.fields[fieldName]; + if (field?.type !== "json" && field?.type !== "json[]") { + throw new Error(`Cannot use JSON path on non-JSON field: ${fieldName}`); + } + + const isNumeric = typeof value === "number"; + const isBoolean = typeof value === "boolean"; + + let res = sql`jsonb_extract_path_text(${ident(fieldName)}, ${paramList(path)})`; + if (isNumeric) { + res = sql`(${res})::double precision`; + } else if (isBoolean) { + res = sql`(${res})::boolean`; + } + return res; +} + +// --- Driver detection --- + +function isBunSql(driver: PostgresDriver): driver is BunSQL { + return typeof driver === "function" && "unsafe" in driver && "transaction" in driver; +} + +function isPostgresJs(driver: PostgresDriver): driver is PostgresJsSql { + return "unsafe" in driver && "begin" in driver; +} + +function isPg(driver: PostgresDriver): driver is PgClient | PgPool | PgPoolClient { + return "query" in driver; +} + +const isPgPool = (d: PgClient | PgPool | PgPoolClient): d is PgPool => + "connect" in d && !("release" in d); + +// --- Executor factories --- + +function createPostgresJsExecutor( + driver: postgres.Sql | postgres.TransactionSql, + inTransaction = false, +): QueryExecutor { + const runQuery = (query: Sql) => { + const [strings, ...params] = query.toTaggedArgs(); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- calling driver as tagged template function to avoid .unsafe() + const run = driver as ( + s: TemplateStringsArray, + ...p: unknown[] + ) => Promise[]>; + return run(strings, ...params); + }; + + return { + all: (query) => { + return 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 -- postgres.js returns result with .count + const r = rows as unknown as { count?: number }; + return { changes: r.count ?? 0 }; + }, + transaction: (fn: (executor: QueryExecutor) => Promise) => { + // PostgresAdapter.transaction() short-circuits nested calls with `if (this.executor.inTransaction) return fn(this)`. + // This means we only ever enter here when NOT in a transaction, so we always use `begin` and never `savepoint`. + if ("begin" in driver) { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- T matches return type of fn + return driver.begin((tx) => fn(createPostgresJsExecutor(tx, true))) as Promise; + } + throw new Error("Transaction not supported by driver (begin missing)"); + }, + inTransaction, + }; +} + +function createBunSqlExecutor(bunSql: BunSQL, inTransaction = false): QueryExecutor { + const runQuery = (query: Sql) => { + const [strings, ...params] = query.toTaggedArgs(); + return bunSql(strings, ...params) as Promise[]>; + }; + + return { + all: (query) => runQuery(query), + get: async (query) => { + const rows = await runQuery(query); + return rows[0]; + }, + run: async (query) => { + const rows = await runQuery(query); + // 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 as BunSQL, true))), + inTransaction, + }; +} + +function createPgExecutor( + driver: PgClient | PgPool | PgPoolClient, + inTransaction = false, +): QueryExecutor { + function getPrepared(query: Sql) { + // 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; + + const name = `q_${createHash("sha1").update(text).digest("hex").slice(0, 16)}`; + return { name, text, values }; + } + + return { + all: async (q) => { + const res = await driver.query>(getPrepared(q)); + return res.rows; + }, + get: async (q) => { + const res = await driver.query>(getPrepared(q)); + return res.rows[0]; + }, + run: async (q) => { + const res = await driver.query(getPrepared(q)); + return { changes: res.rowCount ?? 0 }; + }, + transaction: async (fn) => { + if (isPgPool(driver)) { + const client = await driver.connect(); + try { + await client.query("BEGIN"); + const res = await fn(createPgExecutor(client, true)); + await client.query("COMMIT"); + return res; + } catch (e) { + await client.query("ROLLBACK"); + throw e; + } finally { + client.release(); + } + } + await driver.query("BEGIN"); + try { + const res = await fn(createPgExecutor(driver, true)); + await driver.query("COMMIT"); + return res; + } catch (e) { + await driver.query("ROLLBACK"); + throw e; + } + }, + inTransaction, + }; +} + +function createPostgresExecutor(driver: PostgresDriver): QueryExecutor { + if (isBunSql(driver)) return createBunSqlExecutor(driver); + if (isPostgresJs(driver)) return createPostgresJsExecutor(driver); + if (isPg(driver)) return createPgExecutor(driver); + throw new Error("Unsupported Postgres driver."); +} + +// --- Adapter --- + +/** + * Postgres Adapter for no-orm. + */ +export class PostgresAdapter implements Adapter { + private executor: QueryExecutor; + + constructor( + private schema: S, + driver: PostgresDriver | QueryExecutor, + ) { + this.executor = isQueryExecutor(driver) ? driver : createPostgresExecutor(driver); + } + + async migrate(): Promise { + const 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 = sqlType(field); + const nullable = field.nullable === true ? "" : " NOT NULL"; + columnParts.push(`"${fieldName}" ${type}${nullable}`); + } + const primaryKeyFields = getPrimaryKeyFields(model); + const pk = `PRIMARY KEY (${primaryKeyFields.map((f) => `"${f}"`).join(", ")})`; + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run(sql` + CREATE TABLE IF NOT EXISTS ${ident(name)} ( + ${raw(columnParts.join(", "))}, + ${raw(pk)} + ) + `); + } + + // 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) => `"${f}"${idx.order ? ` ${idx.order.toUpperCase()}` : ""}`, + ); + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run(sql` + CREATE INDEX IF NOT EXISTS ${ident(`idx_${name}_${j}`)} + ON ${ident(name)} (${raw(formatted.join(", "))}) + `); + } + } + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + if (this.executor.inTransaction) return fn(this); + return this.executor.transaction((exec) => fn(new PostgresAdapter(this.schema, exec))); + } + + async create< + 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 = mapToRecord(model, data); + const fields = Object.keys(input); + const query = sql` + INSERT INTO ${ident(modelName)} (${idList(fields)}) + VALUES (${paramList(fields.map((f) => input[f]))}) + RETURNING ${selectCols(select)} + `; + + const row = await this.executor.get(query); + if (row === undefined || row === null) throw new Error("Failed to insert record"); + return mapFromRecord(model, row); + } + + async find< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where; select?: Select }): Promise { + const { model: modelName, select } = args; + const model = this.schema[modelName]!; + const query = sql` + SELECT ${selectCols(select)} + FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr })} + LIMIT 1 + `; + + const row = await this.executor.get(query); + if (row === undefined || row === null) return null; + return mapFromRecord(model, row); + } + + async findMany< + 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, select, sortBy, limit, offset, cursor } = args; + const model = this.schema[modelName]!; + let query = sql` + SELECT ${selectCols(select)} + FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, cursor, sortBy })} + `; + + if (sortBy && sortBy.length > 0) { + query = sql`${query} ORDER BY ${sort(model, sortBy, toColumnExpr)}`; + } + if (limit !== undefined) { + query = sql`${query} LIMIT ${limit}`; + } + if (offset !== undefined) { + query = sql`${query} OFFSET ${offset}`; + } + const rows = await this.executor.all(query); + + const result: T[] = []; + for (let i = 0; i < rows.length; i++) { + result.push(mapFromRecord(model, rows[i]!)); + } + return result; + } + + /** + * Updates the first record matching the criteria. Primary key updates are rejected. + */ + async update< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; data: Partial; where: Where }): Promise { + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = mapToRecord(model, data); + const fields = Object.keys(input); + + if (fields.length === 0) + return this.find({ model: modelName, where: args.where, select: undefined }); + + const query = sql` + UPDATE ${ident(modelName)} + SET ${set(input)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr })} + RETURNING * + `; + + const row = await this.executor.get(query); + if (row === undefined || row === null) + return this.find({ model: modelName, where: args.where }); + return mapFromRecord(model, row); + } + + /** + * Updates all records matching the criteria. Primary key updates are rejected. + */ + async updateMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where; data: Partial }): Promise { + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = mapToRecord(model, data); + const fields = Object.keys(input); + if (fields.length === 0) return 0; + + const query = sql` + UPDATE ${ident(modelName)} + SET ${set(input)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr })} + `; + + const res = await this.executor.run(query); + return res.changes; + } + + /** + * Performs an atomic insert-or-update. + * + * Conflicts are always handled on the Primary Key. If `where` is provided, the record + * is only updated if the condition is met (acting as a predicate). Primary key + * updates are rejected. + */ + async upsert< + 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: createData, update: updateData, select } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, updateData); + + const insertRow = mapToRecord(model, createData); + const createFields = Object.keys(insertRow); + const updateRow = mapToRecord(model, updateData); + const updateFields = Object.keys(updateRow); + const primaryKeyFields = getPrimaryKeyFields(model); + + const action = + updateFields.length === 0 + ? sql`DO NOTHING` + : args.where + ? sql`DO UPDATE SET ${set(updateRow)} WHERE ${where(args.where, { + model, + columnExpr: toColumnExpr, + })}` + : sql`DO UPDATE SET ${set(updateRow)}`; + + const query = sql` + INSERT INTO ${ident(modelName)} (${idList(createFields)}) + VALUES (${paramList(createFields.map((f) => insertRow[f]))}) + ON CONFLICT (${idList(primaryKeyFields)}) ${action} + RETURNING ${selectCols(select)} + `; + + const row = await this.executor.get(query); + if (row !== undefined && row !== null) { + return mapFromRecord(model, row); + } + + const existing = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, createData)), + select, + }); + if (existing === null) throw new Error("Failed to refetch record after upsert"); + return existing; + } + + async delete< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = sql` + DELETE FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr })} + `; + 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 } = args; + const model = this.schema[modelName]!; + const query = sql` + DELETE FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr })} + `; + 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 } = args; + const model = this.schema[modelName]!; + const query = sql` + SELECT COUNT(*) as count + FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr })} + `; + const row = await this.executor.get(query); + return Number(row?.["count"] ?? 0); + } +} diff --git a/src/adapters/sqlite.test.ts b/src/adapters/sqlite.test.ts new file mode 100644 index 0000000..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..203eac9 --- /dev/null +++ b/src/adapters/sqlite.ts @@ -0,0 +1,558 @@ +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, + getPrimaryKeyFields, + getPrimaryKeyValues, + mapNumeric, +} from "./utils/common"; +import { + type QueryExecutor, + isQueryExecutor, + Sql, + sql, + raw, + idList, + paramList, + where, + set, + sort, +} 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 ident = (s: string) => raw(`"${s}"`); +const selectCols = (select?: readonly string[]) => (select ? idList(select) : raw("*")); + +const mapSqliteValue = (val: unknown, field?: Field) => { + if (field?.type === "boolean" || (field === undefined && typeof val === "boolean")) { + return val === true ? 1 : 0; + } + return val; +}; + +function mapFromRecord>( + model: Model, + record: Record, +): T { + const fields = model.fields; + const keys = Object.keys(record); + for (let i = 0; i < keys.length; i++) { + const k = keys[i]!; + const field = fields[k]; + if (field === undefined || record[k] === null || record[k] === undefined) continue; + + if (field.type === "json" || field.type === "json[]") { + record[k] = typeof record[k] === "string" ? JSON.parse(record[k]) : record[k]; + } else if (field.type === "boolean") { + record[k] = record[k] === 1 || record[k] === true; + } else if (field.type === "number" || field.type === "timestamp") { + record[k] = mapNumeric(record[k]); + } + } + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- mapped fields match the shape of T + return record as T; +} + +function mapToRecord(model: Model, data: Record): 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]; + if (val === undefined) continue; + if (val === null) { + res[k] = null; + continue; + } + const field = fields[k]; + if (field === undefined) { + res[k] = val; + continue; + } + + let processed = val; + if (field.type === "json" || field.type === "json[]") { + processed = JSON.stringify(val); + } else if (field.type === "boolean") { + processed = val === true ? 1 : 0; + } + res[k] = processed; + } + return res; +} + +function sqlType(field: Field): string { + switch (field.type) { + case "string": + return field.max === undefined ? "TEXT" : `VARCHAR(${field.max})`; + case "number": + return "REAL"; + case "boolean": + case "timestamp": + return "INTEGER"; + case "json": + case "json[]": + return "TEXT"; // SQLite stores JSON as plain text + default: + return "TEXT"; + } +} + +function toJsonPath(path: string[]): string { + let jsonPath = "$"; + for (let i = 0; i < path.length; i++) { + const segment = path[i]!; + let isIndex = true; + if (segment.length === 0) isIndex = false; + else { + for (let j = 0; j < segment.length; j++) { + const c = segment.codePointAt(j); + if (c === undefined || c < 48 || c > 57) { + isIndex = false; + break; + } + } + } + if (isIndex) jsonPath += `[${segment}]`; + else jsonPath += `.${segment}`; + } + return jsonPath; +} + +function toColumnExpr(model: Model, fieldName: string, path?: string[]): Sql { + if (!path || path.length === 0) return ident(fieldName); + const field = model.fields[fieldName]; + if (field?.type !== "json" && field?.type !== "json[]") { + throw new Error(`Cannot use JSON path on non-JSON field: ${fieldName}`); + } + return sql`json_extract(${ident(fieldName)}, ${toJsonPath(path)})`; +} + +// --- 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 getPrepared(sqlStr: string): SyncStatement { + let stmt = cache.get(sqlStr); + if (stmt === undefined) { + if (cache.size >= MAX_CACHED_STATEMENTS) { + const first = cache.keys().next(); + if (first.done !== true) cache.delete(first.value); + } + stmt = driver.prepare(sqlStr); + cache.set(sqlStr, stmt); + } + return stmt; + } + + return { + all: (query: Sql) => { + const { strings, params } = query; + const sqlStr = strings.join("?"); + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver result row matches Record shape + return Promise.resolve(getPrepared(sqlStr).all(...params) as Record[]); + }, + get: (query: Sql) => { + const { strings, params } = query; + const sqlStr = strings.join("?"); + return Promise.resolve( + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- driver returns either a row object or undefined + getPrepared(sqlStr).get(...params) as Record | undefined, + ); + }, + run: (query: Sql) => { + const { strings, params } = query; + const sqlStr = strings.join("?"); + const res = getPrepared(sqlStr).run(...params); + return Promise.resolve({ changes: res.changes }); + }, + transaction: async (fn) => { + getPrepared("BEGIN").run(); + try { + const res = await fn(createSyncSqliteExecutor(driver, true)); + getPrepared("COMMIT").run(); + return res; + } catch (e) { + getPrepared("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: Sql) => driver.all(query.strings.join("?"), query.params), + // eslint-disable-next-line typescript-eslint/no-unsafe-return -- async driver returns row + get: (query: Sql) => driver.get(query.strings.join("?"), query.params), + run: async (query: Sql) => { + 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 -- bridges narrowed driver type to internal structural SyncDriver shape + if (isSyncSqlite(driver)) return createSyncSqliteExecutor(driver as SyncDriver); + return createAsyncSqliteExecutor(driver); +} + +// --- Adapter --- + +/** + * SQLite Adapter for no-orm. + */ +export class SqliteAdapter implements Adapter { + private executor: QueryExecutor; + + constructor( + private schema: S, + driver: SqliteDriver | QueryExecutor, + ) { + this.executor = isQueryExecutor(driver) ? driver : createSqliteExecutor(driver); + } + + 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]) => `"${fname}" ${sqlType(f)}${f.nullable === true ? "" : " NOT NULL"}`, + ); + const primaryKeyFields = getPrimaryKeyFields(model); + const pk = `PRIMARY KEY (${primaryKeyFields.map((f) => `"${f}"`).join(", ")})`; + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run(sql` + CREATE TABLE IF NOT EXISTS ${ident(name)} ( + ${raw(columns.join(", "))}, + ${raw(pk)} + ) + `); + } + + // 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) => `"${f}"${idx.order ? ` ${idx.order.toUpperCase()}` : ""}`, + ); + // eslint-disable-next-line no-await-in-loop -- DDL is intentionally sequential + await this.executor.run(sql` + CREATE INDEX IF NOT EXISTS ${ident(`idx_${name}_${j}`)} + ON ${ident(name)} (${raw(formatted.join(", "))}) + `); + } + } + } + + transaction(fn: (tx: Adapter) => Promise): Promise { + if (this.executor.inTransaction) return fn(this); + return this.executor.transaction((exec) => fn(new SqliteAdapter(this.schema, exec))); + } + + async create< + 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 = mapToRecord(model, data); + const fields = Object.keys(input); + const query = sql` + INSERT INTO ${ident(modelName)} (${idList(fields)}) + VALUES (${paramList(fields.map((f) => input[f]))}) + RETURNING ${selectCols(select)} + `; + + const row = await this.executor.get(query); + if (row === undefined || row === null) { + const res = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, data)), + select, + }); + if (!res) throw new Error("Failed to insert record"); + return res; + } + return mapFromRecord(model, row); + } + + async find< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where; select?: Select }): Promise { + const { model: modelName, select } = args; + const model = this.schema[modelName]!; + const query = sql` + SELECT ${selectCols(select)} + FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue })} + LIMIT 1 + `; + + const row = await this.executor.get(query); + if (row === undefined || row === null) return null; + return mapFromRecord(model, row); + } + + async findMany< + 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, select, sortBy, limit, offset, cursor } = args; + const model = this.schema[modelName]!; + let query = sql` + SELECT ${selectCols(select)} + FROM ${ident(modelName)} + WHERE ${where(args.where, { + model, + columnExpr: toColumnExpr, + mapValue: mapSqliteValue, + cursor, + sortBy, + })} + `; + + if (sortBy && sortBy.length > 0) { + query = sql`${query} ORDER BY ${sort(model, sortBy, toColumnExpr)}`; + } + if (limit !== undefined) { + query = sql`${query} LIMIT ${limit}`; + } + if (offset !== undefined) { + query = sql`${query} OFFSET ${offset}`; + } + const rows = await this.executor.all(query); + + const result: T[] = []; + for (let i = 0; i < rows.length; i++) { + result.push(mapFromRecord(model, rows[i]!)); + } + return result; + } + + /** + * Updates the first record matching the criteria. Primary key updates are rejected. + */ + async update< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; data: Partial; where: Where }): Promise { + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = mapToRecord(model, data); + const fields = Object.keys(input); + + if (fields.length === 0) + return this.find({ model: modelName, where: args.where, select: undefined }); + + const query = sql` + UPDATE ${ident(modelName)} + SET ${set(input)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue })} + RETURNING * + `; + + const row = await this.executor.get(query); + if (row === undefined || row === null) + return this.find({ model: modelName, where: args.where }); + return mapFromRecord(model, row); + } + + /** + * Updates all records matching the criteria. Primary key updates are rejected. + */ + async updateMany< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where?: Where; data: Partial }): Promise { + const { model: modelName, data } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, data); + const input = mapToRecord(model, data); + const fields = Object.keys(input); + if (fields.length === 0) return 0; + + const query = sql` + UPDATE ${ident(modelName)} + SET ${set(input)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue })} + `; + + const res = await this.executor.run(query); + return res.changes; + } + + /** + * Performs an atomic insert-or-update. + * + * Conflicts are always handled on the Primary Key. If `where` is provided, the record + * is only updated if the condition is met (acting as a predicate). Primary key + * updates are rejected. + */ + async upsert< + 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: createData, update: updateData, select } = args; + const model = this.schema[modelName]!; + assertNoPrimaryKeyUpdates(model, updateData); + + const insertRow = mapToRecord(model, createData); + const createFields = Object.keys(insertRow); + const updateRow = mapToRecord(model, updateData); + const updateFields = Object.keys(updateRow); + const primaryKeyFields = getPrimaryKeyFields(model); + + const action = + updateFields.length === 0 + ? sql`DO NOTHING` + : args.where + ? sql`DO UPDATE SET ${set(updateRow)} WHERE ${where(args.where, { + model, + columnExpr: toColumnExpr, + mapValue: mapSqliteValue, + })}` + : sql`DO UPDATE SET ${set(updateRow)}`; + + const query = sql` + INSERT INTO ${ident(modelName)} (${idList(createFields)}) + VALUES (${paramList(createFields.map((f) => insertRow[f]))}) + ON CONFLICT (${idList(primaryKeyFields)}) ${action} + RETURNING ${selectCols(select)} + `; + + const row = await this.executor.get(query); + if (row !== undefined && row !== null) { + return mapFromRecord(model, row); + } + + const existing = await this.find({ + model: modelName, + where: buildPrimaryKeyFilter(model, getPrimaryKeyValues(model, createData)), + select, + }); + if (existing === null) throw new Error("Failed to refetch record after upsert"); + return existing; + } + + async delete< + K extends keyof S & string, + T extends Record = InferModel, + >(args: { model: K; where: Where }): Promise { + const { model: modelName } = args; + const model = this.schema[modelName]!; + const query = sql` + DELETE FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue })} + `; + 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 } = args; + const model = this.schema[modelName]!; + const query = sql` + DELETE FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue })} + `; + 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 } = args; + const model = this.schema[modelName]!; + const query = sql` + SELECT COUNT(*) as count + FROM ${ident(modelName)} + WHERE ${where(args.where, { model, columnExpr: toColumnExpr, mapValue: mapSqliteValue })} + `; + const row = await this.executor.get(query); + return Number(row?.["count"] ?? 0); + } +} diff --git a/src/adapters/utils/common.ts b/src/adapters/utils/common.ts new file mode 100644 index 0000000..cb317c8 --- /dev/null +++ b/src/adapters/utils/common.ts @@ -0,0 +1,195 @@ +import type { Cursor, FieldName, Model, SortBy, Where } from "../../types"; + +export type WhereLeaf> = Extract, { field: unknown }>; + +export interface WhereVisitor> { + leaf: (node: WhereLeaf) => R; + and: (children: R[]) => R; + or: (children: R[]) => R; +} + +/** + * Iterative fold over a Where AST. The traversal is backend-agnostic; + * adapters supply per-node callbacks that produce their own result type + * (Sql fragment, boolean, MongoDB filter object, etc.). + */ +export function walkWhere>( + clause: Where, + visitor: WhereVisitor, +): R { + const stack: { clause: Where; processed: boolean }[] = [{ clause, processed: false }]; + const results: R[] = []; + + while (stack.length > 0) { + const item = stack.pop()!; + const c = item.clause; + + if ("and" in c || "or" in c) { + const children = "and" in c ? c.and : c.or; + + if (item.processed) { + const parts: R[] = []; + for (let i = 0; i < children.length; i++) parts.push(results.pop()!); + parts.reverse(); + results.push("and" in c ? visitor.and(parts) : visitor.or(parts)); + } else { + stack.push({ clause: c, processed: true }); + for (let i = children.length - 1; i >= 0; i--) { + stack.push({ clause: children[i]!, processed: false }); + } + } + continue; + } + + results.push(visitor.leaf(c)); + } + + return results[0]!; +} + +// --- Schema & Logic Helpers --- + +export function 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..e676a79 --- /dev/null +++ b/src/adapters/utils/sql.ts @@ -0,0 +1,225 @@ +import type { Cursor, Field, Model, SortBy, Where } from "../../types"; +import { getPaginationFilter, walkWhere } from "./common"; + +/** + * A Sql instance keeps SQL logic and dynamic data separate to prevent injection. + * It is structured to be compatible with TemplateStringsArray for safe driver calls. + */ +export class Sql { + constructor( + readonly strings: string[], + readonly params: unknown[], + ) {} + + /** + * Augments the strings array with a .raw property for drivers that expect TemplateStringsArray. + * This is required for safe driver calls that use tagged templates (e.g. postgres.js, Bun SQL). + */ + toTaggedArgs(): [TemplateStringsArray, ...unknown[]] { + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- augmenting array to satisfy TemplateStringsArray contract + const strings = this.strings as string[] & { raw: readonly string[] }; + // eslint-disable-next-line typescript-eslint/no-unnecessary-condition -- raw may be missing if not already augmented + strings.raw ??= this.strings; + return [strings as TemplateStringsArray, ...this.params]; + } +} + +/** + * Raw text to be included directly in SQL without parameterization. + * Returns a Sql instance with no parameters. + */ +export const raw = (s: string) => new Sql([s], []); + +/** + * Tagged template literal for building SQL fragments safely. + * Nesting Sql instances is supported. + */ +export function sql(strings: TemplateStringsArray, ...values: unknown[]): Sql { + const outStrings = [strings[0]!]; + const outParams: unknown[] = []; + + for (let i = 0; i < values.length; i++) { + const v = values[i]; + const tail = strings[i + 1]!; + + if (v instanceof Sql) { + outStrings[outStrings.length - 1] += v.strings[0]!; + for (let j = 1; j < v.strings.length; j++) { + outStrings.push(v.strings[j]!); + } + for (let j = 0; j < v.params.length; j++) { + outParams.push(v.params[j]); + } + outStrings[outStrings.length - 1] += tail; + } else { + outParams.push(v); + outStrings.push(tail); + } + } + return new Sql(outStrings, outParams); +} + +/** + * Joins multiple identifiers with a separator. + */ +export function idList(names: readonly string[], quoteChar: string = '"'): Sql { + return new Sql([names.map((n) => `${quoteChar}${n}${quoteChar}`).join(", ")], []); +} + +/** + * Generates a comma-separated list of placeholders for values. + */ +export function paramList(values: unknown[]): Sql { + if (values.length === 0) return new Sql([""], []); + const strings: string[] = [""]; + for (let i = 1; i < values.length; i++) { + strings.push(", "); + } + strings.push(""); + return new Sql(strings, values); +} + +/** Shared contracts for SQL executors */ +export interface QueryExecutor { + all(query: Sql): Promise[]>; + get(query: Sql): Promise | undefined | null>; + run(query: Sql): Promise<{ changes: number }>; + transaction(fn: (executor: QueryExecutor) => Promise): Promise; + readonly inTransaction: boolean; +} + +export function isQueryExecutor(obj: unknown): obj is QueryExecutor { + if (typeof obj !== "object" || obj === null) return false; + return ( + "all" in obj && + "run" in obj && + typeof (obj as Record)["all"] === "function" && + typeof (obj as Record)["run"] === "function" + ); +} + +/** + * Concatenates multiple Sql fragments with a separator. + */ +export function join(fragments: Sql[], separator: string): Sql { + if (fragments.length === 0) return new Sql([""], []); + + const strings = fragments[0]!.strings.slice(); + const params = fragments[0]!.params.slice(); + + 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 new Sql(strings, params); +} + +export type ColumnExprFn = ( + model: Model, + fieldName: string, + path?: string[], + value?: unknown, +) => Sql; +export type MapValueFn = (val: unknown, field?: Field) => unknown; + +export interface WhereOptions { + model: Model; + columnExpr: ColumnExprFn; + mapValue?: MapValueFn; +} + +function buildWhere(clause: Where, options: WhereOptions): Sql { + return walkWhere(clause, { + and: (children) => join(children.map((c) => sql`(${c})`), " AND "), + or: (children) => join(children.map((c) => sql`(${c})`), " OR "), + leaf: (c) => { + const expr = options.columnExpr(options.model, c.field as string, c.path, c.value); + const field = options.model.fields[c.field as string]; + const mapped = options.mapValue ? options.mapValue(c.value, field) : c.value; + + switch (c.op) { + case "eq": + return c.value === null ? sql`${expr} IS NULL` : sql`${expr} = ${mapped}`; + case "ne": + return c.value === null ? sql`${expr} IS NOT NULL` : sql`${expr} != ${mapped}`; + case "gt": + return sql`${expr} > ${mapped}`; + case "gte": + return sql`${expr} >= ${mapped}`; + case "lt": + return sql`${expr} < ${mapped}`; + case "lte": + return sql`${expr} <= ${mapped}`; + case "in": { + if (c.value.length === 0) return sql`1=0`; + const params = options.mapValue ? c.value.map((v) => options.mapValue!(v, field)) : c.value; + return sql`${expr} IN (${paramList(params)})`; + } + case "not_in": { + if (c.value.length === 0) return sql`1=1`; + const params = options.mapValue ? c.value.map((v) => options.mapValue!(v, field)) : c.value; + return sql`${expr} NOT IN (${paramList(params)})`; + } + default: + // eslint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- accessing op for error message + throw new Error(`Unsupported operator: ${String((c as Record)["op"])}`); + } + }, + }); +} + +export function where( + clause: Where | undefined, + options: WhereOptions & { cursor?: Cursor; sortBy?: SortBy[] }, +): Sql { + const parts: Sql[] = []; + + if (clause) { + parts.push(sql`(${buildWhere(clause, options)})`); + } + + if (options.cursor) { + const paginationWhere = getPaginationFilter(options.cursor, options.sortBy); + if (paginationWhere) { + parts.push(sql`(${buildWhere(paginationWhere, options)})`); + } + } + + return parts.length > 0 ? join(parts, " AND ") : sql`1=1`; +} + +/** + * Prepares a SET clause for UPDATE or UPSERT. + */ +export function set(data: Record, quoteChar: string = '"'): Sql { + const fields = Object.keys(data); + if (fields.length === 0) throw new Error("set() called with empty data"); + const parts: Sql[] = []; + for (let i = 0; i < fields.length; i++) { + const f = fields[i]!; + parts.push(sql`${raw(`${quoteChar}${f}${quoteChar}`)} = ${data[f]}`); + } + return join(parts, ", "); +} + +/** + * Prepares an ORDER BY clause. + */ +export function sort(model: Model, sortBy: SortBy[], columnExpr: ColumnExprFn): Sql { + if (sortBy.length === 0) throw new Error("sort() called with empty sortBy"); + const parts: Sql[] = []; + for (let i = 0; i < sortBy.length; i++) { + const s = sortBy[i]!; + const expr = columnExpr(model, s.field as string, s.path); + const dir = (s.direction ?? "asc").toUpperCase(); + parts.push(sql`${expr} ${raw(dir)}`); + } + return join(parts, ", "); +} 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",