diff --git a/.changeset/endpoint-matcher-lazy-index.md b/.changeset/endpoint-matcher-lazy-index.md new file mode 100644 index 0000000000..aa5ecb9fc6 --- /dev/null +++ b/.changeset/endpoint-matcher-lazy-index.md @@ -0,0 +1,37 @@ +--- +"@objectstack/metadata": minor +--- + +feat(metadata): 端点匹配器 —— `MetadataManager.matchEndpoint` 惰性索引实现 (#5089) + +`IMetadataService.matchEndpoint?` 的契约在 #5080/#5097 落地(声明先行),本变更补上 +`metadata` 槽位占位者 `MetadataManager` 的实现:把已声明的 `api` 元数据条目编成 +**METHOD → 精确路径 → 端点** 的惰性索引,供 HTTP 分发器在「没有内建域认领这条路径」 +与「回答语义 404」之间做一次查表。这是 #5040 端点执行器程序的 E2 单。 + +**结构性不可达,零行为变更。** 17.x 里没有任何东西会调用 `matchEndpoint`:挂载 seam +是 #5090 的面,而 publish/validate 对非空 `apis:` 仍然硬拒(#4936)。新代码在真实组合 +里不暴露任何 HTTP 行为;测试直接驱动服务,这正是 #5040 设计选定的验收姿态。 + +实现要点(逐字实现契约文本,`packages/spec/src/contracts/metadata-service.ts`): + +- **匹配维度**:`method` 大写规整后比较(请求动词大小写不敏感);`path` 去掉**一个** + 尾斜杠后**整串精确**比较,两侧同规则。17.x 不做百分号解码、不做 Unicode 规整、 + 不做大小写折叠 —— 原串即键。词表(ADR-0121)未定义任何路径模板语法,因此 + `params` **恒为 `{}`**;此处不发明只存在于实现里的方言。 +- **答案是 parse 后的形状**:每条经 `ApiEndpointSchema.safeParse`,默认值已物化 —— + 作者省略 `authRequired` 时消费方拿到的是 `true`,不可能把「缺省」误读为放行。 +- **坏条目响亮缺席**:解析失败的存量条目被跳过并以 `error` 级点名(说明该路由将回 404 + 及如何修),绝不返回半合法形状,也绝不牵连同批的好条目。 +- **重复声明确定性收敛**:两条条目声明同一 METHOD+path 时,`name` 字典序在前者保留 + 路由,被弃者连同规则一并 `error` 级点名 —— 不是静默 last-write-wins,每个节点、每次 + 启动的解析结果一致。 +- **断存储抛错,不伪装 404**:`undefined` 只表示「无声明拥有这条路由」;读不到存储时 + 抛出(与 `loadDiagnosed` 的 miss/outage 之分同源,ADR-0110 D3),因为 miss 会变成 + 404,而故障不得伪装成 404。构建失败不缓存,下次调用重试。 +- **失效**:挂在仓内既有机制上,不新造事件系统 —— `invalidateListCache('api')` 覆盖 + 全部本地写入(含 artifact 装载 / HMR 的 `{ notify: false }` 写入,这些按构造不经过 + watcher),`subscribe('api', …)` 覆盖集群对端回放(它只经 `notifyWatchersLocal`)。 + 失效后下次调用整体重建。 + +`ApiEndpointSchema` 与 `packages/spec` 未做任何改动(词表冻结)。 diff --git a/packages/metadata/src/endpoint-matcher.test.ts b/packages/metadata/src/endpoint-matcher.test.ts new file mode 100644 index 0000000000..67d15aa06f --- /dev/null +++ b/packages/metadata/src/endpoint-matcher.test.ts @@ -0,0 +1,329 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5089 (#5040 E2) — `matchEndpoint`: the declared-endpoint matcher. + * + * The binding specification is the contract text on + * `IMetadataService.matchEndpoint` / `ApiEndpointMatch` + * (`packages/spec/src/contracts/metadata-service.ts`, landed by #5080/#5097). + * Every `it()` below pins one clause of it, so a future edit that softens the + * contract fails here and not in production: + * + * • method compared case-insensitively; + * • path compared as a WHOLE STRING after trimming a trailing slash, with no + * percent-decoding / Unicode normalization / case folding in 17.x; + * • the answer is `ApiEndpointSchema.parse`-d — defaults MATERIALIZED, so an + * omitted `authRequired` comes back `true`; + * • a stored item that fails to parse is skipped LOUDLY and never breaks the + * good items around it; + * • `undefined` is a miss; a store that cannot be read THROWS, because a + * miss becomes a 404 and an outage must not masquerade as one; + * • `params` is always `{}` — 17.x defines no path-template syntax; + * • a duplicate METHOD+path claim resolves deterministically and loudly. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Logger } from '@objectstack/spec/contracts'; +import { + EndpointMatcher, + buildEndpointIndex, + endpointIndexKey, + normalizeEndpointMethod, + normalizeEndpointPath, +} from './endpoint-matcher.js'; + +function makeLogger(): Logger & { error: ReturnType } { + return { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as unknown as Logger & { error: ReturnType }; +} + +/** A minimal, valid `ApiEndpointSchema` input. `authRequired` deliberately omitted. */ +function endpoint(over: Record = {}): Record { + return { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + ...over, + }; +} + +describe('normalization helpers', () => { + it('upper-cases the method', () => { + expect(normalizeEndpointMethod('get')).toBe('GET'); + expect(normalizeEndpointMethod('PoSt')).toBe('POST'); + }); + + it('trims exactly ONE trailing slash', () => { + expect(normalizeEndpointPath('/a/b/')).toBe('/a/b'); + expect(normalizeEndpointPath('/a/b')).toBe('/a/b'); + // one, not all — `/x//` and `/x/` are different paths to every router here + expect(normalizeEndpointPath('/a/b//')).toBe('/a/b/'); + }); + + it('never trims a lone "/" (so a query for "" cannot collide with it)', () => { + expect(normalizeEndpointPath('/')).toBe('/'); + expect(normalizeEndpointPath('')).toBe(''); + }); + + it('does NOT percent-decode, case-fold or Unicode-normalize (17.x)', () => { + expect(normalizeEndpointPath('/a%2Fb')).toBe('/a%2Fb'); + expect(normalizeEndpointPath('/Tasks')).toBe('/Tasks'); + // NFD "é" stays NFD — no NFC folding + expect(normalizeEndpointPath('/café')).toBe('/café'); + }); + + it('keys as "METHOD path"', () => { + expect(endpointIndexKey('get', '/x/')).toBe('GET /x'); + }); +}); + +describe('buildEndpointIndex', () => { + it('builds a METHOD → exact-path → parsed-endpoint index', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [endpoint(), endpoint({ name: 'create_task', method: 'POST', path: '/api/v1/apps/showcase/tasks' })], + logger, + ); + + expect([...index.keys()].sort()).toEqual([ + 'GET /api/v1/apps/showcase/tasks', + 'POST /api/v1/apps/showcase/tasks', + ]); + expect(logger.error).not.toHaveBeenCalled(); + }); + + it('trims a stored declaration\'s trailing slash when indexing it', () => { + const index = buildEndpointIndex([endpoint({ path: '/api/v1/apps/showcase/tasks/' })], makeLogger()); + expect(index.has('GET /api/v1/apps/showcase/tasks')).toBe(true); + }); + + it('materializes schema defaults — an omitted authRequired is `true`', () => { + const index = buildEndpointIndex([endpoint()], makeLogger()); + expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(true); + }); + + it('preserves an explicit authRequired: false', () => { + const index = buildEndpointIndex([endpoint({ authRequired: false })], makeLogger()); + expect(index.get('GET /api/v1/apps/showcase/tasks')!.authRequired).toBe(false); + }); + + it('strips storage annotations (_lock / packageId) rather than choking on them', () => { + const index = buildEndpointIndex( + [{ ...endpoint(), _lock: { managed: true }, _packageId: 'pkg_showcase' }], + makeLogger(), + ); + const hit = index.get('GET /api/v1/apps/showcase/tasks')!; + expect(hit).toBeDefined(); + expect(hit as Record).not.toHaveProperty('_lock'); + }); +}); + +describe('parse failure — loud skip, no collateral damage', () => { + it('skips an unparseable stored item, logs it at error level, and keeps the good ones', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [ + { name: 'broken_ep', path: '/api/v1/apps/showcase/broken' }, // no method / type / target + endpoint(), + ], + logger, + ); + + expect(index.has('GET /api/v1/apps/showcase/tasks')).toBe(true); + expect(index.size).toBe(1); + expect(logger.error).toHaveBeenCalledTimes(1); + const [message] = logger.error.mock.calls[0]; + expect(message).toContain('broken_ep'); + expect(message).toContain('ApiEndpointSchema'); + }); + + it('names an item that has no usable name as instead of throwing', () => { + const logger = makeLogger(); + const index = buildEndpointIndex([null, 42, { path: '/x' }], logger); + expect(index.size).toBe(0); + expect(logger.error).toHaveBeenCalledTimes(3); + expect(logger.error.mock.calls[0][0]).toContain(''); + }); +}); + +describe('duplicate METHOD+path claims — deterministic and loud', () => { + it('keeps the lexicographically-first `name` and names the ignored claimant', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [ + endpoint({ name: 'zeta_tasks', target: 'z' }), + endpoint({ name: 'alpha_tasks', target: 'a' }), + ], + logger, + ); + + expect(index.get('GET /api/v1/apps/showcase/tasks')!.name).toBe('alpha_tasks'); + expect(logger.error).toHaveBeenCalledTimes(1); + const [message, , meta] = logger.error.mock.calls[0]; + expect(message).toContain('duplicate endpoint claim'); + expect(meta).toMatchObject({ winner: 'alpha_tasks', ignored: 'zeta_tasks' }); + }); + + it('resolves identically regardless of the order items arrive in', () => { + const forward = buildEndpointIndex( + [endpoint({ name: 'alpha_tasks' }), endpoint({ name: 'zeta_tasks' })], + makeLogger(), + ); + const reverse = buildEndpointIndex( + [endpoint({ name: 'zeta_tasks' }), endpoint({ name: 'alpha_tasks' })], + makeLogger(), + ); + expect(forward.get('GET /api/v1/apps/showcase/tasks')!.name) + .toBe(reverse.get('GET /api/v1/apps/showcase/tasks')!.name); + }); + + it('does NOT treat different methods on the same path as a duplicate', () => { + const logger = makeLogger(); + const index = buildEndpointIndex( + [endpoint({ name: 'a_get' }), endpoint({ name: 'b_post', method: 'POST' })], + logger, + ); + expect(index.size).toBe(2); + expect(logger.error).not.toHaveBeenCalled(); + }); +}); + +describe('EndpointMatcher.match', () => { + let logger: ReturnType; + let items: unknown[]; + let reads: number; + let matcher: EndpointMatcher; + + beforeEach(() => { + logger = makeLogger(); + items = [endpoint()]; + reads = 0; + matcher = new EndpointMatcher({ + listApiItems: async () => { + reads++; + return items; + }, + logger, + }); + }); + + it('hits an exactly-declared route', async () => { + const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }); + expect(match?.endpoint.name).toBe('list_tasks'); + }); + + it('returns `params: {}` — 17.x has no path-template syntax', async () => { + const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }); + expect(match?.params).toEqual({}); + }); + + it('compares the method case-insensitively', async () => { + for (const verb of ['get', 'Get', 'gEt', 'GET']) { + const match = await matcher.match({ method: verb, path: '/api/v1/apps/showcase/tasks' }); + expect(match?.endpoint.name).toBe('list_tasks'); + } + }); + + it('trims a trailing slash on the QUERY side too', async () => { + const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks/' }); + expect(match?.endpoint.name).toBe('list_tasks'); + }); + + it('trims on BOTH sides consistently (stored with slash, queried without)', async () => { + items = [endpoint({ path: '/api/v1/apps/showcase/tasks/' })]; + matcher.invalidate(); + const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }); + expect(match?.endpoint.name).toBe('list_tasks'); + }); + + it('misses on an undeclared path — undefined, not an error', async () => { + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/nope' })) + .resolves.toBeUndefined(); + }); + + it('misses on a declared path with an undeclared method', async () => { + await expect(matcher.match({ method: 'DELETE', path: '/api/v1/apps/showcase/tasks' })) + .resolves.toBeUndefined(); + }); + + it('misses on a case-differing path — 17.x does NOT case-fold the path', async () => { + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/Tasks' })) + .resolves.toBeUndefined(); + }); + + it('misses on a percent-encoded spelling — 17.x does NOT decode the path', async () => { + items = [endpoint({ path: '/api/v1/apps/showcase/my tasks' })]; + matcher.invalidate(); + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/my%20tasks' })) + .resolves.toBeUndefined(); + }); + + it('a prefix of a declared path is not a match — the whole string is the key', async () => { + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase' })) + .resolves.toBeUndefined(); + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks/42' })) + .resolves.toBeUndefined(); + }); + + it('builds the index lazily — once, then reuses it', async () => { + expect(reads).toBe(0); + await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }); + await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }); + await matcher.match({ method: 'GET', path: '/nope' }); + expect(reads).toBe(1); + }); + + it('shares one store read across concurrent first calls', async () => { + await Promise.all([ + matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }), + matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }), + matcher.match({ method: 'GET', path: '/nope' }), + ]); + expect(reads).toBe(1); + }); + + it('rebuilds after invalidate(), picking up the new declaration', async () => { + expect(await matcher.match({ method: 'POST', path: '/api/v1/apps/showcase/tasks' })).toBeUndefined(); + items = [...items, endpoint({ name: 'create_task', method: 'POST' })]; + matcher.invalidate(); + const match = await matcher.match({ method: 'POST', path: '/api/v1/apps/showcase/tasks' }); + expect(match?.endpoint.name).toBe('create_task'); + expect(reads).toBe(2); + }); +}); + +describe('a store that cannot be read THROWS — an outage is not a 404', () => { + it('propagates the read failure instead of reporting a miss', async () => { + const matcher = new EndpointMatcher({ + listApiItems: async () => { + throw new Error('sys_metadata unreachable'); + }, + logger: makeLogger(), + }); + + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' })) + .rejects.toThrow('sys_metadata unreachable'); + }); + + it('does not cache the failure — a recovered store serves on the next call', async () => { + let healthy = false; + const matcher = new EndpointMatcher({ + listApiItems: async () => { + if (!healthy) throw new Error('sys_metadata unreachable'); + return [endpoint()]; + }, + logger: makeLogger(), + }); + + await expect(matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' })).rejects.toThrow(); + healthy = true; + const match = await matcher.match({ method: 'GET', path: '/api/v1/apps/showcase/tasks' }); + expect(match?.endpoint.name).toBe('list_tasks'); + }); +}); diff --git a/packages/metadata/src/endpoint-matcher.ts b/packages/metadata/src/endpoint-matcher.ts new file mode 100644 index 0000000000..f25e772314 --- /dev/null +++ b/packages/metadata/src/endpoint-matcher.ts @@ -0,0 +1,243 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Endpoint matcher — the producer side of `IMetadataService.matchEndpoint`. + * + * [#5089, #5040 E2] A declared `api` metadata item owns a `method`+`path` + * pair; this module turns the stored `api` items into a lookup the HTTP + * dispatcher can perform once per request, between "no built-in domain + * claimed this path" and "answer a semantic 404". + * + * The binding specification is the contract text on + * `IMetadataService.matchEndpoint` and {@link ApiEndpointMatch} in + * `packages/spec/src/contracts/metadata-service.ts` (landed by #5080/#5097). + * This module implements it literally; where the text is silent the choice is + * documented here rather than invented in a caller. + * + * ## What this module is NOT + * + * It is not a router. `ApiEndpointSchema.path` is a frozen vocabulary + * (ADR-0121) that defines no template syntax — no `:param`, no `{param}` — so + * there is nothing to compile and nothing to rank. Matching is a `Map` lookup + * on an exact key, and {@link ApiEndpointMatch.params} is always `{}`. + * Inventing a template syntax here would create a dialect that exists only + * inside an implementation, which Prime Directive #12 forbids. + * + * ## Normalization (both sides, identically) + * + * - **method** — upper-cased. `ApiEndpointSchema.method` is already an + * upper-case `HttpMethod` enum, so this only ever changes the *query* side, + * which is the point: a request verb is compared case-insensitively. + * - **path** — exactly ONE trailing slash is trimmed, and never from a lone + * `/`. Trimming one (not all) keeps `/x//` and `/x/` distinct, matching how + * every router in this stack treats an empty path segment; keeping `/` + * whole means the normalized form is still a legal `ApiEndpointSchema.path` + * and a query for `""` can never collide with a declaration of `/`. + * Nothing else happens to the path: **no** percent-decoding, **no** Unicode + * normalization, **no** case folding. 17.x compares the raw string. (Design + * §7-5 keeps RFC 3986 canonicalization as an explicit open question — it is + * a vocabulary-level decision, not an implementation detail to smuggle in.) + * + * ## Loud, never half-valid + * + * Every stored item is `ApiEndpointSchema.safeParse`-d. The answer handed back + * is the PARSED object, so schema defaults are materialized — most importantly + * `authRequired`, which the schema defaults to `true`: a consumer can never + * read an author's omission as "no auth required". An item that fails to parse + * is skipped and named at `error` level: an endpoint the author declared and + * the runtime will not serve is a capability that silently went missing, and + * "absence must be loud" (AGENTS.md, Route & surface ownership §3). Skipping + * one bad item never disturbs the good ones. + * + * ## Duplicate claims + * + * Two stored items may claim the same METHOD+path (publish rejects that inside + * one stack, but a direct `metadata.register()` write bypasses publish). The + * resolution is deterministic and announced, never last-write-wins: the + * endpoint whose `name` sorts FIRST lexicographically keeps the route, and the + * discarded claimant is named at `error` level together with the winner and + * the rule. Deterministic because a coin flip would make the same deployment + * behave differently per node and per boot; loud because a declaration that + * does not serve is exactly the "declared ≠ enforced" state this repo keeps + * paying to remove. (#5040 design §1.3.) + * + * ## An outage is not a 404 + * + * `undefined` means "no declaration owns this route". A store that cannot be + * read must THROW — the same distinction `loadDiagnosed` draws for the + * singular read (ADR-0110 D3), for the same reason: a miss becomes a 404, and + * an unreachable metadata store must never masquerade as one. So the read this + * matcher performs is deliberately NOT wrapped in a `try`/`catch`, and a + * failed build is not cached — the next call retries against a store that may + * have recovered. + */ + +import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; +import type { Logger } from '@objectstack/spec/contracts'; + +/** + * Upper-case a request verb so `method` compares case-insensitively. + */ +export function normalizeEndpointMethod(method: string): string { + return String(method ?? '').toUpperCase(); +} + +/** + * Trim exactly one trailing slash, never from a lone `/`. + * + * Applied to BOTH the stored declaration and the query, so the two sides can + * never disagree about which form is canonical. + */ +export function normalizeEndpointPath(path: string): string { + const raw = String(path ?? ''); + if (raw.length > 1 && raw.endsWith('/')) return raw.slice(0, -1); + return raw; +} + +/** The index key for a normalized method+path pair. */ +export function endpointIndexKey(method: string, path: string): string { + return `${normalizeEndpointMethod(method)} ${normalizeEndpointPath(path)}`; +} + +/** The lazily-built lookup: `"METHOD /path"` → parsed endpoint. */ +export type EndpointIndex = ReadonlyMap; + +export interface EndpointMatcherDeps { + /** + * Enumerate the stored `api` metadata items. + * + * MUST reject when the store cannot be read. Resolving with `[]` on a failed + * read would turn an outage into "nothing is declared", i.e. into a 404 — + * precisely what the contract forbids. See `MetadataManager.listForIndex`. + */ + listApiItems(): Promise; + logger: Logger; +} + +/** + * Build the METHOD→path→endpoint index from raw stored items. + * + * Exported for tests and for any future occupant of the `metadata` slot that + * wants the same load-time discipline without inheriting `MetadataManager`. + */ +export function buildEndpointIndex(items: readonly unknown[], logger: Logger): EndpointIndex { + const index = new Map(); + + for (const item of items) { + const parsed = ApiEndpointSchema.safeParse(item); + if (!parsed.success) { + // LOUD skip (contract: "MUST skip (loudly) any stored item that fails to + // parse rather than returning a half-valid shape"). Name the item so the + // author can find it; say what the consequence is. + const declaredName = + item && typeof item === 'object' && typeof (item as { name?: unknown }).name === 'string' + ? (item as { name: string }).name + : ''; + logger.error( + `[EndpointMatcher] stored api item '${declaredName}' does not satisfy ApiEndpointSchema — ` + + `it is EXCLUDED from endpoint matching and its declared route will answer 404. ` + + `Fix the declaration (or remove it); the endpoint index never serves a half-valid shape.`, + undefined, + { issues: parsed.error.issues }, + ); + continue; + } + + const endpoint = parsed.data; + const key = endpointIndexKey(endpoint.method, endpoint.path); + const incumbent = index.get(key); + + if (!incumbent) { + index.set(key, endpoint); + continue; + } + + // Deterministic + loud: lexicographically-first `name` keeps the route. + // `<` (not `<=`) keeps the first-seen entry when names are equal, so the + // rule is total even in the degenerate case. + const challengerWins = endpoint.name < incumbent.name; + const winner = challengerWins ? endpoint : incumbent; + const loser = challengerWins ? incumbent : endpoint; + if (challengerWins) index.set(key, endpoint); + + logger.error( + `[EndpointMatcher] duplicate endpoint claim on '${key}': api items '${incumbent.name}' and ` + + `'${endpoint.name}' both declare it. '${winner.name}' KEEPS the route and '${loser.name}' is ` + + `IGNORED — the rule is lexicographically-first \`name\` wins, chosen so every node and every ` + + `boot resolves it identically. Rename or repath '${loser.name}' to make it reachable.`, + undefined, + { key, winner: winner.name, ignored: loser.name }, + ); + } + + return index; +} + +/** + * Lazy, invalidating endpoint index. + * + * Built on the first {@link match} call and rebuilt on the next call after any + * {@link invalidate}. Endpoint counts are single- to triple-digit, so a whole + * rebuild is cheaper than per-item bookkeeping and cannot drift from the store. + */ +export class EndpointMatcher { + private readonly deps: EndpointMatcherDeps; + /** Resolved index, or `undefined` while dirty. */ + private index?: EndpointIndex; + /** In-flight build, so concurrent requests share one store read. */ + private building?: Promise; + + constructor(deps: EndpointMatcherDeps) { + this.deps = deps; + } + + /** Mark the index stale; the next {@link match} rebuilds it. */ + invalidate(): void { + this.index = undefined; + this.building = undefined; + } + + /** + * Resolve `method`+`path` to the owning declaration. + * + * @returns the parsed endpoint plus `params: {}`, or `undefined` on a miss. + * @throws whatever the store read threw — an outage is never a miss. + */ + async match(query: { path: string; method: string }): Promise { + const index = await this.ensureIndex(); + const endpoint = index.get(endpointIndexKey(query.method, query.path)); + if (!endpoint) return undefined; + // `params` is always {} in 17.x — the frozen vocabulary defines no path + // template syntax. See ApiEndpointMatch.params. + return { endpoint, params: {} }; + } + + private async ensureIndex(): Promise { + if (this.index) return this.index; + if (this.building) return this.building; + + const build = (async () => { + // Deliberately NOT guarded: a store read failure propagates to the + // caller so an outage surfaces as an outage, never as a 404. + const items = await this.deps.listApiItems(); + return buildEndpointIndex(items, this.deps.logger); + })(); + + this.building = build; + try { + const built = await build; + // Only publish the result if no invalidation raced us mid-build. + if (this.building === build) { + this.index = built; + this.building = undefined; + } + return built; + } catch (error) { + // A failed build is never cached — the next request retries against a + // store that may have recovered. + if (this.building === build) this.building = undefined; + throw error; + } + } +} diff --git a/packages/metadata/src/metadata-manager-match-endpoint.test.ts b/packages/metadata/src/metadata-manager-match-endpoint.test.ts new file mode 100644 index 0000000000..0e9885a825 --- /dev/null +++ b/packages/metadata/src/metadata-manager-match-endpoint.test.ts @@ -0,0 +1,226 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #5089 (#5040 E2) — `MetadataManager.matchEndpoint`, the `metadata` slot's + * occupant wiring of the endpoint matcher. + * + * `endpoint-matcher.test.ts` pins the matching semantics against the contract + * text. This file pins the WIRING that the unit tests cannot see: + * + * • the index is built from `api`-type items (registry + loaders), lazily; + * • every path that mutates the stored set invalidates it — including the + * `{ notify: false }` writes the artifact ingest and HMR reload use, which + * by construction never reach a `subscribe()` watcher, AND a cluster + * peer's write, which reaches watchers ONLY; + * • a loader that cannot be read makes `matchEndpoint` THROW rather than + * report a miss (`list()` deliberately warns-and-continues; the endpoint + * read deliberately does not, because its miss becomes a 404). + * + * Nothing here is reachable over HTTP in 17.x: the dispatcher seam is #5090's + * and publish still rejects a non-empty `apis:` (#4936). These tests drive the + * service directly, which is the acceptance posture the #5040 design chose + * ("结构性不可达"): the code is exercised without being exposed. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { MetadataManager } from './metadata-manager.js'; +import { MemoryLoader } from './loaders/memory-loader.js'; +import type { MetadataLoader } from './loaders/loader-interface.js'; + +vi.mock('@objectstack/core', () => ({ + createLogger: () => ({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }), +})); + +function endpoint(over: Record = {}): Record { + return { + name: 'list_tasks', + path: '/api/v1/apps/showcase/tasks', + method: 'GET', + type: 'object_operation', + target: 'showcase_task', + ...over, + }; +} + +const TASKS = { method: 'GET', path: '/api/v1/apps/showcase/tasks' }; + +describe('#5089 — MetadataManager.matchEndpoint', () => { + let manager: MetadataManager; + + beforeEach(() => { + manager = new MetadataManager({ formats: ['json'], loaders: [new MemoryLoader()] }); + }); + + it('is present on the manager — consumers probe it with `typeof === "function"`', () => { + expect(typeof manager.matchEndpoint).toBe('function'); + }); + + it('answers `undefined` when nothing is declared', async () => { + await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); + }); + + it('resolves a registered api item, parsed (authRequired defaulted to true)', async () => { + await manager.register('api', 'list_tasks', endpoint()); + + const match = await manager.matchEndpoint(TASKS); + expect(match?.endpoint.name).toBe('list_tasks'); + expect(match?.endpoint.authRequired).toBe(true); + expect(match?.params).toEqual({}); + }); + + it('resolves an item that only a loader holds (not the in-memory registry)', async () => { + const loader = new MemoryLoader(); + await loader.save('api', 'list_tasks', endpoint()); + manager.registerLoader(loader); + + const match = await manager.matchEndpoint(TASKS); + expect(match?.endpoint.name).toBe('list_tasks'); + }); + + it('normalizes the request verb — a lower-case method still hits', async () => { + await manager.register('api', 'list_tasks', endpoint()); + const match = await manager.matchEndpoint({ method: 'get', path: TASKS.path }); + expect(match?.endpoint.name).toBe('list_tasks'); + }); + + it('ignores items of other metadata types', async () => { + await manager.register('action', 'list_tasks', endpoint()); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); + }); + + describe('invalidation', () => { + it('rebuilds after a register() — a newly declared endpoint is matchable', async () => { + await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); + await manager.register('api', 'list_tasks', endpoint()); + await expect(manager.matchEndpoint(TASKS)).resolves.toMatchObject({ + endpoint: { name: 'list_tasks' }, + }); + }); + + it('rebuilds after an unregister() — a removed endpoint stops matching', async () => { + await manager.register('api', 'list_tasks', endpoint()); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeDefined(); + + await manager.unregister('api', 'list_tasks'); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); + }); + + it('rebuilds after an OVERWRITE — the new target is served, not the cached one', async () => { + await manager.register('api', 'list_tasks', endpoint({ target: 'old_object' })); + expect((await manager.matchEndpoint(TASKS))?.endpoint.target).toBe('old_object'); + + await manager.register('api', 'list_tasks', endpoint({ target: 'new_object' })); + expect((await manager.matchEndpoint(TASKS))?.endpoint.target).toBe('new_object'); + }); + + it('rebuilds after a `{ notify: false }` write — the artifact-ingest / HMR path', async () => { + // `_parseAndRegisterArtifact` and `_reloadAndAnnounce` both register with + // notify:false and announce once for the whole batch, so this write never + // reaches a subscribe() watcher. The index must still go stale. + await manager.register('api', 'list_tasks', endpoint(), { notify: false }); + await expect(manager.matchEndpoint(TASKS)).resolves.toMatchObject({ + endpoint: { name: 'list_tasks' }, + }); + }); + + it('rebuilds after registerInMemory() — the deliberately silent seeding path', async () => { + manager.registerInMemory('api', 'list_tasks', endpoint()); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeDefined(); + }); + + it('rebuilds on a watcher event alone — the cluster peer-replay path', async () => { + const loader = new MemoryLoader(); + manager.registerLoader(loader); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeUndefined(); + + // Write behind the manager's back, then deliver only the event a remote + // node's write produces (`attachClusterPubSub` → `notifyWatchersLocal`), + // which never touches this node's caches directly. + await loader.save('api', 'list_tasks', endpoint()); + (manager as unknown as { notifyWatchers(t: string, e: unknown): void }).notifyWatchers('api', { + type: 'changed', + metadataType: 'api', + name: 'list_tasks', + path: '', + data: undefined, + timestamp: new Date().toISOString(), + }); + + await expect(manager.matchEndpoint(TASKS)).resolves.toMatchObject({ + endpoint: { name: 'list_tasks' }, + }); + }); + + it('a change to another metadata type does not disturb a live index', async () => { + await manager.register('api', 'list_tasks', endpoint()); + await manager.register('object', 'showcase_task', { name: 'showcase_task' }); + await expect(manager.matchEndpoint(TASKS)).resolves.toBeDefined(); + }); + }); + + describe('a stored item that fails to parse is skipped, loudly', () => { + it('does not break matching of the good items around it', async () => { + await manager.register('api', 'broken_ep', { name: 'broken_ep', path: '/api/v1/apps/showcase/x' }); + await manager.register('api', 'list_tasks', endpoint()); + + await expect(manager.matchEndpoint(TASKS)).resolves.toMatchObject({ + endpoint: { name: 'list_tasks' }, + }); + await expect(manager.matchEndpoint({ method: 'GET', path: '/api/v1/apps/showcase/x' })) + .resolves.toBeUndefined(); + }); + }); + + describe('duplicate claims resolve deterministically', () => { + it('lexicographically-first `name` keeps the route', async () => { + await manager.register('api', 'zeta_tasks', endpoint({ name: 'zeta_tasks', target: 'z' })); + await manager.register('api', 'alpha_tasks', endpoint({ name: 'alpha_tasks', target: 'a' })); + + const match = await manager.matchEndpoint(TASKS); + expect(match?.endpoint.name).toBe('alpha_tasks'); + expect(match?.endpoint.target).toBe('a'); + }); + }); + + describe('an unreadable store THROWS — it must not masquerade as a 404', () => { + /** A loader whose plural read fails, the way a real store outage looks. */ + function brokenLoader(): MetadataLoader { + return { + contract: { + name: 'broken', + protocol: 'datasource:', + capabilities: { read: true, write: false }, + }, + async load() { throw new Error('sys_metadata unreachable'); }, + async loadMany() { throw new Error('sys_metadata unreachable'); }, + async exists() { return false; }, + async stat() { return null; }, + async list() { return []; }, + } as unknown as MetadataLoader; + } + + it('matchEndpoint rejects when a loader cannot be read', async () => { + manager.registerLoader(brokenLoader()); + await expect(manager.matchEndpoint(TASKS)).rejects.toThrow('sys_metadata unreachable'); + }); + + it('…even when other loaders answered — a partial read cannot prove absence', async () => { + const good = new MemoryLoader(); + await good.save('api', 'list_tasks', endpoint()); + manager.registerLoader(good); + manager.registerLoader(brokenLoader()); + + await expect(manager.matchEndpoint(TASKS)).rejects.toThrow('sys_metadata unreachable'); + }); + + it('contrast: plain list() still degrades gracefully — only the endpoint read is strict', async () => { + manager.registerLoader(brokenLoader()); + await expect(manager.list('api')).resolves.toEqual([]); + }); + }); +}); diff --git a/packages/metadata/src/metadata-manager.ts b/packages/metadata/src/metadata-manager.ts index bd3efd86ea..c794e8a601 100644 --- a/packages/metadata/src/metadata-manager.ts +++ b/packages/metadata/src/metadata-manager.ts @@ -63,6 +63,8 @@ import type { MetadataEvent, MetaRef, } from '@objectstack/metadata-core'; +import { EndpointMatcher } from './endpoint-matcher.js'; +import type { ApiEndpointMatch } from '@objectstack/spec/contracts'; /** * Watch callback function (legacy) @@ -174,10 +176,37 @@ export class MetadataManager implements IMetadataService { private repoWatchIter?: AsyncIterator; private repoWatchClosed = false; + // ── #5089 (#5040 E2): declared-endpoint index ──────────────────────── + // Backs `matchEndpoint`. Lazily built from `api` items on the first call + // and invalidated by every path that can change them — see + // `invalidateListCache` (local writes, repo events, HMR/artifact ingest, + // which registers with `notify:false`) and the `subscribe('api', …)` + // registration below (cluster peer replay, which reaches watchers only). + private static readonly ENDPOINT_METADATA_TYPE = 'api'; + private readonly endpointMatcher: EndpointMatcher; + constructor(config: MetadataManagerOptions) { this.config = config; this.logger = createLogger({ level: 'info', format: 'pretty' }); + // [#5089] Endpoint index (see `matchEndpoint`). Two invalidation seams, + // covering disjoint event sets — both are needed, neither is redundant: + // 1. `invalidateListCache('api')` — every LOCAL mutation of the stored + // set, including the `{ notify: false }` writes the artifact ingest + // and the HMR reload use, which by construction never reach a + // watcher. It is the same invariant the list cache carries: if the + // cached list of a type is stale, so is the index built from it. + // 2. `subscribe('api', …)` — a CLUSTER peer's write, which + // `attachClusterPubSub` replays through `notifyWatchersLocal` only + // and therefore does not pass through (1). (That the peer replay + // leaves the manager's OWN caches stale is #5109; the index does not + // inherit the bug because it listens on the watcher too.) + this.endpointMatcher = new EndpointMatcher({ + listApiItems: () => this.listForIndex(MetadataManager.ENDPOINT_METADATA_TYPE), + logger: this.logger, + }); + this.subscribe(MetadataManager.ENDPOINT_METADATA_TYPE, () => this.endpointMatcher.invalidate()); + // Initialize serializers this.serializers = new Map(); const formats = config.formats || ['typescript', 'json', 'yaml']; @@ -523,6 +552,58 @@ export class MetadataManager implements IMetadataService { /** Internal helper: drop the cached `list()` result for a type. */ private invalidateListCache(type: string): void { this.listCache.delete(type); + // [#5089] The endpoint index is a cache of the same stored set, so it goes + // stale under exactly the same conditions. Hooking here (rather than only + // on the watcher) is what covers the `{ notify: false }` writes — artifact + // ingest and HMR reload — which never announce to a subscriber. + if (type === MetadataManager.ENDPOINT_METADATA_TYPE) { + this.endpointMatcher.invalidate(); + } + } + + /** + * Enumerate stored items of `type` for an index build — like {@link list}, + * but a store that cannot be read THROWS instead of contributing nothing. + * + * [#5089] `list()` deliberately warn-logs and skips a failing loader so a + * partially-available metadata plane still serves what it can. That posture + * is wrong for `matchEndpoint`: its `undefined` becomes an HTTP 404, and a + * store outage that silently yields "zero declarations" would turn every + * declared endpoint into a semantic "nothing declares this route". Same + * distinction {@link loadDiagnosed} draws on the singular read (ADR-0110 + * D3) — a miss and an outage are different facts with opposite meanings. + * + * Deliberately private and single-purpose: it is not a second `list()`, it + * is `list()`'s failure posture inverted for the one caller whose answer is + * a security/availability decision rather than a best-effort listing. + * + * ⚠️ This surfaces only failures a loader actually reports. `DatabaseLoader` + * currently swallows its own read errors into `[]` (#5108), so a DB outage is + * invisible even here — that is a defect in the loader, not a reason to + * soften this seam. + */ + private async listForIndex(type: string): Promise { + const items = new Map(); + + const typeStore = this.registry.get(type); + if (typeStore) { + for (const [name, data] of typeStore) { + items.set(name, data); + } + } + + for (const loader of this.loaders.values()) { + // No try/catch, on purpose — see the doc comment above. + const loaderItems = await loader.loadMany(type); + for (const item of loaderItems) { + const itemAny = item as { name?: unknown }; + if (itemAny && typeof itemAny.name === 'string' && !items.has(itemAny.name)) { + items.set(itemAny.name, item); + } + } + } + + return Array.from(items.values()); } /** @@ -1447,6 +1528,35 @@ export class MetadataManager implements IMetadataService { } } + // ========================================== + // API Endpoint Resolution + // ========================================== + + /** + * Resolve a request's `method`+`path` to the declared `api` metadata item + * that owns it — `IMetadataService.matchEndpoint` (#5080 contract, #5089 + * implementation, #5040 E2). + * + * The behaviour is specified by the contract text in + * `packages/spec/src/contracts/metadata-service.ts`; the mechanics + * (normalization, lazy index, loud parse-skip, duplicate resolution) live in + * `./endpoint-matcher.ts` and are documented there. + * + * Scope is THIS instance. There is no environment parameter, because callers + * already resolve the `metadata` service for the environment they serve — + * adding one here would create a second scoping mechanism. + * + * Nothing reaches this method over HTTP in 17.x: the dispatcher seam is + * #5090's, and publish still rejects a non-empty `apis:` (#4936), so the + * whole path is structurally unreachable until the #5040 E7 flip. + * + * @throws when the metadata store cannot be read — an outage must never be + * reported as a miss, because a miss becomes a 404. + */ + async matchEndpoint(query: { path: string; method: string }): Promise { + return this.endpointMatcher.match(query); + } + // ========================================== // Legacy Loader API (backward compatible) // ========================================== @@ -1671,6 +1781,7 @@ export class MetadataManager implements IMetadataService { await this.stopWatching().catch(() => undefined); await this.stopRepositoryWatch().catch(() => undefined); this.listCache.clear(); + this.endpointMatcher.invalidate(); } private async startRepositoryWatch(): Promise {