Skip to content

Commit bfbdeec

Browse files
refactor(objectql): spell the eight ObjectQL facade methods' inferred return types and move five bodies into their facets (.d.ts byte-equal) (#30571)
<!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgKd8GqhzWbGWABewb5SLGNm)_ domain:server#5 · os-bill · session_01Utp6CYVV6fcAQrNTDQRvPb ## 维护者速读 - 改了什么:`engine.ts` 门面上 8 个因返回类型靠推断而留在类上的 `ObjectQL` 方法把返回类型写明在签名上(逐字取自今天的 `.d.ts`);其中 `getStatus`、`triggerHooks`、`setDatasourceMapping`、`init`、`destroy` 五个再用 codemod 第四遍(#30055 的 `--members`)把方法体搬进各自的 facet 模块,类上只留一行转调;allow-list 删掉这 5 行。 - 为什么改:返回类型写明后,方法体放在哪都不会改变公开类型,门面才能只剩转调(#30054 第二半,第一半是已合并的 #30516)。 - 风险与回滚:没有行为变化;`@objectstack/objectql` 的三个公开 `.d.ts` 改前改后逐字节相同,codemod 的撤销证明逐字节通过。回滚即撤本 PR。 - 先看:`packages/objectql/src/engine.ts` 里 `init` / `destroy` 的新 stub,再看 `engine-secrets-and-routing.ts` 里搬进来的 `init` 方法体。 - 两分钟验证:`node scripts/codemod-split-engine.mjs --members --verify --base 405373c` 预期 exit 0、“5 moved method(s) … does rebuild the base … byte-for-byte”;`node scripts/check-facade-delegation-only.mjs` 预期 exit 0、56 条具名例外。 - 需要你做的:无,席位落地。`registerApp`、`registerDriver`、`registerHook` 三个 codemod 拒绝搬,留在类上(见 Acceptance notes)。 Part of #30054 (the `engine.ts` half: all eight return types spelled, five bodies moved; three engine.ts bodies and the eight protocol.ts bodies stay, see Acceptance notes) Clause-②: no — the published `index.d.ts`, `core.d.ts` and the shared declaration chunk of `@objectstack/objectql` are byte-equal before and after, so no exported member changes. Metadata-protocol: none Skills-docs: none — an internal refactor of eight method signatures and five method bodies' location; no published type or behaviour changes, and no skill text names them. ## What changes - **Return types (`engine.ts`).** `registerApp`, `registerHook`, `registerDriver`, `init`, `triggerHooks`, `destroy`, `setDatasourceMapping` and `getStatus` declare their return type, copied from the line the base build printed for each in `dist/util-*.d.ts` (the `ObjectQL` class), re-indented to the file's two spaces. `getStatus` spells `name: "data"` with the printer's double quotes, which the declaration emit keeps. - **Moves (`--members`).** `node scripts/codemod-split-engine.mjs --members getStatus,triggerHooks,setDatasourceMapping,init,destroy --write`: `getStatus` and `triggerHooks` go to `engine-hooks-and-functions.ts`, `setDatasourceMapping` and `init` to `engine-secrets-and-routing.ts`, `destroy` to `engine-migration-state.ts`. Each method stays on the class with its docblock and signature as a one-call stub. Nothing generated is edited by hand. - **Registry.** The five moved members' rows are deleted (the gate refuses a row whose member is already a delegation). The three rows that stay keep card #30054 and say why each body stays. - **Pin.** `engine.facade-return-types.pin.test.ts` parses `engine.ts` and requires each of the eight methods to carry its exact return type text. - **Changeset.** `@objectstack/objectql` patch: the declarations are byte-equal, but the shipped source maps move. ## Tests - New pin: 9/9 at head. On `origin/main`'s `engine.ts` (restored unstaged, then put back from `HEAD`, `git diff HEAD` empty) 8 of 9 fail, one per method. - Ablation (once, `scripts/ablation-replace.mjs`): `init`'s stub annotation removed, anchor 1 → 0, blob `2fa8f560` → `20c28b40`; the pin goes 1 failed / 8 passed, naming `init`; restored to the `HEAD` blob with `git diff HEAD` empty. - The facade gate is the pin for the moves: with the five rows deleted it is red on `origin/main` (five members with a body and no row) and green at head. - `@objectstack/objectql` full suite: 554 files passed, 11,019 tests passed at head `2ea0deb9d` (and the same counts before the branch moved onto `c23eafa88`). - `pnpm --filter @objectstack/objectql typecheck` exit 0 at head. ## Acceptance notes - **Three bodies the `--members` pass will not move.** `registerApp` and `registerDriver`: the pass refuses with "engine-registration.ts: its header is not as the first pass wrote it" (that module's header was edited by hand when `isExternalWriteForbidden` was written there, #30370). `registerHook`: the pass writes the move but its undo proof fails ("undoing the moves out of engine-hooks-and-functions.ts does not rebuild its base (first difference at line 44)", in that module's import block). Each is measured alone and together; the order lands only what is mechanical, so their rows stay with the reason. - **What #30054 still owes.** `engine.ts`: `registerApp`, `registerDriver`, `registerHook` (types spelled, bodies stay). `protocol.ts`: the eight doors typed by #30516, whose move waits on the protocol codemod's missing facade pass (#30518). The seat decides whether those eleven rows move to #30518 or a sibling card and the card closes, or the card stays open. - **A spelled type is now the published shape** (as on #30516): a later change to what one of these methods returns edits its signature too, because a wider body return is not refused and the `.d.ts` prints the signature. - **The branch.** #30516 was squash-merged from this same branch. A force-push to restart it from `main` was refused in this session, so the branch merges `origin/main` over #30516's old commits (the merged tree equals `main` exactly: `git diff origin/main` was empty before this PR's commits), and the PR's commit list shows those old commits. The diff is this PR's seven files only. ## Verification Head `2ea0deb9d`, base `origin/main` `c23eafa88`. <details><summary>Declaration-file proof</summary> - Base: `origin/main` `c23eafa88`'s `engine.ts` and its three facet modules restored into the head tree, `turbo run build --filter=@objectstack/objectql...`, `core.d.ts`, `index.d.ts` and `util-DJB5AN6K.d.ts` copied aside (byte-equal to the first base build at `8e5fabe58`, from which the types were copied). - Head: `pnpm build` in `packages/objectql` (tsup with DTS, then `check-dts-emitted`); `cmp` against the base copies: all three byte-equal (`util-DJB5AN6K.d.ts` sha256 `7f1e928b2f3aceac…`). The same held after the annotation commit alone, before the move. - The diff of the emitted declarations is empty. </details> <details><summary>Codemod proof</summary> - `--members getStatus,triggerHooks,setDatasourceMapping,init,destroy` plan, then `--write`: output tree sha256 `ebff07ef5c50e416…` both times; "5 moved method(s) … engine.ts 11321 -> 11223 lines; undoing the moves does rebuild the base engine.ts and its modules byte-for-byte once the 7 import binding(s) only moved bodies read are pruned from the base". - `--members --verify --base 405373c` (the annotation commit, on this base) exit 0 with the same reading. </details> <details><summary>Gates</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` at head `2ea0deb9d`: 70 derived commands (change set: 7 paths, +262 / -168, under the 3,000 threshold). Each ran with its exit code recorded before any pipe; all 70 exit 0. `--ran` reconciles 70 derived, 70 run, 0 NOT-MEASURED, 0 UNRUN. - `node scripts/check-facade-delegation-only.mjs`: "5 facade class(es) in 3 file(s) hold delegations only, 56 named exception(s)", exit 0. Against `origin/main`'s tree (`--root` a detached `c23eafa88` worktree, `--registry` this head's): exit 1, five problems, one per moved member. - eslint `--no-inline-config` over the five changed source files exit 0 at head. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CXZTh9qvT24wTN6dd6)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c447019 commit bfbdeec

