Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions .changeset/dataset-cross-datasource-compile-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
---
"@objectstack/service-analytics": minor
---

fix(service-analytics): reject a dataset's cross-datasource JOIN when it is compiled, not when it is queried (#5115)

#5033 routed a dataset's raw SQL to its base object's own datasource, which
turned a JOIN whose target lives in another database into a **loud query-time
failure** — correct, but late: the dataset can still be saved, published and
put on a dashboard, and the failure lands in front of whoever opens that
dashboard, usually in another environment on another day. It is a pure metadata
error, decidable the moment the dataset is compiled: the whole dataset is
lowered into ONE statement on the base object's datasource, so a join target
bound elsewhere is simply not there.

`compileDataset` now decides it. `AnalyticsService.registerDataset` — the single
door every dataset passes through, whether pre-registered at boot, saved, or
previewed as a Studio draft — hands the compiler the datasource and federation
probes that already existed on `AnalyticsServiceConfig`, and a proven conflict
is rejected before any SQL is built. The message names both objects, both
datasources, the offending `include` path, and the two ways out (bind both
objects to the same datasource, or drop the relationship), in the same wording
family as the #5033 query-time diagnostic so the two never read as two bugs.

**Who is affected.** This is a tightening: a dataset that used to compile and
then fail (or, before #5033, silently read the wrong database) now fails at
registration. It fires only where the metadata *proves* the conflict — the base
object and a join target each declare an explicit `object.datasource` and the
two names differ. A dataset registered at boot is skipped with a WARN naming the
conflict, as before; the rest of the host's datasets still register.

**What is deliberately not rejected** ("cannot answer, do not block", the same
tiering as `isRegisteredObject` / `getObjectFieldNames`):

- a host that wires no datasource probe at all (no data engine) — compiles
exactly as it did before;
- either side leaving `datasource` at its default. `'default'` is the schema's
default *value*, not a routing decision: `ObjectQL.getDriver` short-circuits
only on an explicit non-`'default'` name, then falls through to
`datasourceMapping` rules, the ADR-0057 §3.6 lifecycle split
(audit/telemetry/event) and the owning package's `defaultDatasource` — none of
which are visible to the compiler. Treating `'default'` as "the primary DB"
would reject datasets whose objects a mapping rule in fact lands on the *same*
database;
- a federated (external) participant on either side. `NativeSQLStrategy` already
declines such a cube (ADR-0062 D6), so the query is served by the ObjectQL
FK-expand path, which crosses datasources by construction.

Everything not proven here keeps failing loudly at query time via #5033.
Making cross-datasource dashboards actually *work* (declining in
`NativeSQLStrategy` and serving the join with two reads) is separate and not
part of this change.
Original file line number Diff line number Diff line change
Expand Up @@ -217,3 +217,199 @@ describe('compileDataset — multi-hop joins (ADR-0071)', () => {
expect(cube.joins?.['account__owner']?.name).toBe('owner');
});
});


