Skip to content

Commit 607a151

Browse files
committed
feat(client, rest): bind both getHistory exits to HistoryMetaItemResponse; ledger row names the schema
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ
1 parent cf74a11 commit 607a151

5 files changed

Lines changed: 156 additions & 15 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/client': minor
3+
'@objectstack/rest': patch
4+
---
5+
6+
`client.meta.getHistory` answers the published `HistoryMetaItemResponse` on **both** of its exits, and the route ledger names the schema.
7+
8+
**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`).
9+
10+
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.
11+
12+
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<unknown>` 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.
13+
14+
`@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.
15+
16+
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.
17+
18+
<!-- adr-0087: not-required (no-migration-prescription) Nothing here is reachable by `objectstack migrate meta`: no spec property, metadata key, accepted value or exported symbol is retired, `packages/spec` is not touched at all, and no stored metadata changes shape. The affected party is a TypeScript consumer and the delivery channel is the compiler at their own call site; the remedy is a null check on `actor`, which is application code rather than a metadata migration. `type-surface-only` is deliberately NOT claimed: its predicate 4 admits a narrowing that starts from `any`, `unknown` or no annotation, and while the scoped exit is exactly that case, the unscoped exit starts from a CONCRETE inline object type whose members change — the case that category's header names as outside its class. -->

packages/client/src/index.ts

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,12 @@ import {
109109
AuditMetaItemResponse,
110110
RollbackMetaItemResponse,
111111
DiffMetaItemResponse,
112+
// [#13523] The change-log body of `GET /meta/:type/:name/history`, the one
113+
// door of the family above whose declaration (#12005, PR #13521) landed
114+
// AFTER the ruling's bindings were written — so both of its exits carried a
115+
// pre-declaration spelling until now. Bound here on the same terms as its
116+
// `AuditMetaItemResponse` twin: the PAYLOAD, envelope-free.
117+
HistoryMetaItemResponse,
112118
PackagePublishResult,
113119
DiscardPackageDraftsResponse,
114120
ListPackageCommitsResponse,
@@ -1726,30 +1732,35 @@ export class ObjectStackClient {
17261732
* Returns events recorded in `sys_metadata_history` for every
17271733
* overlay put/delete, ordered by `event_seq` ascending. Non-overlay
17281734
* metadata types return an empty list.
1735+
*
1736+
* [#13523] Returns {@link HistoryMetaItemResponse} — the published
1737+
* declaration (#12005), replacing the inline shape this method carried
1738+
* from before that schema existed. The route answers BARE, so the named
1739+
* type is the whole body, exactly as on the `getAudit` twin.
1740+
*
1741+
* ⚠️ The rebind is NOT field-for-field: the inline shape declared
1742+
* `actor: string` for a door that answers `null` on every
1743+
* system-initiated write (boot sync, migration, scheduled job — the
1744+
* producer's own `rowToEvent`), so a caller that read `actor` without a
1745+
* null check was type-checked against a promise the door never made. It
1746+
* also declared `op` as a plain `string` where the producer's vocabulary
1747+
* is closed, `ref.org` as optional where the producer always writes one,
1748+
* and omitted `ref.version` / `version` / `previousName` entirely. See
1749+
* the card for the field-by-field measurement.
17291750
*/
17301751
getHistory: async (
17311752
type: string,
17321753
name: string,
17331754
options?: { sinceSeq?: number; limit?: number },
1734-
): Promise<{ events: Array<{
1735-
seq: number;
1736-
op: string;
1737-
ref: { org?: string; type: string; name: string };
1738-
hash: string | null;
1739-
parentHash: string | null;
1740-
actor: string;
1741-
message?: string;
1742-
ts: string;
1743-
source: string;
1744-
}> }> => {
1755+
): Promise<HistoryMetaItemResponse> => {
17451756
const route = this.getRoute('metadata');
17461757
const params = new URLSearchParams();
17471758
if (options?.sinceSeq !== undefined) params.set('sinceSeq', String(options.sinceSeq));
17481759
if (options?.limit !== undefined) params.set('limit', String(options.limit));
17491760
const qs = params.toString();
17501761
const url = `${this.baseUrl}${route}/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ''}`;
17511762
const res = await this.fetch(url);
1752-
return this.unwrapResponse(res);
1763+
return this.unwrapResponse<HistoryMetaItemResponse>(res);
17531764
},
17541765

17551766
/**
@@ -6870,19 +6881,34 @@ export class ScopedEnvironmentClient {
68706881
// Bare body, same as the unscoped twin — `_unwrap` is `unwrapResponse`.
68716882
return this.parent._unwrap<DeleteMetaItemResponse>(res);
68726883
},
6884+
/**
6885+
* The durable change-log for a metadata item, scoped to this
6886+
* environment. Reaches the SAME handler as the unscoped twin — one
6887+
* `registerForBase` replay against `/environments/:environmentId` — so
6888+
* the body is byte-identical and the declaration must be too.
6889+
*
6890+
* [#13523] Returns {@link HistoryMetaItemResponse}. This exit declared
6891+
* NOTHING before: no return annotation, and `_unwrap` called with no type
6892+
* argument, so `T` had no inference site and the published method
6893+
* answered `Promise<unknown>` — every caller forced to narrow by hand,
6894+
* against no contract. The unscoped twin meanwhile declared a DIFFERENT,
6895+
* inline shape. Binding one exit and not the other would have relocated
6896+
* that divergence rather than removed it (the #7019 direction), so both
6897+
* exits name this one type.
6898+
*/
68736899
getHistory: async (
68746900
type: string,
68756901
name: string,
68766902
options?: { sinceSeq?: number; limit?: number },
6877-
) => {
6903+
): Promise<HistoryMetaItemResponse> => {
68786904
const params = new URLSearchParams();
68796905
if (options?.sinceSeq !== undefined) params.set('sinceSeq', String(options.sinceSeq));
68806906
if (options?.limit !== undefined) params.set('limit', String(options.limit));
68816907
const qs = params.toString();
68826908
const res = await this.parent._fetch(
68836909
this.url(`/meta/${encodeURIComponent(type)}/${encodeURIComponent(name)}/history${qs ? `?${qs}` : ''}`),
68846910
);
6885-
return this.parent._unwrap(res);
6911+
return this.parent._unwrap<HistoryMetaItemResponse>(res);
68866912
},
68876913
};
68886914

packages/client/src/return-type-precision.test.ts

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ import type {
9696
AuditMetaItemResponse,
9797
RollbackMetaItemResponse,
9898
DiffMetaItemResponse,
99+
HistoryMetaItemResponse,
99100
PackagePublishResult,
100101
DiscardPackageDraftsResponse,
101102
ListPackageCommitsResponse,
@@ -365,6 +366,12 @@ export async function returnTypePrecisionPins12038(): Promise<void> {
365366
expectTypeOf(await client.meta.getAudit('view', 'account_list')).toEqualTypeOf<AuditMetaItemResponse>();
366367
expectTypeOf(await client.meta.rollbackItem('view', 'account_list', 3)).toEqualTypeOf<RollbackMetaItemResponse>();
367368
expectTypeOf(await client.meta.diffItem('view', 'account_list')).toEqualTypeOf<DiffMetaItemResponse>();
369+
// [#13523] The ninth door of this family — declared after the ruling's
370+
// bindings were written (#12005, PR #13521), so it kept a
371+
// pre-declaration spelling on BOTH of its exits until now. See
372+
// `returnTypePrecisionPins13523` below for the scoped exit and for the
373+
// wrong-shape direction; the two are pinned TOGETHER on purpose.
374+
expectTypeOf(await client.meta.getHistory('view', 'account_list')).toEqualTypeOf<HistoryMetaItemResponse>();
368375
// Ruling 1C: `getPublished` is bound to `unknown` BY RULING — an
369376
// arbitrary metadata item body, never a union frozen against the type
370377
// registry. `unknown` (not `any`) is the binding: callers must narrow.
@@ -409,6 +416,91 @@ export async function returnTypePrecisionPins12038(): Promise<void> {
409416
void wrongDiagnostics;
410417
}
411418

419+
/**
420+
* [#13523] The history door — the #12038 family's ninth member, and the one
421+
* whose declaration landed AFTER the ruling's bindings were written.
422+
*
423+
* ## Why this door needed its own block: it has TWO exits, and they disagreed
424+
*
425+
* `getHistory` exists twice in `./index.ts` — once on `ObjectStackClient` and
426+
* once on `ScopedEnvironmentClient` — and the two are not independent doors.
427+
* They are the SAME mount replayed against `/environments/:environmentId`
428+
* (`registerForBase` in `rest-server.ts`), so they answer a byte-identical
429+
* body. Their DECLARATIONS were nevertheless in two different pre-declaration
430+
* states:
431+
*
432+
* - the unscoped exit declared a hand-written inline shape;
433+
* - the scoped exit declared NOTHING — no return annotation, and `_unwrap`
434+
* called with no type argument, so `T` had no inference site and the
435+
* published method resolved to `Promise< unknown >`.
436+
*
437+
* Binding one and leaving the other would have RELOCATED that divergence
438+
* rather than removed it, which is why the equality pin below is the first
439+
* assertion in this block: it is red both when neither exit is bound and when
440+
* only one is.
441+
*
442+
* ## The rebind is a NARROWING, not a rename
443+
*
444+
* The inline shape and `HistoryMetaItemResponse` are not field-for-field
445+
* equivalent, so this is a real move of a published face. Every difference is
446+
* pinned below, in the direction that is red before the change.
447+
*/
448+
export async function returnTypePrecisionPins13523(): Promise<void> {
449+
// ── the two exits are ONE door ────────────────────────────────────────
450+
// RED BEFORE in both of the ways it can be: the inline shape is not
451+
// `unknown` (neither exit bound), and neither is equal to the published
452+
// type (one exit bound). This is the assertion that refuses a half-fix.
453+
type UnscopedHistory = Awaited<ReturnType<ObjectStackClient['meta']['getHistory']>>;
454+
type ScopedHistory = Awaited<ReturnType<ScopedEnvironmentClient['meta']['getHistory']>>;
455+
expectTypeOf<UnscopedHistory>().toEqualTypeOf<HistoryMetaItemResponse>();
456+
expectTypeOf<ScopedHistory>().toEqualTypeOf<HistoryMetaItemResponse>();
457+
expectTypeOf<UnscopedHistory>().toEqualTypeOf<ScopedHistory>();
458+
459+
// The scoped exit answered `unknown`, which has NO members — so this
460+
// member read is red before the rebind (TS2339/TS18046) and is the
461+
// simplest statement of what that exit's callers could not do.
462+
void (await scoped.meta.getHistory('view', 'account_list')).events;
463+
464+
const event = (await client.meta.getHistory('view', 'account_list')).events[0];
465+
466+
// ── difference 1: `actor` is NULLABLE, and the inline shape said it was not ─
467+
// The consequential one. `rowToEvent` writes `null` for every
468+
// system-initiated write (boot sync, migration, scheduled job) and the
469+
// schema declares it "never a sentinel string", so callers that resolve
470+
// the actor against `sys_user` must be able to tell "nobody" from "a user
471+
// id". The inline `actor: string` type-checked those callers against a
472+
// promise the door has never made.
473+
// RED BEFORE: the suppression is unused (TS2578) while `actor` is `string`.
474+
// @ts-expect-error `actor` is `string | null` — a system-initiated event names no user
475+
const actorIsNeverNull: string = event.actor;
476+
477+
// ── difference 2: `op` is a CLOSED vocabulary, not a plain string ──────
478+
// RED BEFORE: with `op: string` this comparison overlaps and the
479+
// suppression goes unused (TS2578).
480+
// @ts-expect-error `save` is not in the ADR-0008 §2.4 change-log vocabulary
481+
const opOutsideTheVocabulary = event.op === 'save';
482+
483+
// ── difference 3: `ref.org` is ALWAYS written, not optional ───────────
484+
// The positive direction on purpose: red before as TS2322
485+
// (`string | undefined` is not assignable to `string`), green after.
486+
const org: string = event.ref.org;
487+
488+
// ── differences 4-6: three members the inline shape omitted entirely ──
489+
// Red before as TS2339 — the inline shape declared no such properties, so
490+
// no caller could reach the version lineage the rollback door pins
491+
// against, nor the rename door's previous name.
492+
const lineageVersion: number | undefined = event.version;
493+
const previousName: string | undefined = event.previousName;
494+
const refVersion: string | undefined = event.ref.version;
495+
496+
void actorIsNeverNull;
497+
void opOutsideTheVocabulary;
498+
void org;
499+
void lineageVersion;
500+
void previousName;
501+
void refVersion;
502+
}
503+
412504
/**
413505
* [#12034 — shipping half] The three `packages` WRITE verbs, bound to the bare
414506
* `InstalledPackage` row.

packages/client/src/unwrap-misfire.pin.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
AuditMetaItemResponseSchema,
4141
RollbackMetaItemResponseSchema,
4242
DiffMetaItemResponseSchema,
43+
HistoryMetaItemResponseSchema,
4344
ResolvedBookSchema,
4445
PackagePublishResultSchema,
4546
DiscardPackageDraftsResponseSchema,
@@ -60,6 +61,9 @@ const BOUND_PAYLOAD_SCHEMAS: ReadonlyArray<readonly [string, unknown]> = [
6061
['AuditMetaItemResponseSchema', AuditMetaItemResponseSchema],
6162
['RollbackMetaItemResponseSchema', RollbackMetaItemResponseSchema],
6263
['DiffMetaItemResponseSchema', DiffMetaItemResponseSchema],
64+
// [#13523] Bound at the ledger row and on both SDK exits of the history
65+
// door, so the hazard this suite pins now reaches it too.
66+
['HistoryMetaItemResponseSchema', HistoryMetaItemResponseSchema],
6367
['ResolvedBookSchema', ResolvedBookSchema],
6468
['PackagePublishResultSchema', PackagePublishResultSchema],
6569
['DiscardPackageDraftsResponseSchema', DiscardPackageDraftsResponseSchema],

packages/rest/src/rest-route-ledger.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,8 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
237237
{ route: 'DELETE /api/v1/meta/:type/:name', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.deleteItem',
238238
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' },
239239
{ route: 'GET /api/v1/meta/:type/:name/history', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getHistory',
240-
note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s' },
240+
responseSchema: 'HistoryMetaItemResponseSchema',
241+
note: 'REST-only: the dispatcher /meta branch swallows /history as a compound name and 404s. [#13523] payload answered BARE, so the named schema is the whole body. The schema postdates this row (#12005, PR #13521) — describe-only transcription of `historyMetaItem`\'s declared return; conformance: the #12005 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`' },
241242
{ route: 'GET /api/v1/meta/:type/:name/audit', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getAudit',
242243
responseSchema: 'AuditMetaItemResponseSchema',
243244
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`' },

0 commit comments

Comments
 (0)