Skip to content

Commit 6fde910

Browse files
fix(objectql,service-analytics): 报告对象「实际所在」的 datasource,而非它声明的那个 (#5288) (#6820)
analytics 的 `getObjectDatasource` 探针读的是 `getObject(name).datasource` —— 对象**声明**的值,也就是 `ObjectQL.getDriver` 五步解析顺序里的第 1 步。 `ObjectSchema.datasource` 带 `.default('default')`,而 `'default'` 在引擎里 的含义是「没有显式绑定,继续往下查」,所以凡是被 `datasourceMapping` 规则、 ADR-0057 §3.6 生命周期分流、或所属 package 的 `defaultDatasource` 路由过去 的对象,一律回答 `'default'`,在探针的读者那里又被当成「主库」。 `sys_audit_log` 就是活标本:`lifecycle.class: 'audit'` 把它放到 `telemetry`, 全程没有任何声明可读。于是 #5033 那条查询期诊断 —— 它存在的唯一意义就是 点名「表不在哪个库」—— 点错了库。 引擎侧新增 `ObjectQL.resolveEffectiveDatasource(objectName)`:`getDriver` 既有解析顺序的公开、只算名字的那一面,抽出来是为了让这个顺序只存在一份 (与 #4462 抽出 `resolveMappedDatasource` 是同一个理由:第二份更短的路由 实现必然少一步,而且是悄悄地少)。`getDriver` 改为消费同一个解析器,行为 逐条不变 —— 优先级、声明/映射的 datasource 无驱动时拒绝回落到默认库、 两条诊断文案都原样保留。 对象什么都没被绑定、纯粹骑着部署默认驱动时,访问器答 `undefined`:默认驱动 保留自己的自然名(#3826),那是驱动名而不是谁把这个对象绑上去的 datasource; 而这也正是消费方早已写在文档里的口径。 analytics 侧只做一件事:探针改问引擎。路由规则**没有**在 analytics 侧重算。 #5115 的编译期闸门判据一字未改,变化的是它的输入现在能对隐式路由的对象作答。 Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei Co-authored-by: Claude <noreply@anthropic.com>
1 parent d03fe25 commit 6fde910

8 files changed

Lines changed: 766 additions & 72 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
---
2+
"@objectstack/objectql": patch
3+
"@objectstack/service-analytics": patch
4+
---
5+
6+
fix(objectql,service-analytics): report the datasource an object is actually on, not the one it declares (#5288)
7+
8+
Analytics' `getObjectDatasource` probe read `getObject(name).datasource` — the
9+
object's **declared** value, which is step 1 of the five `ObjectQL.getDriver`
10+
resolves by. `ObjectSchema.datasource` carries `.default('default')`, and
11+
`'default'` means "no explicit binding, keep looking" inside the engine, so
12+
every object placed by a `datasourceMapping` rule, by the ADR-0057 §3.6
13+
lifecycle split, or by its package's `defaultDatasource` answered `'default'`
14+
and was read out here as "the primary DB".
15+
16+
`sys_audit_log` is the live specimen: `lifecycle.class: 'audit'` puts it on the
17+
`telemetry` datasource with nothing declared to read. So #5033's query-time
18+
diagnostic — whose entire job is to NAME the database a table is missing from —
19+
named the wrong one:
20+
21+
```
22+
before: table "account" is not on datasource "default", which is where its base object "sys_audit_log" lives
23+
after: table "account" is not on datasource "telemetry", which is where its base object "sys_audit_log" lives
24+
```
25+
26+
**New engine accessor — `ObjectQL.resolveEffectiveDatasource(objectName)`.** The
27+
public, name-only face of the resolution order `getDriver` already routes by,
28+
extracted so the order exists exactly once (the same argument that produced
29+
`resolveMappedDatasource` in #4462: a second, shorter copy of a routing order
30+
drifts by one step, silently). `getDriver` now consumes the same resolver and
31+
keeps every existing behaviour — precedence, the refusal to fall through to the
32+
default store when a declared or mapped datasource has no live driver, and both
33+
of its diagnostics.
34+
35+
It answers `undefined` when nothing binds the object anywhere and it simply
36+
rides the deployment's default driver. That is deliberate and unchanged from
37+
what consumers already documented: the default driver keeps its natural name
38+
(#3826), so that name identifies a driver rather than a datasource anyone bound
39+
the object to. `getDefaultDriverName()` is still there for callers that want it.
40+
41+
Analytics' probe now asks the engine instead of the declaration; the routing
42+
rules are **not** re-implemented on the analytics side. #5115's compile-time
43+
cross-datasource join gate keeps its predicate exactly as written — what changed
44+
is that its input can now answer for objects bound by a mapping rule, by the
45+
lifecycle split, or by a package default, so a join between two bound
46+
datasources is refused at registration instead of exploding at query time. A
47+
join from a bound object to one that merely rides the deployment default is
48+
still not decidable at compile time and remains the query-time diagnostic's
49+
business.
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #5288 — `resolveEffectiveDatasource`: the datasource an object's rows are
5+
* actually on, by NAME.
6+
*
7+
* The engine has five resolution steps (`getDriver`), and everyone who only
8+
* needed the NAME used to read `object.datasource` — step 1. That value is the
9+
* DECLARATION, and `ObjectSchema.datasource` carries `.default('default')`, so
10+
* an object placed by a `datasourceMapping` rule, by the ADR-0057 §3.6
11+
* lifecycle split, or by its package's `defaultDatasource` still answered
12+
* `'default'` — which in `getDriver` means "no explicit binding, keep looking",
13+
* never "the primary DB". Analytics' `getObjectDatasource` probe was such a
14+
* reader, and #5033's query-time diagnostic composed from it therefore named a
15+
* database the rows are not in.
16+
*
17+
* These cases pin the accessor against `getDriver` itself: for every routing
18+
* mechanism, the NAME it answers is the name of the driver `getDriver` picks.
19+
* One resolution order, two shapes of answer.
20+
*/
21+
22+
import { describe, it, expect, beforeEach } from 'vitest';
23+
import { ObjectSchema } from '@objectstack/spec/data';
24+
import { ObjectQL } from './engine.js';
25+
26+
/** Owning package for the probe objects — no manifest is registered for it, so
27+
* step 4 stays a no-op unless a case registers one. */
28+
const PKG = 'com.example.probe';
29+
30+
function stubDriver(name: string) {
31+
return {
32+
name,
33+
version: '0.0.0',
34+
supports: {},
35+
async connect() {},
36+
async disconnect() {},
37+
async checkHealth() {
38+
return true;
39+
},
40+
async execute() {
41+
return null;
42+
},
43+
async find() {
44+
return [];
45+
},
46+
async findOne() {
47+
return null;
48+
},
49+
async create(_o: string, d: Record<string, unknown>) {
50+
return d;
51+
},
52+
async update() {
53+
return {};
54+
},
55+
async upsert() {
56+
return {};
57+
},
58+
async delete() {
59+
return true;
60+
},
61+
async count() {
62+
return 0;
63+
},
64+
async bulkCreate() {
65+
return [];
66+
},
67+
async bulkUpdate() {
68+
return [];
69+
},
70+
async bulkDelete() {},
71+
async beginTransaction() {
72+
return {};
73+
},
74+
async commit() {},
75+
async rollback() {},
76+
} as any;
77+
}
78+
79+
describe('resolveEffectiveDatasource — one name per routing step (#5288)', () => {
80+
let engine: ObjectQL;
81+
let primary: any;
82+
83+
beforeEach(async () => {
84+
engine = new ObjectQL();
85+
primary = stubDriver('memory');
86+
engine.registerDriver(primary, true);
87+
await engine.init();
88+
});
89+
90+
// ── Step 1: an explicit binding. The one step the old declared read got right ─
91+
92+
it('answers the explicit `datasource` — the step the declared read already had', () => {
93+
engine.registerDriver(stubDriver('warehouse'));
94+
engine.registry.registerObject({ name: 'wh_fact', datasource: 'warehouse', fields: {} }, PKG);
95+
96+
expect(engine.resolveEffectiveDatasource('wh_fact')).toBe('warehouse');
97+
// …and it is the driver `getDriver` picks, not a parallel opinion about it.
98+
expect(engine.getDriverForObject('wh_fact')).toBe(engine.getDriverByName('warehouse'));
99+
});
100+
101+
// ── Step 2: a datasourceMapping rule ────────────────────────────────────────
102+
103+
it('answers the mapped datasource for an object a mapping rule places (step 2)', () => {
104+
engine.registerDriver(stubDriver('archive'));
105+
engine.setDatasourceMapping([{ objectPattern: 'log_*', datasource: 'archive' }]);
106+
engine.registry.registerObject({ name: 'log_request', fields: {} }, PKG);
107+
108+
// What the probe used to read — the declaration — says nothing at all here.
109+
expect(engine.getObject('log_request')?.datasource).toBeUndefined();
110+
111+
expect(engine.resolveEffectiveDatasource('log_request')).toBe('archive');
112+
expect(engine.getDriverForObject('log_request')).toBe(engine.getDriverByName('archive'));
113+
});
114+
115+
// ── Step 3: the ADR-0057 §3.6 lifecycle split — #5033's own object ──────────
116+
117+
it('answers `telemetry` for a lifecycle-classed ledger (step 3) — the #5033 case', () => {
118+
engine.registerDriver(stubDriver(ObjectQL.LIFECYCLE_DATASOURCE));
119+
engine.registry.registerObject({
120+
name: 'sys_audit_log',
121+
lifecycle: { class: 'audit', retention: { maxAge: '90d' } },
122+
fields: {},
123+
}, PKG);
124+
125+
expect(engine.resolveEffectiveDatasource('sys_audit_log')).toBe('telemetry');
126+
expect(engine.getDriverForObject('sys_audit_log')).toBe(
127+
engine.getDriverByName(ObjectQL.LIFECYCLE_DATASOURCE),
128+
);
129+
});
130+
131+
it('is Zod-parse independent: the declared read answers `default`, this answers `telemetry`', () => {
132+
// The exact shape the defect wore in a real deployment. `ObjectSchema` gives
133+
// `datasource` a `.default('default')`, so a PARSED object carries the
134+
// string `'default'` — and the old probe reported it as if it were a
135+
// database name, for an object the engine had routed elsewhere.
136+
engine.registerDriver(stubDriver(ObjectQL.LIFECYCLE_DATASOURCE));
137+
const parsed = ObjectSchema.parse({
138+
name: 'sys_audit_log',
139+
label: 'Audit log',
140+
lifecycle: { class: 'audit', retention: { maxAge: '90d' } },
141+
fields: { action: { type: 'text', label: 'Action' } },
142+
});
143+
expect(parsed.datasource).toBe('default');
144+
engine.registry.registerObject(parsed, PKG);
145+
146+
expect(engine.resolveEffectiveDatasource('sys_audit_log')).toBe('telemetry');
147+
});
148+
149+
it('does NOT invent lifecycle routing when no telemetry datasource is registered', () => {
150+
// Step 3 is opt-in by the datasource's existence. Without it the ledger
151+
// really is on the default store, and saying `'telemetry'` would be the
152+
// same lie in the other direction.
153+
engine.registry.registerObject({
154+
name: 'sys_audit_log',
155+
lifecycle: { class: 'audit', retention: { maxAge: '90d' } },
156+
fields: {},
157+
}, PKG);
158+
159+
expect(engine.resolveEffectiveDatasource('sys_audit_log')).toBeUndefined();
160+
expect(engine.getDriverForObject('sys_audit_log')).toBe(primary);
161+
});
162+
163+
// ── Step 4: the owning package's defaultDatasource ──────────────────────────
164+
165+
it("answers the owning package's `defaultDatasource` (step 4)", () => {
166+
engine.registerDriver(stubDriver('billing_db'));
167+
engine.registerApp({
168+
id: 'com.example.billing',
169+
name: 'billing',
170+
defaultDatasource: 'billing_db',
171+
objects: [{ name: 'invoice', fields: {} }],
172+
});
173+
174+
expect(engine.getObject('invoice')?.datasource).toBeUndefined();
175+
expect(engine.resolveEffectiveDatasource('invoice')).toBe('billing_db');
176+
expect(engine.getDriverForObject('invoice')).toBe(engine.getDriverByName('billing_db'));
177+
});
178+
179+
it('ignores a package default whose datasource has no driver, exactly as getDriver does', () => {
180+
engine.registerApp({
181+
id: 'com.example.billing',
182+
name: 'billing',
183+
defaultDatasource: 'never_connected',
184+
objects: [{ name: 'invoice', fields: {} }],
185+
});
186+
187+
// Step 4 answers only when the driver exists — the rows are on the default
188+
// store, and that is what both the driver lookup and the name report.
189+
expect(engine.resolveEffectiveDatasource('invoice')).toBeUndefined();
190+
expect(engine.getDriverForObject('invoice')).toBe(primary);
191+
});
192+
193+
// ── Step 5 / no routing at all: unchanged, and deliberately `undefined` ─────
194+
195+
it('answers `undefined` for an object nothing binds — it rides the default driver', () => {
196+
// Unchanged from what the declared read answered for such an object, and
197+
// deliberate: the default driver keeps its NATURAL name (#3826, here
198+
// `memory`), so that name identifies a DRIVER, not a datasource anyone bound
199+
// this object to. Callers that want it have `getDefaultDriverName()`.
200+
engine.registry.registerObject({ name: 'biz_account', fields: {} }, PKG);
201+
202+
expect(engine.resolveEffectiveDatasource('biz_account')).toBeUndefined();
203+
expect(engine.getDriverForObject('biz_account')).toBe(primary);
204+
expect(engine.getDefaultDriverName()).toBe('memory');
205+
});
206+
207+
it('answers `undefined` for an object this engine has never heard of', () => {
208+
expect(engine.resolveEffectiveDatasource('no_such_object')).toBeUndefined();
209+
});
210+
211+
// ── Precedence and the broken-deployment case ──────────────────────────────
212+
213+
it('keeps getDriver’s precedence: an explicit binding outranks lifecycle routing', () => {
214+
engine.registerDriver(stubDriver(ObjectQL.LIFECYCLE_DATASOURCE));
215+
engine.registerDriver(stubDriver('special'));
216+
engine.registry.registerObject({
217+
name: 'probe_pinned',
218+
datasource: 'special',
219+
lifecycle: { class: 'telemetry', retention: { maxAge: '14d' } },
220+
fields: {},
221+
}, PKG);
222+
223+
expect(engine.resolveEffectiveDatasource('probe_pinned')).toBe('special');
224+
});
225+
226+
it('keeps getDriver’s precedence: a mapping rule outranks lifecycle routing', () => {
227+
engine.registerDriver(stubDriver(ObjectQL.LIFECYCLE_DATASOURCE));
228+
engine.registerDriver(stubDriver('archive'));
229+
engine.setDatasourceMapping([{ objectPattern: 'sys_audit_*', datasource: 'archive' }]);
230+
engine.registry.registerObject({
231+
name: 'sys_audit_log',
232+
lifecycle: { class: 'audit', retention: { maxAge: '90d' } },
233+
fields: {},
234+
}, PKG);
235+
236+
expect(engine.resolveEffectiveDatasource('sys_audit_log')).toBe('archive');
237+
expect(engine.getDriverForObject('sys_audit_log')).toBe(engine.getDriverByName('archive'));
238+
});
239+
240+
it('names a binding whose driver is missing instead of throwing', () => {
241+
// A naming probe exists to be READ, including while the deployment is
242+
// broken: `getDriver` refuses to serve this object (it will not silently
243+
// write to the default store — #4462), and the name it refuses ON is
244+
// precisely what a diagnostic needs to print.
245+
engine.registry.registerObject({ name: 'wh_fact', datasource: 'warehouse', fields: {} }, PKG);
246+
247+
expect(engine.resolveEffectiveDatasource('wh_fact')).toBe('warehouse');
248+
expect(() => engine.getDriverForObject('wh_fact')).not.toThrow();
249+
expect(engine.getDriverForObject('wh_fact')).toBeUndefined();
250+
});
251+
});

0 commit comments

Comments
 (0)