From d30798484a9e460681755db9479e5092af556639 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:03:52 +0000 Subject: [PATCH 1/2] feat(query): tag-aware ProjectableFromDomain for projectComputed Projection Encoded fields must come from the matching domain tagged state (or from computed keys). Distributes over union Encoded so a cancel branch cannot require pack-only fields like activeRequest (MACS-SCANNER-API-KS class). Flat Class+_tag Literals models fall back to the full Encoded shape. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- .changeset/projectable-from-domain.md | 8 ++ packages/effect-app/src/Model/query/dsl.ts | 111 ++++++++++++++++++--- packages/infra/test/query.test.ts | 96 +++++++++++++++++- 3 files changed, 200 insertions(+), 15 deletions(-) create mode 100644 .changeset/projectable-from-domain.md 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..e1a1b3883 100644 --- a/packages/effect-app/src/Model/query/dsl.ts +++ b/packages/effect-app/src/Model/query/dsl.ts @@ -62,19 +62,86 @@ 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, with a type assignable to the + * domain field (so `name: number` fails when the domain encodes `name` as + * string). + * + * Uses `keyof Source` (not `KeysOfUnion` of the whole domain union) so a field + * owned only by some tags cannot be required on every branch. + */ +type ProjectableEncodedMember< + I, + From, + ExtraKeys extends PropertyKey = never +> = I extends FieldValues ? { + [K in keyof I]-?: K extends ExtraKeys ? I[K] + : K extends keyof ProjectableSource ? ProjectableSource[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 +906,22 @@ const makeComputedHelpers = (): ComputedHelper relation: (path) => relation(path) }) +/** + * `projectComputed` projection schemas must only require: + * - keys present on the matching domain Encoded member (tag-aware), or + * - keys produced by the computed map (`ExtraKeys` = `keyof M`). + * + * Intersected onto the schema argument so a cancel branch that demands a + * pack-only field (`activeRequest`) fails at the call site, not in prod decode. + */ +type ProjectComputedSchema< + Schema extends S.Codec, + Domain, + M extends ComputedProjectionMap +> = + & Schema + & ProjectableGuard, Domain, string & keyof M> + export const projectComputed: { < Q extends Query | QueryWhere | QueryEnd, @@ -847,7 +930,7 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: Schema, + schema: ProjectComputedSchema, M>, build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, mode: "collect" ): ( @@ -867,7 +950,7 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: Schema, + schema: ProjectComputedSchema, M>, build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, mode?: "project" ): ( @@ -881,7 +964,7 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: Schema, + schema: ProjectComputedSchema, M>, computedProjection: M & NoExtraComputedKeys, mode: "collect" ): ( @@ -901,7 +984,7 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: Schema, + schema: ProjectComputedSchema, M>, computedProjection: M & NoExtraComputedKeys, mode?: "project" ): ( diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index aeec79f24..c81858c29 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,16 +662,20 @@ 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({})) ) make().pipe( + // @ts-expect-error repo field name is encoded as string — projection type must match domain Encoded projectComputed( S.Struct({ name: S.Number }), computed({}) ) ) + // 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,6 +741,96 @@ 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( + // @ts-expect-error activeRequest is not on domain cancelled — cannot project it there + 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 + }) + ]), + 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, From ea7e9dc938419ff9348698a7a17d282df446a001 Mon Sep 17 00:00:00 2001 From: "omegent-app[bot]" <306514130+omegent-app[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:08:34 +0000 Subject: [PATCH 2/2] fix(query): infer ProjectableFromDomain via computed map + untagged keys - Constrain the computed-map argument so M is concrete before ExtraKeys apply (Record would otherwise allow every field as "computed"). - Untagged project() DTOs keep KeysOfUnion domain keys; tagged projections use per-state keys. - Key presence only (not Encoded value types) so package view narrowing works. Co-authored-by: Patrick Roza <42661+patroza@users.noreply.github.com> --- packages/effect-app/src/Model/query/dsl.ts | 69 ++++++++++++++-------- packages/infra/test/query.test.ts | 5 +- 2 files changed, 48 insertions(+), 26 deletions(-) diff --git a/packages/effect-app/src/Model/query/dsl.ts b/packages/effect-app/src/Model/query/dsl.ts index e1a1b3883..86a1c8d60 100644 --- a/packages/effect-app/src/Model/query/dsl.ts +++ b/packages/effect-app/src/Model/query/dsl.ts @@ -79,20 +79,30 @@ type ProjectableSource = I extends { readonly _tag: infer Tag } ? ( /** * 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, with a type assignable to the - * domain field (so `name: number` fails when the domain encodes `name` as - * string). + * - 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 keyof ProjectableSource ? ProjectableSource[K] + : K extends ProjectableDomainKeys ? I[K] : never } : never @@ -907,20 +917,31 @@ const makeComputedHelpers = (): ComputedHelper }) /** - * `projectComputed` projection schemas must only require: - * - keys present on the matching domain Encoded member (tag-aware), or - * - keys produced by the computed map (`ExtraKeys` = `keyof M`). - * - * Intersected onto the schema argument so a cancel branch that demands a - * pack-only field (`activeRequest`) fails at the call site, not in prod decode. + * 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 ProjectComputedSchema< - Schema extends S.Codec, - Domain, - M extends ComputedProjectionMap +type ProjectableComputedMap< + M extends ComputedProjectionMap, + I extends FieldValues, + Domain > = - & Schema - & ProjectableGuard, Domain, string & keyof M> + & 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: { < @@ -930,8 +951,8 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: ProjectComputedSchema, M>, - build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, + schema: Schema, + build: (helpers: ComputedHelpers>) => ProjectableComputedMap>, mode: "collect" ): ( current: Q @@ -950,8 +971,8 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: ProjectComputedSchema, M>, - build: (helpers: ComputedHelpers>) => M & NoExtraComputedKeys, + schema: Schema, + build: (helpers: ComputedHelpers>) => ProjectableComputedMap>, mode?: "project" ): ( current: Q @@ -964,8 +985,8 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: ProjectComputedSchema, M>, - computedProjection: M & NoExtraComputedKeys, + schema: Schema, + computedProjection: ProjectableComputedMap>, mode: "collect" ): ( current: Q @@ -984,8 +1005,8 @@ export const projectComputed: { I extends FieldValues = S.Codec.Encoded, E extends boolean = ExtractExclusiveness >( - schema: ProjectComputedSchema, M>, - computedProjection: M & NoExtraComputedKeys, + schema: Schema, + computedProjection: ProjectableComputedMap>, mode?: "project" ): ( current: Q diff --git a/packages/infra/test/query.test.ts b/packages/infra/test/query.test.ts index c81858c29..9df5ec8bf 100644 --- a/packages/infra/test/query.test.ts +++ b/packages/infra/test/query.test.ts @@ -666,8 +666,8 @@ it("projectComputed constrains projection schema to encoded repo fields and comp projectComputed(S.Struct({ missingField: S.String }), computed({})) ) + // Key presence only (not Encoded value types) — name: number is still projectable. make().pipe( - // @ts-expect-error repo field name is encoded as string — projection type must match domain Encoded projectComputed( S.Struct({ name: S.Number }), computed({}) @@ -785,7 +785,6 @@ it("projectComputed rejects state-owned fields required on the wrong tagged bran ) make().pipe( - // @ts-expect-error activeRequest is not on domain cancelled — cannot project it there projectComputed( S.Union([ S.Struct({ @@ -801,6 +800,7 @@ it("projectComputed rejects state-owned fields required on the wrong tagged bran articleCount: S.Number }) ]), + // @ts-expect-error activeRequest is not on domain cancelled — cannot project it there computed({ articleCount: relation("items").count() }) @@ -837,6 +837,7 @@ it("projection schema with computed fields fails without computed map", () => { 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")