diff --git a/.changeset/history-door-schema-rebind.md b/.changeset/history-door-schema-rebind.md new file mode 100644 index 0000000000..69e67b2eb1 --- /dev/null +++ b/.changeset/history-door-schema-rebind.md @@ -0,0 +1,18 @@ +--- +'@objectstack/client': minor +'@objectstack/rest': patch +--- + +`client.meta.getHistory` answers the published `HistoryMetaItemResponse` on **both** of its exits, and the route ledger names the schema. + +**BREAKING (types):** the unscoped `client.meta.getHistory` declared a hand-written inline shape whose `actor` member was `string`. The door answers `null` there for every system-initiated write — boot sync, migration, a scheduled job — and the published schema declares it "never a sentinel string", so consumers that resolve the actor against `sys_user` must be able to tell "nobody" from "a user id". Reading `actor` without a null check compiled against a promise the door has never made; it no longer compiles. The same rebind closes the vocabulary of `op` (the ADR-0008 §2.4 change-log verbs, previously a plain `string`). + +Three members the inline shape omitted become reachable in the same move: `version` (the per-`(org,type,name)` lineage counter that `rollbackItem({ toVersion })` pins against), `previousName` (set on `op: "rename"`), and `ref.version`. `ref.org` was declared optional and is now what the producer always writes. + +The scoped twin — `client.environments.use(id).meta.getHistory` — carried no declaration at all: no return annotation, and the SDK's internal unwrap called with no type argument, so the published method resolved to `Promise` and every caller had to narrow by hand against nothing. It is the SAME mount as the unscoped exit, replayed against `/environments/:environmentId`, so it answers a byte-identical body; the two now name one type. Binding only one exit would have relocated that divergence rather than removed it, and the equality of the two declared types is pinned rather than left to review. + +`@objectstack/rest` is `patch`: the route-ledger row for `GET /api/v1/meta/:type/:name/history` now names `HistoryMetaItemResponseSchema`. Data only, in a package-internal module — no route, handler or emitted byte changes. The row could not name the schema before because the declaration (#12005) landed after the row was written. + +No wire byte moves anywhere in this change. `HistoryMetaItemResponseSchema` is a describe-only transcription of what `historyMetaItem` already returned, and the SDK's runtime path is untouched — only what the compiler knows about it. + + diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 898dd9dd4f..7fa9e129ca 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -109,6 +109,12 @@ import { AuditMetaItemResponse, RollbackMetaItemResponse, DiffMetaItemResponse, + // [#13523] The change-log body of `GET /meta/:type/:name/history`, the one + // door of the family above whose declaration (#12005, PR #13521) landed + // AFTER the ruling's bindings were written — so both of its exits carried a + // pre-declaration spelling until now. Bound here on the same terms as its + // `AuditMetaItemResponse` twin: the PAYLOAD, envelope-free. + HistoryMetaItemResponse, PackagePublishResult, DiscardPackageDraftsResponse, ListPackageCommitsResponse, @@ -1726,22 +1732,27 @@ export class ObjectStackClient { * Returns events recorded in `sys_metadata_history` for every * overlay put/delete, ordered by `event_seq` ascending. Non-overlay * metadata types return an empty list. + * + * [#13523] Returns {@link HistoryMetaItemResponse} — the published + * declaration (#12005), replacing the inline shape this method carried + * from before that schema existed. The route answers BARE, so the named + * type is the whole body, exactly as on the `getAudit` twin. + * + * ⚠️ The rebind is NOT field-for-field: the inline shape declared + * `actor: string` for a door that answers `null` on every + * system-initiated write (boot sync, migration, scheduled job — the + * producer's own `rowToEvent`), so a caller that read `actor` without a + * null check was type-checked against a promise the door never made. It + * also declared `op` as a plain `string` where the producer's vocabulary + * is closed, `ref.org` as optional where the producer always writes one, + * and omitted `ref.version` / `version` / `previousName` entirely. See + * the card for the field-by-field measurement. */ getHistory: async ( type: string, name: string, options?: { sinceSeq?: number; limit?: number }, - ): Promise<{ events: Array<{ - seq: number; - op: string; - ref: { org?: string; type: string; name: string }; - hash: string | null; - parentHash: string | null; - actor: string; - message?: string; - ts: string; - source: string; - }> }> => { + ): Promise => { const route = this.getRoute('metadata'); const params = new URLSearchParams(); if (options?.sinceSeq !== undefined) params.set('sinceSeq', String(options.sinceSeq)); @@ -1749,7 +1760,7 @@ export class ObjectStackClient { const qs = params.toString(); const url = `${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ''}`; const res = await this.fetch(url); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -6870,11 +6881,26 @@ export class ScopedEnvironmentClient { // Bare body, same as the unscoped twin — `_unwrap` is `unwrapResponse`. return this.parent._unwrap(res); }, + /** + * The durable change-log for a metadata item, scoped to this + * environment. Reaches the SAME handler as the unscoped twin — one + * `registerForBase` replay against `/environments/:environmentId` — so + * the body is byte-identical and the declaration must be too. + * + * [#13523] Returns {@link HistoryMetaItemResponse}. This exit declared + * NOTHING before: no return annotation, and `_unwrap` called with no type + * argument, so `T` had no inference site and the published method + * answered `Promise` — every caller forced to narrow by hand, + * against no contract. The unscoped twin meanwhile declared a DIFFERENT, + * inline shape. Binding one exit and not the other would have relocated + * that divergence rather than removed it (the #7019 direction), so both + * exits name this one type. + */ getHistory: async ( type: string, name: string, options?: { sinceSeq?: number; limit?: number }, - ) => { + ): Promise => { const params = new URLSearchParams(); if (options?.sinceSeq !== undefined) params.set('sinceSeq', String(options.sinceSeq)); if (options?.limit !== undefined) params.set('limit', String(options.limit)); @@ -6882,7 +6908,7 @@ export class ScopedEnvironmentClient { const res = await this.parent._fetch( this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ''}`), ); - return this.parent._unwrap(res); + return this.parent._unwrap(res); }, }; diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 5435ceb6f6..a94ff9eee6 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -96,6 +96,7 @@ import type { AuditMetaItemResponse, RollbackMetaItemResponse, DiffMetaItemResponse, + HistoryMetaItemResponse, PackagePublishResult, DiscardPackageDraftsResponse, ListPackageCommitsResponse, @@ -365,6 +366,12 @@ export async function returnTypePrecisionPins12038(): Promise { expectTypeOf(await client.meta.getAudit('view', 'account_list')).toEqualTypeOf(); expectTypeOf(await client.meta.rollbackItem('view', 'account_list', 3)).toEqualTypeOf(); expectTypeOf(await client.meta.diffItem('view', 'account_list')).toEqualTypeOf(); + // [#13523] The ninth door of this family — declared after the ruling's + // bindings were written (#12005, PR #13521), so it kept a + // pre-declaration spelling on BOTH of its exits until now. See + // `returnTypePrecisionPins13523` below for the scoped exit and for the + // wrong-shape direction; the two are pinned TOGETHER on purpose. + expectTypeOf(await client.meta.getHistory('view', 'account_list')).toEqualTypeOf(); // Ruling 1C: `getPublished` is bound to `unknown` BY RULING — an // arbitrary metadata item body, never a union frozen against the type // registry. `unknown` (not `any`) is the binding: callers must narrow. @@ -409,6 +416,91 @@ export async function returnTypePrecisionPins12038(): Promise { void wrongDiagnostics; } +/** + * [#13523] The history door — the #12038 family's ninth member, and the one + * whose declaration landed AFTER the ruling's bindings were written. + * + * ## Why this door needed its own block: it has TWO exits, and they disagreed + * + * `getHistory` exists twice in `./index.ts` — once on `ObjectStackClient` and + * once on `ScopedEnvironmentClient` — and the two are not independent doors. + * They are the SAME mount replayed against `/environments/:environmentId` + * (`registerForBase` in `rest-server.ts`), so they answer a byte-identical + * body. Their DECLARATIONS were nevertheless in two different pre-declaration + * states: + * + * - the unscoped exit declared a hand-written inline shape; + * - the scoped exit declared NOTHING — no return annotation, and `_unwrap` + * called with no type argument, so `T` had no inference site and the + * published method resolved to `Promise< unknown >`. + * + * Binding one and leaving the other would have RELOCATED that divergence + * rather than removed it, which is why the equality pin below is the first + * assertion in this block: it is red both when neither exit is bound and when + * only one is. + * + * ## The rebind is a NARROWING, not a rename + * + * The inline shape and `HistoryMetaItemResponse` are not field-for-field + * equivalent, so this is a real move of a published face. Every difference is + * pinned below, in the direction that is red before the change. + */ +export async function returnTypePrecisionPins13523(): Promise { + // ── the two exits are ONE door ──────────────────────────────────────── + // RED BEFORE in both of the ways it can be: the inline shape is not + // `unknown` (neither exit bound), and neither is equal to the published + // type (one exit bound). This is the assertion that refuses a half-fix. + type UnscopedHistory = Awaited>; + type ScopedHistory = Awaited>; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + + // The scoped exit answered `unknown`, which has NO members — so this + // member read is red before the rebind (TS2339/TS18046) and is the + // simplest statement of what that exit's callers could not do. + void (await scoped.meta.getHistory('view', 'account_list')).events; + + const event = (await client.meta.getHistory('view', 'account_list')).events[0]; + + // ── difference 1: `actor` is NULLABLE, and the inline shape said it was not ─ + // The consequential one. `rowToEvent` writes `null` for every + // system-initiated write (boot sync, migration, scheduled job) and the + // schema declares it "never a sentinel string", so callers that resolve + // the actor against `sys_user` must be able to tell "nobody" from "a user + // id". The inline `actor: string` type-checked those callers against a + // promise the door has never made. + // RED BEFORE: the suppression is unused (TS2578) while `actor` is `string`. + // @ts-expect-error `actor` is `string | null` — a system-initiated event names no user + const actorIsNeverNull: string = event.actor; + + // ── difference 2: `op` is a CLOSED vocabulary, not a plain string ────── + // RED BEFORE: with `op: string` this comparison overlaps and the + // suppression goes unused (TS2578). + // @ts-expect-error `save` is not in the ADR-0008 §2.4 change-log vocabulary + const opOutsideTheVocabulary = event.op === 'save'; + + // ── difference 3: `ref.org` is ALWAYS written, not optional ─────────── + // The positive direction on purpose: red before as TS2322 + // (`string | undefined` is not assignable to `string`), green after. + const org: string = event.ref.org; + + // ── differences 4-6: three members the inline shape omitted entirely ── + // Red before as TS2339 — the inline shape declared no such properties, so + // no caller could reach the version lineage the rollback door pins + // against, nor the rename door's previous name. + const lineageVersion: number | undefined = event.version; + const previousName: string | undefined = event.previousName; + const refVersion: string | undefined = event.ref.version; + + void actorIsNeverNull; + void opOutsideTheVocabulary; + void org; + void lineageVersion; + void previousName; + void refVersion; +} + /** * [#12034 — shipping half] The three `packages` WRITE verbs, bound to the bare * `InstalledPackage` row. diff --git a/packages/client/src/unwrap-misfire.pin.test.ts b/packages/client/src/unwrap-misfire.pin.test.ts index 801e0da492..a703698deb 100644 --- a/packages/client/src/unwrap-misfire.pin.test.ts +++ b/packages/client/src/unwrap-misfire.pin.test.ts @@ -40,6 +40,7 @@ import { AuditMetaItemResponseSchema, RollbackMetaItemResponseSchema, DiffMetaItemResponseSchema, + HistoryMetaItemResponseSchema, ResolvedBookSchema, PackagePublishResultSchema, DiscardPackageDraftsResponseSchema, @@ -60,6 +61,9 @@ const BOUND_PAYLOAD_SCHEMAS: ReadonlyArray = [ ['AuditMetaItemResponseSchema', AuditMetaItemResponseSchema], ['RollbackMetaItemResponseSchema', RollbackMetaItemResponseSchema], ['DiffMetaItemResponseSchema', DiffMetaItemResponseSchema], + // [#13523] Bound at the ledger row and on both SDK exits of the history + // door, so the hazard this suite pins now reaches it too. + ['HistoryMetaItemResponseSchema', HistoryMetaItemResponseSchema], ['ResolvedBookSchema', ResolvedBookSchema], ['PackagePublishResultSchema', PackagePublishResultSchema], ['DiscardPackageDraftsResponseSchema', DiscardPackageDraftsResponseSchema], diff --git a/packages/rest/src/rest-route-ledger.ts b/packages/rest/src/rest-route-ledger.ts index 4aeda17711..e17e958aa6 100644 --- a/packages/rest/src/rest-route-ledger.ts +++ b/packages/rest/src/rest-route-ledger.ts @@ -236,8 +236,17 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [ note: '[#6603] gated on `manage_metadata` (ADR-0066 D1), same mechanism as POST /meta/_migrate-stored — a session alone is no longer enough. The write-side answer to ADR-0106 D1: a masked read PUT back verbatim used to delete the fields the caller could not see. [#12702] the gate is the shared `metaWriteCapabilityVerdict`: `manage_org_presentation` is also admitted, ONLY for an `allowOrgOverride: true` type written org-scoped to the caller\'s own active organization' }, { route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem', note: 'REST-only: the dispatcher /meta branch has no DELETE handling — it falls into the read path. [#7019] gated on `manage_metadata` (ADR-0066 D1), same mechanism as the PUT twins — but NOT for the ADR-0106 reason: nothing is masked or round-tripped here, this discards a customization overlay outright, and `?dropStorage=true` takes the object table with it. [#12702] same shared verdict as the PUT door: an admitted `manage_org_presentation` reset threads the caller\'s own organization, so the only row it can discard is their own org\'s overlay' }, + // The response schema POSTDATES this row: the row was written when the door + // had no declaration, and `HistoryMetaItemResponseSchema` was authored later + // by the card that declared the history protocol member. That is why this was + // the one row of the metadata family left unfilled while its `audit`, + // `rollback` and `diff` siblings were bound. The tracker anchors for both + // halves live in git history and in this comment's own PR, deliberately not + // in the `note` string below — that string reaches authors and operators + // through generated surfaces, where an issue id resolves to nothing. { route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory', - note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' }, + responseSchema: 'HistoryMetaItemResponseSchema', + note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s. Payload answered BARE, so the named schema is the whole body — a describe-only transcription of `historyMetaItem`\'s declared return. Conformance: the history capture suite in spec `api/protocol.test.ts`, which parses a real two-event body (an update carrying every optional member, and the delete tombstone with `hash: null` and a `null` system actor) and pins the closed `op` vocabulary against the deliberately open `ref.type`' }, { route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit', responseSchema: 'AuditMetaItemResponseSchema', note: '[#12038] REST-only route; payload answered BARE, so the named schema is the whole body. The schema predates this row (#11678, exact field-for-field match of `auditMetaItem`\'s declared return); conformance: the #11678 capture suite in spec `api/protocol.test.ts`' },