diff --git a/.changeset/slot-lookup-fourth-shape.md b/.changeset/slot-lookup-fourth-shape.md new file mode 100644 index 0000000000..ff7f45c0a4 --- /dev/null +++ b/.changeset/slot-lookup-fourth-shape.md @@ -0,0 +1,63 @@ +--- +"@objectstack/core": patch +"@objectstack/runtime": patch +"@objectstack/platform-objects": patch +"@objectstack/plugin-security": patch +"@objectstack/cloud-connection": patch +--- + +fix(lint,runtime,core): the slot-lookup guard sees the split-declaration form — the shape that made the ratchet look cleaner the more it was used (#4251) + +The three selectors from #4321 all key off the erasure and the lookup being in +ONE expression. Split them and every selector misses: + +```ts +let ql: any; +try { ql = ctx.getService('objectql'); } catch { /* optional */ } +``` + +Selector 1 needs the call inside the declarator (this declarator has no init), +selector 2 needs `as`, selector 3 needs a type argument. The contract is erased +exactly as in `const ql: any = ctx.getService(…)`. + +**Why this could not wait for the batches.** The baseline's monotonicity check +means a file that leaves the grandfather list can never be re-added. So every +batch converted more of this shape from "grandfathered" into "lint covers this +file and says nothing" — B2 alone moved `plugin-security/security-plugin.ts` +into that state. A ratchet that reports a cleaner number the more you sweep is +the #4342 failure wearing different clothes, and the fix only gets more +expensive per batch shipped. + +**It is a rule, not a fourth selector, and that is the whole finding.** esquery +can match `AssignmentExpression:has(CallExpression[…])`, but it cannot tell +which declaration the assigned identifier resolves to — so it would equally +flag the correctly-typed form this work line exists to produce (`let +i18nService: II18nService | undefined; i18nService = …`, 8 such sites today in +runtime/app-plugin.ts, service-automation and metadata-protocol). Resolving the +identifier needs SCOPE analysis. That is cheap and needs no type information, so +this stays out of the typed-lint pass the KNOWN RESIDUAL still waits on — but it +is a rule, and the earlier "just one more selector" estimate was wrong. + +Verified against exactly that: the rule flags all 16 real sites and none of the +8 correctly-typed lookalikes. + +**Scale.** The baseline goes 140 → **169 sites** with the file count unchanged +at 37: 29 sites were already inside grandfathered files and simply invisible. +16 more could NOT be grandfathered (12 in files earlier batches had cleared, 3 +in files never listed, 1 the regex sweep had missed) and are typed here — +`runtime/app-plugin.ts` ×5, `core/fallbacks/authored-translation-sync.ts` ×2, +`plugin-security/security-plugin.ts` ×2, `cloud-connection/{runtime-config, +marketplace-proxy}-plugin.ts` ×3, `platform-objects/src/plugin.ts` ×2, +`runtime/http-dispatcher.ts`, `runtime/domains/ai.ts`. No baseline key was +added; the key set still only shrinks. + +Contracts where they exist (`IAIService`, `IJobService`, `IMetadataService`, +`II18nService`, `IDataEngine`, `IHttpServer`), named local surfaces where they +do not — `AppEngineSurface`, `SecurityEngineSurface`, `RawAppHost`, +`EnvRegistrySurface`, `FreshDatastoreEngine`, `AuthoredTranslationSink`. Two of +those record something worth naming: `IHttpServer` has no `getRawApp()` (the +contract is framework-agnostic and the raw app is Hono's own handle), and +ObjectQL's `_defaultBodyRunner` / `_defaultActionRunner` have no public reader +at all — the engine attaches them via `(this as any)` and publishes nothing, +while `getHookMetricsRecorder()` exists for exactly that question about the +metrics recorder. Declared rather than laundered through `any`, and filed. diff --git a/eslint.config.mjs b/eslint.config.mjs index a531108e66..e960e8a512 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -115,6 +115,98 @@ const SLOT_LOOKUP_UNSWEPT = Object.keys(JSON.parse( readFileSync(new URL('./scripts/slot-lookup-baseline.json', import.meta.url), 'utf8'), )); +// [#4251] The FOURTH erasure shape: the declaration and the lookup split apart. +// +// let ql: any; +// try { ql = ctx.getService('objectql'); } catch { /* optional */ } +// +// The contract is erased exactly as in `const ql: any = ctx.getService(…)`, and +// all three selectors below miss it: selector 1 needs the call inside the +// declarator (here the declarator has no init), selector 2 needs `as`, selector +// 3 needs a type argument. 23 sites repo-wide used it, 12 of them in files no +// longer grandfathered — i.e. lint covered them and said nothing. Worse, that +// number GREW with every batch: sweeping a file removes it from the baseline, +// and the baseline's monotonicity check means it can never be re-added, so each +// batch converted more of this shape from "grandfathered" into "silently clean". +// A ratchet that looks cleaner the more you use it is the #4342 failure again. +// +// This is a RULE and not a fourth selector because esquery cannot do it. A +// selector can match `AssignmentExpression:has(CallExpression[…])`, but it +// cannot tell which declaration the assigned identifier resolves to — so it +// would equally flag the correctly-typed form this whole work line is trying to +// produce (`let i18nService: II18nService | undefined; i18nService = …`, 8 such +// sites today, in runtime/app-plugin.ts and service-automation among others). +// Resolving the identifier to its declaration needs SCOPE analysis, which is +// cheap and needs no type information — so this stays out of the typed-lint +// pass that the KNOWN RESIDUAL below still waits on. +const slotLookupPlugin = { + rules: { + 'no-any-assignment': { + meta: { + type: 'problem', + docs: { description: 'Ban assigning a service-lookup result to an `any`-declared variable.' }, + schema: [], + messages: { erased: SLOT_LOOKUP_ANY_MESSAGE }, + }, + create(context) { + const lookupNames = new Set(SLOT_LOOKUPS.split('|')); + const uncontracted = new RegExp(`^(${UNCONTRACTED_SLOTS})$`); + + /** The slot-lookup call inside `node`, or null. Mirrors the selectors' `:has`. */ + const findLookupCall = (node) => { + let found = null; + const walk = (n) => { + if (found || !n || typeof n.type !== 'string') return; + if ( + n.type === 'CallExpression' && + n.callee?.type === 'MemberExpression' && + lookupNames.has(n.callee.property?.name) + ) { + // Same exemption channel as the selectors: the slot name is read + // off a literal argument, so an UNCONTRACTED_SLOTS lookup is + // legitimately `any` and must not be reported. + const exempt = n.arguments.some( + (a) => a?.type === 'Literal' && uncontracted.test(String(a.value)), + ); + if (!exempt) { found = n; return; } + } + for (const key of Object.keys(n)) { + if (key === 'parent') continue; + const child = n[key]; + if (Array.isArray(child)) child.forEach(walk); + else if (child && typeof child.type === 'string') walk(child); + } + }; + walk(node); + return found; + }; + + /** True when `name` resolves, in scope, to a variable declared `: any`. */ + const declaredAny = (name, node) => { + let scope = context.sourceCode.getScope(node); + for (; scope; scope = scope.upper) { + const variable = scope.variables.find((v) => v.name === name); + if (!variable) continue; + return variable.defs.some( + (d) => d.node?.id?.typeAnnotation?.typeAnnotation?.type === 'TSAnyKeyword', + ); + } + return false; + }; + + return { + AssignmentExpression(node) { + if (node.left.type !== 'Identifier') return; + if (!findLookupCall(node.right)) return; + if (!declaredAny(node.left.name, node)) return; + context.report({ node, messageId: 'erased' }); + }, + }; + }, + }, + }, +}; + export default [ { files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'], @@ -259,7 +351,12 @@ export default [ parser: tsParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, }, + plugins: { 'slot-lookup': slotLookupPlugin }, rules: { + // The split-declaration form (#4251) — see `slotLookupPlugin`. Reports the + // SAME message as the three selectors below, so `check:slot-lookup` counts + // all four shapes without knowing there are four. + 'slot-lookup/no-any-assignment': 'error', 'no-restricted-syntax': ['error', { // `const svc: any = await deps.resolveService('auth', env)` diff --git a/packages/cloud-connection/src/marketplace-proxy-plugin.ts b/packages/cloud-connection/src/marketplace-proxy-plugin.ts index 35d2b769ff..531db3e397 100644 --- a/packages/cloud-connection/src/marketplace-proxy-plugin.ts +++ b/packages/cloud-connection/src/marketplace-proxy-plugin.ts @@ -33,6 +33,21 @@ import { publicMarketplaceKeyForApiPath, } from './marketplace-public-url.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +/** + * The `http-server` slot plus the one member this plugin needs from it. + * + * [#4251] `IHttpServer` is a real contract (`@objectstack/spec/contracts`) and + * this slot is bound to it — but `getRawApp()` is NOT on it, deliberately: the + * contract is framework-agnostic and the raw app is the framework's own handle + * (Hono here). So the escape is one named member returning `any`, scoped to the + * one thing that genuinely cannot be typed framework-agnostically, instead of + * the whole lookup collapsing to `any`. The probe below is what runs when a + * host serves the slot with an adapter that exposes no raw app. + */ +type RawAppHost = IHttpServer & { getRawApp?(): any }; + const MARKETPLACE_PREFIX = '/api/v1/marketplace'; /** @@ -181,9 +196,9 @@ export class MarketplaceProxyPlugin implements Plugin { manifest?.register?.(MARKETPLACE_BROWSE_UI_BUNDLE); } catch { /* no manifest service */ } - let httpServer: any; + let httpServer: RawAppHost | undefined; try { - httpServer = ctx.getService('http-server'); + httpServer = ctx.getService('http-server'); } catch { ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server not available — marketplace routes not mounted'); return; diff --git a/packages/cloud-connection/src/runtime-config-plugin.ts b/packages/cloud-connection/src/runtime-config-plugin.ts index ae8e14d323..150937a3de 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -41,6 +41,31 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { resolveCloudUrl } from './cloud-url.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +/** + * The `http-server` slot plus the one member this plugin needs from it. + * + * [#4251] `IHttpServer` is a real contract (`@objectstack/spec/contracts`) and + * this slot is bound to it — but `getRawApp()` is NOT on it, deliberately: the + * contract is framework-agnostic and the raw app is the framework's own handle + * (Hono here). So the escape is one named member returning `any`, scoped to the + * one thing that genuinely cannot be typed framework-agnostically, instead of + * the whole lookup collapsing to `any`. The probe below is what runs when a + * host serves the slot with an adapter that exposes no raw app. + */ +type RawAppHost = IHttpServer & { getRawApp?(): any }; + +/** + * The `env-registry` slot's hostname resolvers — see the call site for both + * spellings. Async by contract: the consumer awaits the result, and declaring + * it `unknown` would only have moved the erasure one line down. + */ +interface EnvRegistrySurface { + resolveByHostname?(hostname: string): Promise; + resolveHostname?(hostname: string): Promise; +} + /** * Feature-flag overrides a host's distribution policy can derive per request. @@ -171,9 +196,9 @@ export class RuntimeConfigPlugin implements Plugin { start = async (ctx: PluginContext): Promise => { ctx.hook('kernel:ready', async () => { - let httpServer: any; + let httpServer: RawAppHost | undefined; try { - httpServer = ctx.getService('http-server'); + httpServer = ctx.getService('http-server'); } catch { ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server not available — runtime/config not mounted'); return; @@ -194,8 +219,14 @@ export class RuntimeConfigPlugin implements Plugin { // kernel router uses (env-registry). Falls back to the static // payload when the host doesn't map to any env (e.g. a marketing // root or a CLI-served single-env runtime). - let envRegistry: any = null; - try { envRegistry = ctx.getService('env-registry'); } catch { /* not mounted (file/CLI mode) */ } + // [#4251] `env-registry` has no written contract, so the two + // hostname resolvers this reads are declared here rather than + // erased. Both spellings are probed below because the slot has two + // providers that disagree — declaring them is what makes that + // disagreement visible instead of a pair of `?.` guesses. + let envRegistry: EnvRegistrySurface | null = null; + try { envRegistry = ctx.getService('env-registry') ?? null; } + catch { /* not mounted (file/CLI mode) */ } // Merge the distribution's feature overrides over the static base. // Arbitrary keys returned by the host pass through verbatim — the diff --git a/packages/core/src/fallbacks/authored-translation-sync.ts b/packages/core/src/fallbacks/authored-translation-sync.ts index 73515e63a2..365ed4ee1b 100644 --- a/packages/core/src/fallbacks/authored-translation-sync.ts +++ b/packages/core/src/fallbacks/authored-translation-sync.ts @@ -44,6 +44,7 @@ */ import { LEGACY_OBJECT_FIRST_KEYS } from '@objectstack/spec/system'; +import type { IDataEngine } from '@objectstack/spec/contracts'; import { deepMerge } from './memory-i18n.js'; @@ -63,6 +64,22 @@ interface MinimalCtx { */ const OWNER_PROP = '__authoredTranslationSyncOwner'; +/** + * The `i18n` slot as this wirer uses it: the authored-layer replace seam, plus + * the ownership marker stamped on the instance. + * + * [#4251] `replaceAuthoredTranslations` is NOT on `II18nService` — it is the + * authored-overlay seam service-i18n grew for this sync, and every call site + * probes for it. `OWNER_PROP` is this module's own marker, stamped on whatever + * object occupies the slot so two wirers cannot both drive one instance. + * Declared here rather than erased to `any` so both facts stay legible: what + * the slot must supply, and what this module writes onto it. + */ +interface AuthoredTranslationSink { + replaceAuthoredTranslations(layer: Record): void; + [OWNER_PROP]?: symbol; +} + // Deliberately narrow (language + optional script/region only): item names // are snake_case, so a permissive multi-segment pattern would classify names // like `my_custom_strings` as locales. @@ -157,8 +174,8 @@ export function wireAuthoredTranslationSync(ctx: MinimalCtx): void { if (typeof ctx.hook !== 'function') return; const token = Symbol('authored-translation-sync'); - const resolveOwnedI18n = (): any | null => { - let i18n: any; + const resolveOwnedI18n = (): AuthoredTranslationSink | null => { + let i18n: AuthoredTranslationSink | undefined; try { i18n = ctx.getService('i18n'); } catch { return null; } if (!i18n || typeof i18n.replaceAuthoredTranslations !== 'function') return null; const current = i18n[OWNER_PROP]; @@ -176,7 +193,7 @@ export function wireAuthoredTranslationSync(ctx: MinimalCtx): void { const run = chain.then(async () => { const i18n = resolveOwnedI18n(); if (!i18n) return; - let engine: any; + let engine: IDataEngine | undefined; try { engine = ctx.getService('objectql'); } catch { return; } if (!engine || typeof engine.find !== 'function') return; const layer = await readAuthoredTranslationLayer(engine, ctx.logger); diff --git a/packages/platform-objects/src/plugin.ts b/packages/platform-objects/src/plugin.ts index 698f04eccf..e64773e820 100644 --- a/packages/platform-objects/src/plugin.ts +++ b/packages/platform-objects/src/plugin.ts @@ -4,7 +4,20 @@ import { SetupAppTranslations } from './apps/translations/index.js'; import { MetadataFormsTranslations } from './metadata-translations/index.js'; import { SysMigration } from './system/sys-migration.object.js'; import { SysSecret } from './system/sys-secret.object.js'; -import { attestFreshDatastore } from './system/migration-flag.js'; +import { attestFreshDatastore, type MigrationFlagEngine } from './system/migration-flag.js'; +import type { II18nService } from '@objectstack/spec/contracts'; + +/** + * The `objectql` slot's fresh-datastore attestation seam. + * + * [#4251] Not on `IDataEngine` — these are ObjectQL's own migration-flag + * accessors, and the probe at the call site is what runs when the slot holds an + * engine without them. Declared narrow and named instead of erased to `any`. + */ +interface FreshDatastoreEngine extends MigrationFlagEngine { + wasDatastoreCreatedFromEmpty?(): boolean; + invalidateDataMigrationFlags?(): void; +} /** * `PlatformObjectsPlugin` @@ -95,7 +108,9 @@ export class PlatformObjectsPlugin { // service-storage). A store that was found rather than created attests // nothing and keeps producing evidence by scan. ctx?.hook?.('kernel:ready', async () => { - let engine: any; + // [#4251] The fresh-datastore attestation seam is ObjectQL's own, not + // `IDataEngine`'s; declared here rather than erased to `any`. + let engine: FreshDatastoreEngine | undefined; try { engine = ctx.getService?.('objectql'); } catch { @@ -120,7 +135,7 @@ export class PlatformObjectsPlugin { }); ctx?.hook?.('kernel:ready', async () => { - let i18n: any; + let i18n: II18nService | undefined; try { i18n = ctx.getService?.('i18n'); } catch { diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 8a4d1eaf10..5832c3f2a2 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -17,7 +17,25 @@ import { intersectFieldMasks, } from './explain-engine.js'; import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/security'; -import type { II18nService } from '@objectstack/spec/contracts'; +import type { IDataEngine, II18nService, IMetadataService } from '@objectstack/spec/contracts'; + +/** + * The `objectql` slot as this plugin's start-up path checks it: the data + * contract plus the middleware seam it installs. + * + * [#4251] `registerMiddleware` is ObjectQL's own, not `IDataEngine`'s, and the + * probe right below is what the plugin does when it is absent. Declared narrow + * and named rather than erased — see the standing record on `getObjectQL` in + * `@objectstack/runtime` for why ObjectQL's wider contract stays unwritten. + */ +type SecurityEngineSurface = IDataEngine & { + registerMiddleware?( + fn: (opCtx: any, next: () => Promise) => Promise, + options?: { object?: string }, + ): void; + /** Schema lookup the engine-owned write guard reads `managedBy` off of. */ + getSchema?(objectName: string): any; +}; import { bootstrapDeclaredPositions } from './bootstrap-declared-positions.js'; import { bootstrapDeclaredPermissions, upsertPackagePermissionSet, readDeclared } from './bootstrap-declared-permissions.js'; import { applyManagedWriteDenies } from './managed-object-write-denies.js'; @@ -472,12 +490,12 @@ export class SecurityPlugin implements Plugin { ctx.logger.info('Starting Security Plugin...'); // Get required services - let ql: any; - let metadata: any; + let ql: SecurityEngineSurface | undefined; + let metadata: IMetadataService | undefined; try { - ql = ctx.getService('objectql'); - metadata = ctx.getService('metadata'); + ql = ctx.getService('objectql'); + metadata = ctx.getService('metadata'); } catch (e) { ctx.logger.warn('ObjectQL or metadata service not available, security middleware not registered'); return; diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 542ec58e20..5893b6283f 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -7,7 +7,7 @@ import { SeedLoaderService } from './seed-loader.js'; import { recordSeedOutcome } from './seed-summary.js'; import { mergeSeedDatasets, readSeedDatasets, registerSeedReplayerOnce } from './seed-datasets.js'; import { loadDisabledPackageIds } from './package-state-store.js'; -import type { IMetadataService, II18nService } from '@objectstack/spec/contracts'; +import type { IDataEngine, IJobService, IMetadataService, II18nService } from '@objectstack/spec/contracts'; import { readServiceSelfInfo } from '@objectstack/spec/api'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; import { hookBodyRunnerFactory, actionBodyRunnerFactory } from './sandbox/body-runner.js'; @@ -32,6 +32,54 @@ const SEED_WRITE_OPTIONS = { context: { isSystem: true, skipTriggers: true, seed * hooks that drive the org-scoped `sys_app` catalog. Standalone (single-tenant) * usages may omit this — no catalog hooks are emitted in that case. */ +/** + * The `objectql` slot BEYOND `IDataEngine` — the seams AppPlugin installs on the + * engine. + * + * [#4251] The slot's ledger entry is `IDataEngine`, and it covers the `insert` + * calls in this file and nothing else here. Everything below is ObjectQL's own + * surface, for which no contract has been written — the standing record of why + * is on `getObjectQL` in {@link DomainHandlerContext}. Declared narrow and named + * so the extension is legible rather than erased to `any`; whoever writes + * ObjectQL's contract absorbs this and deletes it. + * + * The members are declared REQUIRED even though a kernel may hold an engine + * that implements none of them: every call site already probes with + * `typeof … === 'function'` and degrades there, and that runtime guard is the + * real defence. Marking them optional here would only turn each guarded + * invocation into a `possibly undefined` error and push the code back toward + * the `any` this rule exists to remove. + */ +interface AppEngineSurface { + bindHooks(hooks: unknown[], options: Record): void; + registerAction(objectKey: string, name: string, handler: unknown, packageId: string): void; + registerDriver(driver: unknown): void; + setDatasourceMapping(rules: unknown): void; + setDefaultBodyRunner(runner: unknown): void; + setDefaultActionRunner( + runner: (actionDef: any) => ((ctx: any) => Promise) | undefined, + ): void; + setHookMetricsRecorder(recorder: unknown): void; + getHookMetricsRecorder(): any; + readonly registry?: { setInitialDisabledPackageIds?: (ids: Iterable) => void }; + /** + * The idempotence guards for the two runners above, read directly. + * + * These are NOT part of any declared API: the engine attaches them with + * `(this as any)._defaultBodyRunner = runner` and publishes no reader, so + * "has another AppPlugin already installed one?" has no answer except + * reaching for the field. Declared here to keep that reach visible instead + * of laundering it through `any` — the fix is a public accessor on the + * engine beside `getHookMetricsRecorder`, which already exists for exactly + * this question about the metrics recorder. Filed on #4251. + */ + _defaultBodyRunner?: unknown; + _defaultActionRunner?: unknown; +} + +/** The engine as AppPlugin uses it: the data contract plus those seams. */ +type AppEngine = IDataEngine & AppEngineSurface; + export interface AppPluginProjectContext { environmentId: string; organizationId: string; @@ -271,9 +319,9 @@ export class AppPlugin implements Plugin { ctx.logger.info('[AppPlugin] OS_DISABLE_AUTHORED_HOOKS=1 — runtime-authored hook bodies will not execute'); return; } - let ql: any; + let ql: AppEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { return; // no engine on this kernel — nothing to wire } @@ -309,9 +357,9 @@ export class AppPlugin implements Plugin { ctx.logger.info('[AppPlugin] OS_DISABLE_AUTHORED_ACTIONS=1 — runtime-authored action bodies will not execute'); return; } - let ql: any; + let ql: AppEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { return; // no engine on this kernel — nothing to wire } @@ -339,9 +387,9 @@ export class AppPlugin implements Plugin { * idempotent across the multiple AppPlugins a multi-app env installs. */ private installHookMetricsTiming(ctx: PluginContext): void { - let ql: any; + let ql: AppEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { return; // no engine on this kernel — nothing to wire } @@ -380,9 +428,9 @@ export class AppPlugin implements Plugin { // Retrieve ObjectQL engine from services // ctx.getService throws when a service is not registered, so we // must use try/catch instead of a null-check. - let ql: any; + let ql: AppEngine | undefined; try { - ql = ctx.getService('objectql'); + ql = ctx.getService('objectql'); } catch { // Service not registered — handled below } @@ -733,8 +781,8 @@ export class AppPlugin implements Plugin { : []; if (jobs.length > 0) { ctx.hook('kernel:ready', async () => { - let svc: any; - try { svc = ctx.getService('job'); } catch { /* not installed */ } + let svc: IJobService | undefined; + try { svc = ctx.getService('job'); } catch { /* not installed */ } if (!svc || typeof svc.schedule !== 'function') { ctx.logger.warn('[AppPlugin] job service not registered — skipping declarative jobs', { appId, jobCount: jobs.length, diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index fb46467acd..bae579adc8 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -15,6 +15,7 @@ import { } from '@objectstack/core'; import { isServiceServeable } from '../service-serveable.js'; import { capabilityUnavailable } from './unavailable.js'; +import type { IAIService } from '@objectstack/spec/contracts'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -31,7 +32,7 @@ export function createAiDomain(deps: DomainHandlerDeps): DomainRoute { * Resolves the AI service and its built-in route handlers, then dispatches. */ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise { - let aiService: any; + let aiService: IAIService | undefined; try { aiService = await deps.resolveService('ai'); } catch { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 05b492879c..a894ddf7b8 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -6,6 +6,7 @@ import { import { isMcpServerEnabled, looksLikeInternalErrorLeak, INTERNAL_ERROR_MESSAGE } from '@objectstack/types'; import { measureServerTiming, allowPerfDisclosure, isPerfDisclosurePrincipal } from '@objectstack/observability'; import { CoreServiceName, serviceUnavailableMessage } from '@objectstack/spec/system'; +import type { IDataEngine } from '@objectstack/spec/contracts'; import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api'; import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; @@ -395,7 +396,10 @@ export class HttpDispatcher { } let unhealthy: string[] = []; try { - let engine: any; + // [#4251] `checkDriversHealth` is ObjectQL's, not `IDataEngine`'s — + // declared narrow rather than erased; the probe below is what runs + // when the slot holds an engine without a driver-health surface. + let engine: (IDataEngine & { checkDriversHealth?(): Promise }) | undefined; try { engine = (this.kernel as any)?.getService?.('data'); } catch { diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index df999668b9..a9319f889d 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -1,38 +1,38 @@ { "packages/cli/src/commands/migrate/files-to-references.ts": 1, "packages/cli/src/commands/migrate/value-shapes.ts": 1, - "packages/cli/src/commands/serve.ts": 7, + "packages/cli/src/commands/serve.ts": 10, "packages/client/src/client.hono.test.ts": 2, - "packages/cloud-connection/src/cloud-connection-plugin.ts": 3, - "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 11, + "packages/cloud-connection/src/cloud-connection-plugin.ts": 5, + "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 17, "packages/core/examples/kernel-features-example.ts": 5, "packages/metadata-protocol/src/plugin.ts": 1, "packages/metadata/src/plugin.ts": 1, "packages/objectql/src/plugin.integration.test.ts": 23, - "packages/objectql/src/plugin.ts": 6, - "packages/plugins/plugin-approvals/src/approvals-plugin.ts": 7, - "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts": 2, + "packages/objectql/src/plugin.ts": 7, + "packages/plugins/plugin-approvals/src/approvals-plugin.ts": 9, + "packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts": 3, "packages/plugins/plugin-audit/src/audit-plugin.ts": 1, "packages/plugins/plugin-email/src/email-plugin.ts": 3, "packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts": 2, - "packages/plugins/plugin-reports/src/reports-plugin.ts": 5, - "packages/plugins/plugin-sharing/src/sharing-plugin.ts": 7, - "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts": 1, + "packages/plugins/plugin-reports/src/reports-plugin.ts": 8, + "packages/plugins/plugin-sharing/src/sharing-plugin.ts": 10, + "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts": 2, "packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts": 1, "packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts": 1, "packages/rest/src/external-datasource-routes.ts": 1, - "packages/rest/src/rest-api-plugin.ts": 14, - "packages/services/service-datasource/src/admin-routes.ts": 1, - "packages/services/service-job/src/job-service-plugin.ts": 2, + "packages/rest/src/rest-api-plugin.ts": 15, + "packages/services/service-datasource/src/admin-routes.ts": 2, + "packages/services/service-job/src/job-service-plugin.ts": 4, "packages/services/service-messaging/src/messaging-service-plugin.test.ts": 2, "packages/services/service-messaging/src/messaging-service-plugin.ts": 1, - "packages/services/service-queue/src/queue-service-plugin.ts": 2, + "packages/services/service-queue/src/queue-service-plugin.ts": 4, "packages/services/service-realtime/src/realtime-service-plugin.ts": 1, "packages/services/service-settings/src/settings-service-plugin.ts": 2, "packages/services/service-sms/src/sms-plugin.ts": 1, - "packages/services/service-storage/src/storage-service-plugin.ts": 5, + "packages/services/service-storage/src/storage-service-plugin.ts": 6, "packages/triggers/trigger-record-change/src/formula-context.test.ts": 2, "packages/triggers/trigger-record-change/src/multilookup-context.test.ts": 2, "packages/triggers/trigger-record-change/src/record-change-integration.test.ts": 11