diff --git a/.changeset/objectql-engine-contract.md b/.changeset/objectql-engine-contract.md new file mode 100644 index 0000000000..f299729589 --- /dev/null +++ b/.changeset/objectql-engine-contract.md @@ -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('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` +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**. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e2a92c3bd1..a56fa6ab51 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -53,5 +53,7 @@ export type { RouteHandler, Middleware, IDataEngine, + IObjectQLEngine, + EngineSchemaRegistryView, IDataDriver, } from '@objectstack/spec/contracts'; diff --git a/packages/metadata-protocol/src/plugin.ts b/packages/metadata-protocol/src/plugin.ts index bb71da2432..44b998bc60 100644 --- a/packages/metadata-protocol/src/plugin.ts +++ b/packages/metadata-protocol/src/plugin.ts @@ -23,6 +23,7 @@ */ import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; import { SysMetadataObject, SysMetadataHistoryObject, @@ -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('objectql'); // Assembly-conflict guard: the engine plugin's built-in assembly // (registerProtocol !== false) already registered `protocol`. diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 42f2dbaa1c..fcb92e2e8a 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -26,6 +26,7 @@ import { ExecutionContext, ExecutionContextInput, ExecutionContextSchema } from import { IDataDriver, IDataEngine, + type IObjectQLEngine, Logger, createLogger, withTransientRetry, @@ -446,7 +447,12 @@ interface SummaryDescriptor { filter?: Record; } -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 diff --git a/packages/objectql/src/plugin.ts b/packages/objectql/src/plugin.ts index 18a9b8808d..17f989552b 100644 --- a/packages/objectql/src/plugin.ts +++ b/packages/objectql/src/plugin.ts @@ -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 }; @@ -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('metadata'); if (metadataService && typeof metadataService.loadMany === 'function' && this.ql) { await this.loadMetadataFromService(metadataService, ctx); } @@ -1095,7 +1096,7 @@ export class ObjectQLPlugin implements Plugin { */ private async bridgeObjectsToMetadataService(ctx: PluginContext): Promise { try { - const metadataService = ctx.getService('metadata'); + const metadataService = ctx.getService('metadata'); if (!metadataService || typeof metadataService.register !== 'function') { ctx.logger.debug('Metadata service unavailable for bridging, skipping'); return; @@ -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('metadata'); + metadataService = ctx.getService('metadata'); } catch { return; // no metadata service on this kernel — nothing to bridge into } @@ -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('metadata'); if (metadataService && typeof metadataService.loadMany === 'function') { serviceHooks = (await metadataService.loadMany('hook')) ?? []; } @@ -1577,9 +1578,10 @@ export class ObjectQLPlugin implements Plugin { if (!ql || typeof ql.listRegisteredActions !== 'function') return; let loadStandaloneActions: (() => Promise) | undefined; try { - const meta: any = ctx.getService('metadata'); - if (meta && typeof meta.loadMany === 'function') { - loadStandaloneActions = () => meta.loadMany('action'); + const meta = ctx.getService('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({ @@ -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('metadata'); if (metadataService && typeof metadataService.loadMany === 'function') { serviceActions = (await metadataService.loadMany('action')) ?? []; } diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index e64773e820..f4d595d4aa 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -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` @@ -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 { diff --git a/packages/plugins/plugin-auth/src/auth-plugin.ts b/packages/plugins/plugin-auth/src/auth-plugin.ts index 0628721199..108d878291 100644 --- a/packages/plugins/plugin-auth/src/auth-plugin.ts +++ b/packages/plugins/plugin-auth/src/auth-plugin.ts @@ -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, @@ -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, - options?: { object?: string | string[]; priority?: number; packageId?: string }, - ): void; - registerMiddleware?( - fn: (opCtx: any, next: () => Promise) => Promise, - 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. @@ -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('objectql'); + const ql = ctx.getService('objectql'); if (ql && typeof ql.registerMiddleware === 'function') { ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { await next(); @@ -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('objectql'); + const engine = ctx.getService('objectql'); if (!engine || typeof engine.registerHook !== 'function') return; const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] }; engine.registerHook('afterInsert', async (hookCtx: any) => { @@ -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('objectql'); + const engine = ctx.getService('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 @@ -910,7 +880,7 @@ export class AuthPlugin implements Plugin { // Register auth middleware on ObjectQL engine (if available) try { - const ql = ctx.getService('objectql'); + const ql = ctx.getService('objectql'); if (ql && typeof ql.registerMiddleware === 'function') { ql.registerMiddleware(async (opCtx: any, next: () => Promise) => { // If context already has userId or isSystem, skip auth resolution diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts index 9085197655..10003e9746 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints.ts @@ -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. */ @@ -261,37 +261,6 @@ export function foldWildcardSuperUser(objects: Record): 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. @@ -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('objectql') ?? null; } + try { return ctx.getService('objectql') ?? null; } catch { return null; } })(); const dbLoader = ql @@ -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) { @@ -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('objectql')?.registry; + const registry = ctx.getService('objectql')?.registry; for (const app of registry?.getAllApps?.() ?? []) { if ((app as { name?: unknown })?.name) byName.set(String((app as { name: unknown }).name), app); } diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts index 53fdea3bc2..10dd57bff8 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-capabilities.test.ts @@ -9,9 +9,9 @@ function makeQl(declared: any[] = []) { const rows: any[] = []; return { rows, - // readDeclared() reads engine._registry.listItems(type); stub it so + // readDeclared() reads engine.registry.listItems(type); stub it so // capabilities are surfaced without a metadata service. - _registry: { + registry: { listItems(type: string) { return type === 'capability' ? declared.map((c) => ({ content: c })) : []; }, @@ -58,7 +58,7 @@ describe('bootstrapDeclaredCapabilities (ADR-0066 D1 package declaration)', () = const ql = makeQl([{ name: 'billing.refund', label: 'Refund', scope: 'platform', _packageId: 'com.acme.billing' }]); await bootstrapDeclaredCapabilities(ql, null); // Ship a new label on the next boot. - (ql as any)._registry.listItems = (t: string) => + (ql as any).registry.listItems = (t: string) => t === 'capability' ? [{ content: { name: 'billing.refund', label: 'Issue Refund', _packageId: 'com.acme.billing' } }] : []; const out2 = await bootstrapDeclaredCapabilities(ql, null); expect(out2.seeded).toBe(0); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts index eaad4472b4..4b94c6fbdf 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.test.ts @@ -11,7 +11,7 @@ function makeQl(declared: any[] = []) { const rows: any[] = []; return { rows, - _registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, + registry: { listItems: (type: string) => (type === 'permission' ? declared : []) }, async find(object: string, q: any) { if (object !== 'sys_permission_set') return []; const where = q?.where ?? {}; @@ -59,7 +59,7 @@ describe('bootstrapDeclaredPermissions (ADR-0086 D5)', () => { const ql = makeQl([declaredSet()]); await bootstrapDeclaredPermissions(ql, undefined); // simulate a package upgrade changing the shipped grants - (ql as any)._registry = { + (ql as any).registry = { listItems: () => [declaredSet({ objects: { crm_lead: { allowRead: true } } })], }; const r2 = await bootstrapDeclaredPermissions(ql, undefined); diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts index 9a12d782fc..109b7ec545 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-permissions.ts @@ -60,7 +60,7 @@ interface SeedOptions { */ export function readDeclared(engine: any, type: string): any[] { try { - const reg = engine?._registry; + const reg = engine?.registry; if (reg?.listItems) { return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); } diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts index 2af6dca522..df0ce82d2b 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.test.ts @@ -20,7 +20,7 @@ function makeQl(declared: any[] = []) { const rows: any[] = []; return { rows, - _registry: { listItems: (type: string) => (type === 'position' ? declared.map((c) => ({ content: c })) : []) }, + registry: { listItems: (type: string) => (type === 'position' ? declared.map((c) => ({ content: c })) : []) }, async find(object: string, q: any) { if (object !== 'sys_position') return []; const where = q?.where ?? {}; diff --git a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts index 759eed96f5..998865078e 100644 --- a/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts +++ b/packages/plugins/plugin-security/src/bootstrap-declared-positions.ts @@ -50,7 +50,7 @@ interface SeedOptions { */ function readDeclared(engine: any, type: string): any[] { try { - const reg = engine?._registry; + const reg = engine?.registry; if (reg?.listItems) { return (reg.listItems(type) ?? []).map((i: any) => i?.content ?? i).filter(Boolean); } diff --git a/packages/plugins/plugin-security/src/permission-set-projection.test.ts b/packages/plugins/plugin-security/src/permission-set-projection.test.ts index 654d95d890..d8592cf6bd 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.test.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.test.ts @@ -232,7 +232,7 @@ describe('projectPermissionMutation (the awaited projector)', () => { const declaredBody = envBody({ systemPermissions: ['declared.only'] }); const declared = { organization_admin: declaredBody }; // engine SchemaRegistry — the artifact source the projection never writes - (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; const protocol = makeProtocol(ql, declared); const metadata = makeMetadataFacade(); const deps = { ql, metadata }; @@ -313,7 +313,7 @@ describe('package-owned set customization lifecycle (env overlay)', () => { it('a Studio env-scope save on a PACKAGE name customizes the record and keeps provenance', async () => { const ql = makeQl(); const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); - (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; const protocol = makeProtocol(ql, { organization_admin: declaredBody }); registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); @@ -330,7 +330,7 @@ describe('package-owned set customization lifecycle (env overlay)', () => { it('deleting the overlay RESETS the package record to its declared baseline', async () => { const ql = makeQl(); const declaredBody = envBody({ systemPermissions: ['pkg.baseline'] }); - (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; const protocol = makeProtocol(ql, { organization_admin: declaredBody }); registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ id: 'ps_pkg', name: 'organization_admin', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); @@ -481,7 +481,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => it('UPDATE of a PACKAGE-OWNED set becomes an env overlay; the record keeps its provenance', async () => { const ql = makeQl(); const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; const protocol = makeProtocol(ql, { crm_rep: declaredBody }); registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ @@ -507,7 +507,7 @@ describe('createPermissionSetWriteThrough (data door → metadata store)', () => it('DELETE of a customized PACKAGE set removes the overlay and resets to the declared baseline', async () => { const ql = makeQl(); const declaredBody = envBody({ name: 'crm_rep', systemPermissions: ['pkg.baseline'] }); - (ql as any)._registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; + (ql as any).registry = { listItems: (t: string) => (t === 'permission' ? [declaredBody] : []) }; const protocol = makeProtocol(ql, { crm_rep: declaredBody }); registerPermissionSetProjection(protocol, { ql }); ql.permRows.push({ id: 'ps_pkg', name: 'crm_rep', managed_by: 'package', package_id: 'com.example.crm', system_permissions: '["pkg.baseline"]' }); diff --git a/packages/plugins/plugin-security/src/permission-set-projection.ts b/packages/plugins/plugin-security/src/permission-set-projection.ts index ffcf287b76..3ebd629632 100644 --- a/packages/plugins/plugin-security/src/permission-set-projection.ts +++ b/packages/plugins/plugin-security/src/permission-set-projection.ts @@ -164,7 +164,7 @@ const isProjectionEcho = (v: any): boolean => */ function readDeclaredBody(ql: any, name: string): any { try { - const items = ql?._registry?.listItems?.('permission') ?? []; + const items = ql?.registry?.listItems?.('permission') ?? []; for (const i of items) { const body = i?.content ?? i; // Skip projection echoes too: deleteMetaItem's registry heal @@ -182,7 +182,7 @@ function readDeclaredBody(ql: any, name: string): any { /** Whether the engine exposes a SchemaRegistry we can read declared bodies from. */ function hasSchemaRegistry(ql: any): boolean { - return typeof ql?._registry?.listItems === 'function'; + return typeof ql?.registry?.listItems === 'function'; } /** @@ -327,7 +327,7 @@ async function retirePermissionSetRecord( // Drop any engine-registry ghost of the retired definition (a runtime // shadow, or a projection echo re-registered by the delete-time registry // heal) so metadata lists don't keep showing a deleted set. - try { ql?._registry?.unregisterItem?.('permission', name); } catch { /* best-effort */ } + try { ql?.registry?.unregisterItem?.('permission', name); } catch { /* best-effort */ } } catch (e) { logger?.warn?.('[security] failed to retire sys_permission_set record after metadata delete', { name, error: (e as Error)?.message, diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 68f42adbff..a76ff98e31 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3454,7 +3454,7 @@ describe('managed-object write denies wiring (#3325)', () => { insert: async (_o: string, d: any) => ({ id: d?.id ?? 'x' }), update: async () => {}, // A better-auth object the static BETTER_AUTH_MANAGED_OBJECTS list does NOT contain. - _registry: { + registry: { listItems: (type: string) => type === 'object' ? [{ name: 'sys_fake_identity', managedBy: 'better-auth' }] : [], }, diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 5832c3f2a2..0d37fdf3e4 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -17,25 +17,8 @@ import { intersectFieldMasks, } from './explain-engine.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; -import type { IDataEngine, II18nService, IMetadataService } from '@objectstack/spec/contracts'; +import type { II18nService, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; -/** - * The `objectql` slot as this plugin's start-up path checks it: the data - * contract plus the middleware seam it installs. - * - * [#4251] `registerMiddleware` is ObjectQL's own, not `IDataEngine`'s, and the - * probe right below is what the plugin does when it is absent. Declared narrow - * and named rather than erased — see the standing record on `getObjectQL` in - * `@objectstack/runtime` for why ObjectQL's wider contract stays unwritten. - */ -type SecurityEngineSurface = IDataEngine & { - registerMiddleware?( - fn: (opCtx: any, next: () => Promise) => Promise, - options?: { object?: string }, - ): void; - /** Schema lookup the engine-owned write guard reads `managedBy` off of. */ - getSchema?(objectName: string): any; -}; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; @@ -76,7 +59,7 @@ import { matchesFilterCondition } from '@objectstack/formula'; import { FieldMasker } from './field-masker.js'; import { assertReadableQueryFields } from './predicate-guard.js'; import { PermissionDeniedError } from './errors.js'; -import { assertEngineOwnedWriteAllowed } from './system-write-guard.js'; +import { assertEngineOwnedWriteAllowed, type EngineOwnedSchemaLike } from './system-write-guard.js'; import { bootstrapPlatformAdmin } from './bootstrap-platform-admin.js'; import { backfillOrgAdminGrants, @@ -490,11 +473,11 @@ export class SecurityPlugin implements Plugin { ctx.logger.info('Starting Security Plugin...'); // Get required services - let ql: SecurityEngineSurface | undefined; + let ql: IObjectQLEngine | undefined; let metadata: IMetadataService | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); metadata = ctx.getService('metadata'); } catch (e) { ctx.logger.warn('ObjectQL or metadata service not available, security middleware not registered'); @@ -894,7 +877,11 @@ 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( - typeof ql?.getSchema === 'function' ? ql.getSchema(opCtx.object) : undefined, + // The contract's getSchema returns `unknown` (schema shape is + // engine-local); narrow to the slice the guard reads. + typeof ql?.getSchema === 'function' + ? ql.getSchema(opCtx.object) as EngineOwnedSchemaLike | undefined + : undefined, opCtx.operation, opCtx.context, ); diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts index bf94c4a3da..37fcd104ba 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.test.ts @@ -27,7 +27,7 @@ function makeQl(packages: any[] = []) { return { tables, insertCalls, - _registry: { + registry: { getAllPackages: () => packages, listItems: (_type: string) => [], }, @@ -124,7 +124,7 @@ describe('syncAudienceBindingSuggestions (ADR-0090 D5/D9)', () => { it('prunes a pending suggestion once its declaration is gone (uninstall)', async () => { const ql = makeQl([CRM_PACKAGE]); await syncAudienceBindingSuggestions(ql); - ql._registry.getAllPackages = () => []; + ql.registry.getAllPackages = () => []; const out = await syncAudienceBindingSuggestions(ql); expect(out.pruned).toBe(1); expect(ql.tables.sys_audience_binding_suggestion).toHaveLength(0); diff --git a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts index 3c4f85d45e..c3cd72366c 100644 --- a/packages/plugins/plugin-security/src/suggested-audience-bindings.ts +++ b/packages/plugins/plugin-security/src/suggested-audience-bindings.ts @@ -148,7 +148,7 @@ export function collectDeclaredSuggestions(ql: any, metadata?: any): DeclaredSug // Source 2 — installed package manifests (live at install time). try { - const packages: any[] = ql?._registry?.getAllPackages?.() ?? []; + const packages: any[] = ql?.registry?.getAllPackages?.() ?? []; for (const pkg of packages) { if (pkg?.enabled === false) continue; const manifest = pkg?.manifest; diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 31bb30356d..a5b2b02b94 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -17,7 +17,7 @@ import { validateActionParams, type ResolvedActionParam } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; -import type { ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; +import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; import { checkApiExposure } from './api-exposure.js'; import { GLOBAL_ACTION_OBJECT_KEY, @@ -82,7 +82,7 @@ function warnActionParamsOnce(key: string, message: string): void { export interface ActionExecutionDeps { resolveService(name: K, environmentId?: string): Promise | undefined>; resolveService(name: string, environmentId?: string): any; - getObjectQL(environmentId?: string): Promise; + getObjectQL(environmentId?: string): Promise; } /** diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 58045b11d6..95a7a34359 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -7,7 +7,7 @@ import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { loadDisabledPackageIds } from './package-state-store.js'; -import type { IDataEngine, IJobService, IMetadataService, II18nService } from '@objectstack/spec/contracts'; +import type { IJobService, IMetadataService, IObjectQLEngine, II18nService } from '@objectstack/spec/contracts'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; @@ -32,49 +32,6 @@ const SEED_WRITE_OPTIONS = { context: { isSystem: true, skipTriggers: true, seed * hooks that drive the org-scoped `sys_app` catalog. Standalone (single-tenant) * usages may omit this — no catalog hooks are emitted in that case. */ -/** - * The `objectql` slot BEYOND `IDataEngine` — the seams AppPlugin installs on the - * engine. - * - * [#4251] The slot's ledger entry is `IDataEngine`, and it covers the `insert` - * calls in this file and nothing else here. Everything below is ObjectQL's own - * surface, for which no contract has been written — the standing record of why - * is on `getObjectQL` in {@link DomainHandlerContext}. Declared narrow and named - * so the extension is legible rather than erased to `any`; whoever writes - * ObjectQL's contract absorbs this and deletes it. - * - * The members are declared REQUIRED even though a kernel may hold an engine - * that implements none of them: every call site already probes with - * `typeof … === 'function'` and degrades there, and that runtime guard is the - * real defence. Marking them optional here would only turn each guarded - * invocation into a `possibly undefined` error and push the code back toward - * the `any` this rule exists to remove. - */ -interface AppEngineSurface { - bindHooks(hooks: unknown[], options: Record): void; - registerAction(objectKey: string, name: string, handler: unknown, packageId: string): void; - registerDriver(driver: unknown): void; - setDatasourceMapping(rules: unknown): void; - /** - * FIRST-WINS setters (#4251): the engine keeps the first runner and - * returns whether this call installed it. The idempotence guard used to - * live HERE, as a probe of the engine's private `_defaultBodyRunner` / - * `_defaultActionRunner` fields through this surface — an invariant owned - * by every caller and enforced by none. It is the engine's now. - * `boolean | void` because pre-first-wins engines (and bare `vi.fn()` - * doubles) return undefined — treated as installed. - */ - setDefaultBodyRunner(runner: unknown): boolean | void; - setDefaultActionRunner( - runner: (actionDef: any) => ((ctx: any) => Promise) | undefined, - ): boolean | void; - setHookMetricsRecorder(recorder: unknown): void; - getHookMetricsRecorder(): any; - readonly registry?: { setInitialDisabledPackageIds?: (ids: Iterable) => void }; -} - -/** The engine as AppPlugin uses it: the data contract plus those seams. */ -type AppEngine = IDataEngine & AppEngineSurface; export interface AppPluginProjectContext { environmentId: string; @@ -315,9 +272,9 @@ export class AppPlugin implements Plugin { ctx.logger.info('[AppPlugin] OS_DISABLE_AUTHORED_HOOKS=1 — runtime-authored hook bodies will not execute'); return; } - let ql: AppEngine | undefined; + let ql: IObjectQLEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { return; // no engine on this kernel — nothing to wire } @@ -357,9 +314,9 @@ export class AppPlugin implements Plugin { ctx.logger.info('[AppPlugin] OS_DISABLE_AUTHORED_ACTIONS=1 — runtime-authored action bodies will not execute'); return; } - let ql: AppEngine | undefined; + let ql: IObjectQLEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { return; // no engine on this kernel — nothing to wire } @@ -389,9 +346,9 @@ export class AppPlugin implements Plugin { * idempotent across the multiple AppPlugins a multi-app env installs. */ private installHookMetricsTiming(ctx: PluginContext): void { - let ql: AppEngine | undefined; + let ql: IObjectQLEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { return; // no engine on this kernel — nothing to wire } @@ -430,9 +387,9 @@ export class AppPlugin implements Plugin { // Retrieve ObjectQL engine from services // ctx.getService throws when a service is not registered, so we // must use try/catch instead of a null-check. - let ql: AppEngine | undefined; + let ql: IObjectQLEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { // Service not registered — handled below } diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 85cbd6616c..b222cae9bd 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -35,7 +35,7 @@ import type { HttpProtocolContext, HttpDispatcherResult } from './http-dispatcher.js'; import type { CoreServiceName } from '@objectstack/spec/system'; -import type { CoreServiceContract, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; +import type { CoreServiceContract, IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; /** * The normalized request slice a domain handler receives. `path` is the @@ -130,26 +130,17 @@ export interface DomainHandlerDeps { * `.registry`; null otherwise). The data-plane domains (/keys today, * /data /meta when they migrate) depend on this. * - * [#4127 batch 4] **Deliberately still `any`, and this is the record of why.** - * It looks like it should be `IDataEngine` now — batch 3 evidenced - * `objectql: IDataEngine` and this method resolves exactly that slot. It is - * not, because this accessor exists to reach the surface `IDataEngine` does - * NOT cover: it gates on `.registry`, and its callers use `executeAction`. - * Neither is declared on the data-engine contract; `insert`, the third - * method they call, is. - * - * So the slot has two truths and both are real: ObjectQL implements - * `IDataEngine` (the plugin registers it as `data` saying exactly that), and - * ObjectQL is also wider than `IDataEngine`, with no contract written for - * the wider part. Typing this as `IDataEngine` would be the more - * comfortable-looking lie — it would force casts at `.registry` and - * `executeAction` and bury the gap under them. - * - * The concrete input for whoever writes ObjectQL's own contract: - * `registry` and `executeAction` are what the dispatcher actually needs - * from it beyond the data engine. + * [#4127 batch 4 → #4251 B3] This was **deliberately `any`** for two + * batches, with the record of why kept right here: ObjectQL is wider than + * `IDataEngine`, the wider part (`registry`, `executeAction` — exactly + * what this accessor's callers use) had no written contract, and typing it + * `IDataEngine` would have been "the more comfortable-looking lie" that + * buries the gap under casts. That record was the input for + * {@link IObjectQLEngine}, which now declares the full engine and is + * checked against the class by `implements` — so the honest type finally + * exists, and this accessor uses it. */ - getObjectQL(environmentId?: string): Promise; + getObjectQL(environmentId?: string): Promise; /** * Service lookup on the request's RESOLVED (per-environment) kernel — * NOT the default kernel and NOT the scoped-factory path. Domains whose diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index a894ddf7b8..58312de3a7 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -6,7 +6,7 @@ import { import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage } from '@objectstack/spec/system'; -import type { IDataEngine } from '@objectstack/spec/contracts'; +import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -396,10 +396,12 @@ export class HttpDispatcher { } let unhealthy: string[] = []; try { - // [#4251] `checkDriversHealth` is ObjectQL's, not `IDataEngine`'s — - // declared narrow rather than erased; the probe below is what runs + // [#4251] `checkDriversHealth` is ObjectQL's (IObjectQLEngine), not + // `IDataEngine`'s — and this lookup resolves the `data` slot, whose + // ledger entry deliberately stays the narrow view. Partial> + // names the one wider member probed; the probe below is what runs // when the slot holds an engine without a driver-health surface. - let engine: (IDataEngine & { checkDriversHealth?(): Promise }) | undefined; + let engine: (IDataEngine & Partial>) | undefined; try { engine = (this.kernel as any)?.getService?.('data'); } catch { @@ -1415,7 +1417,7 @@ export class HttpDispatcher { * Get the ObjectQL service which provides access to SchemaRegistry. * Tries multiple access patterns since kernel structure varies. */ - private async getObjectQLService(scopeId?: string): Promise { + private async getObjectQLService(scopeId?: string): Promise { // 1. Try via resolveService (handles scoped, async factories, sync, context, and map) try { const svc = await this.resolveService('objectql', scopeId); diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index cf70b02d62..2db064a303 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3650,6 +3650,7 @@ "EmailAddress (type)", "EmailAttachment (interface)", "EmailDeliveryStatus (type)", + "EngineSchemaRegistryView (interface)", "ExecuteUpgradeInput (interface)", "ExplainAccessRequest (interface)", "ExportJobDownload (interface)", @@ -3697,6 +3698,7 @@ "ILock (interface)", "IMetadataService (interface)", "INotificationService (interface)", + "IObjectQLEngine (interface)", "IPackageService (interface)", "IPluginLifecycleEvents (interface)", "IPluginValidator (interface)", diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts index 0b0763edae..0c4049f0c2 100644 --- a/packages/spec/src/contracts/core-service-contracts.test.ts +++ b/packages/spec/src/contracts/core-service-contracts.test.ts @@ -16,6 +16,7 @@ import type { INotificationService } from './notification-service'; import type { II18nService } from './i18n-service'; import type { IDataEngine } from './data-engine'; import type { IHttpServer } from './http-server'; +import type { IObjectQLEngine } from './objectql-engine'; import type { ISecurityService } from './security-service'; import type { IShareLinkService } from './share-link-service'; @@ -92,10 +93,17 @@ describe('slot → contract ledger beyond the enum (#4127 batch 3)', () => { expect(true).toBe(true); }); - it('resolves objectql to the same contract as data, because it is the same instance', () => { - // `packages/objectql`'s plugin registers `this.ql` under both names two - // lines apart. Anything else here would claim they are two services. - type _Alias = Expect, ServiceSlotContract<'data'>>>; + it('resolves objectql to the FULL engine contract, a strict widening of data (#4251 B3)', () => { + // Same instance, two views: `data` is the engine as IDataEngine (the + // data plane), `objectql` is the whole engine. The subtype relation is + // the claim that they cannot drift apart — every IObjectQLEngine IS an + // IDataEngine, so code holding the objectql view can always be handed + // where the data view is expected, never the reverse. + type Extends = A extends B ? true : false; + type _Widens = Expect, ServiceSlotContract<'data'>>>; + type _IsFull = Expect, IObjectQLEngine>>; + // Deliberately NOT equal any more — `data` stays the narrow view. + type _NotSame = Expect, ServiceSlotContract<'data'>>, false>>; expect(true).toBe(true); }); diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts index c1cb85b7aa..f0d9473028 100644 --- a/packages/spec/src/contracts/core-service-contracts.ts +++ b/packages/spec/src/contracts/core-service-contracts.ts @@ -41,6 +41,7 @@ import type { IWorkflowService } from './workflow-service'; import type { ISecurityService } from './security-service'; import type { IShareLinkService } from './share-link-service'; import type { IHttpServer } from './http-server'; +import type { IObjectQLEngine } from './objectql-engine'; /** * The evidenced slot → contract bindings. @@ -126,13 +127,20 @@ export interface ServiceSlotContracts extends CoreServiceContracts { /** `plugin-sharing` registers `ShareLinkService`, which declares `implements IShareLinkService`. */ shareLinks: IShareLinkService; /** - * An **alias of `data`**, not a second service: `packages/objectql`'s plugin - * registers the *same instance* under both names, two lines apart — - * `ctx.registerService('objectql', this.ql)` and - * `ctx.registerService('data', this.ql) // ObjectQL implements IDataEngine`. - * `data` resolved to `IDataEngine` and `objectql` to `any`, for one object. + * The SAME instance as `data`, seen whole. `packages/objectql`'s plugin + * registers one object under both names, two lines apart — and the two + * entries deliberately differ: `data` is the engine as + * {@link IDataEngine} (the data plane, what most consumers should ask + * for), `objectql` is the full engine — registry, hook/middleware seams, + * runners, boot wiring, ops probes. + * + * [#4251 B3] Was `IDataEngine` here too, with the remainder recorded as + * "wider, contract unwritten" on `DomainHandlerContext.getObjectQL` and + * seven consumer-local surface declarations standing in for it. The + * contract exists now and `ObjectQL implements IObjectQLEngine` checks it, + * so the slot resolves to what its occupant actually is. */ - objectql: IDataEngine; + objectql: IObjectQLEngine; /** * The HTTP server slot — **`http.server` is the canonical name**. * diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index 347c1a100d..12d6d355a6 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -9,6 +9,7 @@ export * from './logger.js'; export * from './data-engine.js'; +export * from './objectql-engine.js'; export * from './data-driver.js'; export * from './http-server.js'; export * from './service-registry.js'; diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index d781e4f06e..1af6394adb 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -36,6 +36,14 @@ */ import type { MetadataQuery, MetadataQueryResult, MetadataValidationResult, MetadataBulkResult, MetadataDependency } from '../kernel/metadata-plugin.zod'; +// The PERSISTENCE-side watch event (`add`/`added`/`changed`/`deleted`/…, path + +// file stats) — what `MetadataManager.subscribe` actually relays. NOT the +// near-namesake in `../kernel/metadata-loader.zod`: spec carries TWO types +// named `MetadataWatchEvent` with different shapes (reported on #4251; merging +// them is its own change), and `MetadataManager implements IMetadataService` +// rejected the first draft of this import — which is exactly the check doing +// its job. +import type { MetadataWatchEvent } from '../system/metadata-persistence.zod'; import type { Action } from '../ui/action.zod'; import type { MetadataOverlay } from '../kernel/metadata-customization.zod'; import type { PackagePublishResult, MetadataHistoryQueryOptions, MetadataHistoryQueryResult, MetadataDiffResult } from '../system/metadata-persistence.zod'; @@ -409,6 +417,39 @@ export interface IMetadataService { */ watch?(type: string, callback: MetadataWatchCallback): MetadataWatchHandle; + /** + * Subscribe to LOADER-level change events of a type, unsubscribing by + * calling the returned function. + * + * NOT {@link watch} with a different return shape: the two carry different + * events. `watch` reports registration-level transitions + * (`registered`/`updated`/`unregistered`); `subscribe` relays the loader + * pipeline's {@link MetadataWatchEvent} (`add`/`changed`/`deleted`, with + * path and file stats) — the granularity ObjectQLPlugin's metadata bridge + * re-syncs runtime-authored hooks/actions from. (The first draft of this + * member reused `watch`'s callback type; `MetadataManager implements + * IMetadataService` rejected it — the check working exactly as intended.) + * + * [#4251 B3] Declared from the implementation and its one cross-package + * caller, probed with `typeof … === 'function'`. Optional like `watch`: + * the in-memory fallback need not provide it, and the probe is the + * degraded path. + */ + subscribe?(type: string, callback: (event: MetadataWatchEvent) => void | Promise): () => void; + + /** + * Load EVERY item of a type across all loaders — the bulk read the + * ObjectQLPlugin bridge boots from (hooks, actions), where {@link list} + * serves the registry index. + * + * [#4251 B3] Same evidence as {@link subscribe}: implemented by + * `MetadataManager.loadMany` since the bridge existed, reached through the + * slot only via `any`. `options` is the manager's load-options bag, + * engine-local in shape — declared `Record` here; the one + * caller passes nothing. + */ + loadMany?(type: string, options?: Record): Promise; + // ========================================== // Import / Export // ========================================== diff --git a/packages/spec/src/contracts/objectql-engine.ts b/packages/spec/src/contracts/objectql-engine.ts new file mode 100644 index 0000000000..9afb16d403 --- /dev/null +++ b/packages/spec/src/contracts/objectql-engine.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `IObjectQLEngine` — the contract of the `objectql` service slot: the FULL + * engine, where the `data` slot is the same instance seen as `IDataEngine`. + * + * ## Why this exists (#4251, closing the #4127-batch-4 record) + * + * ObjectQL registers ONE instance under two names — `data` ("ObjectQL + * implements IDataEngine", its registration comment) and `objectql`. The slot + * ledger mapped both to `IDataEngine`, and the standing record on + * `DomainHandlerContext.getObjectQL` explained the remainder honestly: ObjectQL + * is genuinely wider than `IDataEngine`, nobody had written a contract for the + * wider part, and typing the whole thing `IDataEngine` would be "the more + * comfortable-looking lie" — so that accessor stayed `any`, and every consumer + * of the wider surface declared its own local slice (`AppEngineSurface`, + * `EngineRegistrySurface`, `EngineExtensionSurface`, `SecurityEngineSurface`, + * `FreshDatastoreEngine`, …). + * + * Seven such local surfaces later, the problem inverted: each was an honest + * but UNCHECKED claim — `getService('objectql')` is an + * assertion, and nothing tied any of them to the class, so an engine rename + * would break every consumer at runtime with zero compile errors. This file is + * those surfaces merged, deduplicated, and made checkable: `ObjectQL` declares + * `implements IObjectQLEngine`, so every member here is verified against the + * implementation on every build, and consumers import ONE declaration instead + * of maintaining seven. + * + * ## The evidence bar (unchanged from the ledger) + * + * A member is declared here only where a CROSS-PACKAGE consumer already calls + * it through the service slot — this is the union of what the deleted local + * surfaces declared plus the dispatcher's recorded needs (`registry`, + * `executeAction`), not a transcription of the class. Engine members without + * such a consumer (e.g. `triggerHooks`, used cross-package only by tests that + * can import the class) stay OFF the contract until one appears. Widening this + * is for whoever needs more, with the call site to prove it. + * + * ## 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. + */ + +import type { IDataEngine } from './data-engine'; +import type { IDataDriver } from './data-driver'; + +/** + * The engine's schema-registry view — the eight members reached through the + * `objectql` slot from outside the engine package. + * + * ObjectQL exposes the registry as a public `registry` getter over a private + * `_registry` field. Every consumer belongs on the GETTER: the `/me/apps` + * handler reaching `_registry` through `as any` while its sibling handler read + * the public getter (B2), and plugin-security's declared-metadata readers doing + * the same, are the reaches this view retires. + */ +export interface EngineSchemaRegistryView { + /** The registered object schema, or `undefined`. */ + getObject(name: string): unknown; + /** Every registered object schema, optionally scoped to one package. */ + getAllObjects(packageId?: string): unknown[]; + /** Every registered app, nav contributions merged — the `/me/apps` authority. */ + getAllApps(): unknown[]; + /** A registered metadata item by type + name (package-scoped resolution). */ + getItem(type: string, name: string, currentPackageId?: string): T | undefined; + /** Every registered metadata item of a type, optionally scoped to one package. */ + listItems(type: string, packageId?: string): T[]; + /** Every installed package manifest. */ + getAllPackages(): unknown[]; + /** Remove one registered metadata item (plugin-security's projection cleanup). */ + unregisterItem(type: string, name: string): void; + /** Seed the persisted disabled-package set before artifact load (AppPlugin boot). */ + setInitialDisabledPackageIds(ids: Iterable): void; +} + +/** + * The full ObjectQL engine, as the `objectql` slot's consumers use it. + * + * Members beyond {@link IDataEngine} are REQUIRED, not optional: `ObjectQL` + * implements every one (checked by `implements`), and this contract describes + * THAT engine — the slot's actual occupant — not a hypothetical minimal one. + * Callers that tolerate test doubles or foreign engines keep their runtime + * probes (`typeof ql.registerHook === 'function'`), which is defence the type + * system does not replace; marking members optional here would only turn every + * guarded call into a `possibly undefined` error and push code back toward the + * `any` this contract exists to remove. + */ +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; + /** The schema registry — see {@link EngineSchemaRegistryView}. */ + readonly registry: EngineSchemaRegistryView; + + // ── Actions ────────────────────────────────────────────────────────── + registerAction(objectName: string, actionName: string, handler: (ctx: any) => Promise | any, packageName?: string): void; + removeActionsByPackage(packageName: string): void; + /** The dispatcher's action path — one of the two members `getObjectQL` was recorded as needing. */ + executeAction(objectName: string, actionName: string, ctx: any): Promise; + + // ── Hook / middleware seams ────────────────────────────────────────── + registerHook( + event: string, + handler: (context: any) => Promise | void, + options?: { object?: string | string[]; priority?: number; packageId?: string }, + ): void; + unregisterHooksByPackage(packageId: string): number; + registerFunction(name: string, handler: (context: any) => Promise | void, packageId?: string): void; + registerMiddleware( + fn: (opCtx: any, next: () => Promise) => Promise, + options?: { object?: string }, + ): void; + /** Bind declarative Hook metadata — AppPlugin's app-bundle path. */ + bindHooks( + hooks: unknown[] | undefined, + opts?: { + packageId?: string; + functions?: Record Promise | void>; + bodyRunner?: unknown; + strict?: boolean; + warnLegacyHandler?: boolean; + metrics?: unknown; + }, + ): void; + + // ── Default runners & hook metrics (first-wins setters, #4251) ─────── + setDefaultBodyRunner(runner: any): boolean; + getDefaultBodyRunner(): any; + setDefaultActionRunner(runner: (actionDef: any) => ((ctx: any) => Promise) | undefined): boolean; + getDefaultActionRunner(): ((actionDef: any) => ((ctx: any) => Promise) | undefined) | undefined; + setHookMetricsRecorder(recorder: unknown): void; + getHookMetricsRecorder(): any; + + // ── Boot-time wiring (AppPlugin / metadata-protocol) ───────────────── + /** Register a driver; the optional second argument makes it the default. */ + registerDriver(driver: IDataDriver, isDefault?: boolean): void; + /** Install the stack's datasource-mapping rules. Rule shape is engine-local; see `setDatasourceMapping` on the class. */ + setDatasourceMapping(rules: unknown[]): void; + /** Register an app/plugin manifest (objects, apps, metadata items) — MetadataProtocolPlugin's table-provisioning path. */ + registerApp(manifest: any): void; + + // ── Operations ─────────────────────────────────────────────────────── + /** Per-driver health probe — the readiness gate's source. A driver with no probe reports healthy. */ + checkDriversHealth(opts?: { timeoutMs?: number }): Promise>; + /** True when this boot created the datastore from empty — platform-objects' fresh-datastore attestation. */ + wasDatastoreCreatedFromEmpty(): boolean; + /** Drop the memoized migration-flag reads (the attestation may race a fast boot's first read). */ + invalidateDataMigrationFlags(): void; +} diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index 522a35434b..b9fe535daa 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -6,9 +6,7 @@ "packages/cloud-connection/src/cloud-connection-plugin.ts": 5, "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 16, "packages/core/examples/kernel-features-example.ts": 5, - "packages/metadata-protocol/src/plugin.ts": 1, "packages/objectql/src/plugin.integration.test.ts": 23, - "packages/objectql/src/plugin.ts": 7, "packages/plugins/plugin-approvals/src/approvals-plugin.ts": 9, "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts": 3, "packages/plugins/plugin-audit/src/audit-plugin.ts": 1,