Skip to content

Commit f76fe42

Browse files
os-warrenclaude
andauthored
fix(service-datasource): read the introspected primary key at the isPrimary/primaryKey seam (#11001)
Claude-Session: https://claude.ai/code/session_01PnJHU45vPJj5UQrxe946Bx Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 13e04d6 commit f76fe42

3 files changed

Lines changed: 310 additions & 1 deletion

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
"@objectstack/service-datasource": patch
3+
---
4+
5+
Restore the introspected primary key in the persisted `external_catalog`
6+
(#10676). `ExternalDatasourceService` reads `column.primaryKey` — the
7+
`packages/spec` `IntrospectedColumn` spelling — but `plugin.ts` hands it the
8+
driver's `introspectSchema()` result unmodified, and `SqlDriver` (and
9+
`SqliteWasmDriver`, which extends it) speaks the other `IntrospectedColumn`
10+
contract, from `packages/objectql/src/util.ts`: it sets `column.isPrimary` and
11+
fills `table.primaryKeys`, never `column.primaryKey`.
12+
13+
Measured against a live SQLite database: for a table declared
14+
`primary key (id)`, the driver's `id` column carries `isPrimary: true` and the
15+
table carries `primaryKeys: ['id']`, while `primaryKey` is `undefined`. Because
16+
`ExternalCatalogSchema` defaults `primaryKey` to `false`, `refreshCatalog`
17+
persisted a catalog in which **every** column of **every** remote table claimed
18+
not to be part of the remote key — so Studio's schema browser and the boot gate
19+
read a catalog that shows no primary keys at all.
20+
21+
The seam now reads the union of all three signals (`primaryKey`, `isPrimary`,
22+
`table.primaryKeys`) rather than any one of them. No in-tree producer uses a
23+
`false` to negate a key another signal asserts, and taking the union means a
24+
producer that fills only the table-level list — or only the per-column flag —
25+
cannot lose half a composite key. No response or record shape changes: a field
26+
that should always have carried the introspected value starts carrying it.
27+
28+
The regression pin drives the service off a **real** `SqlDriver.introspectSchema()`
29+
result rather than a hand-written fixture. The pre-existing suite could not see
30+
this defect precisely because it hand-wrote its fixture in the spec spelling, so
31+
no test ever fed the service what a driver actually emits.
32+
33+
Not fixed here: `generateObjectDraft` still drops the key from the generated
34+
object definition. Its destination is an open contract question rather than a
35+
missing read — `fields.<name>.primaryKey` is **not** an authorable spec field
36+
key (an object literal carrying it fails `tsc` against `ServiceObject` with
37+
TS2353, and `ObjectSchema.safeParse` with `unrecognized_keys`), and there is no
38+
key on `ObjectExternalBindingSchema` to hold a remote primary key either. See
39+
#10676 for the routing decision.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The pin the original suite could not be: this service driven off a REAL
5+
* `SqlDriver.introspectSchema()` result, not a hand-written fake.
6+
*
7+
* `external-datasource-service.test.ts` hand-writes its fixture with
8+
* `primaryKey: true` — the `packages/spec` contract spelling
9+
* (`contracts/schema-diff-service.ts`). The driver emits the OTHER spelling:
10+
* `SqlDriver.introspectSchema` sets `col.isPrimary` and fills
11+
* `table.primaryKeys` (the `packages/objectql/src/util.ts` shape). `plugin.ts`
12+
* hands the driver's result to this service unmodified, so the two contracts
13+
* meet — and disagree — exactly here. A fixture written in EITHER spelling is
14+
* blind to that; only a live introspection can see it, so every case below
15+
* introspects a real in-memory SQLite database rather than describing one.
16+
*
17+
* Both directions are pinned deliberately: an implementation that stamped the
18+
* key onto the first column would satisfy a positive-only suite.
19+
*/
20+
21+
import { describe, it, expect, afterEach } from 'vitest';
22+
import { SqlDriver } from '@objectstack/driver-sql';
23+
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
24+
import {
25+
ExternalDatasourceService,
26+
type DatasourceLike,
27+
} from '../external-datasource-service.js';
28+
29+
const opened: SqlDriver[] = [];
30+
31+
afterEach(async () => {
32+
while (opened.length) {
33+
const d = opened.pop()!;
34+
try {
35+
await (d as unknown as { knex?: { destroy(): Promise<void> } }).knex?.destroy();
36+
} catch {
37+
/* the pool may never have opened */
38+
}
39+
}
40+
});
41+
42+
/**
43+
* A live in-memory SQLite database, introspected by the real driver. Returns
44+
* the driver's own result, deliberately NOT reshaped — anything this service
45+
* needs, it must read from the bytes the driver actually produces.
46+
*/
47+
async function introspectReal(ddl: (knex: never) => Promise<void>): Promise<unknown> {
48+
const driver = new SqlDriver({
49+
client: 'better-sqlite3',
50+
connection: { filename: ':memory:' },
51+
useNullAsDefault: true,
52+
} as never);
53+
opened.push(driver);
54+
await ddl((driver as unknown as { knex: never }).knex);
55+
return driver.introspectSchema();
56+
}
57+
58+
/** Wire the service to a fixed introspection result, exactly as `plugin.ts` does. */
59+
function serviceOver(schema: unknown): ExternalDatasourceService {
60+
return new ExternalDatasourceService({
61+
introspect: async () => schema as IntrospectedSchema,
62+
getDatasource: async (name): Promise<DatasourceLike> => ({ name, schemaMode: 'external' }),
63+
getObject: async () => undefined,
64+
listObjects: async () => [],
65+
});
66+
}
67+
68+
/** The catalog columns for one remote table, keyed by column name. */
69+
async function catalogColumns(
70+
schema: unknown,
71+
remoteName: string,
72+
): Promise<Record<string, { primaryKey: boolean; nullable: boolean; sqlType: string }>> {
73+
const catalog = await serviceOver(schema).refreshCatalog('showcase_external');
74+
const table = catalog.tables.find((t) => t.remoteName === remoteName);
75+
expect(table, `no '${remoteName}' in the refreshed catalog`).toBeDefined();
76+
return Object.fromEntries(table!.columns.map((c) => [c.name, c])) as never;
77+
}
78+
79+
describe('the introspection seam, as a real SqlDriver actually spells it', () => {
80+
it('the driver speaks isPrimary/primaryKeys and never the spec spelling', async () => {
81+
const schema = (await introspectReal(async (knex: never) => {
82+
await (knex as { schema: { createTable(n: string, cb: (t: never) => void): Promise<void> } }).schema.createTable(
83+
'customers',
84+
(t: never) => {
85+
const b = t as unknown as { string(n: string): { primary(): void }; integer(n: string): void };
86+
b.string('id').primary();
87+
b.string('name');
88+
b.integer('age');
89+
},
90+
);
91+
})) as { tables: Record<string, { primaryKeys: string[]; columns: Record<string, unknown>[] }> };
92+
93+
const table = schema.tables.customers;
94+
const id = table.columns.find((c) => c.name === 'id')!;
95+
96+
// This is the whole defect, stated as an assertion on the producer's own
97+
// output. If it ever flips — the driver starting to emit the spec
98+
// spelling, or the two contracts being reconciled upstream — the union
99+
// read in `primaryKeyReader` stops being load-bearing and should be
100+
// re-derived rather than quietly relaxed.
101+
expect(table.primaryKeys).toEqual(['id']);
102+
expect(id.isPrimary).toBe(true);
103+
expect(id.primaryKey).toBeUndefined();
104+
});
105+
106+
it('refreshCatalog carries the introspected key onto the right column only', async () => {
107+
const schema = await introspectReal(async (knex: never) => {
108+
await (knex as { schema: { createTable(n: string, cb: (t: never) => void): Promise<void> } }).schema.createTable(
109+
'customers',
110+
(t: never) => {
111+
const b = t as unknown as { string(n: string): { primary(): void }; integer(n: string): void };
112+
b.string('id').primary();
113+
b.string('name');
114+
b.integer('age');
115+
},
116+
);
117+
});
118+
119+
const cols = await catalogColumns(schema, 'customers');
120+
expect(cols.id.primaryKey).toBe(true);
121+
// …and nothing else was promoted.
122+
expect(cols.name.primaryKey).toBe(false);
123+
expect(cols.age.primaryKey).toBe(false);
124+
});
125+
126+
it('refreshCatalog invents no key for a table that declares none', async () => {
127+
const schema = await introspectReal(async (knex: never) => {
128+
await (knex as { schema: { createTable(n: string, cb: (t: never) => void): Promise<void> } }).schema.createTable(
129+
'events',
130+
(t: never) => {
131+
const b = t as unknown as { string(n: string): unknown };
132+
b.string('label');
133+
b.string('payload');
134+
},
135+
);
136+
});
137+
138+
const cols = await catalogColumns(schema, 'events');
139+
// Still a usable catalog entry…
140+
expect(Object.keys(cols)).toEqual(['label', 'payload']);
141+
// …and no column was promoted, least of all the first one.
142+
expect(cols.label.primaryKey).toBe(false);
143+
expect(cols.payload.primaryKey).toBe(false);
144+
});
145+
});
146+
147+
describe('the seam read, where the two spellings disagree', () => {
148+
/**
149+
* No in-tree driver produces a disagreement — `SqlDriver` derives
150+
* `isPrimary` FROM `primaryKeys`, so the two always agree. This case is
151+
* therefore hand-built ON PURPOSE, and it is the one place in this file
152+
* where that is the right instrument: it fixes the behaviour under a
153+
* disagreement no live database can currently stage, so a future producer
154+
* that fills only one of the two signals cannot silently lose half a
155+
* composite key. The spelling seam itself is pinned above, against a real
156+
* driver, where a fake would have been blind.
157+
*/
158+
function serviceOverRaw(table: unknown): ExternalDatasourceService {
159+
return serviceOver({ tables: { order_lines: table } });
160+
}
161+
162+
const cols = [
163+
{ name: 'order_id', type: 'varchar', nullable: false },
164+
{ name: 'line_no', type: 'varchar', nullable: false },
165+
{ name: 'sku', type: 'varchar', nullable: true },
166+
];
167+
168+
it('takes the union when the table-level list is wider than the per-column flag', async () => {
169+
const catalog = await serviceOverRaw({
170+
name: 'order_lines',
171+
primaryKeys: ['order_id', 'line_no'],
172+
columns: cols.map((c) => ({ ...c, isPrimary: c.name === 'order_id' })),
173+
}).refreshCatalog('showcase_external');
174+
175+
const byName = Object.fromEntries(catalog.tables[0].columns.map((c) => [c.name, c]));
176+
expect(byName.order_id.primaryKey).toBe(true);
177+
expect(byName.line_no.primaryKey).toBe(true);
178+
expect(byName.sku.primaryKey).toBe(false);
179+
});
180+
181+
it('takes the union when the per-column flag is wider than the table-level list', async () => {
182+
const catalog = await serviceOverRaw({
183+
name: 'order_lines',
184+
primaryKeys: ['order_id'],
185+
columns: cols.map((c) => ({ ...c, isPrimary: c.name !== 'sku' })),
186+
}).refreshCatalog('showcase_external');
187+
188+
const byName = Object.fromEntries(catalog.tables[0].columns.map((c) => [c.name, c]));
189+
expect(byName.order_id.primaryKey).toBe(true);
190+
expect(byName.line_no.primaryKey).toBe(true);
191+
expect(byName.sku.primaryKey).toBe(false);
192+
});
193+
194+
it('still honours the spec spelling on its own — the pre-existing fixtures keep working', async () => {
195+
const catalog = await serviceOverRaw({
196+
name: 'order_lines',
197+
// No `primaryKeys`, no `isPrimary` — the hand-written shape the rest of
198+
// this package's suite feeds in.
199+
columns: cols.map((c) => ({ ...c, primaryKey: c.name === 'order_id' })),
200+
}).refreshCatalog('showcase_external');
201+
202+
const byName = Object.fromEntries(catalog.tables[0].columns.map((c) => [c.name, c]));
203+
expect(byName.order_id.primaryKey).toBe(true);
204+
expect(byName.line_no.primaryKey).toBe(false);
205+
});
206+
});

packages/services/service-datasource/src/external-datasource-service.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import type {
2222
SchemaValidationReport,
2323
IntrospectedSchema,
2424
IntrospectedTable,
25+
IntrospectedColumn,
2526
} from '@objectstack/spec/contracts';
2627
import type { SchemaDiffEntry } from '@objectstack/spec/shared';
2728
import {
@@ -93,6 +94,63 @@ export interface ExternalDatasourceServiceConfig {
9394
/** Columns ObjectStack manages itself — never validated against the remote. */
9495
const BUILTIN_COLUMNS = new Set(['id', 'created_at', 'updated_at']);
9596

97+
/**
98+
* Read "is this column part of the remote primary key" across the TWO
99+
* introspection contracts that meet at this service.
100+
*
101+
* `plugin.ts` hands the driver's `introspectSchema()` result to this service
102+
* unmodified, and the driver does not speak the contract this file is typed
103+
* against:
104+
*
105+
* | producer | per-column | table-level |
106+
* | ---------------------------------------------- | -------------- | -------------- |
107+
* | `SqlDriver` (+ `SqliteWasmDriver`, which extends it) | `isPrimary` | `primaryKeys` |
108+
* | `packages/spec` `IntrospectedColumn` (what this file's types say) | `primaryKey` | — |
109+
*
110+
* Measured against a live SQLite database at `368e7a06f`: the driver's column
111+
* for a `primary key (id)` table carries `isPrimary: true` and the table
112+
* carries `primaryKeys: ['id']`, while `primaryKey` is `undefined`. Reading
113+
* only `col.primaryKey` therefore reads a key no in-tree driver ever sets, and
114+
* the remote key is silently lost.
115+
*
116+
* This reads the UNION of all three signals rather than picking one:
117+
*
118+
* - No in-tree producer uses `primaryKey: false` / `isPrimary: false` to
119+
* NEGATE a key another signal asserts — the falses are just "not a key",
120+
* written by producers that fill exactly one of the three. A precedence
121+
* chain would therefore drop a real key whenever the winning signal is the
122+
* one its producer left blank, which is the defect being repaired here.
123+
* - A producer that fills only `table.primaryKeys` (the shape a table-level
124+
* reader would naturally emit) is covered without needing a per-column flag,
125+
* and vice versa.
126+
*
127+
* When the per-column flag and the table-level list DISAGREE, the union takes
128+
* both. That is deliberate: for a federated table, under-reporting the key
129+
* costs the caller its addressing key, and no in-tree consumer treats a
130+
* column's PK-ness as an exclusive claim. Note that no in-tree driver produces
131+
* such a disagreement today — `SqlDriver` derives `isPrimary` FROM
132+
* `primaryKeys`, so the two always agree, including where both are wrong (a
133+
* SQLite composite key reports only its first column, because
134+
* `introspectPrimaryKeys` filters `PRAGMA table_info` on `pk === 1` while
135+
* SQLite numbers composite members `1, 2, ...`). That truncation is upstream
136+
* of this seam and is not repaired here.
137+
*
138+
* Deliberately structural: the extra spellings are read off the value without
139+
* widening any declared contract, because reconciling
140+
* `packages/objectql/src/util.ts` with
141+
* `packages/spec/src/contracts/schema-diff-service.ts` is a spec-owned change.
142+
*/
143+
function primaryKeyReader(table: IntrospectedTable): (col: IntrospectedColumn) => boolean {
144+
const declared = (table as unknown as { primaryKeys?: unknown }).primaryKeys;
145+
const listed = new Set(
146+
Array.isArray(declared) ? declared.filter((n): n is string => typeof n === 'string') : [],
147+
);
148+
return (col) =>
149+
col.primaryKey === true ||
150+
(col as { isPrimary?: unknown }).isPrimary === true ||
151+
listed.has(col.name);
152+
}
153+
96154
/** Split a possibly schema-qualified name (`mart.fact_orders`). */
97155
function parseQualified(raw: string): { schema?: string; name: string } {
98156
const idx = raw.indexOf('.');
@@ -302,14 +360,20 @@ export class ExternalDatasourceService implements IExternalDatasourceService {
302360
dialect: schema.dialect,
303361
tables: Object.values(schema.tables).map((t) => {
304362
const { schema: s, name } = parseQualified(t.name);
363+
// The introspection seam: a real driver spells this `isPrimary` /
364+
// `primaryKeys`, never `primaryKey`. `ExternalCatalogSchema` defaults
365+
// the key to `false`, so reading only `c.primaryKey` persisted a
366+
// catalog in which EVERY column claimed not to be part of the remote
367+
// key — including the ones that are.
368+
const isPk = primaryKeyReader(t);
305369
return {
306370
remoteSchema: s,
307371
remoteName: name,
308372
columns: t.columns.map((c) => ({
309373
name: c.name,
310374
sqlType: c.type,
311375
nullable: c.nullable,
312-
primaryKey: c.primaryKey,
376+
primaryKey: isPk(c),
313377
suggestedFieldType: suggestFieldTypeForSqlType(c.type, schema.dialect as SqlDialect),
314378
})),
315379
};

0 commit comments

Comments
 (0)