Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
# Fix: api-migrations SurrealQL ledger CREATE silently dropped under SurrealDB v3
**Package**: `@suaveplan/api-migrations`
**Status**: PROPOSED
**Type**: bug-fix
## Why
`createSurrealqlAdapter`'s `insertApplied` issued `CREATE ${table}:$name SET ...`. SurrealDB v3's parser does NOT substitute `$name` in the record-id position — the token is consumed as a literal ident, the CREATE is accepted, but no row is created. The migration body still executes (it goes through `adapter.execute`) and the advisory lock works, but the ledger is silently empty. The runner therefore re-executes every "applied" migration on the next boot, defeating the entire purpose of the ledger.
Surfaced while shipping `@suaveplan/api-plugin-database`: the integration test confirmed the migration body applied, then read the ledger directly via `INFO FOR DB` and found nothing. A workaround was added in api-plugin-database; the underlying bug lives here.
The same shape is reused by `deleteApplied` (`DELETE ${table}:${id}`), which silently drops any id that isn't a bare SurrealQL ident, breaking rollback for filenames like `001_initial`.
## What changes
- MODIFIED: `createSurrealqlAdapter.insertApplied` — switched record-id production from `${table}:$name` (silently dropped under v3) to a backtick-quoted record-id literal `\`<table>:\`<escaped-id>\`\`` spliced into the SQL. Backticks inside the id are doubled. Field values stay parameterised via named bindings.
- MODIFIED: `createSurrealqlAdapter.deleteApplied` — same v3-safe record-id form.
- MODIFIED: `createSurrealqlAdapter.selectApplied` — `bareId` helper strips the `${table}:` prefix and any wrapping backticks from returned SurrealDB ids so the value that round-trips into `deleteApplied(table, id)` is the bare migration name. Object-form `RecordId { tb, id }` is unwrapped to its bare id when stringy.
- MODIFIED: `createSurrealqlAdapter.insertApplied` — `appliedAt` is now bound as the raw `Date` object so the SDK CBOR-encodes it as a SurrealDB `datetime`. The prior `record.appliedAt.toISOString()` form is rejected by v3 with `InternalError: Couldn't coerce value for field 'appliedAt'` (the DDL declares the column `TYPE datetime`). Second silent bug surfaced by the regression test.
- ADDED: `vitest.integration.config.ts` + `test:integration` script + `tests/integration/global-setup.ts` + `tests/integration/integration-setup.ts` mirroring the `@suaveplan/surrealdb` pattern.
- ADDED: `tests/integration/surrealql-ledger.integration.test.ts` — three live-SurrealDB-v3 regression tests (silent CREATE drop, second-run skip, rollback round-trip).
## Out of scope
- Postgres / sqlite adapter changes — only SurrealQL is affected.
- Adding a new error code for "invalid table identifier" — the runner only passes the configured tableName (default `_migrations`), and table-ident validation would require expanding `MIGRATION_CODES` which is a wider spec change. The fix splices `table` directly into the SQL exactly as the original code did; no new injection surface is introduced.
- Reverting the `INFO FOR DB` workaround inside `@suaveplan/api-plugin-database` — a different session is shipping that package and may want to keep the workaround until they re-verify. See `## Follow-up` below.
## Source mapping
This change is a bug fix against an already-shipped genesis package — no `../suaveplan/enterprise/` source applies. The shipped reference for the v3-safe record-id form is `packages/auth/better-auth-surrealdb/src/id-strategy/id-strategy.ts::prepareRecordId` (line 112), which produces the same backtick-quoted record-ref shape and is proven against the same `testing-integration-surrealdb` testcontainer.
The behavior of `$param` in SurrealDB v3 record-id position is documented in the SurrealQL CREATE statement reference (https://surrealdb.com/docs/surrealql/statements/create) — only bare idents or backticked / angle-bracketed literals are valid in the post-`:` position; parameter references are evaluated as values, not as id constructors.
## Verification approach
- `src/adapters/surrealql/surrealql.test.ts` — unit assertions on the emitted SurrealQL string (regression: `not.toContain("$name SET")`, positive: `toContain("CREATE _migrations:\`001\` SET")`, backtick escape, rowToRecord normalisation).
- `tests/integration/surrealql-ledger.integration.test.ts` — three real-SurrealDB-v3 tests verifying:
- the ledger row is queryable IN THE SAME CONNECTION after the migration body succeeds,
- a second `runFromDirectory` call is a no-op (ledger is read back),
- `rollback()` removes the ledger row written by `insertApplied`.
## Migration impact
Internal to the package — the `MigrationAdapter` public surface is unchanged. Consumers (`@suaveplan/api-plugin-database` and any direct callers) see corrected behavior on the next install. No breaking changes.
## Follow-up
`@suaveplan/api-plugin-database` currently uses an `INFO FOR DB` workaround at its full-boot integration test to confirm a migration applied without trusting the ledger. With this fix, that workaround is no longer necessary and the next touch on api-plugin-database can revert it. Out of scope for this change because a parallel session owns api-plugin-database.

View File

@@ -0,0 +1,54 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-migrations (fix-api-migrations-surrealql-ledger)
**Package**: `@suaveplan/api-migrations`
**Status**: PROPOSED
> The existing `Requirement: MigrationAdapter is the per-dialect surface` covers the full adapter contract, including `insertApplied`/`deleteApplied`/`selectApplied`. The body of that requirement is unchanged. This change adds a new requirement that pins the v3-safe record-id production for the SurrealQL adapter specifically — it is additive to the existing per-dialect surface, not a replacement.
## ADDED Requirements
### Requirement: SurrealQL adapter records the ledger row under SurrealDB v3
`createSurrealqlAdapter.insertApplied(table, record)` SHALL produce a record-id reference in the form `<table>:\`<escaped-id>\`` where `<escaped-id>` is `record.id` with every backtick doubled. The adapter SHALL NOT use the `${table}:$name` form — SurrealDB v3 parses `$param` in the record-id position as a literal ident (not a parameter substitution), so the CREATE is acknowledged but silently records no row, leaving the ledger empty and causing the runner to re-execute completed migrations. After a successful `insertApplied` invocation, a subsequent `selectApplied(table)` on the same connection SHALL return a row whose `name`, `checksum`, and `duration` match the inserted record.
`createSurrealqlAdapter.deleteApplied(table, id)` SHALL produce the matching `<table>:\`<escaped-id>\`` reference so the round-trip from `selectApplied` → `deleteApplied` correctly targets the row that `insertApplied` wrote, regardless of whether `id` is a bare SurrealQL ident.
`createSurrealqlAdapter.selectApplied(table)` SHALL normalise the SurrealDB-returned `id` field to the bare migration name — strip the `${table}:` prefix and any wrapping backticks, and for object-form `RecordId { tb, id }` return the inner `id` string when present. This guarantees `rollback()`'s call to `deleteApplied(table, last.id)` produces the same record-ref shape as the original `insertApplied`.
`createSurrealqlAdapter.insertApplied` SHALL bind `record.appliedAt` as the raw `Date` value, NOT as an ISO 8601 string. SurrealDB v3's `TYPE datetime` column refuses to coerce a `string` into `datetime` and rejects the CREATE with `InternalError: Couldn't coerce value for field 'appliedAt'`. The SDK CBOR-encodes the Date binding as a SurrealDB `datetime` value.
#### Scenario: insertApplied uses backticked record-id literal (v3-safe)
- **WHEN** `createSurrealqlAdapter(client).insertApplied("_migrations", { id: "001", name: "001", appliedAt, checksum: "h", duration: 5 })` is called
- **THEN** the issued SurrealQL is `CREATE _migrations:\`001\` SET name = $name, appliedAt = $appliedAt, checksum = $checksum, duration = $duration` (NOT `CREATE _migrations:$name SET ...`)
#### Scenario: backticks in id are escaped
- **WHEN** `record.id` is `weird\`id`
- **THEN** the issued SurrealQL contains `CREATE _migrations:\`weird\`\`id\` SET ...`
#### Scenario: ledger row is queryable after the migration body succeeds (live v3)
- **WHEN** `runFromDirectory(dir)` applies a single migration against a real SurrealDB v3.x testcontainer
- **THEN** `SELECT name, checksum, duration FROM _migrations` on the same connection returns exactly one row with the migration name and a 64-char-hex `checksum`
#### Scenario: second run sees the ledger and skips already-applied migrations (live v3)
- **WHEN** `runFromDirectory(dir)` runs a second time after the first has applied one migration
- **THEN** the result array is empty AND the ledger still contains exactly one row
#### Scenario: rollback removes the ledger row written by insertApplied (live v3)
- **WHEN** `runner.rollback()` is awaited after one successful apply
- **THEN** the rollback result is `{ name, success: true }` AND the ledger table is empty
#### Scenario: selectApplied normalises returned ids to the bare migration name
- **WHEN** SurrealDB returns `id: "_migrations:\`001_initial\`"` for a row
- **THEN** `selectApplied("_migrations")` returns `{ id: "001_initial", ... }`
**Verified by:** `src/adapters/surrealql/surrealql.test.ts::"insertApplied uses backticked record-id literal (v3-safe)"` AND `src/adapters/surrealql/surrealql.test.ts::"insertApplied escapes backticks inside the migration id"` AND `src/adapters/surrealql/surrealql.test.ts::"insertApplied binds appliedAt as a Date (not a string)"` AND `src/adapters/surrealql/surrealql.test.ts::"deleteApplied uses backticked record-id literal (v3-safe)"` AND `src/adapters/surrealql/surrealql.test.ts::"selectApplied strips backtick-quoted id form"` AND `src/adapters/surrealql/surrealql.test.ts::"selectApplied strips table prefix from string ids"` AND `tests/integration/surrealql-ledger.integration.test.ts::"ledger row is queryable after a migration body succeeds (regression: silent CREATE drop)"` AND `tests/integration/surrealql-ledger.integration.test.ts::"second run sees the ledger and skips already-applied migrations"` AND `tests/integration/surrealql-ledger.integration.test.ts::"rollback() removes the ledger row written by insertApplied (round-trip)"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,56 @@
# Tasks — Fix api-migrations SurrealQL ledger CREATE silent drop on SurrealDB v3
## 1. Spec authoring
- [x] 1.1 Not a migration — no `enterprise/<pkg>.md` to read
- [x] 1.2 Read `openspec/specs/api-migrations/spec.md`
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md`
- [x] 1.5 Source mapping cites the genesis-internal reference (`better-auth-surrealdb::prepareRecordId`) and the SurrealDB v3 CREATE docs
- [x] 1.6 Draft `specs/api-migrations/spec.md` with `## MODIFIED Requirements`
- [x] 1.7 Every requirement carries a `**Verified by:**` line naming concrete `it(...)` titles
## 2. Implementation
- [x] 2.1 Replace `CREATE ${table}:$name SET ...` with backtick-quoted record-id literal in `insertApplied`
- [x] 2.2 Replace `DELETE ${table}:${id}` with the matching backtick-quoted record-id literal in `deleteApplied`
- [x] 2.3 Add `bareId` helper that strips `${table}:` prefix and wrapping backticks from SurrealDB-returned ids in `selectApplied`
- [x] 2.4 Update `rowToRecord` to thread `table` through and call `bareId`
## 3. Tests
- [x] 3.1 Update unit tests in `src/adapters/surrealql/surrealql.test.ts` — regression `not.toContain("$name SET")`, positive `toContain("CREATE _migrations:\`001\` SET")`, backtick escape, `selectApplied` strip + RecordId object form + fallback paths
- [x] 3.2 Add live SurrealDB v3 integration suite at `tests/integration/surrealql-ledger.integration.test.ts` — three tests: silent-drop regression, second-run no-op, rollback round-trip
- [x] 3.3 Add `tests/integration/global-setup.ts` + `integration-setup.ts` + `vitest.integration.config.ts` + `test:integration` script (mirroring `@suaveplan/surrealdb`)
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }` or 60000 for the live-container tests
## 4. Documentation
- [x] 4.1 Sibling `.md` for the implementation file already exists (`surrealql.md`) — refreshed to describe the v3-safe form
- [x] 4.2 README links unchanged — module list still accurate
- [x] 4.3 CLAUDE.md unchanged — no new package
## 5. Pre-archive verification (the 5-gate triple)
- [x] 5.1 `bun run check` — 1 unrelated warning (broken symlink in unrelated tree); 0 errors
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-migrations` — green (76 unit tests)
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-migrations` — 100/100/100/100 istanbul
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-migrations` — exit 0 (PASS, 0 errors / 0 warnings / 0 info)
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-migrations` — green
- [x] 5.6 Live SurrealDB v3 integration tests (`bun run test:integration`) — 3/3 pass; verified to fail with the pre-fix SQL form
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics threaded — `bareId` returns string; `rowToRecord` typed; no `any`
- [x] 6.2 Zod schemas — no boundary changes; existing schemas untouched
- [x] 6.3 Every error code in `error-codes.ts` still thrown by at least one production path (unchanged surface)
- [x] 6.4 Strict-mode behaviors — `{ rollback: false }` path retested
- [x] 6.5 OTel instruments — unchanged; existing assertions in `runner.test.ts` cover both outcomes
- [x] 6.6 Lifecycle dedup — N/A for this change
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-migrations/spec.md`
- [x] 7.2 Move `openspec/changes/fix-api-migrations-surrealql-ledger/` → `openspec/archive/2026-05-19-fix-api-migrations-surrealql-ledger/`
- [x] 7.3 MANIFEST.md unchanged (no package set/dep change at the manifest level)