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
60 changes: 60 additions & 0 deletions .changeset/objectql-engine-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": minor
"@objectstack/objectql": patch
"@objectstack/core": patch
"@objectstack/runtime": patch
"@objectstack/metadata-protocol": patch
"@objectstack/platform-objects": patch
"@objectstack/plugin-auth": patch
"@objectstack/plugin-hono-server": patch
"@objectstack/plugin-security": patch
---

feat(spec,objectql): `IObjectQLEngine` — the `objectql` slot's contract exists, the class `implements` it, and the seven consumer-local stand-ins are deleted (#4251 B3)

ObjectQL registers one instance under two names, and the ledger can finally say
what each name means: `data` stays `IDataEngine` (the data plane), `objectql`
now resolves to **`IObjectQLEngine`** — the full engine: schema access
(`getSchema` / `getObject` / `registry`), actions (`registerAction` /
`removeActionsByPackage` / `executeAction`), the hook/middleware seams
(`registerHook` / `unregisterHooksByPackage` / `registerFunction` /
`registerMiddleware` / `bindHooks`), the first-wins default runners and hook
metrics, boot wiring (`registerDriver` / `setDatasourceMapping` /
`registerApp`), and the ops probes (`checkDriversHealth` /
`wasDatastoreCreatedFromEmpty` / `invalidateDataMigrationFlags`). The ledger
test pins the new relation: `objectql` strictly widens `data`, deliberately no
longer equal.

**Why now, and why `implements` is the point.** The honest state for two
batches was recorded on `DomainHandlerContext.getObjectQL`: ObjectQL is wider
than `IDataEngine`, the wider part had no contract, and typing it `IDataEngine`
would be "the more comfortable-looking lie". The interim discipline — each
consumer declares the narrow slice it uses — produced seven local surfaces
(`AppEngineSurface`, `EngineRegistrySurface`, `EngineExtensionSurface`,
`SecurityEngineSurface`, `FreshDatastoreEngine`, the dispatcher's inline
`checkDriversHealth` slice, the `getObjectQL: any` itself). Each was honest and
each was an UNCHECKED claim: `getService<Surface>('objectql')` is an assertion,
so an engine rename would have broken every consumer at runtime with zero
compile errors. `ObjectQL implements IObjectQLEngine` converts all of them into
one compiler-verified claim. All seven stand-ins are deleted; consumers import
the one declaration. `getObjectQL` is typed `Promise<IObjectQLEngine | null>`
end to end, closing the oldest documented `any` in the dispatcher.

**Evidence bar unchanged.** Every declared member has a cross-package consumer
reaching it through the slot; engine members without one (e.g. `triggerHooks`,
cross-package only in tests) stay off until a caller appears. The registry view
(`EngineSchemaRegistryView`) declares exactly the eight members consumers use.

**`_registry` never leaves the engine package now.** plugin-security's
declared-metadata readers (`readDeclared`, permission-set projection, suggested
audience bindings) reached ObjectQL's private `_registry` field through `any` —
the same private reach `/me/apps` had in B2, five more times. All migrated to
the public `registry` getter the contract declares, test doubles included.

**`IMetadataService` gains `subscribe?` / `loadMany?`** — implemented by
`MetadataManager` beside `watch` all along, reached through the slot only via
`any` by ObjectQLPlugin's metadata bridge (the re-sync keeping runtime-authored
hooks/actions live). With them declared, the bridge's six `metadata` lookups
and metadata-protocol's `objectql` lookup carry contract types, and both files
leave the grandfather list entirely: baseline **167 → 159 sites, 36 → 34
files**.
2 changes: 2 additions & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,7 @@ export type {
RouteHandler,
Middleware,
IDataEngine,
IObjectQLEngine,
EngineSchemaRegistryView,
IDataDriver,
} from '@objectstack/spec/contracts';
3 changes: 2 additions & 1 deletion packages/metadata-protocol/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
*/

