From 322f4c3d2f9101c44e2bc4f1b49e8fa35fe8a2af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:54:39 +0000 Subject: [PATCH 1/3] fix(spec,runtime): catch the getService type-argument form and widen the lookup guard to all packages (#4251) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #4127/#4214 rule matched ': any' and 'as any' on a lookup result but not the type-argument form getService(...) — the shape the codebase actually used — and its files glob stopped at packages/runtime while the composition roots held the majority of lookups. Both gaps close here: - eslint.config.mjs: third selector for the type-argument form (typeArguments.params.0 = TSAnyKeyword), scope widened to packages/**, http.server added to UNCONTRACTED_SLOTS (three providers, no contract). The 40 not-yet-swept files are grandfathered in SLOT_LOOKUP_UNSWEPT — a visible ratchet enumerated at 180 sites by running the rule with the list emptied; batches remove entries, never add. - The three in-scope runtime src sites are typed. Doing so surfaced the batch's first yield: both addDatasource branches (DefaultDatasourcePlugin parity branch, DriverPlugin.start) probed a method no metadata service implements, so they had never run on any boot — deleted rather than typed against a phantom shape. The inert DriverPluginOptions are tracked in #4320; registerInMemory('datasource', ...) is the actual visibility path. - Contracts declare the evidenced members those sites read, both optional: IDataEngine.getDefaultDriverName/getDriverByName (ObjectQL's driver registry, re-registered as driver. services for os migrate and serve storage detection) and IMetadataService.registerInMemory (MetadataManager's boot-time seeding primitive, #3827). - Runtime test lookups typed as IDataEngine; the five http.server test lookups ride the slot exemption. Verified: pnpm lint clean; spec check:generated all 8 up to date; tests green — spec 7137/277, objectql 1345/85, runtime 954/67, metadata 281/13, plugin-webhooks 25/3. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz --- .../slot-lookup-type-argument-ratchet.md | 32 ++++++ eslint.config.mjs | 100 ++++++++++++++++-- .../src/default-datasource-plugin.test.ts | 23 ++-- .../runtime/src/default-datasource-plugin.ts | 29 +++-- packages/runtime/src/driver-plugin.ts | 50 ++++----- .../notifications.hono.integration.test.ts | 3 +- packages/spec/src/contracts/data-engine.ts | 16 +++ .../spec/src/contracts/metadata-service.ts | 19 ++++ 8 files changed, 208 insertions(+), 64 deletions(-) create mode 100644 .changeset/slot-lookup-type-argument-ratchet.md diff --git a/.changeset/slot-lookup-type-argument-ratchet.md b/.changeset/slot-lookup-type-argument-ratchet.md new file mode 100644 index 0000000000..309cc918d7 --- /dev/null +++ b/.changeset/slot-lookup-type-argument-ratchet.md @@ -0,0 +1,32 @@ +--- +"@objectstack/spec": patch +"@objectstack/runtime": patch +--- + +fix(spec,runtime): the service-lookup `any` guard now sees the type-argument form, and its scope stops at nothing under `packages/` (#4251) + +The #4127/#4214 rule banned `: any` and `as any` on a service-lookup result but +not `getService('data')` — the form the codebase actually used (80 sites, +zero matches), erasing the slot contract identically. And the rule's `files` +covered only `packages/runtime`, leaving the composition roots (rest, +plugins/*, services/*) that hold most lookups unlinted. Both gaps closed: a +third AST selector catches the type-argument form, the scope is now all of +`packages/`, and the 40 not-yet-swept files are grandfathered in a visible, +shrinking ratchet list (`SLOT_LOOKUP_UNSWEPT`) — enumerated at 180 sites by +running the widened rule with the list emptied. `http.server` joins +`UNCONTRACTED_SLOTS` (three providers, no written contract). + +Typing the three in-scope runtime sites surfaced its first yield: both +`addDatasource` datasource-registration branches (DefaultDatasourcePlugin, +DriverPlugin) probed a method **no metadata service implements**, so they had +never run on any boot — deleted rather than typed against a phantom shape. The +inert `DriverPluginOptions` they configured are tracked in #4320. +`registerInMemory('datasource', …)` is the actual visibility path (#3827). + +Contract members declared from evidence, both optional: `IDataEngine` gains +`getDefaultDriverName?()` / `getDriverByName?()` (ObjectQL's driver registry — +the surface `os migrate` and serve's storage detection reach through +`driver.` services), `IMetadataService` gains `registerInMemory?()` +(MetadataManager's boot-time seeding primitive). Callers that supplied `` +to these lookups should pass the slot's contract type instead — or nothing: +an unmapped slot deliberately resolves to `unknown`, not `any`. diff --git a/eslint.config.mjs b/eslint.config.mjs index 8740323df1..4cd987657a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -59,16 +59,82 @@ const SLOT_LOOKUPS = ['resolveService', 'getService', 'getRequestKernelService'] // eslint-disable comments on purpose: exceptions belong in one reviewable // place, not sprinkled through the code. Deleting a name from this list is how // the exemption ends once that contract gets written. -const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager'].join('|'); +// +// Entries are spliced into a regex, so escape metacharacters (`http\\.server`). +// `http.server` is served by three providers (plugin-hono-server, runtime's +// config.server path, qa's node-plugin) and no IHttpServer contract exists; +// callers read only `getPort()`. +const UNCONTRACTED_SLOTS = ['protocol', 'mcp', 'kernel-resolver', 'scope-manager', 'http\\.server'].join('|'); const SLOT_LOOKUP_ANY_MESSAGE = - 'Do not annotate a service-lookup result as `any` — the lookup already returns ' + - 'the slot\'s contract (#4168/#4176/#4202), and this switches that checking off ' + + 'Do not erase a service-lookup result to `any` (`: any`, `as any`, or a ' + + '`getService(…)` type argument) — the lookup already returns the slot\'s ' + + 'contract (#4168/#4176/#4202), and this switches that checking off ' + 'for the call site while looking identical to code that has it. Every such ' + 'annotation found so far was hiding a real gap, including a project-membership ' + - 'gate that silently stopped gating. If the slot genuinely has no contract, add ' + - 'its name to UNCONTRACTED_SLOTS in eslint.config.mjs with a note, so the ' + - 'exemption is reviewed once and visible in one place — see issue #4127.'; + 'gate that silently stopped gating and two datasource-registration branches ' + + 'probing a method no metadata service has (#4251). Pass the slot\'s contract ' + + 'type instead (`getService(\'data\')`). If the slot genuinely has ' + + 'no contract, add its name to UNCONTRACTED_SLOTS in eslint.config.mjs with a ' + + 'note, so the exemption is reviewed once and visible in one place — see ' + + 'issues #4127 and #4251.'; + +// [#4251] The sweep ratchet. These files hold pre-existing lookup-erasure +// sites — `getService(…)`, `: any`, or `as any` — that predate the rule +// reaching them: the rule's scope was packages/runtime only until #4251 +// widened it, and the type-argument selector did not exist. Enumerated by +// running this config with this list emptied: 180 sites in 44 files (the +// issue's 80 was the non-test `` form alone; the annotation forms and +// test files the old selectors would have caught under a wider scope roughly +// double it). They are grandfathered BY FILE, here, for the same reason +// UNCONTRACTED_SLOTS is central: `--no-inline-config` means the escape must +// live in config, and a shrinking list in one place is the ratchet made +// visible. Batches remove entries as they sweep (see #4214 for the batch +// pattern and its yield — these sites are where the erased contracts live). +// NEVER add an entry: a new file starts covered, and a new violation in a +// listed file rides an existing entry only until its batch. +const SLOT_LOOKUP_UNSWEPT = [ + 'packages/cli/src/commands/migrate/files-to-references.ts', + 'packages/cli/src/commands/migrate/value-shapes.ts', + 'packages/cli/src/commands/serve.ts', + 'packages/client/src/client.hono.test.ts', + 'packages/cloud-connection/src/cloud-connection-plugin.ts', + 'packages/cloud-connection/src/marketplace-install-local-plugin.ts', + 'packages/core/examples/kernel-features-example.ts', + 'packages/metadata-protocol/src/plugin.ts', + 'packages/metadata/src/plugin.ts', + 'packages/objectql/src/plugin.integration.test.ts', + 'packages/objectql/src/plugin.ts', + 'packages/plugins/plugin-approvals/src/approvals-plugin.ts', + 'packages/plugins/plugin-approvals/src/status-mirror-cascade.integration.test.ts', + 'packages/plugins/plugin-audit/src/audit-plugin.ts', + 'packages/plugins/plugin-auth/src/auth-plugin.ts', + 'packages/plugins/plugin-email/src/email-plugin.ts', + 'packages/plugins/plugin-hono-server/src/current-user-endpoints.ts', + 'packages/plugins/plugin-pinyin-search/src/pinyin-search-plugin.ts', + 'packages/plugins/plugin-reports/src/reports-plugin.ts', + 'packages/plugins/plugin-security/src/security-plugin.ts', + 'packages/plugins/plugin-sharing/src/sharing-plugin.ts', + 'packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts', + 'packages/qa/dogfood/test/showcase-agent-intersection.dogfood.test.ts', + 'packages/qa/dogfood/test/showcase-bu-hierarchy-sharing.dogfood.test.ts', + 'packages/qa/dogfood/test/showcase-d3-d4-capabilities.dogfood.test.ts', + 'packages/qa/dogfood/test/showcase-permission-zoo.dogfood.test.ts', + 'packages/rest/src/external-datasource-routes.ts', + 'packages/rest/src/rest-api-plugin.ts', + 'packages/services/service-datasource/src/admin-routes.ts', + 'packages/services/service-job/src/job-service-plugin.ts', + 'packages/services/service-messaging/src/messaging-service-plugin.test.ts', + 'packages/services/service-messaging/src/messaging-service-plugin.ts', + 'packages/services/service-queue/src/queue-service-plugin.ts', + 'packages/services/service-realtime/src/realtime-service-plugin.ts', + 'packages/services/service-settings/src/settings-service-plugin.ts', + 'packages/services/service-sms/src/sms-plugin.ts', + 'packages/services/service-storage/src/storage-service-plugin.ts', + 'packages/triggers/trigger-record-change/src/formula-context.test.ts', + 'packages/triggers/trigger-record-change/src/multilookup-context.test.ts', + 'packages/triggers/trigger-record-change/src/record-change-integration.test.ts', +]; export default [ { @@ -193,6 +259,13 @@ export default [ // having — a deliberate gap is a reviewed line in this file, a careless one // is a build failure, and the two stop looking identical in the code. // + // [#4251] Scope is all of packages/ — the rule shipped scoped to + // packages/runtime while the composition roots (rest, plugins/*, services/*) + // held 77 of the 80 known sites, an unlinted majority that looked covered. + // Per-package curation would recreate that gap one package at a time, so the + // scope is total and the not-yet-swept files are grandfathered individually + // in SLOT_LOOKUP_UNSWEPT above — a shrinking list, not a silent boundary. + // // KNOWN RESIDUAL: a wrapper whose own return type is annotated // (`const getEngine = async (): Promise => …resolveService(…)`) erases // the slot type just as effectively, and this selector cannot see it — the @@ -200,8 +273,8 @@ export default [ // existed (share-links `getEngine`, fixed in batch 4). Catching that shape // needs type information, so it belongs to a typed-lint pass, not here. { - files: ['packages/runtime/**/*.{ts,mts,cts}'], - ignores: ['**/node_modules/**', '**/dist/**'], + files: ['packages/**/*.{ts,tsx,mts,cts}'], + ignores: ['**/node_modules/**', '**/dist/**', ...SLOT_LOOKUP_UNSWEPT], languageOptions: { parser: tsParser, parserOptions: { ecmaVersion: 'latest', sourceType: 'module' }, @@ -224,6 +297,17 @@ export default [ `:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/])))`, message: SLOT_LOOKUP_ANY_MESSAGE, }, + { + // `ctx.getService('data')` — the type-argument form (#4251). + // No annotation, no `as`, and the contract is erased all the same; + // this is the shape 80 sites actually used while the two selectors + // above matched zero of them. + selector: + `CallExpression[callee.property.name=/^(${SLOT_LOOKUPS})$/]` + + '[typeArguments.params.0.type="TSAnyKeyword"]' + + `:not(:has(Literal[value=/^(${UNCONTRACTED_SLOTS})$/]))`, + message: SLOT_LOOKUP_ANY_MESSAGE, + }, ], }, }, diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts index f4ecfd03ae..414ce912f0 100644 --- a/packages/runtime/src/default-datasource-plugin.test.ts +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -7,6 +7,7 @@ // the real kernel (init-all → start-all) with the real driver factory. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { IDataEngine } from '@objectstack/spec/contracts'; import { Runtime } from './runtime.js'; import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; import { AppPlugin } from './app-plugin.js'; @@ -64,10 +65,10 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# }); try { await kernel.bootstrap(); - const engine = kernel.getService('data'); + const engine = kernel.getService('data'); // The driver keeps its NATURAL name (no 'default' stamping) — routing to // `default` goes through the engine's default-driver fallback. - expect(engine.getDriverByName('default')).toBeUndefined(); + expect(engine.getDriverByName?.('default')).toBeUndefined(); await engine.insert('note', { title: 'through-the-default' }); const rows = await engine.find('note'); expect(rows.map((r: any) => r.title)).toContain('through-the-default'); @@ -93,7 +94,7 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# const kernel = await assemble({ withAdminPlugin: false }); try { await kernel.bootstrap(); - const engine = kernel.getService('data'); + const engine = kernel.getService('data'); await engine.insert('sys_metadata', undefined as never).catch(() => { /* shape probe only */ }); // The default driver exists and the engine can answer a trivial query path. expect(typeof engine.find).toBe('function'); @@ -133,8 +134,8 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# }); try { await expect(kernel.bootstrap()).resolves.not.toThrow(); - const engine = kernel.getService('data'); - expect(engine.getDefaultDriverName()).toBeDefined(); + const engine = kernel.getService('data'); + expect(engine.getDefaultDriverName?.()).toBeDefined(); } finally { try { await (kernel as any)?.stop?.(); } catch { /* noop */ } } @@ -158,11 +159,11 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# }); try { await kernel.bootstrap(); - const engine = kernel.getService('data'); - const defaultName = engine.getDefaultDriverName(); + const engine = kernel.getService('data'); + const defaultName = engine.getDefaultDriverName?.(); expect(defaultName).toBeDefined(); // Identity, not equivalence: the engine's default driver IS the host's instance. - expect(engine.getDriverByName(defaultName)).toBe(hostBuilt); + expect(engine.getDriverByName?.(defaultName!)).toBe(hostBuilt); await engine.insert('note', { title: 'through-the-adopted-default' }); const rows = await engine.find('note'); expect(rows.map((r: any) => r.title)).toContain('through-the-adopted-default'); @@ -197,8 +198,10 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# it('kernel teardown disconnects an OWNED (factory-built) default through the one service (#3993)', async () => { const kernel = await assemble({}); await kernel.bootstrap(); - const engine = kernel.getService('data'); - const drv = engine.getDriverByName(engine.getDefaultDriverName()); + const engine = kernel.getService('data'); + // The real engine carries the driver registry; `!` asserts exactly that, + // and a missing surface fails the test rather than sliding past it. + const drv = engine.getDriverByName!(engine.getDefaultDriverName!()!)!; let disconnects = 0; const orig = drv.disconnect?.bind(drv); drv.disconnect = async () => { disconnects += 1; return orig?.(); }; diff --git a/packages/runtime/src/default-datasource-plugin.ts b/packages/runtime/src/default-datasource-plugin.ts index 8fe105e9d9..d93bff94df 100644 --- a/packages/runtime/src/default-datasource-plugin.ts +++ b/packages/runtime/src/default-datasource-plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { Plugin, PluginContext } from '@objectstack/core'; +import type { IDataEngine, IMetadataService } from '@objectstack/spec/contracts'; import { DatasourceConnectionService, createDefaultDatasourceDriverFactory, @@ -160,7 +161,7 @@ export class DefaultDatasourcePlugin implements Plugin { // call registerDriver again — the engine's skip-if-present guard makes // that a no-op for the same driver name. try { - const engine = ctx.getService('data'); + const engine = ctx.getService('data'); const driverName = engine?.getDefaultDriverName?.(); const driver = driverName ? engine?.getDriverByName?.(driverName) : undefined; if (driver) { @@ -216,17 +217,20 @@ export class DefaultDatasourcePlugin implements Plugin { }; /** - * Two registries, two consumers: - * - `registerInMemory('datasource', …)` feeds the datasource-admin list - * (`metadata.list('datasource')`), so Setup → Datasources shows the - * primary DB — and, via the retained connect verdict, its REAL status - * (#3827). Stamped `origin:'code'` → read-only in the admin UI. - * - `addDatasource(…)` keeps parity with what DriverPlugin.start() used to - * register for legacy `getDatasources()` consumers. + * `registerInMemory('datasource', …)` feeds the datasource-admin list + * (`metadata.list('datasource')`), so Setup → Datasources shows the + * primary DB — and, via the retained connect verdict, its REAL status + * (#3827). Stamped `origin:'code'` → read-only in the admin UI. + * + * A second branch used to probe `metadata.addDatasource(…)` "for legacy + * `getDatasources()` consumers" — typing this lookup (#4251) showed no + * metadata service implements either method, in this repo or its history's + * reach, so the probe never fired and the branch advertised parity it never + * delivered. registerInMemory IS the datasource-visibility path. */ private async registerVisibility(ctx: PluginContext): Promise { try { - const metadata = ctx.getService('metadata'); + const metadata = ctx.getService('metadata'); if (typeof metadata?.registerInMemory === 'function') { metadata.registerInMemory('datasource', 'default', { name: 'default', @@ -235,13 +239,6 @@ export class DefaultDatasourcePlugin implements Plugin { origin: 'code', }); } - if (typeof metadata?.addDatasource === 'function') { - const existing = typeof metadata.getDatasources === 'function' ? metadata.getDatasources() : []; - const hasDefault = Array.isArray(existing) && existing.some((ds: any) => ds?.name === 'default'); - if (!hasDefault) { - await metadata.addDatasource({ name: 'default', driver: this.def.driver }); - } - } } catch (e) { ctx.logger.debug('[DefaultDatasourcePlugin] metadata service unavailable — default not listed', { error: e }); } diff --git a/packages/runtime/src/driver-plugin.ts b/packages/runtime/src/driver-plugin.ts index 9091f18035..ec4e83886a 100644 --- a/packages/runtime/src/driver-plugin.ts +++ b/packages/runtime/src/driver-plugin.ts @@ -16,6 +16,15 @@ import { Plugin, PluginContext } from '@objectstack/core'; * const driverPlugin = new DriverPlugin(memoryDriver, 'memory'); * kernel.use(driverPlugin); */ +/** + * ⚠️ Both options are INERT. They configured start()'s datasource + * registration, which probed `metadata.addDatasource` — a method no metadata + * service implements — so the guarded block never ran on any boot and was + * removed when typing the lookup surfaced it (#4251). The one live caller + * that passes them (`serve.ts`, `datasourceName: 'telemetry'`) has never + * gotten the registration it asks for. Kept only for source compatibility; + * revive-or-remove is tracked in #4320. + */ export interface DriverPluginOptions { /** * If set, registers a named datasource so packages declaring @@ -36,12 +45,13 @@ export class DriverPlugin implements Plugin { version = '1.0.0'; private driver: any; - private options: DriverPluginOptions; - constructor(driver: any, driverNameOrOptions?: string | DriverPluginOptions, options?: DriverPluginOptions) { + // Options are accepted (source compatibility for existing callers) but no + // longer stored — nothing reads them since the dead datasource block left + // start(); see the DriverPluginOptions doc. + constructor(driver: any, driverNameOrOptions?: string | DriverPluginOptions, _options?: DriverPluginOptions) { this.driver = driver; const driverName = typeof driverNameOrOptions === 'string' ? driverNameOrOptions : undefined; - this.options = (typeof driverNameOrOptions === 'object' ? driverNameOrOptions : options) ?? {}; this.name = `com.objectstack.driver.${driverName || driver.name || 'unknown'}`; } @@ -55,33 +65,15 @@ export class DriverPlugin implements Plugin { }); } + // start() used to hold a named/default datasource registration block, + // gated on `metadata.addDatasource` — a method no metadata service + // implements, here or anywhere in the repo — so the guard's early return + // made every line behind it (and the options above) unreachable on every + // boot. Typing the lookup (#4251) surfaced that; the dead block is gone + // rather than typed against a phantom shape. Datasource declaration and + // visibility live in ADR-0062's DatasourceConnectionService + + // `registerInMemory('datasource', …)` path — see DefaultDatasourcePlugin. start = async (ctx: PluginContext) => { - try { - const metadata = ctx.getService('metadata'); - if (!metadata?.addDatasource) return; - - // Register a named datasource for this driver (e.g. 'cloud'). - if (this.options.datasourceName) { - await metadata.addDatasource({ - name: this.options.datasourceName, - driver: this.driver.name, - }); - ctx.logger.info(`[DriverPlugin] Registered named datasource '${this.options.datasourceName}'`, { driver: this.driver.name }); - } - - // Auto-register as 'default' datasource unless explicitly disabled. - if (this.options.registerAsDefault !== false) { - const datasources = metadata.getDatasources ? metadata.getDatasources() : []; - const hasDefault = datasources.some((ds: any) => ds.name === 'default'); - if (!hasDefault) { - ctx.logger.info(`[DriverPlugin] No 'default' datasource found — registering '${this.driver.name}' as default.`); - await metadata.addDatasource({ name: 'default', driver: this.driver.name }); - } - } - } catch (e) { - ctx.logger.debug('[DriverPlugin] Failed to configure datasource (metadata service missing?)', { error: e }); - } - ctx.logger.debug('Driver plugin started', { driverName: this.driver.name || 'unknown' }); } } diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 7f894c4021..20db9e5d3a 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import type { IDataEngine } from '@objectstack/spec/contracts'; import { ObjectKernel, Plugin, PluginContext } from '@objectstack/core'; import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { ObjectQLPlugin } from '@objectstack/objectql'; @@ -164,7 +165,7 @@ describe('in-app notifications over a real hono server (integration, #3362)', () // The receipts were actually persisted as `read` (not merely a view-layer // computation) — the server-side state the console poll re-reads. - const data = kernel.getService('data'); + const data = kernel.getService('data'); const receipts = await data.find('sys_notification_receipt', { where: { user_id: TEST_USER, channel: 'inbox' }, }); diff --git a/packages/spec/src/contracts/data-engine.ts b/packages/spec/src/contracts/data-engine.ts index d52c2e6bdd..f24b6e0344 100644 --- a/packages/spec/src/contracts/data-engine.ts +++ b/packages/spec/src/contracts/data-engine.ts @@ -10,6 +10,7 @@ import { DataEngineRequest, DroppedFieldsEvent, } from '../data/index.js'; +import type { IDataDriver } from './data-driver.js'; /** * In-process write-observability hooks for `insert`/`update` (#3407). @@ -71,4 +72,19 @@ export interface IDataEngine { * Execute raw command (Escape hatch) */ execute?(command: any, options?: Record): Promise; + + /** + * Driver registry — optional: only engines that own a named-driver registry. + * + * [#4251] Declared because the binding is evidenced, not to fill the table: + * ObjectQL implements both (`packages/objectql/src/engine.ts`, the `drivers` + * map), and DefaultDatasourcePlugin reads them to re-register the default + * driver as a `driver.` kernel service — the surface `os migrate` + * (SQL_DRIVER_SERVICES) and serve's storage detection locate drivers + * through. Optional because `IDataEngine` is also satisfied by engines with + * no such registry (test fakes, remote/virtual engines); callers probe with + * `?.`, which is what the runtime caller already did while typed `any`. + */ + getDefaultDriverName?(): string | undefined; + getDriverByName?(name: string): IDataDriver | undefined; } diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 71c6cba803..1de951ccdc 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -166,6 +166,25 @@ export interface IMetadataService { */ register(type: string, name: string, data: unknown, options?: MetadataWriteOptions): Promise; + /** + * Register a metadata item in memory only — never persisted, never + * announced to `subscribe(type, …)` watchers. + * + * [#4251] Declared from the evidenced binding: `MetadataManager` + * implements it (`packages/metadata/src/metadata-manager.ts`) as the + * boot-time seeding primitive for source-control-owned artefacts that must + * be *listable* without leaking into the runtime DB store (`origin:'code'` + * datasources, ADR-0015 Addendum), and DefaultDatasourcePlugin calls it to + * surface the primary DB in Setup → Datasources (#3827). Optional: the + * in-memory registry is `MetadataManager`'s split, not something every + * occupant of the `metadata` slot must carry. + * + * @param type - Metadata type (e.g. 'datasource') + * @param name - Item name/identifier (snake_case) + * @param data - The metadata definition to register + */ + registerInMemory?(type: string, name: string, data: unknown): void; + /** * Get a metadata item by type and name * @param type - Metadata type From 76ca106039289897486590843021cbc5df5e068e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 05:57:26 +0000 Subject: [PATCH 2/3] docs(kernel): document the evidenced contract members added for #4251 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The IDataEngine and IMetadataService contract references enumerate the interface surface member by member, so the two additions (driver registry lookups; registerInMemory) were stale-by-omission — flagged by the docs drift check on #4321. Both interface listings and the per-member sections now match packages/spec/src/contracts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz --- content/docs/kernel/contracts/data-engine.mdx | 19 +++++++++++++++++ .../kernel/contracts/metadata-service.mdx | 21 +++++++++++++++++++ 2 files changed, 40 insertions(+) diff --git a/content/docs/kernel/contracts/data-engine.mdx b/content/docs/kernel/contracts/data-engine.mdx index 7d0ec6f46d..cf8fff765f 100644 --- a/content/docs/kernel/contracts/data-engine.mdx +++ b/content/docs/kernel/contracts/data-engine.mdx @@ -52,6 +52,10 @@ export interface IDataEngine { // Raw Command Escape Hatch (optional) execute?(command: any, options?: Record): Promise; + + // Driver Registry (optional — engines that own named drivers) + getDefaultDriverName?(): string | undefined; + getDriverByName?(name: string): IDataDriver | undefined; } ``` @@ -365,6 +369,21 @@ const result = await engine.execute?.( ); ``` +### getDefaultDriverName / getDriverByName (Driver Registry) + +Look up the engine's registered drivers. Only engines that own a named-driver +registry implement these (ObjectQL does; test fakes and remote/virtual engines +need not) — always probe with `?.`: + +```typescript +const driverName = engine.getDefaultDriverName?.(); +const driver = driverName ? engine.getDriverByName?.(driverName) : undefined; +``` + +This is the surface the runtime uses to re-register the default driver as a +`driver.` kernel service, which is where `os migrate` and serve's storage +detection locate drivers. + --- ## Error Codes diff --git a/content/docs/kernel/contracts/metadata-service.mdx b/content/docs/kernel/contracts/metadata-service.mdx index eaa791400f..617e1b29fe 100644 --- a/content/docs/kernel/contracts/metadata-service.mdx +++ b/content/docs/kernel/contracts/metadata-service.mdx @@ -30,6 +30,7 @@ definition for a type. export interface IMetadataService { // Core CRUD (by type + name) register(type: string, name: string, data: unknown): Promise; + registerInMemory?(type: string, name: string, data: unknown): void; get(type: string, name: string): Promise; list(type: string): Promise; unregister(type: string, name: string): Promise; @@ -166,6 +167,26 @@ await metadataService.unregister('object', 'project'); To inspect what would break before removing an item, call `getDependents('object', 'project')` — it returns the items that reference this one. +### registerInMemory + +Optional. Seeds an item into the in-memory registry **only** — never persisted +to the DB store, never announced to `watch` subscribers. This is the boot-time +path for source-control-owned artefacts that must be *listable* without +creating DB drift — e.g. code-defined datasources (`origin: 'code'`), which is +how the default datasource appears in Setup → Datasources: + +```typescript +metadataService.registerInMemory?.('datasource', 'default', { + name: 'default', + label: 'Default', + driver: 'sqlite', + origin: 'code', +}); +``` + +Callers that mutate metadata mid-run want `register`, which persists and +announces. + --- ## Bulk Operations From 03b22f1f9ebf4b84b073777ba2184b4e2d73495e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 31 Jul 2026 06:27:07 +0000 Subject: [PATCH 3/3] feat(runtime,cli)!: retire the inert DriverPluginOptions (#4320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new DriverPlugin(driver, { datasourceName, registerAsDefault }) never did what it promised: both options configured the start() datasource block that probed metadata.addDatasource — a method no metadata service implements — so they were dead weight on every boot since inception. Routing to a named auxiliary driver never came from the option: it keys off the DRIVER name (init registers driver., ObjectQL's discovery loop adopts it, the engine's lifecycle resolution looks the name up — engine.ts LIFECYCLE_DATASOURCE), which is why serve's telemetry split worked all along despite the option doing nothing. - DriverPlugin constructor narrowed to (driver, driverName?); the options interface (module-local, never exported from the package root) is gone. - serve.ts drops the options argument; the telemetry wiring comment now states the driver-name mechanism explicitly. - Major changeset for @objectstack/runtime carries the FROM -> TO migration; behavior is unchanged by construction (the code the options configured never ran). Verified: full turbo build green (71 packages); runtime 954/67 and cli 628/64 tests pass; pnpm lint clean. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AWW5xEALZNU5VBXfGyWPGz --- .../retire-inert-driver-plugin-options.md | 33 +++++++++++++ packages/cli/src/commands/serve.ts | 10 +++- packages/runtime/src/driver-plugin.ts | 47 ++++++------------- 3 files changed, 56 insertions(+), 34 deletions(-) create mode 100644 .changeset/retire-inert-driver-plugin-options.md diff --git a/.changeset/retire-inert-driver-plugin-options.md b/.changeset/retire-inert-driver-plugin-options.md new file mode 100644 index 0000000000..d552e7b288 --- /dev/null +++ b/.changeset/retire-inert-driver-plugin-options.md @@ -0,0 +1,33 @@ +--- +"@objectstack/runtime": major +"@objectstack/cli": patch +--- + +feat(runtime)!: retire the inert `DriverPluginOptions` — `DriverPlugin` takes `(driver, driverName?)` (#4320) + +`new DriverPlugin(driver, { datasourceName, registerAsDefault })` never did +what it promised: both options configured a datasource-registration block in +`start()` gated on `metadata.addDatasource`, a method **no metadata service +implements** — so the block early-returned on every boot since inception and +the options were dead weight (found while typing service lookups for #4251). + +**Migration** — delete the options argument; nothing changes at runtime +because nothing ever happened: + +- FROM `new DriverPlugin(driver, { datasourceName: 'x', registerAsDefault: false })` + TO `new DriverPlugin(driver)` +- FROM `new DriverPlugin(driver, 'name', options)` TO `new DriverPlugin(driver, 'name')` +- The string second argument (`new DriverPlugin(driver, 'memory')`) is unchanged. + +If you passed `datasourceName` expecting routing to a named auxiliary driver: +that routing never came from the option. It keys off the **driver name** — +`DriverPlugin.init()` registers `driver.`, ObjectQL's discovery loop +adopts it, and the engine's lifecycle/datasource resolution looks the name up +(see the telemetry provision in `os serve` for the pattern: stamp +`driver.name`, register the plugin, done). For Setup → Datasources visibility, +declare the datasource through `DatasourceConnectionService` / +`registerInMemory('datasource', …)` (ADR-0062). + +The `DriverPluginOptions` interface was module-local (never exported from the +package root), so the only public break is the constructor's second/third +argument shape. diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 5ecb472a7f..77137bc284 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -1003,9 +1003,15 @@ export default class Serve extends Command { }); if (telemetry.engine !== 'memory') { // The engine keys datasources by driver name — the - // lifecycle router looks this exact name up. + // lifecycle router looks this exact name up. The driver + // name is the WHOLE wiring: DriverPlugin.init registers + // `driver.telemetry`, ObjectQL's discovery loop adopts + // it, and lifecycle-classed objects route to it. (An + // options bag once also asked for `datasourceName: + // 'telemetry'` metadata registration — inert since + // inception, retired in #4320.) Object.defineProperty(telemetry.driver, 'name', { value: 'telemetry' }); - await kernel.use(new DriverPlugin(telemetry.driver, { datasourceName: 'telemetry', registerAsDefault: false })); + await kernel.use(new DriverPlugin(telemetry.driver)); trackPlugin('TelemetryDatasource'); console.log(chalk.dim(` telemetry datasource: ${telemetryPath} (lifecycle-classed system data; OS_TELEMETRY_DB=0 to disable)`)); } diff --git a/packages/runtime/src/driver-plugin.ts b/packages/runtime/src/driver-plugin.ts index ec4e83886a..70ad87bb8b 100644 --- a/packages/runtime/src/driver-plugin.ts +++ b/packages/runtime/src/driver-plugin.ts @@ -16,29 +16,6 @@ import { Plugin, PluginContext } from '@objectstack/core'; * const driverPlugin = new DriverPlugin(memoryDriver, 'memory'); * kernel.use(driverPlugin); */ -/** - * ⚠️ Both options are INERT. They configured start()'s datasource - * registration, which probed `metadata.addDatasource` — a method no metadata - * service implements — so the guarded block never ran on any boot and was - * removed when typing the lookup surfaced it (#4251). The one live caller - * that passes them (`serve.ts`, `datasourceName: 'telemetry'`) has never - * gotten the registration it asks for. Kept only for source compatibility; - * revive-or-remove is tracked in #4320. - */ -export interface DriverPluginOptions { - /** - * If set, registers a named datasource so packages declaring - * `defaultDatasource: ''` resolve to this driver. - */ - datasourceName?: string; - /** - * If `true` (default), registers this driver as the `default` datasource - * when none exists. Set to `false` for proxy drivers (e.g. cloud proxy) - * that should never become the default. - */ - registerAsDefault?: boolean; -} - export class DriverPlugin implements Plugin { name: string; type = 'driver'; @@ -46,12 +23,17 @@ export class DriverPlugin implements Plugin { private driver: any; - // Options are accepted (source compatibility for existing callers) but no - // longer stored — nothing reads them since the dead datasource block left - // start(); see the DriverPluginOptions doc. - constructor(driver: any, driverNameOrOptions?: string | DriverPluginOptions, _options?: DriverPluginOptions) { + // A `DriverPluginOptions` bag (`datasourceName` / `registerAsDefault`) + // used to be accepted here. Both options configured start()'s datasource + // registration, which probed `metadata.addDatasource` — a method no + // metadata service implements — so they were inert on every boot since + // inception; retired via #4320 (found by #4251). Routing to a named + // auxiliary driver needs only the DRIVER name: init() registers + // `driver.`, ObjectQL's discovery loop adopts it, and the engine's + // lifecycle/datasource resolution keys off that name (see serve.ts's + // telemetry provision for the pattern). + constructor(driver: any, driverName?: string) { this.driver = driver; - const driverName = typeof driverNameOrOptions === 'string' ? driverNameOrOptions : undefined; this.name = `com.objectstack.driver.${driverName || driver.name || 'unknown'}`; } @@ -68,10 +50,11 @@ export class DriverPlugin implements Plugin { // start() used to hold a named/default datasource registration block, // gated on `metadata.addDatasource` — a method no metadata service // implements, here or anywhere in the repo — so the guard's early return - // made every line behind it (and the options above) unreachable on every - // boot. Typing the lookup (#4251) surfaced that; the dead block is gone - // rather than typed against a phantom shape. Datasource declaration and - // visibility live in ADR-0062's DatasourceConnectionService + + // made every line behind it (and the options that configured it) + // unreachable on every boot. Typing the lookup (#4251) surfaced that; the + // dead block is gone rather than typed against a phantom shape, and the + // options followed it (#4320). Datasource declaration and visibility live + // in ADR-0062's DatasourceConnectionService + // `registerInMemory('datasource', …)` path — see DefaultDatasourcePlugin. start = async (ctx: PluginContext) => { ctx.logger.debug('Driver plugin started', { driverName: this.driver.name || 'unknown' });