Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/driver-turso-remote-declared-indexes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/driver-turso': patch
---

fix(driver-turso): remote mode materializes every declared object-level index, not only field-level `unique` (#17609)

## What was wrong

In remote mode (`libsql://` / `https://`), `TursoDriver` provisions tables through `RemoteTransport`, and the only index DDL that path could emit came from field-level `unique`. An object's declared `indexes: [...]` — unique or not — had no consumer there, so no remote database ever carried one. The local face (`SqlDriver`) created all of them, so nothing failed and no local test noticed: on a remote tenant database `sys_notification_delivery` (five declared indexes) and `sys_job_queue` (three) held only their primary-key autoindex, and the delivery claim query answered every poll with a full table scan (`SCAN sys_notification_delivery` + `USE TEMP B-TREE FOR ORDER BY`).

## What changes

- Remote mode now creates **every** declared index: field-level `unique` plus the object's own `indexes`, unique and non-unique, including `unique: 'organization'` with its NULL-safe `COALESCE(<tenant>, '__global__')` key part. Names and keys come from the same shared normalizers `SqlDriver` and the drift differ use (`uniqueIndexesFromFields`, `normalizeDeclaredIndex`, `buildIndexName`), so both faces land the same index set — pinned by a new local/remote parity suite that compares `sqlite_master` on both.
- New tables get their indexes in the same batch as `CREATE TABLE`.
- **Existing tables are retrofitted on the next schema sync** with `CREATE [UNIQUE] INDEX IF NOT EXISTS`. No row is read-modified or rewritten.
- An index the retrofit cannot create is reported once at `error`, naming the index, the table and the database's own cause. A declared `unique` index over rows that already violate it is **not** forced and no data is repaired: de-duplicate the key's values and re-run schema sync.
- Steady-state cost goes down: a sync now reads the existing index names once (one statement, folded into the column-probe batch it already sends) and issues no index DDL when every declared index exists. Before, every boot re-sent one `CREATE UNIQUE INDEX IF NOT EXISTS` per field-level unique index on an existing table.

## Upgrading

Nothing to change in metadata or configuration. The first kernel build after upgrading creates the missing indexes on each existing remote database — on a large table that one build pays the index build time. Watch the boot log for `could not create the declared` lines at `error`: each names an index that is still absent and why.
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* where an identifier is INLINED into SQL (SQLite cannot bind one): `object`,
* `field` and the `groupBy` field / output key in `aggregate`, the table and
* column names in `syncSchema` / `syncSchemasBatch` / `buildCreateTableSQL`,
* and the index name and columns in `syncUniqueIndexes`. It threw a bare
* and the index key columns in `buildDeclaredIndexDDL`. It threw a bare
* `Error` — no `code`, no `status` — so `mapDataError`
* (`packages/rest/src/error-response.ts`) reached none of its classifying
* branches, fell through to its sanitised terminal and served a **500**. A
Expand Down Expand Up @@ -57,9 +57,12 @@
* a separate card and stays open. [#14235] That card has since landed: the
* `groupBy` OUT KEY position is ESCAPED rather than refused now, so its case
* below asserts the quoted emission instead of a refusal — every OTHER
* position named above (`object`, `field`, the `groupBy` FIELD, the DDL table,
* column and index names) is untouched and still refuses with this envelope,
* and the accept-set `describe` below still drives the `groupBy` FIELD.
* position named above (`object`, `field`, the `groupBy` FIELD, the DDL table
* and column names, the index key columns) is untouched and still refuses with
* this envelope, and the accept-set `describe` below still drives the `groupBy`
* FIELD. [#17609] An author-declared index NAME moved the same way, for the
* same reason — escaped, not refused; pinned in
* `turso-local-remote-declared-index-parity.test.ts`.
*
* ## Reverse verification — direction predicted BEFORE it was run
*
Expand Down
Loading
Loading