From 09eba4f95d011e20680976e0de26dfa79169072d Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 16:14:33 -0400 Subject: [PATCH 1/3] feat: contract ORM API and recover failed migration cleanup --- .github/workflows/ci.yml | 17 +- CHANGELOG.md | 38 ++ README.md | 17 +- benchmarks/runtime.mjs | 13 +- docs/0.5.0-api.md | 186 +++++ docs/0.5.0-hardening.md | 112 +++ docs/adapters.md | 7 +- docs/migrations.md | 8 + package-lock.json | 492 +++++++------- package.json | 14 +- src/adapter.ts | 2 + src/cleanup-recovery.test.ts | 88 +++ src/client.test.ts | 3 +- src/errors.test.ts | 3 +- src/index.ts | 66 +- src/integration.test.ts | 114 +++- src/migration-recovery.test.ts | 116 ++++ src/migrations.ts | 13 +- src/postgres.test.ts | 55 +- src/postgres.ts | 45 +- src/schema.test.ts | 2 +- src/sql.test.ts | 37 +- src/sql.ts | 13 +- src/sqlite.test.ts | 24 +- src/sqlite.ts | 46 +- src/tooling-impl.ts | 1091 +++++++++++++++++++++++++++++ src/tooling.test.ts | 2 +- src/tooling.ts | 1092 +----------------------------- src/value-conformance.fixture.ts | 70 ++ src/value-conformance.test.ts | 12 + tests/packed-contract.mjs | 190 ++++++ tests/public-contract.json | 167 +++++ tests/types/contracts.ts | 13 +- vite.config.ts | 8 + 34 files changed, 2714 insertions(+), 1462 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/0.5.0-api.md create mode 100644 docs/0.5.0-hardening.md create mode 100644 src/cleanup-recovery.test.ts create mode 100644 src/migration-recovery.test.ts create mode 100644 src/tooling-impl.ts create mode 100644 src/value-conformance.fixture.ts create mode 100644 src/value-conformance.test.ts create mode 100644 tests/packed-contract.mjs create mode 100644 tests/public-contract.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 659c22a..3120ea0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,11 @@ permissions: jobs: package: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] steps: - name: Checkout repository uses: actions/checkout@v7 @@ -24,9 +28,20 @@ jobs: - name: Install dependencies run: npm ci + - name: Format check + run: npm run fmt -- --check + - name: Run package gate run: npm run check + - name: Upload implementation coverage + if: always() + uses: actions/upload-artifact@v7 + with: + name: orm-coverage-${{ matrix.os }} + path: coverage + if-no-files-found: warn + postgres-contract: name: PostgreSQL ${{ matrix.postgres }} runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..76eb8bc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +## Unreleased + +### Breaking + +- Curate the root to 68 names and the tooling subpath to its single runDatabaseCli + bridge. Remove 54 named exports and sql.key. Replace direct client construction + with defineDatabase/open; use sql.identifier/literal/unsafe and defineQuery for + their canonical contracts. Every removed name has migration guidance in + docs/0.5.0-api.md. Retain all five export-map keys and optional PostgreSQL peers. + +### Added + +- Optional DatabaseAdapter.executeScript for complete migration scripts; custom + drivers without it keep the existing execute fallback. PostgreSQL and SQLite + implementations execute driver-owned scripts without splitting semicolons. +- Exact packed runtime/declaration checks, removed-import and private-path checks, + optional-peer isolation/minimum installs, and real-adapter value parity probes. + +### Fixed + +- SQLite migration execution no longer drops all statements after the first. +- PostgreSQL migration scripts no longer fail when the driver returns multiple + result sets. +- Failed SQLite rollback closes and quarantines the adapter, rolls back remaining + writes and releases shared-file ownership before a new connection recovers. +- PostgreSQL failed rollback/advisory unlock discards poisoned pooled connections + while preserving the primary operation error; failed statement deallocation + still releases and discards the owned client. +- Update compatible development tooling to audited versions and add TypeScript 6 compiler-API audits alongside the TypeScript 7 gate and pin + the matching coverage provider. + +### Remaining release gate + +- PostgreSQL shadow reset/introspection/query description are incomplete in the + advertised generation/validation workflow (#55). No ORM 0.5.0 readiness or + publication claim is made until that real workflow is qualified. diff --git a/README.md b/README.md index eedd03a..d52147c 100644 --- a/README.md +++ b/README.md @@ -73,7 +73,10 @@ their scalar value. Writes return status by default. Ordinary promises are the non-atomic coordination mechanism; use `db.transaction(...)` when operations must be atomic. Nested transactions use savepoints, and a transaction client throws after its callback completes. If rollback cleanup itself fails, the -original callback error remains the error surfaced to the caller. +original callback error remains the error surfaced to the caller. Failed +PostgreSQL cleanup discards the pooled connection; failed SQLite rollback +quarantines the adapter and closes its database. If physical close fails, explicit +close retries cleanup. Open a new SQLite adapter before retrying application work. `escapeLikePattern()` escapes `\\`, `%`, and `_` for literal-text searches. `like()` and `ilike()` bind the pattern and emit the matching `ESCAPE '\\'` @@ -95,6 +98,11 @@ askr database migration plan askr database migration apply --yes ``` +The PostgreSQL generated-artifact workflow is a 0.5.0 release blocker tracked in +[#55](https://github.com/askrjs/askr-orm/issues/55): the shipped shadow reset, +introspection and description methods are incomplete. Ordinary runtime access +and application of an already bundled migration manifest are covered separately. + Generation replays checksummed, forward-only SQL against the shadow database before accepting it. It writes migration SQL plus one committed `database/generated.ts` artifact containing schema identity, migration @@ -109,3 +117,10 @@ SQLite uses Node's synchronous `node:sqlite` API behind a re-entrant async connection queue. Transactions and streams hold the connection; cancellation is checked between streamed rows, but a synchronous statement already running cannot be interrupted. + +## 0.5.0 review + +The [API decisions](docs/0.5.0-api.md) record every retained and removed name and +its migration. The [hardening report](docs/0.5.0-hardening.md) distinguishes +regression fixes, executed characterization and the remaining release blocker. +The package version remains 0.4.0 until the coordinated candidate is prepared. diff --git a/benchmarks/runtime.mjs b/benchmarks/runtime.mjs index b7b42ac..21aceca 100644 --- a/benchmarks/runtime.mjs +++ b/benchmarks/runtime.mjs @@ -1,5 +1,5 @@ import { performance } from "node:perf_hooks"; -import { createDatabaseClient, eq, table, text, uuid } from "../dist/index.js"; +import { defineDatabase, eq, table, text, uuid } from "../dist/index.js"; const gate = process.argv.includes("--gate"); const iterations = 20_000; @@ -23,7 +23,16 @@ const users = table("users", { email: text().notNull(), }); const adapter = new BenchmarkAdapter(); -const db = createDatabaseClient({ users }, adapter); +const db = await defineDatabase({ + tables: { users }, + driver: { + dialect: "postgres", + open: async () => adapter, + shadow: async () => { + throw new Error("Benchmark does not use tooling."); + }, + }, +}).open(); async function measure(callback) { const started = performance.now(); diff --git a/docs/0.5.0-api.md b/docs/0.5.0-api.md new file mode 100644 index 0000000..6c81558 --- /dev/null +++ b/docs/0.5.0-api.md @@ -0,0 +1,186 @@ +# 0.5.0 ORM API decisions + +Status: implementation candidate; API issue #53 remains open for the complete 0.5.0 packed-package and generated website review. PostgreSQL generation/validation also remains blocked by [#55](https://github.com/askrjs/askr-orm/issues/55). + +The published 0.4.0 declarations expose 132 entrypoint/name pairs. The curated contract exposes 78: 68 root names, one CLI bridge, seven PostgreSQL names and two SQLite names. There are 54 removed named exports, plus the removed `sql.key` member. No compatibility shims are provided. + +## Export-map decisions + +| Key | Decision | Supported owner | +| ---------------- | -------- | --------------------------------------------------------------------------------------------- | +| `.` | KEEP | Application schema, SQL expressions, database factory and explicit adapter/type contracts. | +| `./postgres` | KEEP | PostgreSQL driver and dialect-specific column declarations; optional peers remain lazy. | +| `./sqlite` | KEEP | Node SQLite driver; root/SQLite imports work without PostgreSQL peers. | +| `./tooling` | KEEP | Single runDatabaseCli bridge used by askr-cli, with implementation moved to a private module. | +| `./package.json` | KEEP | askr-cli resolves the declared tooling import via package metadata. | + +The runtime and declaration contract is frozen in `tests/public-contract.json`. Private source modules and dist paths are denied by the export map. Type-only ColumnBuilder remains usable as an annotation and cannot be constructed through the root. + +## Every named export + +| Entry | Name | Decision | Consumer need or migration | +| ------------ | ---------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `.` | `AnyTable` | KEEP | Constraint for reusable typed table helpers, paired with InferRow. | +| `.` | `Codec` | KEEP | Documented mapWith converter with application/database encode and decode types. | +| `.` | `ColumnBuilder` | KEEP | Type-only annotation for reusable column factories; constructor stays private. | +| `.` | `DatabaseAdapter` | KEEP | Custom-driver parameter binding, transaction, session and script execution boundary. | +| `.` | `DatabaseClient` | KEEP | Annotate an application database client or transaction helper. | +| `.` | `DatabaseDefinition` | KEEP | Export an annotated application database definition. | +| `.` | `DatabaseDriver` | KEEP | Custom driver supplied to defineDatabase; owns runtime and isolated tooling adapters. | +| `.` | `DatabaseError` | KEEP | Applications inspect normalized category and original cause. | +| `.` | `DatabaseErrorCategory` | KEEP | Typed application error handling for constraints, cancellation, timeout and connection failures. | +| `.` | `DatabaseOpenOptions` | KEEP | Typed telemetry configuration for database.open(). | +| `.` | `DatabaseToolingAdapter` | KEEP | Custom driver implements its isolated reset/replay/introspection/description boundary. | +| `.` | `DialectName` | KEEP | Custom driver chooses PostgreSQL or SQLite explicitly. | +| `.` | `ExecutionResult` | KEEP | Custom execute() returns rows and rowCount. | +| `.` | `Expression` | KEEP | Typed reusable predicates accept column references or SQL fragments. | +| `.` | `GeneratedDatabaseArtifact` | KEEP | Generated database/generated.ts imports this exact name in a satisfies clause. | +| `.` | `InferInsert` | KEEP | README insert payload contract accounts for nullability/defaults. | +| `.` | `InferKey` | KEEP | README scalar/composite-key payload contract. | +| `.` | `InferPatch` | KEEP | README patch payload contract excludes primary keys. | +| `.` | `InferRow` | KEEP | README readonly row contract for typed reads. | +| `.` | `MigrationApplyOptions` | KEEP | Typed signal, timeout and onEvent policy for programmatic apply(). | +| `.` | `MigrationEvent` | KEEP | Typed start/applied event callbacks for migration progress. | +| `.` | `MigrationManifest` | KEEP | Typed generated/hand-authored manifest passed to defineDatabase. | +| `.` | `MigrationsApi` | KEEP | Annotate programmatic plan/apply/resolve operations. | +| `.` | `QueryOptions` | KEEP | Typed signal, timeout and prepared-name options for execution. | +| `.` | `RegisteredQuery` | KEEP | Annotate named query declarations without exposing a second query executor. | +| `.` | `SqlFragment` | KEEP | Typed reusable SQL fragment/predicate helpers. | +| `.` | `SqlQuery` | KEEP | Custom adapter receives parameterized text and values. | +| `.` | `TelemetryEvent` | KEEP | Typed observational onEvent callback payload. | +| `.` | `TelemetryOptions` | KEEP | Typed SQL opt-in and telemetry sink configuration. | +| `.` | `TransactionOptions` | KEEP | Caller-owned isolation, read-only and cancellation settings. | +| `.` | `WriteResult` | KEEP | Annotate the status returned by insert/update/delete without returning rows. | +| `.` | `and` | KEEP | Conjoin typed predicates without text concatenation. | +| `.` | `bigInt` | KEEP | Explicit bigint declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `boolean` | KEEP | Explicit boolean declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `bytes` | KEEP | Explicit portable binary declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `check` | KEEP | Explicit table CHECK constraint declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `columnRef` | KEEP | Explicit column expression outside a query callback, including HAVING expressions. | +| `.` | `compileSql` | KEEP | Compile an application SQL fragment to adapter text and ordered values. | +| `.` | `date` | KEEP | Explicit date declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `defineDatabase` | KEEP | README application factory; validates dialect and owns open, queries and migrations. | +| `.` | `defineQuery` | KEEP | README named query declaration and generated query metadata owner. | +| `.` | `doublePrecision` | KEEP | Explicit double precision declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `eq` | KEEP | Equality predicate in the installed typed join fixture. | +| `.` | `escapeLikePattern` | KEEP | README literal-text search escapes wildcard characters before binding. | +| `.` | `gt` | KEEP | Bound strict greater-than predicate. | +| `.` | `gte` | KEEP | Bound inclusive greater-than predicate. | +| `.` | `ilike` | KEEP | README PostgreSQL-only escaped, bound case-insensitive pattern predicate. | +| `.` | `inArray` | KEEP | Bound membership predicate with an explicit empty-list boundary. | +| `.` | `index` | KEEP | Explicit table index declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `integer` | KEEP | Explicit integer declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `isNotNull` | KEEP | Explicit SQL NOT NULL predicate. | +| `.` | `isNull` | KEEP | Explicit SQL NULL predicate. | +| `.` | `json` | KEEP | Explicit JSON declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `like` | KEEP | README escaped, bound text pattern predicate. | +| `.` | `lt` | KEEP | Bound strict less-than predicate. | +| `.` | `lte` | KEEP | Bound inclusive less-than predicate. | +| `.` | `ne` | KEEP | Bound inequality predicate. | +| `.` | `not` | KEEP | Negate one typed predicate. | +| `.` | `numeric` | KEEP | Explicit exact numeric string declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `or` | KEEP | Disjoin typed predicates without text concatenation. | +| `.` | `real` | KEEP | Explicit real declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `sql` | KEEP | Canonical parameterized tag and identifier/literal/unsafe boundaries. | +| `.` | `table` | KEEP | Explicit table and inferred CRUD payload declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `text` | KEEP | Explicit text declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `timestamp` | KEEP | Explicit timestamp without time zone declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `unique` | KEEP | Explicit table UNIQUE constraint declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `uuid` | KEEP | Explicit UUID declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `view` | KEEP | Explicit read-only view declaration in the supported SQL-shaped schema DSL; this is the single portable factory path. | +| `.` | `AnyColumn` | REMOVE | Infer concrete column factories; for a reusable factory annotate with the supported ColumnBuilder type. | +| `.` | `AppliedMigration` | REMOVE | Use Awaited>["applied"][number]. | +| `.` | `BundledMigration` | REMOVE | Use MigrationManifest["migrations"][number]. | +| `.` | `CheckConstraint` | REMOVE | Use ReturnType. | +| `.` | `CleanDatabaseOptions` | REMOVE | Use Parameters[0], or infer the argument object. | +| `.` | `ColumnAst` | REMOVE | Read metadata on the declared column only when necessary; do not construct internal AST nodes. | +| `.` | `ColumnRef` | REMOVE | Infer a callback ref or ReturnType; use Expression for generic predicates. | +| `.` | `ColumnValue` | REMOVE | Use InferRow["column"] for the application value. | +| `.` | `EnumDefinition` | REMOVE | Use typeof the declared postgresEnum result. | +| `.` | `IndexDefinition` | REMOVE | Use ReturnType. | +| `.` | `JoinTarget` | REMOVE | Pass a database table client to join; infer its type from typeof db.tableName. | +| `.` | `JoinedQuery` | REMOVE | Infer the result of the typed join/on chain. | +| `.` | `KeyedSql` | REMOVE | Use RegisteredQuery or typeof the defineQuery result. | +| `.` | `MigrationApplyResult` | REMOVE | Use Awaited>. | +| `.` | `MigrationPlan` | REMOVE | Use Awaited>. | +| `.` | `MigrationPlanEntry` | REMOVE | Use Awaited>["pending"][number]. | +| `.` | `PreparedQuery` | REMOVE | Infer the result of query.prepare(). | +| `.` | `References` | REMOVE | Infer callback refs from the declared query. | +| `.` | `RegisteredQueryFunction` | REMOVE | Use typeof db.queries.name. | +| `.` | `ReturningRow` | REMOVE | Use the inline { returning: "row" } option. | +| `.` | `ReturningRows` | REMOVE | Use the inline { returning: "rows" } option. | +| `.` | `ReturningStatus` | REMOVE | Use the inline/default { returning: "status" } option. | +| `.` | `SelectQuery` | REMOVE | Infer the table query chain; do not construct its private runtime owner. | +| `.` | `Selection` | REMOVE | Infer the select callback result. | +| `.` | `SelectionResult` | REMOVE | Infer the row from Awaited>[number]. | +| `.` | `TableClient` | REMOVE | Use the table client returned by database.open(); annotate its type with typeof db.tableName. | +| `.` | `TableConstraint` | REMOVE | Infer the supported check/unique/index factory result. | +| `.` | `TableDefinition` | REMOVE | Use typeof the declared table; use AnyTable for a generic table constraint. | +| `.` | `TableOptions` | REMOVE | Infer the table() options object. | +| `.` | `TableRefs` | REMOVE | Infer callback refs from the declared query; no duplicate table-ref annotation. | +| `.` | `UniqueConstraint` | REMOVE | Use ReturnType. | +| `.` | `UnsafeSql` | REMOVE | Use ReturnType. | +| `.` | `ViewDefinition` | REMOVE | Use typeof the declared view result. | +| `.` | `assertSafeIdentifier` | REMOVE | Let schema declarations and sql.identifier validate their boundary; no standalone validation import. | +| `.` | `compileKeyedSql` | REMOVE | Use defineQuery(key) with parameter-name substitutions and query.compile(params). | +| `.` | `createDatabaseClient` | REMOVE | Use defineDatabase({ driver, tables, ... }).open(); adapters remain explicit via DatabaseDriver.open(). | +| `.` | `executeKeyedSql` | REMOVE | Register defineQuery in defineDatabase({ queries }) and call db.queries.name(params). | +| `.` | `identifier` | REMOVE | Use sql.identifier(name). | +| `.` | `joinFragments` | REMOVE | Compose application clauses with sql fragments and supported and/or/inArray predicates. | +| `.` | `literal` | REMOVE | Use sql.literal(value). | +| `.` | `normalizeDatabaseError` | REMOVE | Catch DatabaseError from client/query/migration operations; raw custom drivers own their own error conversion. | +| `.` | `quoteIdentifier` | REMOVE | Use sql.identifier(name) and compileSql for dynamic identifiers. | +| `.` | `tableRefs` | REMOVE | Use the typed refs supplied to where/select/join/on callbacks, or explicit columnRef for standalone SQL. | +| `.` | `toSnakeCase` | REMOVE | Use schema naming defaults or .name(sqlName); application text conversion belongs to the application. | +| `.` | `unsafeSql` | REMOVE | Use sql.unsafe(text). | +| `./tooling` | `runDatabaseCli` | KEEP | askr-cli dynamically resolves the tooling export and calls only this bridge; ORM owns database schema/migration semantics. | +| `./tooling` | `DatabaseCliIo` | REMOVE | Infer the bridge argument or use NonNullable[1]> and its io property. | +| `./tooling` | `RunDatabaseCliOptions` | REMOVE | Infer the bridge argument or use NonNullable[1]> and its io property. | +| `./tooling` | `SchemaSnapshot` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./tooling` | `SnapshotColumn` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./tooling` | `SnapshotTable` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./tooling` | `diffSnapshots` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./tooling` | `hasDatabaseEntry` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./tooling` | `snapshotDefinition` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./tooling` | `validateDiscoveredDatabase` | REMOVE | Use the askr database CLI workflow; snapshots, diffs and discovery are private implementation contracts. | +| `./postgres` | `PostgresOptions` | KEEP | Typed lazy target/shadow URLs and pg pool configuration. | +| `./postgres` | `bytea` | KEEP | PostgreSQL-specific binary column declaration. | +| `./postgres` | `jsonb` | KEEP | PostgreSQL-specific JSONB column declaration. | +| `./postgres` | `postgres` | KEEP | README PostgreSQL runtime and shadow driver factory. | +| `./postgres` | `postgresEnum` | KEEP | PostgreSQL enum declaration with a typed column factory. | +| `./postgres` | `postgresType` | KEEP | Explicit declaration for PostgreSQL types not covered by portable column factories. | +| `./postgres` | `timestampTz` | KEEP | PostgreSQL-specific timestamp with time zone. | +| `./sqlite` | `SqliteOptions` | KEEP | Typed lazy filename override for SQLite. | +| `./sqlite` | `sqlite` | KEEP | README SQLite driver factory with isolated in-memory shadow. | + +## Nested SQL member decision + +`sql.identifier`, `sql.literal` and `sql.unsafe` are the canonical paths. `sql.key` is removed: defineQuery owns named application queries and generated metadata. Lower placeholder rewriting utilities stay private and their inert-text regression tests remain. + +## Consumer audit + +The 0.4.x compiler inventory found only the type fixture importing createDatabaseClient from the package. It now uses defineDatabase/open with an explicit custom driver. Source-level unit/integration tests retain direct imports of their private implementation helpers. The benchmark now opens through defineDatabase; its timed CRUD/prepared/bulk workloads and budgets are unchanged. + +Additional string/dynamic consumers were inspected: askr-cli reads package.json and invokes runDatabaseCli, and ORM generated source imports GeneratedDatabaseArtifact. Both are retained. No sibling application/template imports of the removed root/tooling names or sql.key were found in the reviewed source snapshots. Website API generation remains pending the complete 0.5.0 candidate set. + +## Reusable typed declarations + +```ts +import { + numeric, + eq, + type ColumnBuilder, + type Expression, + type SqlFragment, + type Codec, +} from "@askrjs/orm"; +const money = (): ColumnBuilder => numeric(10, 2); +const equals = (left: Expression, value: T): SqlFragment => eq(left, value); +const caseFold: Codec = { + name: "case-fold", + encode: (value) => value.toLowerCase(), + decode: (value) => value, +}; +``` + +Use type-only imports for adapter contracts, options and inference. Install pg/pg-query-stream for PostgreSQL use and @types/pg for TypeScript consumers. The packed check verifies the documented minimum peers 8.23.0/4.17.0 with a normal install, strict TypeScript 6/7 declaration checking, exact runtime/declaration names, all 54 removed imports, private subpath denial, and SQLite migration/rollback recovery. diff --git a/docs/0.5.0-hardening.md b/docs/0.5.0-hardening.md new file mode 100644 index 0000000..3d16a89 --- /dev/null +++ b/docs/0.5.0-hardening.md @@ -0,0 +1,112 @@ +# 0.5.0 ORM hardening evidence + +Status: runtime regressions are fixed and the API candidate is qualified locally. +The package is **not release-ready**. [PostgreSQL generated-artifact tooling +#55](https://github.com/askrjs/askr-orm/issues/55) remains a release blocker, so +#53 and #54 stay open alongside the complete candidate/website qualification. + +## Assertion review and real adapters + +Existing tests were read before adding probes. They already assert placeholder +ordering/inert quoted text, the PostgreSQL bind ceiling with real 70-column +batches, constraint normalization, caller-owned rollback, four nested SQLite +savepoints, same-file queues, concurrent PostgreSQL migration applicants, cursor +cancellation, and failed non-transactional migration resolution. Those scenarios +remain; the new work targets cleanup recovery and complete migration scripts. + +Local qualification uses Node 24.21.0, Node's real SQLite adapter, and an isolated +PostgreSQL 18.6 cluster with its own target/shadow databases. It does not use or +reset a user's database. Hosted integration retains PostgreSQL 16/17/18 and adds +normal package gates on Linux, macOS and Windows. + +## Confirmed runtime regressions + +The 25 focused cases were run with adapter/migration source restored from base +commit `4cb035c32240bd54464e25fb947b01b5e2279595`, then restored to the candidate. +The original source fails ten cases and passes 15 characterizations. The ten +failures exercise several defects, not ten independent bug claims: + +| Boundary | Original behavior | Candidate and asserted recovery | +| --------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| SQLite script with a failing middle statement | Prepares only the first statement, reports success and writes an applied ledger entry. | Executes the complete driver-owned script; earlier table/data and the ledger row roll back; a corrected script applies afterward. | +| SQLite successful script | Drops following statements, including text containing a quoted semicolon. | Commits all statements and preserves `semi;colon` as a value. | +| PostgreSQL successful script | Treats a multi-result array as one result and throws after successful execution. | Executes a parameter-free script without interpreting result sets; both inserts and its ledger entry commit. | +| PostgreSQL rollback/unlock failure | Returns a connection with failed cleanup to the pool. | Marks it poisoned and discards it, preserves the primary callback error, removes listeners, and obtains a fresh usable connection. Real connection termination also verifies no inserted row survives. | +| PostgreSQL deallocation failure | Skips client release when DEALLOCATE throws. | Releases/discards once, removes the listener and permits a subsequent description. | +| SQLite root/nested rollback failure | Leaves unrolled-back writes on a reusable connection, allowing later commit. | Quarantines the adapter and closes its database; another adapter and a reopened file observe only committed recovery writes. | +| SQLite quarantine close failure | The first attempted runtime fix skipped any later physical close after quarantine. | Logical retirement and physical closure are separate; explicit close retries cleanup and releases queue ownership only after closure succeeds. Its additional RED case failed against the intermediate implementation before the refinement. | + +No application SQL splitter was added. DatabaseAdapter.executeScript is optional; +the supported drivers own their script semantics, and custom drivers retain the +existing execute fallback. Non-transactional migrations keep their intentional +partial-state and explicit resolve policy. + +## Characterization and parity + +The 15 passing original-source characterizations include pre-execution migration +cancellation/recovery, database self-disconnect rollback and subsequent migration +lock recovery, existing PostgreSQL lifecycle cases, and common adapter values. +The shared real-adapter value probe verifies NULL, empty text/binary values, +embedded quote/semicolon/placeholder text, a 30,000-repeat Unicode text payload, +a 65,536-byte binary payload, duplicate-key normalization with original cause, +identifier rejection and a subsequent successful read on both databases. + +## Packed contract and ordinary gates + +The normal package gate includes strict packed TypeScript 6/7 declaration checking +without skipLibCheck, exact 78 runtime/type names, all 54 removed imports, +ColumnBuilder's type-only boundary, removed sql.key, 14 private subpaths, a normal +install of the minimum PostgreSQL peers (8.23.0/4.17.0), and root/SQLite imports +with those optional peers absent. Installed SQLite executes a complete migration +script and proves transaction rollback plus a subsequent query. The CLI bridge's +help path resolves; real PostgreSQL generation is separately blocked by #55. + +`npm run check` runs lint/types, V8 implementation coverage, type contracts, +packed consumer, build, the unchanged 20,000-operation/10,000-row benchmark, +publint and pack dry run. Format checking runs explicitly and in hosted CI. +The final source suite contains 79 cases: 67 ordinary cases and 12 real +PostgreSQL integration cases. A lane without the database URL skips only those +12 integration cases; it is not PostgreSQL qualification by itself. + +Coverage is measured across implementation source, including the incomplete tooling. + +Measured with the real PostgreSQL URL enabled (79 cases): + +| Implementation | Statements | Branches | Functions | Lines | +| --------------- | ------------------ | ----------------- | ---------------- | ------------------ | +| total | 1282/1619 (79.18%) | 700/1023 (68.42%) | 323/396 (81.56%) | 1186/1443 (82.18%) | +| migrations.ts | 77/84 (91.66%) | 30/37 (81.08%) | 18/19 (94.73%) | 71/77 (92.2%) | +| postgres.ts | 118/141 (83.68%) | 55/76 (72.36%) | 25/30 (83.33%) | 113/124 (91.12%) | +| sqlite.ts | 122/168 (72.61%) | 40/68 (58.82%) | 31/49 (63.26%) | 112/145 (77.24%) | +| tooling-impl.ts | 280/482 (58.09%) | 155/352 (44.03%) | 51/85 (60%) | 262/432 (60.64%) | + +Executed commands: + +```sh +vp env exec --node 24.21.0 npm run fmt -- --check +ASKR_ORM_TEST_DATABASE_URL= vp env exec --node 24.21.0 npm run check +vp env exec --node 24.21.0 npm audit --json +``` + +The normal package CI gate has no PostgreSQL URL; its unit report is separate +from the integration proof above. The real PostgreSQL matrix executes the 12 +integration cases. Coverage HTML/JSON is retained for assertion inspection. + +Test/fixture source is excluded. In particular, incomplete tooling branches are +not treated as covered or accepted merely because runtime tests pass. + +The compatible development dependency update reduces the npm audit result from +four findings to zero. It does not change optional peer ranges or use forced +peer installs. No version bump, release tag or package publication is included. + +## Remaining PostgreSQL tooling blocker + +An executed real PostgreSQL probe confirms that shadow reset always throws, +introspection returns database/schema identity rows rather than a normalized +schema snapshot, and description returns no columns for `SELECT $1::text AS +value`. The advertised generate/validate path calls these boundaries directly. +The prior generation unit test supplies fake implementations and therefore does +not qualify this real adapter workflow. #55 requires actual target/scratch +separation before destructive reset, complete supported-object introspection, +query description without executing application queries, and a real +`generate → validate → unchanged generate` recovery matrix for PostgreSQL 16–18. diff --git a/docs/adapters.md b/docs/adapters.md index 8b5e301..6d0fff8 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -6,6 +6,11 @@ The runtime and tooling adapters are deliberately separate. - `execute` accepts parameterized `{ text, values }` and optional cancellation, timeout, and prepared-statement name. +- `executeScript` is optional and executes the complete parameter-free SQL script + supplied by a migration. PostgreSQL ignores multi-statement result sets and + SQLite uses DatabaseSync.exec. Drivers without this method retain the existing + execute fallback and must supply complete-script semantics themselves. This is + a migration boundary; ordinary execute still returns one query result. - `transaction` owns `BEGIN`, isolation/read-only settings, commit, and rollback. - `session` pins a physical database session. @@ -23,7 +28,7 @@ interpolate application data. Placeholder rendering is dialect-owned. - `identity` is a stable, non-secret database identity. - `reset` destroys and recreates only the isolated scratch schema/database. - `execute` replays migration SQL. -- `introspect` returns the canonical `SchemaSnapshot`. +- `introspect` returns the canonical schema snapshot expected by the private CLI implementation. - `describe` uses PostgreSQL prepared-statement description and returns parameter and result metadata without running application data queries. diff --git a/docs/migrations.md b/docs/migrations.md index 4329c39..8341b65 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -45,3 +45,11 @@ changes unless the definition carries supported explicit intent. Use Opening a database never applies migrations. Programmatic `plan()` and `apply()` use the same manifest, ledger checks, session lock, and transaction rules as the CLI, but never prompt. + +Migration replay uses the driver's complete-script boundary: quoted semicolons +remain SQL text and are never split by an application parser. If a transactional +statement fails, all preceding DDL/data and the applied ledger entry roll back. +A failed rollback quarantines the connection. PostgreSQL discards the poisoned +pool client; SQLite closes the failed adapter and rolls back its remaining writes. +Open a new SQLite adapter before retrying. Non-transactional scripts retain their +explicit partial-state/resolve policy described above. diff --git a/package-lock.json b/package-lock.json index 08753dd..c0121e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,11 +14,12 @@ "devDependencies": { "@types/node": "^26.3.0", "@types/pg": "^8.23.1", - "@vitest/coverage-v8": "^4.1.11", + "@typescript/typescript6": "npm:typescript@6.0.2", + "@vitest/coverage-v8": "4.1.11", "pg": "^8.23.0", "pg-query-stream": "^4.17.0", "publint": "^0.3.24", - "typescript": "^7.0.2", + "typescript": "7.0.2", "vite-plus": "^0.3.1", "vitest": "^4.1.11" }, @@ -582,9 +583,9 @@ } }, "node_modules/@oxc-project/runtime": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.148.0.tgz", - "integrity": "sha512-0ExYgJZv8+nFuoV0T/xzX5ZyXFXwkHJFJg9LWTwhqgRLpe+IzBLirvyYKSdS8mPofTBKAjDYIA2o84xh5BHXew==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.150.0.tgz", + "integrity": "sha512-vC4tN4n2c+gVq69Oi6CoZxGk/1mYjLiN4hVEYwvtuns/S4QwUBiJYViwLGLxPaLQnsY5FQDgGs+h6moAmSedvA==", "dev": true, "license": "MIT", "engines": { @@ -602,9 +603,9 @@ } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.66.0.tgz", - "integrity": "sha512-2Me9eoptv6ERdEuI2P8AOlYdHHraXebJaM6SC0kc2Dfb+mLrep2db+fedBPKaYn673h/vBgvP4tkOdAbaudX6w==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.68.0.tgz", + "integrity": "sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg==", "cpu": [ "arm" ], @@ -619,9 +620,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.66.0.tgz", - "integrity": "sha512-u7O+bSSF0HGsDKkQQxBqvLGVepu93RA+JKu+ONqvfh4sCnCEbj31wZj4iG5gk3XfRwrmYj0/8catkO2LcblQKQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.68.0.tgz", + "integrity": "sha512-v3Njdi6qY0O/5eGfg01ww2w6gTn2mUvZ72Bnx1/UN53A9wruh3Nk6otc3WkgJLkXD4Qgz1SOcVQieH1oD03V9Q==", "cpu": [ "arm64" ], @@ -636,9 +637,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.66.0.tgz", - "integrity": "sha512-/ikyMIVjX/sdo7KtjxoEsSUosfPzveVhT9RWMx9yGqFDKFJ89JAEKuEeLBmurDjrkb4w8tOnAdSO3SBaplY3bw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.68.0.tgz", + "integrity": "sha512-ei4MCMzHFREmZwPJ7KuWUB4kBuHdsgDrnXGJVcEAopU7fj7S42I8BChdFILWdHvhFqR08FLJtOfbZIr2CDw0cA==", "cpu": [ "arm64" ], @@ -653,9 +654,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.66.0.tgz", - "integrity": "sha512-q5xUsKeFqawa9NXa6ZGXWimFV19m8MogKPdTaSVDAAk2EQKBmBZRDeluwcl1p8ty/OFc9s9888OKEh3xfPVH0g==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.68.0.tgz", + "integrity": "sha512-UrKgzZxYhwB9DSvTX+vdgl9M32wLUNKJcAKIoiyx/Kzn/zveqi7W6kYVcRroFMhS3Kwz0KhTk3WBeSuQn4YCTg==", "cpu": [ "x64" ], @@ -670,9 +671,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.66.0.tgz", - "integrity": "sha512-CR+x4VzMY0pRXLK/xFQ/RzsSFkP5t2Z2mef0QY6OP/rTRcMUoMLCOM62/3Fp/t0K+UDoBKxvMyeb6D0zPMjleA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.68.0.tgz", + "integrity": "sha512-6jrEKgpJbilM1QaRv7hEtKXr4p4AK4jvvyOtajwyhu0kOz3e0O7OLnSTk6tBotRqCcUC4ehZRJ1Zx+Y99wieLw==", "cpu": [ "x64" ], @@ -687,9 +688,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.66.0.tgz", - "integrity": "sha512-ZEYmO/LbH9tTQCADILHGZE4GeOXOAj2VzedHkASNwjmwlwtutJCLpCJbIs37wRGTFgWRoEcD72jpMX+IBJUGjQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.68.0.tgz", + "integrity": "sha512-YOIVnKOBaLeGullskS179N12hjSAdFYnzLjOaKiLhAKNWgnShq9w4xRdtmUm6BlnP65l2/EA9Aw/KlftNxDM7Q==", "cpu": [ "arm" ], @@ -704,9 +705,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.66.0.tgz", - "integrity": "sha512-hNtR9/oU0CeTkq7JnRkmBQwqe17v2ZaAMLC4VcN7IIOWeRyWDk0knSPWS9iiLmtbZ2RRBBtsG01jQgkZmKCJeQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.68.0.tgz", + "integrity": "sha512-xW5XoEHVNqydPBv2KXvk9lmEzyAlOQHVEazKoXUuAacqekjya+OdiaFjjEBl0oJD02raG8g3TRl9OVCh9PDIHA==", "cpu": [ "arm" ], @@ -721,9 +722,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.66.0.tgz", - "integrity": "sha512-uwOVQ8i6I1LT/+eDzfsgrrcZp8Fn6NPVUPn8fF5gdFGekFf0PddF+LEuwsD0/pbNUcKZhDj2rQ5UpITh9gF4iQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.68.0.tgz", + "integrity": "sha512-QCvYwVVQieu6oyJglAgV9vH/YMDxZyR4cwVSYtoq9oOXd5N+D3TDUBjNwxFrLn5AdcJZOcvn/7IB17vJGp+2Og==", "cpu": [ "arm64" ], @@ -741,9 +742,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.66.0.tgz", - "integrity": "sha512-tTkF2Dmx4nGAjmBlZb+UtTGqR/EK4ZrW9qBfzte07a9XWqzoGGKzpFFlyNDhQe+Uwql94+ReCTeNbhOXscw1Dg==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.68.0.tgz", + "integrity": "sha512-4TVz5iFQ8ndrHnhX50UXiz9BIWAtUSOHJ6Nus4qWFfJBXq/Ed/krXbF/ehJu42BXM5tIvBs99jNIM22s9agY5A==", "cpu": [ "arm64" ], @@ -761,9 +762,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.66.0.tgz", - "integrity": "sha512-F3cKHUav4yXOHn6GFnwpBhSYsJOYKKf9eqO/9jlEuqPxNw9zb98E9ZFct79gcg8pibUGkbveEu9WDlmXJpDzKw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.68.0.tgz", + "integrity": "sha512-qLe3ao0RP84bnPxBvRI+GnlK/jybo538NWu0Xrm+zYeTmJtpzqLhnnd5BH21NafqKnplbGZjtN1cnrOlWF73lw==", "cpu": [ "ppc64" ], @@ -781,9 +782,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.66.0.tgz", - "integrity": "sha512-K5fDaNZfDyQMYA/3qL21bqyN0X9T15LLwwbFPt2aHc94+ZG7bh0vZEsy2y7NlRnjjHFSwN+Hzg6ldJtbOriH4Q==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.68.0.tgz", + "integrity": "sha512-Yvyl7a6gbb0vM6r925KW2dO+/CmXySO5TVbcX7o/uZJ+d108HFOG0TxIyHApmn5USuj5mhDPxzhiVwOlGP7uSA==", "cpu": [ "riscv64" ], @@ -801,9 +802,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.66.0.tgz", - "integrity": "sha512-44Yc+I+qOmTElRcEhm5hUKIUJEQIOugymz4ua4tB0Wox7tGAfIbjzmXz/HDAtw1Ij6gmBwZlzh4hc9679RhWeA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.68.0.tgz", + "integrity": "sha512-mJlFuFVCxzrYM5sFStN433D/s/mb6Wq4aCQAM02vs/OudHywnaSAd2rb1vlYUJtqdYIciJtiasuxvfbYkv5fLg==", "cpu": [ "riscv64" ], @@ -821,9 +822,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.66.0.tgz", - "integrity": "sha512-1e29Eg9hEj2kRBB19M0seIehPbbXHCk35GvImjDvb79rjjYjXCRmtbUNHJcgoktZAMIzXrTbxDBKmTc1V4bg3A==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.68.0.tgz", + "integrity": "sha512-RlfSg++qs1hbKltRR6lYvV9EoI3MdlfSQD9w1hdHVYjHqjIn1tkH4FWOpMSmjKGN20zr+nI+W9o4ARogCDudGQ==", "cpu": [ "s390x" ], @@ -841,9 +842,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.66.0.tgz", - "integrity": "sha512-vODY1UQo10gngn0+D4xHKU84F1Twm1LqrzV4SqPXvmQKSd87paehvZ6jqA5wKs6XQrlWul9clYMDVHcoW9CPMA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.68.0.tgz", + "integrity": "sha512-nyzRB9U+dlYUKu3pMo3afHzZBUv/oTHZMG36ZfJViNVfOIzp70Q4GS8FFRGgYJ/p0zcyDCgpBvYISOdJOMh+jQ==", "cpu": [ "x64" ], @@ -861,9 +862,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.66.0.tgz", - "integrity": "sha512-YDzXx2JsT4+HL4MdkVrYjO55NS5lUKNm8rLC4ZPou8+seu0v0jhecSh+ufoO6+xEa8gccEezMlI2WHJi4ApUgw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.68.0.tgz", + "integrity": "sha512-iCx3sbZRIvGrL1RafphEiUKBaW1lc0/tAjKOIB/Wjw2+STRBEdu5+fH1Gc1faEWEmc2k5Ks4iUUV54Zd5C9a1A==", "cpu": [ "x64" ], @@ -881,9 +882,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.66.0.tgz", - "integrity": "sha512-mJjUYd8lj0+j4JkYyEM+5qKBf1Rnrpgjn/SVYKJhicVDqLz566ooa7Fs8zflPqt+dnZDV7X054rVIQX6ZcQNlQ==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.68.0.tgz", + "integrity": "sha512-x2X5AZez7OgyLLFpwIgItoXBUqudDM7yiaTsxv8R8vKQ6e81l0jVw0NFeUCXzcl1sAJq8h+tC8N4mY8EiMeL4w==", "cpu": [ "arm64" ], @@ -898,9 +899,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.66.0.tgz", - "integrity": "sha512-soV+0vESv7e5ntCHWC61x4gg8OSak6IHHnWsZmHrJFlvMj2AK+kmldErCNkVkrvc1Ts2/++rJXn+IuAb2WMXhw==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.68.0.tgz", + "integrity": "sha512-AHVPjXkenLPQUh6kB8zSC8pX2ct9r4T1Edk9r/RNJyov6wPsS5uAfYtipwG4chn6+3bPFG5rI/3DxEeH8vib1w==", "cpu": [ "arm64" ], @@ -915,9 +916,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.66.0.tgz", - "integrity": "sha512-YCPi23uRIEYuIKTZohAkKbPFpujQ5QBuUM5iDv+UqbCmTPAkaFsxjsSuB8xlBpRT0G7eP/4HMF+cPDSqHtOD9A==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.68.0.tgz", + "integrity": "sha512-n09SjEk5VH7z8Hl4WVP7hho+cCwGViENkQFiM45vbW85dJd7kEhWaHRUobaPzEmrWyu6uumd4EuNfNyDKLtzDA==", "cpu": [ "ia32" ], @@ -932,9 +933,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.66.0.tgz", - "integrity": "sha512-bwTQcv/JVRPkOqQtMF0X7vpvpncDQiBcXHxZ9S2hR12Hlo8bvBdUR5x5XnxzDZ3kM0qoZw1rv7KaD66Ly+pFWA==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.68.0.tgz", + "integrity": "sha512-gPe+dJLXaPuWPWqlpklDAJp0k+K9KhQPYiQLHfb+i2rmFuUGfJ/5Qlj6tr1mO6of5g0DiLjG/XCFHIaPhotqqA==", "cpu": [ "x64" ], @@ -1033,9 +1034,9 @@ ] }, "node_modules/@oxlint/binding-android-arm-eabi": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.81.0.tgz", - "integrity": "sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.83.0.tgz", + "integrity": "sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==", "cpu": [ "arm" ], @@ -1050,9 +1051,9 @@ } }, "node_modules/@oxlint/binding-android-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.81.0.tgz", - "integrity": "sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.83.0.tgz", + "integrity": "sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==", "cpu": [ "arm64" ], @@ -1067,9 +1068,9 @@ } }, "node_modules/@oxlint/binding-darwin-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.81.0.tgz", - "integrity": "sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.83.0.tgz", + "integrity": "sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==", "cpu": [ "arm64" ], @@ -1084,9 +1085,9 @@ } }, "node_modules/@oxlint/binding-darwin-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.81.0.tgz", - "integrity": "sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.83.0.tgz", + "integrity": "sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==", "cpu": [ "x64" ], @@ -1101,9 +1102,9 @@ } }, "node_modules/@oxlint/binding-freebsd-x64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.81.0.tgz", - "integrity": "sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.83.0.tgz", + "integrity": "sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==", "cpu": [ "x64" ], @@ -1118,9 +1119,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-gnueabihf": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.81.0.tgz", - "integrity": "sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.83.0.tgz", + "integrity": "sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==", "cpu": [ "arm" ], @@ -1135,9 +1136,9 @@ } }, "node_modules/@oxlint/binding-linux-arm-musleabihf": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.81.0.tgz", - "integrity": "sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.83.0.tgz", + "integrity": "sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==", "cpu": [ "arm" ], @@ -1152,9 +1153,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.81.0.tgz", - "integrity": "sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.83.0.tgz", + "integrity": "sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==", "cpu": [ "arm64" ], @@ -1172,9 +1173,9 @@ } }, "node_modules/@oxlint/binding-linux-arm64-musl": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.81.0.tgz", - "integrity": "sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.83.0.tgz", + "integrity": "sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==", "cpu": [ "arm64" ], @@ -1192,9 +1193,9 @@ } }, "node_modules/@oxlint/binding-linux-ppc64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.81.0.tgz", - "integrity": "sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.83.0.tgz", + "integrity": "sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==", "cpu": [ "ppc64" ], @@ -1212,9 +1213,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.81.0.tgz", - "integrity": "sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.83.0.tgz", + "integrity": "sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==", "cpu": [ "riscv64" ], @@ -1232,9 +1233,9 @@ } }, "node_modules/@oxlint/binding-linux-riscv64-musl": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.81.0.tgz", - "integrity": "sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.83.0.tgz", + "integrity": "sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==", "cpu": [ "riscv64" ], @@ -1252,9 +1253,9 @@ } }, "node_modules/@oxlint/binding-linux-s390x-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.81.0.tgz", - "integrity": "sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.83.0.tgz", + "integrity": "sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==", "cpu": [ "s390x" ], @@ -1272,9 +1273,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-gnu": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.81.0.tgz", - "integrity": "sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.83.0.tgz", + "integrity": "sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==", "cpu": [ "x64" ], @@ -1292,9 +1293,9 @@ } }, "node_modules/@oxlint/binding-linux-x64-musl": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.81.0.tgz", - "integrity": "sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.83.0.tgz", + "integrity": "sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==", "cpu": [ "x64" ], @@ -1312,9 +1313,9 @@ } }, "node_modules/@oxlint/binding-openharmony-arm64": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.81.0.tgz", - "integrity": "sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.83.0.tgz", + "integrity": "sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==", "cpu": [ "arm64" ], @@ -1329,9 +1330,9 @@ } }, "node_modules/@oxlint/binding-win32-arm64-msvc": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.81.0.tgz", - "integrity": "sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.83.0.tgz", + "integrity": "sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==", "cpu": [ "arm64" ], @@ -1346,9 +1347,9 @@ } }, "node_modules/@oxlint/binding-win32-ia32-msvc": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.81.0.tgz", - "integrity": "sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.83.0.tgz", + "integrity": "sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==", "cpu": [ "ia32" ], @@ -1363,9 +1364,9 @@ } }, "node_modules/@oxlint/binding-win32-x64-msvc": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.81.0.tgz", - "integrity": "sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.83.0.tgz", + "integrity": "sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==", "cpu": [ "x64" ], @@ -2130,6 +2131,21 @@ "node": ">=16.20.0" } }, + "node_modules/@typescript/typescript6": { + "name": "typescript", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.2.tgz", + "integrity": "sha512-bGdAIrZ0wiGDo5l8c++HWtbaNCWTS4UTv7RaTH/ThVIgjkveJt83m74bBHMJkuCbslY8ixgLBVZJIOiQlQTjfQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@vitest/browser": { "version": "4.1.11", "resolved": "https://registry.npmjs.org/@vitest/browser/-/browser-4.1.11.tgz", @@ -2316,9 +2332,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-darwin-arm64": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.1.tgz", - "integrity": "sha512-3xK+G6zJHa4Q60NgAnnZ38ML5NSf5n3+DBHSINqCgsPpQ7XtMWkpEsXArWU7u3nlW3f1F2XLAvuAk54TmQXptw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-arm64/-/vite-plus-darwin-arm64-0.3.3.tgz", + "integrity": "sha512-1hwUfQGqvzlO+qQ7r1i5XQWkKaWq/JcdAAZOPloMQsZq7P8rgiY1pq/0q4stzcNxFF74k4aEFOlDUKi6D149YA==", "cpu": [ "arm64" ], @@ -2333,9 +2349,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-darwin-x64": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.1.tgz", - "integrity": "sha512-GbJ2RWZezmpr/erPlJ4Kw9T3y2EP3cwZSkh+Ph8IfcJMj3MRqTw1lfxCl81x8Opyct6wYU6kDRU3zH95Tl341Q==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-darwin-x64/-/vite-plus-darwin-x64-0.3.3.tgz", + "integrity": "sha512-nM2+EC1BPochRhtf48FWZX3wPnrsy8aBSI5TPqjxr0OS0bydvPavrhdSUmgUGZG9/hp0NTeXhhqY9A7yuvZLZQ==", "cpu": [ "x64" ], @@ -2350,9 +2366,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-arm64-gnu": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.1.tgz", - "integrity": "sha512-vYXxMRxE7DAa8QIh3k5IkfTeBbA/8JHYHL14rsodq4er4FNOd4AAM07eAczin7Pdf4Aie/2BPkymSF8hFyjZDg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-gnu/-/vite-plus-linux-arm64-gnu-0.3.3.tgz", + "integrity": "sha512-WC8/kT0/btxnwgYR5QvvelbgUaLQ6lbxs8aMQpjc63p1QdMTNZEbp3vnTV2l9KgoUYeyHnALJVBW9l1CJAM+fA==", "cpu": [ "arm64" ], @@ -2370,9 +2386,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-arm64-musl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.1.tgz", - "integrity": "sha512-LAnyEqhZ2Fji6bXDnigSWbEjS/B7u/Al01N4JERSR6scr4PbZpfMW8/1EUHfi+9pIdxwoDazIRZeh5DvEDX8eQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-arm64-musl/-/vite-plus-linux-arm64-musl-0.3.3.tgz", + "integrity": "sha512-iy3AgJK9wwOGjlEzq0Sm1bqK5pDSWF4nz29Ey5BmK7bGciyfufeKRqRASKJdMhRMEQ7WVUQz5luC5GddLEDwXg==", "cpu": [ "arm64" ], @@ -2390,9 +2406,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-x64-gnu": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.1.tgz", - "integrity": "sha512-F9Ek2p+ZnDB2FrO6ugZU2ZnuOO/Q9GR9jwUcnK2oUHwl6AZUvg671L7vU57rrtY4f9pS+AcL9lWRoS4wedXm3A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-gnu/-/vite-plus-linux-x64-gnu-0.3.3.tgz", + "integrity": "sha512-ZHN3RCA422XrGYmSDE9IZqx/eM5PsFCXHn8hUttQEwcSVXsuKBArDyb/uxonn1n0bPNW8c4G4HvNmIS3ckLXzA==", "cpu": [ "x64" ], @@ -2410,9 +2426,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-linux-x64-musl": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.1.tgz", - "integrity": "sha512-3Q+hJ19iw1k2NOCoop/gK+etMPnPYbgdqXBS9mgUuuDUA142v1PoYH+gk3NMV+KyDvSZaLkgDuGrdNav0pJtqw==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-linux-x64-musl/-/vite-plus-linux-x64-musl-0.3.3.tgz", + "integrity": "sha512-lDmmjrgh6qNvBlzapzKMjxiqRigkyPjIimkxrk8CSz+/AyQp6I/wuIAyjBmNfeh14SQoDNVgf03CTJzmJycrpg==", "cpu": [ "x64" ], @@ -2430,9 +2446,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-win32-arm64-msvc": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.1.tgz", - "integrity": "sha512-aZAfZdQBFDzktOF/0OJDYrkOP551IxmoMRGfU3KdJm25Gpj8WHl5QTNx/mL8216rbZ7p4QIdBc4gajoRCAtsuA==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-arm64-msvc/-/vite-plus-win32-arm64-msvc-0.3.3.tgz", + "integrity": "sha512-/5/YdLItQH9KC+jL49uw9CidC7MeO3Q8ojRcuOPcH5ujU5tU4P4lO2qJ9RbM8ckC1lfiZYTtVtJUSkbPn1R/YQ==", "cpu": [ "arm64" ], @@ -2447,9 +2463,9 @@ } }, "node_modules/@voidzero-dev/vite-plus-win32-x64-msvc": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.1.tgz", - "integrity": "sha512-8rxCvdTpd1v/+g/ECjqKnHxBlxmk8O9kNl4wwDcfWszbL/O/pNNSktKHq6rR3A6m6p0NCoZsenbGLuJsHE+q9A==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-win32-x64-msvc/-/vite-plus-win32-x64-msvc-0.3.3.tgz", + "integrity": "sha512-npbLQAAKdWHEB0qOFkmF/2OUzdraNBe1w7gY1LjXQFvqORAKZlHyQ2rgF5nBtG5+LKvbKWL6XpvyP+cQEh7MZg==", "cpu": [ "x64" ], @@ -2470,6 +2486,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2484,6 +2501,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2498,6 +2516,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2512,6 +2531,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2526,6 +2546,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -2543,6 +2564,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -2560,6 +2582,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -2577,6 +2600,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -2594,6 +2618,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "glibc" @@ -2611,6 +2636,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "libc": [ "musl" @@ -2628,6 +2654,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2642,6 +2669,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-codegen is pure JavaScript since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2656,6 +2684,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2670,6 +2699,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2684,6 +2714,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2698,6 +2729,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2712,6 +2744,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -2729,6 +2762,7 @@ "cpu": [ "arm" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -2746,6 +2780,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -2763,6 +2798,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -2780,6 +2816,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "glibc" @@ -2797,6 +2834,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "libc": [ "musl" @@ -2814,6 +2852,7 @@ "cpu": [ "arm64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -2828,6 +2867,7 @@ "cpu": [ "x64" ], + "deprecated": "yuku-parser runs on yuku-core since 0.14", "dev": true, "license": "MIT", "optional": true, @@ -3479,13 +3519,13 @@ } }, "node_modules/oxfmt": { - "version": "0.66.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.66.0.tgz", - "integrity": "sha512-FfvqR8RFtV6JJpRrpkfqyVCQ7HDvZ/VriWFx7veftCgL1B5ZO9qNr+1rvPieycMQnNfVG0PWyJQiy7p0hq1I5w==", + "version": "0.68.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.68.0.tgz", + "integrity": "sha512-Z0XMofcXCGUXbcpBHnWyUiX93BGiw1B+lcHNbQDWEtOhX06ewoFfu4zXkyiLhRrNnMq0twqXRHUcJetf+GsiQQ==", "dev": true, "license": "MIT", "dependencies": { - "tinypool": "2.1.0" + "tinypool": "2.1.2" }, "bin": { "oxfmt": "bin/oxfmt" @@ -3497,25 +3537,25 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.66.0", - "@oxfmt/binding-android-arm64": "0.66.0", - "@oxfmt/binding-darwin-arm64": "0.66.0", - "@oxfmt/binding-darwin-x64": "0.66.0", - "@oxfmt/binding-freebsd-x64": "0.66.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.66.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.66.0", - "@oxfmt/binding-linux-arm64-gnu": "0.66.0", - "@oxfmt/binding-linux-arm64-musl": "0.66.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.66.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.66.0", - "@oxfmt/binding-linux-riscv64-musl": "0.66.0", - "@oxfmt/binding-linux-s390x-gnu": "0.66.0", - "@oxfmt/binding-linux-x64-gnu": "0.66.0", - "@oxfmt/binding-linux-x64-musl": "0.66.0", - "@oxfmt/binding-openharmony-arm64": "0.66.0", - "@oxfmt/binding-win32-arm64-msvc": "0.66.0", - "@oxfmt/binding-win32-ia32-msvc": "0.66.0", - "@oxfmt/binding-win32-x64-msvc": "0.66.0" + "@oxfmt/binding-android-arm-eabi": "0.68.0", + "@oxfmt/binding-android-arm64": "0.68.0", + "@oxfmt/binding-darwin-arm64": "0.68.0", + "@oxfmt/binding-darwin-x64": "0.68.0", + "@oxfmt/binding-freebsd-x64": "0.68.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.68.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.68.0", + "@oxfmt/binding-linux-arm64-gnu": "0.68.0", + "@oxfmt/binding-linux-arm64-musl": "0.68.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.68.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.68.0", + "@oxfmt/binding-linux-riscv64-musl": "0.68.0", + "@oxfmt/binding-linux-s390x-gnu": "0.68.0", + "@oxfmt/binding-linux-x64-gnu": "0.68.0", + "@oxfmt/binding-linux-x64-musl": "0.68.0", + "@oxfmt/binding-openharmony-arm64": "0.68.0", + "@oxfmt/binding-win32-arm64-msvc": "0.68.0", + "@oxfmt/binding-win32-ia32-msvc": "0.68.0", + "@oxfmt/binding-win32-x64-msvc": "0.68.0" }, "peerDependencies": { "svelte": "^5.0.0", @@ -3531,9 +3571,9 @@ } }, "node_modules/oxlint": { - "version": "1.81.0", - "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.81.0.tgz", - "integrity": "sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==", + "version": "1.83.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.83.0.tgz", + "integrity": "sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==", "dev": true, "license": "MIT", "bin": { @@ -3546,25 +3586,25 @@ "url": "https://github.com/sponsors/oxc-project" }, "optionalDependencies": { - "@oxlint/binding-android-arm-eabi": "1.81.0", - "@oxlint/binding-android-arm64": "1.81.0", - "@oxlint/binding-darwin-arm64": "1.81.0", - "@oxlint/binding-darwin-x64": "1.81.0", - "@oxlint/binding-freebsd-x64": "1.81.0", - "@oxlint/binding-linux-arm-gnueabihf": "1.81.0", - "@oxlint/binding-linux-arm-musleabihf": "1.81.0", - "@oxlint/binding-linux-arm64-gnu": "1.81.0", - "@oxlint/binding-linux-arm64-musl": "1.81.0", - "@oxlint/binding-linux-ppc64-gnu": "1.81.0", - "@oxlint/binding-linux-riscv64-gnu": "1.81.0", - "@oxlint/binding-linux-riscv64-musl": "1.81.0", - "@oxlint/binding-linux-s390x-gnu": "1.81.0", - "@oxlint/binding-linux-x64-gnu": "1.81.0", - "@oxlint/binding-linux-x64-musl": "1.81.0", - "@oxlint/binding-openharmony-arm64": "1.81.0", - "@oxlint/binding-win32-arm64-msvc": "1.81.0", - "@oxlint/binding-win32-ia32-msvc": "1.81.0", - "@oxlint/binding-win32-x64-msvc": "1.81.0" + "@oxlint/binding-android-arm-eabi": "1.83.0", + "@oxlint/binding-android-arm64": "1.83.0", + "@oxlint/binding-darwin-arm64": "1.83.0", + "@oxlint/binding-darwin-x64": "1.83.0", + "@oxlint/binding-freebsd-x64": "1.83.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.83.0", + "@oxlint/binding-linux-arm-musleabihf": "1.83.0", + "@oxlint/binding-linux-arm64-gnu": "1.83.0", + "@oxlint/binding-linux-arm64-musl": "1.83.0", + "@oxlint/binding-linux-ppc64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-gnu": "1.83.0", + "@oxlint/binding-linux-riscv64-musl": "1.83.0", + "@oxlint/binding-linux-s390x-gnu": "1.83.0", + "@oxlint/binding-linux-x64-gnu": "1.83.0", + "@oxlint/binding-linux-x64-musl": "1.83.0", + "@oxlint/binding-openharmony-arm64": "1.83.0", + "@oxlint/binding-win32-arm64-msvc": "1.83.0", + "@oxlint/binding-win32-ia32-msvc": "1.83.0", + "@oxlint/binding-win32-x64-msvc": "1.83.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", @@ -3960,9 +4000,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -4041,9 +4081,9 @@ } }, "node_modules/tinypool": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.0.tgz", - "integrity": "sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-2.1.2.tgz", + "integrity": "sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==", "dev": true, "license": "MIT", "engines": { @@ -4209,13 +4249,13 @@ } }, "node_modules/vite-plus": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.1.tgz", - "integrity": "sha512-U8KZ3c3mbX0qLfigx9rW2W9J73w9wjdf4s/s91H77ff5afqSYEdZd8Or41Jl99kD42a+UCL3LFXzb5b+bIBCpQ==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/vite-plus/-/vite-plus-0.3.3.tgz", + "integrity": "sha512-gr3t8ZIsytJnpPE0YPxtkouf+asMsc+TWlKXYzEdY6rRXUdCGYxgaPmzRePx1Gyip2gmARRLogAwoMMW9BvSUA==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.148.0", + "@oxc-project/types": "=0.150.0", "@oxlint/plugins": "=1.79.0", "@vitest/browser": "4.1.11", "@vitest/browser-preview": "4.1.11", @@ -4226,10 +4266,10 @@ "@vitest/snapshot": "4.1.11", "@vitest/spy": "4.1.11", "@vitest/utils": "4.1.11", - "oxfmt": "=0.66.0", - "oxlint": "=1.81.0", + "oxfmt": "=0.68.0", + "oxlint": "=1.83.0", "oxlint-tsgolint": "=7.0.2001", - "vite": "npm:@voidzero-dev/vite-plus-core@0.3.1", + "vite": "npm:@voidzero-dev/vite-plus-core@0.3.3", "vitest": "4.1.11" }, "bin": { @@ -4242,14 +4282,14 @@ "node": "^20.19.0 || ^22.18.0 || >=24.11.0" }, "optionalDependencies": { - "@voidzero-dev/vite-plus-darwin-arm64": "0.3.1", - "@voidzero-dev/vite-plus-darwin-x64": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.1", - "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.1", - "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.1" + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.3", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.3", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.3", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.3" }, "peerDependencies": { "@vitest/browser-playwright": "4.1.11", @@ -4265,9 +4305,9 @@ } }, "node_modules/vite-plus/node_modules/@oxc-project/types": { - "version": "0.148.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.148.0.tgz", - "integrity": "sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==", + "version": "0.150.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.150.0.tgz", + "integrity": "sha512-rDS5/31E9HfPl/CIzGrn0DOlvBbXFseQ5URJ9sYMfstbKLD/c6Gm9vmRzRGDdAXyOIL4zmO37lc9RIwYqVruZw==", "dev": true, "license": "MIT", "funding": { @@ -4276,14 +4316,14 @@ }, "node_modules/vite-plus/node_modules/vite": { "name": "@voidzero-dev/vite-plus-core", - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.1.tgz", - "integrity": "sha512-3uVTyZzrLWKV3aIjRPuqkPwzG0iNQIncJlCDIT/gXFk0FQOW5US9bSHf3jsDqD+NjgTP1vAfI99MZuYTUjEDbg==", + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@voidzero-dev/vite-plus-core/-/vite-plus-core-0.3.3.tgz", + "integrity": "sha512-Ui92VENIZG+p0nMJuVGMsHBHrQqgUNxXObdo5PlSDEBOdRmD8AHKr8tOClZTVU1U5blLQKtHKplxtTun0yZEmw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "=0.148.0", - "@oxc-project/types": "=0.148.0", + "@oxc-project/runtime": "=0.150.0", + "@oxc-project/types": "=0.150.0", "lightningcss": "^1.33.0", "postcss": "^8.5.6", "yuku-codegen": "^0.9.3", @@ -4293,20 +4333,20 @@ "node": "^20.19.0 || ^22.18.0 || >=24.11.0" }, "optionalDependencies": { - "@voidzero-dev/vite-plus-darwin-arm64": "0.3.1", - "@voidzero-dev/vite-plus-darwin-x64": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.1", - "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.1", - "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.1", - "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.1", + "@voidzero-dev/vite-plus-darwin-arm64": "0.3.3", + "@voidzero-dev/vite-plus-darwin-x64": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-arm64-musl": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-gnu": "0.3.3", + "@voidzero-dev/vite-plus-linux-x64-musl": "0.3.3", + "@voidzero-dev/vite-plus-win32-arm64-msvc": "0.3.3", + "@voidzero-dev/vite-plus-win32-x64-msvc": "0.3.3", "fsevents": "~2.3.3" }, "peerDependencies": { "@arethetypeswrong/core": "^0.18.1", "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.4.0 || ^0.5.0", + "@vitejs/devtools": "^0.7.1", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", diff --git a/package.json b/package.json index c89aecc..824e075 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,9 @@ }, "files": [ "dist/**/*.js", - "dist/**/*.d.ts" + "dist/**/*.d.ts", + "docs/*.md", + "CHANGELOG.md" ], "type": "module", "sideEffects": false, @@ -64,9 +66,10 @@ "test:types": "npm run build && tsc -p tests/types/tsconfig.json", "test:publint": "publint", "pack:check": "npm pack --ignore-scripts --dry-run --json", - "check": "npm run lint && npm run test:coverage && npm run test:types && npm run build && npm run bench && npm run test:publint && npm run pack:check", + "check": "npm run lint && npm run test:coverage && npm run test:types && npm run test:packed && npm run build && npm run bench && npm run test:publint && npm run pack:check", "prepack": "npm run build", - "prepublishOnly": "npm run check" + "prepublishOnly": "npm run check", + "test:packed": "npm run build && node tests/packed-contract.mjs" }, "dependencies": { "tsx": "^4.23.12" @@ -74,11 +77,12 @@ "devDependencies": { "@types/node": "^26.3.0", "@types/pg": "^8.23.1", - "@vitest/coverage-v8": "^4.1.11", + "@typescript/typescript6": "npm:typescript@6.0.2", + "@vitest/coverage-v8": "4.1.11", "pg": "^8.23.0", "pg-query-stream": "^4.17.0", "publint": "^0.3.24", - "typescript": "^7.0.2", + "typescript": "7.0.2", "vite-plus": "^0.3.1", "vitest": "^4.1.11" }, diff --git a/src/adapter.ts b/src/adapter.ts index 74eb758..c6191a7 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -20,6 +20,8 @@ export interface DatabaseAdapter { query: SqlQuery, options?: QueryOptions, ): Promise>; + /** Executes a migration's complete SQL script without interpreting result sets. */ + executeScript?(sql: string, options?: QueryOptions): Promise; stream?>( query: SqlQuery, options?: QueryOptions, diff --git a/src/cleanup-recovery.test.ts b/src/cleanup-recovery.test.ts new file mode 100644 index 0000000..8f4dcd9 --- /dev/null +++ b/src/cleanup-recovery.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { DatabaseSync } from "node:sqlite"; +import { describe, expect, it, vi } from "vitest"; +import { sqlite } from "./sqlite"; + +describe("SQLite cleanup failure and persisted recovery", () => { + it.each(["root", "nested", "root-close"] as const)( + "should discard the connection after %s rollback failure and release its shared-file lock", + async (phase) => { + const root = await mkdtemp(path.join(tmpdir(), "askr-orm-cleanup-")); + const filename = path.join(root, "probe.sqlite"); + const first = await sqlite({ filename }).open(); + const second = await sqlite({ filename }).open(); + const primary = new Error("callback failed"); + const originalExec = DatabaseSync.prototype.exec; + const exec = vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function ( + this: DatabaseSync, + sql, + ) { + if ( + (phase !== "nested" && sql === "ROLLBACK") || + (phase === "nested" && sql.startsWith("ROLLBACK TO SAVEPOINT")) + ) + throw new Error("rollback transport failed"); + return originalExec.call(this, sql); + }); + const close = + phase === "root-close" + ? vi.spyOn(DatabaseSync.prototype, "close").mockImplementationOnce(() => { + throw new Error("close temporarily failed"); + }) + : undefined; + try { + await first.execute({ + text: 'CREATE TABLE "items" ("id" integer PRIMARY KEY)', + values: [], + }); + const pending = first.transaction(async (outer) => { + if (phase !== "nested") { + await outer.execute({ text: 'INSERT INTO "items" VALUES ($1)', values: [1] }); + throw primary; + } + await expect( + outer.transaction(async (inner) => { + await inner.execute({ text: 'INSERT INTO "items" VALUES ($1)', values: [1] }); + throw primary; + }), + ).rejects.toBe(primary); + // Handling the nested error cannot make its unrolled-back writes safe. + }); + if (phase !== "nested") await expect(pending).rejects.toBe(primary); + else await expect(pending).rejects.toThrow(/closed|not open/); + await expect(first.execute({ text: 'SELECT * FROM "items"', values: [] })).rejects.toThrow( + /closed/, + ); + // An explicit close must retry a failed quarantine close before recovery. + if (close) { + await first.close?.(); + expect(close).toHaveBeenCalledTimes(2); + close.mockRestore(); + } + exec.mockRestore(); + await expect( + second.execute({ text: 'SELECT * FROM "items"', values: [] }), + ).resolves.toMatchObject({ rows: [] }); + await second.execute({ text: 'INSERT INTO "items" VALUES ($1)', values: [2] }); + await first.close?.(); + await second.close?.(); + const reopened = await sqlite({ filename }).open(); + try { + expect( + (await reopened.execute({ text: 'SELECT * FROM "items"', values: [] })).rows, + ).toEqual([{ id: 2 }]); + } finally { + await reopened.close?.(); + } + } finally { + close?.mockRestore(); + exec.mockRestore(); + await first.close?.(); + await second.close?.(); + await rm(root, { recursive: true, force: true }); + } + }, + ); +}); diff --git a/src/client.test.ts b/src/client.test.ts index bad2518..f7f704b 100644 --- a/src/client.test.ts +++ b/src/client.test.ts @@ -1,6 +1,7 @@ +import { createDatabaseClient } from "./client"; import { describe, expect, it } from "vitest"; import type { DatabaseAdapter, ExecutionResult, QueryOptions, TransactionOptions } from "./adapter"; -import { columnRef, createDatabaseClient, eq, table, text, uuid } from "./index"; +import { columnRef, eq, table, text, uuid } from "./index"; import type { SqlQuery } from "./sql"; class RecordingAdapter implements DatabaseAdapter { diff --git a/src/errors.test.ts b/src/errors.test.ts index b0fce34..18ace97 100644 --- a/src/errors.test.ts +++ b/src/errors.test.ts @@ -1,5 +1,6 @@ +import { normalizeDatabaseError } from "./errors"; import { describe, expect, it } from "vitest"; -import { DatabaseError, normalizeDatabaseError } from "./index"; +import { DatabaseError } from "./index"; describe("database errors", () => { it.each([ diff --git a/src/index.ts b/src/index.ts index ad316d0..e4b6b71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,49 +9,21 @@ export type { TelemetryOptions, TransactionOptions, } from "./adapter"; -export { - createDatabaseClient, - TableClient, - type DatabaseClient, - type ReturningRows, - type ReturningRow, - type ReturningStatus, - type WriteResult, -} from "./client"; +export type { DatabaseClient, WriteResult } from "./client"; export { defineDatabase, type DatabaseDefinition, type DatabaseToolingAdapter, - type CleanDatabaseOptions, type GeneratedDatabaseArtifact, } from "./definition"; -export { - defineQuery, - type RegisteredQuery, - type RegisteredQueryFunction, -} from "./registered-query"; -export { DatabaseError, normalizeDatabaseError, type DatabaseErrorCategory } from "./errors"; -export { - type AppliedMigration, - type BundledMigration, - type MigrationApplyOptions, - type MigrationApplyResult, - type MigrationEvent, - type MigrationManifest, - type MigrationPlan, - type MigrationPlanEntry, - type MigrationsApi, +export { defineQuery, type RegisteredQuery } from "./registered-query"; +export { DatabaseError, type DatabaseErrorCategory } from "./errors"; +export type { + MigrationApplyOptions, + MigrationEvent, + MigrationManifest, + MigrationsApi, } from "./migrations"; -export { assertSafeIdentifier, quoteIdentifier, toSnakeCase } from "./naming"; -export { - type JoinTarget, - type JoinedQuery, - type PreparedQuery, - type References, - type SelectQuery, - type Selection, - type SelectionResult, -} from "./query"; export { bigInt, boolean, @@ -70,56 +42,34 @@ export { unique, uuid, view, - type AnyColumn, type AnyTable, - type CheckConstraint, type Codec, - type ColumnAst, type ColumnBuilder, - type ColumnValue, - type EnumDefinition, - type IndexDefinition, type InferInsert, type InferKey, type InferPatch, type InferRow, - type TableConstraint, - type TableDefinition, - type TableOptions, - type UniqueConstraint, - type ViewDefinition, } from "./schema"; export { and, columnRef, - compileKeyedSql, compileSql, eq, escapeLikePattern, - executeKeyedSql, gt, gte, - identifier, ilike, inArray, isNotNull, isNull, - joinFragments, like, - literal, lt, lte, ne, not, or, sql, - tableRefs, - unsafeSql, - type ColumnRef, type Expression, - type KeyedSql, type SqlFragment, type SqlQuery, - type TableRefs, - type UnsafeSql, } from "./sql"; diff --git a/src/integration.test.ts b/src/integration.test.ts index d3eee3d..46556b4 100644 --- a/src/integration.test.ts +++ b/src/integration.test.ts @@ -1,3 +1,5 @@ +import { assertValueConformance } from "./value-conformance.fixture"; +import { createDatabaseClient } from "./client"; import { randomUUID } from "node:crypto"; import { execFile } from "node:child_process"; import path from "node:path"; @@ -5,7 +7,7 @@ import { promisify } from "node:util"; import { Pool } from "pg"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import type { DatabaseAdapter } from "./adapter"; -import { createDatabaseClient, eq, table, text, uuid, type DatabaseClient } from "./index"; +import { eq, table, text, uuid, type DatabaseClient } from "./index"; import { postgres, timestampTz } from "./postgres"; import { createMigrationsApi, type MigrationManifest } from "./migrations"; @@ -244,4 +246,114 @@ integration("PostgreSQL adapter conformance", () => { await expect(migrations.resolve("01FAILED", "rolled-back")).resolves.toBeUndefined(); expect((await migrations.plan()).pending.map(({ id }) => id)).toEqual(["01FAILED"]); }); + + it("should preserve a callback failure through connection loss during rollback and obtain a fresh connection", async () => { + const id = randomUUID(); + const primary = new Error("rollback after disconnect"); + let killed = 0; + await expect( + adapter.transaction(async (transaction) => { + await transaction.execute({ + text: 'INSERT INTO "orm_groups" ("id", "name") VALUES ($1, $2)', + values: [id, "must roll back"], + }); + killed = ( + await transaction.execute<{ pid: number }>({ + text: "SELECT pg_backend_pid() AS pid", + values: [], + }) + ).rows[0]!.pid; + expect( + (await pool.query("SELECT pg_terminate_backend($1) AS terminated", [killed])).rows[0] + .terminated, + ).toBe(true); + throw primary; + }), + ).rejects.toBe(primary); + expect( + (await adapter.execute({ text: 'SELECT * FROM "orm_groups" WHERE "id" = $1', values: [id] })) + .rows, + ).toEqual([]); + const fresh = await adapter.execute<{ pid: number }>({ + text: "SELECT pg_backend_pid() AS pid", + values: [], + }); + expect(fresh.rows[0]!.pid).not.toBe(killed); + }); + + it("should apply every statement of a PostgreSQL migration script atomically", async () => { + await pool.query('DROP TABLE IF EXISTS "_askr_migrations", "orm_migration_script"'); + const manifest: MigrationManifest = { + migrations: [ + { + id: "01PGSCRIPT", + parent: null, + checksum: "pgscript", + transactional: true, + sql: 'CREATE TABLE "orm_migration_script" ("id" integer PRIMARY KEY); INSERT INTO "orm_migration_script" VALUES (1); INSERT INTO "orm_migration_script" VALUES (2);', + }, + ], + }; + try { + const migrations = createMigrationsApi(adapter, manifest); + await expect(migrations.apply()).resolves.toEqual({ applied: ["01PGSCRIPT"] }); + expect( + ( + await adapter.execute({ + text: 'SELECT * FROM "orm_migration_script" ORDER BY id', + values: [], + }) + ).rows, + ).toEqual([{ id: 1 }, { id: 2 }]); + expect((await migrations.plan()).pending).toHaveLength(0); + } finally { + await pool.query('DROP TABLE IF EXISTS "_askr_migrations", "orm_migration_script"'); + } + }); + + it("should release the migration lock and leave no application DDL or ledger entry after self-disconnect", async () => { + await pool.query('DROP TABLE IF EXISTS "_askr_migrations", "orm_migration_disconnect"'); + const manifest: MigrationManifest = { + migrations: [ + { + id: "01DISCONNECT", + parent: null, + checksum: "disconnect", + transactional: true, + sql: 'CREATE TABLE "orm_migration_disconnect" ("id" integer PRIMARY KEY); INSERT INTO "orm_migration_disconnect" VALUES (1); SELECT pg_terminate_backend(pg_backend_pid());', + }, + ], + }; + try { + const migrations = createMigrationsApi(adapter, manifest); + await expect(migrations.apply()).rejects.toMatchObject({ + category: "connection", + code: "57P01", + }); + expect( + (await pool.query("SELECT to_regclass('public.orm_migration_disconnect') AS name")).rows, + ).toEqual([{ name: null }]); + expect( + (await adapter.execute({ text: 'SELECT * FROM "_askr_migrations"', values: [] })).rows, + ).toEqual([]); + expect((await migrations.plan()).pending).toHaveLength(1); + const corrected = { + migrations: [ + { + ...manifest.migrations[0]!, + sql: 'CREATE TABLE "orm_migration_disconnect" ("id" integer PRIMARY KEY)', + }, + ], + }; + await expect(createMigrationsApi(adapter, corrected).apply()).resolves.toEqual({ + applied: ["01DISCONNECT"], + }); + expect((await createMigrationsApi(adapter, corrected).plan()).pending).toHaveLength(0); + } finally { + await pool.query('DROP TABLE IF EXISTS "_askr_migrations", "orm_migration_disconnect"'); + } + }); + it("should preserve null, empty, binary and large values through PostgreSQL binding and recover from constraints", async () => { + await assertValueConformance(adapter, "postgres"); + }); }); diff --git a/src/migration-recovery.test.ts b/src/migration-recovery.test.ts new file mode 100644 index 0000000..253fe1f --- /dev/null +++ b/src/migration-recovery.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from "vitest"; +import { sqlite } from "./sqlite"; +import { createMigrationsApi, type MigrationManifest } from "./migrations"; + +describe("SQLite migration scripts and lock recovery", () => { + it("should roll back every statement and ledger write after a middle statement fails, then recover", async () => { + const adapter = await sqlite({ filename: ":memory:" }).open(); + const manifest: MigrationManifest = { + migrations: [ + { + id: "01SCRIPT", + parent: null, + checksum: "script", + transactional: true, + sql: 'CREATE TABLE "items" ("id" integer PRIMARY KEY); INSERT INTO "items" VALUES (1); INSERT INTO "missing_items" VALUES (2);', + }, + ], + }; + try { + await expect(createMigrationsApi(adapter, manifest).apply()).rejects.toThrow(/missing_items/); + expect( + ( + await adapter.execute({ + text: "SELECT name FROM sqlite_schema WHERE name = 'items'", + values: [], + }) + ).rows, + ).toEqual([]); + expect( + (await adapter.execute({ text: 'SELECT * FROM "_askr_migrations"', values: [] })).rows, + ).toEqual([]); + expect((await createMigrationsApi(adapter, manifest).plan()).pending).toHaveLength(1); + const corrected = { + migrations: [ + { + ...manifest.migrations[0]!, + sql: 'CREATE TABLE "items" ("id" integer PRIMARY KEY); INSERT INTO "items" VALUES (3);', + }, + ], + }; + await expect(createMigrationsApi(adapter, corrected).apply()).resolves.toEqual({ + applied: ["01SCRIPT"], + }); + expect((await adapter.execute({ text: 'SELECT * FROM "items"', values: [] })).rows).toEqual([ + { id: 3 }, + ]); + expect((await createMigrationsApi(adapter, corrected).plan()).pending).toHaveLength(0); + } finally { + await adapter.close?.(); + } + }); + + it("should commit every statement of a successful migration script", async () => { + const adapter = await sqlite({ filename: ":memory:" }).open(); + try { + await expect( + createMigrationsApi(adapter, { + migrations: [ + { + id: "01SUCCESS", + parent: null, + checksum: "success", + transactional: true, + sql: 'CREATE TABLE "items" ("value" text); INSERT INTO "items" VALUES (\'semi;colon\'); INSERT INTO "items" VALUES (\'second\');', + }, + ], + }).apply(), + ).resolves.toEqual({ applied: ["01SUCCESS"] }); + expect( + (await adapter.execute({ text: 'SELECT * FROM "items" ORDER BY rowid', values: [] })).rows, + ).toEqual([{ value: "semi;colon" }, { value: "second" }]); + } finally { + await adapter.close?.(); + } + }); + + it("should release its migration lock and preserve state after cancellation before execution", async () => { + const adapter = await sqlite({ filename: ":memory:" }).open(); + const controller = new AbortController(); + const reason = new DOMException("cancel migration", "AbortError"); + const manifest: MigrationManifest = { + migrations: [ + { + id: "01CANCEL", + parent: null, + checksum: "cancel", + transactional: true, + sql: 'CREATE TABLE "items" ("id" integer PRIMARY KEY)', + }, + ], + }; + const migrations = createMigrationsApi(adapter, manifest); + try { + await expect( + migrations.apply({ + signal: controller.signal, + onEvent: (event) => { + if (event.type === "started") controller.abort(reason); + }, + }), + ).rejects.toMatchObject({ category: "cancellation", cause: reason }); + expect( + ( + await adapter.execute({ + text: "SELECT name FROM sqlite_schema WHERE name = 'items'", + values: [], + }) + ).rows, + ).toEqual([]); + expect((await migrations.plan()).pending).toHaveLength(1); + await expect(migrations.apply()).resolves.toEqual({ applied: ["01CANCEL"] }); + } finally { + await adapter.close?.(); + } + }); +}); diff --git a/src/migrations.ts b/src/migrations.ts index 8a1d12c..c4e52ea 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -169,7 +169,7 @@ async function applyOne( if (migration.transactional) { await adapter.transaction( async (transaction) => { - await transaction.execute({ text: migration.sql, values: [] }, options); + await executeScript(transaction, migration.sql, options); await recordState(transaction, migration, "applied", performance.now() - started, options); }, options.signal === undefined ? {} : { signal: options.signal }, @@ -177,7 +177,7 @@ async function applyOne( } else { await recordState(adapter, migration, "applying", null, options); try { - await adapter.execute({ text: migration.sql, values: [] }, options); + await executeScript(adapter, migration.sql, options); await recordState(adapter, migration, "applied", performance.now() - started, options); } catch (error) { await recordState(adapter, migration, "failed", performance.now() - started, options).catch( @@ -191,6 +191,15 @@ async function applyOne( return durationMs; } +async function executeScript( + adapter: DatabaseAdapter, + sql: string, + options: QueryOptions, +): Promise { + if (adapter.executeScript) await adapter.executeScript(sql, options); + else await adapter.execute({ text: sql, values: [] }, options); +} + export function createMigrationsApi( adapter: DatabaseAdapter, manifest: MigrationManifest, diff --git a/src/postgres.test.ts b/src/postgres.test.ts index bd40cf4..e7c4b58 100644 --- a/src/postgres.test.ts +++ b/src/postgres.test.ts @@ -5,6 +5,9 @@ const state = vi.hoisted(() => ({ ends: 0, releases: 0, failRollback: false, + failUnlock: false, + failDeallocate: false, + releaseErrors: [] as unknown[], clientErrorListeners: new Set<(error: Error) => void>(), })); @@ -14,6 +17,10 @@ vi.mock("pg", () => { const statement = typeof config === "string" ? config : (config.text ?? ""); state.statements.push(statement); if (state.failRollback && statement === "ROLLBACK") throw new Error("rollback failed"); + if (state.failUnlock && statement.startsWith("SELECT pg_advisory_unlock")) + throw new Error("unlock failed"); + if (state.failDeallocate && statement === "DEALLOCATE askr_describe") + throw new Error("deallocate failed"); return { rows: [], rowCount: 0 }; } @@ -26,8 +33,9 @@ vi.mock("pg", () => { off: (event: string, listener: (error: Error) => void) => { if (event === "error") state.clientErrorListeners.delete(listener); }, - release: () => { + release: (error?: unknown) => { state.releases += 1; + state.releaseErrors.push(error); }, }; } @@ -54,6 +62,9 @@ describe("PostgreSQL adapter", () => { state.ends = 0; state.releases = 0; state.failRollback = false; + state.failUnlock = false; + state.failDeallocate = false; + state.releaseErrors.length = 0; state.clientErrorListeners.clear(); }); @@ -95,6 +106,46 @@ describe("PostgreSQL adapter", () => { ).rejects.toMatchObject({ category: "connection", code: "57P01" }); expect(state.clientErrorListeners).toHaveLength(0); expect(state.releases).toBe(1); + expect(state.releaseErrors[0]).toMatchObject({ category: "connection", code: "57P01" }); + await adapter.close?.(); + }); + + it("should discard a session after advisory unlock fails without replacing the callback error", async () => { + const adapter = await postgres({ + url: "postgres://target", + shadowUrl: "postgres://shadow", + }).open(); + const primary = new Error("migration failed"); + state.failUnlock = true; + await expect( + adapter.migrationLock!(async () => { + throw primary; + }), + ).rejects.toBe(primary); + expect(state.releases).toBe(1); + expect(state.releaseErrors[0]).toMatchObject({ message: "unlock failed" }); + expect(state.clientErrorListeners).toHaveLength(0); + await adapter.close?.(); + }); + + it("should release and discard a client even if statement deallocation fails", async () => { + const shadow = await postgres({ + url: "postgres://target", + shadowUrl: "postgres://shadow", + }).shadow(); + state.failDeallocate = true; + await expect(shadow.describe("SELECT $1::text", ["value"])).rejects.toThrow( + "deallocate failed", + ); + expect(state.releases).toBe(1); + expect(state.releaseErrors[0]).toMatchObject({ message: "deallocate failed" }); + expect(state.clientErrorListeners).toHaveLength(0); + state.failDeallocate = false; + await expect(shadow.describe("SELECT $1::text", ["value"])).resolves.toMatchObject({ + parameters: ["value"], + }); + expect(state.releases).toBe(2); + await shadow.close?.(); }); it("should preserve the callback error when rollback fails", async () => { @@ -112,5 +163,7 @@ describe("PostgreSQL adapter", () => { ).rejects.toBe(callbackError); expect(state.statements).toEqual(["BEGIN", "ROLLBACK"]); expect(state.releases).toBe(1); + expect(state.releaseErrors[0]).toMatchObject({ message: "rollback failed" }); + await adapter.close?.(); }); }); diff --git a/src/postgres.ts b/src/postgres.ts index 377bfbe..57c9ac1 100644 --- a/src/postgres.ts +++ b/src/postgres.ts @@ -57,7 +57,17 @@ class CheckedOutClient { return this.client.query(query as never) as T; } - release(): void { + async cleanup(statement: string): Promise { + try { + await this.query(statement); + } catch (error) { + // A failed rollback/unlock cannot leave a reusable pooled connection. + this.failure ??= normalizeDatabaseError(error); + } + } + + release(error?: unknown): void { + if (error !== undefined) this.failure ??= normalizeDatabaseError(error); this.client.off("error", this.onError); this.client.release(this.failure); } @@ -88,6 +98,15 @@ class PgAdapter implements DatabaseAdapter { return { rows: result.rows as Row[], rowCount: result.rowCount ?? result.rows.length }; } + async executeScript(sql: string, options: QueryOptions = {}): Promise { + if (options.signal?.aborted) throw options.signal.reason; + await this.executor.query({ + text: sql, + ...(options.signal ? { signal: options.signal } : {}), + ...(options.timeoutMs ? { query_timeout: options.timeoutMs } : {}), + }); + } + async *stream(query: SqlQuery, options: QueryOptions = {}): AsyncIterable { if (options.signal?.aborted) throw options.signal.reason; if (!this.client && !this.pool) throw new Error("PostgreSQL streaming requires a pool."); @@ -134,8 +153,8 @@ class PgAdapter implements DatabaseAdapter { await this.client.query(`RELEASE SAVEPOINT ${savepoint}`); return value; } catch (error) { - await this.client.query(`ROLLBACK TO SAVEPOINT ${savepoint}`).catch(() => undefined); - await this.client.query(`RELEASE SAVEPOINT ${savepoint}`).catch(() => undefined); + await this.client.cleanup(`ROLLBACK TO SAVEPOINT ${savepoint}`); + await this.client.cleanup(`RELEASE SAVEPOINT ${savepoint}`); throw error; } } @@ -154,14 +173,14 @@ class PgAdapter implements DatabaseAdapter { await client.query("COMMIT"); return value; } catch (error) { - await client.query("ROLLBACK").catch(() => undefined); + await client.cleanup("ROLLBACK"); throw error; } finally { owned?.release(); } } - async session(callback: (adapter: DatabaseAdapter) => Promise): Promise { + async session(callback: (adapter: PgAdapter) => Promise): Promise { if (this.client) return callback(this); if (!this.pool) throw new Error("PostgreSQL sessions require a pool."); const client = new CheckedOutClient(await this.pool.connect()); @@ -180,9 +199,7 @@ class PgAdapter implements DatabaseAdapter { try { return await callback(session); } finally { - await session - .execute({ text: `SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`, values: [] }) - .catch(() => undefined); + await session.client!.cleanup(`SELECT pg_advisory_unlock(${MIGRATION_LOCK_KEY})`); } }); } @@ -235,14 +252,18 @@ async function pgTooling( }, async describe(sql, parameterNames) { const client = new CheckedOutClient(await pool.connect()); - let prepared = false; + let cleanupError: unknown; try { await client.query(`PREPARE askr_describe AS ${sql}`); - prepared = true; + try { + await client.query("DEALLOCATE askr_describe"); + } catch (error) { + cleanupError = error; + throw error; + } return { parameters: [...parameterNames], columns: [] }; } finally { - if (prepared) await client.query("DEALLOCATE askr_describe"); - client.release(); + client.release(cleanupError); } }, async close() { diff --git a/src/schema.test.ts b/src/schema.test.ts index 5d12546..ff1e74d 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -1,8 +1,8 @@ +import { toSnakeCase } from "./naming"; import { describe, expect, expectTypeOf, it } from "vitest"; import { table, text, - toSnakeCase, uuid, type Codec, type InferInsert, diff --git a/src/sql.test.ts b/src/sql.test.ts index 5f35ab9..c9fb07d 100644 --- a/src/sql.test.ts +++ b/src/sql.test.ts @@ -1,17 +1,6 @@ +import { compileKeyedSql, keyedSql } from "./sql"; import { describe, expect, it } from "vitest"; -import { - and, - columnRef, - compileKeyedSql, - compileSql, - eq, - escapeLikePattern, - identifier, - inArray, - like, - literal, - sql, -} from "./index"; +import { and, columnRef, compileSql, eq, escapeLikePattern, inArray, like, sql } from "./index"; import { rewritePlaceholders, sqlStructure } from "./placeholders"; describe("SQL boundaries", () => { @@ -31,7 +20,7 @@ describe("SQL boundaries", () => { it("should parameterize values and quote generated identifiers", () => { const input = `x'); DROP TABLE users; --`; expect( - compileSql(sql`SELECT * FROM ${identifier("user data")} WHERE email = ${input}`), + compileSql(sql`SELECT * FROM ${sql.identifier("user data")} WHERE email = ${input}`), ).toEqual({ text: 'SELECT * FROM "user data" WHERE email = $1', values: [input], @@ -58,18 +47,18 @@ describe("SQL boundaries", () => { }); it("should require static keyed SQL and reuse repeated named parameters", () => { - const query = sql.key("users.by-email", { email: "" })` + const query = keyedSql("users.by-email", { email: "" })` SELECT id FROM users WHERE email = :email OR backup_email = :email `; expect(compileKeyedSql(query, { email: "a@example.com" })).toEqual({ text: "\n SELECT id FROM users WHERE email = $1 OR backup_email = $1\n ", values: ["a@example.com"], }); - expect(() => sql.key("bad key", {})``).toThrow(/Invalid keyed SQL key/); + expect(() => keyedSql("bad key", {})``).toThrow(/Invalid keyed SQL key/); }); it("should replace only structural named parameters given inert SQL regions", () => { - const exact = sql.key("notes.search", { email: "" })` + const exact = keyedSql("notes.search", { email: "" })` SELECT id FROM users WHERE note = 'contact via :email for help' AND email = :email `; expect(compileKeyedSql(exact, { email: "attacker@example.com" })).toEqual({ @@ -102,7 +91,7 @@ describe("SQL boundaries", () => { }); it("should keep placeholder-like text inert across escaped and JSON-shaped values", () => { - const query = sql.key("documents.by-id", { id: "", payload: {} })` + const query = keyedSql("documents.by-id", { id: "", payload: {} })` SELECT ':id', E'escaped\\:id', payload FROM documents WHERE payload = :payload::jsonb AND id = :id `; @@ -115,11 +104,15 @@ describe("SQL boundaries", () => { }); it("should quote embedded identifier delimiters and preserve edge finite literals", () => { - expect(compileSql(sql`SELECT ${identifier('odd"name')} AS ${identifier("select")}`)).toEqual({ + expect( + compileSql(sql`SELECT ${sql.identifier('odd"name')} AS ${sql.identifier("select")}`), + ).toEqual({ text: 'SELECT "odd""name" AS "select"', values: [], }); - expect(compileSql(sql`SELECT ${literal(-0)}, ${literal(Number.MAX_SAFE_INTEGER)}`)).toEqual({ + expect( + compileSql(sql`SELECT ${sql.literal(-0)}, ${sql.literal(Number.MAX_SAFE_INTEGER)}`), + ).toEqual({ text: "SELECT 0, 9007199254740991", values: [], }); @@ -131,13 +124,13 @@ describe("SQL boundaries", () => { it("should reject non-finite numbers given SQL literal formatting", () => { for (const value of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY]) { - expect(() => compileSql(sql`SELECT ${literal(value)}`)).toThrow(/finite number/i); + expect(() => compileSql(sql`SELECT ${sql.literal(value)}`)).toThrow(/finite number/i); } }); it("should reject integer literals whose precision cannot be represented", () => { for (const value of [Number.MAX_SAFE_INTEGER + 1, Number.MIN_SAFE_INTEGER - 1]) { - expect(() => compileSql(sql`SELECT ${literal(value)}`)).toThrow(/safe integer/i); + expect(() => compileSql(sql`SELECT ${sql.literal(value)}`)).toThrow(/safe integer/i); } }); }); diff --git a/src/sql.ts b/src/sql.ts index bb89d49..ead2836 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -25,7 +25,7 @@ type SqlChunk = | { readonly kind: "identifier"; readonly value: string } | { readonly kind: "fragment"; readonly value: SqlFragment }; -/** A named SQL template with `:named` parameters, built with `sql.key(...)`. See {@link compileKeyedSql}. */ +/** A named SQL template with `:named` parameters, built by the private keyedSql helper. See {@link compileKeyedSql}. */ export interface KeyedSql, TResult> { readonly kind: "keyed-sql"; readonly key: string; @@ -105,13 +105,9 @@ interface SqlTag { readonly identifier: (name: string) => SqlFragment; readonly literal: (value: string | number | boolean | null) => SqlFragment; readonly unsafe: (text: string) => UnsafeSql; - readonly key: , TResult = unknown>( - keyValue: string, - parameters: TParameters, - ) => (strings: TemplateStringsArray) => KeyedSql; } -function keyedSql, TResult = unknown>( +export function keyedSql, TResult = unknown>( keyValue: string, parameters: TParameters, ): (strings: TemplateStringsArray) => KeyedSql { @@ -135,14 +131,13 @@ function keyedSql, TResult = unknown /** * Tagged template for building a {@link SqlFragment}: interpolated fragments splice in, other - * values become bound parameters. Also exposes `sql.identifier`, `sql.literal`, `sql.unsafe`, - * and `sql.key` for keyed/named-parameter queries. + * values become bound parameters. Exposes `sql.identifier`, `sql.literal` and `sql.unsafe` + * for identifiers, explicit literals, and the unsafe SQL boundary. */ export const sql: SqlTag = Object.assign(sqlTag, { identifier, literal, unsafe: unsafeSql, - key: keyedSql, }); /** Compiles a {@link SqlFragment} tree into parameterized SQL text and an ordered values array. */ diff --git a/src/sqlite.test.ts b/src/sqlite.test.ts index 350e45d..ded13c3 100644 --- a/src/sqlite.test.ts +++ b/src/sqlite.test.ts @@ -1,17 +1,10 @@ +import { createDatabaseClient } from "./client"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { DatabaseSync } from "node:sqlite"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; -import { - createDatabaseClient, - defineDatabase, - defineQuery, - escapeLikePattern, - like, - table, - text, -} from "./index"; +import { defineDatabase, defineQuery, escapeLikePattern, like, table, text } from "./index"; import { jsonb } from "./postgres"; import { sqlite } from "./sqlite"; import { createMigrationsApi } from "./migrations"; @@ -42,12 +35,13 @@ describe("SQLite dialect", () => { const adapter = await sqlite({ filename: ":memory:" }).open(); const callbackError = new Error("callback failed"); const originalExec = DatabaseSync.prototype.exec; - const exec = vi - .spyOn(DatabaseSync.prototype, "exec") - .mockImplementation(function (this: DatabaseSync, sql) { - if (sql === "ROLLBACK") throw new Error("rollback failed"); - return originalExec.call(this, sql); - }); + const exec = vi.spyOn(DatabaseSync.prototype, "exec").mockImplementation(function ( + this: DatabaseSync, + sql, + ) { + if (sql === "ROLLBACK") throw new Error("rollback failed"); + return originalExec.call(this, sql); + }); try { await expect( diff --git a/src/sqlite.ts b/src/sqlite.ts index 2000b2f..2b7291a 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -58,6 +58,7 @@ class SqliteAdapter implements DatabaseAdapter { private readonly queue: SqliteQueue; private savepoint = 0; private closed = false; + private databaseClosed = false; constructor( private readonly database: DatabaseSync, @@ -74,11 +75,26 @@ class SqliteAdapter implements DatabaseAdapter { if (this.closed) throw new Error("SQLite database is closed."); } + private closeDatabase(): void { + if (this.databaseClosed) return; + this.database.close(); + this.databaseClosed = true; + this.releaseQueue(); + } + private bestEffortExec(sql: string): void { + if (this.closed) return; try { this.database.exec(sql); } catch { - // Preserve the operation error that triggered transaction cleanup. + // Quarantine an unrolled-back connection while preserving the operation + // error. Closing rolls back its remaining writes and releases file locks. + this.closed = true; + try { + this.closeDatabase(); + } catch { + // Preserve the operation error; explicit close can retry this cleanup. + } } } @@ -103,6 +119,16 @@ class SqliteAdapter implements DatabaseAdapter { }); } + async executeScript(sql: string, options: QueryOptions = {}): Promise { + const perform = () => { + this.assertOpen(); + if (options.signal?.aborted) throw options.signal.reason; + this.database.exec(sqliteSql(sql)); + }; + if (context.getStore()?.adapter === this) perform(); + else await this.queue.run(async () => perform()); + } + async *stream(query: SqlQuery, options: QueryOptions = {}): AsyncIterable { const release = context.getStore()?.adapter === this ? () => undefined : await this.queue.acquire(); @@ -131,9 +157,11 @@ class SqliteAdapter implements DatabaseAdapter { } return this.queue.run(() => context.run({ adapter: this, nestedQueue: new SqliteQueue() }, async () => { + this.assertOpen(); this.database.exec("BEGIN"); try { const result = await callback(this); + this.assertOpen(); this.database.exec("COMMIT"); return result; } catch (error) { @@ -144,10 +172,14 @@ class SqliteAdapter implements DatabaseAdapter { ); } - session(callback: (adapter: DatabaseAdapter) => Promise): Promise { + async session(callback: (adapter: DatabaseAdapter) => Promise): Promise { + this.assertOpen(); if (context.getStore()?.adapter === this) return callback(this); return this.queue.run(() => - context.run({ adapter: this, nestedQueue: new SqliteQueue() }, () => callback(this)), + context.run({ adapter: this, nestedQueue: new SqliteQueue() }, () => { + this.assertOpen(); + return callback(this); + }), ); } @@ -156,6 +188,7 @@ class SqliteAdapter implements DatabaseAdapter { } private async nested(callback: (adapter: DatabaseAdapter) => Promise): Promise { + this.assertOpen(); const name = `askr_${++this.savepoint}`; this.database.exec(`SAVEPOINT ${name}`); try { @@ -171,13 +204,8 @@ class SqliteAdapter implements DatabaseAdapter { async close(): Promise { await this.queue.run(async () => { - if (this.closed) return; this.closed = true; - try { - this.database.close(); - } finally { - this.releaseQueue(); - } + this.closeDatabase(); }); } } diff --git a/src/tooling-impl.ts b/src/tooling-impl.ts new file mode 100644 index 0000000..4a4e7f9 --- /dev/null +++ b/src/tooling-impl.ts @@ -0,0 +1,1091 @@ +import { createHash, randomBytes } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { createInterface } from "node:readline/promises"; +import { pathToFileURL } from "node:url"; +import { tsImport } from "tsx/esm/api"; +import type { DatabaseAdapter } from "./adapter"; +import type { DatabaseDefinition, DatabaseToolingAdapter } from "./definition"; +import type { BundledMigration, MigrationManifest } from "./migrations"; +import { quoteIdentifier } from "./naming"; +import { sqlStructure } from "./placeholders"; +import type { + AnyTable, + ColumnAst, + IndexDefinition, + TableConstraint, + ViewDefinition, +} from "./schema"; + +/** Output sink used by {@link runDatabaseCli} for logging and errors. */ +export interface DatabaseCliIo { + log(message?: unknown): void; + error(message?: unknown): void; +} + +/** Options accepted by {@link runDatabaseCli}. */ +export interface RunDatabaseCliOptions { + readonly cwd?: string; + readonly io?: DatabaseCliIo; + readonly confirm?: (message: string) => Promise; +} + +/** Serialized column shape stored in a {@link SchemaSnapshot}. */ +export interface SnapshotColumn extends Omit { + /** Accepted only when reading a legacy snapshot; live definitions cannot declare drops. */ + readonly drop?: boolean; + readonly property: string; + readonly reference?: { + readonly schema: string; + readonly table: string; + readonly column: string; + }; + readonly codec?: string; +} + +/** Serialized table shape stored in a {@link SchemaSnapshot}. */ +export interface SnapshotTable { + readonly schema: string; + readonly name: string; + readonly renamedFrom?: string; + /** Accepted only when reading a legacy snapshot; live definitions cannot declare drops. */ + readonly drop?: boolean; + readonly columns: readonly SnapshotColumn[]; + readonly constraints: readonly TableConstraint[]; +} + +/** Point-in-time serialized shape of a database schema, produced by {@link snapshotDefinition}. */ +export interface SchemaSnapshot { + readonly version: 1; + readonly enums: readonly { + readonly schema: string; + readonly name: string; + readonly values: readonly string[]; + }[]; + readonly tables: readonly SnapshotTable[]; + readonly views: readonly ViewDefinition[]; +} + +interface LoadedDatabase { + readonly name: string; + readonly definition: DatabaseDefinition>; + readonly databaseDir: string; + readonly projectRoot: string; +} + +interface KeyedSqlDescription { + readonly key: string; + readonly source: string; + readonly parameters: readonly string[]; + readonly columns: readonly { + readonly name: string; + readonly dataType: string; + readonly nullable: boolean; + }[]; +} + +const GENERATED_FILE = "generated.ts" as const; + +function sha256(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +function stableJson(value: unknown): string { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function columnSnapshot(property: string, column: AnyTable["$columns"][string]): SnapshotColumn { + const reference = column.ast.references?.(); + const referenceOwner = reference ? columnOwners.get(reference) : undefined; + return { + property, + name: column.ast.name, + dataType: column.ast.dataType, + nullable: column.ast.nullable, + primaryKey: column.ast.primaryKey, + unique: column.ast.unique, + ...(column.ast.default === undefined ? {} : { default: column.ast.default }), + ...(column.ast.generated === undefined ? {} : { generated: column.ast.generated }), + ...(reference === undefined + ? {} + : { + reference: { + schema: referenceOwner?.schema ?? "public", + table: referenceOwner?.table ?? findOwningTableName(reference) ?? "", + column: reference.ast.name, + }, + }), + ...(column.ast.codec === undefined ? {} : { codec: column.ast.codec.name }), + ...(column.ast.renamedFrom === undefined ? {} : { renamedFrom: column.ast.renamedFrom }), + ...(column.ast.convertUsing === undefined ? {} : { convertUsing: column.ast.convertUsing }), + }; +} + +const columnOwners = new WeakMap(); + +function findOwningTableName(column: object): string | undefined { + return columnOwners.get(column)?.table; +} + +/** Serializes a live {@link DatabaseDefinition} into a {@link SchemaSnapshot} for diffing/persistence. */ +export function snapshotDefinition( + definition: DatabaseDefinition< + Record, + Record>> + >, +): SchemaSnapshot { + for (const table of Object.values(definition.tables)) { + for (const column of Object.values(table.$columns)) { + columnOwners.set(column, { schema: table.$schema, table: table.$name }); + } + } + const tables = Object.values(definition.tables) + .map((table): SnapshotTable => ({ + schema: table.$schema, + name: table.$name, + ...(table.$options.renamedFrom === undefined + ? {} + : { renamedFrom: table.$options.renamedFrom }), + columns: Object.entries(table.$columns) + .map(([property, column]) => columnSnapshot(property, column)) + .sort((left, right) => left.name.localeCompare(right.name)), + constraints: [...(table.$options.constraints ?? [])].sort((left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)), + ), + })) + .sort((left, right) => + `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), + ); + return { + version: 1, + enums: [...definition.enums] + .map((entry) => ({ + schema: entry.schema, + name: entry.name, + values: [...entry.values], + })) + .sort((left, right) => + `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), + ), + tables, + views: [...definition.views].sort((left, right) => + `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), + ), + }; +} + +function sqlString(value: string): string { + return `'${value.replaceAll("'", "''")}'`; +} + +function qualified( + schema: string, + name: string, + dialect: "postgres" | "sqlite" = "postgres", +): string { + return dialect === "sqlite" + ? quoteIdentifier(name) + : `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; +} + +function physicalType(dataType: string, dialect: "postgres" | "sqlite"): string { + if (dialect === "postgres") return dataType === "bytes" ? "bytea" : dataType; + if ( + dataType === "uuid" || + dataType === "json" || + dataType.startsWith("timestamp") || + dataType === "date" + ) + return "text"; + if (dataType === "boolean" || dataType === "bigint") return "integer"; + if (dataType === "bytes" || dataType === "bytea") return "blob"; + return dataType === "double precision" ? "real" : dataType; +} + +function columnSql(column: SnapshotColumn, dialect: "postgres" | "sqlite" = "postgres"): string { + const parts = [quoteIdentifier(column.name), physicalType(column.dataType, dialect)]; + if (column.generated) { + parts.push(`GENERATED ALWAYS AS (${column.generated}) STORED`); + } else if (column.default) { + parts.push(`DEFAULT ${column.default}`); + } + if (!column.nullable) parts.push("NOT NULL"); + if (column.primaryKey) parts.push("PRIMARY KEY"); + if (column.unique) parts.push("UNIQUE"); + if (column.reference) { + parts.push( + `REFERENCES ${qualified(column.reference.schema, column.reference.table, dialect)} (${quoteIdentifier(column.reference.column)})`, + ); + } + return parts.join(" "); +} + +function constraintSql(table: SnapshotTable, constraint: TableConstraint): string { + const name = constraint.name ? `CONSTRAINT ${quoteIdentifier(constraint.name)} ` : ""; + if (constraint.kind === "check") return `${name}CHECK (${constraint.expression})`; + if (constraint.kind === "unique") { + return `${name}UNIQUE (${constraint.columns.map(quoteIdentifier).join(", ")})`; + } + throw new Error(`Index ${constraint.name ?? ""} is not an inline constraint.`); +} + +function indexName(table: SnapshotTable, index: IndexDefinition, position: number): string { + return index.name ?? `${table.name}_${position + 1}_idx`; +} + +function createTableSql(table: SnapshotTable, dialect: "postgres" | "sqlite" = "postgres"): string { + const inline = table.constraints.filter((entry) => entry.kind !== "index"); + const parts = [ + ...table.columns.filter((column) => !column.drop).map((column) => columnSql(column, dialect)), + ...inline.map((constraint) => constraintSql(table, constraint)), + ]; + const statements = [ + `CREATE TABLE ${qualified(table.schema, table.name, dialect)} (\n ${parts.join(",\n ")}\n);`, + ]; + table.constraints + .filter((entry): entry is IndexDefinition => entry.kind === "index") + .forEach((index, position) => { + statements.push( + `CREATE${index.unique ? " UNIQUE" : ""} INDEX ${quoteIdentifier(indexName(table, index, position))} ON ${qualified(table.schema, table.name, dialect)}${index.method && dialect === "postgres" ? ` USING ${index.method}` : ""} (${index.expressions.join(", ")})${index.where ? ` WHERE ${index.where}` : ""};`, + ); + }); + return statements.join("\n"); +} + +function activeSnapshot(snapshot: SchemaSnapshot): SchemaSnapshot { + return { + ...snapshot, + tables: snapshot.tables + .filter((table) => !table.drop) + .map((table) => ({ + ...table, + columns: table.columns.filter((column) => !column.drop), + })), + }; +} + +function renderInitialSchema( + snapshot: SchemaSnapshot, + dialect: "postgres" | "sqlite" = "postgres", +): string { + const statements: string[] = []; + const schemas = new Set([ + ...snapshot.enums.map((entry) => entry.schema), + ...snapshot.tables.map((entry) => entry.schema), + ...snapshot.views.map((entry) => entry.schema), + ]); + for (const schema of [...schemas].sort()) { + if (dialect === "postgres" && schema !== "public") + statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schema)};`); + } + for (const value of snapshot.enums) { + statements.push( + `CREATE TYPE ${qualified(value.schema, value.name, dialect)} AS ENUM (${value.values.map(sqlString).join(", ")});`, + ); + } + for (const table of snapshot.tables.filter((entry) => !entry.drop)) { + statements.push(createTableSql(table, dialect)); + } + for (const view of snapshot.views) { + statements.push(`CREATE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`); + } + return `${statements.join("\n\n")}\n`; +} + +function byName( + values: readonly T[], +): Map { + return new Map(values.map((value) => [`${value.schema}.${value.name}`, value])); +} + +/** + * Generates a migration's SQL by diffing a `current` schema snapshot against a `desired` one + * (an empty `current` produces the full initial-schema SQL instead of a diff). + */ +export function diffSnapshots( + current: SchemaSnapshot, + desiredInput: SchemaSnapshot, + dialect: "postgres" | "sqlite" = "postgres", +): string { + const desired = activeSnapshot(desiredInput); + if (current.tables.length === 0 && current.enums.length === 0 && current.views.length === 0) { + return renderInitialSchema(desired, dialect); + } + const statements: string[] = []; + const currentEnums = byName(current.enums); + const desiredEnums = byName(desired.enums); + for (const value of desired.enums) { + const existing = currentEnums.get(`${value.schema}.${value.name}`); + if (!existing) { + statements.push( + `CREATE TYPE ${qualified(value.schema, value.name, dialect)} AS ENUM (${value.values.map(sqlString).join(", ")});`, + ); + continue; + } + if ( + existing.values.some((entry, index) => value.values[index] !== entry) || + value.values.length < existing.values.length + ) { + throw new Error(`Enum ${value.name} removes or reorders values; use a manual migration.`); + } + for (const added of value.values.slice(existing.values.length)) { + statements.push( + `ALTER TYPE ${qualified(value.schema, value.name, dialect)} ADD VALUE ${sqlString(added)};`, + ); + } + } + for (const value of current.enums) { + if (!desiredEnums.has(`${value.schema}.${value.name}`)) { + throw new Error(`Dropping enum ${value.name} requires an explicit manual migration.`); + } + } + + const currentTables = byName(current.tables); + const desiredTables = byName(desired.tables); + for (const table of desired.tables) { + const declaredTable = desiredInput.tables.find( + (entry) => entry.schema === table.schema && entry.name === table.name, + )!; + let existing = currentTables.get(`${table.schema}.${table.name}`); + if (!existing && table.renamedFrom) { + existing = currentTables.get(`${table.schema}.${table.renamedFrom}`); + if (!existing) throw new Error(`renamedFrom table ${table.renamedFrom} does not exist.`); + statements.push( + `ALTER TABLE ${qualified(table.schema, table.renamedFrom, dialect)} RENAME TO ${quoteIdentifier(table.name)};`, + ); + } + if (!existing) { + statements.push(createTableSql(table, dialect)); + continue; + } + const existingColumns = new Map(existing.columns.map((column) => [column.name, column])); + const desiredColumns = new Map(table.columns.map((column) => [column.name, column])); + for (const column of table.columns) { + let old = existingColumns.get(column.name); + if (!old && column.renamedFrom) { + old = existingColumns.get(column.renamedFrom); + if (!old) { + throw new Error( + `${table.name}.${column.property} renamedFrom ${column.renamedFrom} does not exist.`, + ); + } + statements.push( + `ALTER TABLE ${qualified(table.schema, table.name, dialect)} RENAME COLUMN ${quoteIdentifier(column.renamedFrom)} TO ${quoteIdentifier(column.name)};`, + ); + } + if (!old) { + if (!column.nullable && column.default === undefined && column.generated === undefined) { + throw new Error( + `Adding required column ${table.name}.${column.name} needs a default or manual migration.`, + ); + } + statements.push( + `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ADD COLUMN ${columnSql(column, dialect)};`, + ); + continue; + } + if (old.dataType !== column.dataType) { + if (dialect === "sqlite") { + throw new Error( + `Changing ${table.name}.${column.name} requires a reviewed SQLite table rebuild or manual migration.`, + ); + } + if (!column.convertUsing) { + throw new Error( + `Changing ${table.name}.${column.name} from ${old.dataType} to ${column.dataType} requires convertUsing or a manual migration.`, + ); + } + statements.push( + `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ALTER COLUMN ${quoteIdentifier(column.name)} TYPE ${physicalType(column.dataType, dialect)} USING ${column.convertUsing};`, + ); + } + if (old.nullable && !column.nullable) { + throw new Error( + `Making ${table.name}.${column.name} required is ambiguous; use a manual data migration.`, + ); + } + if (!old.nullable && column.nullable) { + if (dialect === "sqlite") { + throw new Error( + `Changing ${table.name}.${column.name} nullability requires a reviewed SQLite table rebuild or manual migration.`, + ); + } + statements.push( + `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ALTER COLUMN ${quoteIdentifier(column.name)} DROP NOT NULL;`, + ); + } + } + for (const old of existing.columns) { + if (!desiredColumns.has(old.name)) { + const renamed = declaredTable.columns.some((column) => column.renamedFrom === old.name); + if (renamed) continue; + throw new Error( + `Dropping ${table.name}.${old.name} requires an explicit manual migration.`, + ); + } + } + if (JSON.stringify(existing.constraints) !== JSON.stringify(table.constraints)) { + throw new Error( + `Changing constraints or indexes on ${table.name} requires a manual migration.`, + ); + } + } + for (const table of current.tables) { + if (desiredTables.has(`${table.schema}.${table.name}`)) continue; + const renamed = desiredInput.tables.some( + (candidate) => candidate.schema === table.schema && candidate.renamedFrom === table.name, + ); + if (renamed) continue; + throw new Error(`Dropping table ${table.name} requires an explicit manual migration.`); + } + + const currentViews = byName(current.views); + const desiredViews = byName(desired.views); + for (const view of desired.views) { + const old = currentViews.get(`${view.schema}.${view.name}`); + if (!old) + statements.push( + `CREATE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`, + ); + else if (old.query !== view.query) { + if (dialect === "sqlite") { + throw new Error(`Changing view ${view.name} requires an explicit SQLite manual migration.`); + } + statements.push( + `CREATE OR REPLACE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`, + ); + } + } + for (const view of current.views) { + if (!desiredViews.has(`${view.schema}.${view.name}`)) { + throw new Error(`Dropping view ${view.name} requires a manual migration.`); + } + } + return statements.length === 0 ? "" : `${statements.join("\n\n")}\n`; +} + +function parseMigration(content: string, file: string): BundledMigration { + const id = /^-- askr:id ([A-Z0-9]+)$/m.exec(content)?.[1]; + const parentValue = /^-- askr:parent (.+)$/m.exec(content)?.[1]; + const transactionalValue = /^-- askr:transactional (true|false)$/m.exec(content)?.[1]; + const risk = /^-- askr:risk (safe|review|destructive)$/m.exec(content)?.[1] as + | BundledMigration["risk"] + | undefined; + if (!id || parentValue === undefined || !transactionalValue) { + throw new Error(`Migration ${file} is missing required askr headers.`); + } + if (!file.startsWith(`${id}.`)) + throw new Error(`Migration file ${file} does not match id ${id}.`); + return { + id, + parent: parentValue === "none" ? null : parentValue, + checksum: sha256(content), + sql: content, + transactional: transactionalValue === "true", + ...(risk === undefined ? {} : { risk }), + }; +} + +async function readMigrations(databaseDir: string): Promise { + const directory = path.join(databaseDir, "migrations"); + const entries = await fs.readdir(directory).catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return []; + throw error; + }); + const migrations = await Promise.all( + entries + .filter((file) => file.endsWith(".sql")) + .sort() + .map(async (file) => + parseMigration(await fs.readFile(path.join(directory, file), "utf8"), file), + ), + ); + let parent: string | null = null; + for (const migration of migrations) { + if (migration.parent !== parent) { + throw new Error( + `Migration ${migration.id} has parent ${migration.parent ?? "none"}; expected ${parent ?? "none"}.`, + ); + } + parent = migration.id; + } + return { migrations }; +} + +const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +function encodeBase32(value: bigint, length: number): string { + let output = ""; + for (let index = 0; index < length; index += 1) { + output = ULID_ALPHABET[Number(value & 31n)] + output; + value >>= 5n; + } + return output; +} + +function ulid(): string { + const time = encodeBase32(BigInt(Date.now()), 10); + const random = BigInt(`0x${randomBytes(10).toString("hex")}`); + return `${time}${encodeBase32(random, 16)}`; +} + +async function findDatabaseEntry( + start: string, +): Promise<{ projectRoot: string; databaseDir: string }> { + let current = path.resolve(start); + while (true) { + for (const relative of [ + ["database", "index.ts"], + ["src", "database", "index.ts"], + ]) { + const candidate = path.join(current, ...relative); + if (await fs.stat(candidate).catch(() => null)) { + return { projectRoot: current, databaseDir: path.dirname(candidate) }; + } + } + const parent = path.dirname(current); + if (parent === current) { + throw new Error( + "No database/index.ts found. Run this command from an Askr database project.", + ); + } + current = parent; + } +} + +async function loadDatabases(cwd: string): Promise<{ + readonly all: readonly LoadedDatabase[]; + readonly projectRoot: string; +}> { + const location = await findDatabaseEntry(cwd); + const module = (await tsImport(path.join(location.databaseDir, "index.ts"), { + parentURL: pathToFileURL(location.projectRoot).href, + })) as Record; + const exported = module.default ?? module.database; + if (!exported || typeof exported !== "object") { + throw new Error("database/index.ts must export a defineDatabase(...) definition."); + } + if ((exported as { kind?: string }).kind !== "database") { + throw new Error("Unsupported database root export."); + } + return { + projectRoot: location.projectRoot, + all: [ + { + name: "default", + definition: exported as DatabaseDefinition>, + databaseDir: location.databaseDir, + projectRoot: location.projectRoot, + }, + ], + }; +} + +function selectDatabases( + loaded: Awaited>, + name: string | undefined, + all: boolean, + migrationCommand: boolean, +): readonly LoadedDatabase[] { + if (all) return loaded.all; + if (name) { + const selected = loaded.all.find((entry) => entry.name === name); + if (!selected) throw new Error(`Unknown database ${name}.`); + return [selected]; + } + if (loaded.all.length === 1 && !migrationCommand) return loaded.all; + if (loaded.all.length === 1 && loaded.all[0]?.name === "default") return loaded.all; + throw new Error( + migrationCommand + ? "Migration commands require --database or explicit --all." + : "Multiple databases are configured; pass --database .", + ); +} + +async function describeRegisteredQueries( + definition: DatabaseDefinition< + Record, + Record>> + >, + scratch: DatabaseToolingAdapter, +): Promise { + const descriptions: KeyedSqlDescription[] = []; + const keys = new Set(); + for (const query of Object.values(definition.queries)) { + if (keys.has(query.key)) throw new Error(`Duplicate registered query key ${query.key}.`); + keys.add(query.key); + const parameters = [...query.parameters]; + const compiled = query.compile(Object.fromEntries(parameters.map((name) => [name, null]))); + const described = await scratch.describe(compiled.text, parameters); + descriptions.push({ + key: query.key, + source: compiled.text, + parameters, + columns: described.columns, + }); + } + return descriptions.sort((left, right) => left.key.localeCompare(right.key)); +} + +function tsType(dataType: string, nullable: boolean): string { + const normalized = dataType.toLowerCase(); + const base = + normalized === "boolean" + ? "boolean" + : /^(?:smallint|integer|real|double precision)$/.test(normalized) + ? "number" + : normalized === "bigint" + ? "bigint" + : normalized === "bytea" + ? "Uint8Array" + : /^(?:json|jsonb)$/.test(normalized) + ? "unknown" + : "string"; + return nullable ? `${base} | null` : base; +} + +function renderQueries(descriptions: readonly KeyedSqlDescription[]): string { + const lines = ["export interface GeneratedDatabaseQueries {"]; + for (const query of descriptions) { + lines.push(` readonly ${JSON.stringify(query.key)}: {`); + lines.push(" readonly parameters: {"); + for (const parameter of query.parameters) + lines.push(` readonly ${JSON.stringify(parameter)}: unknown;`); + lines.push(" };", " readonly row: {"); + for (const column of query.columns) { + lines.push( + ` readonly ${JSON.stringify(column.name)}: ${tsType(column.dataType, column.nullable)};`, + ); + } + lines.push(" };", " };"); + } + lines.push("}", ""); + return lines.join("\n"); +} + +function generatedArtifacts( + _database: LoadedDatabase, + snapshot: SchemaSnapshot, + descriptions: readonly KeyedSqlDescription[], + manifest: MigrationManifest, +): string { + const identity = sha256(stableJson(activeSnapshot(snapshot))); + return [ + "// Generated by @askrjs/orm. Do not edit.", + 'import type { GeneratedDatabaseArtifact } from "@askrjs/orm";', + "", + renderQueries(descriptions).trimEnd(), + "", + `export const generated = ${JSON.stringify({ schemaIdentity: identity, manifest, queries: Object.fromEntries(descriptions.map((query) => [query.key, { parameters: query.parameters, columns: query.columns }])) }, null, 2)} as const satisfies GeneratedDatabaseArtifact;`, + "", + ].join("\n"); +} + +async function replay(database: LoadedDatabase): Promise<{ + readonly scratch: DatabaseToolingAdapter; + readonly manifest: MigrationManifest; + readonly current: SchemaSnapshot; +}> { + const scratch = await database.definition.scratch(); + if ( + database.definition.targetIdentity && + scratch.identity === database.definition.targetIdentity + ) { + await scratch.close?.(); + throw new Error("Scratch and target database identities are equal; refusing to reset target."); + } + if ( + database.definition.scratchIdentity && + scratch.identity !== database.definition.scratchIdentity + ) { + await scratch.close?.(); + throw new Error("Scratch adapter identity does not match configured scratchIdentity."); + } + const manifest = await readMigrations(database.databaseDir); + try { + await scratch.reset(); + for (const migration of manifest.migrations) await scratch.execute(migration.sql); + const current = (await scratch.introspect()) as SchemaSnapshot; + if (!current || current.version !== 1 || !Array.isArray(current.tables)) { + throw new Error("Scratch introspection did not return an Askr SchemaSnapshot."); + } + return { scratch, manifest, current }; + } catch (error) { + await scratch.close?.(); + throw error; + } +} + +async function writeArtifacts( + database: LoadedDatabase, + artifacts: ReturnType, +): Promise { + await fs.writeFile(path.join(database.databaseDir, GENERATED_FILE), artifacts, "utf8"); +} + +async function validateOne(database: LoadedDatabase): Promise { + const desired = activeSnapshot(snapshotDefinition(database.definition)); + const replayed = await replay(database); + try { + const actual = activeSnapshot(replayed.current); + if (stableJson(actual) !== stableJson(desired)) { + throw new Error("Migration history does not produce the TypeScript schema definition."); + } + const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); + const expected = generatedArtifacts(database, desired, descriptions, replayed.manifest); + const target = path.join(database.databaseDir, GENERATED_FILE); + const committed = await fs.readFile(target, "utf8").catch(() => null); + if (committed !== expected) { + throw new Error(`Generated artifact database/${GENERATED_FILE} is stale.`); + } + } finally { + await replayed.scratch.close?.(); + } +} + +function migrationContents( + id: string, + parent: string | null, + sqlText: string, + transactional: boolean, + risk: NonNullable, +): string { + return `-- askr:id ${id}\n-- askr:parent ${parent ?? "none"}\n-- askr:transactional ${transactional}\n-- askr:risk ${risk}\n\n${sqlText.trim()}\n`; +} + +async function generateOne(database: LoadedDatabase): Promise { + const desired = snapshotDefinition(database.definition); + const replayed = await replay(database); + let created: string | null = null; + try { + const delta = diffSnapshots(replayed.current, desired, database.definition.dialect); + if (delta) { + const id = ulid(); + const parent = replayed.manifest.migrations.at(-1)?.id ?? null; + const risk = /\b(?:DROP|ALTER\s+TABLE\b[^;]*\bTYPE\b)/i.test(sqlStructure(delta)) + ? "destructive" + : "safe"; + const content = migrationContents(id, parent, delta, true, risk); + const directory = path.join(database.databaseDir, "migrations"); + await fs.mkdir(directory, { recursive: true }); + created = path.join(directory, `${id}.sql`); + await fs.writeFile(created, content, "utf8"); + await replayed.scratch.execute(content); + } + const finalSnapshot = activeSnapshot((await replayed.scratch.introspect()) as SchemaSnapshot); + if (stableJson(finalSnapshot) !== stableJson(activeSnapshot(desired))) { + throw new Error("Generated migration did not produce the desired schema."); + } + const manifest = await readMigrations(database.databaseDir); + const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); + await writeArtifacts(database, generatedArtifacts(database, desired, descriptions, manifest)); + return created; + } catch (error) { + if (created) await fs.unlink(created).catch(() => undefined); + throw error; + } finally { + await replayed.scratch.close?.(); + } +} + +async function refreshGenerated(database: LoadedDatabase): Promise { + const desired = activeSnapshot(snapshotDefinition(database.definition)); + const replayed = await replay(database); + try { + if (stableJson(activeSnapshot(replayed.current)) !== stableJson(desired)) { + throw new Error( + "Manual migration history does not produce the TypeScript schema definition.", + ); + } + const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); + await writeArtifacts( + database, + generatedArtifacts(database, desired, descriptions, replayed.manifest), + ); + } finally { + await replayed.scratch.close?.(); + } +} + +async function createManualMigration( + database: LoadedDatabase, + transactional: boolean, +): Promise { + const manifest = await readMigrations(database.databaseDir); + const id = ulid(); + const content = migrationContents( + id, + manifest.migrations.at(-1)?.id ?? null, + "-- Write the forward-only data or DDL transition here.", + transactional, + "review", + ); + const directory = path.join(database.databaseDir, "migrations"); + await fs.mkdir(directory, { recursive: true }); + const file = path.join(directory, `${id}.sql`); + await fs.writeFile(file, content, "utf8"); + return file; +} + +interface ParsedArgs { + readonly command: readonly string[]; + readonly database?: string; + readonly all: boolean; + readonly yes: boolean; + readonly json: boolean; + readonly cwd: string; + readonly nonTransactional: boolean; + readonly resolution: "applied" | "rolled-back"; +} + +function parseArgs(args: readonly string[], defaultCwd: string): ParsedArgs { + const command: string[] = []; + let database: string | undefined; + let all = false; + let yes = false; + let json = false; + let cwd = defaultCwd; + let nonTransactional = false; + let help = false; + let resolution: "applied" | "rolled-back" = "applied"; + for (let index = 0; index < args.length; index += 1) { + const value = args[index]!; + if (value === "--database") { + database = args[++index]; + if (!database) throw new Error("--database requires a value."); + } else if (value.startsWith("--database=")) database = value.slice(11); + else if (value === "--all") all = true; + else if (value === "--yes") yes = true; + else if (value === "--json") json = true; + else if (value === "--non-transactional") nonTransactional = true; + else if (value === "--applied") resolution = "applied"; + else if (value === "--rolled-back") resolution = "rolled-back"; + else if (value === "--help" || value === "-h") help = true; + else if (value === "--cwd") { + const next = args[++index]; + if (!next) throw new Error("--cwd requires a value."); + cwd = path.resolve(next); + } else if (value.startsWith("--cwd=")) cwd = path.resolve(value.slice(6)); + else if (value.startsWith("-")) throw new Error(`Unknown option ${value}.`); + else command.push(value); + } + return { + command: help ? ["help"] : command, + ...(database === undefined ? {} : { database }), + all, + yes, + json, + cwd, + nonTransactional, + resolution, + }; +} + +function helpText(): string { + return `askr database - Generate, validate, and migrate Askr databases + +Usage: + askr database validate [--database ] + askr database generate [--database ] + askr database migration create [--database ] [--non-transactional] + askr database migration status --database + askr database migration plan --database + askr database migration apply --database [--yes] + askr database migration resolve --database [--applied|--rolled-back] + +Options: + --all Select all configured databases explicitly + --cwd Resolve database/index.ts from another directory + --json Emit machine-readable output + --yes Apply the displayed migration plan without prompting +`; +} + +async function defaultConfirm(message: string): Promise { + if (!process.stdin.isTTY) return false; + const reader = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await reader.question(`${message} [y/N] `); + return /^y(?:es)?$/i.test(answer.trim()); + } finally { + reader.close(); + } +} + +async function targetAdapter(database: LoadedDatabase): Promise { + const adapter = await database.definition.target(); + if ( + database.definition.scratchIdentity && + adapter.identity === database.definition.scratchIdentity + ) { + await adapter.close?.(); + throw new Error( + "Target and scratch database identities are equal; refusing migration command.", + ); + } + return adapter; +} + +/** + * Runs the `askr-orm` database CLI (validate, generate, migration create/status/plan/apply/resolve) + * against the databases discovered under `options.cwd`, writing results via `options.io`. + * + * @param args CLI arguments, e.g. `["migration", "apply", "--yes"]`. + * @returns The process exit code: `0` on success, `1` on error. + */ +export async function runDatabaseCli( + args: readonly string[], + options: RunDatabaseCliOptions = {}, +): Promise { + const io = options.io ?? console; + let parsed: ParsedArgs; + try { + parsed = parseArgs(args, options.cwd ?? process.cwd()); + if ( + parsed.command.length === 0 || + parsed.command[0] === "help" || + parsed.command[0] === "--help" + ) { + io.log(helpText().trimEnd()); + return 0; + } + const loaded = await loadDatabases(parsed.cwd); + const action = parsed.command.join(" "); + const migrationCommand = parsed.command[0] === "migration"; + const selected = selectDatabases(loaded, parsed.database, parsed.all, migrationCommand); + const results: unknown[] = []; + for (const database of selected) { + if (action === "validate") { + await validateOne(database); + results.push({ database: database.name, status: "valid" }); + } else if (action === "generate") { + const file = await generateOne(database); + results.push({ + database: database.name, + status: file ? "generated" : "unchanged", + migration: file ? path.relative(database.projectRoot, file) : null, + }); + } else if (action === "migration create") { + const file = await createManualMigration(database, !parsed.nonTransactional); + try { + await refreshGenerated(database); + } catch (error) { + await fs.unlink(file).catch(() => undefined); + throw error; + } + results.push({ + database: database.name, + status: "created", + migration: path.relative(database.projectRoot, file), + }); + } else if ( + action === "migration status" || + action === "migration plan" || + action === "migration apply" + ) { + const adapter = await targetAdapter(database); + try { + const manifest = await readMigrations(database.databaseDir); + const client = (await import("./migrations")).createMigrationsApi(adapter, manifest); + const plan = await client.plan(); + if (action === "migration apply") { + if (!parsed.json) { + io.log( + `${database.name}: ${plan.pending.length} pending migration(s)\n${plan.pending + .map( + (entry) => + ` ${entry.id} [${entry.risk ?? "review"}] ${entry.transactional ? "transactional" : "NON-TRANSACTIONAL"}`, + ) + .join("\n")}`, + ); + } + if ( + plan.pending.length > 0 && + !parsed.yes && + !(await (options.confirm ?? defaultConfirm)("Apply this exact migration plan?")) + ) { + throw new Error("Migration apply cancelled."); + } + const applied = await client.apply(); + results.push({ database: database.name, status: "applied", ...applied }); + } else { + results.push({ database: database.name, status: "planned", plan }); + } + } finally { + await adapter.close?.(); + } + } else if (parsed.command[0] === "migration" && parsed.command[1] === "resolve") { + const id = parsed.command[2]; + if (!id) throw new Error("migration resolve requires an id."); + const resolution = parsed.resolution; + const adapter = await targetAdapter(database); + try { + const manifest = await readMigrations(database.databaseDir); + const client = (await import("./migrations")).createMigrationsApi(adapter, manifest); + await client.resolve(id, resolution); + results.push({ database: database.name, status: "resolved", id, resolution }); + } finally { + await adapter.close?.(); + } + } else { + throw new Error(`Unknown database command: ${action}`); + } + } + if (parsed.json) io.log(JSON.stringify({ status: "ok", results })); + else { + for (const result of results) { + const value = result as Record; + if (value.plan) { + const plan = value.plan as { + pending: readonly BundledMigration[]; + applied: readonly unknown[]; + }; + io.log( + `${value.database}: ${plan.applied.length} applied, ${plan.pending.length} pending`, + ); + } else { + io.log( + `${value.database}: ${value.status}${value.migration ? ` (${value.migration})` : ""}`, + ); + } + } + } + return 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + io.error( + args.includes("--json") ? JSON.stringify({ status: "error", error: message }) : message, + ); + return 1; + } +} + +/** Checks whether a discoverable database entry file exists under `cwd`. */ +export async function hasDatabaseEntry(cwd: string): Promise { + return findDatabaseEntry(cwd).then( + () => true, + () => false, + ); +} + +/** Runs `askr-orm validate` against the database(s) discovered under `cwd`, capturing its output. */ +export async function validateDiscoveredDatabase(cwd: string): Promise<{ + readonly status: "passed" | "failed"; + readonly stdout: string; + readonly stderr: string; +}> { + const logs: string[] = []; + const errors: string[] = []; + const code = await runDatabaseCli(["validate", "--cwd", cwd], { + cwd, + io: { + log: (value = "") => logs.push(String(value)), + error: (value = "") => errors.push(String(value)), + }, + }); + return { + status: code === 0 ? "passed" : "failed", + stdout: logs.join("\n"), + stderr: errors.join("\n"), + }; +} diff --git a/src/tooling.test.ts b/src/tooling.test.ts index 10ca9a5..9d813f1 100644 --- a/src/tooling.test.ts +++ b/src/tooling.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { diffSnapshots, runDatabaseCli, type SchemaSnapshot } from "./tooling"; +import { diffSnapshots, runDatabaseCli, type SchemaSnapshot } from "./tooling-impl"; const temporaryDirectories: string[] = []; diff --git a/src/tooling.ts b/src/tooling.ts index 4a4e7f9..26408a3 100644 --- a/src/tooling.ts +++ b/src/tooling.ts @@ -1,1091 +1 @@ -import { createHash, randomBytes } from "node:crypto"; -import fs from "node:fs/promises"; -import path from "node:path"; -import { createInterface } from "node:readline/promises"; -import { pathToFileURL } from "node:url"; -import { tsImport } from "tsx/esm/api"; -import type { DatabaseAdapter } from "./adapter"; -import type { DatabaseDefinition, DatabaseToolingAdapter } from "./definition"; -import type { BundledMigration, MigrationManifest } from "./migrations"; -import { quoteIdentifier } from "./naming"; -import { sqlStructure } from "./placeholders"; -import type { - AnyTable, - ColumnAst, - IndexDefinition, - TableConstraint, - ViewDefinition, -} from "./schema"; - -/** Output sink used by {@link runDatabaseCli} for logging and errors. */ -export interface DatabaseCliIo { - log(message?: unknown): void; - error(message?: unknown): void; -} - -/** Options accepted by {@link runDatabaseCli}. */ -export interface RunDatabaseCliOptions { - readonly cwd?: string; - readonly io?: DatabaseCliIo; - readonly confirm?: (message: string) => Promise; -} - -/** Serialized column shape stored in a {@link SchemaSnapshot}. */ -export interface SnapshotColumn extends Omit { - /** Accepted only when reading a legacy snapshot; live definitions cannot declare drops. */ - readonly drop?: boolean; - readonly property: string; - readonly reference?: { - readonly schema: string; - readonly table: string; - readonly column: string; - }; - readonly codec?: string; -} - -/** Serialized table shape stored in a {@link SchemaSnapshot}. */ -export interface SnapshotTable { - readonly schema: string; - readonly name: string; - readonly renamedFrom?: string; - /** Accepted only when reading a legacy snapshot; live definitions cannot declare drops. */ - readonly drop?: boolean; - readonly columns: readonly SnapshotColumn[]; - readonly constraints: readonly TableConstraint[]; -} - -/** Point-in-time serialized shape of a database schema, produced by {@link snapshotDefinition}. */ -export interface SchemaSnapshot { - readonly version: 1; - readonly enums: readonly { - readonly schema: string; - readonly name: string; - readonly values: readonly string[]; - }[]; - readonly tables: readonly SnapshotTable[]; - readonly views: readonly ViewDefinition[]; -} - -interface LoadedDatabase { - readonly name: string; - readonly definition: DatabaseDefinition>; - readonly databaseDir: string; - readonly projectRoot: string; -} - -interface KeyedSqlDescription { - readonly key: string; - readonly source: string; - readonly parameters: readonly string[]; - readonly columns: readonly { - readonly name: string; - readonly dataType: string; - readonly nullable: boolean; - }[]; -} - -const GENERATED_FILE = "generated.ts" as const; - -function sha256(value: string): string { - return createHash("sha256").update(value).digest("hex"); -} - -function stableJson(value: unknown): string { - return `${JSON.stringify(value, null, 2)}\n`; -} - -function columnSnapshot(property: string, column: AnyTable["$columns"][string]): SnapshotColumn { - const reference = column.ast.references?.(); - const referenceOwner = reference ? columnOwners.get(reference) : undefined; - return { - property, - name: column.ast.name, - dataType: column.ast.dataType, - nullable: column.ast.nullable, - primaryKey: column.ast.primaryKey, - unique: column.ast.unique, - ...(column.ast.default === undefined ? {} : { default: column.ast.default }), - ...(column.ast.generated === undefined ? {} : { generated: column.ast.generated }), - ...(reference === undefined - ? {} - : { - reference: { - schema: referenceOwner?.schema ?? "public", - table: referenceOwner?.table ?? findOwningTableName(reference) ?? "", - column: reference.ast.name, - }, - }), - ...(column.ast.codec === undefined ? {} : { codec: column.ast.codec.name }), - ...(column.ast.renamedFrom === undefined ? {} : { renamedFrom: column.ast.renamedFrom }), - ...(column.ast.convertUsing === undefined ? {} : { convertUsing: column.ast.convertUsing }), - }; -} - -const columnOwners = new WeakMap(); - -function findOwningTableName(column: object): string | undefined { - return columnOwners.get(column)?.table; -} - -/** Serializes a live {@link DatabaseDefinition} into a {@link SchemaSnapshot} for diffing/persistence. */ -export function snapshotDefinition( - definition: DatabaseDefinition< - Record, - Record>> - >, -): SchemaSnapshot { - for (const table of Object.values(definition.tables)) { - for (const column of Object.values(table.$columns)) { - columnOwners.set(column, { schema: table.$schema, table: table.$name }); - } - } - const tables = Object.values(definition.tables) - .map((table): SnapshotTable => ({ - schema: table.$schema, - name: table.$name, - ...(table.$options.renamedFrom === undefined - ? {} - : { renamedFrom: table.$options.renamedFrom }), - columns: Object.entries(table.$columns) - .map(([property, column]) => columnSnapshot(property, column)) - .sort((left, right) => left.name.localeCompare(right.name)), - constraints: [...(table.$options.constraints ?? [])].sort((left, right) => - JSON.stringify(left).localeCompare(JSON.stringify(right)), - ), - })) - .sort((left, right) => - `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), - ); - return { - version: 1, - enums: [...definition.enums] - .map((entry) => ({ - schema: entry.schema, - name: entry.name, - values: [...entry.values], - })) - .sort((left, right) => - `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), - ), - tables, - views: [...definition.views].sort((left, right) => - `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), - ), - }; -} - -function sqlString(value: string): string { - return `'${value.replaceAll("'", "''")}'`; -} - -function qualified( - schema: string, - name: string, - dialect: "postgres" | "sqlite" = "postgres", -): string { - return dialect === "sqlite" - ? quoteIdentifier(name) - : `${quoteIdentifier(schema)}.${quoteIdentifier(name)}`; -} - -function physicalType(dataType: string, dialect: "postgres" | "sqlite"): string { - if (dialect === "postgres") return dataType === "bytes" ? "bytea" : dataType; - if ( - dataType === "uuid" || - dataType === "json" || - dataType.startsWith("timestamp") || - dataType === "date" - ) - return "text"; - if (dataType === "boolean" || dataType === "bigint") return "integer"; - if (dataType === "bytes" || dataType === "bytea") return "blob"; - return dataType === "double precision" ? "real" : dataType; -} - -function columnSql(column: SnapshotColumn, dialect: "postgres" | "sqlite" = "postgres"): string { - const parts = [quoteIdentifier(column.name), physicalType(column.dataType, dialect)]; - if (column.generated) { - parts.push(`GENERATED ALWAYS AS (${column.generated}) STORED`); - } else if (column.default) { - parts.push(`DEFAULT ${column.default}`); - } - if (!column.nullable) parts.push("NOT NULL"); - if (column.primaryKey) parts.push("PRIMARY KEY"); - if (column.unique) parts.push("UNIQUE"); - if (column.reference) { - parts.push( - `REFERENCES ${qualified(column.reference.schema, column.reference.table, dialect)} (${quoteIdentifier(column.reference.column)})`, - ); - } - return parts.join(" "); -} - -function constraintSql(table: SnapshotTable, constraint: TableConstraint): string { - const name = constraint.name ? `CONSTRAINT ${quoteIdentifier(constraint.name)} ` : ""; - if (constraint.kind === "check") return `${name}CHECK (${constraint.expression})`; - if (constraint.kind === "unique") { - return `${name}UNIQUE (${constraint.columns.map(quoteIdentifier).join(", ")})`; - } - throw new Error(`Index ${constraint.name ?? ""} is not an inline constraint.`); -} - -function indexName(table: SnapshotTable, index: IndexDefinition, position: number): string { - return index.name ?? `${table.name}_${position + 1}_idx`; -} - -function createTableSql(table: SnapshotTable, dialect: "postgres" | "sqlite" = "postgres"): string { - const inline = table.constraints.filter((entry) => entry.kind !== "index"); - const parts = [ - ...table.columns.filter((column) => !column.drop).map((column) => columnSql(column, dialect)), - ...inline.map((constraint) => constraintSql(table, constraint)), - ]; - const statements = [ - `CREATE TABLE ${qualified(table.schema, table.name, dialect)} (\n ${parts.join(",\n ")}\n);`, - ]; - table.constraints - .filter((entry): entry is IndexDefinition => entry.kind === "index") - .forEach((index, position) => { - statements.push( - `CREATE${index.unique ? " UNIQUE" : ""} INDEX ${quoteIdentifier(indexName(table, index, position))} ON ${qualified(table.schema, table.name, dialect)}${index.method && dialect === "postgres" ? ` USING ${index.method}` : ""} (${index.expressions.join(", ")})${index.where ? ` WHERE ${index.where}` : ""};`, - ); - }); - return statements.join("\n"); -} - -function activeSnapshot(snapshot: SchemaSnapshot): SchemaSnapshot { - return { - ...snapshot, - tables: snapshot.tables - .filter((table) => !table.drop) - .map((table) => ({ - ...table, - columns: table.columns.filter((column) => !column.drop), - })), - }; -} - -function renderInitialSchema( - snapshot: SchemaSnapshot, - dialect: "postgres" | "sqlite" = "postgres", -): string { - const statements: string[] = []; - const schemas = new Set([ - ...snapshot.enums.map((entry) => entry.schema), - ...snapshot.tables.map((entry) => entry.schema), - ...snapshot.views.map((entry) => entry.schema), - ]); - for (const schema of [...schemas].sort()) { - if (dialect === "postgres" && schema !== "public") - statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schema)};`); - } - for (const value of snapshot.enums) { - statements.push( - `CREATE TYPE ${qualified(value.schema, value.name, dialect)} AS ENUM (${value.values.map(sqlString).join(", ")});`, - ); - } - for (const table of snapshot.tables.filter((entry) => !entry.drop)) { - statements.push(createTableSql(table, dialect)); - } - for (const view of snapshot.views) { - statements.push(`CREATE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`); - } - return `${statements.join("\n\n")}\n`; -} - -function byName( - values: readonly T[], -): Map { - return new Map(values.map((value) => [`${value.schema}.${value.name}`, value])); -} - -/** - * Generates a migration's SQL by diffing a `current` schema snapshot against a `desired` one - * (an empty `current` produces the full initial-schema SQL instead of a diff). - */ -export function diffSnapshots( - current: SchemaSnapshot, - desiredInput: SchemaSnapshot, - dialect: "postgres" | "sqlite" = "postgres", -): string { - const desired = activeSnapshot(desiredInput); - if (current.tables.length === 0 && current.enums.length === 0 && current.views.length === 0) { - return renderInitialSchema(desired, dialect); - } - const statements: string[] = []; - const currentEnums = byName(current.enums); - const desiredEnums = byName(desired.enums); - for (const value of desired.enums) { - const existing = currentEnums.get(`${value.schema}.${value.name}`); - if (!existing) { - statements.push( - `CREATE TYPE ${qualified(value.schema, value.name, dialect)} AS ENUM (${value.values.map(sqlString).join(", ")});`, - ); - continue; - } - if ( - existing.values.some((entry, index) => value.values[index] !== entry) || - value.values.length < existing.values.length - ) { - throw new Error(`Enum ${value.name} removes or reorders values; use a manual migration.`); - } - for (const added of value.values.slice(existing.values.length)) { - statements.push( - `ALTER TYPE ${qualified(value.schema, value.name, dialect)} ADD VALUE ${sqlString(added)};`, - ); - } - } - for (const value of current.enums) { - if (!desiredEnums.has(`${value.schema}.${value.name}`)) { - throw new Error(`Dropping enum ${value.name} requires an explicit manual migration.`); - } - } - - const currentTables = byName(current.tables); - const desiredTables = byName(desired.tables); - for (const table of desired.tables) { - const declaredTable = desiredInput.tables.find( - (entry) => entry.schema === table.schema && entry.name === table.name, - )!; - let existing = currentTables.get(`${table.schema}.${table.name}`); - if (!existing && table.renamedFrom) { - existing = currentTables.get(`${table.schema}.${table.renamedFrom}`); - if (!existing) throw new Error(`renamedFrom table ${table.renamedFrom} does not exist.`); - statements.push( - `ALTER TABLE ${qualified(table.schema, table.renamedFrom, dialect)} RENAME TO ${quoteIdentifier(table.name)};`, - ); - } - if (!existing) { - statements.push(createTableSql(table, dialect)); - continue; - } - const existingColumns = new Map(existing.columns.map((column) => [column.name, column])); - const desiredColumns = new Map(table.columns.map((column) => [column.name, column])); - for (const column of table.columns) { - let old = existingColumns.get(column.name); - if (!old && column.renamedFrom) { - old = existingColumns.get(column.renamedFrom); - if (!old) { - throw new Error( - `${table.name}.${column.property} renamedFrom ${column.renamedFrom} does not exist.`, - ); - } - statements.push( - `ALTER TABLE ${qualified(table.schema, table.name, dialect)} RENAME COLUMN ${quoteIdentifier(column.renamedFrom)} TO ${quoteIdentifier(column.name)};`, - ); - } - if (!old) { - if (!column.nullable && column.default === undefined && column.generated === undefined) { - throw new Error( - `Adding required column ${table.name}.${column.name} needs a default or manual migration.`, - ); - } - statements.push( - `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ADD COLUMN ${columnSql(column, dialect)};`, - ); - continue; - } - if (old.dataType !== column.dataType) { - if (dialect === "sqlite") { - throw new Error( - `Changing ${table.name}.${column.name} requires a reviewed SQLite table rebuild or manual migration.`, - ); - } - if (!column.convertUsing) { - throw new Error( - `Changing ${table.name}.${column.name} from ${old.dataType} to ${column.dataType} requires convertUsing or a manual migration.`, - ); - } - statements.push( - `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ALTER COLUMN ${quoteIdentifier(column.name)} TYPE ${physicalType(column.dataType, dialect)} USING ${column.convertUsing};`, - ); - } - if (old.nullable && !column.nullable) { - throw new Error( - `Making ${table.name}.${column.name} required is ambiguous; use a manual data migration.`, - ); - } - if (!old.nullable && column.nullable) { - if (dialect === "sqlite") { - throw new Error( - `Changing ${table.name}.${column.name} nullability requires a reviewed SQLite table rebuild or manual migration.`, - ); - } - statements.push( - `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ALTER COLUMN ${quoteIdentifier(column.name)} DROP NOT NULL;`, - ); - } - } - for (const old of existing.columns) { - if (!desiredColumns.has(old.name)) { - const renamed = declaredTable.columns.some((column) => column.renamedFrom === old.name); - if (renamed) continue; - throw new Error( - `Dropping ${table.name}.${old.name} requires an explicit manual migration.`, - ); - } - } - if (JSON.stringify(existing.constraints) !== JSON.stringify(table.constraints)) { - throw new Error( - `Changing constraints or indexes on ${table.name} requires a manual migration.`, - ); - } - } - for (const table of current.tables) { - if (desiredTables.has(`${table.schema}.${table.name}`)) continue; - const renamed = desiredInput.tables.some( - (candidate) => candidate.schema === table.schema && candidate.renamedFrom === table.name, - ); - if (renamed) continue; - throw new Error(`Dropping table ${table.name} requires an explicit manual migration.`); - } - - const currentViews = byName(current.views); - const desiredViews = byName(desired.views); - for (const view of desired.views) { - const old = currentViews.get(`${view.schema}.${view.name}`); - if (!old) - statements.push( - `CREATE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`, - ); - else if (old.query !== view.query) { - if (dialect === "sqlite") { - throw new Error(`Changing view ${view.name} requires an explicit SQLite manual migration.`); - } - statements.push( - `CREATE OR REPLACE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`, - ); - } - } - for (const view of current.views) { - if (!desiredViews.has(`${view.schema}.${view.name}`)) { - throw new Error(`Dropping view ${view.name} requires a manual migration.`); - } - } - return statements.length === 0 ? "" : `${statements.join("\n\n")}\n`; -} - -function parseMigration(content: string, file: string): BundledMigration { - const id = /^-- askr:id ([A-Z0-9]+)$/m.exec(content)?.[1]; - const parentValue = /^-- askr:parent (.+)$/m.exec(content)?.[1]; - const transactionalValue = /^-- askr:transactional (true|false)$/m.exec(content)?.[1]; - const risk = /^-- askr:risk (safe|review|destructive)$/m.exec(content)?.[1] as - | BundledMigration["risk"] - | undefined; - if (!id || parentValue === undefined || !transactionalValue) { - throw new Error(`Migration ${file} is missing required askr headers.`); - } - if (!file.startsWith(`${id}.`)) - throw new Error(`Migration file ${file} does not match id ${id}.`); - return { - id, - parent: parentValue === "none" ? null : parentValue, - checksum: sha256(content), - sql: content, - transactional: transactionalValue === "true", - ...(risk === undefined ? {} : { risk }), - }; -} - -async function readMigrations(databaseDir: string): Promise { - const directory = path.join(databaseDir, "migrations"); - const entries = await fs.readdir(directory).catch((error: NodeJS.ErrnoException) => { - if (error.code === "ENOENT") return []; - throw error; - }); - const migrations = await Promise.all( - entries - .filter((file) => file.endsWith(".sql")) - .sort() - .map(async (file) => - parseMigration(await fs.readFile(path.join(directory, file), "utf8"), file), - ), - ); - let parent: string | null = null; - for (const migration of migrations) { - if (migration.parent !== parent) { - throw new Error( - `Migration ${migration.id} has parent ${migration.parent ?? "none"}; expected ${parent ?? "none"}.`, - ); - } - parent = migration.id; - } - return { migrations }; -} - -const ULID_ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; - -function encodeBase32(value: bigint, length: number): string { - let output = ""; - for (let index = 0; index < length; index += 1) { - output = ULID_ALPHABET[Number(value & 31n)] + output; - value >>= 5n; - } - return output; -} - -function ulid(): string { - const time = encodeBase32(BigInt(Date.now()), 10); - const random = BigInt(`0x${randomBytes(10).toString("hex")}`); - return `${time}${encodeBase32(random, 16)}`; -} - -async function findDatabaseEntry( - start: string, -): Promise<{ projectRoot: string; databaseDir: string }> { - let current = path.resolve(start); - while (true) { - for (const relative of [ - ["database", "index.ts"], - ["src", "database", "index.ts"], - ]) { - const candidate = path.join(current, ...relative); - if (await fs.stat(candidate).catch(() => null)) { - return { projectRoot: current, databaseDir: path.dirname(candidate) }; - } - } - const parent = path.dirname(current); - if (parent === current) { - throw new Error( - "No database/index.ts found. Run this command from an Askr database project.", - ); - } - current = parent; - } -} - -async function loadDatabases(cwd: string): Promise<{ - readonly all: readonly LoadedDatabase[]; - readonly projectRoot: string; -}> { - const location = await findDatabaseEntry(cwd); - const module = (await tsImport(path.join(location.databaseDir, "index.ts"), { - parentURL: pathToFileURL(location.projectRoot).href, - })) as Record; - const exported = module.default ?? module.database; - if (!exported || typeof exported !== "object") { - throw new Error("database/index.ts must export a defineDatabase(...) definition."); - } - if ((exported as { kind?: string }).kind !== "database") { - throw new Error("Unsupported database root export."); - } - return { - projectRoot: location.projectRoot, - all: [ - { - name: "default", - definition: exported as DatabaseDefinition>, - databaseDir: location.databaseDir, - projectRoot: location.projectRoot, - }, - ], - }; -} - -function selectDatabases( - loaded: Awaited>, - name: string | undefined, - all: boolean, - migrationCommand: boolean, -): readonly LoadedDatabase[] { - if (all) return loaded.all; - if (name) { - const selected = loaded.all.find((entry) => entry.name === name); - if (!selected) throw new Error(`Unknown database ${name}.`); - return [selected]; - } - if (loaded.all.length === 1 && !migrationCommand) return loaded.all; - if (loaded.all.length === 1 && loaded.all[0]?.name === "default") return loaded.all; - throw new Error( - migrationCommand - ? "Migration commands require --database or explicit --all." - : "Multiple databases are configured; pass --database .", - ); -} - -async function describeRegisteredQueries( - definition: DatabaseDefinition< - Record, - Record>> - >, - scratch: DatabaseToolingAdapter, -): Promise { - const descriptions: KeyedSqlDescription[] = []; - const keys = new Set(); - for (const query of Object.values(definition.queries)) { - if (keys.has(query.key)) throw new Error(`Duplicate registered query key ${query.key}.`); - keys.add(query.key); - const parameters = [...query.parameters]; - const compiled = query.compile(Object.fromEntries(parameters.map((name) => [name, null]))); - const described = await scratch.describe(compiled.text, parameters); - descriptions.push({ - key: query.key, - source: compiled.text, - parameters, - columns: described.columns, - }); - } - return descriptions.sort((left, right) => left.key.localeCompare(right.key)); -} - -function tsType(dataType: string, nullable: boolean): string { - const normalized = dataType.toLowerCase(); - const base = - normalized === "boolean" - ? "boolean" - : /^(?:smallint|integer|real|double precision)$/.test(normalized) - ? "number" - : normalized === "bigint" - ? "bigint" - : normalized === "bytea" - ? "Uint8Array" - : /^(?:json|jsonb)$/.test(normalized) - ? "unknown" - : "string"; - return nullable ? `${base} | null` : base; -} - -function renderQueries(descriptions: readonly KeyedSqlDescription[]): string { - const lines = ["export interface GeneratedDatabaseQueries {"]; - for (const query of descriptions) { - lines.push(` readonly ${JSON.stringify(query.key)}: {`); - lines.push(" readonly parameters: {"); - for (const parameter of query.parameters) - lines.push(` readonly ${JSON.stringify(parameter)}: unknown;`); - lines.push(" };", " readonly row: {"); - for (const column of query.columns) { - lines.push( - ` readonly ${JSON.stringify(column.name)}: ${tsType(column.dataType, column.nullable)};`, - ); - } - lines.push(" };", " };"); - } - lines.push("}", ""); - return lines.join("\n"); -} - -function generatedArtifacts( - _database: LoadedDatabase, - snapshot: SchemaSnapshot, - descriptions: readonly KeyedSqlDescription[], - manifest: MigrationManifest, -): string { - const identity = sha256(stableJson(activeSnapshot(snapshot))); - return [ - "// Generated by @askrjs/orm. Do not edit.", - 'import type { GeneratedDatabaseArtifact } from "@askrjs/orm";', - "", - renderQueries(descriptions).trimEnd(), - "", - `export const generated = ${JSON.stringify({ schemaIdentity: identity, manifest, queries: Object.fromEntries(descriptions.map((query) => [query.key, { parameters: query.parameters, columns: query.columns }])) }, null, 2)} as const satisfies GeneratedDatabaseArtifact;`, - "", - ].join("\n"); -} - -async function replay(database: LoadedDatabase): Promise<{ - readonly scratch: DatabaseToolingAdapter; - readonly manifest: MigrationManifest; - readonly current: SchemaSnapshot; -}> { - const scratch = await database.definition.scratch(); - if ( - database.definition.targetIdentity && - scratch.identity === database.definition.targetIdentity - ) { - await scratch.close?.(); - throw new Error("Scratch and target database identities are equal; refusing to reset target."); - } - if ( - database.definition.scratchIdentity && - scratch.identity !== database.definition.scratchIdentity - ) { - await scratch.close?.(); - throw new Error("Scratch adapter identity does not match configured scratchIdentity."); - } - const manifest = await readMigrations(database.databaseDir); - try { - await scratch.reset(); - for (const migration of manifest.migrations) await scratch.execute(migration.sql); - const current = (await scratch.introspect()) as SchemaSnapshot; - if (!current || current.version !== 1 || !Array.isArray(current.tables)) { - throw new Error("Scratch introspection did not return an Askr SchemaSnapshot."); - } - return { scratch, manifest, current }; - } catch (error) { - await scratch.close?.(); - throw error; - } -} - -async function writeArtifacts( - database: LoadedDatabase, - artifacts: ReturnType, -): Promise { - await fs.writeFile(path.join(database.databaseDir, GENERATED_FILE), artifacts, "utf8"); -} - -async function validateOne(database: LoadedDatabase): Promise { - const desired = activeSnapshot(snapshotDefinition(database.definition)); - const replayed = await replay(database); - try { - const actual = activeSnapshot(replayed.current); - if (stableJson(actual) !== stableJson(desired)) { - throw new Error("Migration history does not produce the TypeScript schema definition."); - } - const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); - const expected = generatedArtifacts(database, desired, descriptions, replayed.manifest); - const target = path.join(database.databaseDir, GENERATED_FILE); - const committed = await fs.readFile(target, "utf8").catch(() => null); - if (committed !== expected) { - throw new Error(`Generated artifact database/${GENERATED_FILE} is stale.`); - } - } finally { - await replayed.scratch.close?.(); - } -} - -function migrationContents( - id: string, - parent: string | null, - sqlText: string, - transactional: boolean, - risk: NonNullable, -): string { - return `-- askr:id ${id}\n-- askr:parent ${parent ?? "none"}\n-- askr:transactional ${transactional}\n-- askr:risk ${risk}\n\n${sqlText.trim()}\n`; -} - -async function generateOne(database: LoadedDatabase): Promise { - const desired = snapshotDefinition(database.definition); - const replayed = await replay(database); - let created: string | null = null; - try { - const delta = diffSnapshots(replayed.current, desired, database.definition.dialect); - if (delta) { - const id = ulid(); - const parent = replayed.manifest.migrations.at(-1)?.id ?? null; - const risk = /\b(?:DROP|ALTER\s+TABLE\b[^;]*\bTYPE\b)/i.test(sqlStructure(delta)) - ? "destructive" - : "safe"; - const content = migrationContents(id, parent, delta, true, risk); - const directory = path.join(database.databaseDir, "migrations"); - await fs.mkdir(directory, { recursive: true }); - created = path.join(directory, `${id}.sql`); - await fs.writeFile(created, content, "utf8"); - await replayed.scratch.execute(content); - } - const finalSnapshot = activeSnapshot((await replayed.scratch.introspect()) as SchemaSnapshot); - if (stableJson(finalSnapshot) !== stableJson(activeSnapshot(desired))) { - throw new Error("Generated migration did not produce the desired schema."); - } - const manifest = await readMigrations(database.databaseDir); - const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); - await writeArtifacts(database, generatedArtifacts(database, desired, descriptions, manifest)); - return created; - } catch (error) { - if (created) await fs.unlink(created).catch(() => undefined); - throw error; - } finally { - await replayed.scratch.close?.(); - } -} - -async function refreshGenerated(database: LoadedDatabase): Promise { - const desired = activeSnapshot(snapshotDefinition(database.definition)); - const replayed = await replay(database); - try { - if (stableJson(activeSnapshot(replayed.current)) !== stableJson(desired)) { - throw new Error( - "Manual migration history does not produce the TypeScript schema definition.", - ); - } - const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); - await writeArtifacts( - database, - generatedArtifacts(database, desired, descriptions, replayed.manifest), - ); - } finally { - await replayed.scratch.close?.(); - } -} - -async function createManualMigration( - database: LoadedDatabase, - transactional: boolean, -): Promise { - const manifest = await readMigrations(database.databaseDir); - const id = ulid(); - const content = migrationContents( - id, - manifest.migrations.at(-1)?.id ?? null, - "-- Write the forward-only data or DDL transition here.", - transactional, - "review", - ); - const directory = path.join(database.databaseDir, "migrations"); - await fs.mkdir(directory, { recursive: true }); - const file = path.join(directory, `${id}.sql`); - await fs.writeFile(file, content, "utf8"); - return file; -} - -interface ParsedArgs { - readonly command: readonly string[]; - readonly database?: string; - readonly all: boolean; - readonly yes: boolean; - readonly json: boolean; - readonly cwd: string; - readonly nonTransactional: boolean; - readonly resolution: "applied" | "rolled-back"; -} - -function parseArgs(args: readonly string[], defaultCwd: string): ParsedArgs { - const command: string[] = []; - let database: string | undefined; - let all = false; - let yes = false; - let json = false; - let cwd = defaultCwd; - let nonTransactional = false; - let help = false; - let resolution: "applied" | "rolled-back" = "applied"; - for (let index = 0; index < args.length; index += 1) { - const value = args[index]!; - if (value === "--database") { - database = args[++index]; - if (!database) throw new Error("--database requires a value."); - } else if (value.startsWith("--database=")) database = value.slice(11); - else if (value === "--all") all = true; - else if (value === "--yes") yes = true; - else if (value === "--json") json = true; - else if (value === "--non-transactional") nonTransactional = true; - else if (value === "--applied") resolution = "applied"; - else if (value === "--rolled-back") resolution = "rolled-back"; - else if (value === "--help" || value === "-h") help = true; - else if (value === "--cwd") { - const next = args[++index]; - if (!next) throw new Error("--cwd requires a value."); - cwd = path.resolve(next); - } else if (value.startsWith("--cwd=")) cwd = path.resolve(value.slice(6)); - else if (value.startsWith("-")) throw new Error(`Unknown option ${value}.`); - else command.push(value); - } - return { - command: help ? ["help"] : command, - ...(database === undefined ? {} : { database }), - all, - yes, - json, - cwd, - nonTransactional, - resolution, - }; -} - -function helpText(): string { - return `askr database - Generate, validate, and migrate Askr databases - -Usage: - askr database validate [--database ] - askr database generate [--database ] - askr database migration create [--database ] [--non-transactional] - askr database migration status --database - askr database migration plan --database - askr database migration apply --database [--yes] - askr database migration resolve --database [--applied|--rolled-back] - -Options: - --all Select all configured databases explicitly - --cwd Resolve database/index.ts from another directory - --json Emit machine-readable output - --yes Apply the displayed migration plan without prompting -`; -} - -async function defaultConfirm(message: string): Promise { - if (!process.stdin.isTTY) return false; - const reader = createInterface({ input: process.stdin, output: process.stdout }); - try { - const answer = await reader.question(`${message} [y/N] `); - return /^y(?:es)?$/i.test(answer.trim()); - } finally { - reader.close(); - } -} - -async function targetAdapter(database: LoadedDatabase): Promise { - const adapter = await database.definition.target(); - if ( - database.definition.scratchIdentity && - adapter.identity === database.definition.scratchIdentity - ) { - await adapter.close?.(); - throw new Error( - "Target and scratch database identities are equal; refusing migration command.", - ); - } - return adapter; -} - -/** - * Runs the `askr-orm` database CLI (validate, generate, migration create/status/plan/apply/resolve) - * against the databases discovered under `options.cwd`, writing results via `options.io`. - * - * @param args CLI arguments, e.g. `["migration", "apply", "--yes"]`. - * @returns The process exit code: `0` on success, `1` on error. - */ -export async function runDatabaseCli( - args: readonly string[], - options: RunDatabaseCliOptions = {}, -): Promise { - const io = options.io ?? console; - let parsed: ParsedArgs; - try { - parsed = parseArgs(args, options.cwd ?? process.cwd()); - if ( - parsed.command.length === 0 || - parsed.command[0] === "help" || - parsed.command[0] === "--help" - ) { - io.log(helpText().trimEnd()); - return 0; - } - const loaded = await loadDatabases(parsed.cwd); - const action = parsed.command.join(" "); - const migrationCommand = parsed.command[0] === "migration"; - const selected = selectDatabases(loaded, parsed.database, parsed.all, migrationCommand); - const results: unknown[] = []; - for (const database of selected) { - if (action === "validate") { - await validateOne(database); - results.push({ database: database.name, status: "valid" }); - } else if (action === "generate") { - const file = await generateOne(database); - results.push({ - database: database.name, - status: file ? "generated" : "unchanged", - migration: file ? path.relative(database.projectRoot, file) : null, - }); - } else if (action === "migration create") { - const file = await createManualMigration(database, !parsed.nonTransactional); - try { - await refreshGenerated(database); - } catch (error) { - await fs.unlink(file).catch(() => undefined); - throw error; - } - results.push({ - database: database.name, - status: "created", - migration: path.relative(database.projectRoot, file), - }); - } else if ( - action === "migration status" || - action === "migration plan" || - action === "migration apply" - ) { - const adapter = await targetAdapter(database); - try { - const manifest = await readMigrations(database.databaseDir); - const client = (await import("./migrations")).createMigrationsApi(adapter, manifest); - const plan = await client.plan(); - if (action === "migration apply") { - if (!parsed.json) { - io.log( - `${database.name}: ${plan.pending.length} pending migration(s)\n${plan.pending - .map( - (entry) => - ` ${entry.id} [${entry.risk ?? "review"}] ${entry.transactional ? "transactional" : "NON-TRANSACTIONAL"}`, - ) - .join("\n")}`, - ); - } - if ( - plan.pending.length > 0 && - !parsed.yes && - !(await (options.confirm ?? defaultConfirm)("Apply this exact migration plan?")) - ) { - throw new Error("Migration apply cancelled."); - } - const applied = await client.apply(); - results.push({ database: database.name, status: "applied", ...applied }); - } else { - results.push({ database: database.name, status: "planned", plan }); - } - } finally { - await adapter.close?.(); - } - } else if (parsed.command[0] === "migration" && parsed.command[1] === "resolve") { - const id = parsed.command[2]; - if (!id) throw new Error("migration resolve requires an id."); - const resolution = parsed.resolution; - const adapter = await targetAdapter(database); - try { - const manifest = await readMigrations(database.databaseDir); - const client = (await import("./migrations")).createMigrationsApi(adapter, manifest); - await client.resolve(id, resolution); - results.push({ database: database.name, status: "resolved", id, resolution }); - } finally { - await adapter.close?.(); - } - } else { - throw new Error(`Unknown database command: ${action}`); - } - } - if (parsed.json) io.log(JSON.stringify({ status: "ok", results })); - else { - for (const result of results) { - const value = result as Record; - if (value.plan) { - const plan = value.plan as { - pending: readonly BundledMigration[]; - applied: readonly unknown[]; - }; - io.log( - `${value.database}: ${plan.applied.length} applied, ${plan.pending.length} pending`, - ); - } else { - io.log( - `${value.database}: ${value.status}${value.migration ? ` (${value.migration})` : ""}`, - ); - } - } - } - return 0; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - io.error( - args.includes("--json") ? JSON.stringify({ status: "error", error: message }) : message, - ); - return 1; - } -} - -/** Checks whether a discoverable database entry file exists under `cwd`. */ -export async function hasDatabaseEntry(cwd: string): Promise { - return findDatabaseEntry(cwd).then( - () => true, - () => false, - ); -} - -/** Runs `askr-orm validate` against the database(s) discovered under `cwd`, capturing its output. */ -export async function validateDiscoveredDatabase(cwd: string): Promise<{ - readonly status: "passed" | "failed"; - readonly stdout: string; - readonly stderr: string; -}> { - const logs: string[] = []; - const errors: string[] = []; - const code = await runDatabaseCli(["validate", "--cwd", cwd], { - cwd, - io: { - log: (value = "") => logs.push(String(value)), - error: (value = "") => errors.push(String(value)), - }, - }); - return { - status: code === 0 ? "passed" : "failed", - stdout: logs.join("\n"), - stderr: errors.join("\n"), - }; -} +export { runDatabaseCli } from "./tooling-impl"; diff --git a/src/value-conformance.fixture.ts b/src/value-conformance.fixture.ts new file mode 100644 index 0000000..1d4b322 --- /dev/null +++ b/src/value-conformance.fixture.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import type { DatabaseAdapter, DialectName } from "./adapter"; +import { + bytes, + compileSql, + DatabaseError, + defineDatabase, + integer, + sql, + table, + text, +} from "./index"; + +export async function assertValueConformance( + adapter: DatabaseAdapter, + dialect: DialectName, +): Promise { + const items = table("orm_value_probe", { + id: integer().primaryKey(), + note: text(), + payload: bytes().notNull(), + }); + const db = await defineDatabase({ + tables: { items }, + driver: { + dialect, + open: async () => adapter, + shadow: async () => { + throw new Error("No shadow in value probe."); + }, + }, + }).open(); + const rows = [ + { id: 1, note: null, payload: new Uint8Array() }, + { id: 2, note: "", payload: new Uint8Array([0, 255, 39, 59, 128]) }, + { + id: 3, + note: "'quoted'; -- $99 😀".repeat(30_000), + payload: Uint8Array.from({ length: 65_536 }, (_, i) => i % 256), + }, + ]; + try { + await adapter.execute({ + text: `CREATE TABLE "orm_value_probe" ("id" integer PRIMARY KEY, "note" text, "payload" ${dialect === "postgres" ? "bytea" : "blob"} NOT NULL)`, + values: [], + }); + await db.items.insertMany(rows, { chunkSize: 2 }); + const actual = await db.items + .select(({ orm_value_probe }) => orm_value_probe) + .orderBy(({ orm_value_probe }) => orm_value_probe.id) + .execute(); + assert.deepEqual( + actual.map((row) => ({ ...row, payload: new Uint8Array(row.payload) })), + rows, + ); + await assert.rejects( + db.items.insert(rows[0]!), + (error) => + error instanceof DatabaseError && error.category === "constraint" && Boolean(error.cause), + ); + assert.deepEqual(await db.items.get(2), actual[1]); + assert.throws(() => compileSql(sql`SELECT ${sql.identifier("bad\0name")}`), /identifier/i); + assert.equal( + (await db.items.select(({ orm_value_probe }) => orm_value_probe).execute()).length, + 3, + ); + } finally { + await adapter.execute({ text: 'DROP TABLE IF EXISTS "orm_value_probe"', values: [] }); + } +} diff --git a/src/value-conformance.test.ts b/src/value-conformance.test.ts new file mode 100644 index 0000000..61ad1f8 --- /dev/null +++ b/src/value-conformance.test.ts @@ -0,0 +1,12 @@ +import { it } from "vitest"; +import { sqlite } from "./sqlite"; +import { assertValueConformance } from "./value-conformance.fixture"; + +it("should preserve null, empty, binary and large values through SQLite binding and recover from constraints", async () => { + const adapter = await sqlite({ filename: ":memory:" }).open(); + try { + await assertValueConformance(adapter, "sqlite"); + } finally { + await adapter.close?.(); + } +}); diff --git a/tests/packed-contract.mjs b/tests/packed-contract.mjs new file mode 100644 index 0000000..09ac624 --- /dev/null +++ b/tests/packed-contract.mjs @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import ts from "@typescript/typescript6"; + +const root = process.cwd(); +const contract = JSON.parse(await fs.readFile("tests/public-contract.json", "utf8")); +const consumer = await fs.mkdtemp(path.join(os.tmpdir(), "askr-orm-packed-")); +const npmCli = process.env.npm_execpath; +assert(npmCli, "Run this contract through npm run test:packed."); +const npm = (args, cwd) => + execFileSync(process.execPath, [npmCli, ...args], { cwd, encoding: "utf8", stdio: "pipe" }); +try { + const records = JSON.parse( + npm(["pack", "--ignore-scripts", "--json", "--pack-destination", consumer], root), + ); + const packed = Array.isArray(records) ? records[0] : Object.values(records)[0]; + assert(packed?.filename, "npm pack must provide an archive filename"); + await fs.writeFile( + path.join(consumer, "package.json"), + JSON.stringify({ name: "orm-packed-consumer", private: true, type: "module" }), + ); + npm( + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--no-save", + path.join(consumer, packed.filename), + ], + consumer, + ); + const manifest = JSON.parse( + await fs.readFile(path.join(consumer, "node_modules/@askrjs/orm/package.json"), "utf8"), + ); + assert.deepEqual(Object.keys(manifest.exports).sort(), contract.exportKeys); + // Root and SQLite must work without PostgreSQL's optional peers. + await assert.rejects(fs.access(path.join(consumer, "node_modules/pg")), { code: "ENOENT" }); + await fs.writeFile( + path.join(consumer, "runtime.mjs"), + ` + import assert from 'node:assert/strict'; + import fs from 'node:fs/promises'; + import * as root from '@askrjs/orm'; + import * as postgres from '@askrjs/orm/postgres'; + import * as sqlite from '@askrjs/orm/sqlite'; + import * as tooling from '@askrjs/orm/tooling'; + const modules = { '.': root, './postgres': postgres, './sqlite': sqlite, './tooling': tooling }; + for (const [key, expected] of Object.entries(${JSON.stringify(contract.entrypoints)})) + assert.deepEqual(Object.keys(modules[key]), expected.values, key); + assert.deepEqual(Object.keys(root.sql).sort(), ['identifier', 'literal', 'unsafe']); + for (const privatePath of ${JSON.stringify(contract.privateSubpaths)}) + await assert.rejects(import('@askrjs/orm/' + privatePath), { code: 'ERR_PACKAGE_PATH_NOT_EXPORTED' }); + const fragment = root.sql\`SELECT \${root.sql.identifier('odd"name')} WHERE id = \${"semi;colon"}\`; + assert.deepEqual(root.compileSql(fragment), { text: 'SELECT "odd""name" WHERE id = $1', values: ['semi;colon'] }); + const messages = []; + assert.equal(await tooling.runDatabaseCli(['--help'], { io: { log: value => messages.push(value), error: value => { throw new Error(String(value)); } } }), 0); + assert(messages.some(value => String(value).includes('migration'))); + const items = root.table('items', { id: root.integer().primaryKey(), value: root.text().notNull() }); + const definition = root.defineDatabase({ driver: sqlite.sqlite({ filename: ':memory:' }), tables: { items }, generated: { manifest: { migrations: [{ id: '01SCRIPT', parent: null, checksum: 'success', transactional: true, sql: 'CREATE TABLE "items" ("id" integer PRIMARY KEY, "value" text NOT NULL); INSERT INTO "items" VALUES (1, \\'semi;colon\\'); INSERT INTO "items" VALUES (2, \\'second\\');' }] } } }); + const db = await definition.open(); + try { + assert.deepEqual(await db.migrations.apply(), { applied: ['01SCRIPT'] }); + assert.deepEqual(await db.items.select(({ items }) => items).orderBy(({ items }) => items.id).execute(), [{ id: 1, value: 'semi;colon' }, { id: 2, value: 'second' }]); + const primary = new Error('rollback primary'); + await assert.rejects(db.transaction(async tx => { await tx.items.insert({ id: 3, value: 'discard' }); throw primary; }), error => error === primary); + assert.equal((await db.items.select(({ items }) => items).execute()).length, 2); + assert.equal((await db.migrations.plan()).pending.length, 0); + } finally { await db.close(); } + `, + ); + execFileSync(process.execPath, [path.join(consumer, "runtime.mjs")], { + cwd: consumer, + stdio: "pipe", + }); + // Also qualify the documented optional-peer minimum with a normal install. + npm( + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--no-save", + path.join(consumer, packed.filename), + "pg@8.23.0", + "pg-query-stream@4.17.0", + "@types/pg@8.23.1", + "@types/node@26.3.0", + ], + consumer, + ); + const fixture = path.join(consumer, "fixture.ts"); + const imports = Object.entries(contract.entrypoints) + .map( + ([key]) => + `import * as Entry_${key === "." ? "root" : key.slice(2)} from '@askrjs/orm${key === "." ? "" : key.slice(1)}';`, + ) + .join("\n"); + const negative = Object.entries(contract.entrypoints) + .flatMap(([key, c]) => + c.removed.map( + (name) => + `// @ts-expect-error removed public name\nimport type { ${name} as Removed_${key === "." ? "root" : key.slice(2)}_${name} } from '@askrjs/orm${key === "." ? "" : key.slice(1)}';`, + ), + ) + .join("\n"); + await fs.writeFile( + fixture, + `${imports}\n${await fs.readFile("tests/types/contracts.ts", "utf8")}\n${negative}\n// @ts-expect-error keyed SQL is replaced by defineQuery\nEntry_root.sql.key('old', {});\n// @ts-expect-error ColumnBuilder is a type-only contract\nnew Entry_root.ColumnBuilder({});\nconst generated: Entry_root.GeneratedDatabaseArtifact = { manifest: { migrations: [] } };\nconst codec: Entry_root.Codec = { name: 'identity', encode: value => value, decode: value => value };\nconst postgresOptions: Entry_postgres.PostgresOptions = { url: 'postgresql://localhost/database', pool: { max: 2 } }; +// @ts-expect-error pool sizes remain numbers +const invalidPool: Entry_postgres.PostgresOptions = { pool: { max: 'many' } }; +const sqliteOptions: Entry_sqlite.SqliteOptions = { filename: ':memory:' }; +void [generated, codec, postgresOptions, invalidPool, sqliteOptions];\n`, + ); + await fs.writeFile( + path.join(consumer, "tsconfig.json"), + JSON.stringify({ + compilerOptions: { + strict: true, + noEmit: true, + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext", + types: ["node"], + skipLibCheck: false, + }, + files: ["fixture.ts"], + }), + ); + execFileSync( + process.execPath, + [path.join(root, "node_modules/typescript/bin/tsc"), "--project", "tsconfig.json"], + { cwd: consumer, stdio: "pipe" }, + ); + const options = { + strict: true, + noEmit: true, + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + types: ["node"], + skipLibCheck: false, + }; + const program = ts.createProgram([fixture], options); + const diagnostics = ts.getPreEmitDiagnostics(program); + assert.equal( + diagnostics.length, + 0, + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (value) => value, + getCurrentDirectory: () => consumer, + getNewLine: () => "\n", + }), + ); + const checker = program.getTypeChecker(); + for (const [key, c] of Object.entries(contract.entrypoints)) { + const moduleName = "@askrjs/orm" + (key === "." ? "" : key.slice(1)); + const declaration = program + .getSourceFile(fixture) + .statements.find( + (statement) => + ts.isImportDeclaration(statement) && statement.moduleSpecifier.text === moduleName, + ); + const names = checker + .getExportsOfModule(checker.getSymbolAtLocation(declaration.moduleSpecifier)) + .map((symbol) => symbol.name) + .sort(); + assert.deepEqual(names, [...c.values, ...c.types].sort(), moduleName + " packed declarations"); + } + console.log( + JSON.stringify({ + entrypoints: 4, + names: Object.values(contract.entrypoints).reduce( + (n, c) => n + c.values.length + c.types.length, + 0, + ), + removed: Object.values(contract.entrypoints).reduce((n, c) => n + c.removed.length, 0), + privateSubpaths: contract.privateSubpaths.length, + postgresPeers: ["8.23.0", "4.17.0"], + sqliteScript: "complete, rollback, subsequent query", + }), + ); +} finally { + await fs.rm(consumer, { recursive: true, force: true }); +} diff --git a/tests/public-contract.json b/tests/public-contract.json new file mode 100644 index 0000000..5add462 --- /dev/null +++ b/tests/public-contract.json @@ -0,0 +1,167 @@ +{ + "exportKeys": [".", "./package.json", "./postgres", "./sqlite", "./tooling"], + "entrypoints": { + ".": { + "values": [ + "DatabaseError", + "and", + "bigInt", + "boolean", + "bytes", + "check", + "columnRef", + "compileSql", + "date", + "defineDatabase", + "defineQuery", + "doublePrecision", + "eq", + "escapeLikePattern", + "gt", + "gte", + "ilike", + "inArray", + "index", + "integer", + "isNotNull", + "isNull", + "json", + "like", + "lt", + "lte", + "ne", + "not", + "numeric", + "or", + "real", + "sql", + "table", + "text", + "timestamp", + "unique", + "uuid", + "view" + ], + "types": [ + "AnyTable", + "Codec", + "ColumnBuilder", + "DatabaseAdapter", + "DatabaseClient", + "DatabaseDefinition", + "DatabaseDriver", + "DatabaseErrorCategory", + "DatabaseOpenOptions", + "DatabaseToolingAdapter", + "DialectName", + "ExecutionResult", + "Expression", + "GeneratedDatabaseArtifact", + "InferInsert", + "InferKey", + "InferPatch", + "InferRow", + "MigrationApplyOptions", + "MigrationEvent", + "MigrationManifest", + "MigrationsApi", + "QueryOptions", + "RegisteredQuery", + "SqlFragment", + "SqlQuery", + "TelemetryEvent", + "TelemetryOptions", + "TransactionOptions", + "WriteResult" + ], + "removed": [ + "AnyColumn", + "AppliedMigration", + "BundledMigration", + "CheckConstraint", + "CleanDatabaseOptions", + "ColumnAst", + "ColumnRef", + "ColumnValue", + "EnumDefinition", + "IndexDefinition", + "JoinTarget", + "JoinedQuery", + "KeyedSql", + "MigrationApplyResult", + "MigrationPlan", + "MigrationPlanEntry", + "PreparedQuery", + "References", + "RegisteredQueryFunction", + "ReturningRow", + "ReturningRows", + "ReturningStatus", + "SelectQuery", + "Selection", + "SelectionResult", + "TableClient", + "TableConstraint", + "TableDefinition", + "TableOptions", + "TableRefs", + "UniqueConstraint", + "UnsafeSql", + "ViewDefinition", + "assertSafeIdentifier", + "compileKeyedSql", + "createDatabaseClient", + "executeKeyedSql", + "identifier", + "joinFragments", + "literal", + "normalizeDatabaseError", + "quoteIdentifier", + "tableRefs", + "toSnakeCase", + "unsafeSql" + ] + }, + "./tooling": { + "values": ["runDatabaseCli"], + "types": [], + "removed": [ + "DatabaseCliIo", + "RunDatabaseCliOptions", + "SchemaSnapshot", + "SnapshotColumn", + "SnapshotTable", + "diffSnapshots", + "hasDatabaseEntry", + "snapshotDefinition", + "validateDiscoveredDatabase" + ] + }, + "./postgres": { + "values": ["bytea", "jsonb", "postgres", "postgresEnum", "postgresType", "timestampTz"], + "types": ["PostgresOptions"], + "removed": [] + }, + "./sqlite": { + "values": ["sqlite"], + "types": ["SqliteOptions"], + "removed": [] + } + }, + "privateSubpaths": [ + "adapter", + "client", + "definition", + "errors", + "migrations", + "naming", + "placeholders", + "query", + "registered-query", + "schema", + "sql", + "tooling-impl", + "dist/index.js", + "dist/tooling-impl.js" + ] +} diff --git a/tests/types/contracts.ts b/tests/types/contracts.ts index 2e49816..b4a7280 100644 --- a/tests/types/contracts.ts +++ b/tests/types/contracts.ts @@ -1,5 +1,5 @@ import { - createDatabaseClient, + defineDatabase, eq, table, text, @@ -22,7 +22,16 @@ const users = table("users", { }); declare const adapter: DatabaseAdapter; -const db = createDatabaseClient({ users, groups }, adapter); +const db = await defineDatabase({ + tables: { users, groups }, + driver: { + dialect: "postgres", + open: async () => adapter, + shadow: async () => { + throw new Error("No tooling in this fixture."); + }, + }, +}).open(); const row: InferRow = { id: "id", diff --git a/vite.config.ts b/vite.config.ts index d8c93c6..cd3e2f9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from "vite-plus"; export default defineConfig({ + test: { + coverage: { + provider: "v8", + reporter: ["text", "json-summary", "html"], + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.fixture.ts"], + }, + }, pack: { entry: { index: "src/index.ts", From 6ac352665152045cf17fada658eeaa2bc296e080 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 16:29:39 -0400 Subject: [PATCH 2/3] fix: guard migration lifetimes and load project entries by file URL --- CHANGELOG.md | 6 +++ docs/0.5.0-hardening.md | 46 ++++++++++++++++------ src/client.ts | 16 +++++++- src/tooling-impl.ts | 2 +- src/tooling.test.ts | 8 ++-- src/transaction-migration-lifetime.test.ts | 38 ++++++++++++++++++ tests/packed-contract.mjs | 5 +++ 7 files changed, 103 insertions(+), 18 deletions(-) create mode 100644 src/transaction-migration-lifetime.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 76eb8bc..5970cca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,12 @@ - Update compatible development tooling to audited versions and add TypeScript 6 compiler-API audits alongside the TypeScript 7 gate and pin the matching coverage provider. +- Load CLI database entries through file URLs so Windows drive paths and reserved + URL characters in project directories remain filesystem paths. + +- Transaction-owned migration plan/apply/resolve handles now reject use after + their transaction ends, before any SQL can run on a released connection. + ### Remaining release gate - PostgreSQL shadow reset/introspection/query description are incomplete in the diff --git a/docs/0.5.0-hardening.md b/docs/0.5.0-hardening.md index 3d16a89..3dc5c8c 100644 --- a/docs/0.5.0-hardening.md +++ b/docs/0.5.0-hardening.md @@ -21,9 +21,9 @@ normal package gates on Linux, macOS and Windows. ## Confirmed runtime regressions -The 25 focused cases were run with adapter/migration source restored from base +The 33 focused cases were run with adapter/migration source restored from base commit `4cb035c32240bd54464e25fb947b01b5e2279595`, then restored to the candidate. -The original source fails ten cases and passes 15 characterizations. The ten +The original source fails 14 cases and passes 19 characterizations. Those failures exercise several defects, not ten independent bug claims: | Boundary | Original behavior | Candidate and asserted recovery | @@ -43,7 +43,7 @@ partial-state and explicit resolve policy. ## Characterization and parity -The 15 passing original-source characterizations include pre-execution migration +The 19 passing original-source characterizations include pre-execution migration cancellation/recovery, database self-disconnect rollback and subsequent migration lock recovery, existing PostgreSQL lifecycle cases, and common adapter values. The shared real-adapter value probe verifies NULL, empty text/binary values, @@ -64,21 +64,22 @@ help path resolves; real PostgreSQL generation is separately blocked by #55. `npm run check` runs lint/types, V8 implementation coverage, type contracts, packed consumer, build, the unchanged 20,000-operation/10,000-row benchmark, publint and pack dry run. Format checking runs explicitly and in hosted CI. -The final source suite contains 79 cases: 67 ordinary cases and 12 real +The final source suite contains 82 cases: 70 ordinary cases and 12 real PostgreSQL integration cases. A lane without the database URL skips only those 12 integration cases; it is not PostgreSQL qualification by itself. Coverage is measured across implementation source, including the incomplete tooling. -Measured with the real PostgreSQL URL enabled (79 cases): +Measured with the real PostgreSQL URL enabled (82 cases): -| Implementation | Statements | Branches | Functions | Lines | -| --------------- | ------------------ | ----------------- | ---------------- | ------------------ | -| total | 1282/1619 (79.18%) | 700/1023 (68.42%) | 323/396 (81.56%) | 1186/1443 (82.18%) | -| migrations.ts | 77/84 (91.66%) | 30/37 (81.08%) | 18/19 (94.73%) | 71/77 (92.2%) | -| postgres.ts | 118/141 (83.68%) | 55/76 (72.36%) | 25/30 (83.33%) | 113/124 (91.12%) | -| sqlite.ts | 122/168 (72.61%) | 40/68 (58.82%) | 31/49 (63.26%) | 112/145 (77.24%) | -| tooling-impl.ts | 280/482 (58.09%) | 155/352 (44.03%) | 51/85 (60%) | 262/432 (60.64%) | +| Implementation | Statements | Branches | Functions | Lines | +| --------------- | ------------------ | ----------------- | --------------- | ------------------ | +| total | 1289/1626 (79.27%) | 699/1023 (68.32%) | 326/399 (81.7%) | 1193/1450 (82.27%) | +| migrations.ts | 77/84 (91.66%) | 30/37 (81.08%) | 18/19 (94.73%) | 71/77 (92.2%) | +| postgres.ts | 118/141 (83.68%) | 55/76 (72.36%) | 25/30 (83.33%) | 113/124 (91.12%) | +| sqlite.ts | 122/168 (72.61%) | 40/68 (58.82%) | 31/49 (63.26%) | 112/145 (77.24%) | +| tooling-impl.ts | 280/482 (58.09%) | 155/352 (44.03%) | 51/85 (60%) | 262/432 (60.64%) | +| client.ts | 216/250 (86.4%) | 108/147 (73.46%) | 65/74 (87.83%) | 200/221 (90.49%) | Executed commands: @@ -99,6 +100,27 @@ The compatible development dependency update reduces the npm audit result from four findings to zero. It does not change optional peer ranges or use forced peer installs. No version bump, release tag or package publication is included. +## Transaction migration lifetime + +Three real SQLite cases capture the transaction's migrations API, then invoke +plan, apply and resolve after the callback has ended. Original code bypasses the +client's lifetime guard: plan/apply succeed and resolve reaches migration logic. +The guarded API rejects every call before SQL and leaves root plan usable. The +installed package repeats all three retirement assertions. Calls started inside +a transaction still need to be awaited by the transaction callback. + +## Hosted platform follow-up + +The first new Windows package lane failed the existing generation/validation +case. Extending that case to a project directory containing spaces, # and % +reproduced a loader failure locally: a filesystem path was interpreted as URL +syntax and truncated before the project name. Converting the database entry to +a file URL fixes the local RED case and explicitly handles Windows drive paths. +The fixture now includes CLI error output in failed expectations. Of the 41 +moved tooling function bodies, 40 remain token-identical; loadDatabases has this +single intentional boundary correction. This does not complete the distinct +PostgreSQL shadow workflow tracked below. + ## Remaining PostgreSQL tooling blocker An executed real PostgreSQL probe confirms that shadow reset always throws, diff --git a/src/client.ts b/src/client.ts index c821362..22ae059 100644 --- a/src/client.ts +++ b/src/client.ts @@ -604,6 +604,7 @@ export function createDatabaseClient< new TableClient(definition, effectiveAdapter, options.telemetry), ]), ) as DatabaseTables; + const migrations = createMigrationsApi(currentAdapter, manifest); return Object.assign(clients, { queries: Object.fromEntries( Object.entries(registeredQueries).map(([name, query]) => [ @@ -614,7 +615,20 @@ export function createDatabaseClient< }, ]), ) as QueryFunctions, - migrations: createMigrationsApi(currentAdapter, manifest), + migrations: { + async plan(queryOptions) { + assertActive(); + return migrations.plan(queryOptions); + }, + async apply(applyOptions) { + assertActive(); + return migrations.apply(applyOptions); + }, + async resolve(id, resolution, queryOptions) { + assertActive(); + return migrations.resolve(id, resolution, queryOptions); + }, + } satisfies MigrationsApi, transaction: ( callback: (database: DatabaseClient) => Promise, transactionOptions?: TransactionOptions, diff --git a/src/tooling-impl.ts b/src/tooling-impl.ts index 4a4e7f9..b34399c 100644 --- a/src/tooling-impl.ts +++ b/src/tooling-impl.ts @@ -558,7 +558,7 @@ async function loadDatabases(cwd: string): Promise<{ readonly projectRoot: string; }> { const location = await findDatabaseEntry(cwd); - const module = (await tsImport(path.join(location.databaseDir, "index.ts"), { + const module = (await tsImport(pathToFileURL(path.join(location.databaseDir, "index.ts")).href, { parentURL: pathToFileURL(location.projectRoot).href, })) as Record; const exported = module.default ?? module.database; diff --git a/src/tooling.test.ts b/src/tooling.test.ts index 9d813f1..093eb50 100644 --- a/src/tooling.test.ts +++ b/src/tooling.test.ts @@ -122,7 +122,7 @@ describe("schema migration generation", () => { describe("database CLI", () => { it("should generate, byte-validate, and no-op regenerate a flat project", async () => { const packageRoot = path.resolve(import.meta.dirname, ".."); - const root = await fs.mkdtemp(path.join(packageRoot, ".orm-fixture-")); + const root = await fs.mkdtemp(path.join(packageRoot, ".orm-fixture- #% ")); temporaryDirectories.push(root); await fs.mkdir(path.join(root, "database"), { recursive: true }); await fs.writeFile( @@ -169,12 +169,12 @@ export default defineDatabase({ error: (value: unknown = "") => logs.push(`ERROR ${String(value)}`), }; - expect(await runDatabaseCli(["generate"], { cwd: root, io })).toBe(0); + expect(await runDatabaseCli(["generate"], { cwd: root, io }), logs.join("\n")).toBe(0); expect(logs.at(-1)).toMatch(/generated/); expect((await fs.readdir(path.join(root, "database", "migrations"))).length).toBe(1); - expect(await runDatabaseCli(["validate"], { cwd: root, io })).toBe(0); + expect(await runDatabaseCli(["validate"], { cwd: root, io }), logs.join("\n")).toBe(0); expect(logs.at(-1)).toBe("default: valid"); - expect(await runDatabaseCli(["generate"], { cwd: root, io })).toBe(0); + expect(await runDatabaseCli(["generate"], { cwd: root, io }), logs.join("\n")).toBe(0); expect(logs.at(-1)).toBe("default: unchanged"); const generated = await fs.readFile(path.join(root, "database", "generated.ts"), "utf8"); diff --git a/src/transaction-migration-lifetime.test.ts b/src/transaction-migration-lifetime.test.ts new file mode 100644 index 0000000..221783b --- /dev/null +++ b/src/transaction-migration-lifetime.test.ts @@ -0,0 +1,38 @@ +import { expect, it, vi } from "vitest"; +import { defineDatabase, type MigrationsApi } from "./index"; +import { sqlite } from "./sqlite"; + +it.each(["plan", "apply", "resolve"] as const)( + "should reject escaped transaction migration %s before any SQL and leave the root usable", + async (operation) => { + const adapter = await sqlite({ filename: ":memory:" }).open(); + const db = await defineDatabase({ + tables: {}, + driver: { + dialect: "sqlite", + open: async () => adapter, + shadow: async () => { + throw new Error("No shadow in lifetime probe."); + }, + }, + }).open(); + let escaped!: MigrationsApi; + try { + await db.transaction(async (transaction) => { + escaped = transaction.migrations; + }); + const execute = vi.spyOn(adapter, "execute"); + const calls = { + plan: () => escaped.plan(), + apply: () => escaped.apply(), + resolve: () => escaped.resolve("01EXPIRED", "applied"), + }; + await expect(calls[operation]()).rejects.toThrow("Transaction client is no longer active."); + expect(execute).not.toHaveBeenCalled(); + execute.mockRestore(); + await expect(db.migrations.plan()).resolves.toEqual({ applied: [], pending: [] }); + } finally { + await db.close(); + } + }, +); diff --git a/tests/packed-contract.mjs b/tests/packed-contract.mjs index 09ac624..848aec6 100644 --- a/tests/packed-contract.mjs +++ b/tests/packed-contract.mjs @@ -70,6 +70,11 @@ try { await assert.rejects(db.transaction(async tx => { await tx.items.insert({ id: 3, value: 'discard' }); throw primary; }), error => error === primary); assert.equal((await db.items.select(({ items }) => items).execute()).length, 2); assert.equal((await db.migrations.plan()).pending.length, 0); + let retiredMigrations; + await db.transaction(async tx => { retiredMigrations = tx.migrations; }); + await assert.rejects(retiredMigrations.plan(), /Transaction client is no longer active/); + await assert.rejects(retiredMigrations.apply(), /Transaction client is no longer active/); + await assert.rejects(retiredMigrations.resolve('01SCRIPT', 'applied'), /Transaction client is no longer active/); } finally { await db.close(); } `, ); From ae0a5da9d41501f33b7e05568b620f192c92ff85 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 16:32:23 -0400 Subject: [PATCH 3/3] ci: preserve the required package check across platform gates --- .github/workflows/ci.yml | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3120ea0..279f165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,8 @@ permissions: contents: read jobs: - package: + package-platform: + name: package (${{ matrix.os }}) runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -42,6 +43,16 @@ jobs: path: coverage if-no-files-found: warn + package: + needs: package-platform + if: always() + runs-on: ubuntu-latest + steps: + - name: Require every platform package gate + env: + PACKAGE_RESULT: ${{ needs.package-platform.result }} + run: test "$PACKAGE_RESULT" = success + postgres-contract: name: PostgreSQL ${{ matrix.postgres }} runs-on: ubuntu-latest