Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/30054-objectql-return-types.md
Original file line number Diff line number Diff line change
@@ -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.
70 changes: 65 additions & 5 deletions packages/objectql/src/engine-hooks-and-functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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<HooksAndFunctionsHost, 'hooks' | 'logger'>, packageId: string): number {
if (!packageId) return 0;
Expand Down Expand Up @@ -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<HooksAndFunctionsHost, 'hooks' | 'logger'>, event: string, context: HookContext): Promise<void> {
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`?
*
Expand Down
28 changes: 27 additions & 1 deletion packages/objectql/src/engine-migration-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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'];
Expand All @@ -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'];
Expand Down Expand Up @@ -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<MigrationStateHost, 'drivers' | 'logger' | 'unregisterDriver'>): Promise<void> {
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');
}
}
65 changes: 63 additions & 2 deletions packages/objectql/src/engine-secrets-and-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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';
Expand All @@ -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'];
Expand Down Expand Up @@ -264,4 +268,61 @@ export class SecretsAndRoutingFacet {
static resolveMappedDatasource(this: Pick<SecretsAndRoutingHost, '_registry' | 'resolveDatasourceFromMapping'>, 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<SecretsAndRoutingHost, 'datasourceMapping' | 'logger'>, 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<SecretsAndRoutingHost, 'drivers' | 'logger'>): Promise<void> {
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');
}
}
65 changes: 65 additions & 0 deletions packages/objectql/src/engine.facade-return-types.pin.test.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
registerApp: 'void',
registerHook: 'void',
registerDriver: 'void',
init: 'Promise<void>',
triggerHooks: 'Promise<void>',
destroy: 'Promise<void>',
setDatasourceMapping: 'void',
getStatus: '{\n name: "data";\n status: string;\n version: string;\n features: string[];\n }',
};

function classMethods(): Map<string, ts.MethodDeclaration[]> {
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<string, ts.MethodDeclaration[]>();
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);
});
}
});
Loading
Loading