Skip to content

Commit 43f1da0

Browse files
refactor(objectql): move the compatibility-API bodies out of engine.ts into engine-compatibility-api.ts (#25522 cut 8) (#26229)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQAqwoSwpLxqpJJjfS3KwBq)_ Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 8 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6419652034`. Six bodies of the "Compatibility API" section move unedited into a new module, `packages/objectql/src/engine-compatibility-api.ts`: `registerObject`, `getConfigs`, `introspectDatasource`, `syncSchemas`, `syncObjectSchema` and `removePackage`. No behaviour changes. #25522 stays open for the later cuts. Branch `claude/project-thread-4vp88a` (the card thread's harness-pinned branch), cut from `origin/main` at `7111e14ac`, which contains cut 7's merge (`dbfb52a66`, #26160). ## Why this area (the fresh seam table) Measured on `origin/main` `7111e14ac`. `engine.ts` is 23,165 lines, 7,359 of them (31.8%) from the last 30 days (`git blame -s --since=2026-09-26T15:20Z`). `scripts/pm/git-history.mjs count` (window covered, exit 0): 126 commits in 30 days, 32 in 7. `class ObjectQL` holds 318 members, 255 methods and accessors plus the constructor. Sections are cut 1's contiguous member ranges, re-located by first member name, docblocks included. The measuring script, run on cut 7's base `d1ea2363f`, reproduces cut 7's table and move screen byte for byte, so the readings compare. "inferred public" stays; "U" counts `this.FIELD.delete/insert/update(` sites (AST, casts stripped); "private statics" stay. | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | | 4758-4983 | Instance state and constructor | `txStore` … `constructor` | 226 | 50 | 0 (0 public) | - | 0 | - | | 4984-5006 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 2 (2 public) | getStatus | 0 | - | | 5007-5350 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5351-5706 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 356 | 61 | 7 (0 public) | - | 0 | - | | 5707-5867 | Action registry and activation | `registerAction` … `setActionActive` | 161 | 19 | 10 (10 public) | - | 1 | - | | 5868-6149 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 116 | 6 (6 public) | - | 0 | - | | 6150-6903 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 546 | 15 (2 public) | - | 0 | - | | 6904-7010 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 7011-7246 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 236 | 37 | 7 (0 public) | - | 0 | - | | 7247-7577 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7578-7787 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 7788-8153 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 4 | 6 (0 public) | - | 0 | - | | 8154-8766 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 613 | 80 | 6 (0 public) | - | 1 | - | | 8767-9431 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9432-9640 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 9641-9792 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 152 | 18 | 4 (1 public) | - | 0 | - | | 9793-9891 | Validation-message i18n | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 9892-10984 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 429 | 12 (0 public) | - | 0 | - | | 10985-11100 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 11101-11195 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11196-11615 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 420 | 61 | 9 (3 public) | - | 0 | - | | 11616-11684 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 11685-11950 | Datasource routing | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 266 | 20 | 8 (3 public) | setDatasourceMapping | 0 | - | | 11951-12118 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 168 | 16 | 3 (3 public) | init | 0 | - | | 12119-12192 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12193-12875 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 12876-12898 | destroy | `destroy` … `destroy` | 23 | 0 | 1 (1 public) | destroy | 0 | - | | 12899-12917 | Expand/cascade limits (statics) | `MAX_EXPAND_DEPTH` … `MAX_BULK_PER_ROW_HOOK_ROWS` | 19 | 0 | 0 (0 public) | - | 0 | MAX_EXPAND_DEPTH, MAX_CASCADE_DEPTH | | 12918-13368 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 13369-13622 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 13623-14066 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 444 | 310 | 5 (0 public) | - | 0 | - | | 14067-14545 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 479 | 272 | 9 (0 public) | - | 0 | - | | 14546-15102 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 142 | 4 (2 public) | - | 0 | - | | 15103-15760 | validate | `validate` … `validate` | 658 | 447 | 1 (1 public) | - | 0 | - | | 15761-17107 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1347 | 439 | 3 (2 public) | - | 1 | - | | 17108-19041 | update | `update` … `update` | 1934 | 275 | 1 (1 public) | - | 0 | - | | 19042-20582 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 733 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 20583-21152 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 21153-21772 | count / aggregate | `count` … `aggregate` | 620 | 236 | 3 (2 public) | - | 0 | - | | 21773-22081 | execute and transactions | `execute` … `enforceTransactionOrigin` | 309 | 74 | 8 (2 public) | - | 0 | - | | 22082-22447 | Compatibility API | `registerObject` … `create` | 366 | 41 | 16 (16 public) | - | 0 | - | The per-method move screen for every section, and the full pricing, are in the project file `pm-orders/server/25522-cut8-seam-table.md`. A body stays if any of these holds: an inferred-return public method, a U site, a private-static read, an `objectql/src/engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**`, a `this.update(` / `this.insert(` / `this.delete(` self-call, or a static. Bodies earlier cuts moved count as stubs, not as movable. Pricing (net `engine.ts` lines): a stub costs a comment, the host literal's open and close and the `.call`, plus two lines per field read (a `read*` closure and a getter) and one per method read (a late-bound closure); a body that reads no member costs two; the import adds about three. | area | 30-day lines | paying bodies | net | why not / why | |---|---|---|---|---| | **Compatibility API** (this PR) | **41 of 366** | **6 of 16 bodies, 116 body lines** | **-70 measured** | the coldest section that pays; the six moved bodies carry 32 recent lines (31 in `syncSchemas`); no field writes, so one getter-only host; no `packages/spec` pointer | | Execution plumbing | 37 of 236 | `buildSession` 43, `buildProvenance` 9, `buildEvalUser` 6, `buildUser` 8 (no member reads) | about -55 | pays less; the next candidate | | Autonumber | 80 of 613 | 101 body lines | about -75 | pays five lines more but holds twice the recent lines; #26026 edited this section during cut 7 | | count / aggregate | 236 of 620 | `count` 58, `rejectCredentialAggregation` 38 | about -71 | hot | | Migration flags and attestations | 81 of 683 | 8 bodies | about -91 | the section writes five fields, so its host needs setters (a seat ruling first, as cut 7 said) | | Package registration, Referential integrity, File-reference hydration, Readable-ids, Delete cascade, Driver options, validate | 170 to 733 | 161 to 469 | -120 to -439 | the hottest sections; the card says take the coldest | What stays in the section: `getDriverByName` (ADR-0096 cites it as `engine.ts#getDriverByName`); `create` (a static factory); `createContext`, which passes the engine itself to `ScopedContext`, a class `engine.ts` defines, so its body would need a value import of the engine (a run-time cycle); and `unregisterObject`, `getObject`, `getDriverForObject`, `dropObjectSchema`, `datasource`, `on` and `close`, whose stubs would cost as much as their bodies or more. ## The shape (#23050 shape B, as cuts 1 to 7 apply it) - The class keeps all six methods with name, modifiers, signature, docblock and position. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25522 cut 8]`. All six are public with declared return types, so none is an inferred-return public member. - `CompatibilityApiHost` is new: `_registry`, `drivers`, `hooks`, `logger`, `getDriverForObject`, `removeActionsByPackage`, each typed as the class member (`ObjectQL['_registry']`). Each moved function types its `this` as the `Pick` of exactly the members its body reads, and each stub annotates its literal as `ThisParameterType` of that function. There is no cast, and no member changes visibility. - The four fields go as GETTERS, so each read happens when the body makes it. The two methods go as late-bound closures over the instance, so a double or spy set on the engine still intercepts (`syncSchemas` and `syncObjectSchema` call the public `getDriverForObject`, which a caller may override). - `registerObject` keeps its default parameter (`packageId: string = '__runtime__'`) on both the method and the function; the stub passes the value it received. - **Imports.** The module imports `ObjectQL` as a TYPE only and nothing re-exports it. Its value imports are `StorageNameMapping` (`@objectstack/spec/system`) and `isFederatedObject` (`./federated-object.js`), the same bindings `engine.ts` uses; both still have readers in `engine.ts`, so its import list is unchanged apart from the new module import. ## Net lines (the seat's rule) `engine.ts` goes from 23,165 to 23,095 lines, **net -70** (46 added, 116 removed). The module is 225 lines: the header and host interface (about 66), six one-line docblocks, and the 116 moved body lines with their signatures. With the two re-keyed rows of the reader pin, the diff is 391 changed lines over 3 files (+273 / -118), far under the 3,000-line human-merge threshold. ## The published `.d.ts` does not change (ruling `6348891160`) Base: `@objectstack/objectql` built with `engine.ts` restored from `7111e14ac` and the new module removed (tree only; restored with `git checkout HEAD --`, blob hashes compared, `git status` clean). Head: built at `9fce8d910`. The head `dist/index.mjs` carries `syncSchemas.call(host` once and the base build has it zero times, so each leg built the source it names. All six declaration files are **byte-identical**, and equal to the hashes cut 7 recorded: | file | sha256, base = head | |---|---| | `core.d.ts` | `1d20303a455a909e4b55fd0802fb0b0bcb59ec51784daf27d5ef4c0659410953` | | `index.d.ts` | `0454d5e70b156d8effbf27b8915587c7cbee241dcd7d698013c0a4c369a02aa5` | | `util-CVzOGxAn.d.ts` | `85ef1cbec2b016d5f7afabaa21689c86c6dffc573076fdfdeb8ea0f1a1b1ad53` | | `core.d.mts` | `ef178bf0725eccc2f358f082ce13dc5122a6d5a53c19ef630017256f67de03b4` | | `index.d.mts` | `ccf900240aa137abe72f82b8abfc872d42c5645467400134c580285f2a3b3090` | | `util-CVzOGxAn.d.mts` | `85ef1cbec2b016d5f7afabaa21689c86c6dffc573076fdfdeb8ea0f1a1b1ad53` | **Positive control:** every moved member is public, so the control edits a moved member's published signature instead of its visibility: widening the stub `getConfigs()`'s declared return to a `Record` of `ServiceObject | undefined` (anchor landed once, 0 / 1 after) and rebuilding changed all six hashes and the chunk name (`util-CPRlCSiG.d.ts` `00b673476de74bfc…`, `index.d.ts` `4097f6aa775e78e4…`), and the widened type appears in the util chunk. Restored with `git checkout HEAD --`, blob hash compared, rebuilt, and all six files compared equal to the head build again. Export lists are the same for ESM and CJS at both ends: `.` 182 names (sorted sha256 `881616cc17…`) and `./core` 54 (`393e806906…`). No moved name, and not `CompatibilityApiHost`, is exported. ## Proof of move-only A TypeScript-AST leaf-token compare (recursive `getChildren`, JSDoc skipped) of base `engine.ts` (`7111e14ac`) against the head module: - For each of the six, the parameters (less `this`, default value included), return type, type parameters and body tokens are **SAME**, `async` matches, and every body line equals the base line less two leading spaces, comments included: `registerObject` 72 tokens / 9 lines, `getConfigs` 57 / 8, `introspectDatasource` 58 / 8, `syncSchemas` 312 / 63, `syncObjectSchema` 117 / 17, `removePackage` 79 / 11. - **Negative controls:** flipping `=== 'function'` to `!== 'function'` on the `syncSchema` probe in the moved `syncSchemas` reports DIFFER on its body tokens; editing only one comment in the moved `syncObjectSchema` fails the line rule. Both exit 1, and the module was restored from `HEAD` and its blob hash compared after each. - Class: 318 members at both ends, in the same order. 312 are byte-identical (full text, docblocks included), and the 6 stubs keep docblock, modifiers and signature byte-identical up to the body. - Top level: 230 of 231 base statements are byte-identical in head; the other is the class. Head adds the new module import with its comment. ## Readers of the moved lines - **Reader pin (migrated in this PR).** `packages/objectql/src/federated-injected-column-readers.test.ts` keys every federated-predicate use in `@objectstack/objectql`'s source as `FILE#FUNCTION :: SEAM`. The two schema-sync rows move from `engine.ts#syncSchemas` / `engine.ts#syncObjectSchema` to `engine-compatibility-api.ts#syncSchemas` / `#syncObjectSchema`, with the same disposition and reason. Control: with the base copy of the test restored over this head, it goes red naming exactly those two new keys (1 failed / 4 passed), so the pin does read the moved code; restored from `HEAD`, blob hash compared. - **ADR anchors.** No `scripts/adr-anchors/` entry describes a moved body: the engine entry's one paragraph is the transaction seam, and its ids stay cited in `engine.ts`. So no entry is added. `check-adr-anchors` at base `7111e14ac`: 89 anchored files, 1,881 decision-letter citations; at head: 89 anchored files, 1,881 decision-letter citations (resolving citations 59,157 → 59,159, the module header's two ADR ids). The moved bodies cite no decision letter. - No `objectql/src/engine.ts#NAME` pointer under `packages/spec/**` or `docs/adr/**` names the six. Prose that names a method with `ObjectQL.` or `engine.` (ADR-0015 on `ObjectQL.syncObjectSchema`, `federated-object.ts`, `plugin.ts`, `error-code-ledger.zod.ts` comments, the durability checker's `syncObjectSchema` vocabulary) still resolves, because every method stays on the class. - No gate baseline keys a moved body: `durability-read-invention.baseline.json` names `referenceExists` only, and `query-options-erasure` reports its key set unchanged with the file counts held. The tests that assert the moved bodies' runtime messages (`external-write-gate.test.ts`, `schema-sync-durability-log-level.test.ts`, `sync-schemas-federated-object.test.ts`) assert behaviour, not source text, and pass. ## Acceptance notes - `createContext` (8 body lines) was priced at about -6 by cut 7; it hands `this` (the engine) to `ScopedContext`, which `engine.ts` defines, so the body cannot move without a value import of the engine. Left in the class. - `removePackage` mutates the hook map through the getter (`this.hooks.set(…)`), the same object, so the write lands where it did; it is not a field assignment, and no setter is needed. - Serial check, re-read before `pr_create`: 36 PRs are open; none touches `packages/objectql/src/**`. #26203 edits `packages/objectql/tsconfig*.json`, #26198 and #25640 edit `packages/objectql/package.json` (and the changelog), outside this PR's files. `origin/main` moved 15 commits after the base, none on `packages/objectql/**`; the only `scripts/adr-anchors/**` change is the driver-sql pair from #26169. `dispatch-gates` printed no stale-tree warning, so `main` was not merged in. - Next cut from this screen: Execution plumbing (about -55: `buildSession`, `buildProvenance`, `buildEvalUser`, `buildUser`, none reading a member), then Autonumber (about -75, warmer). Migration flags still needs a setter ruling. ## Verification At `9fce8d910`: - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` + `check:test-typecheck`, 39 files / 233 errors / 64 pinned signatures held): VERDICT command-exit 0. `pnpm exec eslint --no-inline-config packages/objectql`: exit 0. - `pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2`: 471 files, 8,774 tests passed (VERDICT command-exit 0). A focused verbose run of `federated-injected-column-readers.test.ts`, `sync-schemas-federated-object.test.ts`, `schema-sync-durability-log-level.test.ts` and `external-write-gate.test.ts`: 4 files, 28 tests passed. - Dependants: every changed member keeps its declaration and all six published `.d.ts` files are byte-identical, so no dependant can see a difference. CI's `TypeScript Type Check` covers them. - Gates at `9fce8d910`: `dispatch-gates --commands` derived 62 families on the real diff. Every one was run, each exit code captured before any pipe, then reconciled with `--ran`: 62 accounted for, 61 run, 1 NOT MEASURED. **NOT MEASURED** (CI runs it): `check:dual-build-cjs-loads`, exit 3, PREREQUISITE NOT MET (it needs every package's `dist/`; the export lists above answer it for objectql). `check:lean-entry-closure` first exited 3 for want of objectql's `dist/` and was re-run green after the head build (15 packages, the admitted set held). Named readings: `check-system-context-census` OK (151 read sites, 144 of 144 symbols cited), `check:logger-receiver-detach` OK (3,737 files, 0 detaches), `check:durability-log-level` exit 0, `check:query-options-erasure` exit 0 (key set verified, no files added), `check-engine-split-ratio --days 90` 97.6%. - Hand gates outside the derivation, all exit 0 at `9fce8d910`: `check-adr-anchors` at base and head (above), `check-adr-symbol-anchors` (2,660 anchors resolve), `check-tenant-audit-census` (243 write call sites), `check-nul-bytes`, `check-platform-checklist --changed-from origin/main` (13 anchors judged, 0 lost) and `check-commit-card-trailers --range origin/main..HEAD`. - No ablation, because no tested behaviour changed. `test:repo` was not run locally; CI's `Test Core` runs it. `skip-changeset`: move-only. The published `.d.ts` files and export lists are byte-identical, `liveness/` and every other published path are untouched, and the module's runtime is the same code under a different file name. --- _Generated by [Claude Code](https://claude.ai/code/session_012CWiXJotHHDyeTL3Hea2Ad)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 81bba6c commit 43f1da0

3 files changed

Lines changed: 273 additions & 118 deletions

File tree

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 8] The compatibility-API bodies of `ObjectQL` (the methods
5+
* that match the legacy `@objectql/core` interface), moved out of
6+
* `engine.ts` whole: `registerObject` (a single definition into the
7+
* registry, field names filled from their keys), `getConfigs` (the
8+
* registered objects as a name-to-config map), `introspectDatasource` (a
9+
* datasource's live remote schema, ADR-0015), `syncSchemas` (every
10+
* registered object's storage synced to its driver, a federated object bound
11+
* without DDL), `syncObjectSchema` (the same for one object, on demand) and
12+
* `removePackage` (a package's hooks, actions and objects removed).
13+
*
14+
* A MOVE, not a redesign. Each exported function below is the body of the
15+
* `ObjectQL` method of the same name as it stood, re-indented one level and
16+
* otherwise unedited: the same registry calls, the same two refusals before
17+
* introspection, the same federated routing, the same durability ERROR when a
18+
* schema sync fails, and the same removal order. Each method's docblock stays
19+
* on the method in `engine.ts`, which is still the contract.
20+
*
21+
* What stays in the class: `getDriverByName`, because ADR-0096 cites it by
22+
* `engine.ts#getDriverByName`; `create`, a static factory; `createContext`,
23+
* which hands the engine itself to `ScopedContext`, a value `engine.ts`
24+
* defines (moving it would import the engine at run time); and
25+
* `unregisterObject`, `getObject`, `getDriverForObject`,
26+
* `dropObjectSchema`, `datasource`, `on` and `close`, whose bodies are no
27+
* longer than the host literal moving them would cost.
28+
*
29+
* ## How the moved bodies reach the class
30+
*
31+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
32+
* header carries the full reasoning), applied per method as
33+
* `engine-execute-transactions.ts` applies it. The class keeps all six
34+
* methods — every one is public, and the published `.d.ts` lists each one's
35+
* name, signature and position — and each body is one literal of exactly the
36+
* {@link CompatibilityApiHost} members that method reads, plus a `.call`.
37+
* The fields the bodies read (`_registry`, `drivers`, `hooks`, `logger`)
38+
* go as GETTERS, so each read happens when the body makes it, as it did. The
39+
* methods they call (`getDriverForObject`, `removeActionsByPackage`) go as
40+
* late-bound closures over the instance, so a double or spy set on the engine
41+
* is still the one a call reaches.
42+
*
43+
* This module imports the engine only as a TYPE, so there is no module cycle
44+
* at run time, and nothing re-exports it, so the published surface does not
45+
* move.
46+
*/
47+
48+
import type { ServiceObject } from '@objectstack/spec/data';
49+
import type { IntrospectedSchema as SpecIntrospectedSchema } from '@objectstack/spec/contracts';
50+
import { StorageNameMapping } from '@objectstack/spec/system';
51+
import { isFederatedObject } from './federated-object.js';
52+
import type { ObjectQL } from './engine.js';
53+
54+
/**
55+
* The `ObjectQL` members the functions below read — every one of them, and no
56+
* other; each function's `this` is the `Pick` of the members IT reads. Each is
57+
* typed AS the class member (an indexed access on the class), so a change to a
58+
* member's type on the class is a change here too, and naming a member the
59+
* class does not have is a compile error. See the module header for which
60+
* members are getters.
61+
*/
62+
export interface CompatibilityApiHost {
63+
readonly _registry: ObjectQL['_registry'];
64+
readonly drivers: ObjectQL['drivers'];
65+
readonly hooks: ObjectQL['hooks'];
66+
readonly logger: ObjectQL['logger'];
67+
getDriverForObject: ObjectQL['getDriverForObject'];
68+
removeActionsByPackage: ObjectQL['removeActionsByPackage'];
69+
}
70+
71+
/** The body of `ObjectQL#registerObject`; the method's docblock there is its contract. */
72+
export function registerObject(
73+
this: Pick<CompatibilityApiHost, '_registry'>,
74+
schema: ServiceObject,
75+
packageId: string = '__runtime__',
76+
namespace?: string,
77+
): string {
78+
// Auto-assign field names from keys
79+
if (schema.fields) {
80+
for (const [key, field] of Object.entries(schema.fields)) {
81+
if (field && typeof field === 'object' && !('name' in field)) {
82+
(field as any).name = key;
83+
}
84+
}
85+
}
86+
return this._registry.registerObject(schema, packageId, namespace);
87+
}
88+
89+
/** The body of `ObjectQL#getConfigs`; the method's docblock there is its contract. */
90+
export function getConfigs(this: Pick<CompatibilityApiHost, '_registry'>): Record<string, ServiceObject> {
91+
const result: Record<string, ServiceObject> = {};
92+
const objects = this._registry.getAllObjects();
93+
for (const obj of objects) {
94+
if (obj.name) {
95+
result[obj.name] = obj;
96+
}
97+
}
98+
return result;
99+
}
100+
101+
/** The body of `ObjectQL#introspectDatasource`; the method's docblock there is its contract. */
102+
export async function introspectDatasource(
103+
this: Pick<CompatibilityApiHost, 'drivers'>,
104+
datasource: string,
105+
): Promise<SpecIntrospectedSchema> {
106+
const driver = this.drivers.get(datasource);
107+
if (!driver) {
108+
throw new Error(`[ObjectQL] Datasource '${datasource}' has no registered driver to introspect.`);
109+
}
110+
if (typeof driver.introspectSchema !== 'function') {
111+
throw new Error(`[ObjectQL] Driver for datasource '${datasource}' does not support introspectSchema().`);
112+
}
113+
return driver.introspectSchema();
114+
}
115+
116+
/** The body of `ObjectQL#syncSchemas`; the method's docblock there is its contract. */
117+
export async function syncSchemas(
118+
this: Pick<CompatibilityApiHost, '_registry' | 'getDriverForObject' | 'logger'>,
119+
): Promise<void> {
120+
const allObjects = this._registry.getAllObjects();
121+
for (const obj of allObjects) {
122+
const driver = this.getDriverForObject(obj.name);
123+
if (!driver) continue;
124+
// [#21777] Same predicate and same treatment as the boot sync. The remote
125+
// owns this object's schema, so `syncSchema` would be refused
126+
// (`ExternalSchemaModeViolationError` on an external-schema datasource).
127+
// Logging that refusal at the #4632 ERROR below was a false durability
128+
// alarm, because nothing was meant to be created. The DDL-free binding
129+
// still runs, because an object registered at runtime has no other way
130+
// to its remote table: without it every read resolves to a table named
131+
// after the object.
132+
if (isFederatedObject(obj)) {
133+
if (typeof (driver as any).registerExternalObject !== 'function') {
134+
this.logger.debug('Driver does not support registerExternalObject, skipping external object', {
135+
object: obj.name,
136+
driver: (driver as any).name,
137+
});
138+
continue;
139+
}
140+
try {
141+
await (driver as any).registerExternalObject(obj);
142+
this.logger.debug('Federated object is not a DDL target — bound to its remote table without DDL', {
143+
object: obj.name,
144+
driver: (driver as any).name,
145+
});
146+
} catch (e: unknown) {
147+
this.logger.warn('Failed to register external object metadata', {
148+
object: obj.name,
149+
driver: (driver as any).name,
150+
error: e instanceof Error ? e.message : String(e),
151+
});
152+
}
153+
continue;
154+
}
155+
const tableName = StorageNameMapping.resolveTableName(obj);
156+
if (typeof (driver as any).syncSchemasBatch === 'function' && (driver as any).supports?.batchSchemaSync) {
157+
// Already handled per-driver below; skip individual call
158+
}
159+
if (typeof (driver as any).syncSchema === 'function') {
160+
try {
161+
await (driver as any).syncSchema(tableName, obj);
162+
} catch (e: unknown) {
163+
// #4632 — this catch used to be empty, with the comment "log
164+
// suppressed to avoid noise on already-synced tables". Suppressing an
165+
// already-synced no-op is not what it did: `syncSchema` is required to
166+
// be idempotent (see this method's doc comment), so a driver that
167+
// reaches this catch did NOT sync. The only callers are runtime
168+
// installs — marketplace plugin install, template seeding — which go
169+
// on to INSERT into a table this failure means does not exist, and
170+
// then report the install as successful. Nothing that claims to be
171+
// persisted afterwards is.
172+
this.logger.error(
173+
`Schema sync FAILED for object '${obj.name}' — its table/collection was NOT created or altered, yet the object is ` +
174+
`registered and will be written to: those writes will fail, or drop the columns that were never created. ` +
175+
`Any seeding or install step that continues past this point is not durable. ` +
176+
`Fix the driver error below, then re-run the install/sync.`,
177+
e as Error,
178+
{ object: obj.name, tableName, driver: (driver as any)?.name },
179+
);
180+
}
181+
}
182+
}
183+
}
184+
185+
/** The body of `ObjectQL#syncObjectSchema`; the method's docblock there is its contract. */
186+
export async function syncObjectSchema(
187+
this: Pick<CompatibilityApiHost, '_registry' | 'getDriverForObject'>,
188+
objectName: string,
189+
): Promise<void> {
190+
const obj = this._registry.getObject(objectName) as any;
191+
if (!obj) return;
192+
const driver = this.getDriverForObject(objectName);
193+
if (!driver) return;
194+
// Federated (external) object (ADR-0015): register read metadata WITHOUT DDL
195+
// (its remote schema is owned externally). This is what an app's onEnable
196+
// calls after registering a late external driver so coercion maps + the
197+
// physical-table mapping exist for queries. See SqlDriver.registerExternalObject.
198+
if (isFederatedObject(obj)) {
199+
if (typeof (driver as any).registerExternalObject === 'function') {
200+
await (driver as any).registerExternalObject(obj);
201+
}
202+
return;
203+
}
204+
if (typeof (driver as any).syncSchema !== 'function') return;
205+
const tableName = StorageNameMapping.resolveTableName(obj);
206+
await (driver as any).syncSchema(tableName, obj);
207+
}
208+
209+
/** The body of `ObjectQL#removePackage`; the method's docblock there is its contract. */
210+
export function removePackage(
211+
this: Pick<CompatibilityApiHost, 'hooks' | 'removeActionsByPackage' | '_registry'>,
212+
packageId: string,
213+
): void {
214+
// Remove hooks
215+
for (const [key, handlers] of this.hooks.entries()) {
216+
const filtered = handlers.filter(h => h.packageId !== packageId);
217+
if (filtered.length !== handlers.length) {
218+
this.hooks.set(key, filtered);
219+
}
220+
}
221+
// Remove actions
222+
this.removeActionsByPackage(packageId);
223+
// Remove objects
224+
this._registry.unregisterObjectsByPackage(packageId, true);
225+
}

0 commit comments

Comments
 (0)