/** * Reading and writing the type a dynamic-zone instance announces itself under. * * ## Why this is a function and not a constant * * The storage migration renames this key inside stored JSON. Table and column names survive that * because `resolve-storage-names.ts` asks the CATALOG which spelling a database really uses — but * a key inside a row is not a catalog object. Nothing can observe it, so nothing can resolve it. * * A constant therefore does not help a reader: inlining `STORAGE_FORMAT.wireTypeKey` still yields * exactly one spelling, and a document written under the other one reads as untyped. An instance * whose type cannot be read is intact and unusable — the editor cannot render it, a diff cannot * tag it, and a filter on it matches nothing. * * So the contract callers need is an accessor that TRIES BOTH, in the order the migration itself * uses. Exporting one function also means the flip is a change here rather than a search: the * reason `schemas/storage-format.ts` exists, extended to the one name it cannot cover. * * ## Which order, and why it is that way round * * Read the CURRENT spelling first and fall back to the legacy one. Writes always use the current * spelling, never the legacy one. * * That pairing is what makes a partially rewritten database safe rather than merely survivable. A * migration that stops half way leaves both spellings present across different rows; every one of * them still reads, and any row saved afterwards is written in the current spelling, so the set of * legacy rows only ever shrinks. Writing the legacy spelling — which a hardcoded save path does — * grows it instead, behind a migration that has already reported success. */ /** * Every spelling this key can appear under, most current first. * * The two catalogs still contribute, so a future rename is picked up without editing this list; * the pinned history is what survives their convergence. Deduplicated because before the flip the * catalogs differ and after it they do not, and a reader should not check one spelling twice. * * Exported because several callers need the SET rather than a single answer — building a set of * metadata property names, removing the discriminator however it is spelled. Everything else in * this file is derived from it rather than restating it, so the spellings are enumerated once. * * Order is significant and is the read order: a document carrying two spellings resolves to the * most current one. Callers wanting a single spelling want `currentFieldGroupTypeKey`, which says * so; indexing this is the same bug as hardcoding. */ declare const FIELD_GROUP_TYPE_KEY_NAMES: readonly ["_componentType", "_fieldGroupType", "_componentType"]; /** The spelling new documents are written under. */ declare const currentFieldGroupTypeKey: "_componentType"; /** * The type a dynamic-zone instance announces, or undefined when it announces none. * * Undefined is a real answer rather than an error: a single-field-group instance carries no * discriminator at all, and only the multi shape does. Callers already branch on that. */ declare function readFieldGroupType(instance: unknown): string | undefined; /** * Stamp the type onto an instance, under the spelling this version writes. * * Mutates rather than copying, because the callers are building an object they own — a form's * default values, or a row being assembled. A copy would be the safer default in general; here it * would silently drop the assignment for every caller that does not use the return value. */ /** * The type a given instance is allowed to be told it is. * * 🔴 A generated field group declares its discriminator as a LITERAL — the generator emits * `_componentType: "hero"` — so a signature taking any `string` lets `writeFieldGroupType(hero, * "cta")` compile, mutate the value at runtime, and leave TypeScript still narrowing it as a hero. * Every discriminated-union branch downstream then routes cta data through hero-only code, and the * compiler agrees with the wrong answer. * * So a declared discriminator constrains the argument to itself, and anything else — a plain * record, a deserialised payload — keeps the unconstrained `string` it needs. */ type UnionToIntersection = (U extends unknown ? (x: U) => void : never) extends (x: infer I) => void ? I : never; /** * Every spelling a declared discriminator may use, as TYPES. * * 🔴 Derived from the SAME list the runtime read order is built from, and that is the whole point. * Restating the two catalogs here reproduces, in the type system, the convergence bug the comment * on `HISTORICAL_WIRE_TYPE_KEYS` describes: once the current catalog is flipped to the target * spelling the two names coincide, the historical one is absent from the restated union, and a * legacy-generated interface falls through to `string` — re-admitting exactly the retagging these * helpers exist to reject, in the release where nearly every stored document is still legacy. * Deriving makes the two answer for one world rather than two that drift apart on a flip. */ type FieldGroupTypeKeyName = (typeof FIELD_GROUP_TYPE_KEY_NAMES)[number]; /** The literal a value declares its type as, under whichever spelling it uses. */ type DeclaredFieldGroupType = T[Extract]; /** Whether `T` is a union of more than one member. */ type IsUnion = [T] extends [UnionToIntersection] ? false : true; type WritableFieldGroupType = [DeclaredFieldGroupType] extends [never] ? string : [DeclaredFieldGroupType] extends [string] ? IsUnion extends true ? never : DeclaredFieldGroupType : string; /** * Whether an instance is of a given field group, narrowing it when it is. * * The reader alone cannot narrow: it returns `string | undefined`, which has no relationship to * the value, so `switch (readFieldGroupType(block))` leaves a generated `Hero | Cta` union exactly * as wide as it was and every member-specific property inaccessible inside the matching branch. * That pushes callers back to reading the raw key, which is the one thing this module exists to * stop. * * `Extract` falls back to `T` when it matches nothing, so a caller holding a single concrete type, * or a plain record, keeps what it had instead of being narrowed to `never`. */ declare function isFieldGroupType(instance: T, type: K): instance is NarrowedFieldGroup; type NarrowedFieldGroup = [ Extract, K>> ] extends [never] ? T : Extract, K>>; /** The spellings this version no longer writes, but still reads. */ type SupersededFieldGroupTypeKey = Exclude; /** The literal a value declares under a spelling this version has moved past. */ type DeclaredUnderSupersededKey = T[Extract]; /** * Marks a value this function would leave describing itself incorrectly. * * 🔴 The refusal is the honest answer rather than a conservative one. Canonicalising DELETES every * other spelling, so a value whose declared type requires a superseded key emerges without a * property its own type still promises: reading it back type-checks as the literal and is * `undefined` at runtime, and no narrowing downstream can catch that because the compiler agrees * with the stale shape. Neither alternative works — writing the superseded spelling instead grows * the legacy set behind a migration that has reported success, and mutating in place cannot be * reflected back to the caller's binding. * * What this rejects is a value typed by a GENERATOR that ran against a different storage format, * which is a stale artefact rather than a legitimate shape, and the fix is to regenerate. Reading * such a value is untouched: `readFieldGroupType` and `isFieldGroupType` handle every spelling, * which is where compatibility with an unmigrated database actually has to live. * * A property carries the explanation because a bare `never` parameter renders as "type 'string' is * not assignable to type 'never'", which names neither the cause nor the remedy. */ type SupersededSpellingRefusal = { readonly __nextlyRegenerateTypes: "this value declares its field-group type under a superseded key, which writing would delete; regenerate the types for this version"; }; /** * Nothing, or the refusal, depending on what the value declares. * * The `never` arm is checked first: `Extract` yields `never` for a value declaring no superseded * key, `T[never]` is `never`, and `never extends string` is TRUE — so testing assignability alone * would refuse every well-formed value. * * Applied as `T & SupersededRefusal` rather than as a conditional in the parameter's own * position, because `T` is not inferable from a conditional type and would silently fall back to * its constraint, taking the retagging check down with it. Intersecting keeps `T` inferable from * the argument, and `T & unknown` is `T`, so the permitted case is unchanged. */ type SupersededRefusal = [DeclaredUnderSupersededKey] extends [never] ? unknown : [DeclaredUnderSupersededKey] extends [string] ? SupersededSpellingRefusal : unknown; declare function writeFieldGroupType(instance: T & SupersededRefusal, type: WritableFieldGroupType): void; export { isFieldGroupType, readFieldGroupType, writeFieldGroupType };