From d6a5a2bba2e6fd5a0af2a255e4cffc8d9d5ba6e6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 04:53:08 +0000 Subject: [PATCH 1/2] feat(spec): declare the five ruled IDataEngine members and type getObject (#12248) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy --- .../spec/src/contracts/data-engine.test.ts | 125 ++++++++++++++++++ packages/spec/src/contracts/data-engine.ts | 94 +++++++++++++ .../src/contracts/objectql-engine.test.ts | 87 ++++++++++++ .../spec/src/contracts/objectql-engine.ts | 47 +++++-- 4 files changed, 345 insertions(+), 8 deletions(-) create mode 100644 packages/spec/src/contracts/objectql-engine.test.ts diff --git a/packages/spec/src/contracts/data-engine.test.ts b/packages/spec/src/contracts/data-engine.test.ts index 1dc5355442..7ed1155342 100644 --- a/packages/spec/src/contracts/data-engine.test.ts +++ b/packages/spec/src/contracts/data-engine.test.ts @@ -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>; + 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 = (_objectName: string) => null; + expect(nullish).toBeTruthy(); + }); + + it('getDriverForObject answers the CONTRACT driver, or undefined — exactly', () => { + type Answer = ReturnType>; + 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` narrowing on the RETURN — what + // the contract ends is re-inventing the MEMBER, not the narrowing. + type TemporalSurface = Pick; + 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 = (_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 = (_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 = (_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> extends void ? 'void' : never; + const exact: Exact = 'void'; + const clear: NonNullable = (_name: string) => {}; + clear('warehouse'); + expect(exact).toBe('void'); + }); + }); }); diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index 8e0b739c65..334d49239e 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -288,4 +288,98 @@ export interface IDataEngine { * a compiler. */ introspectDatasource?(datasource: string): Promise; + + /** + * 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` 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; } diff --git a/packages/spec/src/contracts/objectql-engine.test.ts b/packages/spec/src/contracts/objectql-engine.test.ts new file mode 100644 index 0000000000..78f7c5e672 --- /dev/null +++ b/packages/spec/src/contracts/objectql-engine.test.ts @@ -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; + type RegistryAnswer = ReturnType; + + 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'); + }); +}); diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 67c2319ce6..7e01bce005 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -39,15 +39,22 @@ * ## Types are deliberately loose at the edges * * Where the real parameter/return types are `packages/objectql`-local - * (`ServiceObject`, `HookContext`, `InstalledPackage`), the contract says - * `unknown`/`any` rather than importing them — spec must not depend on the - * engine package. Consumers that need the shape narrow at the call site, as - * they always have. + * (`HookContext`, `InstalledPackage`), the contract says `unknown`/`any` + * rather than importing them — spec must not depend on the engine package. + * Consumers that need the shape narrow at the call site, as they always + * have. ⚠️ That rationale is a live predicate, not a blanket: a type that + * MOVES into spec loses the excuse. `ServiceObject` was on the original + * list, migrated to `data/object.zod.ts` (`z.input`), and left `getObject` returning an `unknown` that + * three consumer packages were re-narrowing through private structural + * re-declarations — repaired by #12248 (the #11833 ruling's fork 3): the + * member now returns the spec's own registered-object type. */ import type { IDataEngine } from './data-engine'; import type { IDataDriver } from './data-driver'; import type { FlowFunctionEffect, FlowFunctionEntry } from '../automation/flow-function.zod'; +import type { ServiceObject } from '../data/object.zod'; /** * The engine's schema-registry view — the members reached through the @@ -69,8 +76,18 @@ import type { FlowFunctionEffect, FlowFunctionEntry } from '../automation/flow-f * because no `tsc` ever read the caller. */ export interface EngineSchemaRegistryView { - /** The registered object schema, or `undefined`. */ - getObject(name: string): unknown; + /** + * The registered object schema, or `undefined`. + * + * [#12248] Returns the spec's own registered-object type (the #11833 + * ruling's fork 3, "anything but leave as is"): `SchemaRegistry.getObject` + * has always answered `ServiceObject | undefined`, and while this view + * said `unknown`, registry-view consumers (`plugin-pinyin-search`'s + * companion projection, `plugin-sharing`'s share cascade) re-narrowed + * through `any`. Authored state (`z.input`, ADR-0122) deliberately: the + * registry stores what was registered. + */ + getObject(name: string): ServiceObject | undefined; /** Every registered object schema, optionally scoped to one package. */ getAllObjects(packageId?: string): unknown[]; /** Every registered app, nav contributions merged — the `/me/apps` authority. */ @@ -172,8 +189,22 @@ export interface IObjectQLEngine extends IDataEngine { // ── Schema access ──────────────────────────────────────────────────── /** The registered schema for an object, or `undefined` — the write guards' `managedBy` source. */ getSchema(objectName: string): unknown; - /** Engine-level alias of {@link EngineSchemaRegistryView.getObject} (the migration-flag reader's shape). */ - getObject(name: string): unknown; + /** + * Engine-level alias of {@link EngineSchemaRegistryView.getObject} (the + * migration-flag reader's shape). + * + * [#12248] Typed as the spec's registered-object type — the #11833 + * ruling's fork 3. `ObjectQL.getObject` has always returned + * `ServiceObject | undefined` (it aliases `getSchema`), and `ServiceObject` + * lives in spec (`data/object.zod.ts`), so the header's "engine-local + * type" rationale for `unknown` no longer applied here. While it said + * `unknown`, at least three consumer packages re-invented the return + * structurally to read `fields` / `external` off it (`service-analytics`'s + * `DataEngineLike.getObject?`, `service-storage`'s `FileReferenceEngine`, + * the registry-view readers above) — the #4251 drift shape this contract + * exists to end. + */ + getObject(name: string): ServiceObject | undefined; /** The schema registry — see {@link EngineSchemaRegistryView}. */ readonly registry: EngineSchemaRegistryView; From 9f3b282611461fcdcd0f30646b365880e3009306 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 05:00:18 +0000 Subject: [PATCH 2/2] chore: changeset for the #12248 contract adoption (minor, spec) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01E5LFCYBJ3q2s6yW6oMLxwy --- .changeset/twelve-dataengine-contract-adoption.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/twelve-dataengine-contract-adoption.md diff --git a/.changeset/twelve-dataengine-contract-adoption.md b/.changeset/twelve-dataengine-contract-adoption.md new file mode 100644 index 0000000000..f93759ea08 --- /dev/null +++ b/.changeset/twelve-dataengine-contract-adoption.md @@ -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).