From 6e3d61428947a88629c992168965d256bc65b95f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:23:20 +0000 Subject: [PATCH 1/9] refactor(metadata-protocol): spell the inferred return types of the eight protocol.ts doors kept on the facade Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 169 ++++++++++++++++++++- 1 file changed, 161 insertions(+), 8 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index de7412e0287..0a87e4f8227 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2485,7 +2485,60 @@ export class ObjectStackProtocolImplementation implements return ProtocolDiscoveryAndBasesFacet.getProjectId(protocolHost(this)); } - async getDiscovery() { + async getDiscovery(): Promise<{ + authFamilies?: { + admin: boolean; + } | undefined; + version: string; + name: string; + /** @deprecated Use `name`. Removed in protocol 18 (#4828). */ + apiName: string; + environment: "sandbox" | "production" | "development"; + routes: { + data: string; + metadata: string; + discovery?: string | undefined; + ui?: string | undefined; + auth?: string | undefined; + automation?: string | undefined; + storage?: string | undefined; + analytics?: string | undefined; + packages?: string | undefined; + datasources?: string | undefined; + email?: string | undefined; + approvals?: string | undefined; + realtime?: string | undefined; + notifications?: string | undefined; + ai?: string | undefined; + i18n?: string | undefined; + mcp?: string | undefined; + }; + locale: { + default: string; + supported: string[]; + timezone: string; + }; + services: Record; + capabilities: Record<"ai" | "automation" | "cron" | "analytics" | "files" | "search" | "export" | "i18n" | "notifications" | "comments" | "chunkedUpload" | "transactionalBatch" | "websockets", { + enabled: boolean; + features?: Record | undefined; + description?: string | undefined; + }>; + }> { // Get registered services from kernel if available const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); @@ -5406,7 +5459,12 @@ export class ObjectStackProtocolImplementation implements return servesExpansionTarget.call(host, target); } - async findData(request: { object: string, query?: any, context?: any }) { + async findData(request: { object: string, query?: any, context?: any }): Promise<{ + hasMore: boolean; + total?: number | undefined; + object: string; + records: any[]; + }> { // [#3770] Existence first: an unregistered object is a 404 before any // query parameter is even parsed, so an unknown name can never be // probed for query-shape validity (nor reach the driver as a table). @@ -6158,7 +6216,11 @@ export class ObjectStackProtocolImplementation implements }; } - async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[], context?: any }) { + async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[], context?: any }): Promise<{ + object: string; + id: string; + record: Record; + }> { this.assertObjectRegistered(request.object); // [#3770] const queryOptions: any = { where: { id: request.id } @@ -6280,7 +6342,24 @@ export class ObjectStackProtocolImplementation implements * win over the copied values — the natural place to set a new `name`, * clear a unique field, or reset status before insert. */ - async cloneData(request: { object: string, id: string, overrides?: Record, context?: any }) { + async cloneData(request: { object: string, id: string, overrides?: Record, context?: any }): Promise<{ + warnings?: { + rule: string; + severity: "info" | "warning"; + field: string; + code: string; + message: string; + }[] | undefined; + droppedFields?: { + object: string; + fields: string[]; + reason: "readonly" | "computed" | "readonly_when" | "primary_key" | "stored_expression"; + }[] | undefined; + object: string; + id: any; + sourceId: string; + record: any; + }> { // [#3770] This object-existence check used to be open-coded here and // was the ONLY one on the whole data plane; it is now the shared gate // every data entry point runs. Same error envelope as before. @@ -10813,7 +10892,22 @@ export class ObjectStackProtocolImplementation implements // STORED content hash itself (`migrateStoredMetadata`): it reaches the // repository as given. ⛔ No transport sets it — every door builds its // request field by field from named inputs, never by spreading a body. - async saveMetaItem(request: SaveMetaItemRequest) { + async saveMetaItem(request: SaveMetaItemRequest): Promise<{ + state: "draft" | "active"; + message: string; + advisories?: { + rule: string; + path: string; + where: string; + message: string; + hint: string; + severity: "error" | "info" | "warning"; + }[] | undefined; + projectionApplied?: MutationProjectionOutcome | undefined; + success: boolean; + version: string; + seq: number; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'save', type: request.type, name: request.name, actor: request.actor, @@ -11050,7 +11144,53 @@ export class ObjectStackProtocolImplementation implements * Records a history event with `op='publish'`. 404 (`NO_DRAFT`) * when there is nothing to publish. */ - async publishMetaItem(request: PublishMetaItemRequest) { + async publishMetaItem(request: PublishMetaItemRequest): Promise<{ + success: boolean; + version: string; + seq: number; + message?: string; + /** + * Present when a `seed` draft was published: the result of materializing + * its rows. Publishing the metadata ALWAYS succeeds independently — a + * seed-load problem is surfaced here, never thrown, so callers (and UIs) + * must check `seedApplied.success` instead of assuming data went live. + */ + seedApplied?: { + success: boolean; + inserted: number; + updated: number; + error?: string; + errors?: unknown[]; + }; + /** + * Present when a publish-time materializer is registered for this type + * (ADR-0086 P2 — e.g. `flow`): the result of projecting the published + * body into its data-plane representation. Best-effort, + * same contract as `seedApplied` — surfaced, never thrown. + */ + materializeApplied?: PublishMaterializeResult; + /** + * Present when an ADR-0094 mutation projector is registered for this + * type: the outcome of the awaited post-persist projection. The + * draft→active promotion runs the projector exactly as a direct active + * save does, so this is the same receipt `saveMetaItem` returns. + * + * [#7294] It was ASSIGNED below and missing from this annotation, so + * the method's declared type denied a key the wire body carried — the + * same declared-≠-returned gap one layer down from the one #7294 + * closed on the spec side. + */ + projectionApplied?: MutationProjectionOutcome; + /** + * [#9176] Present ONLY when the #4463 runtime authoring gate raised at + * least one non-blocking finding against the draft body this promotion + * carried to `active` — an empty array is never emitted, so a clean + * publish's response bytes are unchanged (the #4717 discipline, one + * door over). Every entry is `warning`/`info` by construction: an + * `error` finding refuses the promotion as the 422 instead. + */ + advisories?: RuntimeAuthoringIssue[]; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'publish', type: request.type, name: request.name, actor: request.actor, @@ -13207,7 +13347,13 @@ export class ObjectStackProtocolImplementation implements * into the row the by-name read naming no package serves, a `.custom` * pair's declared override over a row bound to its read-only base. */ - async rollbackMetaItem(request: RollbackMetaItemRequest) { + async rollbackMetaItem(request: RollbackMetaItemRequest): Promise<{ + success: boolean; + version: string; + seq: number; + restoredFromVersion: number; + message?: string; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'rollback', type: request.type, name: request.name, actor: request.actor, @@ -13297,7 +13443,14 @@ export class ObjectStackProtocolImplementation implements * "Reset to factory default" semantic from ADR-0005. Whitelist is shared * with {@link saveMetaItem}. */ - async deleteMetaItem(request: DeleteMetaItemRequest) { + async deleteMetaItem(request: DeleteMetaItemRequest): Promise<{ + success: boolean; + message?: string; + reset?: boolean; + seq?: number; + /** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */ + projectionApplied?: MutationProjectionOutcome; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'delete', type: request.type, name: request.name, actor: request.actor, From 24ba36fedd1c5bbcfeedf13d4d4116ca52bee839 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:26:45 +0000 Subject: [PATCH 2/9] test(metadata-protocol): pin the eight facade doors' spelled return types Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- ...tocol.facade-door-return-types.pin.test.ts | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts diff --git a/packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts b/packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts new file mode 100644 index 00000000000..8fd11940e3e --- /dev/null +++ b/packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #30054 — the eight public doors that stayed whole on the + * `ObjectStackProtocolImplementation` facade because their return type was + * inferred now spell it. + * + * The protocol.ts codemod keeps a non-private method with an inferred return + * type on the class (`inferred-return-type` in + * `scripts/codemod-split-protocol.mjs`): the published `.d.ts` prints that + * type, and once the body moved the compiler would infer it through the moved + * function instead. Each door below now carries its type on the signature, + * copied from the `.d.ts` it was inferred into (the PR proves the built + * `.d.ts` byte-equal before and after), so the type no longer depends on where + * the body lives. An edit that drops one of these annotations turns this red, + * before a later move could re-infer it. + * + * The bodies are still on the class: each door keeps its row in + * `scripts/facade-delegation-registry.json` until a facade pass moves it. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const FILE = fileURLToPath(new URL('./protocol.ts', import.meta.url)); +const CLASS_NAME = 'ObjectStackProtocolImplementation'; + +/** The doors, each with the head of the type it spells (the outer type of its `.d.ts` line). */ +const DOORS: Readonly> = { + getDiscovery: 'Promise<{', + findData: 'Promise<{', + getData: 'Promise<{', + cloneData: 'Promise<{', + saveMetaItem: 'Promise<{', + publishMetaItem: 'Promise<{', + rollbackMetaItem: 'Promise<{', + deleteMetaItem: 'Promise<{', +}; + +function classMethods(): Map { + const text = readFileSync(FILE, 'utf8'); + const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const cls = sf.statements.find((s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === CLASS_NAME); + const out = new Map(); + for (const m of cls?.members ?? []) { + if (!ts.isMethodDeclaration(m) || !m.body || !m.name || !ts.isIdentifier(m.name)) continue; + out.set(m.name.text, [...(out.get(m.name.text) ?? []), m]); + } + return out; +} + +describe('protocol.ts facade doors spell their return type (#30054)', () => { + const methods = classMethods(); + + it('finds the class and every door, once each', () => { + expect(methods.size).toBeGreaterThan(0); + for (const name of Object.keys(DOORS)) expect(methods.get(name)?.length, name).toBe(1); + }); + + for (const [name, head] of Object.entries(DOORS)) { + it(`${name} declares its return type on the signature`, () => { + const m = methods.get(name)?.[0]; + expect(m, name).toBeDefined(); + const type = m!.type; + expect(type, `${name} has an inferred return type`).toBeDefined(); + expect(type!.getText().startsWith(head), `${name}: ${type!.getText().slice(0, 40)}`).toBe(true); + }); + } +}); From 80bec6db339fb7bd25fac791ca22eeef9911ecb3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:27:33 +0000 Subject: [PATCH 3/9] chore(scripts): the eight protocol.ts door rows say their return type is spelled and why the body stays Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- scripts/facade-delegation-registry.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 0ac73758dd6..b5a67447b7b 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -31,49 +31,49 @@ "file": "packages/metadata-protocol/src/protocol.ts", "member": "getDiscovery", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "findData", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "getData", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "cloneData", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "saveMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "publishMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "rollbackMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "deleteMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", From be02383cdc6240dc21a7f93ce83aab426b22e573 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 12:28:06 +0000 Subject: [PATCH 4/9] chore(changeset): metadata-protocol patch for the spelled door return types Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- .changeset/30054-protocol-door-return-types.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/30054-protocol-door-return-types.md diff --git a/.changeset/30054-protocol-door-return-types.md b/.changeset/30054-protocol-door-return-types.md new file mode 100644 index 00000000000..2dc0b6298b2 --- /dev/null +++ b/.changeset/30054-protocol-door-return-types.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): the eight `ObjectStackProtocolImplementation` doors that kept an inferred return type spell it on the signature + +Clause-②: no +Metadata-protocol: none + +- `getDiscovery`, `findData`, `getData`, `cloneData`, `saveMetaItem`, `publishMetaItem`, `rollbackMetaItem` and `deleteMetaItem` now declare their return type, copied from the published declaration file it was inferred into. The built `index.d.ts` and `index.d.cts` are byte-equal before and after, and no behaviour changes; only the source maps move with the new lines. Nothing to do on upgrade. From 1777fb2d2cdf6ff6057a9f53ed1bafb023033809 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 13:18:28 +0000 Subject: [PATCH 5/9] refactor(metadata-protocol): publishMetaItem's spelled type carries the seed receipt's landed rows from main Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3d06ada134e..d9077708e58 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -11162,6 +11162,8 @@ export class ObjectStackProtocolImplementation implements updated: number; error?: string; errors?: unknown[]; + /** [#30348] Per object, the rows created vs matched — `SeedAppliedLandedSchema`. */ + landed?: SeedAppliedLandedParsed[]; }; /** * Present when a publish-time materializer is registered for this type From 405373c08b49bbab5eae48001cf3a10daa0bdfc2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:26:38 +0000 Subject: [PATCH 6/9] refactor(objectql): spell the inferred return types of the eight ObjectQL methods kept on the facade Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- packages/objectql/src/engine.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index baa2912e80d..9348c303189 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -755,7 +755,12 @@ export class ObjectQL implements IObjectQLEngine { * Service Status Report * Used by Kernel to verify health and capabilities. */ - getStatus() { + getStatus(): { + name: "data"; + status: string; + version: string; + features: string[]; + } { return { name: CoreServiceName.enum.data, status: 'running', @@ -810,7 +815,7 @@ export class ObjectQL implements IObjectQLEngine { meta?: any; /** Stable name from metadata (set by `bindHooksToEngine`). */ hookName?: string; - }) { + }): void { // Refuse a scope that is statically decidable as meaningless before // anything is registered or reported. Each face is checked on its own // first, so the combined check below can assume well-formed names. @@ -1055,7 +1060,7 @@ export class ObjectQL implements IObjectQLEngine { * added here that defers a dispatch past the enclosing unit of work would be * changing that ruling, not implementing it. */ - public async triggerHooks(event: string, context: HookContext) { + public async triggerHooks(event: string, context: HookContext): Promise { const entries = this.hooks.get(event) || []; // [#28251] `ctx.notice` — the write's own `onNotice` listener, bound here // so every insert / update dispatch site carries it (see hook-notice.ts). @@ -2303,7 +2308,7 @@ export class ObjectQL implements IObjectQLEngine { * comes through here (it is installed by `installPackage` in * `@objectstack/metadata-protocol`), so `'authored'` is not accepted. */ - registerApp(registeredBody: any, scope?: ArtifactInstallScope, source: Exclude = 'code') { + registerApp(registeredBody: any, scope?: ArtifactInstallScope, source: Exclude = 'code'): void { // The CONVERTED body, for everything below — the package record, the // live manifest, every item it registers (see {@link convertPackageBodyForward}). // [#23972] Refused before anything registers — see the docblock: an @@ -2504,7 +2509,7 @@ export class ObjectQL implements IObjectQLEngine { * else already is the default** → the caller's intent is being dropped, * so it is loud too rather than folded into the quiet path. */ - registerDriver(driver: IDataDriver, isDefault: boolean = false) { + registerDriver(driver: IDataDriver, isDefault: boolean = false): void { const existing = this.drivers.get(driver.name); if (existing) { // [#27277] The entry may be the floored instance of this very driver. @@ -3749,7 +3754,7 @@ export class ObjectQL implements IObjectQLEngine { default?: boolean; datasource: string; priority?: number; - }>) { + }>): void { this.datasourceMapping = rules; this.logger.info('Datasource mapping rules configured', { ruleCount: rules.length @@ -3784,7 +3789,7 @@ export class ObjectQL implements IObjectQLEngine { * `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`, which boots in a state that is warned * about loudly rather than assumed. */ - async init() { + async init(): Promise { this.logger.info('Initializing ObjectQL engine', { driverCount: this.drivers.size, drivers: Array.from(this.drivers.keys()) @@ -4275,7 +4280,7 @@ export class ObjectQL implements IObjectQLEngine { return wasDatastoreCreatedFromEmpty.call(host); } - async destroy() { + async destroy(): Promise { this.logger.info('Destroying ObjectQL engine', { driverCount: this.drivers.size }); for (const [name, driver] of this.drivers.entries()) { From 9a0592e62a379f2c47405b651894f43c005ec778 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:29:25 +0000 Subject: [PATCH 7/9] refactor(objectql): move getStatus, triggerHooks, setDatasourceMapping, init and destroy into their facets with the codemod's fourth pass Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- .../src/engine-hooks-and-functions.ts | 70 ++++++++- .../objectql/src/engine-migration-state.ts | 28 +++- .../src/engine-secrets-and-routing.ts | 65 ++++++++- packages/objectql/src/engine.ts | 136 +++--------------- scripts/facade-delegation-registry.json | 41 +----- 5 files changed, 177 insertions(+), 163 deletions(-) diff --git a/packages/objectql/src/engine-hooks-and-functions.ts b/packages/objectql/src/engine-hooks-and-functions.ts index 8e43f2c8737..26198f35ef9 100644 --- a/packages/objectql/src/engine-hooks-and-functions.ts +++ b/packages/objectql/src/engine-hooks-and-functions.ts @@ -4,12 +4,12 @@ * [#29986 codemod] The * "Class head: units, hooks, functions, body runners, hook dispatch" section of * `ObjectQL`: the bodies of `recordResidualOwner`, `residualOwnerSource`, - * `unregisterHooksByPackage`, `registerFunction`, `resolveFunction`, - * `resolveFunctionEntry`, `unregisterFunctionsByPackage`, + * `getStatus`, `unregisterHooksByPackage`, `registerFunction`, + * `resolveFunction`, `resolveFunctionEntry`, `unregisterFunctionsByPackage`, * `setDefaultBodyRunner`, `getDefaultBodyRunner`, `setDefaultActionRunner`, - * `getDefaultActionRunner`, `hasHooksFor`, `opensAfterAbortUnit`, - * `dispatchUnscopedMultiWriteHooks` moved out of `engine.ts` whole by - * `scripts/codemod-split-engine.mjs`. + * `getDefaultActionRunner`, `triggerHooks`, `hasHooksFor`, + * `opensAfterAbortUnit`, `dispatchUnscopedMultiWriteHooks` moved out of + * `engine.ts` whole by `scripts/codemod-split-engine.mjs`. * * A MOVE, not a redesign. {@link HooksAndFunctionsFacet} is a stateless facet of `ObjectQL` * (ruling 6624345766): each static method of it is the body of the `ObjectQL` @@ -40,6 +40,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { hookStoppedRefusal } from './summary-errors.js'; import { HookTargetRebindError } from './hook-target-rebind-errors.js'; import { declaredHookCondition } from './hook-wrappers.js'; +import { CoreServiceName } from '@objectstack/spec/system'; +import { bindHookNotice } from './hook-notice.js'; import { hookMatchesObject } from './engine-top-level.js'; import type { FunctionEntry, FunctionRegistrationOptions, HookHandler } from './engine-top-level.js'; import type { ObjectQL } from './engine.js'; @@ -78,6 +80,21 @@ export class HooksAndFunctionsFacet { return this.residualOwnerSources.get(ownerId); } + /** The body of `ObjectQL#getStatus`; the method's docblock there is its contract. */ + static getStatus(): { + name: "data"; + status: string; + version: string; + features: string[]; + } { + return { + name: CoreServiceName.enum.data, + status: 'running', + version: '0.9.0', + features: ['crud', 'query', 'aggregate', 'transactions', 'metadata'] + }; + } + /** The body of `ObjectQL#unregisterHooksByPackage`; the method's docblock there is its contract. */ static unregisterHooksByPackage(this: Pick, packageId: string): number { if (!packageId) return 0; @@ -162,6 +179,49 @@ export class HooksAndFunctionsFacet { return this._defaultActionRunner; } + /** The body of `ObjectQL#triggerHooks`; the method's docblock there is its contract. */ + static async triggerHooks(this: Pick, event: string, context: HookContext): Promise { + const entries = this.hooks.get(event) || []; + // [#28251] `ctx.notice` — the write's own `onNotice` listener, bound here + // so every insert / update dispatch site carries it (see hook-notice.ts). + bindHookNotice(event, context); + + if (entries.length === 0) { + this.logger.debug('No hooks registered for event', { event }); + return; + } + + this.logger.debug('Triggering hooks', { event, count: entries.length }); + + // `session.skipAutomations` (set from ExecutionContext.skipAutomations — + // import with "run automations & triggers" unchecked, import undo) + // suppresses hooks bound FROM METADATA (`bindHooksToEngine` stamps + // `entry.meta`). Hooks registered in code by plugins — audit, capability + // gates, sharing projection — have no `meta` and always run: the opt-out + // must never bypass security or audit (#2922). + const skipAutomations = + (context.session as { skipAutomations?: boolean } | undefined)?.skipAutomations === true; + + for (const entry of entries) { + // Per-object matching — allow face minus exclusion face, the one shared + // rule `hasHooksFor` also reads (#5928). + if (!hookMatchesObject(entry, context.object)) { + continue; // Skip non-matching hooks + } + if (skipAutomations && entry.meta) { + this.logger.debug('Skipping metadata-bound hook (skipAutomations)', { event, hook: entry.hookName }); + continue; + } + // [#29422] A stale-roll-up refusal a hook's own `ctx.api` write raised + // describes THAT write, not this one: `hookStoppedRefusal`. + try { + await entry.handler(context); + } catch (e) { + throw hookStoppedRefusal(e, event, context.object, entry.hookName); + } + } + } + /** * [#5038] Would `triggerHooks(event, ctx)` reach ANY handler for `object`? * diff --git a/packages/objectql/src/engine-migration-state.ts b/packages/objectql/src/engine-migration-state.ts index 9be9ac49a82..9816ee29802 100644 --- a/packages/objectql/src/engine-migration-state.ts +++ b/packages/objectql/src/engine-migration-state.ts @@ -9,7 +9,7 @@ * `haveFileColumnsMoved`, `readMigrationFlagMemoized`, * `valueShapeViolationsAdmitted`, `admittedViolationSink`, * `recordObservedDeviation`, `retractCreationAttestation`, - * `invalidateDataMigrationFlags` moved out of `engine.ts` whole by + * `invalidateDataMigrationFlags`, `destroy` moved out of `engine.ts` whole by * `scripts/codemod-split-engine.mjs`. * * A MOVE, not a redesign. {@link MigrationStateFacet} is a stateless facet of `ObjectQL` @@ -62,6 +62,7 @@ export interface MigrationStateHost { readonly admittedValueShapeViolations: ObjectQLClass['admittedValueShapeViolations']; creationAttestationRetraction: ObjectQLClass['creationAttestationRetraction']; deviationRecording: ObjectQLClass['deviationRecording']; + readonly drivers: ObjectQLClass['drivers']; fileReferencesMigrationVerified: ObjectQLClass['fileReferencesMigrationVerified']; findOne: ObjectQLClass['findOne']; invalidateDataMigrationFlags: ObjectQLClass['invalidateDataMigrationFlags']; @@ -76,6 +77,7 @@ export interface MigrationStateHost { readMigrationFlagRowMemoized: ObjectQLClass['readMigrationFlagRowMemoized']; readonly recordedDeviations: ObjectQLClass['recordedDeviations']; readonly retractedCreationAttestations: ObjectQLClass['retractedCreationAttestations']; + unregisterDriver: ObjectQLClass['unregisterDriver']; update: ObjectQLClass['update']; valueShapesMigrationVerified: ObjectQLClass['valueShapesMigrationVerified']; wasDatastoreCreatedFromEmpty: ObjectQLClass['wasDatastoreCreatedFromEmpty']; @@ -427,4 +429,28 @@ export class MigrationStateFacet { this.migrationGatesAnnounced = false; this.recordedDeviations.clear(); } + + /** The body of `ObjectQL#destroy`; the method's docblock there is its contract. */ + static async destroy(this: Pick): Promise { + this.logger.info('Destroying ObjectQL engine', { driverCount: this.drivers.size }); + + for (const [name, driver] of this.drivers.entries()) { + try { + await driver.disconnect(); + } catch (e) { + this.logger.error('Error disconnecting driver', e as Error, { driverName: name }); + } + } + + // #13578 — teardown is an eviction path too, and it used to disconnect + // every driver while leaving all of them REGISTERED. A destroyed engine + // therefore still answered `checkDriversHealth()` by pinging pools it had + // just closed, so a readiness probe racing shutdown read a list of drivers + // that were deliberately dead. Disconnect-then-evict, through the one + // primitive, so `defaultDriver` and the datasource defs go with them. + // Names are snapshotted first: `unregisterDriver` mutates the Map. + for (const name of [...this.drivers.keys()]) this.unregisterDriver(name); + + this.logger.info('ObjectQL engine destroyed'); + } } diff --git a/packages/objectql/src/engine-secrets-and-routing.ts b/packages/objectql/src/engine-secrets-and-routing.ts index 95e539662db..d56b9931bff 100644 --- a/packages/objectql/src/engine-secrets-and-routing.ts +++ b/packages/objectql/src/engine-secrets-and-routing.ts @@ -7,8 +7,8 @@ * `onCryptoProviderChange`, `setTenancyPostureProvider`, `getSchema`, * `resolveObjectName`, `judgeFilter`, `isSystemLedgerObject`, * `resolveDatasourceBinding`, `resolveEffectiveDatasource`, - * `resolveMappedDatasource` moved out of `engine.ts` whole by - * `scripts/codemod-split-engine.mjs`. + * `resolveMappedDatasource`, `setDatasourceMapping`, `init` moved out of + * `engine.ts` whole by `scripts/codemod-split-engine.mjs`. * * A MOVE, not a redesign. {@link SecretsAndRoutingFacet} is a stateless facet of `ObjectQL` * (ruling 6624345766): each static method of it is the body of the `ObjectQL` @@ -37,6 +37,9 @@ import type { EngineQueryOptions, ServiceObject } from '@objectstack/spec/data'; import type { EngineFilterJudgement, EngineFilterJudgementOptions, ICryptoProvider } from '@objectstack/spec/contracts'; +import { DriverConnectError, emitDegradedBootBanner } from './driver-connect-errors.js'; +import type { DriverConnectFailure } from './driver-connect-errors.js'; +import { resolveAllowDriverConnectFailure } from '@objectstack/types'; import { StorageNameMapping } from '@objectstack/spec/system'; import { objectNotFoundError } from '@objectstack/core'; import { judgeWhereAdmission } from './engine-top-level.js'; @@ -53,6 +56,7 @@ export interface SecretsAndRoutingHost { readonly _registry: ObjectQL['_registry']; cryptoProvider: ObjectQL['cryptoProvider']; readonly cryptoProviderListeners: ObjectQL['cryptoProviderListeners']; + datasourceMapping: ObjectQL['datasourceMapping']; readonly defaultDriver: ObjectQL['defaultDriver']; readonly drivers: ObjectQL['drivers']; readonly logger: ObjectQL['logger']; @@ -264,4 +268,61 @@ export class SecretsAndRoutingFacet { static resolveMappedDatasource(this: Pick, objectName: string): string | null { return this.resolveDatasourceFromMapping(objectName, this._registry.getObject(objectName)); } + + /** The body of `ObjectQL#setDatasourceMapping`; the method's docblock there is its contract. */ + static setDatasourceMapping(this: Pick, rules: Array<{ + namespace?: string; + package?: string; + objectPattern?: string; + default?: boolean; + datasource: string; + priority?: number; + }>): void { + this.datasourceMapping = rules; + this.logger.info('Datasource mapping rules configured', { + ruleCount: rules.length + }); + } + + /** The body of `ObjectQL#init`; the method's docblock there is its contract. */ + static async init(this: Pick): Promise { + this.logger.info('Initializing ObjectQL engine', { + driverCount: this.drivers.size, + drivers: Array.from(this.drivers.keys()) + }); + + const failures: DriverConnectFailure[] = []; + for (const [name, driver] of this.drivers) { + try { + await driver.connect(); + this.logger.info('Driver connected successfully', { driverName: name }); + } catch (e) { + failures.push({ driverName: name, error: e }); + this.logger.error('Failed to connect driver', e as Error, { driverName: name }); + } + } + + if (failures.length > 0) { + if (!resolveAllowDriverConnectFailure()) { + throw new DriverConnectError(failures, this.drivers.size); + } + const failedDrivers = failures.map(f => f.driverName); + const banner = + `⚠️ DEGRADED BOOT: ${failures.length} of ${this.drivers.size} driver(s) failed to connect ` + + `(${failedDrivers.join(', ')}), but OS_ALLOW_DRIVER_CONNECT_FAILURE is set — starting anyway. ` + + `Every query routed to them fails until the datasource becomes reachable, and the boot-time ` + + `schema sync is SKIPPED FOR GOOD — the client will reconnect on its own, but nothing re-runs ` + + `the DDL, so those objects may have no tables even after the database comes back. Unset ` + + `OS_ALLOW_DRIVER_CONNECT_FAILURE to restore fail-fast boot.`; + this.logger.warn(banner, { failedDrivers }); + // …and again on a channel the operator's log level cannot filter away: the + // line above is dropped outright at `error`/`fatal`/`silent`, which is a + // normal production setting and precisely where this flag gets used. See + // the helper's note — the duplication is deliberate, and it is NOT about + // `os serve`'s boot-quiet window (that swallowed it too until #4012). + emitDegradedBootBanner(banner); + } + + this.logger.info('ObjectQL engine initialization complete'); + } } diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9348c303189..2fa8f5600de 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -143,7 +143,7 @@ import { appAutomationElevationOf, } from '@objectstack/core'; import { WriteEpoch } from './write-epoch.js'; -import { SummaryRecomputeError, cascadeStoppedRefusal, hookStoppedRefusal, rolledBackRefusal, type SummaryRecomputeFailure } from './summary-errors.js'; +import { SummaryRecomputeError, cascadeStoppedRefusal, rolledBackRefusal, type SummaryRecomputeFailure } from './summary-errors.js'; import { TransactionUnsupportedError } from './transaction-errors.js'; import { aggregateSummaryValue, @@ -164,14 +164,11 @@ import { import { DriverConnectError, DatasourceUnavailableError, - emitDegradedBootBanner, - type DriverConnectFailure, type DriverHealth, type DatasourceUnavailableInfo, type DatasourceUnavailableKind, type PrimaryDatasourceVerdict, } from './driver-connect-errors.js'; -import { resolveAllowDriverConnectFailure } from '@objectstack/types'; // [#5979] The ONE shared "which read failure is benign?" predicate (#4825 // family). Imported from the leaf `/errors` subpath — which exists precisely // so a cross-package consumer gets the 40-line predicate without the manager, @@ -231,7 +228,6 @@ import { export type InsertManyRowOutcome = | { ok: true; record: any; droppedFields?: DroppedFieldsEvent[] } | { ok: false; error: unknown }; -import { CoreServiceName } from '@objectstack/spec/system'; import { IRealtimeService } from '@objectstack/spec/contracts'; import type { ICryptoProvider } from '@objectstack/spec/contracts'; // [#25522 cut 18] `count` and `aggregate()`'s credential guard @@ -349,7 +345,6 @@ import { // members it reads (`ValidateHost`). Imported after every module that body // imports, so the engine's module evaluation order is what it was. import { validate } from './engine-validate.js'; -import { bindHookNotice } from './hook-notice.js'; // [#25522 cut 20] The `where`-lowering bodies (`QueryLoweringHost`), imported // after every module they import, so module evaluation order is unchanged. import { lowerRelationConditions, resolveSearchQueryableFields, resolveWhereTokens } from './engine-query-lowering.js'; @@ -366,7 +361,7 @@ import { assertHookScopeNotSelfCancelling, assertOrderByIsMaterializable, assertProjectionHasNoDottedPaths, assertValidHookExcludeObjects, assertValidHookObject, assertValidUnscopedMultiWriteFlag, autonumberFieldNames, callerKeySets, callerSuppliedRow, computeStoredExpressions, declaredDatetimeLowering, droppedFieldEvents, - foldEngineOptionAliases, formulaPlanEntry, handBackWithheldInsertReadonly, hookMatchesObject, hydrateWriteFormulas, + foldEngineOptionAliases, formulaPlanEntry, handBackWithheldInsertReadonly, hydrateWriteFormulas, insertStaticReadonlySubject, lowerWhereFilterArray, masterIdOf, mergeReadContext, organizationPredicateDeleteRefusal, packageBodyCollectionRefusal, packageBodyObjectAndAppOwnerStampRefusal, packageBodySeedDataRefusal, planFormulaProjection, planStoredExpressions, recomputeAutonumberReaders, @@ -761,12 +756,8 @@ export class ObjectQL implements IObjectQLEngine { version: string; features: string[]; } { - return { - name: CoreServiceName.enum.data, - status: 'running', - version: '0.9.0', - features: ['crud', 'query', 'aggregate', 'transactions', 'metadata'] - }; + // [#29986 codemod] The body moved to `engine-hooks-and-functions.ts`. + return HooksAndFunctionsFacet.getStatus(); } /** @@ -1060,46 +1051,9 @@ export class ObjectQL implements IObjectQLEngine { * added here that defers a dispatch past the enclosing unit of work would be * changing that ruling, not implementing it. */ - public async triggerHooks(event: string, context: HookContext): Promise { - const entries = this.hooks.get(event) || []; - // [#28251] `ctx.notice` — the write's own `onNotice` listener, bound here - // so every insert / update dispatch site carries it (see hook-notice.ts). - bindHookNotice(event, context); - - if (entries.length === 0) { - this.logger.debug('No hooks registered for event', { event }); - return; - } - - this.logger.debug('Triggering hooks', { event, count: entries.length }); - - // `session.skipAutomations` (set from ExecutionContext.skipAutomations — - // import with "run automations & triggers" unchecked, import undo) - // suppresses hooks bound FROM METADATA (`bindHooksToEngine` stamps - // `entry.meta`). Hooks registered in code by plugins — audit, capability - // gates, sharing projection — have no `meta` and always run: the opt-out - // must never bypass security or audit (#2922). - const skipAutomations = - (context.session as { skipAutomations?: boolean } | undefined)?.skipAutomations === true; - - for (const entry of entries) { - // Per-object matching — allow face minus exclusion face, the one shared - // rule `hasHooksFor` also reads (#5928). - if (!hookMatchesObject(entry, context.object)) { - continue; // Skip non-matching hooks - } - if (skipAutomations && entry.meta) { - this.logger.debug('Skipping metadata-bound hook (skipAutomations)', { event, hook: entry.hookName }); - continue; - } - // [#29422] A stale-roll-up refusal a hook's own `ctx.api` write raised - // describes THAT write, not this one: `hookStoppedRefusal`. - try { - await entry.handler(context); - } catch (e) { - throw hookStoppedRefusal(e, event, context.object, entry.hookName); - } - } + public triggerHooks(event: string, context: HookContext): Promise { + // [#29986 codemod] The body moved to `engine-hooks-and-functions.ts` (see `HooksAndFunctionsHost`). + return HooksAndFunctionsFacet.triggerHooks.call(hooksAndFunctionsHost.of(this), event, context); } private hasHooksFor(event: string, object: string): boolean { @@ -3755,10 +3709,8 @@ export class ObjectQL implements IObjectQLEngine { datasource: string; priority?: number; }>): void { - this.datasourceMapping = rules; - this.logger.info('Datasource mapping rules configured', { - ruleCount: rules.length - }); + // [#29986 codemod] The body moved to `engine-secrets-and-routing.ts` (see `SecretsAndRoutingHost`). + return SecretsAndRoutingFacet.setDatasourceMapping.call(secretsAndRoutingHost.of(this), rules); } /** @@ -3789,45 +3741,9 @@ export class ObjectQL implements IObjectQLEngine { * `OS_ALLOW_DRIVER_CONNECT_FAILURE=1`, which boots in a state that is warned * about loudly rather than assumed. */ - async init(): Promise { - this.logger.info('Initializing ObjectQL engine', { - driverCount: this.drivers.size, - drivers: Array.from(this.drivers.keys()) - }); - - const failures: DriverConnectFailure[] = []; - for (const [name, driver] of this.drivers) { - try { - await driver.connect(); - this.logger.info('Driver connected successfully', { driverName: name }); - } catch (e) { - failures.push({ driverName: name, error: e }); - this.logger.error('Failed to connect driver', e as Error, { driverName: name }); - } - } - - if (failures.length > 0) { - if (!resolveAllowDriverConnectFailure()) { - throw new DriverConnectError(failures, this.drivers.size); - } - const failedDrivers = failures.map(f => f.driverName); - const banner = - `⚠️ DEGRADED BOOT: ${failures.length} of ${this.drivers.size} driver(s) failed to connect ` + - `(${failedDrivers.join(', ')}), but OS_ALLOW_DRIVER_CONNECT_FAILURE is set — starting anyway. ` + - `Every query routed to them fails until the datasource becomes reachable, and the boot-time ` + - `schema sync is SKIPPED FOR GOOD — the client will reconnect on its own, but nothing re-runs ` + - `the DDL, so those objects may have no tables even after the database comes back. Unset ` + - `OS_ALLOW_DRIVER_CONNECT_FAILURE to restore fail-fast boot.`; - this.logger.warn(banner, { failedDrivers }); - // …and again on a channel the operator's log level cannot filter away: the - // line above is dropped outright at `error`/`fatal`/`silent`, which is a - // normal production setting and precisely where this flag gets used. See - // the helper's note — the duplication is deliberate, and it is NOT about - // `os serve`'s boot-quiet window (that swallowed it too until #4012). - emitDegradedBootBanner(banner); - } - - this.logger.info('ObjectQL engine initialization complete'); + init(): Promise { + // [#29986 codemod] The body moved to `engine-secrets-and-routing.ts` (see `SecretsAndRoutingHost`). + return SecretsAndRoutingFacet.init.call(secretsAndRoutingHost.of(this)); } /** @@ -4280,27 +4196,9 @@ export class ObjectQL implements IObjectQLEngine { return wasDatastoreCreatedFromEmpty.call(host); } - async destroy(): Promise { - this.logger.info('Destroying ObjectQL engine', { driverCount: this.drivers.size }); - - for (const [name, driver] of this.drivers.entries()) { - try { - await driver.disconnect(); - } catch (e) { - this.logger.error('Error disconnecting driver', e as Error, { driverName: name }); - } - } - - // #13578 — teardown is an eviction path too, and it used to disconnect - // every driver while leaving all of them REGISTERED. A destroyed engine - // therefore still answered `checkDriversHealth()` by pinging pools it had - // just closed, so a readiness probe racing shutdown read a list of drivers - // that were deliberately dead. Disconnect-then-evict, through the one - // primitive, so `defaultDriver` and the datasource defs go with them. - // Names are snapshotted first: `unregisterDriver` mutates the Map. - for (const name of [...this.drivers.keys()]) this.unregisterDriver(name); - - this.logger.info('ObjectQL engine destroyed'); + destroy(): Promise { + // [#29986 codemod] The body moved to `engine-migration-state.ts` (see `MigrationStateHost`). + return MigrationStateFacet.destroy.call(migrationStateHost.of(this)); } // ============================================ @@ -10540,6 +10438,7 @@ export class ObjectQL implements IObjectQLEngine { set creationAttestationRetraction(v) { engine.creationAttestationRetraction = v; }, get deviationRecording() { return engine.deviationRecording; }, set deviationRecording(v) { engine.deviationRecording = v; }, + get drivers() { return engine.drivers; }, get fileReferencesMigrationVerified() { return engine.fileReferencesMigrationVerified; }, set fileReferencesMigrationVerified(v) { engine.fileReferencesMigrationVerified = v; }, findOne: (...a) => engine.findOne(...a), @@ -10556,6 +10455,7 @@ export class ObjectQL implements IObjectQLEngine { readMigrationFlagRowMemoized: (...a) => engine.readMigrationFlagRowMemoized(...a), get recordedDeviations() { return engine.recordedDeviations; }, get retractedCreationAttestations() { return engine.retractedCreationAttestations; }, + unregisterDriver: (...a) => engine.unregisterDriver(...a), update: (...a) => engine.update(...a), get valueShapesMigrationVerified() { return engine.valueShapesMigrationVerified; }, set valueShapesMigrationVerified(v) { engine.valueShapesMigrationVerified = v; }, @@ -10601,6 +10501,8 @@ export class ObjectQL implements IObjectQLEngine { get cryptoProvider() { return engine.cryptoProvider; }, set cryptoProvider(v) { engine.cryptoProvider = v; }, get cryptoProviderListeners() { return engine.cryptoProviderListeners; }, + get datasourceMapping() { return engine.datasourceMapping; }, + set datasourceMapping(v) { engine.datasourceMapping = v; }, get defaultDriver() { return engine.defaultDriver; }, get drivers() { return engine.drivers; }, get logger() { return engine.logger; }, diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index 764fd289333..22b1dd10d74 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -296,56 +296,21 @@ "class": "ObjectQL", "member": "registerApp", "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054, public .d.ts byte-equal); the codemod --members pass refuses to move it: engine-registration.ts's header is no longer as the first pass wrote it (a hand-written member was added there)" }, { "file": "packages/objectql/src/engine.ts", "class": "ObjectQL", "member": "registerHook", "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054, public .d.ts byte-equal); the codemod --members pass cannot prove its move: undoing it does not rebuild engine-hooks-and-functions.ts byte for byte (first difference at line 44, in its import block)" }, { "file": "packages/objectql/src/engine.ts", "class": "ObjectQL", "member": "registerDriver", "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "init", - "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "triggerHooks", - "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "destroy", - "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "setDatasourceMapping", - "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" - }, - { - "file": "packages/objectql/src/engine.ts", - "class": "ObjectQL", - "member": "getStatus", - "card": "#30054", - "reason": "inferred-return-type (#29986 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054, public .d.ts byte-equal); the codemod --members pass refuses to move it: engine-registration.ts's header is no longer as the first pass wrote it (a hand-written member was added there)" }, { "file": "packages/objectql/src/engine.ts", From 98ce1c711bb72bfa0f0b2df5f261bea08233a3f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:30:53 +0000 Subject: [PATCH 8/9] test(objectql): pin the eight ObjectQL methods' spelled return types Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- .../engine.facade-return-types.pin.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 packages/objectql/src/engine.facade-return-types.pin.test.ts diff --git a/packages/objectql/src/engine.facade-return-types.pin.test.ts b/packages/objectql/src/engine.facade-return-types.pin.test.ts new file mode 100644 index 00000000000..9c653af72ec --- /dev/null +++ b/packages/objectql/src/engine.facade-return-types.pin.test.ts @@ -0,0 +1,65 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #30054 — the eight `ObjectQL` methods that stayed whole on the engine.ts + * facade because their return type was inferred now spell it. + * + * The engine codemod keeps a public method with an inferred return type on + * the class: the published `.d.ts` prints that type, and once the body moved + * the compiler would infer it through the facet instead. Each method below + * carries its type on the signature, copied from the `.d.ts` it was inferred + * into (the PR proves the built `.d.ts` byte-equal before and after). Five of + * them have since moved to their facets with the codemod's `--members` pass; + * `registerApp`, `registerDriver` and `registerHook` keep their bodies and + * their rows in `scripts/facade-delegation-registry.json`. An edit that drops + * one of these annotations turns this red: on a stub, the published type would + * then be re-inferred through the facet. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const FILE = fileURLToPath(new URL('./engine.ts', import.meta.url)); +const CLASS_NAME = 'ObjectQL'; + +/** Each method with the type it spells (the text of its `.d.ts` line). */ +const SPELLED: Readonly> = { + registerApp: 'void', + registerHook: 'void', + registerDriver: 'void', + init: 'Promise', + triggerHooks: 'Promise', + destroy: 'Promise', + setDatasourceMapping: 'void', + getStatus: '{\n name: "data";\n status: string;\n version: string;\n features: string[];\n }', +}; + +function classMethods(): Map { + const text = readFileSync(FILE, 'utf8'); + const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const cls = sf.statements.find((s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === CLASS_NAME); + const out = new Map(); + for (const m of cls?.members ?? []) { + if (!ts.isMethodDeclaration(m) || !m.body || !m.name || !ts.isIdentifier(m.name)) continue; + out.set(m.name.text, [...(out.get(m.name.text) ?? []), m]); + } + return out; +} + +describe('engine.ts ObjectQL methods spell their return type (#30054)', () => { + const methods = classMethods(); + + it('finds the class and every method, once each', () => { + expect(methods.size).toBeGreaterThan(0); + for (const name of Object.keys(SPELLED)) expect(methods.get(name)?.length, name).toBe(1); + }); + + for (const [name, spelled] of Object.entries(SPELLED)) { + it(`${name} declares its return type on the signature`, () => { + const type = methods.get(name)?.[0]?.type; + expect(type, `${name} has an inferred return type`).toBeDefined(); + expect(type!.getText()).toBe(spelled); + }); + } +}); From 2ea0deb9d1f7dcf0aca872e2b5543e96e372fb9a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 5 Nov 2026 15:31:26 +0000 Subject: [PATCH 9/9] chore(changeset): objectql patch for the spelled return types and the five moves Claude-Session: https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6 Co-authored-by: Claude --- .changeset/30054-objectql-return-types.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/30054-objectql-return-types.md diff --git a/.changeset/30054-objectql-return-types.md b/.changeset/30054-objectql-return-types.md new file mode 100644 index 00000000000..ec3baff2024 --- /dev/null +++ b/.changeset/30054-objectql-return-types.md @@ -0,0 +1,10 @@ +--- +'@objectstack/objectql': patch +--- + +refactor(objectql): the eight `ObjectQL` methods that kept an inferred return type spell it, and five of them move into their facets + +Clause-②: no +Metadata-protocol: none + +- `registerApp`, `registerHook`, `registerDriver`, `init`, `triggerHooks`, `destroy`, `setDatasourceMapping` and `getStatus` now declare their return type, copied from the published declaration file it was inferred into. The bodies of `getStatus`, `triggerHooks`, `setDatasourceMapping`, `init` and `destroy` move out of `engine.ts` into their facet modules (the methods stay on the class as one-call stubs). The built declaration files are byte-equal before and after, and no behaviour changes; only the source maps move. Nothing to do on upgrade.