fix(driver-turso): remote mode materializes every declared object-level index (#17609) - #17615
Conversation
Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
…emote face Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
…fusing it Claude-Session: https://claude.ai/code/session_c5c0ce54-bb9c-478c-9e5b-cf44b80d4569 Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 2d2e21a8b85214ea6ec30293ef07479c0e307e36 && git checkout 2d2e21a8b85214ea6ec30293ef07479c0e307e36
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 6241e6e511b82ccaa60772c2033ee4039f70f1b9 && git checkout -B drift-repro 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4 && git merge --no-ff 6241e6e511b82ccaa60772c2033ee4039f70f1b9
node scripts/docs-audit/affected-docs.mjs --json 0918c441188cae8c3990ac8cb38dbb6dd6e7b6e4
|
PM 复核(maintainer direct dispatch,Session
|
| 项 | 读数 | 结论 |
|---|---|---|
| 改动文件 | 5 个:changeset、remote-transport.ts、turso-driver.ts、新对等测试、remote-transport-unsafe-identifier-envelope.test.ts |
与卡片范围一致 |
Clause-② 路径肢 |
packages/spec/src/** 改动 0;driver-sql 改动 0 |
no 成立 |
| retrofit 是否只做 DDL | diff 新增行里只有 CREATE [UNIQUE] INDEX IF NOT EXISTS,无任何 DELETE / UPDATE / INSERT |
满足「零改数据」 |
| 命名与键是否与本地面同源 | 复用已导出的 normalizeDeclaredIndex + uniqueIndexesFromFields(名字经 buildIndexName),未另起命名 |
满足验收 1 |
| 失败是否响亮 | 诊断出口在构造期接到 logger.error |
满足验收 2 |
越界点:作者声明的索引名从「拒绝」改为「转义」——审过,接受
- 实现:
aliasIdentifierSql(alias) → "${String(alias).replace(/"/g, '""')}",标准引号标识符加双写转义;该函数已用于groupBy输出键(drivers(turso): RemoteTransport still refuses a dotted groupBy alias while driver-sql escapes it — the second output-name position #14113 deliberately left alone #14235 做过同样的迁移),本 PR 没有新写转义 - 边界没有扩大到引用位置:索引键列仍然走
assertSafeIdentifier(PR headremote-transport.ts:2260) - 被改的既有测试
remote-transport-unsafe-identifier-envelope.test.ts只改了注释,把「index name」从仍拒绝的位置列表里移出并指向新对等测试;其余位置(object、field、groupByFIELD、DDL 表名/列名、索引键列)仍拒绝 - 理由成立:拒绝会让远程面因一个本地面能正常引号化的合法名字而整批 schema 同步失败;语料中非标识符形态的声明索引名为 0
验证口径说明
席位推送后工作树已清理,我没有在本地重跑新对等测试;以本 PR 的 CI(Test Core 分片、Type Check、Lint & Repo Gates 等)作为独立验证。席位报告的两项本地 NOT MEASURED(check:dual-build-cjs-loads、check:type-check-debt --re-measure,需全仓构建)同样交给 CI。
下一步:CI 全部结束且无失败 → 转 ready → 入合并队列。有任何红,按失败日志回派同一席位。
Closes #17609
Clause-②: no — the driver now materializes indexes the metadata already declares;
packages/spec/src/**is untouched, and no export, schema key or accepted/refused authoring input changes.Premise, verified against
origin/main@3ef96b471before any editTursoDriver.initObjects→RemoteTransport.syncSchemasBatch. Every index DDL inremote-transport.tscame frombuildUniqueIndexDDL→uniqueIndexesFromFields(field-leveluniqueonly); emit sites:1841/:1868/:1941/:1981, sole template:2196. Confirmed as the card states.git grepfinds no otherCREATE INDEXindriver-turso/src, and nothing outsidedriver-sqlcalls an index-sync method.SqlDriver.syncDeclaredIndexesis atsql-driver.ts:12464on that commit, not:9812. Nothing else in the premise moved.bd207df81), was 7 failed / 7; the remote face held onlysqlite_autoindex_sys_notification_delivery_1and planned the claim query asSCAN sys_notification_delivery+USE TEMP B-TREE FOR ORDER BY.What changes
Only
packages/drivers/driver-turso(plus a changeset).driver-sqlis not modified — the remote face imports the already-exportednormalizeDeclaredIndexbesideuniqueIndexesFromFields/organizationKeyPartSql.buildDeclaredIndexDDL(replacesbuildUniqueIndexDDL): field-leveluniqueviauniqueIndexesFromFieldsplus each declared entry vianormalizeDeclaredIndex— the same two normalizersSqlDriver.syncDeclaredIndexesandexpectedIndexescompose. Names come frombuildIndexName(or the author'sname); keys include the NULL-safeCOALESCE(organization_id, '__global__')part forunique: 'organization'. No naming or keying rule is re-implemented. EmitsCREATE [UNIQUE] INDEX IF NOT EXISTS. Unmaterialized columns (virtualformula) are skipped with a diagnostic, as locally.CREATE TABLEbatch — no extra round trip.SELECT name FROM sqlite_master WHERE type='index'. Only indexes whose name is absent are issued, in onewritebatch kept outside the main DDL batch (the blast-radius separation of the field-level unique retrofit is preserved). No row is written.retrofitDeclaredIndexes(replacessyncUniqueIndexes): batch → on failure, per statement → each failure goes to the durability sink (logger.error) naming index, table, DDL and the database's own cause. A unique index over existing duplicates is not forced and no row is repaired. A plain index that fails is reported the same way, with its consequence (full scans).syncSchema(single-object path) behaves the same.IndexSchema.nameis any string and the local face quotes whatever it is given; holding it toSAFE_IDENTIFIERwould have failed the remote face's whole schema sync — every object in the boot — over a declaration the other face accepts. An index name is one name, never a reference: the classaliasIdentifierSqlalready escapes. Key columns stay gated. Corpus at3ef96b471: 85 files declareindexes: [, 0 author-given names are non-identifiers, so nothing in-tree changes behaviour.Acceptance evidence
All pins live in
packages/drivers/driver-turso/src/turso-local-remote-declared-index-parity.test.ts. Local face =new TursoDriver({ url: ':memory:' })(theSqlDriverpath). Remote face =new TursoDriver({ url: 'libsql://…', client })with the real@libsql/clientoverfile::memory:— not the better-sqlite3 stub, because a libsqlwritebatch is transactional and the failure disposition depends on it (measured: one unique failure in a write batch rolls back a plainCREATE INDEXin the same batch).Fixtures:
sys_notification_deliveryandsys_job_queueshapes (fields +indexesverbatim fromservice-messaging/platform-objects), andos17609_scoped— tenant column, field-leveluniquescoped by it,unique: 'organization',unique: 'global', two author-named indexes (one containing a space and a double quote), and an index over aformulafield.1 — every declared index, same name and key; new tables in batch; retrofit
IF NOT EXISTS, zero data changea fresh database carries the same index set — names, uniqueness, origin and key parts: per table,PRAGMA index_list+PRAGMA index_xinfo+ the stored COALESCE text, local vs remote, deep-equal — anchored tobuildIndexName-computed sets so two faces that both lost every index cannot pass:sys_notification_delivery(6, the card's local count):idx_sys_notification_delivery_ad1bb657,idx_sys_notification_delivery_b3081700,idx_sys_notification_delivery_notification_id,idx_sys_notification_delivery_status_claimed_at,uniq_sys_notification_delivery_f5f71821,sqlite_autoindex_sys_notification_delivery_1sys_job_queue(4, the card's local count):idx_sys_job_queue_queue_status_scheduled_for,idx_sys_job_queue_idempotency_key_queue,idx_sys_job_queue_status+sqlite_autoindex_sys_job_queue_1os17609_scoped:uniq_os17609_scoped_organization_id_code(field-level, NULL-safe),uniq_os17609_scoped_organization_id_slug(organization, NULL-safe),uniq_os17609_scoped_external_id(global, verbatim),idx_os17609_scoped_region_slug+os17609_scoped_by_region,os17609 scoped-by "slug",sqlite_autoindex_os17609_scoped_1; theformulaindex is absent on both facesthe single-object syncSchema path lands the same set as the batch pathretrofits every declared index onto tables that already exist — and changes no row: tables provisioned without object-level indexes and holding rows; the nextinitObjectsleaves every row byte-identical andtotal_changes()unchanged, every retrofit statement isIF NOT EXISTS, and the resulting shapes equal the local face's.2 — failures are not silent
an object-level UNIQUE over existing duplicates: not created, named on the durability channel, no row repaired: two rows share(notification_id, recipient_id, channel).initObjectsresolves; the dedup index is absent while the four plain indexes land (the transactional batch rolled them back — the per-statement fallback lands them); exactly onelogger.errorreport names the index and table and carriesUNIQUE constraint failed; nothing onwarn; both duplicate rows remain.a PLAIN index the server refuses: named on the durability channel with its cause; the rest still land: an injectedSQLITE_FULLfor the claim index → exactly onelogger.errorreport naming it with the cause; the other four declared indexes land.3 — parity test
The suite above: 7 pins, both faces,
sqlite_mastercompared, covering both hot-table shapes.4 — boot cost
steady state costs zero index DDL — one index-name read, riding a round trip the sync already pays: a secondinitObjectsover existing indexes sends 0 index DDL statements.syncSchemasBatchalone, measured exactly: 2 round trips (batch,batch), 2N + 1 statements (N = 3 objects → 7), independent of how many indexes are declared (14 here). The index-name read is the last statement of the batch that already carries the NPRAGMA table_infoprobes.CREATE UNIQUE INDEX IF NOT EXISTS); the red base run shows that statement re-sent foruniq_os17609_scoped_organization_id_code. Applied to the card's tenant A shape (100 tables, 4 field-level uniques) that formula gives 204 statements / 3 round trips before and 201 / 2 after — arithmetic from the measured formula, not a production reading.5 —
EXPLAIN QUERY PLANon the remote facePinned in
serves the delivery claim query from the declared index — the same plan on both faces(assertsSEARCH … USING INDEX, noSCAN, plan text equal to local). Captured from the built package, real@libsql/clientoverfile::memory:, one database across both steps:Reverse verification
Directions predicted before each run. The suite imports
./turso-driver.jsfromsrc, so the mutatedsrc/remote-transport.tsis what ran (the local face's@objectstack/driver-sqlresolves to its built dist, which no leg touched). Each leg: marker counted on disk before the run; restore bygit checkout HEAD --, proven by the blob hash equalling HEAD's and an emptygit diff HEAD.bd207df81): 7 failed / 7. Fix (6da2d2684): 7 passed.6241e6e51, three ablations ofsrc/remote-transport.ts:missingIndexesreturns every planned index (no existing-name skip). Predicted 1 failed | 6 passed, only the steady-state pin. Observed 1 failed | 6 passed:steady state costs zero index DDL….os17609_scoped, whose quote-bearing index name breaks the raw statement, and none of the three that sync onlysys_notification_delivery. Observed 4 failed | 3 passed: fresh database,syncSchemapath, retrofit, steady state.90cf0ed7equals HEAD's;git diff HEADempty afterwards.Local verification (HEAD
6241e6e51)Exit codes were captured before any pipe; each reading quotes the gate's own verdict line.
Package —
@objectstack/driver-tursopnpm --filter @objectstack/driver-turso test— exit 0: 52 files, 1243 tests passed.pnpm --filter @objectstack/driver-turso typecheck— exit 0.tsc --listFilescounts all 52 test files, the new suite among them.pnpm --filter @objectstack/driver-turso build— exit 0:check-dts-emitted: @objectstack/driver-turso - 1/1 declared declaration file(s) present.3ef96b471before any edit:pnpm turbo run build --filter=@objectstack/driver-turso^... --concurrency=2— 7 successful, 0 cached.@objectstack/driver-sqlis not modified, so nodriver-sqltest / build / typecheck is owed.Lint — a declared narrowing, with its proof (the repo-wide
pnpm lintis CI's run)pnpm exec eslint --no-inline-config --format jsonover the 4 touched.tsfiles — exit 0. The JSON reports 4 files, 0 errors, 0 warnings, and no ignore notice on any of them, so all 4 are inside eslint's own lint population.eslint.config.mjsnever enables type-aware linting — it says so itself (noparserOptions.project, no typed@typescript-eslintrules) — so this diff cannot move a verdict in any untouched file. The fifth changed path is the changeset (Markdown, not linted).Derived gates —
node scripts/pm/dispatch-gates.mjs --commandson this tree derives 62 commands. Also run: the 4 roster gates it flags under this PR's own directories (check-changeset-fixed,check:authz-resolver,check:error-code-casing,check:filter-alias-parity) andcheck:durability-log-level.pnpm check:durability-log-level—35 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller.pnpm check:nul-bytes—OK (scanned 8367 text file(s) … no raw ASCII control bytes).pnpm check:cross-package-test-inputs—28 package(s) read outside themselves, all declared(the new suite reads nothing outside its package).pnpm check:test-source-alias,pnpm check:type-source-resolution,pnpm check:object-def-param-keys,pnpm check:driver-conformance,pnpm check:published-files— OK.pnpm check:type-check-coverage—76/80 workspace packages type-checked.node scripts/check-adr-0087-registration.mjs --base origin/main—this PR adds no declared-breaking changeset (1 non-breaking changeset(s) seen);node scripts/check-empty-changeset.mjs --base origin/main—No empty-frontmatter changeset introduced.node scripts/check-changeset-no-major.mjs --base origin/main— exit 0, but locally it printedno pull_request payload was available to read a declaration from: theClause-②limb reads this PR body, which only CI can hand it.pnpm check:lean-entry-closure— first run exit 3 (PREREQUISITE NOT MET: no builtobjectql); afterpnpm turbo run build --filter=@objectstack/objectql --concurrency=2(15 tasks, all cached) re-run exit 0:2 published condition(s) measured from a real load … Admitted set held exactly (15 packages).pnpm check:dts-closureandpnpm check:sourcemap-no-sources-content— exit 0 over the packages built in this worktree (driver-turso among them), not the whole repo.PREREQUISITE NOT MET— the gates' own words: neither a pass nor a failure):pnpm check:dual-build-cjs-loads— reads every package's built output; 78 packages have nodist/here. It needs a fullpnpm build, which this seat did not run beside another seat's build. CI builds first.pnpm check:type-check-debt—--re-measureneeds built type entry points for 24 workspace dependencies of the ledgered packages; same reason.driver-tursois not in that ledger.node scripts/pm/dispatch-gates.mjs --ranover a record carrying every command ascommand :: exit N—62 derived famil(ies) accounted for — 60 run, 2 NOT-MEASURED (2 DERIVED from a recorded exit 3), 0 unrun. Derived on this tree, 2 commits behindorigin/main0918c4411; the tool reports none of those commits touched what the derivation reads.dispatch-gatesalso names what no local command covers, all NOT MEASURED here and owned by CI: 5 families whose argv takes a workflow value, 5 path-scheduled CI jobs (Test Core, Temporal Conformance, Dogfood Regression Gate, Dogfood Verify CLI, Build Core), 11 wide-population families and 45 roster families.Lock: every
scripts/pm/os-verify-lock.shcall endedVERDICT … UNLOCKED (declared)— a macOS host with noflock— so the shared verify lock was never taken and nothing was serialized.Acceptance notes
os-verify-lock.shran in declared UNLOCKED mode on every locked command: the host is macOS with noflock, so the shared verify lock was never taken and nothing was serialized.driver-sqlis untouched, so nodriver-sqltest/build/typecheck is owed; its built dist (from the closure build) is what the local face ran.driver-tursodoes not depend onservice-messagingorplatform-objects. If those objects'indexeschange, this suite keeps pinning driver parity, not those objects' current declarations.emaildelivery for a tenant with no email transport — it dead-letters on its first attempt and is kept 90d, so the hot outbox grows ~1 dead row per notification #17611 remain open; this PR only makes each claim statement an index lookup.Generated by Claude Code