Skip to content

Commit f1cd529

Browse files
refactor(driver-sql): move the data-lifecycle rotation sub-banner's movable members out of sql-driver.ts into sql-driver-schema-sync.ts (#25521 cut 17) (#26808)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XHoLU3ToFTmB9h8qjF9hUi5)_ Part of #25521 Clause-②: no Metadata-protocol: none Ruling-ref: 6270097840 Ruling-ref: 6348891160 ## 维护者速读 - **改了什么**:把 `sql-driver.ts` 里「数据生命周期轮转」小节中能搬的五个方法体(轮转器 `ensureRotation` 及它调用的分片键 `rotationShardKey`、视图列清单 `rotationColumnList`,三条建表路径共用的内置列冲突告警 `warnBuiltinColumnCollisions`,联邦外部对象的免 DDL 注册 `registerExternalObject`)原样搬进已有的 `sql-driver-schema-sync.ts`;类上五个方法全部保留,只把方法体转交过去,`SchemaSyncHost` 按需增补成员。 - **为什么改**:#25521 拆分超大文件的第 17 刀;重新测量后这是收益最大且不碰任何系列限制的区域,`sql-driver.ts` 减少 125 行。 - **风险与回滚**:纯搬移,无行为变化;三个 SQL 驱动的测试两端逐条一致,公开类型声明哈希不变,121 条探针读数两端一致(仅 8 条栈顶接收者名不同)。回滚即还原本 PR。 - **先看**:`sql-driver-schema-sync.ts` 头注释新增的第 17 刀段落与 `SchemaSyncHost` 新增成员;`sql-driver.ts` 中五个方法的转交桩;`sql-driver.ts` 顶部两处 import 的改动。 - **两分钟验证**:`pnpm --filter @objectstack/driver-sql exec vitest run src/sql-driver-rotation.test.ts src/sql-driver-ddl-gate.test.ts src/sql-driver-external-columnmap.test.ts src/sql-driver-shard-tenancy-opt-out.test.ts`,预期全部通过。 - **需要你做的**:无,席位落地。 ## What changes Cut 17 of the `sql-driver.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6446054910`. The cut opens with a fresh seam table (below), taken on `origin/main` `785a7c3b3e`, and takes the area cut 16 priced next and that still pays best without breaking a series limit: the **movable members of the data-lifecycle rotation sub-banner** (`Schema Sync › Data-lifecycle rotation`). Their bodies move unedited into the **existing** seam module `packages/drivers/driver-sql/src/sql-driver-schema-sync.ts`, the module cut 12 already moved six members of this same sub-banner into, through its host `SchemaSyncHost`: - `ensureRotation`, the rotator: it inventories a rotation-managed table's shards, adopts a legacy base table as the current shard (and recovers when both exist), column-syncs the retained shards, drops the expired ones and rebuilds the `UNION ALL` read view; - `rotationShardKey` and `rotationColumnList`, which it calls (the shard key of an instant, and the view's column list); - `warnBuiltinColumnCollisions`, the #12015 warning the create, alter and shard paths share when a declared field collides with a builtin column; - `registerExternalObject`, the DDL-free registration of a federated object. No module-level declaration moves: none is read by these bodies alone. `declaredValueShapes` is also read by `registerManagedObjectMetadata`, which stays, so it gains a leading `export` in place and the module imports it. The three names of `./builtin-column-collision.js` (`undeliveredStorageAttributes`, `formatAttribute`, `type UndeliveredAttribute`) lose their last value reader in `sql-driver.ts`; the import there shrinks to `type undeliveredStorageAttributes`, which the staying docblock of `warnBuiltinColumnCollisions` links (a link-only import is a type import, erased from the build). The change is move-only, and the card stays open for later cuts. There is no `docs/adr/**` edit, no `packages/spec/**` edit, no anchor entry change, no gate edit, no test edit and no new gate. Branch `claude/project-thread-0gietk` (the platform assigned it to this card thread, as the claim records). The move is one commit, `6b6ca4b01`, on `785a7c3b3e`, followed by one merge of `origin/main` (`83c4bd29f7`); the head is `3dcd74655b`. **Net line count (the seat's rule).** `sql-driver.ts` goes from 18,646 lines to 18,521: **-125**. - **Out:** the five bodies, 192 lines (87 + 67 + 28 + 7 + 3), and 4 import lines (the three-name `builtin-column-collision.js` import becomes one line). - **In:** the five stub bodies, 68 lines (44 + 14 + 6 + 2 + 2), and 3 comment lines above the existing `sql-driver-schema-sync.js` import, whose one line gains the five names. - `sql-driver-schema-sync.ts` goes from 1,653 to 1,914 lines (+261): the five functions, 20 new `SchemaSyncHost` members, three imports and a header paragraph. **Path readers.** No test, script, doc, ADR or anchor entry reads any of the five bodies by path, so none is migrated (screen below). **Serial with the file's in-flight PRs.** I read the file list of every open PR on `objectstack-ai/objectstack` at the start (27) and again before opening this one (26). None touches `sql-driver.ts`, `sql-driver-schema-sync.ts`, `turso-driver.ts`, `sqlite-wasm-driver.ts`, `check-tenant-chokepoint.mjs`, `check-object-def-param-keys.mjs` or a driver-sql anchor entry. #26556 (dependabot) touches only `driver-sql/package.json`. ## Seam table, fresh, on `785a7c3b3e` - **Size and churn.** `sql-driver.ts` is 18,646 lines. The 30-day column counts `git blame` lines from commits on or after 2026-09-27T15:00:18Z, 30 days before the base commit. `scripts/pm/git-history.mjs count` proves 76 commits on the file in 30 days and 31 in the last 7. In all, 3,323 lines (17.8%) are from the last 30 days. - **Class shape.** `SqlDriver` spans lines 4244-18571 with 350 members: 243 instance methods, 13 accessors and 32 statics, all 32 private or protected. - **How it was measured.** The same TypeScript compiler AST script cuts 9-16 used, which strips casts, parentheses, `!` and `satisfies` before it reads `this`. Sections are the class's own banner comments. | Lines | Section | First … last member | Lines | 30-day | Methods (public) | Inferred public | getBuilder sites | Statics (private/protected) | |:--|:--|:--|--:|--:|:--|--:|--:|:--| | 1-4243 | Module preamble | - | 4,243 | 688 | - | - | - | - | | 4244-5835 | Class head: fields, statics, constructor, capability getters | `name` … `assertDialectEmits` | 1,592 | 290 | 8 (1) | 1 | 0 | 18 (18) | | 5836-6267 | Lifecycle | `connect` … `disconnect` | 432 | 155 | 8 (3) | 0 | 0 | 6 (6) | | 6268-8674 | CRUD — IDataDriver core | `find` … `delete` | 2,407 | 745 | 43 (7) | 0 | 12 | 0 (0) | | 8675-9204 | Bulk & Batch Operations | `bulkCreate` … `count` | 530 | 70 | 9 (6) | 0 | 7 | 0 (0) | | 9205-9301 | Raw Execution | `rawStatementFault` … `noteRuntimeIndexDdl` | 97 | 28 | 3 (1) | 0 | 0 | 0 (0) | | 9302-9394 | Transactions | `beginTransaction` … `rollbackTransaction` | 93 | 46 | 7 (5) | 0 | 0 | 0 (0) | | 9395-9891 | Aggregation | `aggregate` … `unresolvableAggregateColumnRefusal` | 497 | 70 | 5 (1) | 0 | 1 | 0 (0) | | 9892-10153 | Distinct | `distinct` … `unresolvableDistinctColumnRefusal` | 262 | 26 | 3 (1) | 0 | 1 | 0 (0) | | 10154-10269 | Window Functions | `findWithWindowFunctions` … `findWithWindowFunctions` | 116 | 4 | 1 (1) | 0 | 1 | 0 (0) | | 10270-10368 | Query Plan Analysis | `explain` … `analyzeQuery` | 99 | 1 | 2 (2) | 0 | 1 | 0 (0) | | 10369-10427 | Schema Sync (syncSchema / init) | `syncSchema` … `reclaimSpace` | 59 | 34 | 3 (3) | 0 | 0 | 0 (0) | | 10428-11976 | Schema Sync (syncSchema / init) › Data-lifecycle rotation (ADR-0057 P2) | `rotationStateByTable` … `migrateMysqlTimeColumns` | 1,549 | 158 | 26 (4) | 0 | 0 | 0 (0) | | 11977-12233 | Schema Sync (syncSchema / init) › Deferred schema DDL (#3917) | `setDeferredDdl` … `flushDeferredSchemaDdl` | 257 | 25 | 6 (4) | 0 | 0 | 0 (0) | | 12234-13048 | Schema Sync (syncSchema / init) › Managed-schema drift & reconcile (#2186) | `dialectName` … `createNullSafeUniqueIndex` | 815 | 237 | 23 (3) | 0 | 0 | 0 (0) | | 13049-13100 | Schema Introspection | `listPhysicalColumns` … `introspectSchema` | 52 | 46 | 2 (2) | 0 | 0 | 0 (0) | | 13101-13371 | Internal helpers | `getKnex` … `auditMissingTenant` | 271 | 73 | 8 (1) | 0 | 1 | 0 (0) | | 13372-15421 | Internal helpers › Filter helpers | `tableNameForBuilder` … `applyFalseConstant` | 2,050 | 331 | 44 (2) | 0 | 0 | 0 (0) | | 15422-15597 | Internal helpers › Field mapping | `mapSortField` … `mapAggregateFunc` | 176 | 2 | 6 (0) | 0 | 0 | 0 (0) | | 15598-15645 | Internal helpers › Window function builder | `buildWindowFunction` … `buildWindowFunction` | 48 | 26 | 1 (0) | 0 | 0 | 0 (0) | | 15646-17592 | Internal helpers › Column creation helper | `nowColumnDefault` … `applyDeclaredColumnDefault` | 1,947 | 92 | 19 (0) | 0 | 0 | 8 (8) | | 17593-17890 | Internal helpers › Database helpers | `sqliteFilename` … `isJsonField` | 298 | 76 | 9 (2) | 0 | 0 | 0 (0) | | 17891-18367 | Internal helpers › SQLite serialisation | `formatInput` … `formatOutput` | 477 | 27 | 2 (0) | 0 | 0 | 0 (0) | | 18368-18571 | Internal helpers › Introspection internals | `introspectColumnOrder` … `introspectUniqueConstraints` | 204 | 73 | 5 (0) | 0 | 0 | 0 (0) | | 18572-18646 | After the class | - | 75 | 0 | - | - | - | - | ### Pricing the areas Eligibility is as cuts 11-16 defined it: an annotated return type or not public; no `getBuilder`, `this.FIELD.delete/insert/update(`, private-static, `super`, bare-`this` or `this`-write site; not `insertOnlyUpsertColumns`; not a member pinned in place (`createColumn`, `applyFilterCondition`, `formatInput`/`formatOutput`, `initObjects`, `ensureShardTable`, `computeTenantField`, `migrateMysqlDatetimeColumns`/`migrateMysqlTimeColumns`, `compileFilters`). Each area is priced as its stubs minus its bodies, then net of the module-level declarations only it reads (they would move along) and of its import edits. | Area | Bodies | Bodies net | Declarations and imports | Disposition | |:--|--:|--:|--:|:--| | **Rotation sub-banner** (`ensureRotation` -53, `registerExternalObject` -43, `warnBuiltinColumnCollisions` -22, `rotationShardKey` -5, `rotationColumnList` -1) | 5 | -124 | 0 declarations move; -4 for the collision import, +3 for the import comment | **Moves (this cut).** -125 measured. | | Deferred-DDL previews (`previewDeferredSchemaWork` -32, `previewDatetimeConvergence` -22, `previewTimeConvergence` -22) | 3 | -76 | 0 (`PendingSchemaWork`, `fieldHasColumn` and the two `SQLITE_*` constants are shared) | Not taken: smaller. Priced as the next slice. | | Filter helpers remainder (14 small members, largest `presentReadValue` -29) | 14 | -80 | 0 | Not taken: fourteen members of four mechanisms (presentation, withheld-filter log, canonical SQL, column expression), not one area; `compileFilters` (-75) is refused (inferred return type). | | Class head (`buildDateBucketExpr` -42, `installQueryTiming` -20) | 2 | -62 | 0 | Not taken: two unrelated mechanisms, and smaller. | | Column creation helper remainder (`nowColumnDefault` -39 and four small members) | 5 | -62 | 0 | Not taken: smaller; the rest of that sub-banner reads private statics. | ## Why ### Why this area, as one area - **One banner.** The five are the members of the class's own `Data-lifecycle rotation` sub-banner that can move. Cut 12 moved six other members of the same sub-banner (the SQLite backfills and the MySQL widening's reads and runner) into `sql-driver-schema-sync.ts`, so this cut puts them beside those, behind the host that already exists, instead of opening a new module. - **It pays the most of any area that moves cleanly**, about 1.6 times the next one. - **What stays, and why.** - `rotateShards` (its stub would cost as many lines as its body), `aliasShardBookkeeping` (its stub would cost more than its body), `ensureShardTable` (named by `check:object-def-param-keys`), `computeTenantField` (named by `packages/spec/liveness/object.json`), `computeAndRecordTenantField` and `registerManagedObjectMetadata` (each has a `this.FIELD.delete(` site, the tenant-audit series limit), `initObjects` (pinned), and `migrateMysqlDatetimeColumns`/`migrateMysqlTimeColumns` (each catch around a vocabulary callee reports through `logDurabilityFailure`, which `check:durability-log-level` resolves only within one file). - The accessors and the three one-line readers of `rotationStateByTable` stay; each stub would cost more than its body. - **No series limit is touched.** None of the five builds through `getBuilder`, reads a static, has a `this.FIELD.delete/insert/update(` site, uses `super` or a bare `this`, or assigns to `this`. `registerExternalObject` is public with an annotated `void` return; the other four are protected with annotated returns. `check:tenant-chokepoint`'s D8 predicates, measured with its own `nullArmsOf`: **4 at base and 4 at head**, all in `sql-driver.ts`, 0 in the moved bodies (`registerExternalObject` names `tenantField` four times, as a value it stores, never as a predicate). - **Durability gate.** The moved bodies have no `catch` clause. `check:durability-log-level --list` gives the same seams at both ends, line numbers aside (164 lines of output at each end). - **Logger.** `ensureRotation` and `warnBuiltinColumnCollisions` and `registerExternalObject` write to the logger through `this.logger`, which the host passes as a getter, so each call keeps its receiver. `check:logger-receiver-detach` is green (below). - **Subclasses.** `TursoDriver` and `SqliteWasmDriver` extend `SqlDriver` and override none of the five (`TursoDriver` calls the inherited `registerExternalObject`). Every stub keeps its signature and modifier, and every member a body calls is a late-bound closure on the instance (probe below). - **Path readers, screened.** No script, test, doc or ADR names any of the five by a `sql-driver.ts#…` pointer or a `sql-driver.ts::…` key, and no `scripts/adr-anchors/**` record names them. `check-object-def-param-keys.mjs` cites `#ensureRotation` as a symbol anchor in prose; the method keeps its name and signature in `sql-driver.ts`, and the gate's output is byte-identical at both ends. The tests that read `sql-driver.ts` as text look for `createColumn`, the width constants, the INVALID_FILTER builders and log-call shapes, none of which moved. Prose mentions elsewhere (`turso-driver.ts`, `remote-transport.ts`, ADR-0015 and ADR-0062, the objectql federation tests) name `SqlDriver.registerExternalObject`, which is still true. ## The shape (#23050 shape B, as cuts 1-16 apply it) - Each class method keeps its docblock and signature, byte-identical. Its body is now one host literal plus `NAME.call(host, ...)`, marked `[#25521 cut 17]`. `rotationShardKey` and `rotationColumnList` read no member of the driver, so their methods call the function directly. `ensureRotation` loses `async` and returns the moved function's own promise, as cut 12's methods do. - The fields the bodies read (`knex`, `logger`, `rotationStateByTable`, `isSqlite` and the seventeen per-object bookkeeping maps) go as GETTERS that read the driver at call time; the methods (`ensureShardTable`, `aliasShardBookkeeping`, `rotationShardKey`, `rotationColumnList`, `computeAndRecordTenantField`, `isJsonField`) go as closures that call the method on the driver at call time. So a subclass override, an instance spy, or a field replaced after construction is still the one the body reaches. - `SchemaSyncHost` grows by exactly the 20 members these bodies read that it did not list yet, each typed as the class member's indexed access, in the interface's sorted order (54 to 74 members). There is no cast, and no member becomes public. - **Module imports added.** `aggregandColumnClass` (`@objectstack/core`); `FILE_REFERENCE_TYPES`, `NUMERIC_VALUE_TYPES`, `parseAutonumberFormat`, `resolveAutonumberFormat`, `type AutonumberToken` (`@objectstack/spec/data`); `formatAttribute`, `undeliveredStorageAttributes`, `type UndeliveredAttribute` (`./builtin-column-collision.js`); and `declaredValueShapes`, `NUMERIC_SCALAR_TYPES` added to the existing `./sql-driver.js` import. The module reads them only inside function bodies, so the import cycle stays evaluation-safe. `index.ts` names its exports one by one; the export lists below prove none of them leaks. - **Comments.** The module header gains one `[#25521 cut 17]` paragraph saying what moved, what stayed and where each read declaration comes from. The `sql-driver.ts` import comment names cut 17 in 3 lines. Neither cites an `ADR-NNNN` letter. The line comments inside `registerExternalObject`'s body that link `{@link mediaFields}` and its siblings now name `SchemaSyncHost` members of the same names. - **How the move was made.** A script produced it from the source text; nothing was retyped. Each body line is re-indented two columns; no body line starts inside a template literal. `registerExternalObject`'s one multi-line parameter type is re-indented the same way. ## Verification ### ADR anchors - **The count.** `check:adr-anchors` counts **1898** decision-letter citations at base `785a7c3b3e` (a clean detached worktree) and **1898** at head, over 91 anchored files at both ends. Both runs exit 0. - **Why equal.** No comment inside the moved bodies or the new header paragraph cites a decision letter. The stubs keep their docblocks, so every `ADR-…` mention in them stays in `sql-driver.ts`. - **No entry needs a new home.** No anchor entry names a moved member. `check:adr-symbol-anchors`: 2687 resolve at both ends. - **After the merge.** `main` moved under the cut, so I measured its tip `83c4bd29f7` against the merge head `3dcd74655b`: **1898** letters at both (91 anchored files, exit 0), and 2687 symbol anchors resolve at both. ### The published `.d.ts` does not change This is measured under the series bar (ruling `6348891160`, item 1). - **How each end was built.** `pnpm turbo run build --filter=@objectstack/driver-sql --force` at each end, against the same dependency tree. The base leg writes base `sql-driver.ts` and base `sql-driver-schema-sync.ts` into this worktree under an EXIT/INT/TERM trap that restores HEAD. The restore is proved by the HEAD blob hashes, an empty `git diff HEAD` and 0 status entries. - **The hashes.** The raw sha256 of `dist/index.d.ts` and `dist/index.d.mts` is the same at base and at head, `0f33bf3febaac3c7aa96f68a4226d752766b8203ab7acade523b69f9aaa44d74` (unchanged since cut 8), on the move commit and again on the merge head. These two are the dist's only declaration files (no chunk), so nothing was re-ordered and the declaration-reorder bar of ruling `6441032442` does not come into play. - **Positive control.** I dropped `protected` from `ensureRotation` in the head `index.d.ts`. The result hashes to `7d72d5f631e8e134fe0cfc67e9fcaa78bc3cddd2b5702c2754502ec0bcff8987`, with `cmp` exit 1. - **Export lists.** CJS `require` of each dist gives 59 names (sha1 `05757ce5ae` at both ends); the ESM export statement of the base and head `index.mjs` lists the same 59 names (sha1 `05757ce5ae` both). None of the five moved functions, `declaredValueShapes` or `SchemaSyncHost` is among them. - **The head dist carries the move.** `.call(host` sites in `index.mjs` go from 61 at base to 64 at head (three host stubs; the two direct-call stubs have none). ### Move-only proof The proof compares TypeScript compiler AST leaf tokens (JSDoc nodes skipped), base `785a7c3b3e` against head `6b6ca4b01`. It is a scratch script and is not committed. | Moved body | Body tokens | Comments | Parameter tokens less `this` | Return type tokens | Body lines | |:--|--:|--:|--:|--:|--:| | `registerExternalObject` | 759 | 16 | 48 | 1 | 87 | | `ensureRotation` | 557 | 14 | 57 | 24 | 67 | | `warnBuiltinColumnCollisions` | 179 | 1 | 26 | 3 | 28 | | `rotationShardKey` | 99 | 1 | 10 | 1 | 7 | | `rotationColumnList` | 69 | 0 | 13 | 1 | 3 | - **Each moved body.** In every row, 0 tokens differ and every comment is identical. Each body line is the base line less two leading spaces. `ensureRotation` is `async` at both ends; the other four are not. - **The five stubs.** Leading trivia (the docblock), modifiers and the signature are identical to base (`ensureRotation` less `async`). - **The class.** It has 350 members at both ends, in the same order, and the 345 unmoved members are byte-identical, trivia included. - **Top level of `sql-driver.ts`.** 176 of 180 base statements are byte-identical; the other four are the class, `declaredValueShapes` (it differs from base by a leading `export` and nothing else, checked), and the two edited imports. - **The module.** Its 34 base statements are all in the head module byte-identical except three: its first import (whose leading trivia is the header, which gained the cut 17 paragraph), the `./sql-driver.js` import (two names added) and `SchemaSyncHost` (20 members added). The head adds three imports and the five functions. - **Result.** `PROOF OK`, exit 0. - **Negative control.** Through `scripts/ablation-replace.mjs`, I changed `week: 7 * 86_400_000` to `week: 8 * 86_400_000` in the module's `ensureRotation` (1 hit, checked on disk). The proof then reports `ensureRotation` as differing, prints `PROOF FAILED`, and exits 1. The restore was proved (blob equals HEAD, empty `git diff HEAD`). ### Tests Each end ran under one `os-verify-lock` hold, after building that end's driver-sql dist. I compared the vitest JSON per test name and status, base `785a7c3b3e` against head `6b6ca4b01`, and then again on the merge head `3dcd74655b`. Every part's exit code was captured on its own (build, probe, three suites: all 0 at every leg). | Suite | Base | Head | Lost / changed / added | |:--|:--|:--|:--| | `@objectstack/driver-sql` | 4061 (3847 passed, 214 skipped; 248 files) | same | 0 / 0 / 0 | | `@objectstack/driver-turso`, against that end's built driver-sql dist | 2526 (2492 passed, 34 skipped; 95 files) | same | 0 / 0 / 0 | | `@objectstack/driver-sqlite-wasm` | 746 (746 passed; 39 files) | same | 0 / 0 / 0 | On the merge head (it brings in `main` up to `83c4bd29f7`: 14 commits, none in `packages/drivers/**`), the three suites give the same totals and the same per-test statuses as base, 0 / 0 / 0 in each. - **Type check and lint.** `tsc --noEmit -p .` in driver-sql exits 0 on the move commit and on the merge head. `pnpm exec eslint --no-inline-config packages/drivers/driver-sql` exits 0. - **Late-binding probe.** A scratch script, not committed. It runs node against each end's built dist and prints 121 readings, each with the result or the error's message, `code`, `status`, own keys and top stack frame, the tables `sqlite_master` holds, the bookkeeping maps written, and the log lines: 1. `rotationShardKey` over `day`, `week`, `month` and an unknown unit, across Sunday/Monday, month, year and leap-day edges, and a `NaN` instant; 2. `rotationColumnList` with no fields, builtin names, an odd column name, and `null`; 3. `warnBuiltinColumnCollisions` over the three phases and an unknown one, with no fields, a presentation-only collision, undelivered storage attributes and a `null` declaration; 4. `ensureRotation` through `rotateShards` on SQLite across seven sweeps (new shards, the window, drops, rows written and counted through the view), weekly and monthly policies, the legacy base-table adoption, the partial-failure recovery (base table and current shard both present), a direct call, and a builtin-collision field on the shard path; 5. `registerExternalObject` on SQLite, Postgres and MySQL drivers: every field class, `remoteName`/`remoteSchema` (warned and ignored on SQLite, stored elsewhere), a `columnMap` with a non-string entry, autonumber formats, a tenancy opt-out and its sticky re-registration, a declared tenant field, `sys_organization`, and bad input; 6. a SUBCLASS overriding `rotationShardKey`, `rotationColumnList`, `ensureShardTable`, `aliasShardBookkeeping`, `computeAndRecordTenantField`, `isJsonField` and `warnBuiltinColumnCollisions`; instance spies on six of them; the logger, four bookkeeping maps and `rotationStateByTable` replaced after construction, and `knex` behind a getter (the new ones are the ones reached); 7. bad arguments. - **Probe result.** 113 of the 121 readings are identical, results, tables, maps and log lines included. The other 8 differ only in the top stack frame's receiver name, where bad input throws inside a moved body: `_SqlDriver.X` at base and `Object.X` (host stubs) or `X` (direct-call stubs) at head. That is shape B's receiver, the same at every earlier cut, and no test or consumer reads it. One reading depends on the calendar day (the boot shard `initObjects` creates on the real clock is dropped by the first pinned sweep); every leg ran on 2026-10-27. On the merge head the probe output is byte-identical to the move commit's. - **Not run.** No ablation, because no tested behaviour changed. - **`test:repo`.** Not run locally; it is CI's run on this PR. ### Gates `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derives **48** families for this diff, the same list on the move commit and on the merge head. I ran each one on its own and captured its exit code before any pipe. - **Merge head `3dcd74655b`: 48 run, all 48 exit 0.** `--ran` reconciles: `48 derived famil(ies) accounted for — 48 run, 0 NOT-MEASURED`, exit 0. - **Move commit `6b6ca4b01`:** 46 exited 0 on the first pass. Two exited 3, a missing prerequisite and not a verdict: `node scripts/check-plugin-teardown-shape.mjs --self-test` could not read its pinned fixture commit in this shallow clone, and passed (48 cases) once that commit was fetched; `check:dual-build-cjs-loads` needed a full workspace build, and passed (112 require entry points across 69 packages load) after `pnpm build`. - **Hand gates on both heads, all exit 0:** `check-adr-anchors`, `check-adr-symbol-anchors`, `check-tenant-audit-census` (244 write call sites at both ends), `check-platform-checklist --changed-from origin/main`, `check-commit-card-trailers --range origin/main..HEAD`, `eslint --no-inline-config packages/drivers/driver-sql`, `check-durability-degradation-log-level --list` (same seams both ends). `check-object-def-param-keys` and `check-tenant-audit-census` print byte-identical output at base and head. - **No gate silently empty.** Each derived gate's log prints a non-zero population (for example `check:object-def-param-keys` compares 7 override parameter positions over 3362 files with every control firing; `check:tenant-chokepoint` reads the `getBuilder()` bindings of its scan files; `check:durability-log-level` reads 88 read seams). The 48 derived commands: `node scripts/check-closing-keyword-parity.mjs`, `node scripts/check-closing-keyword-parity.mjs --self-test`, `node scripts/check-comment-mask-adoption.mjs`, `node scripts/check-comment-mask-adoption.mjs --self-test`, `node scripts/check-comment-mask-corpus.mjs`, `node scripts/check-dts-emitted.mjs --self-test`, `node scripts/check-issue-citations.mjs`, `node scripts/check-plugin-teardown-shape.mjs`, `node scripts/check-plugin-teardown-shape.mjs --self-test`, `node scripts/ci/assert-turbo-remote-cache.mjs --self-test`, `node scripts/ci/plan-test-core-shards.mjs --self-test`, `node scripts/ci/prune-turbo-cache.mjs --self-test`, `node scripts/docs-audit/check-affected-docs.mjs`, `node scripts/docs-audit/check-drift-comment.mjs`, `pnpm --filter @objectstack/spec run check:duration-unit-keys`, `pnpm check:ci-filter-parity`, `pnpm check:closed-ceremony-text`, `pnpm check:cross-package-test-inputs`, `pnpm check:dispatcher-error-vocabulary`, `pnpm check:doc-authoring`, `pnpm check:driver-conformance`, `pnpm check:driver-memory-census`, `pnpm check:dts-closure`, `pnpm check:dual-build-cjs-loads`, `pnpm check:durability-log-level`, `pnpm check:error-status-conformance`, `pnpm check:gitlink-declared`, `pnpm check:issue-citations`, `pnpm check:keyed-text-bounds`, `pnpm check:lean-entry-closure`, `pnpm check:logger-receiver-detach`, `pnpm check:nul-bytes`, `pnpm check:object-def-param-keys`, `pnpm check:org-identifier`, `pnpm check:page-declaration-shape`, `pnpm check:platform-object-tenancy-census`, `pnpm check:published-files`, `pnpm check:refd-timer-probe`, `pnpm check:registry-log-declared`, `pnpm check:rest-log-spy-declared`, `pnpm check:slot-lookup`, `pnpm check:sourcemap-no-sources-content`, `pnpm check:system-context-census`, `pnpm check:tenant-chokepoint`, `pnpm check:test-source-alias`, `pnpm check:tier-file-adoption`, `pnpm check:undeclared-dep-imports`, `pnpm check:watch-hint-literal`. ## Acceptance notes - **`logger-receiver-detach.test.ts`'s structural pin reads `sql-driver.ts` only.** Three log calls moved to `sql-driver-schema-sync.ts` (as cut 12's did); the repo-wide `check:logger-receiver-detach` gate still reads them there and is green. Noted, not filed. - **`check:tenant-chokepoint`'s `SCAN_FILES` does not list `sql-driver-schema-sync.ts`.** The moved bodies hold 0 of the file's 4 tenant predicates and build no query. Noted, not filed: widening `SCAN_FILES` would be a gate change. - **Stale links from cut 15 are unchanged.** `sql-driver.ts` still has the same 30 `{@link X}` names that are neither a top-level binding nor a `SqlDriver` member at base and at head (the set is identical); this cut adds none, because `undeliveredStorageAttributes` stays linkable through its type import. - **Next slice, priced on this base.** The deferred-DDL previews (`previewDeferredSchemaWork`, `previewDatetimeConvergence`, `previewTimeConvergence`, about -76) are now the largest clean area. After them the series' remaining yield outside the pinned methods is small, scattered bodies of about -60 or less per area. ## Human Minimum - [x] 1. No `packages/spec` change. - [x] 2. No new name; `SchemaSyncHost` gains members only. - [x] 3. No formatter ran; scoped `eslint --no-inline-config` passes (repo-wide `pnpm lint` is CI's run). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. No publishable behaviour change: `skip-changeset`; both declaration lines above. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to generated docs, changelogs or release notes. - [x] 8. Title, body and comments are in English (the maintainer brief excepted). --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5o3WH4EyHws8N24jPx)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5o3WH4EyHws8N24jPx Co-authored-by: Claude <noreply@anthropic.com>
1 parent aeb9cf0 commit f1cd529

2 files changed

Lines changed: 337 additions & 201 deletions

File tree

‎packages/drivers/driver-sql/src/sql-driver-schema-sync.ts‎

Lines changed: 262 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,27 @@
8383
* bytes, indentation included, because they are the statement's own text.
8484
* `sql-driver.ts` no longer reads `recoverUnencodedJsonText`.
8585
*
86+
* [#25521 cut 17] The movable members of the class's data-lifecycle rotation
87+
* sub-banner joined them the same way: `ensureRotation`, which inventories a
88+
* rotation-managed table's shards, adopts a legacy base table, column-syncs
89+
* the retained shards, drops the expired ones and rebuilds the read view, with
90+
* `rotationShardKey` and `rotationColumnList`, which it calls;
91+
* `warnBuiltinColumnCollisions`, the warning the create, alter and shard paths
92+
* share when a declared field collides with a builtin column; and
93+
* `registerExternalObject`, the DDL-free registration of a federated object.
94+
* The class keeps all five methods, and `SchemaSyncHost` grew by exactly the
95+
* members these bodies read that it did not list yet; the fields among them
96+
* go as getters too. `rotationShardKey` and `rotationColumnList` read no member
97+
* of the driver, so their methods call them directly. `rotateShards`,
98+
* `ensureShardTable`, `aliasShardBookkeeping` and the two tenant-field
99+
* resolvers stay in the class. The module-level declarations these bodies read
100+
* are imported from where they are declared: `declaredValueShapes` (which now
101+
* carries `export`), `NUMERIC_SCALAR_TYPES` and `isMultiValuedColumn` from
102+
* `sql-driver.ts`, the attribute check and its formatter from
103+
* `builtin-column-collision.ts`, and the field-type sets and autonumber
104+
* parsers from `@objectstack/spec/data` and `@objectstack/core`.
105+
* `sql-driver.ts` no longer reads `formatAttribute` or `UndeliveredAttribute`.
106+
*
86107
* A MOVE, not a redesign. Each function below is the body of the `SqlDriver`
87108
* method of the same name as it stood, re-indented one level and otherwise
88109
* unedited: the same dialect statements, the same rebuild procedure, the same
@@ -118,7 +139,10 @@ import { carriesNullSafeTenantKeyPart, ceremonyOwnedDropFinding, describeMissing
118139
import { isJsonColumnType, mediaColumnMoveDialect, mediaColumnMovePlan, type MediaColumnMovePlan, type MediaColumnMoveRefusal, type MediaColumnMoveScan } from './media-column-move.js';
119140
import { StandardErrorCode } from '@objectstack/spec/api';
120141
import { recoverUnencodedJsonText } from './unencoded-json-text.js';
121-
import { AUDIT_TIMESTAMP_COLUMNS, type IntrospectedColumn, isMultiValuedColumn, LOCK_WAIT_REFUSAL, type SqlDriver, SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS } from './sql-driver.js';
142+
import { aggregandColumnClass } from '@objectstack/core';
143+
import { FILE_REFERENCE_TYPES, NUMERIC_VALUE_TYPES, parseAutonumberFormat, resolveAutonumberFormat, type AutonumberToken } from '@objectstack/spec/data';
144+
import { formatAttribute, undeliveredStorageAttributes, type UndeliveredAttribute } from './builtin-column-collision.js';
145+
import { AUDIT_TIMESTAMP_COLUMNS, declaredValueShapes, type IntrospectedColumn, isMultiValuedColumn, LOCK_WAIT_REFUSAL, NUMERIC_SCALAR_TYPES, type SqlDriver, SQLITE_NON_TEMPORAL_TEXT_REFS, SQLITE_TIME_EXPR_REFS } from './sql-driver.js';
122146

123147
/**
124148
* The `SqlDriver` members the functions below read — every one of them, and
@@ -129,33 +153,43 @@ import { AUDIT_TIMESTAMP_COLUMNS, type IntrospectedColumn, isMultiValuedColumn,
129153
* for which members are getters.
130154
*/
131155
export interface SchemaSyncHost {
156+
aliasShardBookkeeping: SqlDriver['aliasShardBookkeeping'];
132157
applyDeclaredColumnDefault: SqlDriver['applyDeclaredColumnDefault'];
133158
applyDriftOpInPlace: SqlDriver['applyDriftOpInPlace'];
134159
applyIndexDriftOp: SqlDriver['applyIndexDriftOp'];
135160
applyMigrationEntries: SqlDriver['applyMigrationEntries'];
136161
applyNullSafeUniquePreflight: SqlDriver['applyNullSafeUniquePreflight'];
137162
assertSchemaMutable: SqlDriver['assertSchemaMutable'];
138163
readonly autoMigrate: SqlDriver['autoMigrate'];
164+
readonly autoNumberFields: SqlDriver['autoNumberFields'];
165+
readonly booleanFields: SqlDriver['booleanFields'];
139166
buildRebuiltColumn: SqlDriver['buildRebuiltColumn'];
140167
readonly canonicalDatetimeFields: SqlDriver['canonicalDatetimeFields'];
141168
readonly canonicalTimeFields: SqlDriver['canonicalTimeFields'];
169+
readonly columnFieldByObject: SqlDriver['columnFieldByObject'];
170+
computeAndRecordTenantField: SqlDriver['computeAndRecordTenantField'];
142171
convergeJsonColumn: SqlDriver['convergeJsonColumn'];
143172
countNonTemporalTextRows: SqlDriver['countNonTemporalTextRows'];
144173
createHashShadowUniqueIndex: SqlDriver['createHashShadowUniqueIndex'];
145174
createNullSafeUniqueIndex: SqlDriver['createNullSafeUniqueIndex'];
175+
readonly dateFields: SqlDriver['dateFields'];
146176
readonly datetimeFields: SqlDriver['datetimeFields'];
147177
detectTableDrift: SqlDriver['detectTableDrift'];
148178
detectTableIndexDrift: SqlDriver['detectTableIndexDrift'];
149179
readonly dialectName: SqlDriver['dialectName'];
150180
readonly driftWarned: SqlDriver['driftWarned'];
151181
dropIndexIfExists: SqlDriver['dropIndexIfExists'];
152182
dropOrphanedHashShadowColumn: SqlDriver['dropOrphanedHashShadowColumn'];
183+
ensureShardTable: SqlDriver['ensureShardTable'];
153184
explainUnkeyableTextColumn: SqlDriver['explainUnkeyableTextColumn'];
185+
readonly fieldColumnByObject: SqlDriver['fieldColumnByObject'];
154186
readonly fileColumnsMoved: SqlDriver['fileColumnsMoved'];
155187
readonly flushingDeferredDdl: SqlDriver['flushingDeferredDdl'];
188+
readonly fractionalNumericFields: SqlDriver['fractionalNumericFields'];
156189
getExistingIndexNames: SqlDriver['getExistingIndexNames'];
157190
introspectColumns: SqlDriver['introspectColumns'];
158191
introspectIndexes: SqlDriver['introspectIndexes'];
192+
isJsonField: SqlDriver['isJsonField'];
159193
readonly isMysql: SqlDriver['isMysql'];
160194
readonly isPostgres: SqlDriver['isPostgres'];
161195
isProductionEnv: SqlDriver['isProductionEnv'];
@@ -169,18 +203,28 @@ export interface SchemaSyncHost {
169203
readonly managedObjectIndexes: SqlDriver['managedObjectIndexes'];
170204
readonly mediaFields: SqlDriver['mediaFields'];
171205
nowColumnDefault: SqlDriver['nowColumnDefault'];
206+
readonly numericFields: SqlDriver['numericFields'];
207+
readonly numericValueFields: SqlDriver['numericValueFields'];
208+
readonly objectByPhysicalTable: SqlDriver['objectByPhysicalTable'];
209+
readonly physicalSchemaByObject: SqlDriver['physicalSchemaByObject'];
210+
readonly physicalTableByObject: SqlDriver['physicalTableByObject'];
172211
probeNullSafeUniqueDuplicates: SqlDriver['probeNullSafeUniqueDuplicates'];
173212
rebuildSqliteTablePatched: SqlDriver['rebuildSqliteTablePatched'];
174213
resolveTenantField: SqlDriver['resolveTenantField'];
175214
restoreBareIndexAfterFailedTighten: SqlDriver['restoreBareIndexAfterFailedTighten'];
215+
rotationColumnList: SqlDriver['rotationColumnList'];
216+
rotationShardKey: SqlDriver['rotationShardKey'];
217+
readonly rotationStateByTable: SqlDriver['rotationStateByTable'];
176218
readonly runtimeCreatedIndexes: SqlDriver['runtimeCreatedIndexes'];
177219
readonly schemaMode: SqlDriver['schemaMode'];
178220
sqliteCanonicalDatetimeSql: SqlDriver['sqliteCanonicalDatetimeSql'];
179221
sqliteCanonicalTimeSql: SqlDriver['sqliteCanonicalTimeSql'];
180222
sqliteNonTemporalTextSql: SqlDriver['sqliteNonTemporalTextSql'];
181223
syncDeclaredIndexes: SqlDriver['syncDeclaredIndexes'];
182224
syncTableIndexes: SqlDriver['syncTableIndexes'];
225+
readonly tenantFieldByTable: SqlDriver['tenantFieldByTable'];
183226
readonly timeFields: SqlDriver['timeFields'];
227+
readonly valueShapeFields: SqlDriver['valueShapeFields'];
184228
varcharColumnChars: SqlDriver['varcharColumnChars'];
185229
withPinnedSession: SqlDriver['withPinnedSession'];
186230
}
@@ -1651,3 +1695,220 @@ export async function legacyMysqlTimeColumns(this: Pick<SchemaSyncHost, 'isMysql
16511695
}))
16521696
.filter((c) => c.name && candidates.has(c.name));
16531697
}
1698+
1699+
/** The body of `SqlDriver#rotationShardKey`; the method's docblock there is its contract. */
1700+
export function rotationShardKey(nowMs: number, unit: 'day' | 'week' | 'month'): string {
1701+
const ymd = (ms: number) => new Date(ms).toISOString().slice(0, 10).replace(/-/g, '');
1702+
if (unit === 'month') return ymd(nowMs).slice(0, 6);
1703+
if (unit === 'week') {
1704+
const dow = (new Date(nowMs).getUTCDay() + 6) % 7; // Monday = 0
1705+
return ymd(nowMs - dow * 86_400_000);
1706+
}
1707+
return ymd(nowMs);
1708+
}
1709+
1710+
/** The body of `SqlDriver#ensureRotation`; the method's docblock there is its contract. */
1711+
export async function ensureRotation(this: Pick<SchemaSyncHost, 'aliasShardBookkeeping' | 'ensureShardTable' | 'knex' | 'logger' | 'rotationColumnList' | 'rotationShardKey' | 'rotationStateByTable'>, tableName: string, obj: { name: string; fields?: Record<string, any>; tenancy?: any; indexes?: any[] }, policy: { shards: number; unit: 'day' | 'week' | 'month' }, nowMs: number = Date.now()): Promise<{ object: string; current: string; shards: string[]; dropped: string[] }> {
1712+
const current = `${tableName}__r${this.rotationShardKey(nowMs, policy.unit)}`;
1713+
1714+
// Physical inventory: the base name (table before adoption, view after)
1715+
// and every existing shard.
1716+
const esc = tableName.replace(/[\\%_]/g, '\\$&');
1717+
const raw: any = await this.knex.raw(
1718+
`SELECT name, type FROM sqlite_master WHERE name = ? OR (name LIKE ? ESCAPE '\\')`,
1719+
[tableName, `${esc}\\_\\_r%`],
1720+
);
1721+
const rows: Array<{ name: string; type: string }> = Array.isArray(raw) ? raw : raw?.rows ?? [];
1722+
const baseType = rows.find((r) => r.name === tableName)?.type as 'table' | 'view' | undefined;
1723+
const shardNames = new Set(
1724+
rows.map((r) => r.name).filter((n) => n !== tableName && /__r\d{6,8}$/.test(n)),
1725+
);
1726+
1727+
// Adopt a legacy pre-rotation table: its whole history becomes the
1728+
// current shard (coarse, but safe — it then ages out of the window).
1729+
if (baseType === 'table') {
1730+
if (!shardNames.has(current)) {
1731+
await this.knex.schema.renameTable(tableName, current);
1732+
} else {
1733+
// Partial-failure recovery: both exist — merge, then drop the base.
1734+
await this.knex.raw(`INSERT INTO "${current}" SELECT * FROM "${tableName}"`);
1735+
await this.knex.schema.dropTable(tableName);
1736+
}
1737+
shardNames.add(current);
1738+
}
1739+
shardNames.add(current);
1740+
1741+
// Time-based window: retain shards whose period falls inside the last
1742+
// `shards × unit` (ends at the current period); everything older is the
1743+
// O(1) reclaim. Count-based retention would silently stretch the window
1744+
// when rotation cadence has gaps.
1745+
const unitMs = { day: 86_400_000, week: 7 * 86_400_000, month: 30 * 86_400_000 }[policy.unit];
1746+
const oldestRetainedKey = this.rotationShardKey(nowMs - (Math.max(1, policy.shards) - 1) * unitMs, policy.unit);
1747+
const parsed = [...shardNames].sort((a, b) => b.localeCompare(a));
1748+
const keyOf = (n: string) => n.slice(tableName.length + 3);
1749+
const retained = parsed.filter((n) => keyOf(n) >= oldestRetainedKey);
1750+
const dropped = parsed.filter((n) => keyOf(n) < oldestRetainedKey);
1751+
1752+
// Column-sync every retained shard (creates the current one; adds any
1753+
// newly declared columns to older shards so the UNION stays uniform).
1754+
for (const shard of retained) {
1755+
await this.ensureShardTable(shard, obj, tableName);
1756+
this.aliasShardBookkeeping(tableName, shard);
1757+
}
1758+
1759+
for (const d of dropped) {
1760+
await this.knex.schema.dropTableIfExists(d);
1761+
}
1762+
1763+
// Rebuild the read view over the retained set, newest shard first. An
1764+
// explicit column list (not `*`) keeps the view stable when old shards
1765+
// carry orphaned columns.
1766+
const cols = this.rotationColumnList(obj);
1767+
await this.knex.raw(`DROP VIEW IF EXISTS "${tableName}"`);
1768+
await this.knex.raw(
1769+
`CREATE VIEW "${tableName}" AS ` + retained.map((s) => `SELECT ${cols} FROM "${s}"`).join(' UNION ALL '),
1770+
);
1771+
1772+
this.rotationStateByTable.set(tableName, { shards: retained, current });
1773+
if (dropped.length > 0) {
1774+
(this.logger as { info?: (msg: string) => void }).info?.(
1775+
`[sql-driver] rotated ${tableName}: ${retained.length} shard(s) live, dropped ${dropped.join(', ')}`,
1776+
);
1777+
}
1778+
return { object: tableName, current, shards: retained, dropped };
1779+
}
1780+
1781+
/** The body of `SqlDriver#warnBuiltinColumnCollisions`; the method's docblock there is its contract. */
1782+
export function warnBuiltinColumnCollisions(this: Pick<SchemaSyncHost, 'logger'>, tableName: string, fields: Record<string, any> | undefined, builtinColumns: ReadonlySet<string>, phase: 'create' | 'alter' | 'shard'): string[] {
1783+
const where = {
1784+
create: `while creating table "${tableName}"`,
1785+
alter: `while syncing existing table "${tableName}"`,
1786+
shard: `while syncing shard "${tableName}"`,
1787+
}[phase];
1788+
1789+
const warned: string[] = [];
1790+
for (const [field, declaration] of Object.entries(fields ?? {})) {
1791+
if (!builtinColumns.has(field)) continue;
1792+
const undelivered: UndeliveredAttribute[] = undeliveredStorageAttributes(
1793+
field,
1794+
declaration as Record<string, unknown>,
1795+
);
1796+
// Declared, colliding, and yet nothing was lost — the honoured half only.
1797+
if (undelivered.length === 0) continue;
1798+
1799+
this.logger.warn(
1800+
`[sql-driver] ${where}: declared field '${field}' asks for storage the platform's own ` +
1801+
`'${field}' column does not provide — ${undelivered.map(formatAttribute).join('; ')}. ` +
1802+
`The platform emits id/created_at/updated_at itself, so THOSE attributes are not applied; ` +
1803+
`the rest of the declaration (label, help text, the ADR-0113 write contract, and everything ` +
1804+
`other layers read) is honoured as written. Drop the storage attribute(s) named above, or ` +
1805+
`rename the field if you meant a column of your own.`,
1806+
{ table: tableName, field, phase, undelivered: undelivered.map((a) => a.key) },
1807+
);
1808+
warned.push(field);
1809+
}
1810+
return warned;
1811+
}
1812+
1813+
/** The body of `SqlDriver#rotationColumnList`; the method's docblock there is its contract. */
1814+
export function rotationColumnList(obj: { fields?: Record<string, any> }): string {
1815+
const builtin = ['id', 'created_at', 'updated_at'];
1816+
const declared = Object.keys(obj.fields ?? {}).filter((f) => !builtin.includes(f));
1817+
return [...builtin, ...declared].map((c) => `"${c}"`).join(', ');
1818+
}
1819+
1820+
/** The body of `SqlDriver#registerExternalObject`; the method's docblock there is its contract. */
1821+
export function registerExternalObject(this: Pick<SchemaSyncHost, 'autoNumberFields' | 'booleanFields' | 'columnFieldByObject' | 'computeAndRecordTenantField' | 'dateFields' | 'datetimeFields' | 'fieldColumnByObject' | 'fractionalNumericFields' | 'isJsonField' | 'isSqlite' | 'jsonFields' | 'logger' | 'mediaFields' | 'numericFields' | 'numericValueFields' | 'objectByPhysicalTable' | 'physicalSchemaByObject' | 'physicalTableByObject' | 'tenantFieldByTable' | 'timeFields' | 'valueShapeFields'>, schema: {
1822+
name: string;
1823+
fields?: Record<string, any>;
1824+
tenancy?: any;
1825+
external?: { remoteName?: string; remoteSchema?: string; columnMap?: Record<string, string> };
1826+
}): void {
1827+
const key = schema.name;
1828+
const remoteName = schema.external?.remoteName || schema.name;
1829+
const remoteSchema = schema.external?.remoteSchema;
1830+
this.physicalTableByObject[key] = remoteName;
1831+
this.objectByPhysicalTable[remoteName] = key;
1832+
if (remoteSchema) {
1833+
if (this.isSqlite) {
1834+
this.logger.warn(
1835+
`[sql-driver] external object "${key}" declares remoteSchema="${remoteSchema}" but SQLite has no schema namespace; ignoring (treating "${remoteName}" as a bare table).`,
1836+
);
1837+
} else {
1838+
this.physicalSchemaByObject[key] = remoteSchema;
1839+
}
1840+
}
1841+
1842+
// External columnMap (ADR-0015) is declared as { remoteColumn -> localField }.
1843+
// Keep it for read-output remap, and invert to { localField -> remoteColumn }
1844+
// for WHERE/ORDER BY/write translation. Absent => managed-identical behavior.
1845+
const columnMap = schema.external?.columnMap;
1846+
if (columnMap && typeof columnMap === 'object' && Object.keys(columnMap).length > 0) {
1847+
const fieldToCol: Record<string, string> = {};
1848+
const colToField: Record<string, string> = {};
1849+
for (const [remoteCol, localField] of Object.entries(columnMap)) {
1850+
if (typeof localField === 'string' && localField) {
1851+
fieldToCol[localField] = remoteCol;
1852+
colToField[remoteCol] = localField;
1853+
}
1854+
}
1855+
this.fieldColumnByObject[key] = fieldToCol;
1856+
this.columnFieldByObject[key] = colToField;
1857+
}
1858+
1859+
const jsonCols: string[] = [];
1860+
const mediaCols: string[] = [];
1861+
const booleanCols: string[] = [];
1862+
const numericCols: string[] = [];
1863+
const numericValueCols: string[] = [];
1864+
const fractionalCols: string[] = [];
1865+
const dateCols: string[] = [];
1866+
const datetimeCols: string[] = [];
1867+
const timeCols: string[] = [];
1868+
const autoNumberCols: Array<{ name: string; format: string; tokens: AutonumberToken[]; tenantField: string | null }> = [];
1869+
1870+
const tenantField = this.computeAndRecordTenantField(key, schema);
1871+
if (schema.fields) {
1872+
for (const [name, field] of Object.entries<any>(schema.fields)) {
1873+
const type = field.type || 'string';
1874+
if (this.isJsonField(type, field)) jsonCols.push(name);
1875+
// Unconditional, on BOTH arms — see {@link mediaFields}. The read-side
1876+
// legacy-encoding repair runs on a deployment that has not moved too.
1877+
if (!isMultiValuedColumn(type, field) && FILE_REFERENCE_TYPES.has(type)) mediaCols.push(name);
1878+
// [commit d46deba19] SCALAR only — the house spelling its three neighbours in
1879+
// this block already carry, and this line was the single omission. See
1880+
// {@link booleanFields}: every reader of this registry presents its
1881+
// entry as ONE JS boolean, which for a multi-valued (JSON) column
1882+
// collapses the parsed array to `true`. [#17469] The exclusion asks
1883+
// {@link isMultiValuedColumn}, so "excluded from the scalar registries"
1884+
// and "stored as a JSON column" stay the SAME population.
1885+
if ((type === 'boolean' || type === 'toggle') && !isMultiValuedColumn(type, field)) booleanCols.push(name);
1886+
if (NUMERIC_SCALAR_TYPES.has(type) && !isMultiValuedColumn(type, field)) numericCols.push(name);
1887+
// [#16318] The authorable half only — see {@link numericValueFields}.
1888+
if (NUMERIC_VALUE_TYPES.has(type) && !isMultiValuedColumn(type, field)) numericValueCols.push(name);
1889+
// [#20387, #21042] See {@link fractionalNumericFields}: the predicate's
1890+
// `'fractional'` class, scalar only by the predicate's own reading.
1891+
if (aggregandColumnClass({ type, multiple: field?.multiple }) === 'fractional') fractionalCols.push(name);
1892+
if (type === 'date') dateCols.push(name);
1893+
if (type === 'datetime') datetimeCols.push(name);
1894+
if (type === 'time') timeCols.push(name);
1895+
if (type === 'auto_number' || type === 'autonumber') {
1896+
const fmt = resolveAutonumberFormat(field);
1897+
autoNumberCols.push({ name, format: fmt, tokens: parseAutonumberFormat(fmt), tenantField });
1898+
}
1899+
}
1900+
}
1901+
this.jsonFields[key] = jsonCols;
1902+
// [#20444] The declared value shapes `$empty` expands — see {@link valueShapeFields}.
1903+
this.valueShapeFields[key] = declaredValueShapes(schema.fields);
1904+
this.mediaFields[key] = mediaCols;
1905+
this.booleanFields[key] = booleanCols;
1906+
this.numericFields[key] = numericCols;
1907+
this.numericValueFields[key] = numericValueCols;
1908+
this.fractionalNumericFields[key] = fractionalCols;
1909+
this.autoNumberFields[key] = autoNumberCols;
1910+
this.tenantFieldByTable[key] = tenantField;
1911+
if (dateCols.length) this.dateFields[key] = new Set(dateCols);
1912+
if (datetimeCols.length) this.datetimeFields[key] = new Set(datetimeCols);
1913+
if (timeCols.length) this.timeFields[key] = new Set(timeCols);
1914+
}

0 commit comments

Comments
 (0)