import type { Plugin, PluginContext } from '@objectstack/core';
import type { IObjectQLEngine } from '@objectstack/spec/contracts';
import {
SysMetadataObject,
SysMetadataHistoryObject,
Expand Down Expand Up @@ -51,7 +52,7 @@ export function createMetadataProtocolPlugin(options: MetadataProtocolPluginOpti
dependencies: ['com.objectstack.engine.objectql'],

init: async (ctx: PluginContext) => {
const ql: any = ctx.getService('objectql');
const ql = ctx.getService<IObjectQLEngine>('objectql');

// Assembly-conflict guard: the engine plugin's built-in assembly
// (registerProtocol !== false) already registered `protocol`.
Expand Down
8 changes: 7 additions & 1 deletion packages/objectql/src/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { ExecutionContext, ExecutionContextInput, ExecutionContextSchema } from
import {
IDataDriver,
IDataEngine,
type IObjectQLEngine,
Logger,
createLogger,
withTransientRetry,
Expand Down Expand Up @@ -446,7 +447,12 @@ interface SummaryDescriptor {
filter?: Record<string, unknown>;
}

export class ObjectQL implements IDataEngine {
// `implements IObjectQLEngine` is the verification step of #4251 B3: every
// member the `objectql` slot's contract declares is checked against this class
// on every build, so the seven consumer-local surface declarations the contract
// replaced can never silently drift from the engine again. IObjectQLEngine
// extends IDataEngine, so the old claim rides along.
export class ObjectQL implements IObjectQLEngine {
/**
* Ambient transaction store (ADR-0034). While a `transaction()` callback
* runs, the active transaction handle lives here so that EVERY data
Expand Down
20 changes: 11 additions & 9 deletions packages/objectql/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { StorageNameMapping } from '@objectstack/spec/system';
import { LifecycleService } from './lifecycle/lifecycle-service.js';
import { lifecycleSettingsManifest } from './lifecycle/lifecycle-settings.js';
import { runActionGovernanceInventory } from './action-governance.js';
import type { IMetadataService } from '@objectstack/spec/contracts';

export type { Plugin, PluginContext };

Expand Down Expand Up @@ -311,7 +312,7 @@ export class ObjectQLPlugin implements Plugin {

// Sync from external metadata service (e.g. MetadataPlugin) if available
try {
const metadataService = ctx.getService('metadata') as any;
const metadataService = ctx.getService<IMetadataService>('metadata');
if (metadataService && typeof metadataService.loadMany === 'function' && this.ql) {
await this.loadMetadataFromService(metadataService, ctx);
}
Expand Down Expand Up @@ -1095,7 +1096,7 @@ export class ObjectQLPlugin implements Plugin {
*/
private async bridgeObjectsToMetadataService(ctx: PluginContext): Promise<void> {
try {
const metadataService = ctx.getService<any>('metadata');
const metadataService = ctx.getService<IMetadataService>('metadata');
if (!metadataService || typeof metadataService.register !== 'function') {
ctx.logger.debug('Metadata service unavailable for bridging, skipping');
return;
Expand Down Expand Up @@ -1181,9 +1182,9 @@ export class ObjectQLPlugin implements Plugin {
if (!packageId || !this.ql?.registry) return;

try {
let metadataService: any;
let metadataService: IMetadataService | undefined;
try {
metadataService = ctx.getService<any>('metadata');
metadataService = ctx.getService<IMetadataService>('metadata');
} catch {
return; // no metadata service on this kernel — nothing to bridge into
}
Expand Down Expand Up @@ -1383,7 +1384,7 @@ export class ObjectQLPlugin implements Plugin {

let serviceHooks: any[] | null = null;
try {
const metadataService = ctx.getService('metadata') as any;
const metadataService = ctx.getService<IMetadataService>('metadata');
if (metadataService && typeof metadataService.loadMany === 'function') {
serviceHooks = (await metadataService.loadMany('hook')) ?? [];
}
Expand Down Expand Up @@ -1577,9 +1578,10 @@ export class ObjectQLPlugin implements Plugin {
if (!ql || typeof ql.listRegisteredActions !== 'function') return;
let loadStandaloneActions: (() => Promise<any[]>) | undefined;
try {
const meta: any = ctx.getService('metadata');
if (meta && typeof meta.loadMany === 'function') {
loadStandaloneActions = () => meta.loadMany('action');
const meta = ctx.getService<IMetadataService>('metadata');
const loadMany = meta?.loadMany;
if (meta && typeof loadMany === 'function') {
loadStandaloneActions = () => loadMany.call(meta, 'action');
}
} catch { /* no metadata service — registry objects still audit */ }
this.lastGovernanceFingerprint = await runActionGovernanceInventory({
Expand Down Expand Up @@ -1638,7 +1640,7 @@ export class ObjectQLPlugin implements Plugin {

let serviceActions: any[] | null = null;
try {
const metadataService = ctx.getService('metadata') as any;
const metadataService = ctx.getService<IMetadataService>('metadata');
if (metadataService && typeof metadataService.loadMany === 'function') {
serviceActions = (await metadataService.loadMany('action')) ?? [];
}
Expand Down
19 changes: 3 additions & 16 deletions packages/platform-objects/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,20 +4,9 @@ import { SetupAppTranslations } from './apps/translations/index.js';
import { MetadataFormsTranslations } from './metadata-translations/index.js';
import { SysMigration } from './system/sys-migration.object.js';
import { SysSecret } from './system/sys-secret.object.js';
import { attestFreshDatastore, type MigrationFlagEngine } from './system/migration-flag.js';
import type { II18nService } from '@objectstack/spec/contracts';
import { attestFreshDatastore } from './system/migration-flag.js';
import type { II18nService, IObjectQLEngine } from '@objectstack/spec/contracts';

/**
* The `objectql` slot's fresh-datastore attestation seam.
*
* [#4251] Not on `IDataEngine` — these are ObjectQL's own migration-flag
* accessors, and the probe at the call site is what runs when the slot holds an
* engine without them. Declared narrow and named instead of erased to `any`.
*/
interface FreshDatastoreEngine extends MigrationFlagEngine {
wasDatastoreCreatedFromEmpty?(): boolean;
invalidateDataMigrationFlags?(): void;
}

/**
* `PlatformObjectsPlugin`
Expand Down Expand Up @@ -108,9 +97,7 @@ export class PlatformObjectsPlugin {
// service-storage). A store that was found rather than created attests
// nothing and keeps producing evidence by scan.
ctx?.hook?.('kernel:ready', async () => {
// [#4251] The fresh-datastore attestation seam is ObjectQL's own, not
// `IDataEngine`'s; declared here rather than erased to `any`.
let engine: FreshDatastoreEngine | undefined;
let engine: IObjectQLEngine | undefined;
try {
engine = ctx.getService?.('objectql');
} catch {
Expand Down
40 changes: 5 additions & 35 deletions packages/plugins/plugin-auth/src/auth-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
import { SysOrganizationDetailPage, SysUserDetailPage } from '@objectstack/platform-objects/pages';
import { resolveTenancyPosture } from '@objectstack/types';
import { postureEnforcesWall, type OrgScopingEntitlement } from '@objectstack/spec/security';
import type { IDataEngine, IEmailService, ISmsService } from '@objectstack/spec/contracts';
import type { IDataEngine, IEmailService, IObjectQLEngine, ISmsService } from '@objectstack/spec/contracts';
import {
AuthManager,
resolveOidcProviderEnabled,
Expand All @@ -44,36 +44,6 @@ import {
authPluginManifestHeader,
} from './manifest.js';

/**
* The `objectql` slot BEYOND `IDataEngine` — the hook and middleware seams this
* plugin installs on the engine.
*
* [#4251] The slot's ledger entry is `IDataEngine` and it covers every read and
* write below; it does not cover `registerHook` / `registerMiddleware`, and no
* contract has been written for the wider ObjectQL surface yet (the standing
* record of why is on `getObjectQL` in `@objectstack/runtime`'s
* `DomainHandlerContext`). Declared here, narrow and named, so the extension is
* legible instead of hidden under `any` — and so it is deleted, not migrated,
* when that contract lands.
*
* Both members are optional and every call site already guards with
* `typeof … === 'function'`: the slot is satisfiable by engines that implement
* neither (mock mode), and this plugin degrades rather than fails there.
*/
interface EngineExtensionSurface {
registerHook?(
event: string,
handler: (context: any) => Promise<void> | void,
options?: { object?: string | string[]; priority?: number; packageId?: string },
): void;
registerMiddleware?(
fn: (opCtx: any, next: () => Promise<void>) => Promise<void>,
options?: { object?: string },
): void;
}

/** The engine as this plugin uses it: the data contract plus those two seams. */
type AuthEngine = IDataEngine & EngineExtensionSurface;

/**
* The `settings` slot, as this plugin reads it.
Expand Down Expand Up @@ -764,7 +734,7 @@ export class AuthPlugin implements Plugin {
// to platform admin" case where kernel:ready fired before any user
// existed (same wiring the multi-org bootstrap uses).
try {
const ql = ctx.getService<AuthEngine>('objectql');
const ql = ctx.getService<IObjectQLEngine>('objectql');
if (ql && typeof ql.registerMiddleware === 'function') {
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
await next();
Expand Down Expand Up @@ -841,7 +811,7 @@ export class AuthPlugin implements Plugin {
try {
// Use the kernel's ObjectQL engine (available + hookable at kernel:ready);
// the auth manager's getDataEngine() is not yet wired this early.
const engine = ctx.getService<AuthEngine>('objectql');
const engine = ctx.getService<IObjectQLEngine>('objectql');
if (!engine || typeof engine.registerHook !== 'function') return;
const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] };
engine.registerHook('afterInsert', async (hookCtx: any) => {
Expand Down Expand Up @@ -883,7 +853,7 @@ export class AuthPlugin implements Plugin {
// bypass — see identity-write-guard.ts for the full contract.
ctx.hook('kernel:ready', async () => {
try {
const engine = ctx.getService<AuthEngine>('objectql');
const engine = ctx.getService<IObjectQLEngine>('objectql');
if (!engine || typeof engine.registerHook !== 'function') return;
registerManagedUpdateWhitelist(SystemObjectName.USER, SYS_USER_PROFILE_EDIT_FIELDS);
// [ADR-0105 D7] Extension fields ObjectStack adds to better-auth-managed
Expand All @@ -910,7 +880,7 @@ export class AuthPlugin implements Plugin {

// Register auth middleware on ObjectQL engine (if available)
try {
const ql = ctx.getService<AuthEngine>('objectql');
const ql = ctx.getService<IObjectQLEngine>('objectql');
if (ql && typeof ql.registerMiddleware === 'function') {
ql.registerMiddleware(async (opCtx: any, next: () => Promise<void>) => {
// If context already has userId or isSystem, skip auth resolution
Expand Down
46 changes: 9 additions & 37 deletions packages/plugins/plugin-hono-server/src/current-user-endpoints.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ import {
type EnableLike,
} from '@objectstack/spec/data';
import type { ExecutionContext } from '@objectstack/spec/kernel';
import type { IAuthService, IMetadataService, Logger } from '@objectstack/spec/contracts';
import type { IAuthService, IMetadataService, IObjectQLEngine, Logger } from '@objectstack/spec/contracts';
import { allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability';

/** API prefix these endpoints mount under unless the host overrides it. */
Expand Down Expand Up @@ -261,37 +261,6 @@ export function foldWildcardSuperUser(objects: Record<string, any>): void {
}
}

/**
* The `objectql` slot BEYOND `IDataEngine` — the schema registry these
* endpoints read.
*
* [#4251] The slot's ledger entry is `IDataEngine` (ObjectQL registers the SAME
* instance under `data` and `objectql`), and that covers the reads below. It
* does NOT cover `registry` / `getSchema`, and the honest record of why lives
* on `getObjectQL` in `@objectstack/runtime`'s `DomainHandlerContext`: ObjectQL
* is genuinely wider than `IDataEngine`, nobody has written a contract for the
* wider part, and typing the whole thing `IDataEngine` would be "the more
* comfortable-looking lie". So the extra surface is declared here, named and
* narrow, instead of the lookup being erased to `any` — the wider contract, when
* someone writes it, absorbs this and the declaration is deleted.
*
* Every member is optional and every call site probes with `?.`: the slot is
* satisfied by engines with no registry at all (test fakes, remote engines), and
* these endpoints degrade rather than fail when it is absent.
*/
interface EngineRegistrySurface {
/**
* The engine's schema registry — the PUBLIC accessor. ObjectQL exposes it as
* `get registry()` over the private `_registry` field; `_registry` is what
* `/me/apps` used to reach through `as any`, two handlers away from the
* `/auth/me/permissions` reach for the public one, for the same object.
*/
readonly registry?: {
getAllObjects?(): ApiExposureSchemaLike[];
getAllApps?(): unknown[];
};
getSchema?(objectName: string): unknown;
}

/**
* The `security.permissions` slot, as these two handlers use it.
Expand Down Expand Up @@ -769,7 +738,7 @@ export function registerCurrentUserEndpoints(
// (created via the admin UI as `sys_permission_set`
// rows) that aren't in metadata or bootstrap.
const ql = (() => {
try { return ctx.getService<IDataEngine & EngineRegistrySurface>('objectql') ?? null; }
try { return ctx.getService<IObjectQLEngine>('objectql') ?? null; }
catch { return null; }
})();
const dbLoader = ql
Expand Down Expand Up @@ -897,9 +866,12 @@ export function registerCurrentUserEndpoints(
// can attach their effective apiOperations. Guarded — a failure
// here must never drop the whole response.
try {
const allSchemas: ApiExposureSchemaLike[] = (() => {
try { return ql?.registry?.getAllObjects?.() ?? []; }
catch { return []; }
// The contract's registry view returns `unknown[]` (schema
// shape is engine-local); narrow to the slice this seeding
// reads, as the callers of getSchema below already do.
const allSchemas = (() => {
try { return (ql?.registry?.getAllObjects?.() ?? []) as ApiExposureSchemaLike[]; }
catch { return [] as ApiExposureSchemaLike[]; }
})();
seedSuperUserRestrictedObjects(objects, allSchemas);
} catch (e: any) {
Expand Down Expand Up @@ -980,7 +952,7 @@ export function registerCurrentUserEndpoints(
// private `_registry` while `/auth/me/permissions` read the
// `registry` getter over the same field, on the same object,
// in the same file — visible only once both were typed.
const registry = ctx.getService<EngineRegistrySurface>('objectql')?.registry;
const registry = ctx.getService<IObjectQLEngine>('objectql')?.registry;
for (const app of registry?.getAllApps?.() ?? []) {
if ((app as { name?: unknown })?.name) byName.set(String((app as { name: unknown }).name), app);
}
Expand Down
Loading
Loading