5.1 KiB
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\:```` 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—bareIdhelper strips the${table}:prefix and any wrapping backticks from returned SurrealDB ids so the value that round-trips intodeleteApplied(table, id)is the bare migration name. Object-formRecordId { tb, id }is unwrapped to its bare id when stringy. - MODIFIED:
createSurrealqlAdapter.insertApplied—appliedAtis now bound as the rawDateobject so the SDK CBOR-encodes it as a SurrealDBdatetime. The priorrecord.appliedAt.toISOString()form is rejected by v3 withInternalError: Couldn't coerce value for field 'appliedAt'(the DDL declares the columnTYPE datetime). Second silent bug surfaced by the regression test. - ADDED:
vitest.integration.config.ts+test:integrationscript +tests/integration/global-setup.ts+tests/integration/integration-setup.tsmirroring the@suaveplan/surrealdbpattern. - ADDED:
tests/integration/surrealql-ledger.integration.test.ts— three live-SurrealDB-v3 regression tests (silent CREATE drop, second-run skip, rollback round-trip). - 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 expandingMIGRATION_CODESwhich is a wider spec change. The fix splicestabledirectly into the SQL exactly as the original code did; no new injection surface is introduced. - Reverting the
INFO FOR DBworkaround 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-upbelow. 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
runFromDirectorycall is a no-op (ledger is read back), rollback()removes the ledger row written byinsertApplied.
Out of scope
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
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.