Skip to content

Commit 595c917

Browse files
feat(datasource): reject field.columnName on external + drop showcase onEnable bridge (ADR-0062 Phase 4, D7/D8) (#2203)
1 parent 8f9203f commit 595c917

10 files changed

Lines changed: 225 additions & 45 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/cli": minor
3+
"@objectstack/verify": minor
4+
---
5+
6+
feat(datasource): reject field.columnName on external objects + drop showcase onEnable bridge (ADR-0062 Phase 4, D7/D8)
7+
8+
**D7 — reconcile column mapping.** `os compile`/`build` (`validateStackExpressions`)
9+
now rejects `field.columnName` on a federated (external) object with a corrective
10+
message: the driver's query pipeline ignores `field.columnName` for external
11+
objects, so `external.columnMap` is the single authoritative mechanism. Managed
12+
objects are untouched.
13+
14+
**D8 — drop the canonical example's driver bridge.** `examples/app-showcase`
15+
declares its external datasource with **no** `onEnable` driver registration — the
16+
declared datasource auto-connects at boot (ADR-0062 D1). `onEnable` now only
17+
provisions the "remote" fixture tables. To cover this end-to-end, the
18+
`@objectstack/verify` harness wires the datasource-admin plugin (registering the
19+
`'datasource-connection'` service) when an app declares datasources, so it mirrors
20+
`objectstack dev`/serve; a new dogfood test reads the federated objects through the
21+
real REST stack (incl. the `remoteName` remap). `onEnable` + `ctx.drivers.register`
22+
remains supported as an escape hatch for drivers built dynamically at runtime.

docs/adr/0062-external-datasource-runtime.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,8 @@ The analytics native-SQL strategy compiles its own `FROM "<table>"` / column ref
8383

8484
`external.columnMap` ({ remoteColumn → localField }) is the supported way to map external columns (shipped #2149). `field.columnName` (localField → physicalColumn) is its inverse and is **not** applied by the driver's query pipeline for external objects. Decision: for external objects, `columnMap` is authoritative; `field.columnName` on an external object is rejected at validation (no silent dual-source) until a unified column-resolution model is designed. Managed objects' `field.columnName` semantics are untouched.
8585

86+
> **Phase 4 implementation note (#2163).** *D7* is enforced at build time in `validateStackExpressions` (`os compile`/`build`): any object that declares an `external` binding and a field with `columnName` is an error with a corrective message (use `external.columnMap`). Managed objects are untouched. *D8*: `examples/app-showcase` now declares its external datasource with **no** `onEnable` driver registration — `onEnable` only provisions the "remote" fixture tables; the declared datasource auto-connects (D1). To exercise this in the dogfood gate, the `@objectstack/verify` harness now wires the datasource-admin plugin (hence the `'datasource-connection'` service) when an app declares datasources — so the harness matches `objectstack dev`/serve and the federated read is covered end-to-end (a new dogfood test reads `showcase_ext_customer`/`_order`, including the `remoteName` remap). `onEnable` + `ctx.drivers.register` remains documented as the escape hatch for drivers built dynamically at runtime.
87+
8688
### D8 — Drop the `onEnable` bridge from the canonical example; keep it as an escape hatch
8789

8890
Once D1 lands, `examples/app-showcase` declares its external datasource with **no** `onEnable` driver-registration code (fixture provisioning may remain). `onEnable` + `ctx.drivers.register` stays supported for advanced/dynamic cases (drivers built at runtime from external config).

examples/app-showcase/objectstack.config.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,13 @@ export default defineStack({
129129
// AI-authored metadata survive restarts (a `:memory:` datasource would wipe
130130
// everything on every restart, which makes local app-building unusable).
131131
//
132-
// External-datasource federation demo (ADR-0015): a second, read-only SQLite
133-
// file declared as a code-defined external datasource. It appears in
134-
// Setup → Datasources and its federated objects (below) are queryable via
135-
// REST. The fixture file + live driver are wired by `onEnable` (bottom of
136-
// this file). `os dev` needs no extra setup.
132+
// External-datasource federation demo (ADR-0015 / ADR-0062): a second,
133+
// read-only SQLite file declared as a code-defined external datasource. It
134+
// appears in Setup → Datasources and its federated objects (below) are
135+
// queryable via REST — with NO driver wiring: the declared `external`
136+
// datasource AUTO-CONNECTS at boot (ADR-0062 D1/D8). `onEnable` (bottom of
137+
// this file) only provisions the "remote" fixture file's tables + seed data;
138+
// `os dev` needs no extra setup.
137139
datasources: [ShowcaseExternalDatasource],
138140

139141
// i18n
@@ -185,11 +187,14 @@ export default defineStack({
185187
});
186188

187189
/**
188-
* Runtime wiring for the external-datasource federation demo (ADR-0015).
190+
* Provisions the "remote" fixture database for the external-datasource
191+
* federation demo (ADR-0015 / ADR-0062). Creating + seeding the remote tables is
192+
* CODE (DDL on a separate SQLite file), so it can't live in the declarative
193+
* artifact — it runs here. The AppPlugin invokes `onEnable` at boot.
189194
*
190-
* Code (fixture provisioning + live external-driver registration) can't live in
191-
* the declarative artifact, so it runs here. The AppPlugin invokes `onEnable` at
192-
* boot (Phase 2), before the external-validation gate fires on `kernel:ready`.
195+
* NOTE (ADR-0062 D8): this no longer registers the external driver. The declared
196+
* `external` datasource auto-connects at boot, so the federated objects are
197+
* queryable with no driver wiring.
193198
*/
194199
export const onEnable = async (ctx: unknown): Promise<void> => {
195200
await setupShowcaseExternalDatasource(ctx as Parameters<typeof setupShowcaseExternalDatasource>[0]);

examples/app-showcase/src/datasources/external-fixture.ts

Lines changed: 23 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -3,22 +3,22 @@
33
import { SqlDriver } from '@objectstack/driver-sql';
44

55
/**
6-
* Runtime wiring for the external-datasource federation demo (ADR-0015).
6+
* Fixture provisioning for the external-datasource federation demo
7+
* (ADR-0015 / ADR-0062).
78
*
8-
* This is CODE, so it can't live in the declarative artifact — it runs from the
9-
* stack's `onEnable` hook, which the AppPlugin invokes at boot (Phase 2), before
10-
* the external-validation gate fires on `kernel:ready` (Phase 3).
9+
* This is the "remote database" stand-in: it idempotently creates the separate
10+
* SQLite file with `customers` / `orders` tables + a little seed data (a MANAGED
11+
* driver — DDL allowed) so `os dev` needs no external server. It runs from the
12+
* stack's `onEnable` hook at boot.
1113
*
12-
* It does three things, all zero-config so `os dev` "just works":
13-
* 1. Idempotently provisions the "remote" SQLite file with `customers` /
14-
* `orders` tables + a little seed data (a MANAGED driver — DDL allowed).
15-
* 2. Registers a read-only `schemaMode: 'external'` driver for that same file
16-
* under the datasource name `showcase_external`, so ObjectQL can route
17-
* queries to it. (Declared datasources are surfaced in the metadata
18-
* registry for Setup → Datasources, but a live driver must be registered
19-
* for the federated objects to be queryable in standalone mode.)
20-
* 3. Registers the federated objects' read metadata (DDL-free) so coercion +
21-
* the remote-table mapping exist immediately.
14+
* **It no longer registers a driver or syncs object schemas (ADR-0062 D8).** The
15+
* declared `external` datasource (see `showcase-external.datasource.ts`) now
16+
* AUTO-CONNECTS: at boot the runtime's `DatasourceConnectionService` builds the
17+
* read-only `schemaMode:'external'` driver, registers it under the datasource
18+
* name, and registers the federated objects' read metadata — with zero app code.
19+
* The old `onEnable` + `ctx.drivers.register` bridge is gone; `onEnable` +
20+
* `ctx.drivers.register` remains *supported* only as an advanced escape hatch
21+
* (drivers built dynamically at runtime).
2222
*/
2323

2424
// Same relative path as the datasource config — resolved against the project
@@ -60,15 +60,17 @@ const ORDER_ROWS = [
6060
{ id: 'o4', customer_id: 'c3', amount: 3300, status: 'paid', placed_on: '2026-03-01' },
6161
];
6262

63-
/** Stack `onEnable` payload (subset we use). */
63+
/** Stack `onEnable` payload (subset we use — provisioning only logs). */
6464
interface OnEnableContext {
65-
ql: { syncObjectSchema?: (name: string) => Promise<void> };
66-
drivers: { register: (driver: unknown) => void };
6765
logger?: { info?: (msg: string, meta?: unknown) => void; warn?: (msg: string, meta?: unknown) => void };
6866
}
6967

68+
/**
69+
* Provision the "remote" fixture database. Idempotent: creates the tables and
70+
* seeds them only when empty. Does NOT register a driver — the declared external
71+
* datasource auto-connects at boot (ADR-0062 D1/D8).
72+
*/
7073
export async function setupShowcaseExternalDatasource(ctx: OnEnableContext): Promise<void> {
71-
// 1. Provision the remote fixture with a MANAGED driver (DDL allowed). Idempotent.
7274
const fixture = new SqlDriver({
7375
client: 'better-sqlite3',
7476
connection: { filename: EXTERNAL_DB_FILE },
@@ -93,21 +95,7 @@ export async function setupShowcaseExternalDatasource(ctx: OnEnableContext): Pro
9395
await fixture.disconnect();
9496
}
9597

96-
// 2. Register the read-only EXTERNAL driver under the datasource name.
97-
const ext = new SqlDriver({
98-
client: 'better-sqlite3',
99-
connection: { filename: EXTERNAL_DB_FILE },
100-
useNullAsDefault: true,
101-
schemaMode: 'external',
102-
} as never) as unknown as { name: string; connect: () => Promise<void> };
103-
ext.name = 'showcase_external'; // MUST equal the datasource name (ObjectQL routes by driver name).
104-
await ext.connect();
105-
ctx.drivers.register(ext);
106-
107-
// 3. Register read metadata for the federated objects (DDL-free; ADR-0015).
108-
// Needed because the driver is registered after the boot schema-sync.
109-
await ctx.ql.syncObjectSchema?.('showcase_ext_customer');
110-
await ctx.ql.syncObjectSchema?.('showcase_ext_order');
111-
112-
ctx.logger?.info?.('[showcase] external datasource "showcase_external" ready — federation demo (ADR-0015)');
98+
ctx.logger?.info?.(
99+
'[showcase] external fixture provisioned — datasource "showcase_external" auto-connects (ADR-0062)',
100+
);
113101
}

packages/cli/src/utils/validate-expressions.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,4 +343,55 @@ describe('validateStackExpressions (ADR-0032 build-time)', () => {
343343
expect(issues.some(i => i.where.includes('when') && /bare reference `tier`/.test(i.message))).toBe(true);
344344
});
345345
});
346+
347+
describe('ADR-0062 D7 — field.columnName on external objects', () => {
348+
it('rejects field.columnName on a federated (external) object', () => {
349+
const issues = validateStackExpressions({
350+
objects: [{
351+
name: 'ext_customer',
352+
datasource: 'warehouse',
353+
external: { remoteName: 'customers' },
354+
fields: { region: { type: 'text', columnName: 'cust_region' } },
355+
}],
356+
});
357+
const issue = issues.find(i => /columnName/.test(i.message));
358+
expect(issue).toBeDefined();
359+
expect(issue!.severity).toBe('error');
360+
expect(issue!.where).toContain("object 'ext_customer'");
361+
expect(issue!.where).toContain("field 'region'");
362+
expect(issue!.message).toMatch(/external\.columnMap|ADR-0062 D7/);
363+
});
364+
365+
it('rejects field.columnName declared as a map-shaped field too', () => {
366+
const issues = validateStackExpressions({
367+
objects: [{
368+
name: 'ext_order',
369+
external: {},
370+
fields: { amount: { type: 'number', columnName: 'amt' } },
371+
}],
372+
});
373+
expect(issues.some(i => /columnName/.test(i.message) && i.where.includes("field 'amount'"))).toBe(true);
374+
});
375+
376+
it('leaves field.columnName on a MANAGED object untouched (no issue)', () => {
377+
const issues = validateStackExpressions({
378+
objects: [{
379+
name: 'crm_account',
380+
fields: { region: { type: 'text', columnName: 'acct_region' } },
381+
}],
382+
});
383+
expect(issues.some(i => /columnName/.test(i.message))).toBe(false);
384+
});
385+
386+
it('passes an external object that uses no columnName', () => {
387+
const issues = validateStackExpressions({
388+
objects: [{
389+
name: 'ext_customer',
390+
external: { remoteName: 'customers' },
391+
fields: { region: { type: 'text' } },
392+
}],
393+
});
394+
expect(issues.some(i => /columnName/.test(i.message))).toBe(false);
395+
});
396+
});
346397
});

packages/cli/src/utils/validate-expressions.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,35 @@ export function validateStackExpressions(stack: AnyRec): ExprIssue[] {
150150
const fieldList = Array.isArray(fields)
151151
? (fields as AnyRec[])
152152
: (fields && typeof fields === 'object' ? Object.values(fields as AnyRec) as AnyRec[] : []);
153+
154+
// ── ADR-0062 D7 — reject `field.columnName` on external objects ──────
155+
// `field.columnName` (localField → physicalColumn) is the managed-object
156+
// mechanism; it is NOT applied by the driver's query pipeline for federated
157+
// objects, where `external.columnMap` (remoteColumn → localField) is the
158+
// authoritative — and inverse — mapping. Allowing both would be a silent
159+
// dual-source ambiguity, so reject `columnName` on any object that declares
160+
// an `external` binding (a federated object). Managed objects are untouched.
161+
if (obj.external != null) {
162+
const fieldEntries: Array<[string, AnyRec]> = Array.isArray(fields)
163+
? (fields as AnyRec[]).map((f) => [((f as AnyRec)?.name as string) ?? '?', f as AnyRec])
164+
: (fields && typeof fields === 'object'
165+
? (Object.entries(fields as AnyRec) as Array<[string, AnyRec]>)
166+
: []);
167+
for (const [fname, fdef] of fieldEntries) {
168+
if (fdef && typeof fdef === 'object' && (fdef as AnyRec).columnName != null) {
169+
issues.push({
170+
where: `object '${objectName}' · field '${fname}'`,
171+
message:
172+
`external object '${objectName}': field '${fname}' sets columnName='${String((fdef as AnyRec).columnName)}', ` +
173+
`which is not supported on federated objects (ADR-0062 D7). The driver's query pipeline ignores ` +
174+
`field.columnName for external objects; map remote columns via the datasource's external.columnMap instead.`,
175+
source: `columnName='${String((fdef as AnyRec).columnName)}'`,
176+
severity: 'error',
177+
});
178+
}
179+
}
180+
}
181+
153182
for (const f of fieldList) {
154183
// Field-level conditional rules are server-enforced (rule-validator) and
155184
// record-scoped — a bare ref silently fails the rule (required/readonly
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0062 D1/D8 — the showcase declares its `external` datasource with NO
4+
// `onEnable` driver wiring (only fixture provisioning remains). This proves the
5+
// declared external datasource AUTO-CONNECTS at boot and its federated objects
6+
// are queryable end-to-end through the real REST stack — zero app code. Guards
7+
// against a regression where dropping the `onEnable` bridge would leave the
8+
// external objects unrouted ("Datasource 'showcase_external' is not registered").
9+
10+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
11+
import showcaseStack, { onEnable } from '@objectstack/example-showcase';
12+
import { bootStack, type VerifyStack } from '@objectstack/verify';
13+
14+
function listOf(body: unknown): Array<Record<string, unknown>> {
15+
const b = body as { records?: unknown[]; data?: unknown[] } | unknown[];
16+
if (Array.isArray(b)) return b as Array<Record<string, unknown>>;
17+
return ((b as { records?: unknown[] }).records ?? (b as { data?: unknown[] }).data ?? []) as Array<Record<string, unknown>>;
18+
}
19+
20+
describe('showcase: external datasource auto-connects with no onEnable bridge (ADR-0062 D8)', () => {
21+
let stack: VerifyStack;
22+
let admin: string;
23+
24+
beforeAll(async () => {
25+
// Stand up the "remote" database (the showcase's onEnable fixture provisioner).
26+
// The verify harness imports only the stack's default export, so its onEnable
27+
// never runs here — we invoke it ourselves to create the remote customers/orders
28+
// tables, exactly as `os dev` does at boot. Crucially this does NOT register a
29+
// driver (ADR-0062 D8); auto-connect (below, during bootStack) does that.
30+
await onEnable({ logger: { info() {}, warn() {} } } as never);
31+
stack = await bootStack(showcaseStack);
32+
admin = await stack.signIn();
33+
}, 60_000);
34+
35+
afterAll(async () => { await stack?.stop(); });
36+
37+
it('federated customer object is queryable (auto-connected, seeded fixture rows returned)', async () => {
38+
const res = await stack.apiAs(admin, 'GET', '/data/showcase_ext_customer');
39+
expect(res.status, 'federated object must be queryable — driver auto-connected').toBe(200);
40+
const rows = listOf(await res.json());
41+
expect(rows.length).toBeGreaterThanOrEqual(3);
42+
expect(rows.map((r) => r.name)).toContain('Aurora Labs');
43+
});
44+
45+
it('federated order object (remoteName remap) is queryable too', async () => {
46+
const res = await stack.apiAs(admin, 'GET', '/data/showcase_ext_order');
47+
expect(res.status).toBe(200);
48+
const rows = listOf(await res.json());
49+
expect(rows.length).toBeGreaterThanOrEqual(4);
50+
});
51+
52+
it('region filter pushes down to the remote table', async () => {
53+
const res = await stack.apiAs(admin, 'GET', '/data/showcase_ext_customer?region=EU');
54+
expect(res.status).toBe(200);
55+
const rows = listOf(await res.json());
56+
expect(rows.every((r) => r.region === 'EU')).toBe(true);
57+
expect(rows.length).toBeGreaterThanOrEqual(1);
58+
});
59+
});

packages/verify/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"@objectstack/plugin-org-scoping": "workspace:*",
3333
"@objectstack/service-settings": "workspace:*",
3434
"@objectstack/service-analytics": "workspace:*",
35-
"@objectstack/service-automation": "workspace:*"
35+
"@objectstack/service-automation": "workspace:*",
36+
"@objectstack/service-datasource": "workspace:*"
3637
},
3738
"devDependencies": {
3839
"@types/node": "^26.0.0",

packages/verify/src/harness.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,26 @@ export async function bootStack(
127127
await kernel.use(new AnalyticsServicePlugin());
128128
await kernel.use(new AuthPlugin({ secret: opts.authSecret ?? DEFAULT_AUTH_SECRET }));
129129

130+
// ADR-0062 — datasource connection service (registers 'datasource-connection'),
131+
// mirroring `objectstack dev`/serve. Without it, AppPlugin's declared-datasource
132+
// auto-connect (D1/D2) degrades and a federated app would need an `onEnable`
133+
// driver bridge — so this is what exercises the no-`onEnable` federation path
134+
// end-to-end in the dogfood gate. Wired only when the app declares datasources
135+
// (so the vast majority of apps are unaffected); the D2 gate then leaves
136+
// managed/unrouted datasources metadata-only (e.g. app-crm — unchanged).
137+
{
138+
const dsDefs = (config as { datasources?: unknown }).datasources;
139+
const declaresDatasources = Array.isArray(dsDefs)
140+
? dsDefs.length > 0
141+
: !!dsDefs && typeof dsDefs === 'object' && Object.keys(dsDefs).length > 0;
142+
if (declaresDatasources) {
143+
const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import(
144+
'@objectstack/service-datasource'
145+
);
146+
await kernel.use(new DatasourceAdminServicePlugin({ driverFactory: createDefaultDatasourceDriverFactory() }));
147+
}
148+
}
149+
130150
// Multi-tenant: org-scoping MUST register BEFORE SecurityPlugin — the latter
131151
// probes the `org-scoping` service exactly once at start and caches it, then
132152
// keeps (vs strips) the wildcard `organization_id` RLS policies accordingly.

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)