diff --git a/.changeset/19324-record-stage-index-signature-docblock.md b/.changeset/19324-record-stage-index-signature-docblock.md new file mode 100644 index 0000000000..8df32023bf --- /dev/null +++ b/.changeset/19324-record-stage-index-signature-docblock.md @@ -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>`. 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`. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 4f334d3148..7c470ecb5d 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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, …>` 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, …>` 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'); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index b204de42bc..bc04ff849b 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -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` - * (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 * @@ -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` (from the deliberate - // `z.ZodType, …>` 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, …>` 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 diff --git a/packages/spec/src/api/package-api-assembled.zod.ts b/packages/spec/src/api/package-api-assembled.zod.ts index 1be097dfed..84c731fbcf 100644 --- a/packages/spec/src/api/package-api-assembled.zod.ts +++ b/packages/spec/src/api/package-api-assembled.zod.ts @@ -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`; 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 * @@ -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>`, 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'), diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 3c09282f16..dcdfef43bf 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1450,8 +1450,57 @@ export type ArtifactStagePackageBodyParsed = z.infer, Record>`, 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` 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> = lazySchema(() => (ArtifactStagePackageBodySchema as unknown as z.ZodObject).extend({