diff --git a/.changeset/http-contract-unification.md b/.changeset/http-contract-unification.md new file mode 100644 index 0000000000..0896880e44 --- /dev/null +++ b/.changeset/http-contract-unification.md @@ -0,0 +1,54 @@ +--- +"@objectstack/spec": patch +"@objectstack/core": patch +"@objectstack/cloud-connection": patch +"@objectstack/metadata": patch +"@objectstack/plugin-hono-server": patch +--- + +fix(spec,core,cloud-connection,metadata): one HTTP contract, one canonical slot name — and the dead shadow copy that helped cause the false exemption is deleted (#4251) + +**`packages/core/src/contracts/` was a dead near-copy of the real contracts, +and it is gone.** The directory (http-server.ts, data-engine.ts, logger.ts) had +ZERO importers — no relative import, no subpath export, not a tsup entry; +core's barrel has re-exported the `@objectstack/spec/contracts` versions all +along ("Re-export contracts from @objectstack/spec for backward +compatibility"). But the shadow had already **diverged** from the live +contract (spec's `IHttpResponse` grew `write?`/`end?` and `IHttpRequest` grew +`rawBody?`; the copy never did), so anyone who grepped their way into it read a +stale contract that nothing enforces — the exact both-humans-and-AI failure +mode behind the false `http.server` exemption (#4382). Deleting it is +zero-risk by construction: nothing could reach it. + +**`http.server` is the canonical slot name, and the ledger now says so.** +`ServiceSlotContracts` gains `'http.server': IHttpServer` plus the deprecated +`'http-server'` alias entry (same instance — hono-plugin and qa's node-plugin +register both two lines apart; cloud's two server entrypoints do the same). +Canonical is the only name present on EVERY provider path: runtime's +`config.server` path registers no alias, so the three cloud-connection plugins +that read the alias alone (marketplace-proxy, runtime-config, +marketplace-install-local) found an empty slot there — a live miss, now fixed: +all readers go canonical-first with the alias as a fallback that dies with the +alias registrations. The registrations themselves are untouched this release; +both sites now carry the deprecation note. + +**`getRawApp?(): any` joins `IHttpServer`** — the deliberate framework-handle +escape, declared once. Four consumers were each declaring it locally +(cloud-connection ×2, metadata's HMR routes, cloud's serverless node-server); +those local `RawAppHost`/`HttpServerWithRawApp` types are deleted. The `any` +return is deliberate and documented at the single declaration: the handle's +real type belongs to the framework, and naming it would give the contract a +framework dependency. Adapters are not required to expose it; consumers +feature-detect. + +**`IMetadataService.bulkRegister`/`bulkUnregister` declare the write options +their implementation has always accepted.** `bulkRegister`'s contract options +dropped the `MetadataWriteOptions` half its implementation intersects in +(`notify` is destructured on the method's first line); `bulkUnregister` +declared no options at all while the manager takes them. Same shape as the +`IDataEngine` read-methods gap from B2: a caller typed to the contract could +not reach the channel without erasing the lookup. Both additive; no implementor +or caller breaks. + +Slot-lookup baseline ratchets 168 → 167 (marketplace-install-local's lookup +typed while touched). diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index ef5fc5574f..3852a5c039 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -48,6 +48,7 @@ import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; +import type { IHttpServer } from '@objectstack/spec/contracts'; const ROUTE_BASE = '/api/v1/marketplace/install-local'; @@ -120,14 +121,20 @@ export class MarketplaceInstallLocalPlugin implements Plugin { await this.rehydrate(ctx); // 2. Mount HTTP endpoints. - let httpServer: any; - try { - httpServer = ctx.getService('http-server'); - } catch { + // [#4251] Read canonical-first with a REAL per-name fallback: + // `getService` THROWS for an empty slot, so a single try around + // `canonical ?? alias` never reaches the alias — the shape the old + // alias-first read had too, meaning its fallback never once fired. + const readServer = (name: string): IHttpServer | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + // Canonical first — see marketplace-proxy-plugin. + const httpServer = readServer('http.server') ?? readServer('http-server'); + if (!httpServer) { ctx.logger?.warn?.('[MarketplaceInstallLocal] http-server not available — install endpoints not mounted'); return; } - if (!httpServer || typeof httpServer.getRawApp !== 'function') { + if (typeof httpServer.getRawApp !== 'function') { ctx.logger?.warn?.('[MarketplaceInstallLocal] http-server missing getRawApp() — install endpoints not mounted'); return; } diff --git a/packages/cloud-connection/src/marketplace-proxy-plugin.ts b/packages/cloud-connection/src/marketplace-proxy-plugin.ts index 531db3e397..dc23121d61 100644 --- a/packages/cloud-connection/src/marketplace-proxy-plugin.ts +++ b/packages/cloud-connection/src/marketplace-proxy-plugin.ts @@ -35,19 +35,6 @@ import { 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'; /** @@ -196,14 +183,21 @@ export class MarketplaceProxyPlugin implements Plugin { manifest?.register?.(MARKETPLACE_BROWSE_UI_BUNDLE); } catch { /* no manifest service */ } - let httpServer: RawAppHost | undefined; - try { - httpServer = ctx.getService('http-server'); - } catch { + // [#4251] Read canonical-first with a REAL per-name fallback: + // `getService` THROWS for an empty slot, so a single try around + // `canonical ?? alias` never reaches the alias — the shape the old + // alias-first read had too, meaning its fallback never once fired. + const readServer = (name: string): IHttpServer | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + // `http.server` is canonical (the only name every provider + // registers); the alias fallback dies with the alias registrations. + const httpServer = readServer('http.server') ?? readServer('http-server'); + if (!httpServer) { ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server not available — marketplace routes not mounted'); return; } - if (!httpServer || typeof httpServer.getRawApp !== 'function') { + if (typeof httpServer.getRawApp !== 'function') { ctx.logger?.warn?.('[MarketplaceProxyPlugin] http-server missing getRawApp() — 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 150937a3de..e00461c9c9 100644 --- a/packages/cloud-connection/src/runtime-config-plugin.ts +++ b/packages/cloud-connection/src/runtime-config-plugin.ts @@ -43,19 +43,6 @@ 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 @@ -196,14 +183,20 @@ export class RuntimeConfigPlugin implements Plugin { start = async (ctx: PluginContext): Promise => { ctx.hook('kernel:ready', async () => { - let httpServer: RawAppHost | undefined; - try { - httpServer = ctx.getService('http-server'); - } catch { + // [#4251] Read canonical-first with a REAL per-name fallback: + // `getService` THROWS for an empty slot, so a single try around + // `canonical ?? alias` never reaches the alias — the shape the old + // alias-first read had too, meaning its fallback never once fired. + const readServer = (name: string): IHttpServer | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + // Canonical first — see marketplace-proxy-plugin. + const httpServer = readServer('http.server') ?? readServer('http-server'); + if (!httpServer) { ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server not available — runtime/config not mounted'); return; } - if (!httpServer || typeof httpServer.getRawApp !== 'function') { + if (typeof httpServer.getRawApp !== 'function') { ctx.logger?.warn?.('[RuntimeConfigPlugin] http-server missing getRawApp() — runtime/config not mounted'); return; } diff --git a/packages/core/src/contracts/data-engine.ts b/packages/core/src/contracts/data-engine.ts deleted file mode 100644 index 299374154f..0000000000 --- a/packages/core/src/contracts/data-engine.ts +++ /dev/null @@ -1,62 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { - EngineQueryOptions, - DataEngineInsertOptions, - EngineUpdateOptions, - EngineDeleteOptions, - EngineAggregateOptions, - EngineCountOptions, - DataEngineRequest, - DroppedFieldsEvent, -} from '@objectstack/spec/data'; - -/** - * In-process write-observability hooks for `insert`/`update` (#3407). - * Mirror of `WriteObservabilityOptions` in `@objectstack/spec/contracts` — - * see that definition for the full rationale (in-process only; never part of - * the serializable options schemas or the RPC boundary). - */ -export interface WriteObservabilityOptions { - /** Called once per strip pass that dropped ≥1 caller-supplied field. */ - onFieldsDropped?: (event: DroppedFieldsEvent) => void; -} - -/** - * IDataEngine - Standard Data Engine Interface - * - * Abstract interface for data persistence capabilities. - * Following the Dependency Inversion Principle - plugins depend on this interface, - * not on concrete database implementations. - * - * All query methods use standard QueryAST parameter names - * (where/fields/orderBy/limit/offset/expand) to eliminate mechanical translation - * between the Engine and Driver layers. - * - * Aligned with 'src/data/data-engine.zod.ts' in @objectstack/spec. - */ - -export interface IDataEngine { - find(objectName: string, query?: EngineQueryOptions): Promise; - findOne(objectName: string, query?: EngineQueryOptions): Promise; - insert(objectName: string, data: any | any[], options?: DataEngineInsertOptions & WriteObservabilityOptions): Promise; - update(objectName: string, data: any, options?: EngineUpdateOptions & WriteObservabilityOptions): Promise; - delete(objectName: string, options?: EngineDeleteOptions): Promise; - count(objectName: string, query?: EngineCountOptions): Promise; - aggregate(objectName: string, query: EngineAggregateOptions): Promise; - - /** - * Vector Search (AI/RAG) - */ - vectorFind?(objectName: string, vector: number[], options?: { where?: any, limit?: number, fields?: string[], threshold?: number }): Promise; - - /** - * Batch Operations (Transactional) - */ - batch?(requests: DataEngineRequest[], options?: { transaction?: boolean }): Promise; - - /** - * Execute raw command (Escape hatch) - */ - execute?(command: any, options?: Record): Promise; -} diff --git a/packages/core/src/contracts/http-server.ts b/packages/core/src/contracts/http-server.ts deleted file mode 100644 index dcd21390cc..0000000000 --- a/packages/core/src/contracts/http-server.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * IHttpServer - Standard HTTP Server Interface - * - * Abstract interface for HTTP server capabilities. - * This allows plugins to interact with HTTP servers without knowing - * the underlying implementation (Express, Fastify, Hono, etc.). - * - * Follows Dependency Inversion Principle - plugins depend on this interface, - * not on concrete HTTP framework implementations. - */ - -// We use Zod for validation but export interfaces for internal implementation - -/** - * Generic HTTP Request type - * Abstraction over framework-specific request objects - */ -export interface IHttpRequest { - /** Request path parameters */ - params: Record; - /** Request query parameters */ - query: Record; - /** Request body */ - body?: any; - /** Request headers */ - headers: Record; - /** HTTP method */ - method: string; - /** Request path */ - path: string; -} - -/** - * Generic HTTP Response type - * Abstraction over framework-specific response objects - */ -export interface IHttpResponse { - /** - * Send a JSON response - * @param data - Data to send - */ - json(data: any): void | Promise; - - /** - * Send a text/html response - * @param data - Data to send - */ - send(data: string | Uint8Array | ArrayBuffer): void | Promise; - - /** - * Set HTTP status code - * @param code - HTTP status code - */ - status(code: number): IHttpResponse; - - /** - * Set response header - * @param name - Header name - * @param value - Header value (string or array of strings for multi-value headers) - */ - header(name: string, value: string | string[]): IHttpResponse; -} - -/** - * Route handler function - */ -export type RouteHandler = ( - req: IHttpRequest, - res: IHttpResponse -) => void | Promise; - -/** - * Middleware function - */ -export type Middleware = ( - req: IHttpRequest, - res: IHttpResponse, - next: () => void | Promise -) => void | Promise; - -/** - * IHttpServer - HTTP Server capability interface - * - * Defines the contract for HTTP server implementations. - * Concrete implementations (Express, Fastify, Hono) should implement this interface. - */ -export interface IHttpServer { - /** - * Register a GET route handler - * @param path - Route path (e.g., '/api/users/:id') - * @param handler - Route handler function - */ - get(path: string, handler: RouteHandler): void; - - /** - * Register a POST route handler - * @param path - Route path - * @param handler - Route handler function - */ - post(path: string, handler: RouteHandler): void; - - /** - * Register a PUT route handler - * @param path - Route path - * @param handler - Route handler function - */ - put(path: string, handler: RouteHandler): void; - - /** - * Register a DELETE route handler - * @param path - Route path - * @param handler - Route handler function - */ - delete(path: string, handler: RouteHandler): void; - - /** - * Register a PATCH route handler - * @param path - Route path - * @param handler - Route handler function - */ - patch(path: string, handler: RouteHandler): void; - - /** - * Register middleware - * @param path - Optional path to apply middleware to (if omitted, applies globally) - * @param handler - Middleware function - */ - use(path: string | Middleware, handler?: Middleware): void; - - /** - * Start the HTTP server - * @param port - Port number to listen on - * @returns Promise that resolves when server is ready - */ - listen(port: number): Promise; - - /** - * Get the port the server is listening on. - * Returns the actual bound port after `listen()` resolves, or the - * configured port before that. - */ - getPort?(): number; - - /** - * Stop the HTTP server - * @returns Promise that resolves when server is stopped - */ - close?(): Promise; -} diff --git a/packages/core/src/contracts/logger.ts b/packages/core/src/contracts/logger.ts deleted file mode 100644 index 7d50416637..0000000000 --- a/packages/core/src/contracts/logger.ts +++ /dev/null @@ -1,72 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * Logger Contract - * - * Defines the interface for logging in ObjectStack. - * Compatible with both browser console and structured logging systems. - */ -export interface Logger { - /** - * Log a debug message - * @param message - The message to log - * @param meta - Optional metadata to include - */ - debug(message: string, meta?: Record): void; - - /** - * Log an informational message - * @param message - The message to log - * @param meta - Optional metadata to include - */ - info(message: string, meta?: Record): void; - - /** - * Log a warning message - * @param message - The message to log - * @param meta - Optional metadata to include - */ - warn(message: string, meta?: Record): void; - - /** - * Log an error message - * @param message - The message to log - * @param error - Optional error object - * @param meta - Optional metadata to include - */ - error(message: string, error?: Error, meta?: Record): void; - - /** - * Log a fatal error message - * @param message - The message to log - * @param error - Optional error object - * @param meta - Optional metadata to include - */ - fatal?(message: string, error?: Error, meta?: Record): void; - - /** - * Create a child logger with additional context - * @param context - Context to add to all logs from this child - */ - child?(context: Record): Logger; - - /** - * Set trace context for distributed tracing - * @param traceId - Trace identifier - * @param spanId - Span identifier - */ - withTrace?(traceId: string, spanId?: string): Logger; - - /** - * Compatibility method for console.log usage - * @param message - The message to log - * @param args - Additional arguments - */ - log?(message: string, ...args: any[]): void; - - /** - * Cleanup resources (close file streams, etc.) - * Should be called when the logger is no longer needed - */ - destroy?(): Promise; -} diff --git a/packages/metadata/src/plugin.ts b/packages/metadata/src/plugin.ts index c681e0716e..aa34fda90e 100644 --- a/packages/metadata/src/plugin.ts +++ b/packages/metadata/src/plugin.ts @@ -111,8 +111,6 @@ export { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spe import { isAggregatedViewContainer, expandViewContainer } from '@objectstack/spec'; import type { IHttpServer } from '@objectstack/spec/contracts'; -/** `IHttpServer` plus the framework-specific raw-app handle this route needs. */ -type HttpServerWithRawApp = IHttpServer & { getRawApp?(): any }; export interface MetadataPluginOptions { rootDir?: string; @@ -409,14 +407,19 @@ export class MetadataPlugin implements Plugin { // Production deployments simply won't have a CLI POSTing to this // endpoint and won't surface the route to clients. try { - // [#4251] Both names are the SAME instance — plugin-hono-server and - // qa/node-plugin each register it twice, two lines apart. Typed to - // the contract now that the `http.server` exemption is revoked; - // `getRawApp()` is not on `IHttpServer` (framework-agnostic by - // design, the raw app is the framework's own handle), so that one - // member is named here — the third consumer to need it. - const httpServer = ctx.getService('http-server') - ?? ctx.getService('http.server'); + // [#4251] Both names are the SAME instance; `http.server` is the + // canonical one (the only name every provider registers), read + // first. Per-name try — `getService` THROWS for an empty slot, so + // a single try around `canonical ?? alias` never reaches the + // alias: exactly the shape this read had before (alias-first), + // whose fallback therefore never once fired. `getRawApp?()` is on + // the contract now — the deliberate framework-handle escape, + // declared once there instead of per consumer. The alias fallback + // dies with the alias registrations. + const readServer = (name: string): IHttpServer | undefined => { + try { return ctx.getService(name); } catch { return undefined; } + }; + const httpServer = readServer('http.server') ?? readServer('http-server'); if (httpServer && typeof httpServer.getRawApp === 'function') { const { registerMetadataHmrRoutes } = await import('./routes/hmr-routes.js'); const hub = registerMetadataHmrRoutes(httpServer.getRawApp(), this.manager); diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index a13ba4f80b..f336548e39 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -233,10 +233,13 @@ export class HonoServerPlugin implements Plugin { staticRoot: this.options.staticRoot }); - // Register HTTP server service as IHttpServer - // Register as 'http.server' to match core requirements + // Register HTTP server service as IHttpServer — `http.server` is the + // CANONICAL name (the ledger entry in `@objectstack/spec/contracts` + // documents it; runtime's `config.server` path registers only this one). ctx.registerService('http.server', this.server); - // Alias 'http-server' for backward compatibility + // DEPRECATED alias (#4251): same instance, second name. Kept while + // alias readers migrate; dropped at a release boundary — new code must + // read `http.server`. ctx.registerService('http-server', this.server); ctx.logger.debug('HTTP server service registered', { serviceName: 'http.server' }); diff --git a/packages/qa/http-conformance/src/node-plugin.ts b/packages/qa/http-conformance/src/node-plugin.ts index dd8db94a84..d2279efa0f 100644 --- a/packages/qa/http-conformance/src/node-plugin.ts +++ b/packages/qa/http-conformance/src/node-plugin.ts @@ -35,7 +35,8 @@ export class NodeServerPlugin implements Plugin { init = async (ctx: PluginContext) => { // Same service names as the primary adapter, so every consumer that - // resolves 'http.server' / 'http-server' works unchanged. + // resolves 'http.server' (canonical) or its deprecated 'http-server' + // alias (#4251 — dropped with the alias) works unchanged. ctx.registerService('http.server', this.server); ctx.registerService('http-server', this.server); ctx.logger.debug('Node HTTP server service registered', { serviceName: 'http.server' }); diff --git a/packages/spec/src/contracts/core-service-contracts.test.ts b/packages/spec/src/contracts/core-service-contracts.test.ts index de4654a00b..0b0763edae 100644 --- a/packages/spec/src/contracts/core-service-contracts.test.ts +++ b/packages/spec/src/contracts/core-service-contracts.test.ts @@ -15,6 +15,7 @@ import type { IAutomationService } from './automation-service'; import type { INotificationService } from './notification-service'; import type { II18nService } from './i18n-service'; import type { IDataEngine } from './data-engine'; +import type { IHttpServer } from './http-server'; import type { ISecurityService } from './security-service'; import type { IShareLinkService } from './share-link-service'; @@ -77,6 +78,17 @@ describe('slot → contract ledger beyond the enum (#4127 batch 3)', () => { it('maps the evidenced non-core slots', () => { type _Security = Expect, ISecurityService>>; type _ShareLinks = Expect, IShareLinkService>>; + type _Http = Expect, IHttpServer>>; + expect(true).toBe(true); + }); + + it('resolves the deprecated http-server alias to the same contract as http.server', () => { + // [#4251] Same instance, two registration names — hono-plugin and qa's + // node-plugin register both two lines apart, the alias commented "for + // backward compatibility". Two different contracts here would claim two + // services. The alias dies at a release boundary; this pin moves to the + // canonical entry alone when it does. + type _Alias = Expect, ServiceSlotContract<'http.server'>>>; expect(true).toBe(true); }); @@ -89,7 +101,7 @@ describe('slot → contract ledger beyond the enum (#4127 batch 3)', () => { it('holds exactly the non-core slots whose contract is written', () => { const extra: Array> = [ - 'security', 'shareLinks', 'objectql', + 'security', 'shareLinks', 'objectql', 'http.server', 'http-server', ]; const slots = new Set(CoreServiceName.options); for (const key of extra) { @@ -98,7 +110,7 @@ describe('slot → contract ledger beyond the enum (#4127 batch 3)', () => { // into `CoreServiceContracts` and this assertion is what catches it. expect(slots.has(key), `'${key}' is a CoreServiceName — move its entry into CoreServiceContracts`).toBe(false); } - expect(extra).toHaveLength(3); + expect(extra).toHaveLength(5); }); it('still resolves a slot with no written contract to unknown', () => { diff --git a/packages/spec/src/contracts/core-service-contracts.ts b/packages/spec/src/contracts/core-service-contracts.ts index 93264a3c67..c1cb85b7aa 100644 --- a/packages/spec/src/contracts/core-service-contracts.ts +++ b/packages/spec/src/contracts/core-service-contracts.ts @@ -40,6 +40,7 @@ import type { II18nService } from './i18n-service'; import type { IWorkflowService } from './workflow-service'; import type { ISecurityService } from './security-service'; import type { IShareLinkService } from './share-link-service'; +import type { IHttpServer } from './http-server'; /** * The evidenced slot → contract bindings. @@ -132,6 +133,29 @@ export interface ServiceSlotContracts extends CoreServiceContracts { * `data` resolved to `IDataEngine` and `objectql` to `any`, for one object. */ objectql: IDataEngine; + /** + * The HTTP server slot — **`http.server` is the canonical name**. + * + * [#4251] Entered when the false `UNCONTRACTED_SLOTS` exemption was + * revoked: the exemption claimed "no IHttpServer contract exists" while + * this contract existed and eight call sites already resolved the slot + * through it. Every provider registers the canonical name — hono-plugin's + * own registration comment reads "Register HTTP server service as + * IHttpServer" — and it is the ONLY name present on all provider paths + * (`runtime.ts`'s `config.server` path registers no alias). + */ + 'http.server': IHttpServer; + /** + * **Deprecated alias** of {@link ServiceSlotContracts['http.server']} — + * the same instance under a second name. plugin-hono-server and qa's + * node-plugin register both, two lines apart, the alias explicitly + * commented "for backward compatibility"; `runtime.ts`'s `config.server` + * path registers ONLY the canonical name, so a reader of this alias alone + * finds an empty slot there. New code reads `http.server`; readers of the + * alias migrate as touched, and the alias registrations are dropped at a + * release boundary once none remain. + */ + 'http-server': IHttpServer; } /** diff --git a/packages/spec/src/contracts/http-server.ts b/packages/spec/src/contracts/http-server.ts index f2bcea5e40..35b4ab1a2b 100644 --- a/packages/spec/src/contracts/http-server.ts +++ b/packages/spec/src/contracts/http-server.ts @@ -186,4 +186,22 @@ export interface IHttpServer { * value is unspecified. */ getPort?(): number; + + /** + * The underlying framework's own app object (Hono's `Hono`, Express's + * `Express`, …) — THE deliberate framework-specific escape hatch on this + * otherwise framework-agnostic contract. + * + * [#4251] Declared here because four independent consumers were each + * declaring it locally (`IHttpServer & { getRawApp?(): any }` in + * cloud-connection ×2, metadata's HMR routes, and cloud's serverless + * node-server) — four copies of one truth, which is the failure mode that + * produced the false `http.server` lint exemption. The return type is + * `any` ON PURPOSE and only here: the handle's real type belongs to the + * framework, and naming it would give this contract a framework + * dependency. Consumers that mount framework-native routes feature-detect + * this member and degrade when it is absent — an adapter is NOT required + * to expose its internals. + */ + getRawApp?(): any; } diff --git a/packages/spec/src/contracts/metadata-service.ts b/packages/spec/src/contracts/metadata-service.ts index 1de951ccdc..d781e4f06e 100644 --- a/packages/spec/src/contracts/metadata-service.ts +++ b/packages/spec/src/contracts/metadata-service.ts @@ -328,18 +328,31 @@ export interface IMetadataService { /** * Register multiple metadata items in a single batch. * More efficient than individual register calls. + * + * [#4251] `options` also carries {@link MetadataWriteOptions} — the + * implementation has always intersected it in (`notify` is destructured on + * the first line of `MetadataManager.bulkRegister`), but the contract + * declared only the bulk knobs, so a caller typed to the contract could + * not reach the write options at all. Same shape as the `IDataEngine` + * read-methods gap: the erasures this issue sweeps are sometimes the + * contract's own doing. * @param items - Array of { type, name, data } to register - * @param options - Bulk operation options + * @param options - Bulk operation knobs plus the standard write options * @returns Bulk operation result with success/failure counts */ - bulkRegister?(items: Array<{ type: string; name: string; data: unknown }>, options?: { continueOnError?: boolean; validate?: boolean }): Promise; + bulkRegister?(items: Array<{ type: string; name: string; data: unknown }>, options?: { continueOnError?: boolean; validate?: boolean } & MetadataWriteOptions): Promise; /** * Unregister multiple metadata items in a single batch. + * + * [#4251] `options` declared from the implementation's signature — + * `bulkRegister` beside it always had an options parameter while this one + * had none, and `MetadataManager.bulkUnregister` accepts it. * @param items - Array of { type, name } to unregister + * @param options - Standard write options * @returns Bulk operation result */ - bulkUnregister?(items: Array<{ type: string; name: string }>): Promise; + bulkUnregister?(items: Array<{ type: string; name: string }>, options?: MetadataWriteOptions): Promise; // ========================================== // Overlay / Customization Management diff --git a/scripts/slot-lookup-baseline.json b/scripts/slot-lookup-baseline.json index d072e6963e..522a35434b 100644 --- a/scripts/slot-lookup-baseline.json +++ b/scripts/slot-lookup-baseline.json @@ -4,7 +4,7 @@ "packages/cli/src/commands/serve.ts": 10, "packages/client/src/client.hono.test.ts": 2, "packages/cloud-connection/src/cloud-connection-plugin.ts": 5, - "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 17, + "packages/cloud-connection/src/marketplace-install-local-plugin.ts": 16, "packages/core/examples/kernel-features-example.ts": 5, "packages/metadata-protocol/src/plugin.ts": 1, "packages/objectql/src/plugin.integration.test.ts": 23,