/**
* #5115 — a dataset whose JOIN crosses datasources is metadata that can never
* execute: the whole dataset is lowered into ONE statement on the base object's
* datasource (raw SQL routes by object since #5033), so the joined table is
* simply not there. #5033 made that failure loud at QUERY time, in front of
* whoever opened the dashboard; this suite pins the same verdict at COMPILE
* time, while the author (often an AI author) is still holding the metadata.
*
* The gate is deliberately narrow, and these cases pin the boundary as much as
* the rejection: it fires only on a conflict the METADATA PROVES — two explicit,
* different `object.datasource` bindings — because `'default'` is the schema's
* default value rather than a routing decision (mapping rules / the ADR-0057
* lifecycle split / a package's `defaultDatasource` all route objects that
* never say a word about `datasource`). Everything it cannot prove stays
* compilable and is caught by #5033's query-time defence.
*/
describe('compileDataset — cross-datasource join gate (#5115)', () => {
/** opportunity → crm_account → core_user (all to-one). */
const chainResolver = (obj: string, rel: string) => {
const graph: Record<string, Record<string, { object: string; table: string }>> = {
opportunity: { account: { object: 'crm_account', table: 'crm_account' } },
crm_account: { owner: { object: 'core_user', table: 'core_user' } },
};
return graph[obj]?.[rel];
};

const datasetWith = (include: string[], dimensions: Record<string, string>[]) =>
DatasetSchema.parse({
name: 'revenue_by_region',
label: 'Revenue by region',
object: 'opportunity',
include,
dimensions: dimensions.map((d) => ({ ...d, type: 'string' })),
measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum', field: 'amount' }],
});

const crossDs = datasetWith(['account'], [{ name: 'region', field: 'account.region' }]);

/** Compile `crossDs` with a datasource map; returns the thrown error, if any. */
const compileWith = (
datasources: Record<string, string | undefined>,
isExternalObject?: (o: string) => boolean,
): Error | undefined => {
try {
compileDataset(crossDs, chainResolver, {
getObjectDatasource: (o) => datasources[o],
...(isExternalObject ? { isExternalObject } : {}),
});
return undefined;
} catch (e) {
return e as Error;
}
};

it('rejects two explicitly-bound objects on different datasources, naming BOTH sides and the fix', () => {
const err = compileWith({ opportunity: 'billing_db', crm_account: 'crm_db' });

expect(err).toBeDefined();
const msg = String(err?.message);
// Both objects …
expect(msg).toContain('base object "opportunity"');
expect(msg).toContain('joined object "crm_account"');
// … both datasources …
expect(msg).toContain('datasource "billing_db"');
expect(msg).toContain('datasource "crm_db"');
// … the offending include path …
expect(msg).toContain('"account"');
// … the rule, and a remedy the author can act on — same wording family as
// the #5033 query-time diagnostic, so the two never read as two bugs.
expect(msg).toMatch(/JOIN cannot cross datasources/);
expect(msg).toMatch(/binding both objects to the same datasource/);
expect(msg).toMatch(/dropping "account" from the dataset's `include`/);
});

it('compiles normally when both sides declare the SAME datasource', () => {
expect(compileWith({ opportunity: 'crm_db', crm_account: 'crm_db' })).toBeUndefined();
// …and the join is still emitted, unchanged.
const { cube } = compileDataset(crossDs, chainResolver, { getObjectDatasource: () => 'crm_db' });
expect(cube.joins?.account?.name).toBe('crm_account');
});

it('compares datasource ids case-insensitively (casing is not two databases)', () => {
expect(compileWith({ opportunity: 'CRM_DB', crm_account: 'crm_db' })).toBeUndefined();
});

// ── "cannot answer, do not block" — the tiering, pinned case by case ───────

it('a host with NO datasource probe compiles exactly as before', () => {
// The headline tiering case: an embedding with no data engine passes no
// options at all, and every dataset it registers must still compile.
expect(() => compileDataset(crossDs, chainResolver)).not.toThrow();
expect(() => compileDataset(crossDs, chainResolver, {})).not.toThrow();
const { cube } = compileDataset(crossDs, chainResolver, {});
expect(cube.joins?.account?.name).toBe('crm_account');
});

it('does not block when the probe cannot place the JOIN TARGET', () => {
expect(compileWith({ opportunity: 'billing_db', crm_account: undefined })).toBeUndefined();
});

it('does not block when the probe cannot place the BASE object', () => {
// "Cannot answer for the base" must not reject every join — the base is the
// side every comparison is made against.
expect(compileWith({ opportunity: undefined, crm_account: 'crm_db' })).toBeUndefined();
});

it('treats the DEFAULT binding as unanswered on either side', () => {
// `datasource: 'default'` is the schema's default VALUE, not a routing
// decision: `ObjectQL.getDriver` only short-circuits on a name OTHER than
// 'default', then falls through to datasourceMapping rules, the ADR-0057
// lifecycle split and the package's defaultDatasource. An object that says
// 'default' may well be routed elsewhere — and may land on exactly the
// datasource the other side declares, which is why rejecting here would
// blank a working dashboard.
expect(compileWith({ opportunity: 'default', crm_account: 'crm_db' })).toBeUndefined();
expect(compileWith({ opportunity: 'billing_db', crm_account: 'default' })).toBeUndefined();
expect(compileWith({ opportunity: 'default', crm_account: 'DEFAULT' })).toBeUndefined();
});

it('exempts a FEDERATED join target (ADR-0062 D6 — served by the FK-expand path)', () => {
// NativeSQLStrategy already declines a cube whose base or joined object is
// external, so such a query runs on the ObjectQL FK-expand path (two reads
// joined in memory), which crosses datasources by construction. Rejecting
// it here would break a path that works today.
expect(
compileWith({ opportunity: 'billing_db', crm_account: 'sf_prod' }, (o) => o === 'crm_account'),
).toBeUndefined();
});

it('exempts a FEDERATED base object', () => {
expect(
compileWith({ opportunity: 'sf_prod', crm_account: 'crm_db' }, (o) => o === 'opportunity'),
).toBeUndefined();
});

// ── which hop gets named ──────────────────────────────────────────────────

it('names the ONE offending hop when a multi-hop path crosses on its second hop', () => {
const twoHop = datasetWith(
['account', 'account.owner'],
[{ name: 'owner_region', field: 'account.owner.region' }],
);
const datasources: Record<string, string> = {
opportunity: 'billing_db',
crm_account: 'billing_db', // first hop stays home …
core_user: 'identity_db', // … the second one leaves
};
let thrown: Error | undefined;
try {
compileDataset(twoHop, chainResolver, { getObjectDatasource: (o) => datasources[o] });
} catch (e) {
thrown = e as Error;
}
const msg = String(thrown?.message);
expect(msg).toContain('joined object "core_user"');
expect(msg).toContain('datasource "identity_db"');
expect(msg).toContain('path "account.owner"');
// The innocent intermediate hop is not blamed.
expect(msg).not.toContain('joined object "crm_account"');
});

it('rejects only the crossing target when several joins are declared', () => {
const multi = DatasetSchema.parse({
name: 'multi',
label: 'Multi',
object: 'opportunity',
include: ['owner', 'account'],
dimensions: [
{ name: 'owner_name', field: 'owner.name', type: 'string' },
{ name: 'region', field: 'account.region', type: 'string' },
],
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
});
const flatResolver = (obj: string, rel: string) => {
if (obj !== 'opportunity') return undefined;
if (rel === 'owner') return { object: 'core_user', table: 'core_user' };
if (rel === 'account') return { object: 'crm_account', table: 'crm_account' };
return undefined;
};
const datasources: Record<string, string> = {
opportunity: 'billing_db',
core_user: 'billing_db', // same datasource — fine
crm_account: 'crm_db', // the offender
};
let thrown: Error | undefined;
try {
compileDataset(multi, flatResolver, { getObjectDatasource: (o) => datasources[o] });
} catch (e) {
thrown = e as Error;
}
expect(String(thrown?.message)).toContain('joined object "crm_account"');
expect(String(thrown?.message)).not.toContain('core_user');
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #5115 — the WIRING half of the compile-time cross-datasource gate.
*
* `dataset-compiler.test.ts` pins the verdict; this file pins that the service
* actually asks the question. `registerDataset` is the one door every dataset
* goes through (pre-registered datasets at construction, `queryDataset` for
* saved and Studio-draft datasets alike), so hooking the probes up there is
* what moves the failure off the dashboard and onto the author. A compiler that
* can reject but is never handed the probes would pass every unit test above
* and change nothing in production.
*/

import { describe, it, expect, vi } from 'vitest';
import { DatasetSchema } from '@objectstack/spec/ui';
import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js';

/** opportunity (billing_db) --account--> crm_account (crm_db). */
const DATASOURCES: Record<string, string> = {
opportunity: 'billing_db',
crm_account: 'crm_db',
};

const relationshipResolver = (obj: string, rel: string) =>
obj === 'opportunity' && rel === 'account'
? { object: 'crm_account', table: 'crm_account' }
: undefined;

const crossDsDataset = DatasetSchema.parse({
name: 'revenue_by_region',
label: 'Revenue by region',
object: 'opportunity',
include: ['account'],
dimensions: [{ name: 'region', label: 'Region', field: 'account.region', type: 'string' }],
measures: [{ name: 'revenue', label: 'Revenue', aggregate: 'sum', field: 'amount' }],
});

/** Silent by default — one case swaps in a spy to read the boot-time WARN. */
const silentLogger = { info() {}, warn() {}, error() {}, debug() {} } as AnalyticsServiceConfig['logger'];

const serviceWith = (config: AnalyticsServiceConfig = {}) =>
new AnalyticsService({
relationshipResolver,
getObjectDatasource: (o: string) => DATASOURCES[o],
logger: silentLogger,
...config,
});

describe('registerDataset rejects a cross-datasource join at compile time (#5115)', () => {
it('throws when registering the dataset — no query needed', () => {
expect(() => serviceWith().registerDataset(crossDsDataset)).toThrowError(
/JOIN cannot cross datasources/,
);
});

it('names both objects and both datasources, so the author can act', () => {
const err = (() => {
try {
serviceWith().registerDataset(crossDsDataset);
return undefined;
} catch (e) {
return e as Error;
}
})();
expect(err?.message).toContain('base object "opportunity"');
expect(err?.message).toContain('datasource "billing_db"');
expect(err?.message).toContain('joined object "crm_account"');
expect(err?.message).toContain('datasource "crm_db"');
});

it('fails the QUERY too — before any SQL is built or any driver is touched', async () => {
// The point of the gate: the widget no longer gets as far as a statement,
// so nothing depends on the driver's error text (that was #5033's job).
const executeRawSql = vi.fn();
const executeAggregate = vi.fn();
const service = serviceWith({ executeRawSql, executeAggregate });

await expect(
service.queryDataset(crossDsDataset, { dimensions: ['region'], measures: ['revenue'] }),
).rejects.toThrow(/JOIN cannot cross datasources/);

expect(executeRawSql).not.toHaveBeenCalled();
expect(executeAggregate).not.toHaveBeenCalled();
});

it('does not take the kernel down at boot — a bad pre-registered dataset warns and is skipped', () => {
// Pre-registered datasets are compiled in the constructor, which already
// catches and warns per dataset. A metadata error must stay a metadata
// error: the OTHER datasets in the same host still register.
const warn = vi.fn();
const sane = DatasetSchema.parse({
name: 'pipeline',
label: 'Pipeline',
object: 'opportunity',
dimensions: [{ name: 'stage', label: 'Stage', field: 'stage', type: 'string' }],
measures: [{ name: 'cnt', label: 'Count', aggregate: 'count' }],
});
const service = serviceWith({
datasets: [crossDsDataset, sane],
logger: { info() {}, warn, error() {}, debug() {} } as AnalyticsServiceConfig['logger'],
});

expect(warn.mock.calls.map(String).join('\n')).toMatch(/JOIN cannot cross datasources/);
expect(service.cubeRegistry.get('pipeline')).toBeDefined();
expect(service.cubeRegistry.get('revenue_by_region')).toBeUndefined();
});

it('a host that wires NO datasource probe registers the same dataset unchanged', () => {
// The tiering, at the seam that matters: every embedding without a data
// engine (and every existing test double) must be unaffected by #5115.
const service = new AnalyticsService({ relationshipResolver });
const compiled = service.registerDataset(crossDsDataset);
expect(compiled.cube.joins?.account?.name).toBe('crm_account');
});

it('a FEDERATED join target still registers (ADR-0062 D6 — FK-expand serves it)', () => {
const service = serviceWith({ isExternalObject: (o) => o === 'crm_account' });
expect(() => service.registerDataset(crossDsDataset)).not.toThrow();
});
});
Loading
Loading