diff --git a/.changeset/restore-tight-project-guard.md b/.changeset/restore-tight-project-guard.md new file mode 100644 index 000000000..58d2bfc21 --- /dev/null +++ b/.changeset/restore-tight-project-guard.md @@ -0,0 +1,16 @@ +--- +"effect-app": patch +"@effect-app/infra": patch +"@effect-app/vue": patch +"@effect-app/vue-components": patch +--- + +Keep tag-aware `ProjectableGuard` on both `project()` and `projectComputed` (no loose key-only paper-over). + +Hardening: + +- single-literal tags allow dual same-tag domain variants (KeysOfUnion of matched members) +- multi-tag / string tags still require keys on every matched member +- optional projection/domain keys checked by key presence only (not optional-assignability) + +Call sites must project domain-owned fields per tagged state. diff --git a/packages/effect-app/src/Model/query/dsl.ts b/packages/effect-app/src/Model/query/dsl.ts index 8cbd7b166..f6cd53ea2 100644 --- a/packages/effect-app/src/Model/query/dsl.ts +++ b/packages/effect-app/src/Model/query/dsl.ts @@ -58,10 +58,36 @@ type ExtractFieldValuesRefined = T extends QueryTogether = T extends T ? keyof T : never -type LiteralValue = T extends { readonly literal: infer L } ? L : T +/** Peel Schema `tag`/`Literal` brands down to the underlying literal value. */ +type LiteralValue = T extends { readonly literal: infer L } ? L + : T extends string | number | boolean | null | bigint ? T + // Schema.tag<"x"> / withConstructorDefault wrappers are object brands whose + // nominal shape is not a PropertyKey — fall back to never so callers treat + // them via UnwrapTag below. + : never +// Last-resort: if T is a single object brand wrapping a string literal in its +// structure, treat non-union object tags as single-literal for same-tag duals. +type TagKey = [LiteralValue] extends [never] ? ( + string extends T ? string + : [T] extends [PropertyKey] ? T + // object brand (e.g. tag<"packing">): treat as single opaque tag token + : T + ) + : LiteralValue +// One-directional: domain tag may be a single literal that is a member of a +// multi-tag projection (`"picking" extends "picking"|"picked"`). Bidirectional +// equality would reject every multi-tag flat projection. type ExtractTagged = From extends { readonly _tag: infer FromTag } - ? [LiteralValue] extends [LiteralValue] ? From : never + ? [TagKey] extends [TagKey] ? From : never : never +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never +/** + * True when `Tag` is a single tag token (string literal or opaque brand like + * Schema.tag<"packing">), not a union of tags and not bare `string`. + */ +type IsSingleLiteralTag = string extends Tag ? false + : [Tag] extends [UnionToIntersection] ? true + : false /** * Domain shape that may supply stored fields for projection member `I`. * @@ -83,37 +109,48 @@ type ProjectableSource = I extends { readonly _tag: infer Tag } ? ( * field types may be narrowed by the projection). * * Uses `keyof Source` (not `KeysOfUnion` of the whole domain union) so a field - * owned only by some tags cannot be required on every branch. + * owned only by some tags cannot be required on every branch — except for a + * single literal tag that maps to several domain variants (same `_tag`, + * different payloads), where `KeysOfUnion` allows each variant's keys. */ /** * Keys the domain may supply for projection member `I`. - * - Tagged `I`: only keys of the matching domain state. + * - Single-literal tagged `I` (`_tag: "packing"`): keys of *any* same-tag + * domain variant (`KeysOfUnion`) — dual packing/closed shapes stay projectable. + * - Multi-tag / string-tagged `I` (`_tag: "a"|"b"` or `string`): keys of the + * *intersection* of matched domain members (`keyof` of the source union) so + * a flat multi-tag DTO cannot claim state-only fields (e.g. `batchId` on + * `initial`). * - Untagged `I` (plain project DTOs): keys present on *any* domain member * (`KeysOfUnion`), matching historical `project()` behavior. */ -type ProjectableDomainKeys = I extends { readonly _tag: any } ? keyof ProjectableSource +type ProjectableDomainKeys = I extends { readonly _tag: infer Tag } ? ( + IsSingleLiteralTag extends true ? KeysOfUnion> + : keyof ProjectableSource + ) : KeysOfUnion -type ProjectableEncodedMember< - I, - From, - ExtraKeys extends PropertyKey = never -> = I extends FieldValues ? { - // Keep `I[K]` (key presence only). Requiring domain field types would reject - // legitimate projections that narrow nested shapes (e.g. package views). - [K in keyof I]-?: K extends ExtraKeys ? I[K] - : K extends ProjectableDomainKeys ? I[K] - : never - } - : never +/** + * Keys on projection member `I` that are neither computed (`ExtraKeys`) nor + * present on the matching domain source. Key presence only — nested field + * types may be narrowed by the projection, and optional domain/projection + * keys must not fail via `{ k?: T } extends { k: T }` (the old `-?` mapped + * assignability check rejected legitimate optionalKey fields like closed.batchId). + */ +type UnprojectableKeys = { + [K in keyof I]-?: K extends ExtraKeys ? never + : K extends ProjectableDomainKeys ? never + : K +}[keyof I] /** * Distribute over tagged-union projection Encoded types. A non-distributive - * `[I] extends [...]` check against a union only sees `keyof (A|B)` (key - * intersection) and misses branch-only fields like cancel-only omissions. + * check against a union only sees `keyof (A|B)` (key intersection) and misses + * branch-only fields like cancel-only omissions. */ -type IsProjectableMember = [I] extends - [ProjectableEncodedMember] ? true : false +type IsProjectableMember = [I] extends [FieldValues] + ? ([UnprojectableKeys] extends [never] ? true : false) + : false /** * `unknown` when every member of projection Encoded `I` is projectable from @@ -153,16 +190,6 @@ export type ProjectableFromDomain< ExtraKeys extends PropertyKey = never > = ProjectableGuard -/** - * Loose key-presence guard for {@link project}: every projection key must exist - * on *some* domain member (`KeysOfUnion`). Does **not** enforce per-tag - * ownership — views may reshape freely. Use {@link ProjectableFromDomain} / - * `projectComputed` when tag-scoped ownership matters. - */ -type ProjectableKeyGuard = [I] extends [FieldValues] - ? (Exclude | "_tag"> extends never ? unknown : never) - : unknown - export type RelationDirection = "some" | "every" export type Relation = { relation: RelationDirection } export type Query = QueryTogether @@ -616,7 +643,7 @@ export const project: { >( schema: & S.Codec, I, R> - & ProjectableKeyGuard>, + & ProjectableGuard>, mode: "collect" ): ( current: Q @@ -631,7 +658,7 @@ export const project: { >( schema: & S.Codec - & ProjectableKeyGuard>, + & ProjectableGuard>, mode: "project" ): ( current: Q @@ -645,7 +672,7 @@ export const project: { >( schema: & S.Codec - & ProjectableKeyGuard> + & ProjectableGuard> ): ( current: Q ) => QueryProjection, A, R, ExtractTType, E> diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index 9df5ec8bf..680c7e5f1 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -831,6 +831,34 @@ it("ProjectableFromDomain distributes over tagged union Encoded", () => { void _bad }) +it("ProjectableFromDomain allows dual same-tag domain variants", () => { + // EasyLife packing/closed: two domain shapes share `_tag` with different fields. + type DomainEnc = + | { readonly _tag: "packing"; readonly id: string; readonly packages: readonly string[] } + | { readonly _tag: "packing"; readonly id: string; readonly units: readonly string[] } + | { readonly _tag: "initial"; readonly id: string } + + type Good = + | { readonly _tag: "packing"; readonly id: string; readonly packages: readonly string[] } + | { readonly _tag: "packing"; readonly id: string; readonly units: readonly string[] } + + // Flat multi-tag + state-only field must still fail (batchId not on initial). + type BadFlat = { + readonly _tag: "packing" | "initial" + readonly id: string + readonly packages: readonly string[] + } + + type GoodCheck = ProjectableFromDomain + type BadFlatCheck = ProjectableFromDomain + + const _good: GoodCheck = undefined as unknown + // @ts-expect-error packages is not on domain initial; multi-tag flat intersection rejects it + const _badFlat: BadFlatCheck = undefined as unknown + void _good + void _badFlat +}) + it("projection schema with computed fields fails without computed map", () => { const baseSchema = S.Struct({ id: S.String,