Skip to content

Commit 30d96ab

Browse files
os-warrenclaude
andauthored
feat(spec): declare introspectSchema on IDataDriver and type IDataEngine.introspectDatasource — the engine-registration seam meets the compiler (#11834)
* feat(spec): type the engine-registration road into datasource introspection (#11493) IDataDriver gains optional introspectSchema?(): Promise<IntrospectedSchema>; IDataEngine gains optional introspectDatasource?(datasource): Promise<IntrospectedSchema>. ObjectQL.introspectDatasource() tightens Promise<unknown> to the spec type and drops its as-any driver probe (compiled JS unchanged). service-datasource's plugin deletes its structural DataEngineLike re-declaration and types the 'data' service with the real contract; its dead getDatasourceDriver fallback probe is respelled to the declared getDriverByName member. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy * test(spec): pin introspectDatasource type-level — no new engine double check:engine-double-contract counts IDataEngine literals in this file against a shrink-only baseline; the pins read the member type off the contract instead, so the double census is untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rxnd8cyFnoU8V5y21PaTsy --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 200fc82 commit 30d96ab

10 files changed

Lines changed: 291 additions & 16 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/spec': minor
3+
---
4+
5+
The engine-registration road into datasource introspection now meets the compiler (#11493, extending the #11123 ruling from the `DatasourceDriverHandle` seam): `IDataDriver` gains an optional `introspectSchema?(): Promise<IntrospectedSchema>` member, and `IDataEngine` gains an optional `introspectDatasource?(datasource: string): Promise<IntrospectedSchema>` member. Both are typed with the spec's one introspection shape (`IntrospectedSchema`, `@objectstack/spec/contracts`). Drivers and engines without introspection stay conformant — the members are optional — while a driver that DOES implement `introspectSchema` with a mis-shaped result (a column flag spelled `isPrimary`, a bare `{ tables }` with no `dialect`/`introspectedAt`) now fails compile at the offending field instead of surfacing at runtime as a federated table whose records cannot be located.
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
`ObjectQL.introspectDatasource()` declares its real return type — the spec's `IntrospectedSchema` (the new `IDataEngine.introspectDatasource?` contract member) — instead of an untyped `Promise<unknown>`, and the driver lookup inside it drops its `as any` now that `IDataDriver` declares `introspectSchema?`. Type-level only; runtime behaviour is byte-identical (#11493).
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
---
4+
5+
`ExternalDatasourceServicePlugin` types the `'data'` service with the real engine contract (`IDataEngine`, `@objectstack/spec/contracts`) and deletes its private structural `DataEngineLike` re-declaration — the workaround the untyped `IDataEngine.introspectDatasource()` forced (#11493). The introspection fallback branch now probes `getDriverByName?` (the registry member the contract declares) instead of `getDatasourceDriver?`, a spelling no engine in either repository ever had, so the degradation path is reachable for the first time.

packages/objectql/src/engine.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import type { FlowFunctionEffect } from '@objectstack/spec/automation';
5858
// Imported from spec directly rather than through `@objectstack/core`'s
5959
// re-export block: that block is labelled backward-compatibility, and this
6060
// contract is new (#5945).
61-
import type { IScopedContext, IScopedObjectRepository } from '@objectstack/spec/contracts';
61+
import type { IScopedContext, IScopedObjectRepository, IntrospectedSchema as SpecIntrospectedSchema } from '@objectstack/spec/contracts';
6262
import {
6363
IDataDriver,
6464
IDataEngine,
@@ -12328,9 +12328,18 @@ export class ObjectQL implements IObjectQLEngine {
1232812328
*
1232912329
* @throws if the datasource has no registered driver, or the driver does
1233012330
* not support introspection.
12331-
*/
12332-
async introspectDatasource(datasource: string): Promise<unknown> {
12333-
const driver = this.drivers.get(datasource) as any;
12331+
*
12332+
* [#11493] The return is the spec's ONE introspection shape — the
12333+
* `IDataEngine.introspectDatasource?` contract member this method
12334+
* implements — not the untyped `Promise<unknown>` it declared while
12335+
* `IDataDriver` was silent about `introspectSchema`. The `as any` on the
12336+
* driver lookup went in the same stroke: the member is on the driver
12337+
* contract now, so the duck-typed probe below is a typed read. Runtime is
12338+
* deliberately byte-identical — both throws and the delegation are
12339+
* unchanged.
12340+
*/
12341+
async introspectDatasource(datasource: string): Promise<SpecIntrospectedSchema> {
12342+
const driver = this.drivers.get(datasource);
1233412343
if (!driver) {
1233512344
throw new Error(`[ObjectQL] Datasource '${datasource}' has no registered driver to introspect.`);
1233612345
}

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

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { Plugin, PluginContext } from '@objectstack/core';
4-
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
4+
import type { IDataEngine, IntrospectedSchema } from '@objectstack/spec/contracts';
55
import {
66
ExternalDatasourceService,
77
type ExternalDatasourceServiceConfig,
@@ -10,15 +10,17 @@ import {
1010
type Logger,
1111
} from './external-datasource-service.js';
1212

13-
/**
14-
* Minimal surfaces the plugin needs from the data engine + metadata service.
15-
* Kept structural so the plugin doesn't hard-depend on concrete classes.
16-
*/
17-
interface DataEngineLike {
18-
/** Resolve a driver by datasource name and introspect its live schema. */
19-
introspectDatasource?: (datasource: string) => Promise<IntrospectedSchema>;
20-
getDatasourceDriver?: (datasource: string) => { introspectSchema?: () => Promise<IntrospectedSchema> } | undefined;
21-
}
13+
// The structural `DataEngineLike` re-declaration that used to live here is
14+
// DELETED (#11493, part of the fix by the maintainer ruling): the `'data'`
15+
// service's real contract (`IDataEngine`, `@objectstack/spec/contracts`) now
16+
// declares `introspectDatasource?` with the spec return type, so this plugin
17+
// no longer needs a private engine type to recover `IntrospectedSchema` from
18+
// an untyped `Promise`. Its second member, `getDatasourceDriver?`, matched NO
19+
// engine in either repository (measured 2026-08-24: zero references outside
20+
// this file) — the fallback branch below probed it and could never fire. The
21+
// probe is respelled to the member the contract actually declares
22+
// (`getDriverByName?`, [#4251]), which makes the degradation reachable for
23+
// the first time instead of silently dead.
2224

2325
interface MetadataServiceLike {
2426
get: (type: string, name: string) => Promise<unknown>;
@@ -61,14 +63,14 @@ export class ExternalDatasourceServicePlugin implements Plugin {
6163
}
6264

6365
async init(ctx: PluginContext): Promise<void> {
64-
const engine = safeGetService<DataEngineLike>(ctx, 'data');
66+
const engine = safeGetService<IDataEngine>(ctx, 'data');
6567
const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');
6668

6769
const introspect: ExternalDatasourceServiceConfig['introspect'] =
6870
this.options.introspect ??
6971
(async (datasource: string) => {
7072
if (engine?.introspectDatasource) return engine.introspectDatasource(datasource);
71-
const driver = engine?.getDatasourceDriver?.(datasource);
73+
const driver = engine?.getDriverByName?.(datasource);
7274
if (driver?.introspectSchema) return driver.introspectSchema();
7375
throw new Error(
7476
`Cannot introspect datasource '${datasource}': no driver introspection available.`,

packages/spec/src/contracts/data-driver.test.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from 'vitest';
22
import type { DriverQuery, IDataDriver } from './data-driver';
3+
import type { IntrospectedSchema } from './schema-diff-service';
34
import type { QueryAST } from '../data/query.zod';
45
import type { DriverOptions } from '../data/driver.zod';
56

@@ -270,4 +271,131 @@ describe('IDataDriver', () => {
270271
expect(unknownOperator.where).toBeTruthy();
271272
});
272273
});
274+
275+
// ===========================================================================
276+
// introspectSchema — the engine-registration road meets the compiler (#11493)
277+
// ===========================================================================
278+
//
279+
// #11381 typed the host-factory road (`DatasourceDriverHandle.introspectSchema`,
280+
// option C of the #11123 ruling). This block pins the OTHER documented road: a
281+
// driver implementing `IDataDriver` and handed to `IDataEngine.registerDriver()`.
282+
// Reverse-verified against the pre-#11493 contract (measured 2026-08-24): with
283+
// the interface silent about `introspectSchema`, the mis-shapes below compiled
284+
// GREEN — an extra member rides along unchecked — which is the gap #11493
285+
// closes. As with the DriverQuery pins above, every directive here is resolved
286+
// by tsc: reverting the member makes each `@ts-expect-error` unused, and an
287+
// unused directive is itself an error, so `pnpm --filter @objectstack/spec
288+
// typecheck` goes red on regression in either direction.
289+
290+
describe('introspectSchema (#11493)', () => {
291+
/** The declared return type, read off the CONTRACT rather than re-spelled. */
292+
type DriverIntrospection = Awaited<ReturnType<NonNullable<IDataDriver['introspectSchema']>>>;
293+
294+
const base: IDataDriver = {
295+
name: 'introspecting',
296+
version: '1.0.0',
297+
supports: {},
298+
connect: async () => {},
299+
disconnect: async () => {},
300+
checkHealth: async () => true,
301+
execute: async () => ({}),
302+
find: async () => [],
303+
findOne: async () => null,
304+
create: async () => ({ id: '1' }),
305+
update: async () => ({ id: '1' }),
306+
upsert: async () => ({ id: '1' }),
307+
delete: async () => true,
308+
count: async () => 0,
309+
bulkCreate: async () => [],
310+
bulkUpdate: async () => [],
311+
bulkDelete: async () => {},
312+
beginTransaction: async () => ({}),
313+
commit: async () => {},
314+
rollback: async () => {},
315+
syncSchema: async () => {},
316+
dropTable: async () => {},
317+
};
318+
319+
it('is optional — a driver without introspection stays conformant', () => {
320+
// `base` above declares no `introspectSchema` and satisfies `IDataDriver`
321+
// at its declaration; introspection is a capability, not an obligation.
322+
expect(base.introspectSchema).toBeUndefined();
323+
});
324+
325+
it('declares exactly the spec introspection shape, not a lookalike', () => {
326+
// Mutual extends: the member's return IS `IntrospectedSchema` — a revert
327+
// to `unknown` (or a drift to a private re-spelling) resolves `Exact` to
328+
// `never` and this line goes red naming the contract.
329+
type Exact = DriverIntrospection extends IntrospectedSchema
330+
? (IntrospectedSchema extends DriverIntrospection ? 'exact' : never)
331+
: never;
332+
const exact: Exact = 'exact';
333+
expect(exact).toBe('exact');
334+
});
335+
336+
it('accepts the spec shape, and a shape that EXTENDS it (the driver-sql pattern)', () => {
337+
const conforming: IDataDriver = {
338+
...base,
339+
introspectSchema: async () => ({
340+
dialect: 'postgres',
341+
introspectedAt: '2026-08-24T00:00:00.000Z',
342+
tables: {
343+
wh_order: {
344+
name: 'wh_order',
345+
columns: [{ name: 'id', type: 'uuid', nullable: false, primaryKey: true }],
346+
},
347+
},
348+
}),
349+
};
350+
// Extra facts ride along: driver-sql's table-level `primaryKeys` /
351+
// `foreignKeys` and per-column `isUnique` / `maxLength` live on declared
352+
// types that EXTEND the spec contract, and assignability admits them on
353+
// any non-literal value. What the contract refuses is a wrong spelling
354+
// of a DECLARED key, never a richer driver.
355+
const extendedResult = {
356+
dialect: 'postgres',
357+
introspectedAt: '2026-08-24T00:00:00.000Z',
358+
tables: {
359+
wh_order: {
360+
name: 'wh_order',
361+
columns: [{ name: 'id', type: 'uuid', nullable: false, primaryKey: true, isUnique: true, maxLength: 36 }],
362+
primaryKeys: ['id'],
363+
foreignKeys: [],
364+
},
365+
},
366+
};
367+
const extended: IDataDriver = { ...base, introspectSchema: async () => extendedResult };
368+
expect(typeof conforming.introspectSchema).toBe('function');
369+
expect(typeof extended.introspectSchema).toBe('function');
370+
});
371+
372+
it('refuses the retired isPrimary spelling at the offending field', () => {
373+
// The defect class this seam actually shipped: primary-key membership
374+
// spelled `isPrimary`, which no consumer reads — the federated table's
375+
// records silently could not be located or updated.
376+
// @ts-expect-error - primary-key membership is spelled `primaryKey`, never `isPrimary`
377+
const misSpelled: DriverIntrospection = { dialect: 'postgres', introspectedAt: 'now', tables: { t: { name: 't', columns: [{ name: 'id', type: 'uuid', nullable: false, isPrimary: true }] } } };
378+
expect(misSpelled).toBeTruthy();
379+
});
380+
381+
it('refuses a bare { tables } with no dialect / introspectedAt envelope', () => {
382+
// @ts-expect-error - `dialect` and `introspectedAt` are REQUIRED on the spec schema
383+
const bareTables: DriverIntrospection = { tables: {} };
384+
expect(bareTables).toBeTruthy();
385+
});
386+
387+
it('refuses a mis-shaped implementation where it is OFFERED, on the registerDriver road', () => {
388+
// Exactly what a pre-#11493 driver author shipped: the whole driver value,
389+
// with an `introspectSchema` answering the pre-spec shape. Against the
390+
// silent contract this assignment compiled green (the measured gap);
391+
// declared, tsc refuses it at the member.
392+
const preFixResult = { tables: { t: { name: 't', columns: [{ name: 'id', type: 'uuid', nullable: false, isPrimary: true }] } } };
393+
const author: IDataDriver = {
394+
...base,
395+
// @ts-expect-error - the pre-spec result shape no longer satisfies the declared member
396+
introspectSchema: async () => preFixResult,
397+
};
398+
expect(author).toBeTruthy();
399+
});
400+
});
273401
});

packages/spec/src/contracts/data-driver.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import type { DriverOptions, DriverCapabilities } from '../data/driver.zod.js';
44
import type { QueryAST } from '../data/query.zod.js';
5+
import type { IntrospectedSchema } from './schema-diff-service.js';
56

67
/**
78
* DriverQuery — the query AST as a **driver** receives it: {@link QueryAST}
@@ -363,6 +364,36 @@ export interface IDataDriver {
363364
*/
364365
getSchemaSyncStats?(): { created: number; existing: number };
365366

367+
/**
368+
* Introspect the live physical schema this driver is connected to
369+
* (ADR-0015): table names, columns, and primary-key membership, as the
370+
* spec's ONE introspection shape — {@link IntrospectedSchema}.
371+
*
372+
* The return type is CONTRACTUAL, and it is declared here for the same
373+
* reason `DatasourceDriverHandle.introspectSchema` was typed by #11381
374+
* (option C of the #11123 ruling): a custom driver has TWO documented roads
375+
* into the same runtime read — the host-factory handle, and direct
376+
* `IDataEngine.registerDriver()` — and until #11493 only the first was
377+
* reachable by a compiler. A driver author implementing THIS interface had
378+
* no signature to mis-match against, so a column flag spelled `isPrimary`,
379+
* or a bare `{ tables }` with no `dialect`/`introspectedAt`, compiled clean
380+
* and surfaced only as a federated table whose records silently could not
381+
* be located or updated (absorbed by the PR #11001 runtime shim). Declared
382+
* here, `tsc` refuses the mis-shape at the offending field on either road.
383+
*
384+
* Extra facts a richer driver carries stay legal — driver-sql's table-level
385+
* `primaryKeys` / `foreignKeys`, per-column `isUnique` / `maxLength` ride
386+
* on declared types that EXTEND the spec contract, and assignability
387+
* admits them. What is refused is a WRONG spelling of a declared key,
388+
* which is the defect class this seam has actually shipped.
389+
*
390+
* Optional: introspection is a capability, not an obligation — drivers
391+
* without it (memory, mongodb today) simply omit the member and stay
392+
* conformant. The engine's `introspectDatasource()` answers their absence
393+
* with a named error rather than a guess.
394+
*/
395+
introspectSchema?(): Promise<IntrospectedSchema>;
396+
366397
/** Drop the underlying table or collection (destructive) */
367398
dropTable(object: string, options?: DriverOptions): Promise<void>;
368399

packages/spec/src/contracts/data-engine.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { describe, it, expect } from 'vitest';
22
import type { IDataEngine, WriteObservabilityOptions } from './data-engine';
33
import type { IDataDriver } from './data-driver';
4+
import type { IntrospectedSchema } from './schema-diff-service';
45
import {
56
EngineUpdateOptionsSchema,
67
DataEngineInsertOptionsSchema,
@@ -353,4 +354,65 @@ describe('Data Engine Contract', () => {
353354
// in 17.0.0 (#4484) — it built an IDataDriver whose only job was to satisfy a
354355
// required method no production code ever called.
355356
});
357+
358+
// ===========================================================================
359+
// introspectDatasource — typed on the contract, not re-declared by consumers
360+
// (#11493, extending the #11123 ruling to the engine-registration seam)
361+
// ===========================================================================
362+
//
363+
// Reverse-verified against the pre-#11493 contract (measured 2026-08-24):
364+
// with the member undeclared, an engine answering a non-spec shape compiled
365+
// green, and the one in-tree consumer (service-datasource's plugin) carried
366+
// a private structural `DataEngineLike` to recover the spec return type.
367+
// Every directive below is resolved by tsc; reverting the member makes it
368+
// unused, and an unused directive is itself an error.
369+
370+
// Deliberately NO new engine double in this block: every pin below reads the
371+
// MEMBER type off the contract instead of standing up another `IDataEngine`
372+
// literal (this file's doubles are counted by `check:engine-double-contract`
373+
// against a shrink-only baseline, and a pin block is not a reason to grow
374+
// it). The value-level optionality evidence already exists above: every
375+
// pre-existing minimal `IDataEngine` literal in this file omits
376+
// `introspectDatasource` and compiles.
377+
describe('introspectDatasource (#11493)', () => {
378+
type Member = IDataEngine['introspectDatasource'];
379+
type EngineIntrospection = Awaited<ReturnType<NonNullable<Member>>>;
380+
381+
it('is optional — an engine without a named-driver registry stays conformant', () => {
382+
// Same posture as `getDriverByName?` ([#4251]): the member's type admits
383+
// `undefined`, so the minimal literals above satisfy the contract without
384+
// it. A revert to a REQUIRED member resolves `Optional` to `never`.
385+
type Optional = undefined extends Member ? 'optional' : never;
386+
const optional: Optional = 'optional';
387+
expect(optional).toBe('optional');
388+
});
389+
390+
it('declares exactly the spec introspection shape', () => {
391+
// Mutual extends: a revert to `Promise<unknown>` — the shape that forced
392+
// the consumer-side re-declaration — resolves `Exact` to `never`.
393+
type Exact = EngineIntrospection extends IntrospectedSchema
394+
? (IntrospectedSchema extends EngineIntrospection ? 'exact' : never)
395+
: never;
396+
const exact: Exact = 'exact';
397+
expect(exact).toBe('exact');
398+
});
399+
400+
it('accepts an implementation that answers the spec shape', () => {
401+
const introspect: NonNullable<Member> = async (_datasource: string) => ({
402+
dialect: 'postgres',
403+
introspectedAt: '2026-08-24T00:00:00.000Z',
404+
tables: {},
405+
});
406+
expect(typeof introspect).toBe('function');
407+
});
408+
409+
it('refuses an implementation that answers a non-spec shape', () => {
410+
// The pre-#11493 posture: `{ tables }` alone, no envelope — absorbed at
411+
// runtime by the consumer-side shim, invisible to every compiler.
412+
const bareTables = { tables: {} };
413+
// @ts-expect-error - the untyped pre-#11493 result no longer satisfies the declared member
414+
const misShapen: NonNullable<Member> = async (_datasource: string) => bareTables;
415+
expect(misShapen).toBeTruthy();
416+
});
417+
});
356418
});

0 commit comments

Comments
 (0)