Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/19324-record-stage-index-signature-docblock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@objectstack/spec': patch
'@objectstack/client': patch
---

`RecordStagePackageBodySchema`, `AssembledInstalledPackageSchema` and `ObjectStackClient.packages.list` now say, in their published docblocks, that `manifest`'s static type is deliberately an index signature and that the runtime schema is the enforced contract (#19324)

Clause-②: no

`AssembledInstalledPackage['manifest']` is `RecordStagePackageBodySchema`, declared `z.ZodType<Record<string, unknown>, Record<string, unknown>>`. So its published type is an index signature: an authoring-stage `InstalledPackage` assigns to `AssembledInstalledPackage`, and a row whose `manifest` belongs to neither stage type-checks as an `InstalledPackageAtEitherStage`. The maintainer ruled that this is the accepted static contract (#19324, letter 丙). The three declarations now say so where a TypeScript reader meets them:

- **The runtime schema is the enforced contract.** `InstalledPackageAtEitherStageSchema.safeParse()` refuses a `manifest` that belongs to neither stage. Tell the two stages apart by parsing, never by the static type.
- **Why the type is not inferred.** `tsc` refuses to print the whole metadata vocabulary into the declarations that embed it (TS7056). Dropping the record and artifact stages' annotations and the `ZodRawShape` cast fails the declaration build with TS7056 at `PackageApiContracts`. A named alias would turn `stack.zod` into a shared declaration chunk, the heap failure #14513 recorded.
- **The precise form, if the schema depth ever allows it,** is the one #19324 measured as A2, with its cost recorded at `RecordStagePackageBodySchema`.

**`@objectstack/client`**: the `packages.list` TSDoc used to call this asymmetry "a KNOWN GAP rather than a design", tracked on #19324, and cited a `stack.zod.ts` line number. It now calls it the accepted static contract, cites `RecordStagePackageBodySchema` by name, and keeps its advice unchanged: narrow a row by parsing it with a `@objectstack/spec` schema, and never by `Array.isArray(pkg.manifest.objects)`.

This settles what the `@objectstack/client` read-door changeset (#17536) calls "a known gap, tracked as #19324". The gap is not closing under #19324: it is the accepted static contract, and the client pin that records it stays.

⛔ No behaviour changes. No type, schema, accept set, authorable key or export moves. Only TSDoc and source comments change, and they ship:

- `@objectstack/spec`'s published `files[]` carries `dist`, where the TSDoc is emitted into the `.d.ts` / `.d.mts` declarations, and `src/**/*.zod.ts`, so both edited files also ship as source.
- `@objectstack/client`'s published `files[]` carries `dist`, where the rewritten paragraph lands in `index.d.ts`, `index.d.mts`, `index.js` and `index.mjs`.
21 changes: 13 additions & 8 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2513,14 +2513,19 @@ export class ObjectStackClient {
* as either stage, which is the right answer for it — the key such a guess
* would read is not there.)
*
* ⚠️ The RUNTIME half is the strict one, and the asymmetry is a KNOWN GAP
* rather than a design: `InstalledPackageAtEitherStageSchema.safeParse()`
* refuses a `manifest` belonging to neither stage, while that same row
* COMPILES against this declaration. Tracked as #19324, whose root cause is
* the deliberate `z.ZodType<Record<string, unknown>, …>` annotation at
* `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a
* declaration-chunk ceiling); ⛔ not something this declaration can fix, and
* ⛔ not a licence to relax either runtime branch to match the type.
* ⚠️ The RUNTIME half is the strict one, and the asymmetry is the ACCEPTED
* static contract, not a gap waiting to close:
* `InstalledPackageAtEitherStageSchema.safeParse()` refuses a `manifest`
* belonging to neither stage, while that same row COMPILES against this
* declaration. The index signature comes from the deliberate
* `z.ZodType<Record<string, unknown>, …>` annotation on
* `RecordStagePackageBodySchema` in `@objectstack/spec` (the #14513 pattern —
* TS7056 and a declaration-chunk ceiling), and the maintainer ruled on
* #19324 (letter 丙) to keep it: the runtime Zod schema is the enforced
* contract, so narrow by parsing, as above. The precise form, A2, is
* recorded beside `RecordStagePackageBodySchema` for the day the schema
* depth allows it. ⛔ Not something this declaration can fix, and ⛔ not a
* licence to relax either runtime branch to match the type.
*/
list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => {
const route = this.getRoute('packages');
Expand Down
33 changes: 20 additions & 13 deletions packages/client/src/return-type-precision.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -686,14 +686,17 @@ declare const assembledRow: AssembledInstalledPackage;
*
* ⛔ It does NOT measure object-shaped tolerance, and at this head there is
* some: on the assembled branch `manifest` is declared `Record<string, unknown>`
* (the deliberate annotation at `packages/spec/src/stack.zod.ts:1283`, #14513),
* so an object `manifest` belonging to NEITHER stage compiles against these
* members. The runtime is the half that is correct —
* (the deliberate annotation on `RecordStagePackageBodySchema` in
* `packages/spec/src/stack.zod.ts`, the #14513 pattern), so an object
* `manifest` belonging to NEITHER stage compiles against these members. The
* runtime is the half that is correct —
* `InstalledPackageAtEitherStageSchema.safeParse()` refuses that same row, and
* that refusal is pinned beside its producer in
* `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`.
* The type-level gap is #19324's to close; the third pin below records it as the
* behaviour it is, so the day it closes this file says so.
* The type-level gap is the accepted static contract: #19324 was ruled (letter
* 丙) to record it at `RecordStagePackageBodySchema` rather than close it. The
* third pin below records it as the behaviour it is, so the day the body is
* typed precisely this file says so.
*
* ## Ablation, measured rather than asserted
*
Expand Down Expand Up @@ -761,16 +764,20 @@ export function installedPackageEitherStagePins17536(): void {
// ⛔ NOT a guarantee — a measurement, written down so it cannot change in
// silence. `AssembledInstalledPackage['manifest']` is
// `Record<string, unknown>` (from the deliberate
// `z.ZodType<Record<string, unknown>, …>` annotation at
// `packages/spec/src/stack.zod.ts:1283`, #14513 — TS7056 and a
// declaration-chunk ceiling), so the assembled branch admits ANY object and
// the assignment below COMPILES at this head. Measured with `tsc` against the
// published declarations; the runtime disagrees and is the correct half:
// `z.ZodType<Record<string, unknown>, …>` annotation on
// `RecordStagePackageBodySchema` in `packages/spec/src/stack.zod.ts`, the
// #14513 pattern — TS7056 and a declaration-chunk ceiling), so the
// assembled branch admits ANY object and the assignment below COMPILES at
// this head. Measured with `tsc` against the published declarations; the
// runtime disagrees and is the correct half:
// `InstalledPackageAtEitherStageSchema.safeParse()` answers `success: false`
// for this very row.
// for this very row. #19324 was ruled (letter 丙) to keep this as the
// accepted static contract, so this pin stays until the body is typed
// precisely.
//
// ⚠️ There is deliberately no `@ts-expect-error` here. The day #19324 types
// the assembled body, tsc reds on THIS line — and that red is the
// ⚠️ There is deliberately no `@ts-expect-error` here. The day the assembled
// body is typed precisely (the A2 form recorded at
// `RecordStagePackageBodySchema`), tsc reds on THIS line — and that red is the
// notification this pin exists to deliver: read it as "the gap closed", then
// delete this block and tighten the `manifest` guidance on
// `ObjectStackClient.packages.list` in `index.ts`, which sends callers
Expand Down
28 changes: 21 additions & 7 deletions packages/spec/src/api/package-api-assembled.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,13 +79,9 @@ import {
* C and was REJECTED by name: a union AT THE KEY makes neither stage checkable,
* which is the tolerate-at-the-consumer shape Prime Directive #12 refuses. So
* `ManifestSchema` is untouched here — still `strictObject`, still globs — and
* the assembled stage gets its own name, built from `AssembledPackageBodySchema`
* (#14242's own declaration) rather than a second transcription of it.
*
* The body half is deliberately typed `Record<string, unknown>`; the reason is
* recorded at `AssembledPackageBodySchema` and is not repeated here. The RUNTIME
* schema still carries the manifest's every field plus every collection's full
* declaration, so a wrong-shaped body is refused exactly as it is there.
* the assembled stage gets its own name, built from the same body shape as
* `AssembledPackageBodySchema` (#14242's own declaration) rather than a second
* transcription of it, at the record stage the next section describes.
*
* ## The row's manifest is the RECORD stage, not the assembled one
*
Expand Down Expand Up @@ -124,6 +120,24 @@ import {
* members that need the treatment is MEASURED, never hand-picked — pinned
* key-by-key in `./package-api.test.ts`, so a new collection with no JSON form
* reddens there, naming itself.
*
* ## `manifest`'s published type is deliberately an index signature
*
* `RecordStagePackageBodySchema` is declared `z.ZodType<Record<string,
* unknown>, Record<string, unknown>>`, so this row's `manifest` publishes as an
* index signature: an authoring-stage `InstalledPackage` assigns to
* `AssembledInstalledPackage`, and at the type level this arm absorbs the
* authoring arm of {@link InstalledPackageAtEitherStageSchema}. That is the
* accepted static contract — the maintainer ruling on #19324 (letter 丙) — not
* a gap to tighten in passing. The RUNTIME schema is the enforced contract: it
* still carries the manifest's every field plus every collection's full
* declaration, so `.parse()` refuses a wrong-shaped body — tell the two stages
* apart by parsing, never by the static type. It is not inferred because `tsc`
* refuses to print the whole metadata vocabulary into the declarations that
* embed it (TS7056; #14513 measured it on the assembled body, and on this
* stage it fires at `PackageApiContracts` below). The precise form, if the
* schema depth ever allows it, is A2 — the reasons and A2's measured cost are
* recorded once, at `RecordStagePackageBodySchema` in `../stack.zod`.
*/
export const AssembledInstalledPackageSchema = lazySchema(() => InstalledPackageSchema.extend({
manifest: RecordStagePackageBodySchema.describe('The ASSEMBLED package body this row carries, at the stage the registry records it'),
Expand Down
51 changes: 50 additions & 1 deletion packages/spec/src/stack.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1450,8 +1450,57 @@ export type ArtifactStagePackageBodyParsed = z.infer<typeof ArtifactStagePackage
* ⛔ Never widen this to `z.unknown()` to make a row fit. A row that parses
* through neither this stage nor the authoring one is a producer defect, and
* this is the declaration that has to keep saying so.
*
* ## Its published type is deliberately an index signature
*
* This schema is declared `z.ZodType<Record<string, unknown>, Record<string,
* unknown>>`, so the published type of a record-stage body is an index
* signature: any object assigns to it. It is the `manifest` of
* `AssembledInstalledPackageSchema` (`@objectstack/spec/api-assembled`), so an
* authoring-stage `InstalledPackage` assigns to `AssembledInstalledPackage`,
* and a row whose `manifest` belongs to neither stage type-checks as an
* `InstalledPackageAtEitherStage`. That is the accepted static contract — the
* maintainer ruling on #19324 (letter 丙) — not a gap to tighten in passing.
*
* The RUNTIME schema is the enforced contract. It carries the manifest's every
* field, every collection's full declaration and the two lowered members
* above, so `.parse()` refuses a wrong-shaped body member by member. Narrow a
* record-stage body by parsing it, never by trusting its static type.
*
* Why not inferred: the body is the whole metadata vocabulary, and `tsc`
* refuses to print it into the declarations that embed it — TS7056, 「The
* inferred type of this node exceeds the maximum length the compiler will
* serialize」. #14513 measured that on the assembled body; for this stage,
* dropping this annotation, the artifact stage's and the cast below fails the
* declaration build with TS7056 at `PackageApiContracts` (measured on #19324
* at `d1ca8741dd` and again at `3bd28e2b2e`). Why not a named type: #14513
* measured that an alias declared in this module turns `stack.zod` into a
* shared declaration chunk its embedders import, and recorded the heap failure
* that followed beside {@link AssembledPackageBodySchema}; for this stage that
* reading is inherited, not re-measured.
*
* The precise form, if the schema depth ever allows it, is the one #19324
* measured as A2: infer this stage and the artifact stage (drop both
* annotations and the cast), and give compact `typeof`-based annotations to
* the four declarations that embed the body — `PackageApiContracts`,
* `InstalledPackageAtEitherStageSchema`, `ListInstalledPackagesResponseSchema`
* and `GetInstalledPackageResponseSchema`. At `d1ca8741dd` it built, turned
* all five of the gap's measured type readings into compile errors, and left
* the runtime bundles byte-identical. Its cost is declaration size: +20,209
* lines in the then `./api` entry (one expansion of this stage) and +44,342 in
* the root entry (this stage and the artifact stage). The heaviest type-check
* program under CI's heap ceiling, `qa/http-conformance`'s, was not measured
* under it.
*/
/*
* ANNOTATED structurally — see the note on the artifact stage above, and the
* section of this docblock on the published type for what that costs a
* consumer. The `as unknown as z.ZodObject<z.ZodRawShape>` cast below is that
* annotation's price: the artifact stage's declared type no longer says it is
* an object, so `.extend()` is reached through a cast. The cast is type-only
* and emits nothing; at runtime `.extend()` runs on the artifact stage's
* object schema.
*/
/* ANNOTATED structurally — see the note on the artifact stage above. */
export const RecordStagePackageBodySchema: z.ZodType<Record<string, unknown>, Record<string, unknown>> =
lazySchema(() =>
(ArtifactStagePackageBodySchema as unknown as z.ZodObject<z.ZodRawShape>).extend({
Expand Down
Loading