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/twelve-dataengine-contract-adoption.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
---
'@objectstack/spec': minor
---

The five engine members the #11833 sweep measured as "real on ObjectQL, consumed cross-package, recoverable only through consumer-local structural re-declarations" are now on the contract, per the 2026-08-25 maintainer ruling (#12248). `IDataEngine` gains five optional members: `resolveEffectiveDatasource?(objectName)` (the #5288 effective-datasource name, `undefined` = rides the deployment default), `getDriverForObject?(objectName)` (the public driver-routing read, `IDataDriver | undefined`), and the datasource-lifecycle trio `registerDatasourceDef?` / `markDatasourceUnavailable?` (`kind: 'blocked' | 'failed'`, framework#3828) / `clearDatasourceUnavailable?` (#12010's inventory, adjudicated per the ruling's item 4). Engines without datasource routing stay conformant — every member is optional, preserving each graceful-degradation seam. And `IObjectQLEngine.getObject` / `EngineSchemaRegistryView.getObject` now return `ServiceObject | undefined` — the spec's own registered-object type (authored state, ADR-0122) — instead of `unknown`, so consumers reading `fields` / `external` off a registered object no longer need a private structural re-declaration to do it; an engine or registry fake answering a non-conforming shape now fails compile at the member instead of drifting silently (the #4251 gap, closed at this seam).
125 changes: 125 additions & 0 deletions packages/spec/src/contracts/data-engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -415,4 +415,129 @@ describe('Data Engine Contract', () => {
expect(misShapen).toBeTruthy();
});
});

// ===========================================================================
// Datasource resolution + lifecycle members (#12248 — the #11833 ruling)
// ===========================================================================
//
// Five members ObjectQL has implemented for releases, all consumed
// cross-package, all recoverable until #12248 only through consumer-local
// structural re-declarations (`service-analytics`'s `DataEngineLike`,
// `service-datasource`'s `ConnectionEngineLike` — the #12010 inventory).
// Declared per the 2026-08-25 maintainer ruling on #11833 (fork 1 option A;
// item 4 for the `ConnectionEngineLike` trio), under the [#4251]/[#11493]
// evidence bar.
//
// Same discipline as the block above: NO new engine double (the value-level
// optionality evidence is the minimal `IDataEngine` literals earlier in this
// file, which omit all five and compile); every directive below is resolved
// by tsc, so reverting a member makes its `@ts-expect-error` unused, and an
// unused directive is itself an error.
describe('datasource resolution members (#12248, #11833 fork 1)', () => {
type ResolveMember = IDataEngine['resolveEffectiveDatasource'];
type DriverMember = IDataEngine['getDriverForObject'];

it('both are optional — an engine without datasource routing stays conformant', () => {
type ResolveOptional = undefined extends ResolveMember ? 'optional' : never;
type DriverOptional = undefined extends DriverMember ? 'optional' : never;
const a: ResolveOptional = 'optional';
const b: DriverOptional = 'optional';
expect(a).toBe('optional');
expect(b).toBe('optional');
});

it('resolveEffectiveDatasource answers a NAME or undefined — exactly', () => {
// Mutual extends: `undefined` is the declared "rides the deployment
// default" answer (#5288 — deliberately not the string 'default', which
// the engine reads as "no explicit binding, keep looking"). A drift to
// bare `string`, or to `string | null`, resolves `Exact` to `never`.
type Answer = ReturnType<NonNullable<ResolveMember>>;
type Exact = Answer extends string | undefined
? (string | undefined extends Answer ? 'exact' : never)
: never;
const exact: Exact = 'exact';
expect(exact).toBe('exact');
});

it('refuses a null-answering resolveEffectiveDatasource implementation', () => {
// `null` vs `undefined` is the drift a structural re-declaration lets
// through silently; the declared member refuses it at the return.
// @ts-expect-error - the absent-binding answer is `undefined`, never `null`
const nullish: NonNullable<ResolveMember> = (_objectName: string) => null;
expect(nullish).toBeTruthy();
});

it('getDriverForObject answers the CONTRACT driver, or undefined — exactly', () => {
type Answer = ReturnType<NonNullable<DriverMember>>;
type Exact = Answer extends IDataDriver | undefined
? (IDataDriver | undefined extends Answer ? 'exact' : never)
: never;
const exact: Exact = 'exact';
expect(exact).toBe('exact');
});

it('a consumer can narrow the returned driver to a picked slice at the call site', () => {
// The `service-analytics` pattern (ADR-0053 temporal coercion): the
// consumer keeps `Pick<IDataDriver, …>` narrowing on the RETURN — what
// the contract ends is re-inventing the MEMBER, not the narrowing.
type TemporalSurface = Pick<IDataDriver, 'temporalFilterValue' | 'temporalFilterColumnSql'>;
const read = (engine: IDataEngine, objectName: string): TemporalSurface | undefined =>
engine.getDriverForObject?.(objectName);
expect(typeof read).toBe('function');
});

it('refuses an implementation answering a non-driver', () => {
const bareName = { name: 'sql' };
// @ts-expect-error - a `{ name }` bag is not the IDataDriver contract
const misShapen: NonNullable<DriverMember> = (_objectName: string) => bareName;
expect(misShapen).toBeTruthy();
});
});

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

