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
21 changes: 21 additions & 0 deletions .changeset/data-engine-datasource-def-catchup.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
---
"@objectstack/spec": minor
---

feat(spec): `IDataEngine`'s datasource-def contract catches up to the engine — `registerDatasourceDef` accepts `external.credentialsRef`, and `listDatasourceDefs` is declared (#12805)

Additive contract catch-up to declared-and-enforced engine reality (#12758),
the fourth datasource-lifecycle member under the 2026-08-25 #11833 ruling's
item-4 precedent. `registerDatasourceDef`'s parameter now admits the
`external.credentialsRef` secrets-store handle the engine has accepted and
retained since #12758 — before this, a caller typed against the published
contract was refused with TS2353 at the consumer seam for a value the runtime
keeps (the engine's own typecheck was green either way, parameter bivariance).
The new optional `listDatasourceDefs?(): EngineDatasourceDef[]` member is the
read-back of the same registry, declared so a `sys_secret` reference sweep
(#12804) can ask the engine "which code-declared datasources hold a handle"
through the `'data'` slot contract instead of naming the engine class or
re-declaring a consumer-local structural type (the #11833 pattern). Both
members share the newly exported `EngineDatasourceDef` shape and stay
optional — only engines that own a datasource registry answer. No runtime
change; existing `IDataEngine` implementers and callers are unaffected.
1 change: 1 addition & 0 deletions packages/spec/api-surface/contracts.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@
"EmailAddress (type)",
"EmailAttachment (interface)",
"EmailDeliveryStatus (type)",
"EngineDatasourceDef (interface)",
"EngineSchemaRegistryView (interface)",
"EngineTransactionInfo (interface)",
"EngineTransactionOptions (interface)",
Expand Down
1 change: 1 addition & 0 deletions packages/spec/export-origins/contracts.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -75,6 +75,7 @@
"EmailAddress": "src/contracts/email-service.ts#EmailAddress (type)",
"EmailAttachment": "src/contracts/email-service.ts#EmailAttachment (interface)",
"EmailDeliveryStatus": "src/contracts/email-service.ts#EmailDeliveryStatus (type)",
"EngineDatasourceDef": "src/contracts/data-engine.ts#EngineDatasourceDef (interface)",
"EngineSchemaRegistryView": "src/contracts/objectql-engine.ts#EngineSchemaRegistryView (interface)",
"EngineTransactionInfo": "src/contracts/objectql-engine.ts#EngineTransactionInfo (interface)",
"EngineTransactionOptions": "src/contracts/objectql-engine.ts#EngineTransactionOptions (interface)",
Expand Down
66 changes: 62 additions & 4 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 { EngineDatasourceDef, IDataEngine, WriteObservabilityOptions } from './data-engine';
import type { IDataDriver } from './data-driver';
import type { ServiceSlotContract } from './core-service-contracts';
import type { IntrospectedSchema } from './schema-diff-service';
import {
EngineUpdateOptionsSchema,
Expand DownExpand Up@@ -494,30 +495,87 @@ describe('Data Engine Contract', () => {
});
});

describe('datasource lifecycle members (#12248, #12010 via the #11833 ruling item 4)', () => {
describe('datasource lifecycle members (#12248, #12010, #12805 via the #11833 ruling item 4)', () => {
type RegisterMember = IDataEngine['registerDatasourceDef'];
type ListMember = IDataEngine['listDatasourceDefs'];
type MarkMember = IDataEngine['markDatasourceUnavailable'];
type ClearMember = IDataEngine['clearDatasourceUnavailable'];

it('all three are optional — only engines owning a datasource registry answer', () => {
it('all four are optional — only engines owning a datasource registry answer', () => {
type A = undefined extends RegisterMember ? 'optional' : never;
type L = undefined extends ListMember ? 'optional' : never;
type B = undefined extends MarkMember ? 'optional' : never;
type C = undefined extends ClearMember ? 'optional' : never;
const a: A = 'optional';
const l: L = 'optional';
const b: B = 'optional';
const c: C = 'optional';
expect([a, b, c]).toEqual(['optional', 'optional', 'optional']);
expect([a, l, b, c]).toEqual(['optional', 'optional', 'optional', 'optional']);
});

it('registerDatasourceDef takes the declarative def — name required, write gate keys optional', () => {
const register: NonNullable<RegisterMember> = (_def) => {};
register({ name: 'warehouse' });
register({ name: 'warehouse', schemaMode: 'read-only', external: { allowWrites: false } });
// #12805 — the accept set catches up to what the engine has retained
// since #12758: a fresh literal carrying the secrets-store handle
// compiles at THIS seam (before the catch-up it was refused with
// TS2353 here while the runtime accepted and kept the value).
register({
name: 'warehouse',
schemaMode: 'read-only',
external: { allowWrites: false, credentialsRef: 'secrets/warehouse' },
});
// @ts-expect-error - a datasource definition without a name registers nothing
register({ schemaMode: 'read-only' });
// The widening is exactly the ruled key, not an open door: an inline
// credential has no declared home on the def (secrets travel by
// REFERENCE — `credentialsRef`), so an undeclared `external` key stays
// refused. Pinned in BOTH directions, like the `kind` union below.
// @ts-expect-error - `credentials` (inline) is not a declared external key
register({ name: 'warehouse', external: { credentials: 'user:pass' } });
expect(typeof register).toBe('function');
});

it('listDatasourceDefs answers the SAME declared def the register member takes — one shape, no drift', () => {
// Mutual extends pins that the write side and the read-back share ONE
// declaration (`EngineDatasourceDef`): a widening that reaches only one
// of the pair resolves either half to `never`.
type Registered = Parameters<NonNullable<RegisterMember>>[0];
type Listed = ReturnType<NonNullable<ListMember>>[number];
type Same = Registered extends Listed
? (Listed extends Registered ? 'same' : never)
: never;
type Declared = Listed extends EngineDatasourceDef
? (EngineDatasourceDef extends Listed ? 'declared' : never)
: never;
const same: Same = 'same';
const declared: Declared = 'declared';
expect(same).toBe('same');
expect(declared).toBe('declared');
});

it('a sys_secret sweep can read the handle through the data slot contract alone', () => {
// The #12804 consumer seam (#12805): "which code-declared datasources
// hold a `sys_secret` handle", typed against `ServiceSlotContract`
// for the data slot — naming neither the engine class nor a
// consumer-local structural re-declaration (the #11833 pattern).
const sweep = (engine: ServiceSlotContract<'data'>): string[] =>
(engine.listDatasourceDefs?.() ?? [])
.filter((def) => def.external?.credentialsRef !== undefined)
.map((def) => def.name);
expect(typeof sweep).toBe('function');
});

it('refuses an implementation answering nameless or inline-credential defs', () => {
// @ts-expect-error - every listed definition carries its name
const nameless: NonNullable<ListMember> = () => [{ schemaMode: 'read-only' }];
// @ts-expect-error - the def carries a secrets-store REFERENCE, never an inline credential
const inline: NonNullable<ListMember> = () => [{ name: 'w', external: { credentials: 'user:pass' } }];
expect(nameless).toBeTruthy();
expect(inline).toBeTruthy();
});

it('markDatasourceUnavailable admits exactly the two declared kinds', () => {
// `'blocked'` (host policy refused) vs `'failed'` (connect failed under
// a degraded boot) is the framework#3828 distinction — the reason the
Expand Down
84 changes: 76 additions & 8 deletions packages/spec/src/contracts/data-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -164,6 +164,49 @@ export interface WriteObservabilityOptions {
strictReadonlyWrites?: boolean;
}

/**
* A datasource *definition* as the engine retains it (ADR-0015) — the
* declarative facts a datasource states about itself, never a live driver
* connection (that is `registerDriver`).
*
* Named because two members share it: {@link IDataEngine.registerDatasourceDef}
* writes it and {@link IDataEngine.listDatasourceDefs} reads it back. Two
* inline copies of one shape on one contract is the de-facto-contract drift
* the #11833 sweep exists to retire — and a name here is what lets a consumer
* type its sweep without re-declaring a consumer-local structural element
* type, the same pattern one member over. Naming follows the contract's
* existing `Engine*` family (`EngineQueryOptions`, `EngineUpdateOptions`, …);
* `@objectstack/objectql` exports its own structurally-identical
* `DatasourceDef`, which this contract cannot import (dependency direction),
* so the distinct name keeps the two declarations tellable apart until the
* engine converges on this one.
*
* The keys are a deliberate SUBSET of the spec's authored datasource surface
* (`ExternalDatasourceSettingsSchema` in `data/datasource.zod.ts`) — the
* engine retains only what it has a use for. ⛔ Not a mirror of the authored
* block and not a place to grow one: `validation` and `queryTimeoutMs` are
* absent because nothing in the engine reads them ([#12805] mirrors engine
* reality, it does not invent surface).
*/
export interface EngineDatasourceDef {
name: string;
schemaMode?: string;
external?: {
/** Datasource-wide write gate — ADR-0015 §5.3 Gate 3. */
allowWrites?: boolean;
/**
* Reference into the secrets store, never an inline credential
* (`credentialsRef` on `ExternalDatasourceSettingsSchema`). Valid in
* EVERY `schemaMode` — it is the one `external` key a managed datasource
* may carry (#8153) — so a reader sweeping for handles must not filter
* by schema mode. [#12805] The engine has accepted and retained this key
* since #12758; this contract is the catch-up, not a widening beyond
* engine reality.
*/
credentialsRef?: string;
};
}

/**
* IDataEngine - Standard Data Engine Interface
*
Expand DownExpand Up@@ -351,16 +394,41 @@ export interface IDataEngine {
* the third such type the #11833 sweep measured (#12010), adjudicated onto
* the contract by the 2026-08-25 ruling's item 4.
*
* Register a datasource *definition* (ADR-0015) — declarative
* `schemaMode` + `external.allowWrites`, so the engine's write gate can
* enforce external-datasource ownership. Distinct from registering a live
* Register a datasource *definition* (ADR-0015) — the declarative
* {@link EngineDatasourceDef}: `schemaMode` + `external.allowWrites` so the
* engine's write gate can enforce external-datasource ownership, and
* `external.credentialsRef` so {@link listDatasourceDefs} can hand a
* credentials sweep the handle a code-declared datasource holds ([#12805]
* — the engine accepts and retains the reference since #12758; before
* this catch-up a caller typed against THIS contract was refused with
* TS2353 for a value the runtime keeps). Distinct from registering a live
* driver connection. Safe to call repeatedly; last write wins.
*/
registerDatasourceDef?(def: {
name: string;
schemaMode?: string;
external?: { allowWrites?: boolean };
}): void;
registerDatasourceDef?(def: EngineDatasourceDef): void;

/**
* Every datasource DEFINITION this engine holds — from BOTH entry routes,
* {@link registerDatasourceDef} and the engine's package-manifest install
* path. The read-back of the same registry the member above writes, so it
* shares that member's population ("only engines that own a datasource
* registry answer") and its optionality — the fourth member of the
* lifecycle family the 2026-08-25 ruling's item 4 adjudicated onto this
* contract, declared under the same [#4251]/[#11493] evidence bar
* ([#12805]: implemented on `ObjectQL` since #12758, and the #12804
* `sys_secret` reference sweep is the consumer that otherwise must name
* the engine class concretely or re-declare the member structurally).
*
* Exists because a datasource declared IN CODE never reaches the metadata
* store, so a `sys_secret` reference sweep reading `sys_metadata` alone
* cannot see the handle such a datasource holds at
* `external.credentialsRef` and must be handed the list by the engine.
* Implementations answer UNFILTERED (every definition, whatever its
* `schemaMode`, with or without a reference — `credentialsRef` is valid on
* a managed datasource too, #8153) and answer FRESH copies: a reader must
* not be able to reach through this accessor and mutate the write gate's
* own input.
*/
listDatasourceDefs?(): EngineDatasourceDef[];

/**
* Record that a **declared** datasource has no live driver, and why
Expand Down
Loading