Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/projectable-from-domain.md
Original file line number Diff line number Diff line change
@@ -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`.
132 changes: 118 additions & 14 deletions packages/effect-app/src/Model/query/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,19 +62,96 @@ type LiteralValue<T> = T extends { readonly literal: infer L } ? L : T
type ExtractTagged<From, Tag> = From extends { readonly _tag: infer FromTag }
? [LiteralValue<FromTag>] extends [LiteralValue<Tag>] ? From : never
: never
type ProjectableSource<I, From> = I extends { readonly _tag: infer Tag } ? ExtractTagged<From, Tag>
/**
* 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, From> = I extends { readonly _tag: infer Tag } ? (
[ExtractTagged<From, Tag>] extends [never] ? From : ExtractTagged<From, Tag>
)
: From
type ProjectableField<I, From, K extends PropertyKey> = K extends KeysOfUnion<From> ? I
: never
type ProjectableEncoded<I, From> = I extends FieldValues ? {
[K in keyof I]: ProjectableField<
I[K],
ProjectableSource<I, From>,
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, From> = I extends { readonly _tag: any } ? keyof ProjectableSource<I, From>
: KeysOfUnion<From>

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, From> ? I[K]
: never
}
: never
type ProjectableGuard<I, From> = [I] extends [ProjectableEncoded<I, From>] ? 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, From, ExtraKeys extends PropertyKey> = [I] extends
[ProjectableEncodedMember<I, From, ExtraKeys>] ? 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<I, From, ExtraKeys extends PropertyKey = never> = false extends (
I extends any ? IsProjectableMember<I, From, ExtraKeys> : 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<ProjectionEncoded, DomainEncoded, ExtraKeys>

export type RelationDirection = "some" | "every"
export type Relation = { relation: RelationDirection }
Expand Down Expand Up @@ -839,6 +916,33 @@ const makeComputedHelpers = <TFieldValues extends FieldValues>(): ComputedHelper
relation: (path) => relation<TFieldValues, typeof path>(path)
})

/**
* Only treat computed-map keys as ExtraKeys when `M` is a concrete object type.
* `ComputedProjectionMap` is `Record<string, …>`, so `keyof M` is `string` and
* would otherwise allow every projection field as "computed".
*/
type ConcreteComputedKeys<M> = string extends keyof M ? never : Extract<keyof M, PropertyKey>

/**
* 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<M, I>
& (
[ProjectableGuard<I, Domain, ConcreteComputedKeys<M>>] 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<any> | QueryWhere<any, any, any> | QueryEnd<any, "one" | "many", any>,
Expand All @@ -848,7 +952,7 @@ export const projectComputed: {
E extends boolean = ExtractExclusiveness<Q>
>(
schema: Schema,
build: (helpers: ComputedHelpers<ExtractFieldValues<Q>>) => M & NoExtraComputedKeys<M, I>,
build: (helpers: ComputedHelpers<ExtractFieldValues<Q>>) => ProjectableComputedMap<M, I, ExtractFieldValues<Q>>,
mode: "collect"
): (
current: Q
Expand All @@ -868,7 +972,7 @@ export const projectComputed: {
E extends boolean = ExtractExclusiveness<Q>
>(
schema: Schema,
build: (helpers: ComputedHelpers<ExtractFieldValues<Q>>) => M & NoExtraComputedKeys<M, I>,
build: (helpers: ComputedHelpers<ExtractFieldValues<Q>>) => ProjectableComputedMap<M, I, ExtractFieldValues<Q>>,
mode?: "project"
): (
current: Q
Expand All @@ -882,7 +986,7 @@ export const projectComputed: {
E extends boolean = ExtractExclusiveness<Q>
>(
schema: Schema,
computedProjection: M & NoExtraComputedKeys<M, I>,
computedProjection: ProjectableComputedMap<M, I, ExtractFieldValues<Q>>,
mode: "collect"
): (
current: Q
Expand All @@ -902,7 +1006,7 @@ export const projectComputed: {
E extends boolean = ExtractExclusiveness<Q>
>(
schema: Schema,
computedProjection: M & NoExtraComputedKeys<M, I>,
computedProjection: ProjectableComputedMap<M, I, ExtractFieldValues<Q>>,
mode?: "project"
): (
current: Q
Expand Down
97 changes: 96 additions & 1 deletion packages/infra/test/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -662,16 +662,20 @@ it("projectComputed constrains projection schema to encoded repo fields and comp
)

make<Encoded>().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<Encoded>().pipe(
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<Encoded>().pipe(
projectComputed(
S.Struct({ itemCount: S.String }),
Expand Down Expand Up @@ -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<typeof domain>

// Good: activeRequest only on packing; cancel omits it; articleCount is computed.
make<DomainEnc>().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<DomainEnc>("items").count()
})
)
)

make<DomainEnc>().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<DomainEnc>("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<Good, DomainEnc, "n">
type BadCheck = ProjectableFromDomain<Bad, DomainEnc>

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<S.Codec.Encoded<typeof baseSchema>>().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")
Expand Down
Loading