it('all three are optional — only engines owning a datasource registry answer', () => {
type A = undefined extends RegisterMember ? 'optional' : never;
type B = undefined extends MarkMember ? 'optional' : never;
type C = undefined extends ClearMember ? 'optional' : never;
const a: A = 'optional';
const b: B = 'optional';
const c: C = 'optional';
expect([a, b, c]).toEqual(['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 } });
// @ts-expect-error - a datasource definition without a name registers nothing
register({ schemaMode: 'read-only' });
expect(typeof register).toBe('function');
});

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
// member exists at all. The literal union is pinned in BOTH directions:
// the two declared arms are accepted, an undeclared arm is refused, so
// widening or narrowing the union moves this case.
const mark: NonNullable<MarkMember> = (_info) => {};
mark({ name: 'warehouse', kind: 'blocked' });
mark({ name: 'warehouse', kind: 'failed', publicDetail: 'temporarily unavailable' });
// @ts-expect-error - only 'blocked' | 'failed' are declared unavailability kinds
mark({ name: 'warehouse', kind: 'offline' });
expect(typeof mark).toBe('function');
});

it('clearDatasourceUnavailable drops a record by name and answers nothing', () => {
type Exact = ReturnType<NonNullable<ClearMember>> extends void ? 'void' : never;
const exact: Exact = 'void';
const clear: NonNullable<ClearMember> = (_name: string) => {};
clear('warehouse');
expect(exact).toBe('void');
});
});
});
94 changes: 94 additions & 0 deletions packages/spec/src/contracts/data-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -288,4 +288,98 @@ export interface IDataEngine {
* a compiler.
*/
introspectDatasource?(datasource: string): Promise<IntrospectedSchema>;

/**
* Which datasource is `objectName` BOUND to — the EFFECTIVE one, resolved
* through the same five steps the engine's own query routing walks
* (explicit `object.datasource` → `datasourceMapping` rules → the ADR-0057
* §3.6 lifecycle split → the owning package's `defaultDatasource` → the
* deployment default), computed as a NAME and without taking a driver.
*
* `undefined` means nothing binds the object anywhere and it rides the
* deployment's default driver — deliberately NOT the string `'default'`,
* because `ObjectSchema.datasource` carries `.default('default')` and in
* the engine that value means "no explicit binding, keep looking", never
* "the primary DB" (#5288: a diagnostic built on the declared value named
* a database the rows were not in). Callers that need the default driver's
* name have {@link getDefaultDriverName}. Implementations never throw here:
* a binding whose datasource has no registered driver is still this
* object's datasource, and a naming probe must be able to report the name
* that is broken.
*
* Optional for the same reason as the registry pair above: only an engine
* that owns datasource routing can answer; test fakes and remote/virtual
* engines simply omit it, and callers probe with `?.`.
*
* [#12248] Declared under the [#4251]/[#11493] evidence bar, per the
* 2026-08-25 maintainer ruling on #11833 (fork 1, option A): ObjectQL has
* implemented this method since #5288, and `service-analytics` reads it
* off the data engine for datasource-capability tiering — while this
* contract stayed silent, that consumer had to carry a private structural
* `DataEngineLike` re-declaration to name the member at all.
*/
resolveEffectiveDatasource?(objectName: string): string | undefined;

/**
* Resolve the storage driver backing `objectName` — the public face of the
* engine's internal per-operation driver routing, answering `undefined`
* (instead of throwing) when no driver is available.
*
* Optional exactly like {@link getDriverByName}: only an engine that owns a
* named-driver registry can resolve an object to a driver.
*
* [#12248] Declared under the same evidence bar, per the same #11833
* ruling (fork 1, option A): ObjectQL implements it, and three packages
* already consume it cross-package through structural re-declarations or
* `any` — `service-analytics` (ADR-0053 temporal storage-form coercion,
* via a local `getDriverForObject?` returning a picked temporal surface),
* `metadata-protocol` (the partial-index probe's driver-ownership read),
* and `plugin-audit` (schema-sync driver probe). Consumers that need only
* a slice of the driver keep narrowing the RETURN at the call site
* (`Pick<IDataDriver, …>` admits the full contract value); what this
* member ends is each of them re-inventing the MEMBER.
*/
getDriverForObject?(objectName: string): IDataDriver | undefined;

/**
* Datasource lifecycle writes — optional: only engines that own a
* datasource registry (the same population as the driver-registry pair
* above). All three are consumed today by `service-datasource`'s
* `DatasourceConnectionService`, which drives the engine through the
* `'data'` slot and, until [#12248], could name these members only through
* its consumer-local structural `ConnectionEngineLike` re-declaration —
* 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
* driver connection. Safe to call repeatedly; last write wins.
*/
registerDatasourceDef?(def: {
name: string;
schemaMode?: string;
external?: { allowWrites?: boolean };
}): void;

/**
* Record that a **declared** datasource has no live driver, and why
* (framework#3828): `'blocked'` — the host's connect policy refused it;
* `'failed'` — the connect failed while the operator opted into a degraded
* boot. Without this record the engine cannot distinguish either case from
* a misspelled datasource name, and answers all three with the same bare
* "is not registered". `publicDetail` is the only part safe to echo to an
* end user; the operator-facing cause stays in logs and the admin surface.
*/
markDatasourceUnavailable?(info: {
name: string;
kind: 'blocked' | 'failed';
publicDetail?: string;
}): void;

/**
* Drop a previous {@link markDatasourceUnavailable} record (successful
* (re)connect, or pool removal).
*/
clearDatasourceUnavailable?(name: string): void;
}
87 changes: 87 additions & 0 deletions packages/spec/src/contracts/objectql-engine.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
import { describe, it, expect } from 'vitest';
import type { EngineSchemaRegistryView, IObjectQLEngine } from './objectql-engine';
import type { ServiceObject } from '../data/object.zod';

