Skip to content

Commit c70b6b5

Browse files
committed
refactor(objectql): move the datasource-routing bodies out of engine.ts into engine-datasource-routing.ts (#25522 cut 2)
Move-only. The bodies of ObjectQL#getDriver, #resolveDatasourceFromMapping and #matchPattern move unedited into engine-datasource-routing.ts and reach the class through DatasourceRoutingHost (getters for the fields, late-bound closures for resolveDatasourceBinding and matchPattern). Each method keeps its name, signature, docblock and position in the class. resolveDatasourceBinding stays: packages/spec/liveness ledgers and an ADR cite it by engine.ts#resolveDatasourceBinding. Claude-Session: https://claude.ai/code/session_017jiggF9SoTgeCHgPWuGdU9 Co-authored-by: Claude <noreply@anthropic.com>
1 parent cc10e74 commit c70b6b5

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)