7 files changed

Lines changed: 262 additions & 168 deletions
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
refactor(objectql): the eight `ObjectQL` methods that kept an inferred return type spell it, and five of them move into their facets
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- `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.

‎packages/objectql/src/engine-hooks-and-functions.ts‎

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,12 @@
44
* [#29986 codemod] The
55
* "Class head: units, hooks, functions, body runners, hook dispatch" section of
66
* `ObjectQL`: the bodies of `recordResidualOwner`, `residualOwnerSource`,
7-
* `unregisterHooksByPackage`, `registerFunction`, `resolveFunction`,
8-
* `resolveFunctionEntry`, `unregisterFunctionsByPackage`,
7+
* `getStatus`, `unregisterHooksByPackage`, `registerFunction`,
8+
* `resolveFunction`, `resolveFunctionEntry`, `unregisterFunctionsByPackage`,
99
* `setDefaultBodyRunner`, `getDefaultBodyRunner`, `setDefaultActionRunner`,
10-
* `getDefaultActionRunner`, `hasHooksFor`, `opensAfterAbortUnit`,
11-
* `dispatchUnscopedMultiWriteHooks` moved out of `engine.ts` whole by
12-
* `scripts/codemod-split-engine.mjs`.
10+
* `getDefaultActionRunner`, `triggerHooks`, `hasHooksFor`,
11+
* `opensAfterAbortUnit`, `dispatchUnscopedMultiWriteHooks` moved out of
12+
* `engine.ts` whole by `scripts/codemod-split-engine.mjs`.
1313
*
1414
* A MOVE, not a redesign. {@link HooksAndFunctionsFacet} is a stateless facet of `ObjectQL`
1515
* (ruling 6624345766): each static method of it is the body of the `ObjectQL`
@@ -40,6 +40,8 @@ import type { ExecutionContext } from '@objectstack/spec/kernel';
4040
import { hookStoppedRefusal } from './summary-errors.js';
4141
import { HookTargetRebindError } from './hook-target-rebind-errors.js';
4242
import { declaredHookCondition } from './hook-wrappers.js';
43+
import { CoreServiceName } from '@objectstack/spec/system';
44+
import { bindHookNotice } from './hook-notice.js';
4345
import { hookMatchesObject } from './engine-top-level.js';
4446
import type { FunctionEntry, FunctionRegistrationOptions, HookHandler } from './engine-top-level.js';
4547
import type { ObjectQL } from './engine.js';
@@ -78,6 +80,21 @@ export class HooksAndFunctionsFacet {
7880
return this.residualOwnerSources.get(ownerId);
7981
}
8082

83+
/** The body of `ObjectQL#getStatus`; the method's docblock there is its contract. */
84+
static getStatus(): {
85+
name: "data";
86+
status: string;
87+
version: string;
88+
features: string[];
89+
} {
90+
return {
91+
name: CoreServiceName.enum.data,
92+
status: 'running',
93+
version: '0.9.0',
94+
features: ['crud', 'query', 'aggregate', 'transactions', 'metadata']
95+
};
96+
}
97+
8198
/** The body of `ObjectQL#unregisterHooksByPackage`; the method's docblock there is its contract. */
8299
static unregisterHooksByPackage(this: Pick<HooksAndFunctionsHost, 'hooks' | 'logger'>, packageId: string): number {
83100
if (!packageId) return 0;
@@ -162,6 +179,49 @@ export class HooksAndFunctionsFacet {
162179
return this._defaultActionRunner;
163180
}
164181

182+
/** The body of `ObjectQL#triggerHooks`; the method's docblock there is its contract. */
183+
static async triggerHooks(this: Pick<HooksAndFunctionsHost, 'hooks' | 'logger'>, event: string, context: HookContext): Promise<void> {
184+
const entries = this.hooks.get(event) || [];
185+
// [#28251] `ctx.notice` — the write's own `onNotice` listener, bound here
186+
// so every insert / update dispatch site carries it (see hook-notice.ts).
187+
bindHookNotice(event, context);
188+
189+
if (entries.length === 0) {
190+
this.logger.debug('No hooks registered for event', { event });
191+
return;
192+
}
193+
194+
this.logger.debug('Triggering hooks', { event, count: entries.length });
195+
196+
// `session.skipAutomations` (set from ExecutionContext.skipAutomations —
197+
// import with "run automations & triggers" unchecked, import undo)
198+
// suppresses hooks bound FROM METADATA (`bindHooksToEngine` stamps
199+
// `entry.meta`). Hooks registered in code by plugins — audit, capability
200+
// gates, sharing projection — have no `meta` and always run: the opt-out
201+
// must never bypass security or audit (#2922).
202+
const skipAutomations =
203+
(context.session as { skipAutomations?: boolean } | undefined)?.skipAutomations === true;
204+
205+
for (const entry of entries) {
206+
// Per-object matching — allow face minus exclusion face, the one shared
207+
// rule `hasHooksFor` also reads (#5928).
208+
if (!hookMatchesObject(entry, context.object)) {
209+
continue; // Skip non-matching hooks
210+
}
211+
if (skipAutomations && entry.meta) {
212+
this.logger.debug('Skipping metadata-bound hook (skipAutomations)', { event, hook: entry.hookName });
213+
continue;
214+
}
215+
// [#29422] A stale-roll-up refusal a hook's own `ctx.api` write raised
216+
// describes THAT write, not this one: `hookStoppedRefusal`.
217+
try {
218+
await entry.handler(context);
219+
} catch (e) {
220+
throw hookStoppedRefusal(e, event, context.object, entry.hookName);
221+
}
222+
}
223+
}
224+
165225
/**
166226
* [#5038] Would `triggerHooks(event, ctx)` reach ANY handler for `object`?
167227
*

‎packages/objectql/src/engine-migration-state.ts‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
* `haveFileColumnsMoved`, `readMigrationFlagMemoized`,
1010
* `valueShapeViolationsAdmitted`, `admittedViolationSink`,
1111
* `recordObservedDeviation`, `retractCreationAttestation`,
12-
* `invalidateDataMigrationFlags` moved out of `engine.ts` whole by
12+
* `invalidateDataMigrationFlags`, `destroy` moved out of `engine.ts` whole by
1313
* `scripts/codemod-split-engine.mjs`.
1414
*
1515
* A MOVE, not a redesign. {@link MigrationStateFacet} is a stateless facet of `ObjectQL`
@@ -62,6 +62,7 @@ export interface MigrationStateHost {
6262
readonly admittedValueShapeViolations: ObjectQLClass['admittedValueShapeViolations'];
6363
creationAttestationRetraction: ObjectQLClass['creationAttestationRetraction'];
6464
deviationRecording: ObjectQLClass['deviationRecording'];
65+
readonly drivers: ObjectQLClass['drivers'];
6566
fileReferencesMigrationVerified: ObjectQLClass['fileReferencesMigrationVerified'];
6667
findOne: ObjectQLClass['findOne'];
6768
invalidateDataMigrationFlags: ObjectQLClass['invalidateDataMigrationFlags'];
@@ -76,6 +77,7 @@ export interface MigrationStateHost {
7677
readMigrationFlagRowMemoized: ObjectQLClass['readMigrationFlagRowMemoized'];
7778
readonly recordedDeviations: ObjectQLClass['recordedDeviations'];
7879
readonly retractedCreationAttestations: ObjectQLClass['retractedCreationAttestations'];
80+
unregisterDriver: ObjectQLClass['unregisterDriver'];
7981
update: ObjectQLClass['update'];
8082
valueShapesMigrationVerified: ObjectQLClass['valueShapesMigrationVerified'];
8183
wasDatastoreCreatedFromEmpty: ObjectQLClass['wasDatastoreCreatedFromEmpty'];
@@ -427,4 +429,28 @@ export class MigrationStateFacet {
427429
this.migrationGatesAnnounced = false;
428430
this.recordedDeviations.clear();
429431
}
432+
433+
/** The body of `ObjectQL#destroy`; the method's docblock there is its contract. */
434+
static async destroy(this: Pick<MigrationStateHost, 'drivers' | 'logger' | 'unregisterDriver'>): Promise<void> {
435+
this.logger.info('Destroying ObjectQL engine', { driverCount: this.drivers.size });
436+
437+
for (const [name, driver] of this.drivers.entries()) {
438+
try {
439+
await driver.disconnect();
440+
} catch (e) {
441+
this.logger.error('Error disconnecting driver', e as Error, { driverName: name });
442+
}
443+
}
444+
445+
// #13578 — teardown is an eviction path too, and it used to disconnect
446+
// every driver while leaving all of them REGISTERED. A destroyed engine
447+
// therefore still answered `checkDriversHealth()` by pinging pools it had
448+
// just closed, so a readiness probe racing shutdown read a list of drivers
449+
// that were deliberately dead. Disconnect-then-evict, through the one
450+
// primitive, so `defaultDriver` and the datasource defs go with them.
451+
// Names are snapshotted first: `unregisterDriver` mutates the Map.
452+
for (const name of [...this.drivers.keys()]) this.unregisterDriver(name);
453+
454+
this.logger.info('ObjectQL engine destroyed');
455+
}
430456
}

