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
6 changes: 6 additions & 0 deletions .changeset/data-engine-syncobjectschema-declared.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
---
'@objectstack/spec': minor
'@objectstack/service-messaging': patch
---

`IDataEngine` declares the optional `syncObjectSchema?(objectName: string): Promise<void>` 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.
6 changes: 6 additions & 0 deletions .changeset/objectql-engine-getschema-typed.md
Original file line numberDiff line numberDiff line change
@@ -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.
11 changes: 7 additions & 4 deletions packages/plugins/plugin-security/src/security-plugin.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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,
Expand DownExpand Up@@ -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,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<void> {
// `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<void> }).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
Expand Down
57 changes: 57 additions & 0 deletions packages/spec/src/contracts/data-engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<NonNullable<SyncMember>>;
type Exact = Answer extends Promise<void>
? (Promise<void> extends Answer ? 'exact' : never)
: never;
const exact: Exact = 'exact';
const sync: NonNullable<SyncMember> = 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<void> };
type MessagingSlice = { syncObjectSchema?: (name: string) => Promise<void> };
const asConnectionEngine = (engine: IDataEngine): ConnectionEngineSlice => engine;
const asMessagingEngine = (engine: IDataEngine): MessagingSlice => engine;
const provision = async (engine: IDataEngine, objectName: string): Promise<void> => {
// 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<SyncMember> = (_objectName: string): void => {};
expect(misShapen).toBeTruthy();
});
});
});
31 changes: 31 additions & 0 deletions packages/spec/src/contracts/data-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<void>;
}
57 changes: 57 additions & 0 deletions packages/spec/src/contracts/objectql-engine.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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<IObjectQLEngine['getSchema']>;

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<IObjectQLEngine['getObject']>;
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');
});
});
23 changes: 21 additions & 2 deletions packages/spec/src/contracts/objectql-engine.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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).
Expand Down
Loading