diff --git a/.changeset/30054-protocol-door-return-types.md b/.changeset/30054-protocol-door-return-types.md new file mode 100644 index 00000000000..2dc0b6298b2 --- /dev/null +++ b/.changeset/30054-protocol-door-return-types.md @@ -0,0 +1,10 @@ +--- +'@objectstack/metadata-protocol': patch +--- + +refactor(metadata-protocol): the eight `ObjectStackProtocolImplementation` doors that kept an inferred return type spell it on the signature + +Clause-②: no +Metadata-protocol: none + +- `getDiscovery`, `findData`, `getData`, `cloneData`, `saveMetaItem`, `publishMetaItem`, `rollbackMetaItem` and `deleteMetaItem` now declare their return type, copied from the published declaration file it was inferred into. The built `index.d.ts` and `index.d.cts` are byte-equal before and after, and no behaviour changes; only the source maps move with the new lines. Nothing to do on upgrade. diff --git a/packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts b/packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts new file mode 100644 index 00000000000..8fd11940e3e --- /dev/null +++ b/packages/metadata-protocol/src/protocol.facade-door-return-types.pin.test.ts @@ -0,0 +1,70 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #30054 — the eight public doors that stayed whole on the + * `ObjectStackProtocolImplementation` facade because their return type was + * inferred now spell it. + * + * The protocol.ts codemod keeps a non-private method with an inferred return + * type on the class (`inferred-return-type` in + * `scripts/codemod-split-protocol.mjs`): the published `.d.ts` prints that + * type, and once the body moved the compiler would infer it through the moved + * function instead. Each door below now carries its type on the signature, + * copied from the `.d.ts` it was inferred into (the PR proves the built + * `.d.ts` byte-equal before and after), so the type no longer depends on where + * the body lives. An edit that drops one of these annotations turns this red, + * before a later move could re-infer it. + * + * The bodies are still on the class: each door keeps its row in + * `scripts/facade-delegation-registry.json` until a facade pass moves it. + */ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; +import { describe, expect, it } from 'vitest'; + +const FILE = fileURLToPath(new URL('./protocol.ts', import.meta.url)); +const CLASS_NAME = 'ObjectStackProtocolImplementation'; + +/** The doors, each with the head of the type it spells (the outer type of its `.d.ts` line). */ +const DOORS: Readonly> = { + getDiscovery: 'Promise<{', + findData: 'Promise<{', + getData: 'Promise<{', + cloneData: 'Promise<{', + saveMetaItem: 'Promise<{', + publishMetaItem: 'Promise<{', + rollbackMetaItem: 'Promise<{', + deleteMetaItem: 'Promise<{', +}; + +function classMethods(): Map { + const text = readFileSync(FILE, 'utf8'); + const sf = ts.createSourceFile(FILE, text, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS); + const cls = sf.statements.find((s): s is ts.ClassDeclaration => ts.isClassDeclaration(s) && s.name?.text === CLASS_NAME); + const out = new Map(); + for (const m of cls?.members ?? []) { + if (!ts.isMethodDeclaration(m) || !m.body || !m.name || !ts.isIdentifier(m.name)) continue; + out.set(m.name.text, [...(out.get(m.name.text) ?? []), m]); + } + return out; +} + +describe('protocol.ts facade doors spell their return type (#30054)', () => { + const methods = classMethods(); + + it('finds the class and every door, once each', () => { + expect(methods.size).toBeGreaterThan(0); + for (const name of Object.keys(DOORS)) expect(methods.get(name)?.length, name).toBe(1); + }); + + for (const [name, head] of Object.entries(DOORS)) { + it(`${name} declares its return type on the signature`, () => { + const m = methods.get(name)?.[0]; + expect(m, name).toBeDefined(); + const type = m!.type; + expect(type, `${name} has an inferred return type`).toBeDefined(); + expect(type!.getText().startsWith(head), `${name}: ${type!.getText().slice(0, 40)}`).toBe(true); + }); + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 2f782bc07fb..d9077708e58 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2486,7 +2486,60 @@ export class ObjectStackProtocolImplementation implements return ProtocolDiscoveryAndBasesFacet.getProjectId(protocolHost(this)); } - async getDiscovery() { + async getDiscovery(): Promise<{ + authFamilies?: { + admin: boolean; + } | undefined; + version: string; + name: string; + /** @deprecated Use `name`. Removed in protocol 18 (#4828). */ + apiName: string; + environment: "sandbox" | "production" | "development"; + routes: { + data: string; + metadata: string; + discovery?: string | undefined; + ui?: string | undefined; + auth?: string | undefined; + automation?: string | undefined; + storage?: string | undefined; + analytics?: string | undefined; + packages?: string | undefined; + datasources?: string | undefined; + email?: string | undefined; + approvals?: string | undefined; + realtime?: string | undefined; + notifications?: string | undefined; + ai?: string | undefined; + i18n?: string | undefined; + mcp?: string | undefined; + }; + locale: { + default: string; + supported: string[]; + timezone: string; + }; + services: Record; + capabilities: Record<"ai" | "automation" | "cron" | "analytics" | "files" | "search" | "export" | "i18n" | "notifications" | "comments" | "chunkedUpload" | "transactionalBatch" | "websockets", { + enabled: boolean; + features?: Record | undefined; + description?: string | undefined; + }>; + }> { // Get registered services from kernel if available const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map(); @@ -5407,7 +5460,12 @@ export class ObjectStackProtocolImplementation implements return servesExpansionTarget.call(host, target); } - async findData(request: { object: string, query?: any, context?: any }) { + async findData(request: { object: string, query?: any, context?: any }): Promise<{ + hasMore: boolean; + total?: number | undefined; + object: string; + records: any[]; + }> { // [#3770] Existence first: an unregistered object is a 404 before any // query parameter is even parsed, so an unknown name can never be // probed for query-shape validity (nor reach the driver as a table). @@ -6159,7 +6217,11 @@ export class ObjectStackProtocolImplementation implements }; } - async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[], context?: any }) { + async getData(request: { object: string, id: string, expand?: string | string[], select?: string | string[], context?: any }): Promise<{ + object: string; + id: string; + record: Record; + }> { this.assertObjectRegistered(request.object); // [#3770] const queryOptions: any = { where: { id: request.id } @@ -6281,7 +6343,24 @@ export class ObjectStackProtocolImplementation implements * win over the copied values — the natural place to set a new `name`, * clear a unique field, or reset status before insert. */ - async cloneData(request: { object: string, id: string, overrides?: Record, context?: any }) { + async cloneData(request: { object: string, id: string, overrides?: Record, context?: any }): Promise<{ + warnings?: { + rule: string; + severity: "info" | "warning"; + field: string; + code: string; + message: string; + }[] | undefined; + droppedFields?: { + object: string; + fields: string[]; + reason: "readonly" | "computed" | "readonly_when" | "primary_key" | "stored_expression"; + }[] | undefined; + object: string; + id: any; + sourceId: string; + record: any; + }> { // [#3770] This object-existence check used to be open-coded here and // was the ONLY one on the whole data plane; it is now the shared gate // every data entry point runs. Same error envelope as before. @@ -10814,7 +10893,22 @@ export class ObjectStackProtocolImplementation implements // STORED content hash itself (`migrateStoredMetadata`): it reaches the // repository as given. ⛔ No transport sets it — every door builds its // request field by field from named inputs, never by spreading a body. - async saveMetaItem(request: SaveMetaItemRequest) { + async saveMetaItem(request: SaveMetaItemRequest): Promise<{ + state: "draft" | "active"; + message: string; + advisories?: { + rule: string; + path: string; + where: string; + message: string; + hint: string; + severity: "error" | "info" | "warning"; + }[] | undefined; + projectionApplied?: MutationProjectionOutcome | undefined; + success: boolean; + version: string; + seq: number; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'save', type: request.type, name: request.name, actor: request.actor, @@ -11051,7 +11145,55 @@ export class ObjectStackProtocolImplementation implements * Records a history event with `op='publish'`. 404 (`NO_DRAFT`) * when there is nothing to publish. */ - async publishMetaItem(request: PublishMetaItemRequest) { + async publishMetaItem(request: PublishMetaItemRequest): Promise<{ + success: boolean; + version: string; + seq: number; + message?: string; + /** + * Present when a `seed` draft was published: the result of materializing + * its rows. Publishing the metadata ALWAYS succeeds independently — a + * seed-load problem is surfaced here, never thrown, so callers (and UIs) + * must check `seedApplied.success` instead of assuming data went live. + */ + seedApplied?: { + success: boolean; + inserted: number; + updated: number; + error?: string; + errors?: unknown[]; + /** [#30348] Per object, the rows created vs matched — `SeedAppliedLandedSchema`. */ + landed?: SeedAppliedLandedParsed[]; + }; + /** + * Present when a publish-time materializer is registered for this type + * (ADR-0086 P2 — e.g. `flow`): the result of projecting the published + * body into its data-plane representation. Best-effort, + * same contract as `seedApplied` — surfaced, never thrown. + */ + materializeApplied?: PublishMaterializeResult; + /** + * Present when an ADR-0094 mutation projector is registered for this + * type: the outcome of the awaited post-persist projection. The + * draft→active promotion runs the projector exactly as a direct active + * save does, so this is the same receipt `saveMetaItem` returns. + * + * [#7294] It was ASSIGNED below and missing from this annotation, so + * the method's declared type denied a key the wire body carried — the + * same declared-≠-returned gap one layer down from the one #7294 + * closed on the spec side. + */ + projectionApplied?: MutationProjectionOutcome; + /** + * [#9176] Present ONLY when the #4463 runtime authoring gate raised at + * least one non-blocking finding against the draft body this promotion + * carried to `active` — an empty array is never emitted, so a clean + * publish's response bytes are unchanged (the #4717 discipline, one + * door over). Every entry is `warning`/`info` by construction: an + * `error` finding refuses the promotion as the 422 instead. + */ + advisories?: RuntimeAuthoringIssue[]; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'publish', type: request.type, name: request.name, actor: request.actor, @@ -13211,7 +13353,13 @@ export class ObjectStackProtocolImplementation implements * into the row the by-name read naming no package serves, a `.custom` * pair's declared override over a row bound to its read-only base. */ - async rollbackMetaItem(request: RollbackMetaItemRequest) { + async rollbackMetaItem(request: RollbackMetaItemRequest): Promise<{ + success: boolean; + version: string; + seq: number; + restoredFromVersion: number; + message?: string; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'rollback', type: request.type, name: request.name, actor: request.actor, @@ -13301,7 +13449,14 @@ export class ObjectStackProtocolImplementation implements * "Reset to factory default" semantic from ADR-0005. Whitelist is shared * with {@link saveMetaItem}. */ - async deleteMetaItem(request: DeleteMetaItemRequest) { + async deleteMetaItem(request: DeleteMetaItemRequest): Promise<{ + success: boolean; + message?: string; + reset?: boolean; + seq?: number; + /** [ADR-0094] Outcome of the awaited mutation projector, when one is registered. */ + projectionApplied?: MutationProjectionOutcome; + }> { // [#28355] A refusal that carries no row of its own leaves its `denied` row here. return withRefusalAudit((entry) => this.recordMetadataAudit(entry), { operation: 'delete', type: request.type, name: request.name, actor: request.actor, diff --git a/scripts/facade-delegation-registry.json b/scripts/facade-delegation-registry.json index af2040905db..c5093bf1a76 100644 --- a/scripts/facade-delegation-registry.json +++ b/scripts/facade-delegation-registry.json @@ -31,49 +31,49 @@ "file": "packages/metadata-protocol/src/protocol.ts", "member": "getDiscovery", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "findData", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "getData", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "cloneData", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "saveMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "publishMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "rollbackMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts", "member": "deleteMetaItem", "card": "#30054", - "reason": "inferred-return-type (#29985 report: stays on the class until the return type is spelled)" + "reason": "return type spelled on the signature (#30054 step 1, public .d.ts byte-equal); the body stays until the protocol.ts codemod can move a member of the landed facade (it refuses its own output, and the split no longer re-proves from its #29985 base)" }, { "file": "packages/metadata-protocol/src/protocol.ts",