‎packages/objectql/src/engine-secrets-and-routing.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@
77
* `onCryptoProviderChange`, `setTenancyPostureProvider`, `getSchema`,
88
* `resolveObjectName`, `judgeFilter`, `isSystemLedgerObject`,
99
* `resolveDatasourceBinding`, `resolveEffectiveDatasource`,
10-
* `resolveMappedDatasource` moved out of `engine.ts` whole by
11-
* `scripts/codemod-split-engine.mjs`.
10+
* `resolveMappedDatasource`, `setDatasourceMapping`, `init` moved out of
11+
* `engine.ts` whole by `scripts/codemod-split-engine.mjs`.
1212
*
1313
* A MOVE, not a redesign. {@link SecretsAndRoutingFacet} is a stateless facet of `ObjectQL`
1414
* (ruling 6624345766): each static method of it is the body of the `ObjectQL`
@@ -37,6 +37,9 @@
3737

3838
import type { EngineQueryOptions, ServiceObject } from '@objectstack/spec/data';
3939
import type { EngineFilterJudgement, EngineFilterJudgementOptions, ICryptoProvider } from '@objectstack/spec/contracts';
40+
import { DriverConnectError, emitDegradedBootBanner } from './driver-connect-errors.js';
41+
import type { DriverConnectFailure } from './driver-connect-errors.js';
42+
import { resolveAllowDriverConnectFailure } from '@objectstack/types';
4043
import { StorageNameMapping } from '@objectstack/spec/system';
4144
import { objectNotFoundError } from '@objectstack/core';
4245
import { judgeWhereAdmission } from './engine-top-level.js';
@@ -53,6 +56,7 @@ export interface SecretsAndRoutingHost {
5356
readonly _registry: ObjectQL['_registry'];
5457
cryptoProvider: ObjectQL['cryptoProvider'];
5558
readonly cryptoProviderListeners: ObjectQL['cryptoProviderListeners'];
59+
datasourceMapping: ObjectQL['datasourceMapping'];
5660
readonly defaultDriver: ObjectQL['defaultDriver'];
5761
readonly drivers: ObjectQL['drivers'];
5862
readonly logger: ObjectQL['logger'];
@@ -264,4 +268,61 @@ export class SecretsAndRoutingFacet {
264268
static resolveMappedDatasource(this: Pick<SecretsAndRoutingHost, '_registry' | 'resolveDatasourceFromMapping'>, objectName: string): string | null {
265269
return this.resolveDatasourceFromMapping(objectName, this._registry.getObject(objectName));
266270
}
271+
272+
/** The body of `ObjectQL#setDatasourceMapping`; the method's docblock there is its contract. */
273+
static setDatasourceMapping(this: Pick<SecretsAndRoutingHost, 'datasourceMapping' | 'logger'>, rules: Array<{
274+
namespace?: string;
275+
package?: string;
276+
objectPattern?: string;
277+
default?: boolean;
278+
datasource: string;
279+
priority?: number;
280+
}>): void {
281+
this.datasourceMapping = rules;
282+
this.logger.info('Datasource mapping rules configured', {
283+
ruleCount: rules.length
284+
});
285+
}
286+
287+
/** The body of `ObjectQL#init`; the method's docblock there is its contract. */
288+
static async init(this: Pick<SecretsAndRoutingHost, 'drivers' | 'logger'>): Promise<void> {
289+
this.logger.info('Initializing ObjectQL engine', {
290+
driverCount: this.drivers.size,
291+
drivers: Array.from(this.drivers.keys())
292+
});
293+
294+
const failures: DriverConnectFailure[] = [];
295+
for (const [name, driver] of this.drivers) {
296+
try {
297+
await driver.connect();
298+
this.logger.info('Driver connected successfully', { driverName: name });
299+
} catch (e) {
300+
failures.push({ driverName: name, error: e });
301+
this.logger.error('Failed to connect driver', e as Error, { driverName: name });
302+
}
303+
}
304+
305+
if (failures.length > 0) {
306+
if (!resolveAllowDriverConnectFailure()) {
307+
throw new DriverConnectError(failures, this.drivers.size);
308+
}
309+
const failedDrivers = failures.map(f => f.driverName);
310+
const banner =
311+
`⚠️ DEGRADED BOOT: ${failures.length} of ${this.drivers.size} driver(s) failed to connect ` +
312+
`(${failedDrivers.join(', ')}), but OS_ALLOW_DRIVER_CONNECT_FAILURE is set — starting anyway. ` +
313+
`Every query routed to them fails until the datasource becomes reachable, and the boot-time ` +
314+
`schema sync is SKIPPED FOR GOOD — the client will reconnect on its own, but nothing re-runs ` +
315+
`the DDL, so those objects may have no tables even after the database comes back. Unset ` +
316+
`OS_ALLOW_DRIVER_CONNECT_FAILURE to restore fail-fast boot.`;
317+
this.logger.warn(banner, { failedDrivers });
318+
// …and again on a channel the operator's log level cannot filter away: the
319+
// line above is dropped outright at `error`/`fatal`/`silent`, which is a
320+
// normal production setting and precisely where this flag gets used. See
321+
// the helper's note — the duplication is deliberate, and it is NOT about
322+
// `os serve`'s boot-quiet window (that swallowed it too until #4012).
323+
emitDegradedBootBanner(banner);
324+
}
325+
326+
this.logger.info('ObjectQL engine initialization complete');
327+
}
267328
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #30054 — the eight `ObjectQL` methods that stayed whole on the engine.ts
5+
* facade because their return type was inferred now spell it.
6+
*
7+
* The engine codemod keeps a public method with an inferred return type on
8+
* the class: the published `.d.ts` prints that type, and once the body moved
9+
* the compiler would infer it through the facet instead. Each method below
10+
* carries its type on the signature, copied from the `.d.ts` it was inferred
11+
* into (the PR proves the built `.d.ts` byte-equal before and after). Five of
12+
* them have since moved to their facets with the codemod's `--members` pass;
13+
* `registerApp`, `registerDriver` and `registerHook` keep their bodies and
14+
* their rows in `scripts/facade-delegation-registry.json`. An edit that drops
15+
* one of these annotations turns this red: on a stub, the published type would
16+
* then be re-inferred through the facet.
17+
*/
18+
import { readFileSync } from 'node:fs';
19+
import { fileURLToPath } from 'node:url';
20+
import ts from 'typescript';
21+
import { describe, expect, it } from 'vitest';
22+
23+
const FILE = fileURLToPath(new URL('./engine.ts', import.meta.url));
24+
const CLASS_NAME = 'ObjectQL';
25+
26+
/** Each method with the type it spells (the text of its `.d.ts` line). */
27+
const SPELLED: Readonly<Record<string, string>> = {
28+
registerApp: 'void',
29+
registerHook: 'void',
30+
registerDriver: 'void',
31+
init: 'Promise<void>',
32+
triggerHooks: 'Promise<void>',
33+
destroy: 'Promise<void>',
34+
setDatasourceMapping: 'void',
35+
getStatus: '{\n name: "data";\n status: string;\n version: string;\n features: string[];\n }',
36+
};
37+
38+
function classMethods(): Map<string, ts.MethodDeclaration[]> {
39+
const text = readFileSync(FILE, 'utf8');
40+
const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS);
41+
const cls = sf.statements.find((s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === CLASS_NAME);
42+
const out = new Map<string, ts.MethodDeclaration[]>();
43+
for (const m of cls?.members ?? []) {
44+
if (!ts.isMethodDeclaration(m) || !m.body || !m.name || !ts.isIdentifier(m.name)) continue;
45+
out.set(m.name.text, [...(out.get(m.name.text) ?? []), m]);
46+
}
47+
return out;
48+
}
49+
50+
describe('engine.ts ObjectQL methods spell their return type (#30054)', () => {
51+
const methods = classMethods();
52+
53+
it('finds the class and every method, once each', () => {
54+
expect(methods.size).toBeGreaterThan(0);
55+
for (const name of Object.keys(SPELLED)) expect(methods.get(name)?.length, name).toBe(1);
56+
});
57+
58+
for (const [name, spelled] of Object.entries(SPELLED)) {
59+
it(`${name} declares its return type on the signature`, () => {
60+
const type = methods.get(name)?.[0]?.type;
61+
expect(type, `${name} has an inferred return type`).toBeDefined();
62+
expect(type!.getText()).toBe(spelled);
63+
});
64+
}
65+
});

0 commit comments

Comments
 (0)