Skip to content

Commit 702b44f

Browse files
refactor(objectql): move the datasource-routing bodies out of engine.ts into engine-datasource-routing.ts (#25522 cut 2) (#25636)
Part of #25522 Clause-②: no Metadata-protocol: none Ruling-ref: 6348891160 Cut 2 of the `packages/objectql/src/engine.ts` split (#24586 ruling A, recorded in `6270097840`). The claim is `6383544450`. Three `ObjectQL` method bodies move unedited into a new module, `packages/objectql/src/engine-datasource-routing.ts`: `getDriver`, `resolveDatasourceFromMapping` and `matchPattern`, all private. #25522 stays open for the later cuts. Branch `claude/issue-25522-objectql-engine-cut-2`, cut from `origin/main` at `cc10e74fe`. Since then `main` has moved two commits (#25621, #25620), and neither touches `packages/objectql/**`. The branch has not been merged forward; CI builds the merge ref. **Serial with the file's in-flight PRs.** At start, none of the 14 open PRs touched `packages/objectql/**`. I fetched each head and diffed it against its merge-base. Just before this PR was opened I measured again: none of the 16 open PRs touches it (file lists of #25633, #25632 and the moved #25626 read; the other 13 unchanged). ## Which part of Datasource routing moves, and why not all of it The dispatch proposed the whole **Datasource routing** row less `setDatasourceMapping` and the two public statics. I measured first, and it moves less than that: - **`resolveDatasourceBinding` stays.** Two `packages/spec` liveness ledgers cite it by symbol as their evidence: `packages/spec/liveness/object.json` (`object.datasource`, limb 1) and `packages/spec/liveness/manifest.json` (`manifest.defaultDatasource`, limb 4), both as `packages/objectql/src/engine.ts#resolveDatasourceBinding`. So does `docs/adr/0139-…md`. Card ask 3 wants every path reader migrated in the same PR. This dispatch forbids `packages/spec/**` and `docs/adr/**`. Moving the body would leave both ledger gates green on the stub's name while the code they quote sat in another file, so it stays where they point. - **`resolveEffectiveDatasource`, `resolveMappedDatasource` and `isSystemLedgerObject` stay.** Their bodies are 2, 1 and 4 lines, and each stub's host literal would be longer than the body it replaces. This follows the same reasoning cut 1 used for `setI18nService`. - **Test doubles (assumption A1): no double loses its calls.** Across the repo's tests, the members of this area are doubled four ways: - `vi.spyOn(engine as any, 'getDriver')` in `packages/objectql/src/engine-judge-filter.test.ts`, on a real engine. Every caller left in `engine.ts` still calls `this.getDriver(…)`, and no moved body calls `getDriver`, so the spy sees the same calls. - Instance assignments of `resolveEffectiveDatasource`, in `plugin-audit/src/comment-thread-leaf.test.ts` and `service-analytics/src/__tests__/effective-datasource-probe.test.ts`. That method did not move. - Object-literal fakes that carry `getDriver` or `resolveEffectiveDatasource` (plugin-security, plugin-auth, service-analytics, service-storage, metadata-protocol). They replace the whole engine, so they never reach `ObjectQL`. - Direct calls `(engine as any).getDriver(name)`. They reach the stub, which keeps its name. - The moved bodies reach `resolveDatasourceBinding` and `matchPattern` through late-bound closures, so a double set on the instance is still the one they call. ## The shape (#23050 shape B, as cut 1 and #25222, #25223, #25224 and #25467 apply it) - The class keeps each method with its name, signature, docblock and position. Each body is now one host literal plus `fn.call(host, …)`, marked `[#25522 cut 2]`. `matchPattern` reads no member, so it is called directly. - `DatasourceRoutingHost` is new. It types each member as the class member itself (`ObjectQL['drivers']`, `ObjectQL['resolveDatasourceBinding']`, …). Each moved function types its `this` as the `Pick` of the host over exactly the members its body reads, and each stub annotates its literal as the `ThisParameterType` of the moved function. There is no cast, and no member becomes public or protected. - **Fields go as getters:** `drivers`, `logger`, `unavailableDatasources` and `datasourceMapping`. Each read happens when the body makes it, so a rule array replaced by `setDatasourceMapping`, or a driver registered later, is seen as before. - **Imports.** The module imports `ObjectQL` as a type only, so there is no module cycle at run time. It imports `IDataDriver` as a type from `@objectstack/core` and `DatasourceUnavailableError` from `./driver-connect-errors.js`, the specifiers `engine.ts` uses. No moved body reads a static. `engine.ts` gains one import and keeps every import it had. `DatasourceUnavailableError` stays imported there because a `{@link DatasourceUnavailableError}` in a docblock that stays still names it (`tsc` and eslint are clean). Nothing re-exports the module. ## The published `.d.ts` does not change This is the series bar, ruling `6348891160`. Base was a `turbo run build --filter='@objectstack/objectql...'` at `cc10e74fe` (14/14 tasks). Head was `pnpm --filter @objectstack/objectql build` at `c70b6b5d` against the same dependency tree. The head `dist/index.mjs` carries `readUnavailableDatasources` twice, so the head build is the one compared. All six declaration files are **byte-identical**, and the raw diff of each is 0 lines. The util chunk name, `util-ByiLm3B6`, is the same at both ends. | file | raw sha256, base = head | normalised (ruling, double-quoted), base = head | normalised (single-quoted spelling), base = head | |---|---|---|---| | `index.d.ts` | `9ca22071717e3f761198115fa54a143df4151a52950d826a10677420dcad16ae` | `9ca22071717e…` | `c15106196eb2…` | | `index.d.mts` | `69dd1cfe3875024f434b2d7defce771ebc8c7e48a05be7c946fa6ecdbd912b40` | `69dd1cfe3875…` | `a2d6d7b241e8…` | | `core.d.ts` | `d27b72bde2eec9a47c7dd3c104976321533cf397452ca9f84491a455ec28e73b` | `d27b72bde2ee…` | `d27b72bde2ee…` | | `core.d.mts` | `5a0cca505296b678b31c742c285e028fe434f5467bb8d27e052e470ce0e09652` | `5a0cca505296…` | `5a0cca505296…` | | `util-ByiLm3B6.d.ts` and `.d.mts` | `1742a9cb029654d1ca4e8e392e98001eec85963987f516b1b76f672ecd08e5a9` | `1742a9cb0296…` | `bffd1b630dab…` | - The ruling's normaliser was run verbatim (double-quoted unions only). This package prints single-quoted literals, so on these files it changes nothing and normalised = raw. A second column applies the same single class (sorting the members of one string-literal union) spelled with a single quote. That column also matches at base and head. This cut does not depend on it, because the raw bytes are identical. - **Positive control**, on a copy of the head `util-ByiLm3B6.d.ts`: line 8412 `private matchPattern;` made public (`matchPattern;`, one line changed) gives raw = ruled-normalised `e1ebae28ec516d6480a51cf8551a7eeb62498e24d2409140e0199dcf4ecc34be`, which differs from `1742a9cb…`. The single-quote normalisation gives `f715c98bfa40…`, which differs from `bffd1b630dab…`. - **Export lists**, loaded at run time from the built bundles (ESM `import()` and CJS `require`), are identical at base and head. `.` has 182 exports under ESM and 182 under CJS; `./core` has 54 and 54. The sorted lists hash `881616cc179189098182bd6b8a95701d9922f9677b786bc12e2d31c1adb0adaa` and `393e806906e876c2e020aa96617d54050a35ae63cd483b67d8e968b7b5390f49` at both ends. No changeset: `skip-changeset`, per ruling `6348891160` item 4. ## Proof of move-only - **Bodies, by AST token** (recursive `getChildren` leaves). For each moved function, the parameters (without the added `this`), the return type and the body have the same token sequence as the base method: `getDriver` 206, `resolveDatasourceFromMapping` 168, `matchPattern` 54. That is 428 tokens with 0 differences. Each body line is the base line less two leading spaces (56, 34 and 7 lines). **Negative control:** changing one token in `resolveDatasourceFromMapping` makes the check report DIFFER. - **Class side.** The class has 317 members at base and at head. The 314 that did not move are byte-identical to base. The 3 stubs keep their docblocks and signatures byte-identical. - **Top level of `engine.ts`.** All 234 base statements outside the class are byte-identical at head. The one new statement is the import of the new module. - **Size.** +208 / −97 over 2 files, under the 3,000-line manual-merge line. `engine.ts` goes from 23,881 to 23,807 lines. `engine-datasource-routing.ts` has 185 lines, most of them the module header and the host docblock. - **The tests reach the moved bodies.** These were one-off mutations written with `scripts/ablation-replace.mjs`. Each restored to the HEAD blob `f6252c183b12` with `git diff HEAD` empty, and no test file was kept: - `matchPattern`'s `.replace(/\*/g, '.*')` changed to `'Z'`: `datasource-mapping.test.ts` goes 2 failed / 8 passed. - `getDriver`'s `unavailableDatasources.get(…)` read, which goes through the getter, replaced by `undefined`: `datasource-unavailable.test.ts` goes 4 failed. The same mutation leaves `datasource-mapping.test.ts` green, which is noted under Acceptance notes. ## Readers of the moved lines - **ADR anchors (assumption A2).** `scripts/adr-anchors/packages__objectql__src__engine.ts.json`'s one paragraph is about the transaction seam. Its routing clause ("`SYSTEM_LEDGER_LIFECYCLE_CLASSES`, the same tuple ADR-0057 §3.6 routing reads") describes `resolveDatasourceBinding` step 3 and `isSystemLedgerObject`, and both stay. No moved body reads the tuple or cites an ADR; the ADR-0057 §3.6 mention in `getDriver`'s docblock stays on the stub. So the new module owes no anchor entry, and none is added. `check:adr-anchors`: 85 anchored files at both ends; 1,871 decision-letter citations on the anchored surface at both ends; 52,481 citations across 6,594 files at both ends. - **Liveness ledgers.** `pnpm --filter @objectstack/spec check:liveness` exits 0 at head. 982 `path#symbol` pointers each name a symbol the cited file contains. Key-mention anchoring asks 753 pairs: 752 anchored and 1 exempt, the same as at base. `engine.ts` still names `defaultDatasource` outside the moved bodies. - **Other path readers.** `docs/adr/0062-…md` mentions `engine.ts` (`getDriver`/`registerDriver` routing) in prose, and both methods are still in `engine.ts`. `check-adr-symbol-anchors`: 2,384 anchors resolve at head. `check-platform-checklist --changed-from origin/main`: 1 anchored file in the change set, 14 anchors judged, 0 lost. ## Seam table (fresh, `origin/main` `cc10e74fe`, 2026-10-24) `engine.ts` is 23,881 lines. 8,270 of them (34.6%) come from the last 30 days (`git blame --since=2026-09-24T11:00Z`, history covering 2026-09-15 onward). 126 commits touched it in 30 days, and 35 in 7. The file has the module preamble (lines 1–5015), `class ObjectQL` (5016–23206, 317 members) and a trailer. Sections are contiguous member ranges, docblocks included, with the same names and first members as cut 1's table. The columns: - **methods** counts method declarations and the constructor, without accessors. That is why "Status and registry accessor" reads 1 here and read 2 in cut 1's table. - **inferred public** lists public methods with an inferred return type; these stay. - **U** counts `this.FIELD.delete/insert/update(` sites. - **private statics** lists statics that are not public; these stay. | range | section | first … last member | lines | 30-day lines | methods | inferred public | U | private statics | |---|---|---|---|---|---|---|---|---| | 5016-5242 | Instance state and constructor | `txStore` … `Constructor` | 227 | 50 | 1 (1 public) | - | 0 | - | | 5243-5265 | Status and registry accessor | `getStatus` … `registry` | 23 | 0 | 1 (1 public) | getStatus | 0 | - | | 5266-5609 | Hook and function registry | `registerHook` … `triggerHooks` | 344 | 6 | 16 (16 public) | registerHook, triggerHooks | 1 | - | | 5610-6090 | Hook dispatch internals | `hasHooksFor` … `assertBulkPerRowHookBudget` | 481 | 53 | 7 (0 public) | - | 0 | - | | 6091-6247 | Action registry and activation | `registerAction` … `setActionActive` | 157 | 19 | 10 (10 public) | - | 1 | - | | 6248-6529 | Registered resolvers and write-gate probes | `_heldFileResolver` … `registerInsertRecomputeListener` | 282 | 257 | 6 (6 public) | - | 0 | - | | 6530-7283 | Readable-ids security and permission/formula resolution | `readableIdsSecurityReported` … `resolveDefaultPermissions` | 754 | 754 | 15 (2 public) | - | 0 | - | | 7284-7390 | Authz invalidation and middleware registration | `writeEpoch` … `hasObjectMiddleware` | 107 | 18 | 4 (4 public) | - | 0 | - | | 7391-7626 | Execution plumbing (middleware, session, provenance, users) | `executeWithMiddleware` … `buildUser` | 236 | 37 | 7 (0 public) | - | 0 | - | | 7627-7957 | Driver options and engine posture | `buildDriverOptions` … `resolveEnginePosture` | 331 | 209 | 6 (0 public) | - | 0 | - | | 7958-8167 | Organization probe and system-insert organization | `probeInstallOrganizations` … `transactionCoversDriverFor` | 210 | 45 | 4 (0 public) | - | 0 | - | | 8168-8533 | Field defaults | `resolveNowDefault` … `defaultFieldEntries` | 366 | 37 | 6 (0 public) | - | 0 | - | | 8534-9144 | Autonumber | `applyAutonumbers` … `seedAutonumber` | 611 | 78 | 6 (0 public) | - | 1 | - | | 9145-9809 | Package, app, plugin and driver registration | `registrationConversionWarned` … `registerDriver` | 665 | 170 | 6 (3 public) | registerApp, registerDriver | 0 | - | | 9810-10018 | Datasource registry | `supplyFileColumnsMovedResolver` … `listUnavailableDatasources` | 209 | 1 | 8 (7 public) | - | 3 | - | | 10019-10265 | Write guard and realtime publish | `assertWriteAllowed` … `publishBulkDataEvent` | 247 | 18 | 4 (1 public) | - | 0 | - | | 10266-10364 | Validation-message i18n (cut 1) | `setI18nService` … `objectDisplayLabel` | 99 | 14 | 4 (1 public) | - | 0 | - | | 10365-11457 | Referential integrity and master-detail resolution | `assertReferencesResolve` … `masterDeclaredFields` | 1093 | 625 | 12 (0 public) | - | 0 | - | | 11458-11573 | Dangling-reference and cascade inspection | `inspectDanglingReferences` … `inspectCascadeDelete` | 116 | 48 | 2 (2 public) | - | 0 | - | | 11574-11668 | Crypto and tenancy providers | `setCryptoProvider` … `setTenancyPostureProvider` | 95 | 20 | 4 (4 public) | - | 1 | - | | 11669-12234 | Secret and internal-field handling | `encryptSecretFields` … `resolveInternalField` | 566 | 32 | 9 (3 public) | - | 0 | - | | 12235-12303 | Schema lookup and filter judging | `getSchema` … `judgeFilter` | 69 | 48 | 3 (2 public) | - | 0 | - | | 12304-12646 | **Datasource routing (this cut)** | `LIFECYCLE_DATASOURCE` … `setDatasourceMapping` | 343 | 0 | 8 (3 public) | setDatasourceMapping | 0 | - | | 12647-12869 | Lifecycle init and health | `init` … `resolvePrimaryDatasource` | 223 | 0 | 3 (3 public) | init | 0 | - | | 12870-12943 | Media and value-shape presence | `objectHasMediaField` … `valueShapeStrictFor` | 74 | 13 | 4 (0 public) | - | 0 | mediaFieldPresence, coveredValueFieldPresence | | 12944-13626 | Migration flags and attestations | `isFileReferencesMigrationVerified` … `wasDatastoreCreatedFromEmpty` | 683 | 81 | 17 (8 public) | - | 0 | - | | 13627-13653 | destroy | `destroy` … `destroy` | 27 | 0 | 1 (1 public) | destroy | 0 | - | | 13654-13668 | Expand/cascade limits (statics) | `MAX_EXPAND_DEPTH` … `MAX_BULK_PER_ROW_HOOK_ROWS` | 15 | 0 | 0 (0 public) | - | 0 | MAX_EXPAND_DEPTH, MAX_CASCADE_DEPTH | | 13669-14119 | Roll-up summaries | `autonumberCounters` … `recomputeSummaries` | 451 | 0 | 7 (1 public) | - | 0 | - | | 14120-14373 | Related-record expansion | `expandRelatedRecords` … `expandRelatedRecords` | 254 | 10 | 1 (0 public) | - | 0 | - | | 14374-14821 | File-reference hydration | `resolveFileReferences` … `readParentDerivedFiles` | 448 | 310 | 5 (0 public) | - | 0 | - | | 14822-15239 | Query lowering | `resolveWhereTokens` … `requireFindOnePredicate` | 418 | 212 | 8 (0 public) | - | 0 | - | | 15240-15796 | find / findOne | `find` … `staticReadonlyCreateStrip` | 557 | 152 | 4 (2 public) | - | 0 | - | | 15797-16454 | validate | `validate` … `validate` | 658 | 492 | 1 (1 public) | - | 0 | - | | 16455-17799 | insert / insertMany | `readUpdatePriorRow` … `insertMany` | 1345 | 480 | 3 (2 public) | - | 1 | - | | 17800-19731 | update | `update` … `update` | 1932 | 391 | 1 (1 public) | - | 0 | - | | 19732-21272 | Delete cascade and reference walking | `planCascadeAtomicity` … `organizationOwnedRowCounts` | 1541 | 747 | 22 (1 public) | - | 0 | storedReferenceIncludes, remainderAfterMemberRemoval, referenceCheckContext, outlivesOrganization, keepsOrganizationReference, organizationCountContext | | 21273-21842 | delete | `delete` … `delete` | 570 | 47 | 1 (1 public) | - | 0 | - | | 21843-22455 | count / aggregate | `count` … `aggregate` | 613 | 362 | 3 (2 public) | - | 0 | - | | 22456-22849 | execute and transactions | `execute` … `enforceTransactionOrigin` | 394 | 63 | 8 (2 public) | - | 0 | - | | 22850-23205 | Compatibility API | `registerObject` … `create` | 356 | 36 | 16 (16 public) | - | 0 | - | After this cut, the Datasource routing row at head is lines 12307–12572 (266 lines). ## For the next dispatch (cut 3) - **Proposed area: Lifecycle init and health, less `init`.** `init` has an inferred return type, so it stays. What moves is `checkDriversHealth` (34-line body) and `resolvePrimaryDatasource` (37-line body): - both are public with explicit return types, so their signatures stay on the stubs and the `.d.ts` keeps its bytes; - 0 lines from the last 30 days, no `this.FIELD.delete/insert/update(` site, no static read; - no `engine.ts#…` path reader, and no spy or instance double on either, measured repo-wide. - `resolvePrimaryDatasource` reads `isSystemLedgerObject` and `resolveDatasourceBinding` (both late-bound closures), plus `_registry` and `drivers` (getters). - **Still waiting:** Roll-up summaries (spec-coupled, see cut 1). Datasource registry has U = 3: 2 sites in `unregisterDriver` and 1 in `clearDatasourceUnavailable`. Its other six bodies are 1–15 lines and mostly shorter than a host literal. ## Acceptance notes - `datasource-mapping.test.ts`'s case "when the connect verdict is known, the error says WHY rather than 'not registered'" asserts `/analytics database unreachable|ERR_DATASOURCE_UNAVAILABLE|broken/`. The fallback "mapped … is not registered" message also contains `broken`, so the case stays green with the `unavailableDatasources` branch removed (mutation above). `datasource-unavailable.test.ts` does pin that branch. This predates this PR and is noted, not filed. ## Verification All at head `c70b6b5d`. - `pnpm --filter @objectstack/objectql typecheck` (`tsc --noEmit` and `check:test-typecheck`): exit 0, 39 files / 233 errors / 64 pinned signatures held. Eslint with `--no-inline-config` on `packages/objectql` and on both files: exit 0. - `packages/objectql` suite (`vitest run --project local --maxWorkers=2`, under `os-verify-lock`, VERDICT command-exit 0): 466 files, 8,700 tests, 8,700 passed, 0 failed, 0 skipped. - Downstream type-checks are untouched by construction. Every moved member is private, and all six published declaration files are byte-identical. CI's `TypeScript Type Check` covers the dependants. - `dispatch-gates --commands` derived 56 families on the real diff. All 56 were run, each exit code recorded, and `--ran` reconciles: 56 accounted for, 54 run, 2 NOT MEASURED. 53 exit 0. Three are NOT MEASURED locally; CI runs them: - `check-engine-split-ratio --days 90` exit 2, CANNOT COMPUTE: history starts 2026-09-15, inside the window. - `check-plugin-teardown-shape --self-test` exit 3: the pinned fixture commit is not in this shallow clone. - `check:dual-build-cjs-loads` exit 3, PREREQUISITE NOT MET: no `dist/` for the other packages. The export lists above answer its question for this package. - Hand gates, all exit 0: `check-adr-anchors`, `check-adr-symbol-anchors`, `check-nul-bytes`, `check-platform-checklist --changed-from origin/main`, and `pnpm --filter @objectstack/spec check:liveness`. --- _Generated by [Claude Code](https://claude.ai/code/session_017jiggF9SoTgeCHgPWuGdU9)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 645ede5 commit 702b44f

2 files changed

Lines changed: 208 additions & 97 deletions

File tree

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#25522 cut 2] The datasource-routing bodies of `ObjectQL` — `getDriver`
5+
* (the name the routing order decides on, turned into a driver, with the
6+
* diagnosis when that driver is missing: #4462, framework#3828),
7+
* `resolveDatasourceFromMapping` (first matching `datasourceMapping` rule
8+
* wins) and `matchPattern` (the rules' glob match) — moved out of `engine.ts`
9+
* whole.
10+
*
11+
* A MOVE, not a redesign. Each function below is the body of the `ObjectQL`
12+
* method of the same name as it stood, re-indented one level and otherwise
13+
* unedited: the same debug lines emitted at the decision they describe, the
14+
* same three refusals for a binding with no driver, the same priority sort and
15+
* rule ladder, the same escaped glob.
16+
*
17+
* What stays in the class: `resolveDatasourceBinding`, the routing order
18+
* itself, because the liveness ledgers in `packages/spec/liveness/` and an ADR
19+
* cite it by `engine.ts#resolveDatasourceBinding` as the reader of
20+
* `object.datasource` and `manifest.defaultDatasource`; its two public faces
21+
* `resolveEffectiveDatasource` and `resolveMappedDatasource`, and
22+
* `isSystemLedgerObject`, whose bodies are shorter than the host literal
23+
* moving them would cost; `setDatasourceMapping`; and the two public statics.
24+
*
25+
* ## How the moved bodies reach the class
26+
*
27+
* The shape #23050 set for `rest-server.ts` (its `data-action-routes.ts`
28+
* header carries the full reasoning), applied per method as
29+
* `engine-validation-messages.ts` applies it. The class keeps every method —
30+
* the published `.d.ts` lists each one's name, docblock and position — and
31+
* each body is one literal of exactly the {@link DatasourceRoutingHost}
32+
* members that method reads, plus a `.call`; `matchPattern` reads no member,
33+
* takes no host and is called directly. A method is handed over as a closure
34+
* that calls it on the engine at call time, so a test double or spy set on the
35+
* instance is still the one the body reaches: `getDriver` reaches
36+
* `resolveDatasourceBinding`, and `resolveDatasourceFromMapping` reaches
37+
* `matchPattern`, through the engine, exactly as before.
38+
*
39+
* The fields the bodies read (`drivers`, `logger`, `unavailableDatasources`,
40+
* `datasourceMapping`) go as GETTERS, so each read happens when the body makes
41+
* it: `setDatasourceMapping` replaces the rule array and `registerDriver`
42+
* changes the driver map, and a body sees whatever is installed at the moment
43+
* it reads, as it did.
44+
*
45+
* This module imports the engine only as a TYPE, so there is no module cycle
46+
* at run time, and nothing re-exports it, so the published surface does not
47+
* move.
48+
*/
49+
50+
import type { IDataDriver } from '@objectstack/core';
51+
import type { ObjectQL } from './engine.js';
52+
import { DatasourceUnavailableError } from './driver-connect-errors.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 signature on the class is a change here too, and naming a member
59+
* the class does not have is a compile error. See the module header for which
60+
* members are getters.
61+
*/
62+
export interface DatasourceRoutingHost {
63+
readonly drivers: ObjectQL['drivers'];
64+
readonly logger: ObjectQL['logger'];
65+
readonly unavailableDatasources: ObjectQL['unavailableDatasources'];
66+
readonly datasourceMapping: ObjectQL['datasourceMapping'];
67+
resolveDatasourceBinding: ObjectQL['resolveDatasourceBinding'];
68+
matchPattern: ObjectQL['matchPattern'];
69+
}
70+
71+
/** The body of `ObjectQL#getDriver`; the method's docblock there is its contract. */
72+
export function getDriver(
73+
this: Pick<DatasourceRoutingHost, 'resolveDatasourceBinding' | 'drivers' | 'logger' | 'unavailableDatasources'>,
74+
objectName: string,
75+
): IDataDriver {
76+
const binding = this.resolveDatasourceBinding(objectName);
77+
78+
if (binding) {
79+
const driver = this.drivers.get(binding.datasource);
80+
if (driver) {
81+
// Debug lines kept at the DECISION they describe, but emitted here so
82+
// the resolver stays free of side effects: it also answers the public
83+
// {@link resolveEffectiveDatasource} probe, which must be able to name
84+
// a datasource without logging a routing event that never happened.
85+
if (binding.via === 'mapping') {
86+
this.logger.debug('Resolved datasource from mapping', {
87+
object: objectName,
88+
datasource: binding.datasource
89+
});
90+
} else if (binding.via === 'package') {
91+
this.logger.debug('Resolved datasource from package manifest', {
92+
object: objectName,
93+
package: binding.packageId,
94+
datasource: binding.datasource
95+
});
96+
}
97+
return driver;
98+
}
99+
100+
// Only steps 1 and 2 reach here. Steps 3-5 answer ONLY when their driver
101+
// is registered (that registration is what opts the deployment into
102+
// lifecycle separation / a package default / a global default at all), so
103+
// a binding they produce always resolves.
104+
//
105+
// The datasource layer may have recorded WHY this one has no driver —
106+
// refused by the host policy, or failed to connect under
107+
// OS_ALLOW_DRIVER_CONNECT_FAILURE (framework#3828). Saying so beats
108+
// sending the reader hunting for a typo that isn't there.
109+
const unavailable = this.unavailableDatasources.get(binding.datasource);
110+
if (unavailable) {
111+
throw new DatasourceUnavailableError(
112+
binding.datasource,
113+
objectName,
114+
unavailable.kind,
115+
unavailable.publicDetail,
116+
);
117+
}
118+
if (binding.via === 'mapping') {
119+
throw new Error(
120+
`[ObjectQL] Datasource '${binding.datasource}' mapped for object '${objectName}' is not registered. ` +
121+
`A datasourceMapping rule routes this object to it, so falling back to the default store would ` +
122+
`write the object's data to a different database than the one it declares. Fix the datasource ` +
123+
`configuration, or remove the mapping rule.`,
124+
);
125+
}
126+
// No record: nothing ever tried to connect this name, so it is genuinely
127+
// undeclared (or misspelled). Unchanged message — there is nothing to add.
128+
throw new Error(`[ObjectQL] Datasource '${binding.datasource}' configured for object '${objectName}' is not registered.`);
129+
}
130+
131+
throw new Error(`[ObjectQL] No driver available for object '${objectName}'`);
132+
}
133+
134+
/** The body of `ObjectQL#resolveDatasourceFromMapping`; the method's docblock there is its contract. */
135+
export function resolveDatasourceFromMapping(
136+
this: Pick<DatasourceRoutingHost, 'datasourceMapping' | 'matchPattern'>,
137+
objectName: string,
138+
object?: any
139+
): string | null {
140+
if (!this.datasourceMapping || this.datasourceMapping.length === 0) {
141+
return null;
142+
}
143+
144+
// Sort rules by priority if any have priority set
145+
const sortedRules = [...this.datasourceMapping].sort((a, b) => {
146+
const aPriority = a.priority ?? 1000;
147+
const bPriority = b.priority ?? 1000;
148+
return aPriority - bPriority;
149+
});
150+
151+
for (const rule of sortedRules) {
152+
// 1. Match by namespace
153+
if (rule.namespace && object?.namespace === rule.namespace) {
154+
return rule.datasource;
155+
}
156+
157+
// 2. Match by package ID
158+
if (rule.package && object?.packageId === rule.package) {
159+
return rule.datasource;
160+
}
161+
162+
// 3. Match by object name pattern (glob-style)
163+
if (rule.objectPattern && this.matchPattern(objectName, rule.objectPattern)) {
164+
return rule.datasource;
165+
}
166+
167+
// 4. Default fallback rule
168+
if (rule.default) {
169+
return rule.datasource;
170+
}
171+
}
172+
173+
return null;
174+
}
175+
176+
/** The body of `ObjectQL#matchPattern`; the method's docblock there is its contract. */
177+
export function matchPattern(objectName: string, pattern: string): boolean {
178+
const regexPattern = pattern
179+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
180+
.replace(/\*/g, '.*') // * → .*
181+
.replace(/\?/g, '.'); // ? → .
182+
183+
const regex = new RegExp(`^${regexPattern}$`);
184+
return regex.test(objectName);
185+
}

‎packages/objectql/src/engine.ts‎

Lines changed: 23 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ import {
102102
// [#25522 cut 1] The validation-message localisation helpers; the methods of the
103103
// same names below hand each body the members it reads (`ValidationMessageHost`).
104104
import { negotiatedMessageLocale, objectDisplayLabel, validationMessageContext } from './engine-validation-messages.js';
105+
// [#25522 cut 2] The datasource-routing bodies; the methods of the same names
106+
// below hand each body the members it reads (`DatasourceRoutingHost`).
107+
import { getDriver, matchPattern, resolveDatasourceFromMapping } from './engine-datasource-routing.js';
105108
import { ExecutionContext, ExecutionContextSchema, type InstalledPackageSource } from '@objectstack/spec/kernel';
106109
// [#21120] The family-wide stored-metadata-body seam. A `data.record.*` event
107110
// carries the written row in `after` (and the input patch in `changes`), so a
@@ -12369,62 +12372,17 @@ export class ObjectQL implements IObjectQLEngine {
1236912372
* that driver is missing.
1237012373
*/
1237112374
private getDriver(objectName: string): IDataDriver {
12372-
const binding = this.resolveDatasourceBinding(objectName);
12373-
12374-
if (binding) {
12375-
const driver = this.drivers.get(binding.datasource);
12376-
if (driver) {
12377-
// Debug lines kept at the DECISION they describe, but emitted here so
12378-
// the resolver stays free of side effects: it also answers the public
12379-
// {@link resolveEffectiveDatasource} probe, which must be able to name
12380-
// a datasource without logging a routing event that never happened.
12381-
if (binding.via === 'mapping') {
12382-
this.logger.debug('Resolved datasource from mapping', {
12383-
object: objectName,
12384-
datasource: binding.datasource
12385-
});
12386-
} else if (binding.via === 'package') {
12387-
this.logger.debug('Resolved datasource from package manifest', {
12388-
object: objectName,
12389-
package: binding.packageId,
12390-
datasource: binding.datasource
12391-
});
12392-
}
12393-
return driver;
12394-
}
12395-
12396-
// Only steps 1 and 2 reach here. Steps 3-5 answer ONLY when their driver
12397-
// is registered (that registration is what opts the deployment into
12398-
// lifecycle separation / a package default / a global default at all), so
12399-
// a binding they produce always resolves.
12400-
//
12401-
// The datasource layer may have recorded WHY this one has no driver —
12402-
// refused by the host policy, or failed to connect under
12403-
// OS_ALLOW_DRIVER_CONNECT_FAILURE (framework#3828). Saying so beats
12404-
// sending the reader hunting for a typo that isn't there.
12405-
const unavailable = this.unavailableDatasources.get(binding.datasource);
12406-
if (unavailable) {
12407-
throw new DatasourceUnavailableError(
12408-
binding.datasource,
12409-
objectName,
12410-
unavailable.kind,
12411-
unavailable.publicDetail,
12412-
);
12413-
}
12414-
if (binding.via === 'mapping') {
12415-
throw new Error(
12416-
`[ObjectQL] Datasource '${binding.datasource}' mapped for object '${objectName}' is not registered. ` +
12417-
`A datasourceMapping rule routes this object to it, so falling back to the default store would ` +
12418-
`write the object's data to a different database than the one it declares. Fix the datasource ` +
12419-
`configuration, or remove the mapping rule.`,
12420-
);
12421-
}
12422-
// No record: nothing ever tried to connect this name, so it is genuinely
12423-
// undeclared (or misspelled). Unchanged message — there is nothing to add.
12424-
throw new Error(`[ObjectQL] Datasource '${binding.datasource}' configured for object '${objectName}' is not registered.`);
12425-
}
12426-
12427-
throw new Error(`[ObjectQL] No driver available for object '${objectName}'`);
12375+
// [#25522 cut 2] The body moved to `engine-datasource-routing.ts` (see `DatasourceRoutingHost`).
12376+
const readDrivers = () => this.drivers;
12377+
const readLogger = () => this.logger;
12378+
const readUnavailableDatasources = () => this.unavailableDatasources;
12379+
const host: ThisParameterType<typeof getDriver> = {
12380+
resolveDatasourceBinding: (...a) => this.resolveDatasourceBinding(...a),
12381+
get drivers() { return readDrivers(); },
12382+
get logger() { return readLogger(); },
12383+
get unavailableDatasources() { return readUnavailableDatasources(); },
12384+
};
12385+
return getDriver.call(host, objectName);
1242812386
}
1242912387

1243012388
/**
@@ -12576,54 +12534,22 @@ export class ObjectQL implements IObjectQLEngine {
1257612534
objectName: string,
1257712535
object?: any
1257812536
): string | null {
12579-
if (!this.datasourceMapping || this.datasourceMapping.length === 0) {
12580-
return null;
12581-
}
12582-
12583-
// Sort rules by priority if any have priority set
12584-
const sortedRules = [...this.datasourceMapping].sort((a, b) => {
12585-
const aPriority = a.priority ?? 1000;
12586-
const bPriority = b.priority ?? 1000;
12587-
return aPriority - bPriority;
12588-
});
12589-
12590-
for (const rule of sortedRules) {
12591-
// 1. Match by namespace
12592-
if (rule.namespace && object?.namespace === rule.namespace) {
12593-
return rule.datasource;
12594-
}
12595-
12596-
// 2. Match by package ID
12597-
if (rule.package && object?.packageId === rule.package) {
12598-
return rule.datasource;
12599-
}
12600-
12601-
// 3. Match by object name pattern (glob-style)
12602-
if (rule.objectPattern && this.matchPattern(objectName, rule.objectPattern)) {
12603-
return rule.datasource;
12604-
}
12605-
12606-
// 4. Default fallback rule
12607-
if (rule.default) {
12608-
return rule.datasource;
12609-
}
12610-
}
12611-
12612-
return null;
12537+
// [#25522 cut 2] The body moved to `engine-datasource-routing.ts` (see `DatasourceRoutingHost`).
12538+
const readDatasourceMapping = () => this.datasourceMapping;
12539+
const host: ThisParameterType<typeof resolveDatasourceFromMapping> = {
12540+
get datasourceMapping() { return readDatasourceMapping(); },
12541+
matchPattern: (...a) => this.matchPattern(...a),
12542+
};
12543+
return resolveDatasourceFromMapping.call(host, objectName, object);
1261312544
}
1261412545

1261512546
/**
1261612547
* Simple glob pattern matching
1261712548
* Supports * (any chars) and ? (single char)
1261812549
*/
1261912550
private matchPattern(objectName: string, pattern: string): boolean {
12620-
const regexPattern = pattern
12621-
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // Escape regex special chars
12622-
.replace(/\*/g, '.*') // * → .*
12623-
.replace(/\?/g, '.'); // ? → .
12624-
12625-
const regex = new RegExp(`^${regexPattern}$`);
12626-
return regex.test(objectName);
12551+
// [#25522 cut 2] The body moved to `engine-datasource-routing.ts`; it reads no member.
12552+
return matchPattern(objectName, pattern);
1262712553
}
1262812554

1262912555
/**

0 commit comments

Comments
 (0)