/**
* `getObject` — typed on the contract, not re-declared by consumers
* (#12248, fork 3 of the 2026-08-25 maintainer ruling on #11833).
*
* Reverse-verified against the pre-#12248 contract (measured 2026-08-26 on
* this branch's base): with both members declared `unknown`, the consumer
* reads below (`?.fields`, `?.external`) are compile errors on the contract
* value — which is exactly why `service-analytics`, `service-storage` and the
* registry-view readers each carried a private structural re-declaration or
* an `any` to perform them. Every positive pin below therefore goes red on a
* revert to `unknown` with no `@ts-expect-error` needed: the read itself
* stops compiling.
*
* No engine double is stood up here — every pin reads the MEMBER type off the
* contract (`check:engine-double-contract` counts this file's doubles against
* a shrink-only baseline, and a pin block is not a reason to grow it).
*/
describe('getObject return contract (#12248, #11833 fork 3)', () => {
type EngineAnswer = ReturnType<IObjectQLEngine['getObject']>;
type RegistryAnswer = ReturnType<EngineSchemaRegistryView['getObject']>;

it('the engine-level member answers exactly the spec registered-object type', () => {
// Mutual extends: a revert to `unknown` — the shape that forced the
// consumer-side re-declarations — resolves `Exact` to `never`, as does a
// drift to the PARSED state (`ServiceObjectParsed`): the registry stores
// the authored state (`z.input`, ADR-0122).
type Exact = EngineAnswer extends ServiceObject | undefined
? (ServiceObject | undefined extends EngineAnswer ? 'exact' : never)
: never;
const exact: Exact = 'exact';
expect(exact).toBe('exact');
});

it('the registry view answers the same type — the alias holds', () => {
type Exact = RegistryAnswer extends ServiceObject | undefined
? (ServiceObject | undefined extends RegistryAnswer ? 'exact' : never)
: never;
const exact: Exact = 'exact';
expect(exact).toBe('exact');
});

it('a consumer reads fields and the federation marker off the contract value directly', () => {
// The two reads every measured re-declaration existed to perform:
// `service-analytics` (field metadata for dimension labels + the ADR-0015
// `external` marker) and `service-storage` (file-class field scan). On the
// pre-#12248 `unknown` return, each line below is a compile error.
const readFields = (engine: IObjectQLEngine, objectName: string) =>
engine.getObject(objectName)?.fields;
const readExternal = (view: EngineSchemaRegistryView, objectName: string) =>
view.getObject(objectName)?.external;
const readFieldFacts = (engine: IObjectQLEngine, objectName: string, field: string) => {
const def = engine.getObject(objectName)?.fields[field];
return { type: def?.type, reference: def?.reference, options: def?.options };
};
expect(typeof readFields).toBe('function');
expect(typeof readExternal).toBe('function');
expect(typeof readFieldFacts).toBe('function');
});

it('the contract answer satisfies the shape service-analytics re-declared locally', () => {
// The exhibit from the #11833 measurement: `DataEngineLike.getObject?`'s
// declared return in `service-analytics/src/plugin.ts`. The contract type
// must remain assignable to it, or substituting the contract for the
// local type (the services-lane half this card unblocks) needs a cast —
// the outcome the ruling forbids.
type AnalyticsLocalView =
| {
fields?: Record<
string,
{
type?: string;
reference?: string;
options?: Array<{ value: unknown; label?: string }>;
}
>;
external?: unknown;
}
| undefined;
type Substitutable = EngineAnswer extends AnalyticsLocalView ? 'substitutable' : never;
const ok: Substitutable = 'substitutable';
expect(ok).toBe('substitutable');
});
});
Loading
Loading