Skip to content
10 changes: 10 additions & 0 deletions .changeset/30054-protocol-door-return-types.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> = {
getDiscovery: 'Promise<{',
findData: 'Promise<{',
getData: 'Promise<{',
cloneData: 'Promise<{',
saveMetaItem: 'Promise<{',
publishMetaItem: 'Promise<{',
rollbackMetaItem: 'Promise<{',
deleteMetaItem: 'Promise<{',
};

function classMethods(): Map<string, ts.MethodDeclaration[]> {
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<string, ts.MethodDeclaration[]>();
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);
});
}
});
171 changes: 163 additions & 8 deletions packages/metadata-protocol/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, {
enabled: boolean;
status: "registered" | "degraded" | "available" | "unavailable" | "stub";
handlerReady?: boolean | undefined;
route?: string | undefined;
provider?: string | undefined;
version?: string | undefined;
message?: string | undefined;
rateLimit?: {
requestsPerMinute?: number | undefined;
requestsPerHour?: number | undefined;
burstLimit?: number | undefined;
retryAfterMs?: number | undefined;
} | undefined;
}>;
capabilities: Record<"ai" | "automation" | "cron" | "analytics" | "files" | "search" | "export" | "i18n" | "notifications" | "comments" | "chunkedUpload" | "transactionalBatch" | "websockets", {
enabled: boolean;
features?: Record<string, boolean> | undefined;
description?: string | undefined;
}>;
}> {
// Get registered services from kernel if available
const registeredServices = this.getServicesRegistry ? this.getServicesRegistry() : new Map();

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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<string, any>;
}> {
this.assertObjectRegistered(request.object); // [#3770]
const queryOptions: any = {
where: { id: request.id }
Expand Down Expand Up @@ -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<string, any>, context?: any }) {
async cloneData(request: { object: string, id: string, overrides?: Record<string, any>, 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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
16 changes: 8 additions & 8 deletions scripts/facade-delegation-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading