diff --git a/.changeset/data-engine-syncobjectschema-declared.md b/.changeset/data-engine-syncobjectschema-declared.md new file mode 100644 index 0000000000..d74c29ed7d --- /dev/null +++ b/.changeset/data-engine-syncobjectschema-declared.md @@ -0,0 +1,6 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-messaging': patch +--- + +`IDataEngine` declares the optional `syncObjectSchema?(objectName: string): Promise` member (#12482) — on-demand single-object physical schema sync: create/alter the object's table, or for a federated (external) object register its DDL-free read metadata (ADR-0015 §18). Additive contract catch-up under the 2026-08-25 #11833 ruling's item-4 precedent as executed by #12248: the member #12010's inventory left "not verified" is verified — implemented on `ObjectQL`, consumed cross-package by two service packages, both until now through consumer-local structural recovery (`service-datasource`'s `ConnectionEngineLike.syncObjectSchema?`, called per bound external object after its driver connects; `service-messaging`'s system-table provisioning via an `as unknown as` cast whose own comment recorded the member "lives on the concrete ObjectQL engine, not the contract"). FROM undeclared (consumers cast or re-declare structurally) TO declared-optional on `IDataEngine` (consumers read `engine.syncObjectSchema` directly and keep their runtime probes). `service-messaging` drops the now-redundant cast (behaviour unchanged). Optional, so existing `IDataEngine` implementers and test doubles are unaffected. No runtime change. diff --git a/.changeset/objectql-engine-getschema-typed.md b/.changeset/objectql-engine-getschema-typed.md new file mode 100644 index 0000000000..0a72f9d22c --- /dev/null +++ b/.changeset/objectql-engine-getschema-typed.md @@ -0,0 +1,6 @@ +--- +'@objectstack/spec': minor +'@objectstack/plugin-security': patch +--- + +`IObjectQLEngine.getSchema` now returns `ServiceObject | undefined` instead of `unknown` (#12481) — the #11833 ruling's fork 3 as executed by #12248, applied one member over by inheritance: `ObjectQL.getObject` is literally `getSchema`'s alias (`return this.getSchema(name)`), the class has always answered `ServiceObject | undefined`, and `ServiceObject` lives in spec (`data/object.zod.ts`), so the contract's "engine-local type" rationale for `unknown` no longer applied here either. FROM `getSchema(objectName: string): unknown` TO `getSchema(objectName: string): ServiceObject | undefined` (authored state, ADR-0122, matching `getObject`). Consumers reading `managedBy` / `fields` / `userActions` off the answer no longer need a cast or a private structural re-declaration; `plugin-security`'s engine-owned write guard drops its now-redundant `as EngineOwnedSchemaLike | undefined` narrowing (behaviour unchanged). Implementations conforming to the class's actual behaviour are unaffected; a fake answering a non-conforming shape now fails compile at the member instead of drifting silently. diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 2a426da54c..349f7f6eac 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -106,7 +106,7 @@ import { MasterReferenceMissingError, MaskedValueWriteError, } from './errors.js'; -import { assertEngineOwnedWriteAllowed, type EngineOwnedSchemaLike } from './system-write-guard.js'; +import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; import { bootstrapPlatformAdmin, shouldReplayBootstrapFor } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, @@ -1679,10 +1679,13 @@ export class SecurityPlugin implements Plugin { // construction. Runs BEFORE the empty-principal fall-open so engine-owned // tables fail CLOSED for principal-less-but-user-context callers. assertEngineOwnedWriteAllowed( - // The contract's getSchema returns `unknown` (schema shape is - // engine-local); narrow to the slice the guard reads. + // [#12481] The contract's getSchema answers `ServiceObject | + // undefined`, which satisfies the guard's `EngineOwnedSchemaLike` + // slice directly — the pre-#12481 `as` narrowing of an `unknown` + // return is gone. The runtime probe stays: doubles and foreign + // engines may omit the member (the contract header's own caveat). typeof ql?.getSchema === 'function' - ? ql.getSchema(opCtx.object) as EngineOwnedSchemaLike | undefined + ? ql.getSchema(opCtx.object) : undefined, opCtx.operation, opCtx.context, diff --git a/packages/services/service-messaging/src/messaging-service-plugin.ts b/packages/services/service-messaging/src/messaging-service-plugin.ts index 9061f70885..8d97a7a303 100644 --- a/packages/services/service-messaging/src/messaging-service-plugin.ts +++ b/packages/services/service-messaging/src/messaging-service-plugin.ts @@ -350,9 +350,11 @@ export class MessagingServicePlugin implements Plugin { * safe on every boot; per-object failures are isolated. */ private async provisionSystemTables(engine: IDataEngine, ctx: PluginContext): Promise { - // `syncObjectSchema` lives on the concrete ObjectQL engine, not the - // IDataEngine contract; engines without on-demand DDL skip provisioning. - const sync = (engine as unknown as { syncObjectSchema?: (name: string) => Promise }).syncObjectSchema; + // [#12482] `syncObjectSchema?` is declared on the IDataEngine contract + // (optional — only engines owning drivers and DDL answer); the + // pre-#12482 `as unknown as` structural recovery is gone. The runtime + // probe stays: engines without on-demand DDL skip provisioning. + const sync = engine.syncObjectSchema; if (typeof sync !== 'function') return; const objects = [ // The L2 event is provisioned with the rest of the pipeline it diff --git a/packages/spec/src/contracts/data-engine.test.ts b/packages/spec/src/contracts/data-engine.test.ts index 7ed1155342..2d11e038df 100644 --- a/packages/spec/src/contracts/data-engine.test.ts +++ b/packages/spec/src/contracts/data-engine.test.ts @@ -540,4 +540,61 @@ describe('Data Engine Contract', () => { expect(exact).toBe('void'); }); }); + + describe('syncObjectSchema (#12482 — the #12010 "not verified" member, via the #11833 ruling item-4 precedent)', () => { + type SyncMember = IDataEngine['syncObjectSchema']; + + it('is optional — only engines owning drivers and DDL answer', () => { + // Same population as the lifecycle trio above: test doubles and + // remote/virtual engines omit it, and callers keep their runtime + // probes (`engine.syncObjectSchema?.(name)`). + type Optional = undefined extends SyncMember ? 'optional' : never; + const optional: Optional = 'optional'; + expect(optional).toBe('optional'); + }); + + it('takes the object name and answers Promise of void — exactly', () => { + type Answer = ReturnType>; + type Exact = Answer extends Promise + ? (Promise extends Answer ? 'exact' : never) + : never; + const exact: Exact = 'exact'; + const sync: NonNullable = async (_objectName: string) => {}; + expect(exact).toBe('exact'); + expect(typeof sync).toBe('function'); + }); + + it('the contract member satisfies both measured consumer-local recoveries', () => { + // The two structural re-declarations this member retires, verbatim + // shapes from the consumers: `service-datasource`'s + // `ConnectionEngineLike.syncObjectSchema?` (called per bound external + // object after its driver connects, ADR-0015 §18) and the slice + // `service-messaging`'s system-table provisioning recovered through + // `as unknown as`. The contract member must remain assignable to + // both, or substituting the contract for the local type needs a cast + // — the outcome the ruling forbids. + type ConnectionEngineSlice = { syncObjectSchema?: (objectName: string) => Promise }; + type MessagingSlice = { syncObjectSchema?: (name: string) => Promise }; + const asConnectionEngine = (engine: IDataEngine): ConnectionEngineSlice => engine; + const asMessagingEngine = (engine: IDataEngine): MessagingSlice => engine; + const provision = async (engine: IDataEngine, objectName: string): Promise => { + // The messaging call pattern, cast-free: probe, then call. + const sync = engine.syncObjectSchema; + if (typeof sync !== 'function') return; + await sync.call(engine, objectName); + }; + expect(typeof asConnectionEngine).toBe('function'); + expect(typeof asMessagingEngine).toBe('function'); + expect(typeof provision).toBe('function'); + }); + + it('refuses an implementation answering synchronously', () => { + // The member is awaited by both consumers inside try/catch isolation; + // an implementation answering `void` (fire-and-forget) would silently + // detach those failures from their per-object handling. + // @ts-expect-error - a synchronous void answer is not the contract + const misShapen: NonNullable = (_objectName: string): void => {}; + expect(misShapen).toBeTruthy(); + }); + }); }); diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index 334d49239e..d5f30be0e1 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -382,4 +382,35 @@ export interface IDataEngine { * (re)connect, or pool removal). */ clearDatasourceUnavailable?(name: string): void; + + /** + * Sync ONE object's physical storage on demand — create/alter its + * table, or for a federated (external) object register its DDL-free read + * metadata (ADR-0015 §18) so remote-table/column mapping and coercion + * exist for queries. Boot-time schema sync runs once at startup, so an + * object that becomes live later — a published draft, or an object bound + * to a datasource whose driver connects after boot — has a registry entry + * but no physical mapping until this is called. Idempotent: implementations + * only create what is absent (and alter to add new columns). + * + * Optional for the same reason as the lifecycle trio above: only an + * engine that owns drivers and DDL can answer; test doubles and + * remote/virtual engines omit it, and callers probe + * (`engine.syncObjectSchema?.(name)` / `typeof === 'function'`). + * + * [#12482] Declared under the [#4251]/[#11493] evidence bar, per the + * 2026-08-25 #11833 ruling's item-4 precedent as executed by #12248 — + * the member #12010's inventory left "not verified", verified now: + * ObjectQL has implemented it since the ADR-0015 federation work, and + * two service packages already consume it cross-package, each through + * consumer-local structural recovery. `service-datasource`'s + * `DatasourceConnectionService` declares it on its `ConnectionEngineLike` + * re-declaration and calls it per bound external object after connect; + * `service-messaging`'s system-table provisioning reached it through an + * `as unknown as` cast whose own comment recorded that the member "lives + * on the concrete ObjectQL engine, not the contract" — the #4251 shape + * exactly: producer meets no compiler, drift lands silently in two + * consumers. + */ + syncObjectSchema?(objectName: string): Promise; } diff --git a/packages/spec/src/contracts/objectql-engine.test.ts b/packages/spec/src/contracts/objectql-engine.test.ts index 78f7c5e672..615870245f 100644 --- a/packages/spec/src/contracts/objectql-engine.test.ts +++ b/packages/spec/src/contracts/objectql-engine.test.ts @@ -85,3 +85,60 @@ describe('getObject return contract (#12248, #11833 fork 3)', () => { expect(ok).toBe('substitutable'); }); }); + +/** + * `getSchema` — typed on the contract, one member over from `getObject` + * (#12481; the #11833 ruling's fork 3 as executed by #12248, applied by + * inheritance: `ObjectQL.getObject` is literally `return this.getSchema(name)`, + * so the mother ruling's reason transfers whole). + * + * Same pin discipline as the block above: every pin reads the MEMBER type off + * the contract, no engine double is stood up, and on a revert to `unknown` the + * consumer reads below stop compiling with no `@ts-expect-error` needed. + */ +describe('getSchema return contract (#12481 — #12248 one member over, #11833 fork 3 by inheritance)', () => { + type SchemaAnswer = ReturnType; + + it('answers exactly the spec registered-object type', () => { + // Mutual extends: a revert to `unknown` (the shape that forced the + // consumer-side casts) resolves `Exact` to `never`, as does a drift to + // the parsed state (`ServiceObjectParsed`) — authored state (`z.input`, + // ADR-0122), exactly as the `getObject` pins above. + type Exact = SchemaAnswer extends ServiceObject | undefined + ? (ServiceObject | undefined extends SchemaAnswer ? 'exact' : never) + : never; + const exact: Exact = 'exact'; + expect(exact).toBe('exact'); + }); + + it('getSchema and getObject cannot drift apart — the alias holds on the contract', () => { + type ObjectAnswer = ReturnType; + type Same = SchemaAnswer extends ObjectAnswer + ? (ObjectAnswer extends SchemaAnswer ? 'same' : never) + : never; + const same: Same = 'same'; + expect(same).toBe('same'); + }); + + it('the engine-owned write guard reads its slice off the contract value directly', () => { + // The measured re-narrowing this typing ends: `plugin-security`'s + // engine-owned write guard cast `ql.getSchema(...)` to its local + // `EngineOwnedSchemaLike` slice (`name` / `managedBy` / `userActions`) + // to perform these reads. On the pre-#12481 `unknown` return each read + // below is a compile error; after it, the contract answer must stay + // assignable to that slice, or dropping the cast (the repair) would need + // a cast back — the outcome the ruling forbids. + const readManagedBy = (engine: IObjectQLEngine, objectName: string) => + engine.getSchema(objectName)?.managedBy; + const readUserActions = (engine: IObjectQLEngine, objectName: string) => + engine.getSchema(objectName)?.userActions; + type WriteGuardSlice = + | { name?: string; managedBy?: string; userActions?: unknown } + | undefined; + type Substitutable = SchemaAnswer extends WriteGuardSlice ? 'substitutable' : never; + const ok: Substitutable = 'substitutable'; + expect(typeof readManagedBy).toBe('function'); + expect(typeof readUserActions).toBe('function'); + expect(ok).toBe('substitutable'); + }); +}); diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts index 7e01bce005..e64619453c 100644 --- a/packages/spec/src/contracts/objectql-engine.ts +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -187,8 +187,27 @@ export interface EngineTransactionInfo { */ export interface IObjectQLEngine extends IDataEngine { // ── Schema access ──────────────────────────────────────────────────── - /** The registered schema for an object, or `undefined` — the write guards' `managedBy` source. */ - getSchema(objectName: string): unknown; + /** + * The registered schema for an object, or `undefined` — the write + * guards' `managedBy` source. + * + * [#12481] Typed as the spec's registered-object type — the #11833 + * ruling's fork 3 as executed by #12248, one member over, applied by + * inheritance: `ObjectQL.getSchema` has always answered + * `ServiceObject | undefined` ({@link getObject} is literally its + * alias, `return this.getSchema(name)`), and `ServiceObject` lives in + * spec (`data/object.zod.ts`), so the header's "engine-local type" + * rationale for `unknown` no longer applied here either. While it said + * `unknown`, consumers re-narrowed through casts or `any` + * (`plugin-security`'s engine-owned write guard casting to its + * `EngineOwnedSchemaLike` slice, `runtime`'s route-action resolver, + * `metadata-core`'s structural field-presence probes, + * `service-messaging`'s outbox header-redaction read) — the #4251 + * drift shape this contract exists to end. Authored state (`z.input`, + * ADR-0122), matching {@link getObject}: the registry stores what was + * registered. + */ + getSchema(objectName: string): ServiceObject | undefined; /** * Engine-level alias of {@link EngineSchemaRegistryView.getObject} (the * migration-flag reader's shape).