diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 659c22a..279f165 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,8 +10,13 @@ permissions: contents: read jobs: - package: - runs-on: ubuntu-latest + package-platform: + name: package (${{ matrix.os }}) + 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 +29,30 @@ 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 + + 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 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5970cca --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# 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. + +- 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 + 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..3dc5c8c --- /dev/null +++ b/docs/0.5.0-hardening.md @@ -0,0 +1,134 @@ +# 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 33 focused cases were run with adapter/migration source restored from base +commit `4cb035c32240bd54464e25fb947b01b5e2279595`, then restored to the candidate. +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 | +| --------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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 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, +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 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 (82 cases): + +| 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: + +```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. + +## 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, +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/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/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..b34399c --- /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(pathToFileURL(path.join(location.databaseDir, "index.ts")).href, { + 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..093eb50 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[] = []; @@ -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/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/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/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..848aec6 --- /dev/null +++ b/tests/packed-contract.mjs @@ -0,0 +1,195 @@ +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); + 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(); } + `, + ); + 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",