From 8525e1bb6535f596cdce5ab42de58e5812784b75 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Tue, 29 Sep 2026 14:30:25 -0400 Subject: [PATCH 1/7] docs: require a changelog section with every version bump --- AGENTS.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 99c2cfd..4f6dbdb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,17 @@ Run `npm run check` before declaring a change ready; it includes coverage, types, packaging, and benchmarks. Run real PostgreSQL and SQLite integration lanes for adapter-sensitive changes. +## Changelog + +Any change to the `version` field in `package.json`, whether a release, +prerelease, or patch bump, must include a matching `## ` section in +`CHANGELOG.md` in the same commit or pull request. Date the section and list +breaking changes (with migration notes), deprecations, additions, and fixes. +Move entries from `Unreleased` into the new version section rather than leaving +them there. If the repository has no `CHANGELOG.md` yet, create one (Keep a +Changelog style) at the next version bump. Do not publish or tag a version whose +changelog section is missing. + ## Optimization Gate A benchmark number is only half of an optimization's success criterion. The From 4cb035c32240bd54464e25fb947b01b5e2279595 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 2 Oct 2026 12:59:57 -0400 Subject: [PATCH 2/7] ci: drop the redundant main-branch check from the publish workflow (#52) Publishing runs in the npm environment, which now only deploys from main, so the workflow no longer needs its own GITHUB_REF check. --- .github/workflows/publish.yml | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index e10d74e..e527d55 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -4,20 +4,13 @@ on: workflow_dispatch: jobs: - release-branch: - runs-on: ubuntu-latest - permissions: {} - steps: - - name: Require main release branch - run: test "$GITHUB_REF" = 'refs/heads/main' - ci: uses: ./.github/workflows/ci.yml permissions: contents: read publish: - needs: [release-branch, ci] + needs: [ci] uses: askrjs/actions/.github/workflows/publish-package.yml@main permissions: contents: write From 3f595754d7e2a3135cd0ed0f5ef67b05b306e82c Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 16:43:18 -0400 Subject: [PATCH 3/7] Contract ORM API and preserve migration scripts and failed cleanup (#56) * feat: contract ORM API and recover failed migration cleanup * fix: guard migration lifetimes and load project entries by file URL * ci: preserve the required package check across platform gates --- .github/workflows/ci.yml | 30 +- CHANGELOG.md | 44 + README.md | 17 +- benchmarks/runtime.mjs | 13 +- docs/0.5.0-api.md | 186 ++++ docs/0.5.0-hardening.md | 134 +++ docs/adapters.md | 7 +- docs/migrations.md | 8 + package-lock.json | 492 +++++---- package.json | 14 +- src/adapter.ts | 2 + src/cleanup-recovery.test.ts | 88 ++ src/client.test.ts | 3 +- src/client.ts | 16 +- src/errors.test.ts | 3 +- src/index.ts | 66 +- src/integration.test.ts | 114 +- src/migration-recovery.test.ts | 116 +++ src/migrations.ts | 13 +- src/postgres.test.ts | 55 +- src/postgres.ts | 45 +- src/schema.test.ts | 2 +- src/sql.test.ts | 37 +- src/sql.ts | 13 +- src/sqlite.test.ts | 24 +- src/sqlite.ts | 46 +- src/tooling-impl.ts | 1091 +++++++++++++++++++ src/tooling.test.ts | 10 +- src/tooling.ts | 1092 +------------------- src/transaction-migration-lifetime.test.ts | 38 + src/value-conformance.fixture.ts | 70 ++ src/value-conformance.test.ts | 12 + tests/packed-contract.mjs | 195 ++++ tests/public-contract.json | 167 +++ tests/types/contracts.ts | 13 +- vite.config.ts | 8 + 36 files changed, 2816 insertions(+), 1468 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 docs/0.5.0-api.md create mode 100644 docs/0.5.0-hardening.md create mode 100644 src/cleanup-recovery.test.ts create mode 100644 src/migration-recovery.test.ts create mode 100644 src/tooling-impl.ts create mode 100644 src/transaction-migration-lifetime.test.ts create mode 100644 src/value-conformance.fixture.ts create mode 100644 src/value-conformance.test.ts create mode 100644 tests/packed-contract.mjs create mode 100644 tests/public-contract.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 659c22a..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", From af7c838bee227423f3aecd537ee148779a193df4 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Fri, 9 Oct 2026 18:24:15 -0400 Subject: [PATCH 4/7] fix: qualify real PostgreSQL generation and validation (#57) --- .github/workflows/ci.yml | 7 + CHANGELOG.md | 18 +- README.md | 13 +- docs/0.5.0-api.md | 2 +- docs/0.5.0-hardening.md | 82 +++--- docs/adapters.md | 4 + docs/postgres-tooling.md | 111 ++++++++ package.json | 2 +- src/postgres-client.ts | 49 ++++ src/postgres-tooling.ts | 479 ++++++++++++++++++++++++++++++++ src/postgres.test.ts | 19 +- src/postgres.ts | 98 +------ src/schema.test.ts | 2 +- src/schema.ts | 4 +- src/tooling-impl.ts | 246 ++++++++++++++-- src/tooling-integration.test.ts | 457 ++++++++++++++++++++++++++++++ tests/packed-contract.mjs | 48 +++- tests/postgres-test-env.mjs | 4 + tests/prepare-postgres.mjs | 24 ++ tests/public-contract.json | 4 +- 20 files changed, 1506 insertions(+), 167 deletions(-) create mode 100644 docs/postgres-tooling.md create mode 100644 src/postgres-client.ts create mode 100644 src/postgres-tooling.ts create mode 100644 src/tooling-integration.test.ts create mode 100644 tests/postgres-test-env.mjs create mode 100644 tests/prepare-postgres.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 279f165..805d69d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: --health-retries 10 env: ASKR_ORM_TEST_DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/askr_orm_test + ASKR_ORM_TEST_SHADOW_URL: postgresql://postgres:postgres@127.0.0.1:5432/askr_orm_test_shadow steps: - name: Checkout repository uses: actions/checkout@v7 @@ -87,5 +88,11 @@ jobs: - name: Install dependencies run: npm ci + - name: Create isolated scratch database + run: node tests/prepare-postgres.mjs + - name: Run PostgreSQL integration tests run: npm run test:integration + + - name: Qualify installed PostgreSQL generation with minimum peers + run: npm run test:packed diff --git a/CHANGELOG.md b/CHANGELOG.md index 5970cca..4e8681e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,8 +37,20 @@ - Transaction-owned migration plan/apply/resolve handles now reject use after their transaction ends, before any SQL can run on a released connection. +- Complete PostgreSQL scratch reset with an actual database separation check, + session-owned workflow lock, normalized catalog introspection and protocol-only + query description. Unequal URL strings alone no longer authorize reset. +- Normalize the desired PostgreSQL schema inside rollback-only scratch DDL, + preserving native type/default/expression semantics without comparing codecs + or application property names to database columns. +- Quote enum type/schema names, emit one composite primary key, and defer initial + foreign keys until their referenced tables exist. +- Generate conservative nullable query results and standard PostgreSQL parser + types (int8/numeric strings, timestamp/date Date, unknown unsupported types). +- Gate real and normally installed generation/validation/no-op workflows against + isolated PostgreSQL 16, 17 and 18 scratch databases. + ### 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. +- Complete coordinated 0.5.0 packed-candidate and website qualification remains + pending. Maintainer review and approval are required before publication. diff --git a/README.md b/README.md index d52147c..9d80905 100644 --- a/README.md +++ b/README.md @@ -98,10 +98,13 @@ 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. +PostgreSQL generated-artifact tooling uses a disposable scratch database and +proves actual separation from the target before reset. It normalizes supported +catalog shapes and describes query results without executing application queries. +See [PostgreSQL tooling](docs/postgres-tooling.md) for reset scope, supported +objects, conservative result types and connection requirements. Native and +installed PostgreSQL 16–18 CI lanes qualify the workflow. Complete coordinated +0.5.0 candidate qualification and maintainer review are still required. Generation replays checksummed, forward-only SQL against the shadow database before accepting it. It writes migration SQL plus one committed @@ -122,5 +125,5 @@ cannot be interrupted. 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. +regression fixes, executed characterization and the remaining coordinated release gates. The package version remains 0.4.0 until the coordinated candidate is prepared. diff --git a/docs/0.5.0-api.md b/docs/0.5.0-api.md index 6c81558..06708ae 100644 --- a/docs/0.5.0-api.md +++ b/docs/0.5.0-api.md @@ -1,6 +1,6 @@ # 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). +Status: implementation candidate; API issue #53 remains open for the complete 0.5.0 packed-package and generated website review. The real PostgreSQL tooling fix for [#55](https://github.com/askrjs/askr-orm/issues/55) has separate native and installed workflow qualification in [PostgreSQL tooling](postgres-tooling.md). 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. diff --git a/docs/0.5.0-hardening.md b/docs/0.5.0-hardening.md index 3dc5c8c..10b2d95 100644 --- a/docs/0.5.0-hardening.md +++ b/docs/0.5.0-hardening.md @@ -1,9 +1,9 @@ # 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. +Status: the API/runtime candidate and real PostgreSQL tooling fix are qualified +locally. PostgreSQL 16–18 hosted and installed workflow checks must pass before +#55 closes. The package is **not release-ready**: #53 and #54 still require the +complete coordinated candidate/website qualification and maintainer review. ## Assertion review and real adapters @@ -55,46 +55,49 @@ identifier rejection and a subsequent successful read on both databases. 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 +ColumnBuilder's type-only boundary, removed sql.key, 16 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. +help path resolves. Installed PostgreSQL generation, validation and no-op +regeneration pass with minimum peers and TypeScript 6/7 generated-artifact checks. `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 +The final source suite contains 100 cases: 70 ordinary cases and 30 real PostgreSQL integration cases. A lane without the database URL skips only those -12 integration cases; it is not PostgreSQL qualification by itself. +30 integration cases; it is not PostgreSQL qualification by itself. -Coverage is measured across implementation source, including the incomplete tooling. +Coverage is measured across implementation source, including the complete private tooling path. -Measured with the real PostgreSQL URL enabled (82 cases): +Measured with both real PostgreSQL URLs enabled (100 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%) | +| Implementation | Statements | Branches | Functions | Lines | +| ------------------- | ------------------ | ----------------- | ---------------- | ------------------ | +| total | 1594/1887 (84.47%) | 881/1179 (74.72%) | 407/459 (88.67%) | 1474/1692 (87.11%) | +| client.ts | 216/250 (86.4%) | 106/147 (72.1%) | 65/74 (87.83%) | 200/221 (90.49%) | +| migrations.ts | 77/84 (91.66%) | 29/37 (78.37%) | 18/19 (94.73%) | 71/77 (92.2%) | +| postgres-client.ts | 21/21 (100%) | 4/4 (100%) | 7/7 (100%) | 19/19 (100%) | +| postgres-tooling.ts | 185/187 (98.93%) | 85/90 (94.44%) | 36/36 (100%) | 172/174 (98.85%) | +| postgres.ts | 94/107 (87.85%) | 55/72 (76.38%) | 19/19 (100%) | 89/92 (96.73%) | +| sqlite.ts | 122/168 (72.61%) | 40/68 (58.82%) | 31/49 (63.26%) | 112/145 (77.24%) | +| tooling-impl.ts | 400/569 (70.29%) | 249/418 (59.56%) | 93/113 (82.3%) | 374/513 (72.9%) | 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 +ASKR_ORM_TEST_DATABASE_URL= ASKR_ORM_TEST_SHADOW_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. +from the integration proof above. The real PostgreSQL matrix executes 12 runtime and 18 tooling +integration cases, followed by the normal installed minimum-peer workflow. 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. +Test/fixture source is excluded. Uncovered branches remain visible in the retained coverage; test names and +source counts are not acceptance evidence. 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 @@ -121,14 +124,25 @@ 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. +## PostgreSQL tooling qualification + +The original real PostgreSQL probe found three stubs: reset always threw, +introspection returned database/schema identity rows, and description returned +no columns. The fake-driver unit fixture did not qualify these boundaries. +The real-adapter regressions and the normal installed workflow are documented in +[PostgreSQL tooling](postgres-tooling.md). They cover actual target aliases, +unreachable target, reset rollback, deterministic supported catalogs, protocol +metadata without executing queries, concurrent description, workflow lock +ownership, real generation/byte-validation/no-op, drift/stale-artifact failure +and replay recovery. Composite keys and quoted enum names receive native tests. + +The final 18-case native tooling suite fails 17 cases against the previous merged +PostgreSQL/schema/private-tooling source at +`3f595754d7e2a3135cd0ed0f5ef67b05b306e82c`; unreachable-target refusal is one +passing characterization because the old reset stub refuses everything. These +are scenarios, not 17 independent bug claims. Additional refinement RED cases +caught quoted enum SQL, missing related-table creation order, generated constraint +name differences during explicit rename, four silently ignored physical schema +attributes, duplicate query output names, and repeated parameter declarations +rejected by TypeScript. The final candidate passes all 100 tests and the installed +TypeScript 6/7 workflow. diff --git a/docs/adapters.md b/docs/adapters.md index 6d0fff8..512eac4 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -34,6 +34,10 @@ interpolate application data. Placeholder rendering is dialect-owned. Set `targetIdentity` and `scratchIdentity` in `database/index.ts`. Tooling checks these before reset or target migration work and fails closed on a match. +The built-in PostgreSQL driver additionally proves actual target/scratch +separation before reset and owns a pinned scratch workflow session. See +[PostgreSQL tooling](postgres-tooling.md) for destructive reset scope, supported +catalog shapes, conservative query metadata and connection requirements. Driver errors may pass through the adapter. The ORM normalizes PostgreSQL constraint, serialization, deadlock, timeout, cancellation, and connection diff --git a/docs/postgres-tooling.md b/docs/postgres-tooling.md new file mode 100644 index 0000000..157d8f5 --- /dev/null +++ b/docs/postgres-tooling.md @@ -0,0 +1,111 @@ +# PostgreSQL generated-artifact tooling + +`generate`, `validate` and manual-artifact refresh use the configured **disposable +scratch database**. Reset removes all non-system schemas and their dependent +objects from that database, then recreates `public`. Do not configure a database +containing data you intend to keep as `shadowUrl` / `DATABASE_SHADOW_URL`. + +The application target is never reset or migrated by these commands. Before +scratch reset, tooling opens a read-only target transaction and holds a random +two-integer session advisory lock. The scratch connection must independently +acquire the same key while the target still owns its lock. The target must then +successfully unlock it. A shared actual database, an unavailable target, or a +lost lock owner fails closed before destructive SQL. URL spelling is not proof +of separation. The target connection needs connection and advisory-lock rights; +this guard reads no application tables and requires no superuser catalog access. + +[PostgreSQL documents database-local advisory locks](https://www.postgresql.org/docs/16/view-pg-locks.html) +and the separate integer-key namespaces and session unlock semantics in its +[advisory lock functions](https://www.postgresql.org/docs/16/functions-admin.html#FUNCTIONS-ADVISORY-LOCKS). + +One scratch adapter owns one physical session, serializes its method calls and +holds a distinct workflow advisory lock until close. Two Askr tooling processes +therefore cannot interleave reset/replay/probe/description on the same scratch +database. Use a direct connection or a session pool; transaction/statement +pooling does not provide this session ownership contract. Every workflow closes +its owner, rolling back any unfinished transaction and releasing the lock. +Failed rollback, unlock or deallocation discards an unsafe native pool client. + +## Physical schema comparison + +Introspection reads PostgreSQL catalogs for ordinary tables and columns, native +types and type modifiers, defaults, stored generated expressions, primary keys, +unique/check constraints, single-column foreign keys, ordinary indexes, enums +and views. Results use deterministic object/column/constraint ordering. Enum +labels and column names are explicitly converted to text arrays for normal `pg` +decoding on every supported PostgreSQL version. + +The desired schema is rendered inside a scratch transaction after temporarily +removing the replayed tables/views/enums. PostgreSQL introspects that desired DDL; +rollback then restores the replayed schema before the diff. This compares native +physical forms, including `varchar`/`character varying`, casts, defaults and view +expressions. It does not copy desired physical facts onto actual introspection. +Application property names and codecs stay in generated source identity; +rename/conversion instructions are excluded from physical equality. Constraint and +index names are compared when the definition explicitly names them; generated +names remain engine-owned, including after a declared column/table rename. +Constraint expressions, columns, uniqueness, methods and predicates remain part +of equality. New related tables are all created before their foreign keys. + +Initial tables precede foreign-key additions, permitting references to tables +declared later and mutual references. A composite key renders as one table-level +primary key, preserving its declared column order. Known enum identifiers are +quoted individually, including embedded quotes and reserved URL characters. + +This is a supported-declaration comparison, not a model of every PostgreSQL +object. Materialized/partitioned/foreign relations, inheritance, unlogged tables, identity +columns, custom column collations, non-stored generated columns, deferred, +unvalidated or unenforced constraints, NULLS NOT DISTINCT uniqueness, +multi-column/non-default foreign keys, invalid indexes and indexes with included +columns currently produce corrective errors. Standalone +sequences, functions, extension-owned objects, grants and other administrative +objects are outside the snapshot; maintain them explicitly in reviewed SQL. +Additional unsupported physical changes and constraint/index changes require a +manual migration. A failed desired-schema probe or final comparison is never +accepted as an unchanged schema. + +## Query metadata without execution + +Tooling submits a named protocol Parse, Describe and Sync exchange, followed by +deallocation. It sends no Bind or Execute message. PostgreSQL rejects multiple +commands during Parse; no later statement in a batch can run. Result column names +and native types come from RowDescription and `format_type`, not sampled rows. +Repeated named parameters retain all positional bindings but appear only once +in the generated parameter interface. Duplicate result column names fail with +an explicit alias error rather than producing ambiguous generated properties. +See PostgreSQL's [extended-query message flow](https://www.postgresql.org/docs/16/protocol-flow.html#PROTOCOL-FLOW-EXT-QUERY). + +RowDescription does not establish expression or outer-join nullability, so every +generated PostgreSQL query column is conservatively nullable. Generated result +types assume the standard `pg` parsers: int8/numeric return strings; date and +timestamp return `Date`; supported integer/float/boolean/text/uuid/bytea types use +their corresponding JavaScript types. JSON and unrecognized/custom/array types +remain `unknown`. Custom pool type parsers and application coercions are caller +contracts and are not inferred by tooling. + +## Executed qualification + +The real tooling lane exercises alias-target refusal with preserved target data, +unreachable target, read-only reset rollback, deterministic quoted catalogs, +metadata for SELECT and INSERT RETURNING without changing a row or advancing a +sequence, multi-command rejection, error recovery, concurrent descriptions with +pool size one, scratch lock handoff, complete generation/validation/no-op, +stale artifacts, migration drift, failed replay recovery, incremental related +tables, unique-column rename and no-op replay, composite keys, enums, +indexes, checks, views and standard query value types. + +The packed contract normally installs minimum `pg@8.23.0` and +`pg-query-stream@4.17.0`, repeats generation/validation/no-op through the installed +public tooling bridge and compiles the generated artifact under TypeScript 6 and 7. CI runs both source and installed workflows on PostgreSQL 16, 17 and 18. + +Local integration requires explicit isolated databases: + +```sh +ASKR_ORM_TEST_DATABASE_URL= \ +ASKR_ORM_TEST_SHADOW_URL= \ +vp env exec --node 24.21.0 npm run test:integration +``` + +Ordinary package tests skip these native PostgreSQL cases without both URLs. +The explicit integration command rejects missing URLs instead of passing skips. +No release version, tag or publication is included in this change. diff --git a/package.json b/package.json index 824e075..56bacf0 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "typecheck": "tsc -p tsconfig.json --noEmit", "test": "vp test run", "test:coverage": "vp test run --coverage", - "test:integration": "vp test run src/integration.test.ts", + "test:integration": "node tests/postgres-test-env.mjs && vp test run src/integration.test.ts src/tooling-integration.test.ts", "test:types": "npm run build && tsc -p tests/types/tsconfig.json", "test:publint": "publint", "pack:check": "npm pack --ignore-scripts --dry-run --json", diff --git a/src/postgres-client.ts b/src/postgres-client.ts new file mode 100644 index 0000000..ad77852 --- /dev/null +++ b/src/postgres-client.ts @@ -0,0 +1,49 @@ +import type { PoolClient, QueryResult } from "pg"; +import { normalizeDatabaseError, type DatabaseError } from "./errors"; + +export class CheckedOutClient { + private failure: DatabaseError | undefined; + + private readonly onError = (error: Error): void => { + this.failure ??= normalizeDatabaseError(error); + }; + + constructor(private readonly client: PoolClient) { + client.on("error", this.onError); + } + + assertHealthy(): void { + if (this.failure) throw this.failure; + } + + async query(config: unknown): Promise { + this.assertHealthy(); + try { + const result = await this.client.query(config as never); + this.assertHealthy(); + return result; + } catch (error) { + throw normalizeDatabaseError(error); + } + } + + startStream(query: T): T { + this.assertHealthy(); + return this.client.query(query as never) as T; + } + + 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); + } +} diff --git a/src/postgres-tooling.ts b/src/postgres-tooling.ts new file mode 100644 index 0000000..bbb433e --- /dev/null +++ b/src/postgres-tooling.ts @@ -0,0 +1,479 @@ +import { randomBytes } from "node:crypto"; +import type { Connection, FieldDef, Pool, Submittable } from "pg"; +import type { DatabaseToolingAdapter } from "./definition"; +import { quoteIdentifier } from "./naming"; +import { CheckedOutClient } from "./postgres-client"; +import type { TableConstraint } from "./schema"; +import type { SchemaSnapshot, SnapshotColumn, SnapshotTable } from "./tooling-impl"; + +const TOOLING_LOCK = "4707438161740730"; +const userSchema = "n.nspname !~ '^pg_' AND n.nspname <> 'information_schema'"; + +async function rows( + client: CheckedOutClient, + text: string, + values: unknown[] = [], +): Promise { + return (await client.query({ text, values })).rows as T[]; +} + +/** A Describe/Sync exchange never binds or executes the prepared statement. */ +class StatementMetadata implements Submittable { + private fields: FieldDef[] = []; + private resolve!: (fields: FieldDef[]) => void; + private reject!: (error: unknown) => void; + readonly result = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + constructor(readonly sql?: string) {} + submit(connection: Connection): void { + if (this.sql === undefined) connection.describe({ type: "S", name: "askr_describe" }, false); + else connection.parse({ name: "askr_describe", text: this.sql, types: [] }, false); + connection.sync(); + } + handleRowDescription(message: { fields: FieldDef[] }): void { + this.fields = message.fields; + } + handleReadyForQuery(): void { + this.resolve(this.fields); + } + handleError(error: unknown): void { + this.reject(error); + } +} + +async function introspect(client: CheckedOutClient): Promise { + const relations = await rows<{ + oid: string; + schema: string; + name: string; + kind: string; + inherited: boolean; + persistence: string; + }>( + client, + ` + SELECT c.oid::text, n.nspname AS schema, c.relname AS name, c.relkind AS kind, + EXISTS (SELECT 1 FROM pg_inherits i WHERE i.inhrelid = c.oid) AS inherited, c.relpersistence AS persistence + FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace + WHERE ${userSchema} AND c.relkind IN ('r', 'v', 'm', 'p', 'f') + AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid = 'pg_class'::regclass AND d.objid = c.oid AND d.deptype = 'e') + `, + ); + const tables: SnapshotTable[] = []; + const views: SchemaSnapshot["views"][number][] = []; + for (const relation of relations) { + if (relation.kind === "v") { + const [view] = await rows<{ query: string }>( + client, + "SELECT pg_get_viewdef($1::oid, false) AS query", + [relation.oid], + ); + views.push({ + kind: "view", + schema: relation.schema, + name: relation.name, + query: view!.query, + }); + continue; + } + if (relation.kind !== "r") + throw new Error( + `PostgreSQL tooling cannot diff relation ${relation.schema}.${relation.name} of kind ${relation.kind}; use a supported ordinary table/view.`, + ); + if (relation.inherited || relation.persistence !== "p") + throw new Error( + `Table ${relation.schema}.${relation.name} inheritance or persistence cannot be represented by the declaration.`, + ); + const columns = await rows<{ + name: string; + dataType: string; + nullable: boolean; + primaryKey: boolean; + default: string | null; + generated: string | null; + identity: string; + generatedMode: string; + customCollation: boolean; + }>( + client, + ` + SELECT a.attname AS name, format_type(a.atttypid, a.atttypmod) AS "dataType", + NOT a.attnotnull AS nullable, + EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conrelid = a.attrelid AND k.contype = 'p' AND a.attnum = ANY(k.conkey)) AS "primaryKey", + CASE WHEN a.attgenerated = '' THEN pg_get_expr(d.adbin, d.adrelid, false) END AS "default", + CASE WHEN a.attgenerated <> '' THEN pg_get_expr(d.adbin, d.adrelid, false) END AS generated, + a.attidentity AS identity, a.attgenerated AS "generatedMode", a.attcollation <> t.typcollation AS "customCollation" + FROM pg_attribute a JOIN pg_type t ON t.oid = a.atttypid LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum + WHERE a.attrelid = $1::oid AND a.attnum > 0 AND NOT a.attisdropped + `, + [relation.oid], + ); + const constraints = await rows<{ + kind: string; + name: string; + columns: string[]; + expression: string | null; + referenceSchema: string | null; + referenceTable: string | null; + referenceColumns: string[]; + deleteAction: string; + updateAction: string; + match: string; + deferred: boolean; + validated: boolean; + enforced: boolean; + nullsNotDistinct: boolean; + }>( + client, + ` + SELECT k.contype AS kind, k.conname AS name, + ARRAY(SELECT a.attname::text FROM unnest(k.conkey) WITH ORDINALITY u(num, ord) JOIN pg_attribute a ON a.attrelid = k.conrelid AND a.attnum = u.num ORDER BY u.ord) AS columns, + pg_get_expr(k.conbin, k.conrelid, false) AS expression, + rn.nspname AS "referenceSchema", r.relname AS "referenceTable", + ARRAY(SELECT a.attname::text FROM unnest(k.confkey) WITH ORDINALITY u(num, ord) JOIN pg_attribute a ON a.attrelid = k.confrelid AND a.attnum = u.num ORDER BY u.ord) AS "referenceColumns", + k.confdeltype AS "deleteAction", k.confupdtype AS "updateAction", k.confmatchtype AS match, + k.condeferrable AS deferred, k.convalidated AS validated, + COALESCE((to_jsonb(k)->>'conenforced')::boolean, true) AS enforced, + COALESCE(ci.indnullsnotdistinct, false) AS "nullsNotDistinct" + FROM pg_constraint k LEFT JOIN pg_class r ON r.oid = k.confrelid LEFT JOIN pg_namespace rn ON rn.oid = r.relnamespace + LEFT JOIN pg_index ci ON ci.indexrelid = k.conindid + WHERE k.conrelid = $1::oid + `, + [relation.oid], + ); + const snapshotColumns: SnapshotColumn[] = columns.map((column) => { + if (column.customCollation || (column.generatedMode !== "" && column.generatedMode !== "s")) + throw new Error( + `Column ${relation.name}.${column.name} collation or generation mode cannot be represented by the declaration.`, + ); + if (column.identity) + throw new Error( + `Identity column ${relation.name}.${column.name} requires explicit manual tooling support.`, + ); + const foreign = constraints.filter( + (constraint) => constraint.kind === "f" && constraint.columns.includes(column.name), + ); + if (foreign.length > 1) + throw new Error( + `Multiple foreign keys on ${relation.name}.${column.name} cannot be represented by the declaration.`, + ); + const reference = foreign[0]; + return { + property: column.name, + name: column.name, + dataType: column.dataType, + nullable: column.nullable, + primaryKey: column.primaryKey, + unique: false, + ...(column.default === null ? {} : { default: column.default }), + ...(column.generated === null ? {} : { generated: column.generated }), + ...(reference + ? { + reference: { + schema: reference.referenceSchema!, + table: reference.referenceTable!, + column: reference.referenceColumns[0]!, + }, + } + : {}), + }; + }); + const snapshotConstraints: TableConstraint[] = []; + for (const constraint of constraints) { + if (!constraint.enforced || constraint.nullsNotDistinct) + throw new Error( + `Unenforced or NULLS NOT DISTINCT constraint ${constraint.name} cannot be represented by the declaration.`, + ); + if (constraint.deferred || !constraint.validated) + throw new Error( + `Deferred or unvalidated constraint ${constraint.name} cannot be represented by the declaration.`, + ); + if (constraint.kind === "p" || constraint.kind === "n") continue; + if (constraint.kind === "u") + snapshotConstraints.push({ + kind: "unique", + name: constraint.name, + columns: constraint.columns, + }); + else if (constraint.kind === "c") + snapshotConstraints.push({ + kind: "check", + name: constraint.name, + expression: constraint.expression!, + }); + else if (constraint.kind === "f") { + if ( + constraint.columns.length !== 1 || + constraint.referenceColumns.length !== 1 || + constraint.deleteAction !== "a" || + constraint.updateAction !== "a" || + constraint.match !== "s" + ) + throw new Error( + `Foreign key ${constraint.name} cannot be represented by the declaration.`, + ); + } else + throw new Error( + `Constraint ${constraint.name} of kind ${constraint.kind} cannot be represented by the declaration.`, + ); + } + const indexes = await rows<{ + name: string; + expressions: string[]; + unique: boolean; + method: string; + where: string | null; + valid: boolean; + keyCount: number; + totalCount: number; + nullsNotDistinct: boolean; + }>( + client, + ` + SELECT c.relname AS name, i.indisunique AS unique, m.amname AS method, + ARRAY(SELECT pg_get_indexdef(i.indexrelid, s, false) FROM generate_series(1, i.indnkeyatts) s) AS expressions, + pg_get_expr(i.indpred, i.indrelid, false) AS "where", i.indisvalid AS valid, + i.indnkeyatts AS "keyCount", i.indnatts AS "totalCount", i.indnullsnotdistinct AS "nullsNotDistinct" + FROM pg_index i JOIN pg_class c ON c.oid = i.indexrelid JOIN pg_am m ON m.oid = c.relam + WHERE i.indrelid = $1::oid AND NOT EXISTS (SELECT 1 FROM pg_constraint k WHERE k.conindid = i.indexrelid) + `, + [relation.oid], + ); + for (const index of indexes) { + if (!index.valid || index.keyCount !== index.totalCount || index.nullsNotDistinct) + throw new Error( + `Invalid or covering index ${index.name} cannot be represented by the declaration.`, + ); + snapshotConstraints.push({ + kind: "index", + name: index.name, + expressions: index.expressions, + unique: index.unique, + method: index.method, + ...(index.where === null ? {} : { where: index.where }), + }); + } + const primary = constraints.find((constraint) => constraint.kind === "p"); + tables.push({ + schema: relation.schema, + name: relation.name, + columns: snapshotColumns.sort((a, b) => a.name.localeCompare(b.name)), + constraints: snapshotConstraints.sort((a, b) => + JSON.stringify(a).localeCompare(JSON.stringify(b)), + ), + ...(primary && primary.columns.length > 1 ? { primaryKeyColumns: primary.columns } : {}), + }); + } + const enums = await rows( + client, + ` + SELECT n.nspname AS schema, t.typname AS name, array_agg(e.enumlabel::text ORDER BY e.enumsortorder) AS values + FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace JOIN pg_enum e ON e.enumtypid = t.oid + WHERE ${userSchema} AND NOT EXISTS (SELECT 1 FROM pg_depend d WHERE d.classid = 'pg_type'::regclass AND d.objid = t.oid AND d.deptype = 'e') + GROUP BY n.nspname, t.typname + `, + ); + const order = (a: { schema: string; name: string }, b: { schema: string; name: string }) => + `${a.schema}.${a.name}`.localeCompare(`${b.schema}.${b.name}`); + return { + version: 1, + enums: enums.sort(order), + tables: tables.sort(order), + views: views.sort(order), + }; +} + +/** Proves actual database separation using database-local, session-owned locks. */ +async function assertSeparate(shadow: CheckedOutClient, targetPool: Pool): Promise { + const target = new CheckedOutClient(await targetPool.connect()); + const bytes = randomBytes(8); + const key = [bytes.readInt32BE(0), bytes.readInt32BE(4)]; + let targetLocked = false; + let shadowLocked = false; + try { + // Keep the target lock's backend pinned even behind transaction pooling. + await target.query("BEGIN READ ONLY"); + targetLocked = + ( + await rows<{ locked: boolean }>( + target, + "SELECT pg_try_advisory_lock($1::integer, $2::integer) AS locked", + key, + ) + )[0]?.locked === true; + if (!targetLocked) + throw new Error("Cannot establish target database separation; refusing scratch reset."); + shadowLocked = + ( + await rows<{ locked: boolean }>( + shadow, + "SELECT pg_try_advisory_lock($1::integer, $2::integer) AS locked", + key, + ) + )[0]?.locked === true; + const held = + ( + await rows<{ unlocked: boolean }>( + target, + "SELECT pg_advisory_unlock($1::integer, $2::integer) AS unlocked", + key, + ) + )[0]?.unlocked === true; + targetLocked = !held; + if (!held || !shadowLocked) + throw new Error( + "PostgreSQL scratch must be a distinct actual database from target; refusing reset.", + ); + } finally { + if (shadowLocked) await shadow.cleanup(`SELECT pg_advisory_unlock(${key[0]}, ${key[1]})`); + if (targetLocked) await target.cleanup(`SELECT pg_advisory_unlock(${key[0]}, ${key[1]})`); + await target.cleanup("ROLLBACK"); + target.release(); + } + shadow.assertHealthy(); +} + +export async function createPostgresTooling( + shadowUrl: string, + shadowPool: () => Promise, + targetPool: () => Promise, +): Promise { + const pool = await shadowPool(); + let client: CheckedOutClient | undefined; + let closed = false; + let queue = Promise.resolve(); + const serialized = (operation: () => Promise): Promise => { + const pending = queue.then(async () => { + try { + return await operation(); + } catch (error) { + if (client) { + try { + client.assertHealthy(); + } catch (failure) { + client.release(failure); + client = undefined; + } + } + throw error; + } + }); + queue = pending.then( + () => undefined, + () => undefined, + ); + return pending; + }; + const connection = async () => { + if (closed) throw new Error("PostgreSQL scratch adapter is closed."); + if (!client) { + const acquired = new CheckedOutClient(await pool.connect()); + try { + await acquired.query(`SELECT pg_advisory_lock(${TOOLING_LOCK})`); + } catch (error) { + acquired.release(error); + throw error; + } + client = acquired; + } + client.assertHealthy(); + return client; + }; + const adapter: DatabaseToolingAdapter = { + identity: shadowUrl, + async reset() { + const current = await connection(); + const target = await targetPool(); + try { + await assertSeparate(current, target); + } finally { + await target.end(); + } + await current.query("BEGIN"); + try { + const schemas = await rows<{ name: string }>( + current, + `SELECT n.nspname AS name FROM pg_namespace n WHERE ${userSchema}`, + ); + for (const schema of schemas) + await current.query(`DROP SCHEMA ${quoteIdentifier(schema.name)} CASCADE`); + await current.query('CREATE SCHEMA "public"'); + await current.query("COMMIT"); + } catch (error) { + await current.cleanup("ROLLBACK"); + throw error; + } + }, + async execute(sql) { + await (await connection()).query(sql); + }, + async introspect() { + return introspect(await connection()); + }, + async describe(sql, parameterNames) { + const current = await connection(); + let prepared = false; + let failed = false; + let primaryError: unknown; + let result: Awaited> | undefined; + try { + const prepare = new StatementMetadata(sql); + current.startStream(prepare); + await prepare.result; + prepared = true; + const description = new StatementMetadata(); + current.startStream(description); + const fields = await description.result; + const columns = []; + for (const field of fields) { + const [metadata] = await rows<{ dataType: string }>( + current, + 'SELECT format_type($1::oid, $2::integer) AS "dataType"', + [field.dataTypeID, field.dataTypeModifier], + ); + // RowDescription does not encode outer-join/expression nullability. + columns.push({ name: field.name, dataType: metadata!.dataType, nullable: true }); + } + result = { parameters: [...parameterNames], columns }; + } catch (error) { + failed = true; + primaryError = error; + } + if (prepared) { + try { + await current.query("DEALLOCATE askr_describe"); + } catch (error) { + current.release(error); + client = undefined; + if (!failed) { + failed = true; + primaryError = error; + } + } + } + if (failed) throw primaryError; + return result!; + }, + async close() { + if (closed) return; + closed = true; + if (client) { + await client.cleanup("ROLLBACK"); + await client.cleanup(`SELECT pg_advisory_unlock(${TOOLING_LOCK})`); + client.release(); + client = undefined; + } + await pool.end(); + }, + }; + return { + identity: shadowUrl, + reset: () => serialized(() => adapter.reset()), + execute: (sql) => serialized(() => adapter.execute(sql)), + introspect: () => serialized(() => adapter.introspect()), + describe: (sql, parameters) => serialized(() => adapter.describe(sql, parameters)), + close: () => serialized(() => adapter.close!()), + }; +} diff --git a/src/postgres.test.ts b/src/postgres.test.ts index e7c4b58..14efc77 100644 --- a/src/postgres.test.ts +++ b/src/postgres.test.ts @@ -13,7 +13,16 @@ const state = vi.hoisted(() => ({ vi.mock("pg", () => { class Pool { - async query(config: string | { text?: string }) { + async query( + config: + | string + | { text?: string; sql?: string; submit?: unknown; handleReadyForQuery?: () => void }, + ) { + if (typeof config !== "string" && typeof config.submit === "function") { + state.statements.push(config.sql === undefined ? "DESCRIBE" : `PARSE ${config.sql}`); + config.handleReadyForQuery?.(); + return { rows: [], rowCount: 0 }; + } const statement = typeof config === "string" ? config : (config.text ?? ""); state.statements.push(statement); if (state.failRollback && statement === "ROLLBACK") throw new Error("rollback failed"); @@ -75,10 +84,11 @@ describe("PostgreSQL adapter", () => { }).shadow(); await shadow.describe("SELECT $1::text AS value", ["value"]); await shadow.describe("SELECT $1::text AS value", ["value"]); - expect(state.statements.filter((sql) => sql.startsWith("PREPARE"))).toHaveLength(2); + expect(state.statements.filter((sql) => sql.startsWith("PARSE"))).toHaveLength(2); expect(state.statements.filter((sql) => sql === "DEALLOCATE askr_describe")).toHaveLength(2); - expect(state.releases).toBe(2); + expect(state.releases).toBe(0); await shadow.close?.(); + expect(state.releases).toBe(1); }); it("should make runtime close idempotent", async () => { @@ -144,8 +154,9 @@ describe("PostgreSQL adapter", () => { await expect(shadow.describe("SELECT $1::text", ["value"])).resolves.toMatchObject({ parameters: ["value"], }); - expect(state.releases).toBe(2); + expect(state.releases).toBe(1); await shadow.close?.(); + expect(state.releases).toBe(2); }); it("should preserve the callback error when rollback fails", async () => { diff --git a/src/postgres.ts b/src/postgres.ts index 57c9ac1..a99ff97 100644 --- a/src/postgres.ts +++ b/src/postgres.ts @@ -1,4 +1,4 @@ -import type { Pool as PoolType, PoolClient, PoolConfig, QueryResult } from "pg"; +import type { Pool as PoolType, PoolConfig, QueryResult } from "pg"; import type { DatabaseAdapter, DatabaseDriver, @@ -6,9 +6,9 @@ import type { QueryOptions, TransactionOptions, } from "./adapter"; -import type { DatabaseToolingAdapter } from "./definition"; import type { SqlQuery } from "./sql"; -import { normalizeDatabaseError, type DatabaseError } from "./errors"; +import { CheckedOutClient } from "./postgres-client"; +import { createPostgresTooling } from "./postgres-tooling"; export { jsonb, postgresEnum, postgresType, timestampTz, bytea } from "./schema"; const MIGRATION_LOCK_KEY = "4707438161740729"; @@ -26,53 +26,6 @@ function lazy(value: string | (() => string) | undefined, environment: string): return result; } -class CheckedOutClient { - private failure: DatabaseError | undefined; - - private readonly onError = (error: Error): void => { - this.failure ??= normalizeDatabaseError(error); - }; - - constructor(private readonly client: PoolClient) { - client.on("error", this.onError); - } - - assertHealthy(): void { - if (this.failure) throw this.failure; - } - - async query(config: unknown): Promise { - this.assertHealthy(); - try { - const result = await this.client.query(config as never); - this.assertHealthy(); - return result; - } catch (error) { - throw normalizeDatabaseError(error); - } - } - - startStream(query: T): T { - this.assertHealthy(); - return this.client.query(query as never) as T; - } - - 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); - } -} - class PgAdapter implements DatabaseAdapter { readonly identity: string; private closed = false; @@ -233,45 +186,6 @@ async function createPool( return new module.Pool({ ...options, connectionString: url }); } -async function pgTooling( - url: string, - options?: Omit, -): Promise { - const pool = await createPool(url, options); - return { - identity: url, - async reset() { - throw new Error("PostgreSQL shadow reset must be performed by migration tooling."); - }, - async execute(sql) { - await pool.query(sql); - }, - async introspect() { - return (await pool.query("SELECT current_database() AS database, current_schema() AS schema")) - .rows; - }, - async describe(sql, parameterNames) { - const client = new CheckedOutClient(await pool.connect()); - let cleanupError: unknown; - try { - await client.query(`PREPARE askr_describe AS ${sql}`); - try { - await client.query("DEALLOCATE askr_describe"); - } catch (error) { - cleanupError = error; - throw error; - } - return { parameters: [...parameterNames], columns: [] }; - } finally { - client.release(cleanupError); - } - }, - async close() { - await pool.end(); - }, - }; -} - /** * Creates a PostgreSQL {@link DatabaseDriver} backed by `pg`, connecting to `options.url` * (defaulting to `DATABASE_URL`). Requires the optional peers `pg` and `pg-query-stream`; the @@ -297,7 +211,11 @@ export function postgres(options: PostgresOptions = {}): DatabaseDriver { const shadow = lazy(options.shadowUrl, "DATABASE_SHADOW_URL"); if (target === shadow) throw new Error("PostgreSQL target and shadow database identities must differ."); - return pgTooling(shadow, options.pool); + return createPostgresTooling( + shadow, + () => createPool(shadow, options.pool), + () => createPool(target, options.pool), + ); }, }; } diff --git a/src/schema.test.ts b/src/schema.test.ts index ff1e74d..fd4431f 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -53,7 +53,7 @@ describe("schema definitions", () => { it("should define Postgres enum columns", () => { const status = postgresEnum("account_status", ["active", "disabled"]); - expect(status.column().ast.dataType).toBe("public.account_status"); + expect(status.column().ast.dataType).toBe('"public"."account_status"'); }); it("should infer readonly rows, inserts, patches, and primary keys", () => { diff --git a/src/schema.ts b/src/schema.ts index aa0b853..f2d968b 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,4 +1,4 @@ -import { toSnakeCase } from "./naming"; +import { quoteIdentifier, toSnakeCase } from "./naming"; /** Bidirectional converter between a column's stored (database) and application-facing value. */ export interface Codec { @@ -323,7 +323,7 @@ export function postgresEnum( name, schema, values, - column: () => column(`${schema}.${name}`), + column: () => column(`${quoteIdentifier(schema)}.${quoteIdentifier(name)}`), }; } diff --git a/src/tooling-impl.ts b/src/tooling-impl.ts index b34399c..a16f122 100644 --- a/src/tooling-impl.ts +++ b/src/tooling-impl.ts @@ -52,6 +52,7 @@ export interface SnapshotTable { readonly drop?: boolean; readonly columns: readonly SnapshotColumn[]; readonly constraints: readonly TableConstraint[]; + readonly primaryKeyColumns?: readonly string[]; } /** Point-in-time serialized shape of a database schema, produced by {@link snapshotDefinition}. */ @@ -152,6 +153,13 @@ export function snapshotDefinition( constraints: [...(table.$options.constraints ?? [])].sort((left, right) => JSON.stringify(left).localeCompare(JSON.stringify(right)), ), + ...(Object.values(table.$columns).filter((column) => column.ast.primaryKey).length > 1 + ? { + primaryKeyColumns: Object.values(table.$columns) + .filter((column) => column.ast.primaryKey) + .map((column) => column.ast.name), + } + : {}), })) .sort((left, right) => `${left.schema}.${left.name}`.localeCompare(`${right.schema}.${right.name}`), @@ -233,10 +241,29 @@ function indexName(table: SnapshotTable, index: IndexDefinition, position: numbe return index.name ?? `${table.name}_${position + 1}_idx`; } -function createTableSql(table: SnapshotTable, dialect: "postgres" | "sqlite" = "postgres"): string { +function createTableSql( + table: SnapshotTable, + dialect: "postgres" | "sqlite" = "postgres", + deferReferences = false, +): string { const inline = table.constraints.filter((entry) => entry.kind !== "index"); + const primary = + table.primaryKeyColumns ?? + table.columns.filter((column) => column.primaryKey).map((column) => column.name); const parts = [ - ...table.columns.filter((column) => !column.drop).map((column) => columnSql(column, dialect)), + ...table.columns + .filter((column) => !column.drop) + .map((column) => { + const { reference: _reference, ...withoutReference } = column; + return columnSql( + { + ...(deferReferences ? withoutReference : column), + ...(primary.length > 1 ? { primaryKey: false } : {}), + }, + dialect, + ); + }), + ...(primary.length > 1 ? [`PRIMARY KEY (${primary.map(quoteIdentifier).join(", ")})`] : []), ...inline.map((constraint) => constraintSql(table, constraint)), ]; const statements = [ @@ -264,6 +291,15 @@ function activeSnapshot(snapshot: SchemaSnapshot): SchemaSnapshot { }; } +function foreignKeysSql(table: SnapshotTable): string[] { + return table.columns + .filter((column) => !column.drop && column.reference) + .map((column) => { + const reference = column.reference!; + return `ALTER TABLE ${qualified(table.schema, table.name)} ADD FOREIGN KEY (${quoteIdentifier(column.name)}) REFERENCES ${qualified(reference.schema, reference.table)} (${quoteIdentifier(reference.column)});`; + }); +} + function renderInitialSchema( snapshot: SchemaSnapshot, dialect: "postgres" | "sqlite" = "postgres", @@ -284,7 +320,12 @@ function renderInitialSchema( ); } for (const table of snapshot.tables.filter((entry) => !entry.drop)) { - statements.push(createTableSql(table, dialect)); + statements.push(createTableSql(table, dialect, dialect === "postgres")); + } + if (dialect === "postgres") { + for (const table of snapshot.tables.filter((entry) => !entry.drop)) { + statements.push(...foreignKeysSql(table)); + } } for (const view of snapshot.views) { statements.push(`CREATE VIEW ${qualified(view.schema, view.name, dialect)} AS\n${view.query};`); @@ -312,6 +353,17 @@ export function diffSnapshots( return renderInitialSchema(desired, dialect); } const statements: string[] = []; + if (dialect === "postgres") { + const schemas = new Set( + [...desired.enums, ...desired.tables, ...desired.views].map((entry) => entry.schema), + ); + const existingSchemas = new Set( + [...current.enums, ...current.tables, ...current.views].map((entry) => entry.schema), + ); + for (const schema of [...schemas].sort()) + if (schema !== "public" && !existingSchemas.has(schema)) + statements.push(`CREATE SCHEMA IF NOT EXISTS ${quoteIdentifier(schema)};`); + } const currentEnums = byName(current.enums); const desiredEnums = byName(desired.enums); for (const value of desired.enums) { @@ -342,6 +394,14 @@ export function diffSnapshots( const currentTables = byName(current.tables); const desiredTables = byName(desired.tables); + const newTables = desired.tables.filter( + (table) => !currentTables.has(`${table.schema}.${table.name}`) && !table.renamedFrom, + ); + const foreignKeys: string[] = []; + for (const table of newTables) { + statements.push(createTableSql(table, dialect, dialect === "postgres")); + if (dialect === "postgres") foreignKeys.push(...foreignKeysSql(table)); + } for (const table of desired.tables) { const declaredTable = desiredInput.tables.find( (entry) => entry.schema === table.schema && entry.name === table.name, @@ -355,7 +415,6 @@ export function diffSnapshots( ); } if (!existing) { - statements.push(createTableSql(table, dialect)); continue; } const existingColumns = new Map(existing.columns.map((column) => [column.name, column])); @@ -379,9 +438,12 @@ export function diffSnapshots( `Adding required column ${table.name}.${column.name} needs a default or manual migration.`, ); } + const { reference: _reference, ...withoutReference } = column; statements.push( - `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ADD COLUMN ${columnSql(column, dialect)};`, + `ALTER TABLE ${qualified(table.schema, table.name, dialect)} ADD COLUMN ${columnSql(dialect === "postgres" ? withoutReference : column, dialect)};`, ); + if (dialect === "postgres" && column.reference) + foreignKeys.push(...foreignKeysSql({ ...table, columns: [column] })); continue; } if (old.dataType !== column.dataType) { @@ -424,12 +486,14 @@ export function diffSnapshots( ); } } - if (JSON.stringify(existing.constraints) !== JSON.stringify(table.constraints)) { + const renamed = table.renamedFrom || table.columns.some((column) => column.renamedFrom); + if (!renamed && JSON.stringify(existing.constraints) !== JSON.stringify(table.constraints)) { throw new Error( `Changing constraints or indexes on ${table.name} requires a manual migration.`, ); } } + statements.push(...foreignKeys); for (const table of current.tables) { if (desiredTables.has(`${table.schema}.${table.name}`)) continue; const renamed = desiredInput.tables.some( @@ -617,6 +681,14 @@ async function describeRegisteredQueries( const parameters = [...query.parameters]; const compiled = query.compile(Object.fromEntries(parameters.map((name) => [name, null]))); const described = await scratch.describe(compiled.text, parameters); + const columnNames = new Set(); + for (const column of described.columns) { + if (columnNames.has(column.name)) + throw new Error( + `Registered query ${query.key} has duplicate output column ${column.name}; give each result column an explicit unique alias.`, + ); + columnNames.add(column.name); + } descriptions.push({ key: query.key, source: compiled.text, @@ -627,34 +699,44 @@ async function describeRegisteredQueries( return descriptions.sort((left, right) => left.key.localeCompare(right.key)); } -function tsType(dataType: string, nullable: boolean): string { +function tsType(dataType: string, nullable: boolean, dialect: "postgres" | "sqlite"): string { const normalized = dataType.toLowerCase(); const base = normalized === "boolean" ? "boolean" : /^(?:smallint|integer|real|double precision)$/.test(normalized) ? "number" - : normalized === "bigint" - ? "bigint" + : normalized === "bigint" || normalized.startsWith("numeric") + ? "string" : normalized === "bytea" ? "Uint8Array" - : /^(?:json|jsonb)$/.test(normalized) - ? "unknown" - : "string"; + : dialect === "postgres" && + /^(?:date|timestamp(?:\(\d+\))? (?:with|without) time zone)$/.test(normalized) + ? "Date" + : /^(?:json|jsonb)$/.test(normalized) + ? "unknown" + : /^(?:text|uuid|character(?: varying)?(?:\(\d+\))?|time(?:\(\d+\))? (?:with|without) time zone)$/.test( + normalized, + ) + ? "string" + : "unknown"; return nullable ? `${base} | null` : base; } -function renderQueries(descriptions: readonly KeyedSqlDescription[]): string { +function renderQueries( + descriptions: readonly KeyedSqlDescription[], + dialect: "postgres" | "sqlite", +): 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) + for (const parameter of new Set(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)};`, + ` readonly ${JSON.stringify(column.name)}: ${tsType(column.dataType, column.nullable, dialect)};`, ); } lines.push(" };", " };"); @@ -664,7 +746,7 @@ function renderQueries(descriptions: readonly KeyedSqlDescription[]): string { } function generatedArtifacts( - _database: LoadedDatabase, + database: LoadedDatabase, snapshot: SchemaSnapshot, descriptions: readonly KeyedSqlDescription[], manifest: MigrationManifest, @@ -674,7 +756,7 @@ function generatedArtifacts( "// Generated by @askrjs/orm. Do not edit.", 'import type { GeneratedDatabaseArtifact } from "@askrjs/orm";', "", - renderQueries(descriptions).trimEnd(), + renderQueries(descriptions, database.definition.dialect).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;`, "", @@ -701,8 +783,8 @@ async function replay(database: LoadedDatabase): Promise<{ await scratch.close?.(); throw new Error("Scratch adapter identity does not match configured scratchIdentity."); } - const manifest = await readMigrations(database.databaseDir); try { + const manifest = await readMigrations(database.databaseDir); await scratch.reset(); for (const migration of manifest.migrations) await scratch.execute(migration.sql); const current = (await scratch.introspect()) as SchemaSnapshot; @@ -723,12 +805,116 @@ async function writeArtifacts( await fs.writeFile(path.join(database.databaseDir, GENERATED_FILE), artifacts, "utf8"); } +/** Let PostgreSQL normalize types/expressions; roll back all probe DDL before diffing. */ +async function desiredPhysicalSnapshot( + database: LoadedDatabase, + scratch: DatabaseToolingAdapter, + current: SchemaSnapshot, + declared: SchemaSnapshot, +): Promise { + if (database.definition.dialect !== "postgres") return declared; + await scratch.execute("BEGIN"); + let failed = false; + let primaryError: unknown; + let result: SchemaSnapshot | undefined; + try { + for (const view of current.views) + await scratch.execute(`DROP VIEW IF EXISTS ${qualified(view.schema, view.name)} CASCADE`); + for (const table of current.tables) + await scratch.execute(`DROP TABLE IF EXISTS ${qualified(table.schema, table.name)} CASCADE`); + for (const value of current.enums) + await scratch.execute(`DROP TYPE IF EXISTS ${qualified(value.schema, value.name)} CASCADE`); + await scratch.execute(renderInitialSchema(activeSnapshot(declared), "postgres")); + const physical = (await scratch.introspect()) as SchemaSnapshot; + result = { + ...physical, + tables: physical.tables.map((table) => { + const definition = declared.tables.find( + (entry) => entry.name === table.name && entry.schema === table.schema, + )!; + return { + ...table, + ...(definition.renamedFrom ? { renamedFrom: definition.renamedFrom } : {}), + columns: table.columns.map((column) => { + const original = definition.columns.find((entry) => entry.name === column.name)!; + return { + ...column, + ...(original.renamedFrom ? { renamedFrom: original.renamedFrom } : {}), + ...(original.convertUsing ? { convertUsing: original.convertUsing } : {}), + }; + }), + }; + }), + }; + } catch (error) { + failed = true; + primaryError = error; + } + try { + await scratch.execute("ROLLBACK"); + } catch (error) { + if (!failed) { + failed = true; + primaryError = error; + } + } + if (failed) throw primaryError; + return result!; +} + +/** Rename/conversion instructions are intent, never physical schema facts. */ +function comparisonSnapshot(snapshot: SchemaSnapshot, declared: SchemaSnapshot): SchemaSnapshot { + return { + ...snapshot, + tables: snapshot.tables.map((table) => { + const definition = declared.tables.find( + (entry) => + entry.schema === table.schema && + (entry.name === table.name || entry.renamedFrom === table.name), + ); + const explicitNames = new Set( + definition?.constraints + .map((constraint) => constraint.name) + .filter((name) => name !== undefined), + ); + return { + ...table, + constraints: table.constraints + .map((constraint) => { + const { name, ...unnamed } = constraint; + return name && explicitNames.has(name) ? constraint : unnamed; + }) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), + }; + }), + }; +} + +function physicalIdentity(snapshot: SchemaSnapshot, declared: SchemaSnapshot): string { + const comparable = comparisonSnapshot(snapshot, declared); + return stableJson({ + ...activeSnapshot(comparable), + tables: activeSnapshot(comparable).tables.map(({ renamedFrom: _rename, ...table }) => ({ + ...table, + columns: table.columns.map( + ({ renamedFrom: _columnRename, convertUsing: _convert, ...column }) => column, + ), + })), + }); +} + 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)) { + const physical = await desiredPhysicalSnapshot( + database, + replayed.scratch, + replayed.current, + desired, + ); + if (physicalIdentity(actual, desired) !== physicalIdentity(physical, desired)) { throw new Error("Migration history does not produce the TypeScript schema definition."); } const descriptions = await describeRegisteredQueries(database.definition, replayed.scratch); @@ -758,7 +944,17 @@ async function generateOne(database: LoadedDatabase): Promise { const replayed = await replay(database); let created: string | null = null; try { - const delta = diffSnapshots(replayed.current, desired, database.definition.dialect); + const physical = await desiredPhysicalSnapshot( + database, + replayed.scratch, + replayed.current, + desired, + ); + const delta = diffSnapshots( + comparisonSnapshot(replayed.current, desired), + comparisonSnapshot(physical, desired), + database.definition.dialect, + ); if (delta) { const id = ulid(); const parent = replayed.manifest.migrations.at(-1)?.id ?? null; @@ -773,7 +969,7 @@ async function generateOne(database: LoadedDatabase): Promise { await replayed.scratch.execute(content); } const finalSnapshot = activeSnapshot((await replayed.scratch.introspect()) as SchemaSnapshot); - if (stableJson(finalSnapshot) !== stableJson(activeSnapshot(desired))) { + if (physicalIdentity(finalSnapshot, desired) !== physicalIdentity(physical, desired)) { throw new Error("Generated migration did not produce the desired schema."); } const manifest = await readMigrations(database.databaseDir); @@ -792,7 +988,13 @@ 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)) { + const physical = await desiredPhysicalSnapshot( + database, + replayed.scratch, + replayed.current, + desired, + ); + if (physicalIdentity(replayed.current, desired) !== physicalIdentity(physical, desired)) { throw new Error( "Manual migration history does not produce the TypeScript schema definition.", ); diff --git a/src/tooling-integration.test.ts b/src/tooling-integration.test.ts new file mode 100644 index 0000000..2d90f05 --- /dev/null +++ b/src/tooling-integration.test.ts @@ -0,0 +1,457 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { Pool } from "pg"; +import { describe, expect, it } from "vitest"; +import { postgres } from "./postgres"; +import { runDatabaseCli, type SchemaSnapshot } from "./tooling-impl"; + +const targetUrl = process.env.ASKR_ORM_TEST_DATABASE_URL; +const shadowUrl = process.env.ASKR_ORM_TEST_SHADOW_URL; +const postgresTests = targetUrl && shadowUrl ? describe : describe.skip; + +postgresTests("real PostgreSQL tooling", () => { + it("refuses to reset the actual target through a different URL spelling", async () => { + const pool = new Pool({ connectionString: targetUrl }); + const alias = new URL(targetUrl!); + alias.searchParams.set("application_name", "askr-alias-guard"); + const scratch = await postgres({ url: targetUrl!, shadowUrl: alias.href }).shadow(); + try { + await pool.query("CREATE TABLE askr_tooling_target_guard (value text)"); + await pool.query("INSERT INTO askr_tooling_target_guard VALUES ('preserve me')"); + await expect(scratch.reset()).rejects.toThrow(/target|distinct|separate/i); + expect((await pool.query("SELECT value FROM askr_tooling_target_guard")).rows).toEqual([ + { value: "preserve me" }, + ]); + } finally { + await scratch.close?.(); + await pool.query("DROP TABLE IF EXISTS askr_tooling_target_guard"); + await pool.end(); + } + }); + + it("resets only scratch and returns a physical, deterministic schema snapshot", async () => { + const scratch = await postgres({ url: targetUrl!, shadowUrl: shadowUrl! }).shadow(); + try { + await scratch.reset(); + expect(await scratch.introspect()).toEqual({ version: 1, enums: [], tables: [], views: [] }); + await scratch.execute(` + CREATE SCHEMA "catalog #%"; + CREATE TYPE "catalog #%".status AS ENUM ('new', 'it''s ready'); + CREATE TABLE "catalog #%".users ( + id uuid DEFAULT gen_random_uuid() PRIMARY KEY, + email varchar(100) NOT NULL UNIQUE, + status "catalog #%".status, + slug text GENERATED ALWAYS AS (lower(email)) STORED, + CONSTRAINT positive_length CHECK (length(email) > 0) + ); + CREATE INDEX lower_email ON "catalog #%".users (lower(email)) WHERE status IS NOT NULL; + CREATE VIEW "catalog #%".emails AS SELECT email FROM "catalog #%".users; + `); + const snapshot = (await scratch.introspect()) as SchemaSnapshot; + expect(snapshot.version).toBe(1); + expect(snapshot.enums).toEqual([ + { schema: "catalog #%", name: "status", values: ["new", "it's ready"] }, + ]); + expect(snapshot.tables).toHaveLength(1); + expect(snapshot.tables[0]).toMatchObject({ schema: "catalog #%", name: "users" }); + expect(snapshot.tables[0]!.columns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "email", + dataType: "character varying(100)", + nullable: false, + }), + expect.objectContaining({ name: "id", primaryKey: true, default: "gen_random_uuid()" }), + expect.objectContaining({ name: "slug", generated: expect.any(String) }), + ]), + ); + expect(snapshot.tables[0]!.constraints).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: "check", name: "positive_length" }), + expect.objectContaining({ + kind: "index", + name: "lower_email", + where: expect.any(String), + }), + expect.objectContaining({ kind: "unique", columns: ["email"] }), + ]), + ); + expect(snapshot.views).toEqual([ + expect.objectContaining({ name: "emails", query: expect.stringContaining("email") }), + ]); + expect(await scratch.introspect()).toEqual(snapshot); + await scratch.reset(); + expect(await scratch.introspect()).toEqual({ version: 1, enums: [], tables: [], views: [] }); + } finally { + await scratch.close?.(); + } + }); + + it("describes output and parameters without executing reads or writes, and recovers errors", async () => { + const scratch = await postgres({ + url: targetUrl!, + shadowUrl: shadowUrl!, + pool: { max: 1 }, + }).shadow(); + try { + await scratch.reset(); + await scratch.execute( + "CREATE TABLE users (id integer PRIMARY KEY, label text); CREATE SEQUENCE description_side_effect", + ); + const result = await scratch.describe("SELECT id, label, $1::text AS input FROM users", [ + "input", + ]); + expect(result).toEqual({ + parameters: ["input"], + columns: [ + { name: "id", dataType: "integer", nullable: true }, + { name: "label", dataType: "text", nullable: true }, + { name: "input", dataType: "text", nullable: true }, + ], + }); + expect( + await scratch.describe("SELECT nextval('description_side_effect') AS value", []), + ).toMatchObject({ columns: [{ name: "value", dataType: "bigint" }] }); + expect( + await scratch.describe("INSERT INTO users VALUES ($1::integer, 'ignored') RETURNING id", [ + "id", + ]), + ).toMatchObject({ columns: [{ name: "id", dataType: "integer" }] }); + await expect(scratch.describe("SELECT absent FROM users", [])).rejects.toThrow(); + await expect( + scratch.describe("SELECT 1; INSERT INTO users VALUES (1, 'batch must never execute')", []), + ).rejects.toThrow(/multiple commands/); + expect(await scratch.describe("SELECT id FROM users", [])).toMatchObject({ + columns: [{ name: "id" }], + }); + const observer = new Pool({ connectionString: shadowUrl }); + try { + expect((await observer.query("SELECT count(*)::integer AS count FROM users")).rows).toEqual( + [{ count: 0 }], + ); + expect( + (await observer.query("SELECT is_called FROM description_side_effect")).rows, + ).toEqual([{ is_called: false }]); + } finally { + await observer.end(); + } + } finally { + await scratch.close?.(); + } + }); + + it("generates, validates, no-op regenerates and rejects stale artifacts against real PostgreSQL", async () => { + const root = await fs.mkdtemp( + path.join(path.resolve(import.meta.dirname, ".."), ".orm-pg-fixture- #% "), + ); + const directory = path.join(root, "database"); + const messages: string[] = []; + const io = { + log: (v: unknown = "") => messages.push(String(v)), + error: (v: unknown = "") => messages.push(`ERROR ${v}`), + }; + const run = (command: string) => runDatabaseCli([command], { cwd: root, io }); + try { + await fs.mkdir(directory); + await fs.writeFile( + path.join(directory, "index.ts"), + ` +import { defineDatabase, defineQuery, table, text, uuid, integer } from "../../src/index.ts"; +import { postgres } from "../../src/postgres.ts"; +const users = table("users", { id: uuid().primaryKey().defaultRandom(), emailAddress: text().name("email").notNull().unique() }); +const events = table("events", { id: integer().primaryKey(), userId: uuid().name("user_id").references(() => users.id), payload: text() }); +const byEmail = defineQuery<{ email: string }>("users.by-email")\`SELECT id, email FROM users WHERE email = \${"email"}\`; +export default defineDatabase({ driver: postgres({ url: ${JSON.stringify(targetUrl)}, shadowUrl: ${JSON.stringify(shadowUrl)}, pool: { max: 1 } }), tables: { users, events }, queries: { byEmail } }); +`, + ); + expect(await run("generate"), messages.join("\n")).toBe(0); + const files = await fs.readdir(path.join(directory, "migrations")); + expect(files).toHaveLength(1); + const artifactPath = path.join(directory, "generated.ts"); + const generated = await fs.readFile(artifactPath, "utf8"); + expect(generated).toContain('readonly "id": string | null;'); + expect(generated).toContain('readonly "email": string | null;'); + expect(await run("validate"), messages.join("\n")).toBe(0); + const migrationPath = path.join(directory, "migrations", files[0]!); + const migration = await fs.readFile(migrationPath, "utf8"); + await fs.writeFile(migrationPath, `${migration}\nALTER TABLE users ADD COLUMN drift text;\n`); + expect(await run("validate")).toBe(1); + expect(messages.at(-1)).toMatch(/history does not produce/); + expect(await fs.readFile(artifactPath, "utf8")).toBe(generated); + await fs.writeFile(migrationPath, `${migration}\nSELECT 1 / 0;\n`); + expect(await run("generate")).toBe(1); + expect(messages.at(-1)).toMatch(/division by zero/); + expect(await fs.readFile(artifactPath, "utf8")).toBe(generated); + expect(await fs.readdir(path.join(directory, "migrations"))).toEqual(files); + await fs.writeFile(migrationPath, migration); + expect(await run("validate"), messages.join("\n")).toBe(0); + expect(await run("generate"), messages.join("\n")).toBe(0); + expect(messages.at(-1)).toBe("default: unchanged"); + expect(await fs.readdir(path.join(directory, "migrations"))).toEqual(files); + expect(await fs.readFile(artifactPath, "utf8")).toBe(generated); + await fs.writeFile(artifactPath, `${generated}// stale\n`); + expect(await run("validate")).toBe(1); + expect(messages.at(-1)).toMatch(/stale/); + await fs.writeFile(artifactPath, generated); + expect(await run("validate"), messages.join("\n")).toBe(0); + const entryPath = path.join(directory, "index.ts"); + const originalEntry = await fs.readFile(entryPath, "utf8"); + const expanded = originalEntry + .replace( + "export default defineDatabase", + ` +const zTags = table("z_tags", { id: integer().primaryKey(), label: text() }); +const aPosts = table("a_posts", { id: integer().primaryKey(), tagId: integer().references(() => zTags.id) }); +export default defineDatabase`, + ) + .replace("tables: { users, events }", "tables: { users, events, aPosts, zTags }"); + await fs.writeFile(entryPath, expanded); + expect(await run("generate"), messages.join("\n")).toBe(0); + expect(await run("validate"), messages.join("\n")).toBe(0); + expect(await run("generate"), messages.join("\n")).toBe(0); + expect(messages.at(-1)).toBe("default: unchanged"); + expect(await fs.readdir(path.join(directory, "migrations"))).toHaveLength(2); + const renamed = expanded + .replace('.name("email")', '.name("contact_email").renamedFrom("email")') + .replace( + "SELECT id, email FROM users WHERE email", + "SELECT id, contact_email FROM users WHERE contact_email", + ); + await fs.writeFile(entryPath, renamed); + expect(await run("generate"), messages.join("\n")).toBe(0); + expect(await run("validate"), messages.join("\n")).toBe(0); + expect(await run("generate"), messages.join("\n")).toBe(0); + expect(messages.at(-1)).toBe("default: unchanged"); + expect(await fs.readdir(path.join(directory, "migrations"))).toHaveLength(3); + const renamedArtifact = await fs.readFile(artifactPath, "utf8"); + await fs.writeFile(entryPath, renamed.replace("SELECT id, contact_email", "SELECT id, id")); + expect(await run("generate")).toBe(1); + expect(messages.at(-1)).toMatch(/duplicate output column/i); + expect(await fs.readFile(artifactPath, "utf8")).toBe(renamedArtifact); + await fs.writeFile(entryPath, renamed); + expect(await run("validate"), messages.join("\n")).toBe(0); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, 30_000); + + it("qualifies composite keys, quoted enums, native expressions and query value types", async () => { + const root = await fs.mkdtemp( + path.join(path.resolve(import.meta.dirname, ".."), ".orm-pg-complex-"), + ); + const directory = path.join(root, "database"); + const messages: string[] = []; + const io = { + log: (v: unknown = "") => messages.push(String(v)), + error: (v: unknown = "") => messages.push(`ERROR ${v}`), + }; + try { + await fs.mkdir(directory); + await fs.writeFile( + path.join(directory, "index.ts"), + ` +import { defineDatabase, defineQuery, table, text, integer, numeric, bytes, index, check, view } from "../../src/index.ts"; +import { postgres, postgresEnum, postgresType } from "../../src/postgres.ts"; +const status = postgresEnum('status " key', ['new', "it's ready"], { schema: "catalog #%" }); +const entries = table("entries", { + tenantId: integer().primaryKey(), id: integer().primaryKey(), + state: status.column(), email: postgresType("varchar(80)").notNull(), + lowerEmail: text().generatedAlwaysAs("lower(email)"), amount: numeric(10, 2).default("0.00"), data: bytes(), +}, { schema: "catalog #%", constraints: [check("length(email) > 0", "email_nonempty"), index(["lower(email)"], { name: "lower_email", where: "state IS NOT NULL" })] }); +const emails = view("emails", 'SELECT email FROM "catalog #%".entries', { schema: "catalog #%" }); +const values = defineQuery>("values")\`SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers\`; +export default defineDatabase({ driver: postgres({ url: ${JSON.stringify(targetUrl)}, shadowUrl: ${JSON.stringify(shadowUrl)}, pool: { max: 1 } }), tables: { entries }, enums: [status], views: [emails], queries: { values } }); +`, + ); + for (const command of ["generate", "validate", "generate"]) { + expect(await runDatabaseCli([command], { cwd: root, io }), messages.join("\n")).toBe(0); + } + expect(messages.at(-1)).toBe("default: unchanged"); + expect(await fs.readdir(path.join(directory, "migrations"))).toHaveLength(1); + const artifact = await fs.readFile(path.join(directory, "generated.ts"), "utf8"); + expect(artifact).toContain('readonly "count": string | null;'); + expect(artifact).toContain('readonly "day": Date | null;'); + expect(artifact).toContain('readonly "numbers": unknown | null;'); + const observer = new Pool({ connectionString: shadowUrl }); + try { + const [row] = ( + await observer.query( + "SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers", + ) + ).rows; + expect(row.count).toBe("9007199254740993"); + expect(row.day).toBeInstanceOf(Date); + expect(row.numbers).toEqual([1, 2]); + } finally { + await observer.end(); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }, 30_000); + + it("fails closed if target cannot be reached and preserves existing scratch data", async () => { + const unreachable = new URL(targetUrl!); + unreachable.port = "1"; + const observer = new Pool({ connectionString: shadowUrl }); + const scratch = await postgres({ + url: unreachable.href, + shadowUrl: shadowUrl!, + pool: { connectionTimeoutMillis: 200 }, + }).shadow(); + try { + await observer.query( + "CREATE TABLE reset_guard (value text); INSERT INTO reset_guard VALUES ('preserve')", + ); + await expect(scratch.reset()).rejects.toThrow(); + expect((await observer.query("SELECT value FROM reset_guard")).rows).toEqual([ + { value: "preserve" }, + ]); + } finally { + await scratch.close?.(); + await observer.query("DROP TABLE IF EXISTS reset_guard"); + await observer.end(); + } + }); + + it("serializes concurrent descriptions on one pooled connection and releases it on close", async () => { + const scratch = await postgres({ + url: targetUrl!, + shadowUrl: shadowUrl!, + pool: { max: 1 }, + }).shadow(); + try { + const results = await Promise.all( + Array.from({ length: 12 }, () => + scratch.describe("SELECT $1::integer AS value", ["value"]), + ), + ); + for (const result of results) + expect(result).toEqual({ + parameters: ["value"], + columns: [{ name: "value", dataType: "integer", nullable: true }], + }); + } finally { + await scratch.close?.(); + } + await scratch.close?.(); + await expect(scratch.introspect()).rejects.toThrow(/closed/); + }); + + it("rolls back failed reset and preserves its error and existing scratch data", async () => { + const observer = new Pool({ connectionString: shadowUrl }); + const scratch = await postgres({ + url: targetUrl!, + shadowUrl: shadowUrl!, + pool: { options: "-c default_transaction_read_only=on" }, + }).shadow(); + try { + await observer.query( + "CREATE TABLE readonly_reset_guard (value text); INSERT INTO readonly_reset_guard VALUES ('preserve')", + ); + await expect(scratch.reset()).rejects.toThrow(/read.only/i); + expect((await observer.query("SELECT value FROM readonly_reset_guard")).rows).toEqual([ + { value: "preserve" }, + ]); + } finally { + await scratch.close?.(); + await observer.query("DROP TABLE IF EXISTS readonly_reset_guard"); + await observer.end(); + } + }); + + it("holds the scratch workflow lock until close, then lets another owner reset", async () => { + const first = await postgres({ + url: targetUrl!, + shadowUrl: shadowUrl!, + pool: { max: 1 }, + }).shadow(); + const second = await postgres({ + url: targetUrl!, + shadowUrl: shadowUrl!, + pool: { max: 1 }, + }).shadow(); + const observer = new Pool({ connectionString: shadowUrl }); + let reset: Promise | undefined; + try { + await first.reset(); + await first.execute("CREATE TABLE workflow_guard (value text)"); + reset = second.reset(); + let waiting = false; + for (let attempt = 0; attempt < 100; attempt++) { + const result = await observer.query( + "SELECT 1 FROM pg_locks WHERE locktype = 'advisory' AND NOT granted AND database = (SELECT oid FROM pg_database WHERE datname = current_database())", + ); + if (result.rows.length) { + waiting = true; + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + expect(waiting).toBe(true); + expect( + (await observer.query("SELECT to_regclass('workflow_guard') AS relation")).rows[0].relation, + ).toBe("workflow_guard"); + await first.close?.(); + await reset; + expect(await second.introspect()).toEqual({ version: 1, enums: [], tables: [], views: [] }); + } finally { + await first.close?.(); + await reset?.catch(() => undefined); + await second.close?.(); + await observer.end(); + } + }); + + it.each([ + [ + "identity", + "CREATE TABLE unsupported (id integer GENERATED ALWAYS AS IDENTITY)", + /Identity column/, + ], + ["materialized view", "CREATE MATERIALIZED VIEW unsupported AS SELECT 1 AS id", /kind m/], + [ + "foreign key action", + "CREATE TABLE parent (id integer PRIMARY KEY); CREATE TABLE unsupported (id integer REFERENCES parent ON DELETE CASCADE)", + /Foreign key.*cannot be represented/, + ], + [ + "included index", + "CREATE TABLE unsupported (id integer, label text); CREATE INDEX included ON unsupported (id) INCLUDE (label)", + /covering index/, + ], + [ + "unvalidated check", + "CREATE TABLE unsupported (id integer); ALTER TABLE unsupported ADD CONSTRAINT positive CHECK (id > 0) NOT VALID", + /unvalidated constraint/, + ], + [ + "nulls not distinct", + "CREATE TABLE unsupported (id integer UNIQUE NULLS NOT DISTINCT)", + /cannot be represented/, + ], + [ + "inheritance", + "CREATE TABLE parent (id integer); CREATE TABLE unsupported () INHERITS (parent)", + /cannot be represented/, + ], + ["unlogged table", "CREATE UNLOGGED TABLE unsupported (id integer)", /cannot be represented/], + [ + "nondefault collation", + 'CREATE TABLE unsupported (id text COLLATE "C")', + /cannot be represented/, + ], + ])( + "rejects unsupported %s rather than accepting an incomplete schema", + async (_name, sql, error) => { + const scratch = await postgres({ url: targetUrl!, shadowUrl: shadowUrl! }).shadow(); + try { + await scratch.reset(); + await scratch.execute(sql); + await expect(scratch.introspect()).rejects.toThrow(error); + } finally { + await scratch.close?.(); + } + }, + ); +}); diff --git a/tests/packed-contract.mjs b/tests/packed-contract.mjs index 848aec6..332661b 100644 --- a/tests/packed-contract.mjs +++ b/tests/packed-contract.mjs @@ -100,6 +100,42 @@ try { consumer, ); const fixture = path.join(consumer, "fixture.ts"); + const postgresWorkflow = Boolean( + process.env.ASKR_ORM_TEST_DATABASE_URL && process.env.ASKR_ORM_TEST_SHADOW_URL, + ); + if (postgresWorkflow) { + await fs.mkdir(path.join(consumer, "database")); + await fs.writeFile( + path.join(consumer, "database/index.ts"), + ` +import { defineDatabase, defineQuery, table, text, uuid } from '@askrjs/orm'; +import { postgres } from '@askrjs/orm/postgres'; +const users = table('users', { id: uuid().primaryKey().defaultRandom(), emailAddress: text().name('email').notNull().unique() }); +const byEmail = defineQuery<{email:string}>('users.by-email')\`SELECT id, email FROM users WHERE email = \${'email'} OR email = \${'email'}\`; +export default defineDatabase({ driver: postgres({ url: process.env.ASKR_ORM_TEST_DATABASE_URL!, shadowUrl: process.env.ASKR_ORM_TEST_SHADOW_URL!, pool: {max:1} }), tables: {users}, queries: {byEmail} }); +`, + ); + await fs.writeFile( + path.join(consumer, "postgres-workflow.mjs"), + ` +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import {runDatabaseCli} from '@askrjs/orm/tooling'; +const messages=[]; +const io={log:(v='')=>messages.push(String(v)), error:(v='')=>messages.push(String(v))}; +for (const command of ['generate','validate','generate']) assert.equal(await runDatabaseCli([command], {cwd:process.cwd(),io}), 0, messages.join('\\n')); +assert.equal(messages.at(-1),'default: unchanged'); +assert.equal((await fs.readdir('database/migrations')).length,1); +const generated=await fs.readFile('database/generated.ts','utf8'); +assert(generated.includes('readonly "id": string | null;')); +assert(generated.includes('readonly "email": string | null;')); +`, + ); + execFileSync(process.execPath, [path.join(consumer, "postgres-workflow.mjs")], { + cwd: consumer, + stdio: "pipe", + }); + } const imports = Object.entries(contract.entrypoints) .map( ([key]) => @@ -134,13 +170,13 @@ void [generated, codec, postgresOptions, invalidPool, sqliteOptions];\n`, types: ["node"], skipLibCheck: false, }, - files: ["fixture.ts"], + files: ["fixture.ts", ...(postgresWorkflow ? ["database/generated.ts"] : [])], }), ); execFileSync( process.execPath, [path.join(root, "node_modules/typescript/bin/tsc"), "--project", "tsconfig.json"], - { cwd: consumer, stdio: "pipe" }, + { cwd: consumer, stdio: "pipe", encoding: "utf8" }, ); const options = { strict: true, @@ -151,7 +187,10 @@ void [generated, codec, postgresOptions, invalidPool, sqliteOptions];\n`, types: ["node"], skipLibCheck: false, }; - const program = ts.createProgram([fixture], options); + const program = ts.createProgram( + [fixture, ...(postgresWorkflow ? [path.join(consumer, "database/generated.ts")] : [])], + options, + ); const diagnostics = ts.getPreEmitDiagnostics(program); assert.equal( diagnostics.length, @@ -187,6 +226,9 @@ void [generated, codec, postgresOptions, invalidPool, sqliteOptions];\n`, removed: Object.values(contract.entrypoints).reduce((n, c) => n + c.removed.length, 0), privateSubpaths: contract.privateSubpaths.length, postgresPeers: ["8.23.0", "4.17.0"], + postgresWorkflow: postgresWorkflow + ? "installed generate, validate, unchanged generate; TS6/7 artifact" + : "not run without explicit test databases", sqliteScript: "complete, rollback, subsequent query", }), ); diff --git a/tests/postgres-test-env.mjs b/tests/postgres-test-env.mjs new file mode 100644 index 0000000..377aa72 --- /dev/null +++ b/tests/postgres-test-env.mjs @@ -0,0 +1,4 @@ +if (!process.env.ASKR_ORM_TEST_DATABASE_URL || !process.env.ASKR_ORM_TEST_SHADOW_URL) + throw new Error( + "PostgreSQL integration requires explicit isolated target and disposable scratch URLs in ASKR_ORM_TEST_DATABASE_URL and ASKR_ORM_TEST_SHADOW_URL.", + ); diff --git a/tests/prepare-postgres.mjs b/tests/prepare-postgres.mjs new file mode 100644 index 0000000..91dd2bd --- /dev/null +++ b/tests/prepare-postgres.mjs @@ -0,0 +1,24 @@ +import { Pool } from "pg"; + +// CI's disposable PostgreSQL service owns these two fixed test databases. +if ( + !process.env.CI || + !process.env.ASKR_ORM_TEST_DATABASE_URL || + !process.env.ASKR_ORM_TEST_SHADOW_URL +) + throw new Error("Scratch database provisioning is restricted to the disposable CI service."); +const target = new URL(process.env.ASKR_ORM_TEST_DATABASE_URL); +const shadow = new URL(process.env.ASKR_ORM_TEST_SHADOW_URL); +if ( + target.hostname !== "127.0.0.1" || + target.pathname !== "/askr_orm_test" || + shadow.hostname !== "127.0.0.1" || + shadow.pathname !== "/askr_orm_test_shadow" +) + throw new Error("Provisioning requires the fixed loopback CI test databases."); +const pool = new Pool({ connectionString: process.env.ASKR_ORM_TEST_DATABASE_URL }); +try { + await pool.query('CREATE DATABASE "askr_orm_test_shadow"'); +} finally { + await pool.end(); +} diff --git a/tests/public-contract.json b/tests/public-contract.json index 5add462..f86c658 100644 --- a/tests/public-contract.json +++ b/tests/public-contract.json @@ -162,6 +162,8 @@ "sql", "tooling-impl", "dist/index.js", - "dist/tooling-impl.js" + "dist/tooling-impl.js", + "postgres-client", + "postgres-tooling" ] } From 449742d07cbfd4b247bf41a889829cef1570b044 Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Mon, 12 Oct 2026 18:46:35 -0400 Subject: [PATCH 5/7] fix: generate native SQLite registered query value types (#59) --- CHANGELOG.md | 3 + docs/adapters.md | 10 +++ src/tooling-impl.ts | 9 ++- src/tooling-integration.test.ts | 8 ++- src/tooling.test.ts | 112 ++++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e8681e..8040bfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,9 @@ ### Fixed +- Generate SQLite registered-query fields as native number/string/blob/null + unions instead of PostgreSQL numeric strings and booleans. SQLite affinities + permit mixed storage classes; callers must narrow generated fields before use. - SQLite migration execution no longer drops all statements after the first. - PostgreSQL migration scripts no longer fail when the driver returns multiple result sets. diff --git a/docs/adapters.md b/docs/adapters.md index 512eac4..4314e90 100644 --- a/docs/adapters.md +++ b/docs/adapters.md @@ -23,6 +23,16 @@ The runtime and tooling adapters are deliberately separate. An adapter must pass values to the driver separately from SQL text and never interpolate application data. Placeholder rendering is dialect-owned. +Registered queries return adapter values without decoding or coercion. Generated +SQLite row fields use `number | string | Uint8Array | null`: SQLite column +declarations specify affinity rather than enforce a storage class, so even +`numeric`, `bigint`, and `boolean` columns may contain numbers, text, blobs, or +null. The built-in `node:sqlite` adapter uses number reads for integers; it does +not return JavaScript booleans, bigints, or dates. Integers outside the safe +JavaScript number range raise a driver error. Narrow or validate generated fields +before using a specific value type. Generated PostgreSQL fields follow the +standard `pg` parsers described in [PostgreSQL tooling](postgres-tooling.md). + `DatabaseToolingAdapter` is allowed only in tooling: - `identity` is a stable, non-secret database identity. diff --git a/src/tooling-impl.ts b/src/tooling-impl.ts index a16f122..56d793f 100644 --- a/src/tooling-impl.ts +++ b/src/tooling-impl.ts @@ -700,6 +700,12 @@ async function describeRegisteredQueries( } function tsType(dataType: string, nullable: boolean, dialect: "postgres" | "sqlite"): string { + // SQLite declarations describe affinity, not enforced storage classes. Registered + // queries return node:sqlite values unchanged, with bigint reads disabled. + if (dialect === "sqlite") { + const base = "number | string | Uint8Array"; + return nullable ? `${base} | null` : base; + } const normalized = dataType.toLowerCase(); const base = normalized === "boolean" @@ -710,8 +716,7 @@ function tsType(dataType: string, nullable: boolean, dialect: "postgres" | "sqli ? "string" : normalized === "bytea" ? "Uint8Array" - : dialect === "postgres" && - /^(?:date|timestamp(?:\(\d+\))? (?:with|without) time zone)$/.test(normalized) + : /^(?:date|timestamp(?:\(\d+\))? (?:with|without) time zone)$/.test(normalized) ? "Date" : /^(?:json|jsonb)$/.test(normalized) ? "unknown" diff --git a/src/tooling-integration.test.ts b/src/tooling-integration.test.ts index 2d90f05..4b3173a 100644 --- a/src/tooling-integration.test.ts +++ b/src/tooling-integration.test.ts @@ -259,7 +259,7 @@ const entries = table("entries", { lowerEmail: text().generatedAlwaysAs("lower(email)"), amount: numeric(10, 2).default("0.00"), data: bytes(), }, { schema: "catalog #%", constraints: [check("length(email) > 0", "email_nonempty"), index(["lower(email)"], { name: "lower_email", where: "state IS NOT NULL" })] }); const emails = view("emails", 'SELECT email FROM "catalog #%".entries', { schema: "catalog #%" }); -const values = defineQuery>("values")\`SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers\`; +const values = defineQuery>("values")\`SELECT 9007199254740993::bigint AS count, 1.25::numeric AS amount, true AS flag, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers\`; export default defineDatabase({ driver: postgres({ url: ${JSON.stringify(targetUrl)}, shadowUrl: ${JSON.stringify(shadowUrl)}, pool: { max: 1 } }), tables: { entries }, enums: [status], views: [emails], queries: { values } }); `, ); @@ -270,16 +270,20 @@ export default defineDatabase({ driver: postgres({ url: ${JSON.stringify(targetU expect(await fs.readdir(path.join(directory, "migrations"))).toHaveLength(1); const artifact = await fs.readFile(path.join(directory, "generated.ts"), "utf8"); expect(artifact).toContain('readonly "count": string | null;'); + expect(artifact).toContain('readonly "amount": string | null;'); + expect(artifact).toContain('readonly "flag": boolean | null;'); expect(artifact).toContain('readonly "day": Date | null;'); expect(artifact).toContain('readonly "numbers": unknown | null;'); const observer = new Pool({ connectionString: shadowUrl }); try { const [row] = ( await observer.query( - "SELECT 9007199254740993::bigint AS count, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers", + "SELECT 9007199254740993::bigint AS count, 1.25::numeric AS amount, true AS flag, DATE '2026-10-09' AS day, ARRAY[1,2] AS numbers", ) ).rows; expect(row.count).toBe("9007199254740993"); + expect(row.amount).toBe("1.25"); + expect(row.flag).toBe(true); expect(row.day).toBeInstanceOf(Date); expect(row.numbers).toEqual([1, 2]); } finally { diff --git a/src/tooling.test.ts b/src/tooling.test.ts index 093eb50..8a912ec 100644 --- a/src/tooling.test.ts +++ b/src/tooling.test.ts @@ -1,7 +1,19 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import ts from "@typescript/typescript6"; import { afterEach, describe, expect, it } from "vitest"; +import { + bigInt, + boolean, + defineDatabase, + defineQuery, + integer, + numeric, + table, + text, +} from "./index"; +import { sqlite } from "./sqlite"; import { diffSnapshots, runDatabaseCli, type SchemaSnapshot } from "./tooling-impl"; const temporaryDirectories: string[] = []; @@ -120,6 +132,106 @@ describe("schema migration generation", () => { }); describe("database CLI", () => { + it("should generate SQLite row types that accept native values from every storage class", async () => { + const packageRoot = path.resolve(import.meta.dirname, ".."); + const root = await fs.mkdtemp(path.join(packageRoot, ".orm-sqlite-types-")); + temporaryDirectories.push(root); + const directory = path.join(root, "database"); + await fs.mkdir(path.join(directory, "migrations"), { recursive: true }); + const schema = "CREATE TABLE typed(n numeric,b bigint,flag boolean,label text,count integer)"; + await fs.writeFile( + path.join(directory, "migrations", "01TYPED.sql"), + `-- askr:id 01TYPED\n-- askr:parent none\n-- askr:transactional true\n\n${schema};\n`, + ); + await fs.writeFile( + path.join(directory, "index.ts"), + `import { bigInt, boolean, defineDatabase, defineQuery, integer, numeric, table, text } from "../../src/index.ts"; +import { sqlite } from "../../src/sqlite.ts"; +const typed = table("typed", { n: numeric(), b: bigInt(), flag: boolean(), label: text(), count: integer() }); +const values = defineQuery>("typed.values")\`SELECT *, n + 1 AS expression FROM typed ORDER BY rowid\`; +export default defineDatabase({ driver: sqlite({ filename: ${JSON.stringify(path.join(root, "target.sqlite"))} }), tables: { typed }, queries: { values } }); +`, + ); + const messages: string[] = []; + const io = { + log: (value: unknown = "") => messages.push(String(value)), + error: (value: unknown = "") => messages.push(String(value)), + }; + for (const command of ["generate", "validate", "generate"]) { + expect(await runDatabaseCli([command], { cwd: root, io }), messages.join("\n")).toBe(0); + } + expect(messages.at(-1)).toBe("default: unchanged"); + + const values = defineQuery>( + "typed.values", + )`SELECT *, n + 1 AS expression FROM typed ORDER BY rowid`; + const driver = sqlite({ filename: ":memory:" }); + const adapter = await driver.open(); + const db = await defineDatabase({ + driver: { ...driver, open: async () => adapter }, + tables: { + typed: table("typed", { + n: numeric(), + b: bigInt(), + flag: boolean(), + label: text(), + count: integer(), + }), + }, + queries: { values }, + }).open(); + try { + await adapter.executeScript!(`${schema}; +INSERT INTO typed VALUES (1.25,42,1,'label',3); +INSERT INTO typed VALUES ('text','text','text',X'0102','text'); +INSERT INTO typed VALUES (X'0102',X'0102',X'0102',NULL,X'0102'); +INSERT INTO typed VALUES (NULL,NULL,NULL,NULL,NULL);`); + const rows = await db.queries.values({}); + const blob = new Uint8Array([1, 2]); + expect(rows).toEqual([ + { n: 1.25, b: 42, flag: 1, label: "label", count: 3, expression: 2.25 }, + { n: "text", b: "text", flag: "text", label: blob, count: "text", expression: 1 }, + { n: blob, b: blob, flag: blob, label: null, count: blob, expression: 1 }, + { n: null, b: null, flag: null, label: null, count: null, expression: null }, + ]); + } finally { + await db.close(); + } + + const consumer = path.join(root, "consumer.ts"); + await fs.writeFile( + consumer, + `import type { GeneratedDatabaseQueries } from "./database/generated"; +type Row = GeneratedDatabaseQueries["typed.values"]["row"]; +const numeric: Row = { n: 1.25, b: 42, flag: 1, label: "label", count: 3, expression: 2.25 }; +const textual: Row = { n: "text", b: "text", flag: "text", label: new Uint8Array([1, 2]), count: "text", expression: 1 }; +const blob: Row = { n: new Uint8Array([1, 2]), b: new Uint8Array([1, 2]), flag: new Uint8Array([1, 2]), label: null, count: new Uint8Array([1, 2]), expression: 1 }; +const empty: Row = { n: null, b: null, flag: null, label: null, count: null, expression: null }; +// @ts-expect-error SQLite does not return JavaScript booleans. +const invalidBoolean: Row["flag"] = true; +// @ts-expect-error The built-in adapter does not enable bigint reads. +const invalidBigint: Row["b"] = 42n; +// @ts-expect-error SQLite does not return Date instances. +const invalidDate: Row["expression"] = new Date(); +void [numeric, textual, blob, empty, invalidBoolean, invalidBigint, invalidDate]; +`, + ); + const program = ts.createProgram([consumer], { + target: ts.ScriptTarget.ESNext, + module: ts.ModuleKind.Preserve, + moduleResolution: ts.ModuleResolutionKind.Bundler, + strict: true, + skipLibCheck: true, + noEmit: true, + paths: { "@askrjs/orm": [path.join(packageRoot, "src/index.ts")] }, + }); + expect( + ts + .getPreEmitDiagnostics(program) + .map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n")), + ).toEqual([]); + }); + 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- #% ")); From 817e1e970a9921df53c8eb756fe1ad4e228a8f6b Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Mon, 12 Oct 2026 19:24:59 -0400 Subject: [PATCH 6/7] chore(release): prepare ORM 0.5.0 from current develop (#60) --- CHANGELOG.md | 2 ++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8040bfc..6bbe5eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +## 0.5.0 - 2026-10-12 + ### Breaking - Curate the root to 68 names and the tooling subpath to its single runDatabaseCli diff --git a/package-lock.json b/package-lock.json index c0121e1..17d1fe8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/orm", - "version": "0.4.0", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/orm", - "version": "0.4.0", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { "tsx": "^4.23.12" diff --git a/package.json b/package.json index 56bacf0..77ab618 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/orm", - "version": "0.4.0", + "version": "0.5.0", "description": "Postgres-first, SQL-shaped micro-ORM for Askr", "keywords": [ "askr", From d521612ac2d67f864a6a00fec720a57106040f2b Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Tue, 13 Oct 2026 14:50:44 -0400 Subject: [PATCH 7/7] docs: refresh ORM 0.5.0 qualification status (#61) --- CHANGELOG.md | 8 +++++--- docs/0.5.0-api.md | 2 +- docs/0.5.0-hardening.md | 5 +---- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6bbe5eb..d257742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,7 +55,9 @@ - Gate real and normally installed generation/validation/no-op workflows against isolated PostgreSQL 16, 17 and 18 scratch databases. -### Remaining release gate +### Candidate verification -- Complete coordinated 0.5.0 packed-candidate and website qualification remains - pending. Maintainer review and approval are required before publication. +- The exact promotion candidate on PR #58 passes the Linux, macOS and Windows + package gates, PostgreSQL 16–18 integration and installed-generation checks, + and CodeQL. Tag creation and npm publication run through the main-branch + package workflow. diff --git a/docs/0.5.0-api.md b/docs/0.5.0-api.md index 06708ae..f837894 100644 --- a/docs/0.5.0-api.md +++ b/docs/0.5.0-api.md @@ -1,6 +1,6 @@ # 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. The real PostgreSQL tooling fix for [#55](https://github.com/askrjs/askr-orm/issues/55) has separate native and installed workflow qualification in [PostgreSQL tooling](postgres-tooling.md). +Status: the API contraction was delivered by merged PR #56, and issue #53 is closed. The exact 0.5.0 promotion candidate is covered by the packed-package contract and hosted checks on [PR #58](https://github.com/askrjs/askr-orm/pull/58). The real PostgreSQL tooling fix tracked by [#55](https://github.com/askrjs/askr-orm/issues/55) is qualified in [PostgreSQL tooling](postgres-tooling.md). This document records package API decisions and migration guidance. 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. diff --git a/docs/0.5.0-hardening.md b/docs/0.5.0-hardening.md index 10b2d95..f782d9b 100644 --- a/docs/0.5.0-hardening.md +++ b/docs/0.5.0-hardening.md @@ -1,9 +1,6 @@ # 0.5.0 ORM hardening evidence -Status: the API/runtime candidate and real PostgreSQL tooling fix are qualified -locally. PostgreSQL 16–18 hosted and installed workflow checks must pass before -#55 closes. The package is **not release-ready**: #53 and #54 still require the -complete coordinated candidate/website qualification and maintainer review. +Status: issues #53, #54 and #55 are closed with the API, runtime and real PostgreSQL workflow evidence recorded below. The exact 0.5.0 promotion head on [PR #58](https://github.com/askrjs/askr-orm/pull/58) passes the Linux, macOS and Windows package gates, PostgreSQL 16–18 integration and installed-generation checks, and CodeQL. Tag creation and npm publication run through the main-branch package workflow. ## Assertion review and real adapters