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
5 changes: 5 additions & 0 deletions .changeset/eleven-introspect-contract.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': minor
---

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.
5 changes: 5 additions & 0 deletions .changeset/eleven-introspect-engine.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/objectql': patch
---

`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).
5 changes: 5 additions & 0 deletions .changeset/eleven-introspect-plugin.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/service-datasource': patch
---

`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.
17 changes: 13 additions & 4 deletions packages/objectql/src/engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -58,7 +58,7 @@ import type { FlowFunctionEffect } from '@objectstack/spec/automation';
// Imported from spec directly rather than through `@objectstack/core`'s
// re-export block: that block is labelled backward-compatibility, and this
// contract is new (#5945).
import type { IScopedContext, IScopedObjectRepository } from '@objectstack/spec/contracts';
import type { IScopedContext, IScopedObjectRepository, IntrospectedSchema as SpecIntrospectedSchema } from '@objectstack/spec/contracts';
import {
IDataDriver,
IDataEngine,
Expand DownExpand Up@@ -12239,9 +12239,18 @@ export class ObjectQL implements IObjectQLEngine {
*
* @throws if the datasource has no registered driver, or the driver does
* not support introspection.
*/
async introspectDatasource(datasource: string): Promise<unknown> {
const driver = this.drivers.get(datasource) as any;
*
* [#11493] The return is the spec's ONE introspection shape — the
* `IDataEngine.introspectDatasource?` contract member this method
* implements — not the untyped `Promise<unknown>` it declared while
* `IDataDriver` was silent about `introspectSchema`. The `as any` on the
* driver lookup went in the same stroke: the member is on the driver
* contract now, so the duck-typed probe below is a typed read. Runtime is
* deliberately byte-identical — both throws and the delegation are
* unchanged.
*/
async introspectDatasource(datasource: string): Promise<SpecIntrospectedSchema> {
const driver = this.drivers.get(datasource);
if (!driver) {
throw new Error(`[ObjectQL] Datasource '${datasource}' has no registered driver to introspect.`);
}
Expand Down
26 changes: 14 additions & 12 deletions packages/services/service-datasource/src/plugin.ts
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import type { Plugin, PluginContext } from '@objectstack/core';
import type { IntrospectedSchema } from '@objectstack/spec/contracts';
import type { IDataEngine, IntrospectedSchema } from '@objectstack/spec/contracts';
import {
ExternalDatasourceService,
type ExternalDatasourceServiceConfig,
Expand All@@ -10,15 +10,17 @@ import {
type Logger,
} from './external-datasource-service.js';

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

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

async init(ctx: PluginContext): Promise<void> {
const engine = safeGetService<DataEngineLike>(ctx, 'data');
const engine = safeGetService<IDataEngine>(ctx, 'data');
const metadata = safeGetService<MetadataServiceLike>(ctx, 'metadata');

const introspect: ExternalDatasourceServiceConfig['introspect'] =
this.options.introspect ??
(async (datasource: string) => {
if (engine?.introspectDatasource) return engine.introspectDatasource(datasource);
const driver = engine?.getDatasourceDriver?.(datasource);
const driver = engine?.getDriverByName?.(datasource);
if (driver?.introspectSchema) return driver.introspectSchema();
throw new Error(
`Cannot introspect datasource '${datasource}': no driver introspection available.`,
Expand Down
128 changes: 128 additions & 0 deletions packages/spec/src/contracts/data-driver.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import type { DriverQuery, IDataDriver } from './data-driver';
import type { IntrospectedSchema } from './schema-diff-service';
import type { QueryAST } from '../data/query.zod';
import type { DriverOptions } from '../data/driver.zod';

Expand DownExpand Up@@ -270,4 +271,131 @@ describe('IDataDriver', () => {
expect(unknownOperator.where).toBeTruthy();
});
});

// ===========================================================================
// introspectSchema — the engine-registration road meets the compiler (#11493)
// ===========================================================================
//
// #11381 typed the host-factory road (`DatasourceDriverHandle.introspectSchema`,
// option C of the #11123 ruling). This block pins the OTHER documented road: a
// driver implementing `IDataDriver` and handed to `IDataEngine.registerDriver()`.
// Reverse-verified against the pre-#11493 contract (measured 2026-08-24): with
// the interface silent about `introspectSchema`, the mis-shapes below compiled
// GREEN — an extra member rides along unchecked — which is the gap #11493
// closes. As with the DriverQuery pins above, every directive here is resolved
// by tsc: reverting the member makes each `@ts-expect-error` unused, and an
// unused directive is itself an error, so `pnpm --filter @objectstack/spec
// typecheck` goes red on regression in either direction.

describe('introspectSchema (#11493)', () => {
/** The declared return type, read off the CONTRACT rather than re-spelled. */
type DriverIntrospection = Awaited<ReturnType<NonNullable<IDataDriver['introspectSchema']>>>;

const base: IDataDriver = {
name: 'introspecting',
version: '1.0.0',
supports: {},
connect: async () => {},
disconnect: async () => {},
checkHealth: async () => true,
execute: async () => ({}),
find: async () => [],
findOne: async () => null,
create: async () => ({ id: '1' }),
update: async () => ({ id: '1' }),
upsert: async () => ({ id: '1' }),
delete: async () => true,
count: async () => 0,
bulkCreate: async () => [],
bulkUpdate: async () => [],
bulkDelete: async () => {},
beginTransaction: async () => ({}),
commit: async () => {},
rollback: async () => {},
syncSchema: async () => {},
dropTable: async () => {},
};

it('is optional — a driver without introspection stays conformant', () => {
// `base` above declares no `introspectSchema` and satisfies `IDataDriver`
// at its declaration; introspection is a capability, not an obligation.
expect(base.introspectSchema).toBeUndefined();
});

it('declares exactly the spec introspection shape, not a lookalike', () => {
// Mutual extends: the member's return IS `IntrospectedSchema` — a revert
// to `unknown` (or a drift to a private re-spelling) resolves `Exact` to
// `never` and this line goes red naming the contract.
type Exact = DriverIntrospection extends IntrospectedSchema
? (IntrospectedSchema extends DriverIntrospection ? 'exact' : never)
: never;
const exact: Exact = 'exact';
expect(exact).toBe('exact');
});

it('accepts the spec shape, and a shape that EXTENDS it (the driver-sql pattern)', () => {
const conforming: IDataDriver = {
...base,
introspectSchema: async () => ({
dialect: 'postgres',
introspectedAt: '2026-08-24T00:00:00.000Z',
tables: {
wh_order: {
name: 'wh_order',
columns: [{ name: 'id', type: 'uuid', nullable: false, primaryKey: true }],
},
},
}),
};
// Extra facts ride along: driver-sql's table-level `primaryKeys` /
// `foreignKeys` and per-column `isUnique` / `maxLength` live on declared
// types that EXTEND the spec contract, and assignability admits them on
// any non-literal value. What the contract refuses is a wrong spelling
// of a DECLARED key, never a richer driver.
const extendedResult = {
dialect: 'postgres',
introspectedAt: '2026-08-24T00:00:00.000Z',
tables: {
wh_order: {
name: 'wh_order',
columns: [{ name: 'id', type: 'uuid', nullable: false, primaryKey: true, isUnique: true, maxLength: 36 }],
primaryKeys: ['id'],
foreignKeys: [],
},
},
};
const extended: IDataDriver = { ...base, introspectSchema: async () => extendedResult };
expect(typeof conforming.introspectSchema).toBe('function');
expect(typeof extended.introspectSchema).toBe('function');
});

it('refuses the retired isPrimary spelling at the offending field', () => {
// The defect class this seam actually shipped: primary-key membership
// spelled `isPrimary`, which no consumer reads — the federated table's
// records silently could not be located or updated.
// @ts-expect-error - primary-key membership is spelled `primaryKey`, never `isPrimary`
const misSpelled: DriverIntrospection = { dialect: 'postgres', introspectedAt: 'now', tables: { t: { name: 't', columns: [{ name: 'id', type: 'uuid', nullable: false, isPrimary: true }] } } };
expect(misSpelled).toBeTruthy();
});

it('refuses a bare { tables } with no dialect / introspectedAt envelope', () => {
// @ts-expect-error - `dialect` and `introspectedAt` are REQUIRED on the spec schema
const bareTables: DriverIntrospection = { tables: {} };
expect(bareTables).toBeTruthy();
});

it('refuses a mis-shaped implementation where it is OFFERED, on the registerDriver road', () => {
// Exactly what a pre-#11493 driver author shipped: the whole driver value,
// with an `introspectSchema` answering the pre-spec shape. Against the
// silent contract this assignment compiled green (the measured gap);
// declared, tsc refuses it at the member.
const preFixResult = { tables: { t: { name: 't', columns: [{ name: 'id', type: 'uuid', nullable: false, isPrimary: true }] } } };
const author: IDataDriver = {
...base,
// @ts-expect-error - the pre-spec result shape no longer satisfies the declared member
introspectSchema: async () => preFixResult,
};
expect(author).toBeTruthy();
});
});
});
31 changes: 31 additions & 0 deletions packages/spec/src/contracts/data-driver.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@

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

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

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

/** Drop the underlying table or collection (destructive) */
dropTable(object: string, options?: DriverOptions): Promise<void>;

Expand Down
62 changes: 62 additions & 0 deletions packages/spec/src/contracts/data-engine.test.ts
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import type { IDataEngine, WriteObservabilityOptions } from './data-engine';
import type { IDataDriver } from './data-driver';
import type { IntrospectedSchema } from './schema-diff-service';
import {
EngineUpdateOptionsSchema,
DataEngineInsertOptionsSchema,
Expand DownExpand Up@@ -353,4 +354,65 @@ describe('Data Engine Contract', () => {
// in 17.0.0 (#4484) — it built an IDataDriver whose only job was to satisfy a
// required method no production code ever called.
});

// ===========================================================================
// introspectDatasource — typed on the contract, not re-declared by consumers
// (#11493, extending the #11123 ruling to the engine-registration seam)
// ===========================================================================
//
// Reverse-verified against the pre-#11493 contract (measured 2026-08-24):
// with the member undeclared, an engine answering a non-spec shape compiled
// green, and the one in-tree consumer (service-datasource's plugin) carried
// a private structural `DataEngineLike` to recover the spec return type.
// Every directive below is resolved by tsc; reverting the member makes it
// unused, and an unused directive is itself an error.

// Deliberately NO new engine double in this block: every pin below reads the
// MEMBER type off the contract instead of standing up another `IDataEngine`
// literal (this file's doubles are counted by `check:engine-double-contract`
// against a shrink-only baseline, and a pin block is not a reason to grow
// it). The value-level optionality evidence already exists above: every
// pre-existing minimal `IDataEngine` literal in this file omits
// `introspectDatasource` and compiles.
describe('introspectDatasource (#11493)', () => {
type Member = IDataEngine['introspectDatasource'];
type EngineIntrospection = Awaited<ReturnType<NonNullable<Member>>>;

it('is optional — an engine without a named-driver registry stays conformant', () => {
// Same posture as `getDriverByName?` ([#4251]): the member's type admits
// `undefined`, so the minimal literals above satisfy the contract without
// it. A revert to a REQUIRED member resolves `Optional` to `never`.
type Optional = undefined extends Member ? 'optional' : never;
const optional: Optional = 'optional';
expect(optional).toBe('optional');
});

it('declares exactly the spec introspection shape', () => {
// Mutual extends: a revert to `Promise<unknown>` — the shape that forced
// the consumer-side re-declaration — resolves `Exact` to `never`.
type Exact = EngineIntrospection extends IntrospectedSchema
? (IntrospectedSchema extends EngineIntrospection ? 'exact' : never)
: never;
const exact: Exact = 'exact';
expect(exact).toBe('exact');
});

it('accepts an implementation that answers the spec shape', () => {
const introspect: NonNullable<Member> = async (_datasource: string) => ({
dialect: 'postgres',
introspectedAt: '2026-08-24T00:00:00.000Z',
tables: {},
});
expect(typeof introspect).toBe('function');
});

it('refuses an implementation that answers a non-spec shape', () => {
// The pre-#11493 posture: `{ tables }` alone, no envelope — absorbed at
// runtime by the consumer-side shim, invisible to every compiler.
const bareTables = { tables: {} };
// @ts-expect-error - the untyped pre-#11493 result no longer satisfies the declared member
const misShapen: NonNullable<Member> = async (_datasource: string) => bareTables;
expect(misShapen).toBeTruthy();
});
});
});
Loading
Loading