diff --git a/.changeset/analytics-raw-sql-object-routing.md b/.changeset/analytics-raw-sql-object-routing.md new file mode 100644 index 0000000000..fd7f9df87e --- /dev/null +++ b/.changeset/analytics-raw-sql-object-routing.md @@ -0,0 +1,46 @@ +--- +"@objectstack/service-analytics": patch +--- + +fix(service-analytics): the dataset raw-SQL bridge routes by object, so datasets over non-default datasources stop reading `0` (#5033) + +`AnalyticsServicePlugin`'s `executeRawSql` auto-bridge received the object name +and threw it away: `engine.execute(knexSql, { args: params })`. `ObjectQL.execute()` +picks its driver in the order `options.object` → `getDriver(object)`, then +`options.datasource`, then the default driver — so rule 1 could never fire and +**every dataset raw-SQL read landed on the default datasource**. Any object routed +elsewhere (the ADR-0057 §3.6 telemetry split for `lifecycle.class ∈ {audit, +telemetry, event}`, an explicit `object.datasource`, a `datasourceMapping` rule) +raised `no such table`, which the widget-level graceful degradation then turned +into an empty result — a confident `0` over live rows, on a green dashboard. +Measured: `sys_audit_log` returned 49 records through the object-routed read and +`{"rows":[]}` through the dataset raw-SQL read, on the same running kernel. + +The bridge now passes `{ args: params, object: objectName }`, matching the +`executeAggregate` bridge beside it (`engine.aggregate(objectName, …)`), so both +dataset execution paths give **one** answer to "which datasource is this object in". +No configuration change is needed; misrouted dashboards start reading real data. + +**Behaviour change worth knowing about.** A dataset whose SQL `LEFT JOIN`s (what +`NativeSQLStrategy` emits for a dotted dimension such as `account.industry`) across +two datasources previously ran against the default datasource and silently read the +wrong database. It now runs on the base object's own datasource, where the joined +table genuinely is not — and **fails loudly** instead of degrading, because the base +table resolved fine and reporting it as "unavailable" would keep the confident `0` +alive under a new cause. The error names the actual cause and the remedy: + +``` +[Analytics] dataset "audit_by_actor" cannot be executed as one statement: +table "account" is not on datasource "telemetry", which is where its base object +"sys_audit_log" lives — "account" is registered on the default datasource. +A dataset JOIN cannot cross datasources. Fix it by binding both objects to the +same datasource, or by dropping the cross-datasource relationship from the +dataset's `include`/dimensions. +``` + +Graceful degradation is unchanged for genuine absence: a dataset whose own backing +object (or a joined object that this kernel never registered) has no table still +renders as "no data" with the existing server-side `warn`, rather than failing the +widget. `AnalyticsServiceConfig` gains one optional, diagnostics-only hook — +`getObjectDatasource(objectName)` — used solely to name the datasources in that +message; it never selects a driver. diff --git a/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts b/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts new file mode 100644 index 0000000000..6294a7075b --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/raw-sql-object-routing.test.ts @@ -0,0 +1,366 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5033 — the dataset raw-SQL bridge must route by OBJECT, not to whatever the + * default datasource happens to be. + * + * `ObjectQL.execute()` picks its driver in the order + * `options.object` → `getDriver(object)`, then `options.datasource`, then the + * default driver. `AnalyticsServicePlugin`'s auto-bridge received the object + * name and dropped it, so rule 1 could never fire and every dataset raw-SQL + * read landed on the default DB. Objects routed elsewhere — the ADR-0057 §3.6 + * telemetry split (`lifecycle.class ∈ {audit, telemetry, event}`), an explicit + * `object.datasource`, a `datasourceMapping` rule — hit `no such table`, which + * the widget-level graceful degradation then turned into a confident `0` over + * 49 live audit rows. The `executeAggregate` bridge in the same file has always + * routed correctly (`engine.aggregate(objectName, …)`), so the two dataset + * execution paths disagreed about where an object lives; which strategy the + * orchestrator picked decided whether you read data or zero. + * + * These cases drive the REAL plugin wiring (the `fakePluginContext` harness + * `execution-context-bridge.test.ts` established) against an engine double that + * resolves its driver exactly the way `engine.execute` documents — because the + * defect was invisible to every test that stubbed `executeRawSql` directly. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DatasetSchema } from '@objectstack/spec/ui'; +import { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsServicePlugin } from '../plugin.js'; + +// ── Engine double: two datasources, driver chosen the way execute() documents ─ + +type Rows = Record[]; +/** datasource name → the tables physically present on it. */ +type Topology = Record>; + +const DEFAULT_DS = 'default'; + +interface EngineCall { sql: string; options?: { args?: unknown[]; object?: string } } + +/** + * Minimal ObjectQL stand-in. `execute()` reproduces the documented selection + * order — `options.object` → that object's datasource, else the default one — + * and then answers as a real driver would: a table that is not on the resolved + * datasource raises the driver's own `no such table` error. + */ +function fakeEngine(opts: { + /** + * The kernel's schema registry: object → its declared fields. Membership is + * also what `isRegisteredObject` reads, so an object omitted here is one this + * kernel never mounted (as opposed to one that lives on another datasource). + */ + schema: Record>; + /** object → datasource. Objects absent here ride the default datasource. */ + routing: Record; + topology: Topology; + /** The (dimension, measure alias) pair every query in this file groups by. */ + shape: { groupBy: string; alias: string }; +}) { + const calls: EngineCall[] = []; + const datasourceOf = (object?: string) => (object ? opts.routing[object] ?? DEFAULT_DS : DEFAULT_DS); + const tableOn = (ds: string, table: string): Rows | undefined => opts.topology[ds]?.[table]; + + /** COUNT(*) … GROUP BY over raw rows — the one shape these datasets ask for. */ + const groupCount = (rows: Rows): Rows => { + const buckets = new Map(); + for (const r of rows) buckets.set(r[opts.shape.groupBy], (buckets.get(r[opts.shape.groupBy]) ?? 0) + 1); + return [...buckets].map(([value, count]) => ({ [opts.shape.groupBy]: value, [opts.shape.alias]: count })); + }; + + /** Every relation the compiled statement names — the base table and its joins. */ + const relationsIn = (sql: string): string[] => { + const names: string[] = []; + const from = /\bFROM\s+["`]?([a-z0-9_]+)["`]?/i.exec(sql); + if (from?.[1]) names.push(from[1]); + for (const m of sql.matchAll(/\bJOIN\s+["`]?([a-z0-9_]+)["`]?/gi)) names.push(m[1]); + return names; + }; + + return { + calls, + engine: { + execute: async (sql: unknown, options?: { args?: unknown[]; object?: string }) => { + calls.push({ sql: String(sql), options }); + const ds = datasourceOf(options?.object); + const relations = relationsIn(String(sql)); + for (const rel of relations) { + if (!tableOn(ds, rel)) throw new Error(`SQLITE_ERROR: no such table: ${rel}`); + } + const base = relations[0] ? tableOn(ds, relations[0]) : undefined; + return { rows: groupCount(base ?? []) }; + }, + aggregate: async (object: string, options: Record) => { + const ds = datasourceOf(object); + const rows = tableOn(ds, object); + if (!rows) throw new Error(`SQLITE_ERROR: no such table: ${object}`); + const aggs = options.aggregations as Array<{ alias: string }> | undefined; + const alias = aggs?.[0]?.alias ?? opts.shape.alias; + return groupCount(rows).map((r) => ({ + [opts.shape.groupBy]: r[opts.shape.groupBy], + [alias]: r[opts.shape.alias], + })); + }, + getObject: (name: string) => { + const fields = opts.schema[name]; + if (!fields) return undefined; + const datasource = opts.routing[name]; + return datasource ? { fields, datasource } : { fields }; + }, + }, + }; +} + +/** Minimal PluginContext: the four members `AnalyticsServicePlugin.init` uses. */ +function fakePluginContext(services: Record) { + const registered: Record = {}; + const warn = vi.fn(); + return { + warn, + registered, + ctx: { + getService: (name: string) => services[name] ?? registered[name], + registerService: (name: string, svc: unknown) => { registered[name] = svc; }, + replaceService: (name: string, svc: unknown) => { registered[name] = svc; }, + logger: { info() {}, warn, error() {}, debug() {} }, + }, + }; +} + +const nativeSql = () => ({ nativeSql: true, objectqlAggregate: false, inMemory: false }); +const objectqlOnly = () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }); + +// ── The issue's exact shape: an audit object on the telemetry datasource ───── + +/** ADR-0057 §3.6 — `sys_audit_log` lives in the sibling telemetry DB, not the main one. */ +const TELEMETRY = 'telemetry'; +const auditRows: Rows = [ + { action: 'login', id: 'a1' }, + { action: 'login', id: 'a2' }, + { action: 'permission_change', id: 'a3' }, +]; + +const auditDataset = DatasetSchema.parse({ + name: 'sys_audit_log_metrics', + label: 'Audit events', + object: 'sys_audit_log', + dimensions: [{ name: 'action', field: 'action', type: 'string' }], + measures: [{ name: 'event_count', aggregate: 'count' }], +}); + +function auditEngine() { + return fakeEngine({ + schema: { sys_audit_log: { action: { type: 'text' } }, opportunity: { stage: { type: 'text' } } }, + routing: { sys_audit_log: TELEMETRY }, + topology: { + // The main DB has 88 objects — and NOT this one. Reading it here is the bug. + [DEFAULT_DS]: { opportunity: [] }, + [TELEMETRY]: { sys_audit_log: auditRows }, + }, + shape: { groupBy: 'action', alias: 'event_count' }, + }); +} + +async function analyticsVia( + engine: unknown, + capabilities: () => { nativeSql: boolean; objectqlAggregate: boolean; inMemory: boolean }, +) { + const { ctx, registered, warn } = fakePluginContext({ data: engine }); + await new AnalyticsServicePlugin({ queryCapabilities: capabilities }).init(ctx as never); + return { service: registered.analytics as AnalyticsService, warn }; +} + +const auditSelection = { dimensions: ['action'], measures: ['event_count'] }; + +describe('executeRawSql auto-bridge routes by object (#5033)', () => { + it('hands the object name to engine.execute as its driver-selection key', async () => { + const { engine, calls } = auditEngine(); + const { service } = await analyticsVia(engine, nativeSql); + + await service.queryDataset(auditDataset as never, auditSelection as never); + + // The key `engine.execute` resolves FIRST. Dropping it is the whole defect. + expect(calls).toHaveLength(1); + expect(calls[0].options?.object).toBe('sys_audit_log'); + // …alongside the bound parameters the bridge already forwarded. + expect(calls[0].options).toHaveProperty('args'); + }); + + it('reads the SAME rows through raw SQL as through the object-routed aggregate', async () => { + // The invariant this issue is really about: two dataset execution paths, + // ONE answer to "which datasource is this object in". + const raw = await analyticsVia(auditEngine().engine, nativeSql); + const routed = await analyticsVia(auditEngine().engine, objectqlOnly); + + const viaRawSql = await raw.service.queryDataset(auditDataset as never, auditSelection as never); + const viaAggregate = await routed.service.queryDataset(auditDataset as never, auditSelection as never); + + expect(viaRawSql.rows).toEqual(viaAggregate.rows); + expect(viaRawSql.rows).toEqual([ + { action: 'login', event_count: 2 }, + { action: 'permission_change', event_count: 1 }, + ]); + }); + + it('no longer reaches the graceful-degradation path at all', async () => { + // Pre-fix this query returned `{rows:[],fields:[],totals:[]}` plus a WARN + // naming `sys_audit_log` "unavailable" — over live rows. The fix must not + // merely quieten that log; the misroute that produced it is gone. + const { engine } = auditEngine(); + const { service, warn } = await analyticsVia(engine, nativeSql); + + const result = await service.queryDataset(auditDataset as never, auditSelection as never); + + expect(result.rows.length).toBeGreaterThan(0); + expect(warn.mock.calls.map(String).join('\n')).not.toMatch(/is unavailable/); + }); + + it('leaves default-datasource objects exactly where they were', async () => { + // The regression guard for the other 88 objects: naming the object routes + // to the SAME default driver they already used. + const { engine, calls } = fakeEngine({ + schema: { opportunity: { stage: { type: 'text' } } }, + routing: {}, + topology: { [DEFAULT_DS]: { opportunity: [{ stage: 'won' }, { stage: 'won' }, { stage: 'lost' }] } }, + shape: { groupBy: 'stage', alias: 'deal_count' }, + }); + const dataset = DatasetSchema.parse({ + name: 'pipeline', label: 'Pipeline', object: 'opportunity', + dimensions: [{ name: 'stage', field: 'stage', type: 'string' }], + measures: [{ name: 'deal_count', aggregate: 'count' }], + }); + const { service, warn } = await analyticsVia(engine, nativeSql); + + const result = await service.queryDataset( + dataset as never, + { dimensions: ['stage'], measures: ['deal_count'] } as never, + ); + + expect(calls[0].options?.object).toBe('opportunity'); + expect(result.rows).toEqual([ + { stage: 'won', deal_count: 2 }, + { stage: 'lost', deal_count: 1 }, + ]); + expect(warn).not.toHaveBeenCalled(); + }); +}); + +// ── The accepted behaviour change: cross-datasource joins now fail loudly ──── + +/** + * `NativeSQLStrategy` emits a `LEFT JOIN` for a dotted dimension + * (`account.region`). Once the statement is routed to the base object's OWN + * datasource, a join whose target lives elsewhere can no longer silently read + * the wrong database — it fails there. That is the correct trade (this repo's + * fail-loud line), but it must fail as ITSELF: the pre-fix wording ("backing + * object … is unavailable" → empty result) would keep the confident `0` alive + * under a new cause, with the base table sitting right there. + */ +const crossDsDataset = DatasetSchema.parse({ + name: 'audit_by_actor', + label: 'Audit by actor', + object: 'sys_audit_log', + include: ['account'], + dimensions: [{ name: 'region', field: 'account.region', type: 'string' }], + measures: [{ name: 'event_count', aggregate: 'count' }], +}); + +function crossDsEngine() { + return fakeEngine({ + schema: { + sys_audit_log: { action: { type: 'text' }, account: { type: 'lookup', reference: 'account' } }, + account: { region: { type: 'text' } }, + }, + routing: { sys_audit_log: TELEMETRY }, + topology: { + [DEFAULT_DS]: { account: [{ region: 'NA' }] }, + [TELEMETRY]: { sys_audit_log: auditRows }, + }, + shape: { groupBy: 'region', alias: 'event_count' }, + }); +} + +describe('cross-datasource dataset JOIN fails loudly, naming the real cause (#5033)', () => { + const selection = { dimensions: ['region'], measures: ['event_count'] }; + + it('rejects instead of degrading to an empty result', async () => { + const { service } = await analyticsVia(crossDsEngine().engine, nativeSql); + + await expect( + service.queryDataset(crossDsDataset as never, selection as never), + ).rejects.toThrow(/cannot be executed as one statement/); + }); + + it('names the missing table AND the datasource it is missing from', async () => { + const { service } = await analyticsVia(crossDsEngine().engine, nativeSql); + + const err = await service + .queryDataset(crossDsDataset as never, selection as never) + .then(() => null, (e: Error) => e); + + // table X … + expect(err?.message).toContain('table "account"'); + // … not on datasource Y (the one the BASE object routed to) … + expect(err?.message).toContain('datasource "telemetry"'); + expect(err?.message).toContain('base object "sys_audit_log"'); + // … and the remedy, not just the symptom. + expect(err?.message).toMatch(/JOIN cannot cross datasources/); + // NOT the misleading old shape. + expect(err?.message).not.toMatch(/backing object "sys_audit_log" is unavailable/); + }); + + it('does not emit the widget-degradation WARN for a topology error', async () => { + const { engine } = crossDsEngine(); + const { service, warn } = await analyticsVia(engine, nativeSql); + + await service.queryDataset(crossDsDataset as never, selection as never).catch(() => undefined); + + expect(warn.mock.calls.map(String).join('\n')).not.toMatch(/is unavailable/); + }); +}); + +// ── Ruling 3: the genuine-absence degradation is untouched ─────────────────── + +describe('graceful degradation survives for a genuinely missing table (#5033)', () => { + it("still returns an empty result + WARN when the dataset's OWN object is absent", async () => { + // The case the degradation exists for: a platform dashboard charting an + // object this kernel never mounted. Nothing to route to — render "no data", + // do not 500 the widget. + const { engine } = fakeEngine({ + schema: { opportunity: { stage: { type: 'text' } } }, + routing: {}, + topology: { [DEFAULT_DS]: { opportunity: [] } }, + shape: { groupBy: 'action', alias: 'event_count' }, + }); + const { service, warn } = await analyticsVia(engine, nativeSql); + + const result = await service.queryDataset(auditDataset as never, auditSelection as never); + + expect(result).toEqual({ rows: [], fields: [], totals: [] }); + expect(warn.mock.calls.map(String).join('\n')).toMatch( + /dataset "sys_audit_log_metrics" backing object "sys_audit_log" is unavailable/, + ); + }); + + it('degrades (not throws) when a JOINED object is not registered in this kernel either', async () => { + // The joined table is missing AND the object does not exist here at all — + // absence, not a routing mistake. Same tiering as the base-object case. + const { engine } = fakeEngine({ + // `account` is a declared lookup on the base object, but no such OBJECT is + // mounted in this kernel — so there is nothing to have mis-routed. + schema: { sys_audit_log: { action: { type: 'text' }, account: { type: 'lookup', reference: 'account' } } }, + routing: {}, + topology: { [DEFAULT_DS]: { sys_audit_log: auditRows } }, + shape: { groupBy: 'region', alias: 'event_count' }, + }); + const { service, warn } = await analyticsVia(engine, nativeSql); + + const result = await service.queryDataset( + crossDsDataset as never, + { dimensions: ['region'], measures: ['event_count'] } as never, + ); + + expect(result).toEqual({ rows: [], fields: [], totals: [] }); + expect(warn.mock.calls.map(String).join('\n')).toMatch(/is unavailable/); + }); +}); diff --git a/packages/services/service-analytics/src/analytics-service.ts b/packages/services/service-analytics/src/analytics-service.ts index 00e67d10c6..62b3daa59d 100644 --- a/packages/services/service-analytics/src/analytics-service.ts +++ b/packages/services/service-analytics/src/analytics-service.ts @@ -92,6 +92,44 @@ function isMissingSourceError(err: unknown): boolean { ); } +/** + * #5033 — the relation a missing-source error NAMES, when it names one. + * + * `isMissingSourceError` answers "is something missing"; this answers "what". + * The distinction decides whether the widget may degrade: a dataset whose OWN + * backing table is absent is a kernel that never mounted the object (degrade — + * that is the case the graceful path exists for), while a dataset whose + * *joined* table is absent on the datasource the base object routed to is a + * cross-datasource dataset, i.e. a topology error that must be reported as + * itself instead of hiding behind "backing object … is unavailable". + * + * Returns the bare relation name (schema/database qualifiers stripped — + * `mydb.crm_account` → `crm_account`; Prime Directive #6 makes object name = + * table name, so the result is comparable to a dataset's `object`), or + * `undefined` when the driver's phrasing carries no name. Unparseable ⇒ the + * caller keeps today's degradation, never a louder guess. + */ +function missingSourceRelation(err: unknown): string | undefined { + const msg = String((err as { message?: unknown })?.message ?? err ?? ''); + const patterns = [ + /no such table:\s*[`"'[]?([A-Za-z0-9_$.]+)/i, // sqlite / libsql + /relation\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+does not exist/i, // postgres + /table\s+[`"']?([A-Za-z0-9_$.]+)[`"']?\s+doesn't exist/i, // mysql + /(?:object|table)\s+[`"']([A-Za-z0-9_$.]+)[`"']\s+is not registered/i, // framework + /unknown object:?\s*[`"']?([A-Za-z0-9_$.]+)/i, + /[`"']([A-Za-z0-9_$.]+)[`"']\s+is not a registered object/i, + ]; + for (const re of patterns) { + const m = re.exec(msg); + if (m?.[1]) { + const parts = m[1].split('.').filter(Boolean); + const bare = parts[parts.length - 1]; + if (bare) return bare; + } + } + return undefined; +} + /** * [#4437] A name that is a plain column/table identifier and nothing else. * Anything with a dot, a paren, whitespace or an operator is a SQL EXPRESSION @@ -199,6 +237,18 @@ export interface AnalyticsServiceConfig { * `StrategyContext.isExternalObject`. */ isExternalObject?: (objectName: string) => boolean; + /** + * [#5033] The datasource `objectName` is bound to, or `undefined` when it + * rides the default one (or nothing authoritative can answer). + * + * Diagnostics only — it never selects a driver (that is `engine.execute`'s + * `object` key, which the `plugin.ts` bridge now passes). It exists so that + * when a dataset's SQL references a table that is NOT on the datasource its + * base object routed to, the failure can name the actual cause — *table X is + * not on datasource Y* — instead of the misleading "backing object … + * is unavailable" that a cross-datasource join used to produce. + */ + getObjectDatasource?: (objectName: string) => string | undefined; /** * [#3867] Is `name` a registered object in this kernel's schema registry? * @@ -330,6 +380,8 @@ export class AnalyticsService implements IAnalyticsService { private readonly isRegisteredObject?: AnalyticsServiceConfig['isRegisteredObject']; /** [#4437] Field-name probe gating measure source-field resolution. */ private readonly getObjectFieldNames?: AnalyticsServiceConfig['getObjectFieldNames']; + /** [#5033] Diagnostics-only datasource probe for the missing-source triage. */ + private readonly getObjectDatasource?: AnalyticsServiceConfig['getObjectDatasource']; /** [#3867] One-shot flag for the {@link assertInferableCube} stand-down warning. */ private warnedNoObjectRegistry = false; readonly cubeRegistry: CubeRegistry; @@ -351,6 +403,7 @@ export class AnalyticsService implements IAnalyticsService { this.draftRowsResolver = config.draftRowsResolver; this.isRegisteredObject = config.isRegisteredObject; this.getObjectFieldNames = config.getObjectFieldNames; + this.getObjectDatasource = config.getObjectDatasource; // Compile + register pre-defined datasets (ADR-0021). if (config.datasets) { @@ -608,14 +661,44 @@ export class AnalyticsService implements IAnalyticsService { // that never mounted the audit object) must render as "no data" — NOT // crash the widget with a 500. Datasets were the one read surface that // hard-failed on a missing source. + // + // #5033 — that leniency is scoped to the dataset's OWN source. Once the raw-SQL + // bridge routes by object (`plugin.ts`), a dataset that JOINS across datasources + // fails on the base object's datasource with the JOINED table missing. Reporting + // that as "backing object … is unavailable" would be the misleading old shape + // wearing a new cause: the base table is right there, and the widget would keep + // rendering the confident `0` this issue is about. So triage by WHICH relation + // the driver named, and let a cross-datasource dataset fail loudly. let result: AnalyticsResult; try { result = await new DatasetExecutor(this, orderLabels).execute(compiled, selection, context); } catch (err) { if (isMissingSourceError(err)) { + const missing = missingSourceRelation(err); + const detail = String((err as Error)?.message ?? err); + // A named relation that is NOT the dataset's own object is a joined table. + // If that object IS registered in this kernel, it exists — just not on the + // datasource this query ran against: a topology error, not an absence. + // (`isRegisteredObject` absent / unable to answer ⇒ treat as registered, + // the same "cannot answer, do not block" tiering it carries elsewhere; + // here the honest report is the loud one, since the base table resolved.) + const joined = missing && missing.toLowerCase() !== dataset.object.toLowerCase() ? missing : undefined; + if (joined && (this.isRegisteredObject?.(joined) ?? true)) { + const baseDs = this.getObjectDatasource?.(dataset.object); + const joinedDs = this.getObjectDatasource?.(joined); + const where = baseDs ? `datasource "${baseDs}"` : 'the default datasource'; + const joinedWhere = joinedDs ? `datasource "${joinedDs}"` : 'the default datasource'; + throw new Error( + `[Analytics] dataset "${dataset.name}" cannot be executed as one statement: table "${joined}" ` + + `is not on ${where}, which is where its base object "${dataset.object}" lives — ` + + `"${joined}" is registered on ${joinedWhere}. A dataset JOIN cannot cross datasources. ` + + `Fix it by binding both objects to the same datasource, or by dropping the cross-datasource ` + + `relationship from the dataset's \`include\`/dimensions. (driver said: ${detail})`, + ); + } this.logger.warn( `[Analytics] dataset "${dataset.name}" backing object "${dataset.object}" is unavailable ` + - `(${String((err as Error)?.message ?? err)}); returning an empty result instead of failing the widget`, + `(${detail}); returning an empty result instead of failing the widget`, ); return { rows: [], fields: [], totals: [] }; } diff --git a/packages/services/service-analytics/src/plugin.ts b/packages/services/service-analytics/src/plugin.ts index 78a0b475ea..dca9434d9d 100644 --- a/packages/services/service-analytics/src/plugin.ts +++ b/packages/services/service-analytics/src/plugin.ts @@ -30,7 +30,21 @@ interface DataEngineLike { */ context?: ExecutionContext; }): Promise; - execute?(command: unknown, options?: Record): Promise; + /** + * Raw command pass-through (SQL on driver-sql). The options bag is spelled out + * rather than left as `Record` because **`object` is load-bearing**: + * `ObjectQL.execute()` picks its driver in the order + * `options.object` → `getDriver(object)`, then `options.datasource`, then the + * default driver. A command that reads an object and omits `object` therefore + * runs against the DEFAULT datasource — which is how every dataset backed by a + * telemetry/audit-routed object read `0` from a populated table (#5033). + */ + execute?(command: unknown, options?: { + /** Bound parameters for the command. */ + args?: unknown[]; + /** The object this command reads — routes to that object's own datasource. */ + object?: string; + }): Promise; /** Return the registered object schema (relationship → target + display-label resolution). */ getObject?(name: string): { fields?: Record { + executeRawSql = async (objectName, sql, params) => { const engine = tryGetExecutor(); if (!engine || !engine.execute) { throw new Error( @@ -274,7 +288,16 @@ export class AnalyticsServicePlugin implements Plugin { // NativeSQLStrategy emits `$1, $2, …` placeholders. Knex (used by // driver-sql) speaks `?` placeholders, so translate. const knexSql = sql.replace(/\$(\d+)/g, '?'); - const result = await engine.execute(knexSql, { args: params }); + // #5033 — `object` is ObjectQL's FIRST driver-selection key. This bridge + // received the object name and dropped it, so every dataset raw-SQL read + // fell through to the DEFAULT driver while the object-routed path + // (`executeAggregate` → `engine.aggregate(objectName, …)`, right above) + // resolved the object's own datasource. The two dataset execution paths + // must give ONE answer to "which datasource is this object in": an + // object routed elsewhere (ADR-0057 §3.6 telemetry split, an explicit + // `object.datasource`, a `datasourceMapping` rule) read `no such table` + // on the default DB and degraded to a confident `0` over live rows. + const result = await engine.execute(knexSql, { args: params, object: objectName }); // A driver that cannot run SQL (e.g. the in-memory driver) returns // null from execute(). Silently mapping that to [] made EVERY dataset // query on such environments report "No rows" while looking healthy @@ -535,6 +558,12 @@ export class AnalyticsServicePlugin implements Plugin { | undefined; return f ? { type: f.type, max: f.max, defaultCurrency: f.currencyConfig?.defaultCurrency } : undefined; }, + // #5033 — the datasource an object is bound to, used ONLY to name the + // actual cause when a dataset's SQL references a table that is not on the + // datasource the query was routed to. Undefined ⇒ the object rides the + // default datasource (or the engine cannot answer), and the diagnostic + // says so rather than inventing a name. + getObjectDatasource: (objectName: string) => dataEngine()?.getObject?.(objectName)?.datasource, // ADR-0062 D6 — a federated object carries an `external` block (ADR-0015). // Reported so NativeSQLStrategy declines it (its hand-compiled FROM would // hit the wrong physical table) and the driver-correct ObjectQL path runs.