diff --git a/.changeset/projectable-from-domain.md b/.changeset/projectable-from-domain.md new file mode 100644 index 000000000..c60797afa --- /dev/null +++ b/.changeset/projectable-from-domain.md @@ -0,0 +1,8 @@ +--- +"effect-app": patch +"@effect-app/infra": patch +"@effect-app/vue": patch +"@effect-app/vue-components": patch +--- + +Tag-aware `ProjectableFromDomain` for `projectComputed`: projection Encoded fields must exist on the matching domain tagged state (or be computed). Prevents Overview.List SchemaErrors when cancel states omit workflow lock fields like `activeRequest`. diff --git a/packages/effect-app/src/Model/query/dsl.ts b/packages/effect-app/src/Model/query/dsl.ts index b36e67971..86a1c8d60 100644 --- a/packages/effect-app/src/Model/query/dsl.ts +++ b/packages/effect-app/src/Model/query/dsl.ts @@ -62,19 +62,96 @@ type LiteralValue = T extends { readonly literal: infer L } ? L : T type ExtractTagged = From extends { readonly _tag: infer FromTag } ? [LiteralValue] extends [LiteralValue] ? From : never : never -type ProjectableSource = I extends { readonly _tag: infer Tag } ? ExtractTagged +/** + * Domain shape that may supply stored fields for projection member `I`. + * + * - True tagged unions (`A | B` with different fields per `_tag`) resolve to the + * matching member so state-owned keys are not treated as universal. + * - Flat models that only carry `_tag: "a" | "b"` on a shared shape (no + * per-tag members in the Encoded union) fall back to the full `From` so + * existing Class+Literals projections keep typechecking. + */ +type ProjectableSource = I extends { readonly _tag: infer Tag } ? ( + [ExtractTagged] extends [never] ? From : ExtractTagged + ) : From -type ProjectableField = K extends KeysOfUnion ? I - : never -type ProjectableEncoded = I extends FieldValues ? { - [K in keyof I]: ProjectableField< - I[K], - ProjectableSource, - K - > + +/** + * One projection member is projectable when every key is either: + * - in `ExtraKeys` (computed by the query / not stored on the domain row), or + * - a key of the matching domain source member (key presence only; nested + * 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. + */ +/** + * Keys the domain may supply for projection member `I`. + * - Tagged `I`: only keys of the matching domain state. + * - 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 + : 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 -type ProjectableGuard = [I] extends [ProjectableEncoded] ? unknown : never + +/** + * 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. + */ +type IsProjectableMember = [I] extends + [ProjectableEncodedMember] ? true : false + +/** + * `unknown` when every member of projection Encoded `I` is projectable from + * domain Encoded `From` (plus optional ExtraKeys for computed fields); `never` + * otherwise — use as an intersection constraint on a schema argument. + */ +type ProjectableGuard = false extends ( + I extends any ? IsProjectableMember : never +) ? never + : unknown + +/** + * Compile-time proof that a projection Encoded shape only requires stored keys + * that exist on the matching domain tagged state (or non-tagged source), plus + * any `ExtraKeys` filled by `projectComputed` (counts, flags, collects, …). + * + * Catches the class of Overview.List SchemaError where a cancel/recovery state + * omits a workflow lock field (`activeRequest`) in the domain model but the + * projection still requires it on every branch. + * + * @example + * ```ts + * type _ok = ProjectableFromDomain< + * { readonly _tag: "cancelled"; readonly id: string }, + * DomainEnc + * > // unknown + * + * type _bad = ProjectableFromDomain< + * { readonly _tag: "cancelled"; readonly id: string; readonly activeRequest: null }, + * DomainEnc + * > // never — activeRequest is not on domain cancelled + * ``` + */ +export type ProjectableFromDomain< + ProjectionEncoded, + DomainEncoded, + ExtraKeys extends PropertyKey = never +> = ProjectableGuard export type RelationDirection = "some" | "every" export type Relation = { relation: RelationDirection } @@ -839,6 +916,33 @@ const makeComputedHelpers = (): ComputedHelper relation: (path) => relation(path) }) +/** + * Only treat computed-map keys as ExtraKeys when `M` is a concrete object type. + * `ComputedProjectionMap` is `Record`, so `keyof M` is `string` and + * would otherwise allow every projection field as "computed". + */ +type ConcreteComputedKeys = string extends keyof M ? never : Extract + +/** + * Proof that projection Encoded `I` is projectable from domain Encoded, allowing + * concrete computed keys. Intersected onto the computed-map argument so + * inference of `M` is complete before the check runs. + */ +type ProjectableComputedMap< + M extends ComputedProjectionMap, + I extends FieldValues, + Domain +> = + & M + & NoExtraComputedKeys + & ( + [ProjectableGuard>] extends [never] ? { + readonly __projectableFromDomain: + "projection fields must exist on the matching domain tagged state or be computed keys" + } + : unknown + ) + export const projectComputed: { < Q extends Query | QueryWhere | QueryEnd, @@ -848,7 +952,7 @@ export const projectComputed: { E extends boolean = ExtractExclusiveness >( schema: Schema, - build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, + build: (helpers: ComputedHelpers>) => ProjectableComputedMap>, mode: "collect" ): ( current: Q @@ -868,7 +972,7 @@ export const projectComputed: { E extends boolean = ExtractExclusiveness >( schema: Schema, - build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, + build: (helpers: ComputedHelpers>) => ProjectableComputedMap>, mode?: "project" ): ( current: Q @@ -882,7 +986,7 @@ export const projectComputed: { E extends boolean = ExtractExclusiveness >( schema: Schema, - computedProjection: M & NoExtraComputedKeys, + computedProjection: ProjectableComputedMap>, mode: "collect" ): ( current: Q @@ -902,7 +1006,7 @@ export const projectComputed: { E extends boolean = ExtractExclusiveness >( schema: Schema, - computedProjection: M & NoExtraComputedKeys, + computedProjection: ProjectableComputedMap>, mode?: "project" ): ( current: Q diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index aeec79f24..9df5ec8bf 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -4,7 +4,7 @@ import * as Context from "effect-app/Context" import * as Effect from "effect-app/Effect" import * as Layer from "effect-app/Layer" -import { and, computed, count, expr, make, one, or, order, page, project, projectComputed, type QueryEnd, type QueryProjection, type QueryWhere, relation, toFilter, where } from "effect-app/Model/query" +import { and, computed, count, expr, make, one, or, order, page, project, type ProjectableFromDomain, projectComputed, type QueryEnd, type QueryProjection, type QueryWhere, relation, toFilter, where } from "effect-app/Model/query" import { makeRepo } from "effect-app/Model/Repository" import { RepositoryRegistryLive } from "effect-app/Model/Repository/Registry" import * as Option from "effect-app/Option" @@ -662,9 +662,11 @@ it("projectComputed constrains projection schema to encoded repo fields and comp ) make().pipe( + // @ts-expect-error missingField is neither an encoded repo field nor a computed projection projectComputed(S.Struct({ missingField: S.String }), computed({})) ) + // Key presence only (not Encoded value types) — name: number is still projectable. make().pipe( projectComputed( S.Struct({ name: S.Number }), @@ -672,6 +674,8 @@ it("projectComputed constrains projection schema to encoded repo fields and comp ) ) + // Computed field *value* types are not yet constrained against the expression + // result (only key presence). Keeping this as a documentation call site. make().pipe( projectComputed( S.Struct({ itemCount: S.String }), @@ -737,12 +741,103 @@ it("projectComputed supports union projection schemas", () => { ]) }) +/** + * MACS-SCANNER-API-KS class of bug: domain cancel omits pack/print ownership + * (`activeRequest`); a hand-built overview projection that still requires it on + * every branch must fail at the `projectComputed` call site, not in prod decode. + */ +it("projectComputed rejects state-owned fields required on the wrong tagged branch", () => { + const domain = S.Union([ + S.Struct({ + _tag: S.Literal("packing"), + id: S.String, + activeRequest: S.NullOr(S.String), + items: S.Array(S.Struct({ articleId: S.String })) + }), + S.Struct({ + _tag: S.Literal("cancelled"), + id: S.String, + items: S.Array(S.Struct({ articleId: S.String })) + }) + ]) + type DomainEnc = S.Codec.Encoded + + // Good: activeRequest only on packing; cancel omits it; articleCount is computed. + make().pipe( + projectComputed( + S.Union([ + S.Struct({ + _tag: S.Literal("packing"), + id: S.String, + activeRequest: S.NullOr(S.String), + articleCount: S.Number + }), + S.Struct({ + _tag: S.Literal("cancelled"), + id: S.String, + articleCount: S.Number + }) + ]), + computed({ + articleCount: relation("items").count() + }) + ) + ) + + make().pipe( + projectComputed( + S.Union([ + S.Struct({ + _tag: S.Literal("packing"), + id: S.String, + activeRequest: S.NullOr(S.String), + articleCount: S.Number + }), + S.Struct({ + _tag: S.Literal("cancelled"), + id: S.String, + activeRequest: S.NullOr(S.String), + articleCount: S.Number + }) + ]), + // @ts-expect-error activeRequest is not on domain cancelled — cannot project it there + computed({ + articleCount: relation("items").count() + }) + ) + ) +}) + +it("ProjectableFromDomain distributes over tagged union Encoded", () => { + type DomainEnc = + | { readonly _tag: "packing"; readonly id: string; readonly activeRequest: string | null } + | { readonly _tag: "cancelled"; readonly id: string } + + type Good = + | { readonly _tag: "packing"; readonly id: string; readonly activeRequest: string | null; readonly n: number } + | { readonly _tag: "cancelled"; readonly id: string; readonly n: number } + + type Bad = + | { readonly _tag: "packing"; readonly id: string; readonly activeRequest: string | null } + | { readonly _tag: "cancelled"; readonly id: string; readonly activeRequest: string | null } + + type GoodCheck = ProjectableFromDomain + type BadCheck = ProjectableFromDomain + + const _good: GoodCheck = undefined as unknown + // @ts-expect-error cancelled branch requires activeRequest not present on domain cancelled + const _bad: BadCheck = undefined as unknown + void _good + void _bad +}) + it("projection schema with computed fields fails without computed map", () => { const baseSchema = S.Struct({ id: S.String, items: S.Array(S.Struct({ value: S.Number })) }) const query = make>().pipe( + // @ts-expect-error missing computed keys are rejected; also not projectable from domain alone projectComputed(S.Struct({ pickedCount: S.NonNegativeInt }), computed({})) ) expect(() => toFilter(query, baseSchema)).toThrowError("Missing computed projections for schema keys")