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
16 changes: 16 additions & 0 deletions .changeset/restore-tight-project-guard.md
Original file line number Diff line number Diff line change
@@ -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.
95 changes: 61 additions & 34 deletions packages/effect-app/src/Model/query/dsl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,36 @@ type ExtractFieldValuesRefined<T> = T extends QueryTogether<any, infer TFieldVal
? TFieldValuesRefined
: never
type KeysOfUnion<T> = T extends T ? keyof T : never
type LiteralValue<T> = T extends { readonly literal: infer L } ? L : T
/** Peel Schema `tag`/`Literal` brands down to the underlying literal value. */
type LiteralValue<T> = 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<T> = [LiteralValue<T>] extends [never] ? (
string extends T ? string
: [T] extends [PropertyKey] ? T
// object brand (e.g. tag<"packing">): treat as single opaque tag token
: T
)
: LiteralValue<T>
// 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, Tag> = From extends { readonly _tag: infer FromTag }
? [LiteralValue<FromTag>] extends [LiteralValue<Tag>] ? From : never
? [TagKey<FromTag>] extends [TagKey<Tag>] ? From : never
: never
type UnionToIntersection<U> = (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<Tag> = string extends Tag ? false
: [Tag] extends [UnionToIntersection<Tag>] ? true
: false
/**
* Domain shape that may supply stored fields for projection member `I`.
*
Expand All @@ -83,37 +109,48 @@ type ProjectableSource<I, From> = 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, From> = I extends { readonly _tag: any } ? keyof ProjectableSource<I, From>
type ProjectableDomainKeys<I, From> = I extends { readonly _tag: infer Tag } ? (
IsSingleLiteralTag<Tag> extends true ? KeysOfUnion<ProjectableSource<I, From>>
: 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
/**
* 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<I, From, ExtraKeys extends PropertyKey> = {
[K in keyof I]-?: K extends ExtraKeys ? never
: K extends ProjectableDomainKeys<I, From> ? 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, From, ExtraKeys extends PropertyKey> = [I] extends
[ProjectableEncodedMember<I, From, ExtraKeys>] ? true : false
type IsProjectableMember<I, From, ExtraKeys extends PropertyKey> = [I] extends [FieldValues]
? ([UnprojectableKeys<I, From, ExtraKeys>] extends [never] ? true : false)
: false

/**
* `unknown` when every member of projection Encoded `I` is projectable from
Expand Down Expand Up @@ -153,16 +190,6 @@ export type ProjectableFromDomain<
ExtraKeys extends PropertyKey = never
> = ProjectableGuard<ProjectionEncoded, DomainEncoded, ExtraKeys>

/**
* 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, From> = [I] extends [FieldValues]
? (Exclude<keyof I, KeysOfUnion<From> | "_tag"> extends never ? unknown : never)
: unknown

export type RelationDirection = "some" | "every"
export type Relation = { relation: RelationDirection }
export type Query<TFieldValues extends FieldValues> = QueryTogether<TFieldValues, TFieldValues>
Expand Down Expand Up @@ -616,7 +643,7 @@ export const project: {
>(
schema:
& S.Codec<Option.Option<A>, I, R>
& ProjectableKeyGuard<I, ExtractFieldValues<Q>>,
& ProjectableGuard<I, ExtractFieldValues<Q>>,
mode: "collect"
): (
current: Q
Expand All @@ -631,7 +658,7 @@ export const project: {
>(
schema:
& S.Codec<A, I, R>
& ProjectableKeyGuard<I, ExtractFieldValues<Q>>,
& ProjectableGuard<I, ExtractFieldValues<Q>>,
mode: "project"
): (
current: Q
Expand All @@ -645,7 +672,7 @@ export const project: {
>(
schema:
& S.Codec<A, I, R>
& ProjectableKeyGuard<I, ExtractFieldValues<Q>>
& ProjectableGuard<I, ExtractFieldValues<Q>>
): (
current: Q
) => QueryProjection<ExtractFieldValuesRefined<Q>, A, R, ExtractTType<Q>, E>
Expand Down
28 changes: 28 additions & 0 deletions packages/infra/test/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Good, DomainEnc>
type BadFlatCheck = ProjectableFromDomain<BadFlat, DomainEnc>

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,
Expand Down
Loading