From 9d08b914458fcbd38534aff52d72d9fde164dd02 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Thu, 6 Aug 2026 15:06:27 -0700 Subject: [PATCH 1/4] feat: add t.variant for union members that share a typename Co-Authored-By: Claude Fable 5 --- .changeset/variant-union-dispatch.md | 10 + docs/src/app/core/types/page.md | 35 +- packages/fetchium/src/LiveCollection.ts | 42 +- packages/fetchium/src/QueryClient.ts | 6 +- .../src/__tests__/variant-unions.test.ts | 389 ++++++++++++++++++ packages/fetchium/src/errors.ts | 12 +- packages/fetchium/src/parseEntities.ts | 22 +- packages/fetchium/src/typeDefs.ts | 143 ++++++- packages/fetchium/src/types.ts | 14 +- 9 files changed, 655 insertions(+), 18 deletions(-) create mode 100644 .changeset/variant-union-dispatch.md create mode 100644 packages/fetchium/src/__tests__/variant-unions.test.ts diff --git a/.changeset/variant-union-dispatch.md b/.changeset/variant-union-dispatch.md new file mode 100644 index 0000000..739925d --- /dev/null +++ b/.changeset/variant-union-dispatch.md @@ -0,0 +1,10 @@ +--- +'fetchium': minor +--- + +Add `t.variant(value)`: a variant tag for union members that share a typename. `t.typename` keeps establishing entity identity (the `[typename, id]` cache key); `t.variant` selects which shape a payload parses as, so one entity type can have multiple shapes discriminated by a separate tag field. Parsing and live-collection event routing dispatch on the variant wherever members share a typename. + +Two behavior changes: + +- Unions now throw at definition time when two members share a typename without declaring variants, or collide on a `(typename, variant)` pair. Previously the last member silently overwrote the first, so payloads of the other shape failed validation and were dropped from arrays and mutation events. +- Live collections whose entity defs share a typename without variants (possible via `t.liveValue`, which involves no union) previously checked every event against the last def only; events now route to the first def the entity's data satisfies. diff --git a/docs/src/app/core/types/page.md b/docs/src/app/core/types/page.md index d3e7bbd..a5c7ddf 100644 --- a/docs/src/app/core/types/page.md +++ b/docs/src/app/core/types/page.md @@ -62,6 +62,7 @@ In addition to these basic primitives, there are a number of additional special | `t.enum(...values)` | Union of literals | One of a set of constant values | | `t.enum.caseInsensitive(...values)` | Union of literals | Case-insensitive set of values. All values get coerced to the casing in the _definition_. While not _recommended_, this is helpful for legacy APIs which may have inconsistent casing | | `t.typename(value)` | Literal string | Type identifier for object and [Entity](/core/entities) types | +| `t.variant(value)` | Literal string | Variant tag for object and [Entity](/core/entities) types that share a typename in a union. Validates like `t.const(value)`; see [Unions with Shared Typenames](#unions-with-shared-typenames) | | `t.id` | `string \| number` | Identifier for [Entity](/core/entities) types | | `t.result(type)` | `ParseResult` | Parse result for explicit handling of parse errors | | `t.format(name)` | Registered format type | Formatted string or number value, such as `date` or `date-time`. Formatted values are serialized and deserialized via a registered format function, and types are registered in a global registry | @@ -208,7 +209,7 @@ This is a massive performance penalty on one of the most common patterns in API This brings us to Fetchium's _first_ major restriction on unions: -> **Object/entity unions must be discriminated.** When a union contains multiple object or entity types, each must have a _type_ field, denoted with `t.typename(...)`. This field can be _any_ field (you can call it `type` or `typename` or `__typename` or anything else that is a valid string), but ALL objects in a union must have the _same_ typename field, and each object must have a _unique_ typename _value_. +> **Object/entity unions must be discriminated.** When a union contains multiple object or entity types, each must have a _type_ field, denoted with `t.typename(...)`. This field can be _any_ field (you can call it `type` or `typename` or `__typename` or anything else that is a valid string), but ALL objects in a union must have the _same_ typename field, and each object must be unique within the union: either by its typename _value_, or by its `(typename, variant)` pair when members deliberately share a typename (see [Unions with Shared Typenames](#unions-with-shared-typenames) below). So for example, to define our `TextItem` and `ImageItem` types, we could do the following: @@ -273,6 +274,38 @@ const ImageItem = t.object({ const FeedItem = t.union(TextItem, ImageItem); ``` +### Unions with Shared Typenames + +Sometimes one entity type has multiple _shapes_: the API returns a single conceptual type, with one typename and one id space, but payloads come in two or more forms selected by a separate tag field. The typename can't discriminate such a union, because it is the same for every member. Declaring the tag field with `t.variant(...)` lets the union dispatch on it instead: + +```ts +// ✅ Valid, shared typename discriminated by variant +class ImagePost extends Entity { + __typename = t.typename('Post'); + id = t.id; + kind = t.variant('image'); + url = t.string; +} + +class GalleryPost extends Entity { + __typename = t.typename('Post'); + id = t.id; + kind = t.variant('gallery'); + images = t.array(t.entity(ImagePost)); +} + +const Post = t.union(t.entity(ImagePost), t.entity(GalleryPost)); +``` + +`t.typename` establishes the entity's _identity_: all variants share one cache key space, `[typename, id]`, and [mutation events](/core/streaming) target the shared typename. `t.variant` only selects which shape a payload parses as; it is not part of identity, and validates exactly like `t.const(value)`. + +Two rules follow from this: + +- All members sharing a typename must declare the _same_ variant field. +- Each member must have a _unique_ variant value within its typename. + +Because ids are shared across variants of a typename, the API must guarantee that ids never collide between two variants; two values with the same id are the same entity as far as the cache is concerned. + ### Unions of Collections The other major pain point in parsing is _unions of collections_. To be clear, we are not talking about _collections of unions_. To illustrate: diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index 5814b7f..bf307a1 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -12,6 +12,29 @@ import { } from './ConstraintMatcher.js'; import { ValidatorDef, WRAPPED_VALUE } from './typeDefs.js'; +/** + * Pick the def matching the entity's variant tag. Returns the single def + * directly, and undefined when several defs share the typename but none (or + * no data) resolves a variant. + */ +function resolveEventDef( + defs: ValidatorDef[], + data: Record | undefined, +): ValidatorDef | undefined { + if (defs.length === 1) return defs[0]; + if (data === undefined) return undefined; + for (const def of defs) { + if ( + def.variantValue !== undefined && + def.variantField !== undefined && + data[def.variantField] === def.variantValue + ) { + return def; + } + } + return undefined; +} + function buildKeySet(items: unknown[]): Set { const keys = new Set(); for (const item of items) { @@ -61,7 +84,7 @@ export class LiveCollectionBinding { _queryClient: QueryClient; _parent: LiveCollectionParent; _constraintHashes: Map; - _entityDefsByTypename: Map>; + _entityDefsByTypename: Map[]>; _constraintFieldRefs: Map>; readonly instance: LiveInstance; @@ -82,7 +105,12 @@ export class LiveCollectionBinding { this._entityDefsByTypename = new Map(); for (const def of entityDefs) { if (def.typenameValue !== undefined) { - this._entityDefsByTypename.set(def.typenameValue, def); + const existing = this._entityDefsByTypename.get(def.typenameValue); + if (existing === undefined) { + this._entityDefsByTypename.set(def.typenameValue, [def]); + } else if (!existing.includes(def)) { + existing.push(def); + } } } @@ -117,11 +145,12 @@ export class LiveCollectionBinding { onMatch?: () => void, deleteData?: Record, ): void { - const def = this._entityDefsByTypename.get(typename); - if (def === undefined) return; + const defs = this._entityDefsByTypename.get(typename); + if (defs === undefined) return; const entityInstance = this._queryClient.entityMap.getEntity(entityKey); if (eventType === 'delete') { + const def = resolveEventDef(defs, entityInstance?.data ?? deleteData) ?? defs[0]; const entity = entityInstance !== undefined ? entityInstance.getProxy(def as unknown as EntityDef) : deleteData; if (entity !== undefined) { this.instance.onEvent(entityKey, entity, deleteData ?? entityInstance?.data ?? {}, 'delete'); @@ -131,6 +160,11 @@ export class LiveCollectionBinding { } if (entityInstance === undefined) return; + let def = resolveEventDef(defs, entityInstance.data); + // Members without variants sharing a typename: fall back to the first def + // the entity's current data satisfies. + def ??= defs.find(d => entityInstance.satisfiesDef(d as unknown as ValidatorDef)); + if (def === undefined) return; if (!entityInstance.satisfiesDef(def as unknown as ValidatorDef)) return; onMatch?.(); diff --git a/packages/fetchium/src/QueryClient.ts b/packages/fetchium/src/QueryClient.ts index 6cf4767..1722492 100644 --- a/packages/fetchium/src/QueryClient.ts +++ b/packages/fetchium/src/QueryClient.ts @@ -476,8 +476,10 @@ export class QueryClient { } registerLiveCollection(binding: LiveCollectionBinding): void { - for (const [typename, def] of binding._entityDefsByTypename) { - this.registerEntityDef(def); + for (const [typename, defs] of binding._entityDefsByTypename) { + for (const def of defs) { + this.registerEntityDef(def); + } this.getOrCreateMatcher(typename).registerBinding(binding, typename); } } diff --git a/packages/fetchium/src/__tests__/variant-unions.test.ts b/packages/fetchium/src/__tests__/variant-unions.test.ts new file mode 100644 index 0000000..15386ed --- /dev/null +++ b/packages/fetchium/src/__tests__/variant-unions.test.ts @@ -0,0 +1,389 @@ +import { describe, it, expect } from 'vitest'; +import { t } from '../typeDefs.js'; +import { Entity } from '../proxy.js'; +import { RESTQuery } from '../rest/index.js'; +import { fetchQuery } from '../query.js'; +import { parseValue } from '../parseEntities.js'; +import { testWithClient, sleep, setupTestClient } from './utils.js'; +import type { MutationEvent } from '../types.js'; + +/** + * Union members sharing a typename dispatch on their `t.variant` field; the + * typename stays the identity (cache key). Members must be unique by + * (typename, variant), enforced at definition time. + */ + +class TextPost extends Entity { + __typename = t.typename('Post'); + id = t.id; + kind = t.variant('text'); + body = t.string; + likes = t.number; +} + +class ThreadPost extends Entity { + __typename = t.typename('Post'); + id = t.id; + kind = t.variant('thread'); + title = t.string; + likes = t.number; +} + +const PostRow = t.union(t.entity(TextPost), t.entity(ThreadPost)); + +function compareLikes(a: unknown, b: unknown) { + const left = a as { id: string; likes: number }; + const right = b as { id: string; likes: number }; + + return right.likes - left.likes || String(left.id).localeCompare(String(right.id)); +} + +const emitters = new Map void>(); + +class LiveFeed extends RESTQuery { + params = { user: t.string }; + path = '/feed'; + searchParams = { user: this.params.user }; + result = { + items: t.liveArray([ThreadPost, TextPost] as Array ThreadPost | TextPost>, { sort: compareLikes }), + cursor: t.optional(t.string), + }; + + getConfig() { + return { + staleTime: 60_000, + subscribe: (onEvent: (event: MutationEvent) => void) => { + const user = this.params.user as unknown as string; + emitters.set(user, onEvent); + return () => { + emitters.delete(user); + }; + }, + }; + } +} + +/** Emit through the query's captured onEvent outside any reactive context. */ +async function emit(user: string, event: MutationEvent): Promise { + await new Promise(resolve => { + setTimeout(() => { + const send = emitters.get(user); + if (send === undefined) throw new Error(`no active subscription for ${user}`); + send(event); + resolve(); + }, 0); + }); + await sleep(10); +} + +function ids(relayValue: unknown): string[] { + const value = relayValue as { items: Array<{ id: string }> }; + return value.items.map(item => String(item.id)); +} + +const threadRow = (id: string, likes: number) => ({ + __typename: 'Post', + id, + kind: 'thread', + title: `Thread ${id}`, + likes, +}); + +const textRow = (id: string, likes: number) => ({ + __typename: 'Post', + id, + kind: 'text', + body: `Post ${id}`, + likes, +}); + +describe('variant unions', () => { + describe('definition guards', () => { + // Construction rules are def-kind-agnostic, so these use plain shapes. + // The entity path is covered by the liveArray and same-def tests. + const TextShape = t.object({ __typename: t.typename('Post'), kind: t.variant('text'), body: t.string }); + + it('throws on duplicate typenames without variants', () => { + const A = t.object({ __typename: t.typename('Post'), body: t.string }); + const B = t.object({ __typename: t.typename('Post'), url: t.string }); + + expect(() => t.union(A, B)).toThrow(/Duplicate typename value 'Post' in union/); + }); + + it('throws on duplicate typenames when liveArray entity classes lack variants', () => { + class A extends Entity { + __typename = t.typename('Post'); + id = t.id; + body = t.string; + } + class B extends Entity { + __typename = t.typename('Post'); + id = t.id; + url = t.string; + } + + expect(() => t.liveArray([A, B] as Array A | B>)).toThrow(/Duplicate typename value 'Post' in union/); + }); + + it('throws on a duplicate (typename, variant) pair', () => { + const OtherTextShape = t.object({ __typename: t.typename('Post'), kind: t.variant('text'), text: t.string }); + + expect(() => t.union(TextShape, OtherTextShape)).toThrow(/Duplicate variant value 'text' for typename 'Post'/); + }); + + it('throws when variants of one typename use different fields', () => { + const FlavorShape = t.object({ __typename: t.typename('Post'), flavor: t.variant('link'), url: t.string }); + + expect(() => t.union(TextShape, FlavorShape)).toThrow(/Union variant field conflict/); + }); + + it('throws when a typename mixes variant and non-variant members', () => { + const PlainShape = t.object({ __typename: t.typename('Post'), body: t.string }); + + expect(() => t.union(TextShape, PlainShape)).toThrow(/Duplicate typename value 'Post' in union/); + expect(() => t.union(PlainShape, TextShape)).toThrow(/Duplicate typename value 'Post' in union/); + }); + + it('throws on a duplicate variant field within one definition', () => { + expect(() => + t.object({ __typename: t.typename('Post'), kind: t.variant('text'), other: t.variant('link') }), + ).toThrow(/Duplicate variant field: other/); + }); + + it('allows the same definition to appear twice', () => { + expect(() => t.union(t.entity(TextPost), t.entity(TextPost))).not.toThrow(); + }); + }); + + describe('parse dispatch', () => { + it('dispatches each payload to the member matching its variant', () => { + const text = parseValue(textRow('p1', 1), PostRow, ''); + expect((text as { body: string }).body).toBe('Post p1'); + + const thread = parseValue(threadRow('t1', 3), PostRow, ''); + expect((thread as { title: string }).title).toBe('Thread t1'); + }); + + it('validates fields against the resolved variant, not another member', () => { + // 'text' variant requires body; title from the thread shape does not satisfy it + expect(() => + parseValue({ __typename: 'Post', id: 'p1', kind: 'text', title: 'Thread p1', likes: 1 }, PostRow, ''), + ).toThrow(/body/); + }); + + it('throws a typed error for an unknown variant value', () => { + expect(() => parseValue({ __typename: 'Post', id: 'p1', kind: 'poll', likes: 1 }, PostRow, '')).toThrow( + /Unknown variant 'poll' for typename 'Post'/, + ); + }); + + it('throws a typed error when the variant field is missing', () => { + expect(() => parseValue({ __typename: 'Post', id: 'p1', body: 'hi', likes: 1 }, PostRow, '')).toThrow( + /Unknown variant 'undefined' for typename 'Post'/, + ); + }); + + it('degrades an optional union field to undefined on an unknown variant', () => { + const Card = t.object({ pinned: t.optional(PostRow), label: t.string }); + + const parsed = parseValue({ pinned: { __typename: 'Post', id: 'p1', kind: 'poll' }, label: 'ok' }, Card, '') as { + pinned: unknown; + label: string; + }; + expect(parsed.pinned).toBeUndefined(); + expect(parsed.label).toBe('ok'); + }); + }); + + describe('entity arrays declared as a single variant', () => { + const getClient = setupTestClient(); + + class PinnedFeed extends RESTQuery { + path = '/pinned-feed'; + result = { + rows: t.array(PostRow), + pinned: t.array(t.entity(TextPost)), + }; + } + + it('rejects a sibling-variant payload at parse, like any literal mismatch', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/pinned-feed', { rows: [], pinned: [textRow('p1', 1), threadRow('t1', 2)] }); + + await testWithClient(client, async () => { + const relay = fetchQuery(PinnedFeed); + await relay; + + const value = relay.value as { pinned: Array<{ id: string }> }; + expect(value.pinned.map(p => String(p.id))).toEqual(['p1']); + }); + }); + + it('keeps array reads intact when sibling variant defs are registered', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/pinned-feed', { + rows: [threadRow('t1', 300), textRow('p2', 100)], + pinned: [textRow('p1', 1)], + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(PinnedFeed); + await relay; + + // `rows` parsed both variants, so 'Post' has two registered defs and + // reading `pinned` goes through the multi-def array filter. + const value = relay.value as { pinned: Array<{ id: string; body: string }> }; + expect(value.pinned.map(p => String(p.id))).toEqual(['p1']); + expect(value.pinned[0].body).toBe('Post p1'); + }); + }); + }); + + describe('live arrays over variant entities', () => { + const getClient = setupTestClient(); + + it('parses mixed variants from the initial page and routes membership events per variant', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/feed', { + items: [threadRow('t1', 300), textRow('p1', 100)], + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(LiveFeed, { user: 'u1' }); + await relay; + + // Both variants survive the initial parse + expect(ids(relay.value)).toEqual(['t1', 'p1']); + const items = (relay.value as { items: unknown[] }).items; + expect(items[0]).toBeInstanceOf(ThreadPost); + expect(items[1]).toBeInstanceOf(TextPost); + + // Create for the thread variant inserts + await emit('u1', { type: 'create', typename: 'Post', data: threadRow('t2', 200) }); + expect(ids(relay.value)).toEqual(['t1', 't2', 'p1']); + + // Create for the text variant inserts + await emit('u1', { type: 'create', typename: 'Post', data: textRow('p2', 50) }); + expect(ids(relay.value)).toEqual(['t1', 't2', 'p1', 'p2']); + + // Partial field update re-sorts without changing membership + await emit('u1', { + type: 'update', + typename: 'Post', + id: 'p1', + data: { __typename: 'Post', id: 'p1', likes: 400 }, + }); + expect(ids(relay.value)).toEqual(['p1', 't1', 't2', 'p2']); + + // Delete removes regardless of variant + await emit('u1', { type: 'delete', typename: 'Post', id: 't1', data: 't1' }); + expect(ids(relay.value)).toEqual(['p1', 't2', 'p2']); + }); + }); + + it('applies a create event for a variant absent from the initial page', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/feed', { + items: [textRow('p1', 100)], + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(LiveFeed, { user: 'u2' }); + await relay; + + expect(ids(relay.value)).toEqual(['p1']); + + // No thread row has been parsed yet; the def must already be + // registered so the event parses against a complete merged def. + await emit('u2', { type: 'create', typename: 'Post', data: threadRow('t1', 300) }); + + expect(ids(relay.value)).toEqual(['t1', 'p1']); + const items = (relay.value as { items: unknown[] }).items; + expect(items[0]).toBeInstanceOf(ThreadPost); + expect((items[0] as ThreadPost).title).toBe('Thread t1'); + }); + }); + }); + + describe('multi-def typenames without variants', () => { + const getClient = setupTestClient(); + + // No union is involved for liveValue, so same-typename defs without + // variants are allowed; events route to the first def the entity's + // current data satisfies. + class ReadingMetric extends Entity { + __typename = t.typename('Metric'); + id = t.id; + reading = t.number; + } + + class StatusMetric extends Entity { + __typename = t.typename('Metric'); + id = t.id; + status = t.string; + } + + class LiveMetrics extends RESTQuery { + params = { user: t.string }; + path = '/metrics'; + searchParams = { user: this.params.user }; + result = { + eventCount: t.liveValue( + t.number, + [ReadingMetric, StatusMetric] as Array ReadingMetric | StatusMetric>, + { + onCreate: (v: number) => v + 1, + onUpdate: (v: number) => v, + onDelete: (v: number) => v - 1, + }, + ), + }; + + getConfig() { + return { + staleTime: 60_000, + subscribe: (onEvent: (event: MutationEvent) => void) => { + const user = this.params.user as unknown as string; + emitters.set(user, onEvent); + return () => { + emitters.delete(user); + }; + }, + }; + } + } + + it('routes events for every def sharing the typename, not just the last', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/metrics', { eventCount: 0 }); + + await testWithClient(client, async () => { + const relay = fetchQuery(LiveMetrics, { user: 'm1' }); + await relay; + + const count = () => (relay.value as { eventCount: number }).eventCount; + expect(count()).toBe(0); + + // Satisfies only the FIRST def; previously the binding kept only the + // last def per typename, so this event was silently dropped. + await emit('m1', { type: 'create', typename: 'Metric', data: { __typename: 'Metric', id: 'r1', reading: 10 } }); + expect(count()).toBe(1); + + await emit('m1', { + type: 'create', + typename: 'Metric', + data: { __typename: 'Metric', id: 's1', status: 'ok' }, + }); + expect(count()).toBe(2); + + await emit('m1', { type: 'delete', typename: 'Metric', id: 'r1', data: 'r1' }); + expect(count()).toBe(1); + }); + }); + }); +}); diff --git a/packages/fetchium/src/errors.ts b/packages/fetchium/src/errors.ts index 79f4ff7..270975c 100644 --- a/packages/fetchium/src/errors.ts +++ b/packages/fetchium/src/errors.ts @@ -143,13 +143,19 @@ export function typeToString(type: InternalObjectFieldTypeDef): string { return 'unknown'; } -/** Union payload's `__typename` matched no known variant. Typed so callers can degrade optional fields to `undefined` and surface required ones. */ +/** Union payload's typename (or variant, for members sharing a typename) matched no known member. Typed so callers can degrade optional fields to `undefined` and surface required ones. */ export class UnknownUnionVariantError extends Error { readonly typename: string; - constructor(typename: string, path?: string) { - super(`Unknown typename '${typename}' in union${path ? ` at ${path}` : ''}`); + readonly variant: string | undefined; + constructor(typename: string, path?: string, variant?: string) { + super( + variant === undefined + ? `Unknown typename '${typename}' in union${path ? ` at ${path}` : ''}` + : `Unknown variant '${variant}' for typename '${typename}' in union${path ? ` at ${path}` : ''}`, + ); this.name = 'UnknownUnionVariantError'; this.typename = typename; + this.variant = variant; } } diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index 3a35b6f..f5df100 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -7,7 +7,7 @@ import { hashValue } from 'signalium/utils'; import type { QueryClient, PreloadedEntityMap } from './QueryClient.js'; -import { CaseInsensitiveSet, FormattedValue, FORMAT_MASK_SHIFT, ValidatorDef } from './typeDefs.js'; +import { CaseInsensitiveSet, FormattedValue, FORMAT_MASK_SHIFT, ValidatorDef, VariantGroup } from './typeDefs.js'; import { typeError, UnknownUnionVariantError } from './errors.js'; import { ARRAY_KEY, @@ -323,12 +323,28 @@ function parseUnionData( return parseRecordData(value as Record, recordShape as ComplexTypeDef, ctx, path); } - const matchingDef = unionDef.shape![typename]; + const entry = unionDef.shape![typename]; - if (matchingDef === undefined || typeof matchingDef === 'number') { + if (entry === undefined || typeof entry === 'number') { throw new UnknownUnionVariantError(typename, path); } + // Members sharing a typename are grouped by variant; resolve the second + // level from the payload's variant field. + let matchingDef: ObjectDef | EntityDef; + if (entry instanceof VariantGroup) { + const variantValue = (value as Record)[entry.variantField]; + const variantDef = typeof variantValue === 'string' ? entry.defs[variantValue] : undefined; + + if (variantDef === undefined) { + throw new UnknownUnionVariantError(typename, path, String(variantValue)); + } + + matchingDef = variantDef; + } else { + matchingDef = entry as ObjectDef | EntityDef; + } + if (matchingDef.mask & Mask.ENTITY && ctx.queryClient !== undefined) { return parseEntityData(value as Record, matchingDef as EntityDef, ctx); } diff --git a/packages/fetchium/src/typeDefs.ts b/packages/fetchium/src/typeDefs.ts index dbf7762..1efde06 100644 --- a/packages/fetchium/src/typeDefs.ts +++ b/packages/fetchium/src/typeDefs.ts @@ -3,6 +3,7 @@ import { ARRAY_KEY, ComplexTypeDef, EntityConfig, + EntityDef, EntityMethods, ExtractType, InternalTypeDef, @@ -120,6 +121,7 @@ function mergeObjectShapes( shapes: (Record | undefined)[], count: number, typename: string, + variantField?: string, ): Record { const allKeys = new Set(); for (const shape of shapes) { @@ -133,6 +135,33 @@ function mergeObjectShapes( const merged: Record = {}; for (const key of allKeys) { + // The variant field intentionally differs across defs (one literal per + // variant), so merge it to the union of the values instead of asserting + // field-type compatibility. + if (key === variantField) { + let allSets = true; + let variantPresent = 0; + for (const shape of shapes) { + const fieldDef = shape?.[key]; + if (fieldDef === undefined) continue; + variantPresent++; + if (!(fieldDef instanceof Set)) allSets = false; + } + + if (allSets && variantPresent > 0) { + const values = new Set(); + for (const shape of shapes) { + const fieldDef = shape?.[key]; + if (fieldDef instanceof Set) { + for (const v of fieldDef) values.add(v as string | boolean | number); + } + } + merged[key] = + variantPresent < count ? new ValidatorDef(Mask.UNDEFINED, undefined, values) : (values as unknown); + continue; + } + } + let presentCount = 0; let firstDef: unknown = undefined; const nestedShapes: (Record | undefined)[] = []; @@ -175,6 +204,8 @@ export class ValidatorDef { public shape: InternalTypeDef | InternalObjectShape | UnionTypeDefs | ComplexTypeDef[] | undefined; public typenameField: string | undefined = undefined; public typenameValue: string | undefined = undefined; + public variantField: string | undefined = undefined; + public variantValue: string | undefined = undefined; public idField: string | symbol | undefined = undefined; public values: Set | undefined = undefined; @@ -228,7 +259,19 @@ export class ValidatorDef { const shapes = defs.map(d => d.shape as Record | undefined); const typename = defs[0].typenameValue ?? '(unknown)'; - const mergedShape = mergeObjectShapes(shapes, count, typename); + let variantField: string | undefined; + + for (const def of defs) { + if (variantField === undefined) { + variantField = def.variantField; + } else if (def.variantField !== undefined && def.variantField !== variantField) { + throw new Error( + `[fetchium] Entity typename '${def.typenameValue}' has conflicting variant fields: '${variantField}' vs '${def.variantField}'`, + ); + } + } + + const mergedShape = mergeObjectShapes(shapes, count, typename, variantField); let idField: string | symbol | undefined; let typenameField: string | undefined; @@ -247,7 +290,7 @@ export class ValidatorDef { if (typenameValue === undefined) typenameValue = def.typenameValue; } - return new ValidatorDef( + const merged = new ValidatorDef( Mask.ENTITY | Mask.OBJECT, mergedShape as any, undefined, @@ -255,6 +298,9 @@ export class ValidatorDef { typenameValue, idField, ); + // The merged def spans all variants, so it carries the field but no value. + merged.variantField = variantField; + return merged; } static cloneWith(def: ValidatorDef, mask: Mask): ValidatorDef { @@ -266,6 +312,8 @@ export class ValidatorDef { def.typenameValue, def.idField, ); + newDef.variantField = def.variantField; + newDef.variantValue = def.variantValue; newDef._methods = def._methods; newDef._entityConfig = def._entityConfig; newDef._entityClass = def._entityClass; @@ -345,6 +393,43 @@ registerCustomHash(CaseInsensitiveSet, set => { return sum >>> 0; }); +// ----------------------------------------------------------------------------- +// Variants +// ----------------------------------------------------------------------------- + +/** + * A single-value Set marking its field as the owning type's variant tag. + * Validates exactly like `t.const(value)`; additionally records the + * (field, value) pair on the def so unions can dispatch on it when several + * members share a typename. Unlike the typename, the variant is not part of + * entity identity. + */ +export class VariantSet extends Set { + readonly value: T; + + constructor(value: T) { + super([value]); + this.value = value; + } +} + +const VARIANT_SET_SEED = 0x56415254; + +registerCustomHash(VariantSet, set => (VARIANT_SET_SEED + hashValue(set.value)) >>> 0); + +/** + * Union shape entry for members that share a typename: a second-level + * dispatch table keyed by each member's variant value. + */ +export class VariantGroup { + readonly variantField: string; + readonly defs: Record = Object.create(null); + + constructor(variantField: string) { + this.variantField = variantField; + } +} + // ----------------------------------------------------------------------------- // Complex Type Definitions // ----------------------------------------------------------------------------- @@ -376,6 +461,8 @@ function defineObjectOrEntity(baseMask: Mask, shape: InternalObjectShape): Valid let idField: string | undefined = undefined; let typenameField: string | undefined = undefined; let typenameValue: string | undefined = undefined; + let variantField: string | undefined = undefined; + let variantValue: string | undefined = undefined; for (const [key, value] of entries(shape)) { switch (typeof value) { @@ -397,6 +484,16 @@ function defineObjectOrEntity(baseMask: Mask, shape: InternalObjectShape): Valid typenameValue = value; break; case 'object': + if (value instanceof VariantSet) { + if (variantField !== undefined) { + throw new Error(`Duplicate variant field: ${key}`); + } + + variantField = key; + variantValue = value.value; + break; + } + if (value instanceof CaseInsensitiveSet || value instanceof Set) { break; } @@ -408,7 +505,10 @@ function defineObjectOrEntity(baseMask: Mask, shape: InternalObjectShape): Valid } } - return new ValidatorDef(mask, shape, undefined, typenameField, typenameValue, idField); + const def = new ValidatorDef(mask, shape, undefined, typenameField, typenameValue, idField); + def.variantField = variantField; + def.variantValue = variantValue; + return def; } export function defineObject>(shape: T): TypeDef> { @@ -478,7 +578,37 @@ function addDefToUnion( } unionTypenameField = typenameField; - unionShape[typename] = def as ObjectDef; + + const variantField = (def as ObjectDef).variantField; + const variantValue = (def as ObjectDef).variantValue; + const existing = unionShape[typename]; + + if (existing === undefined) { + if (variantValue !== undefined) { + const group = new VariantGroup(variantField!); + group.defs[variantValue] = def as ObjectDef; + unionShape[typename] = group; + } else { + unionShape[typename] = def as ObjectDef; + } + } else if (existing instanceof VariantGroup && variantValue !== undefined) { + if (existing.variantField !== variantField) { + throw new Error( + `Union variant field conflict: typename '${typename}' has variants keyed on '${existing.variantField}' and '${variantField}'`, + ); + } + + const duplicate = existing.defs[variantValue]; + if (duplicate !== undefined && duplicate !== def) { + throw new Error(`Duplicate variant value '${variantValue}' for typename '${typename}' in union`); + } + + existing.defs[variantValue] = def as ObjectDef; + } else if (existing !== def) { + throw new Error( + `Duplicate typename value '${typename}' in union. Union members must be unique by typename, or by (typename, variant) when members sharing a typename declare a t.variant(...) field`, + ); + } } return unionTypenameField; @@ -593,6 +723,10 @@ function defineTypename(value: T): TypeDef { return value as unknown as TypeDef; } +function defineVariant(value: T): TypeDef { + return new VariantSet(value) as unknown as TypeDef; +} + function defineConst(value: T): TypeDef { return new Set([value]) as unknown as TypeDef; } @@ -941,6 +1075,7 @@ function defineLiveValue(valueType: TypeDef, entityOrArray: unknown, opts: LiveV export const t: APITypes = { format: defineFormatted, typename: defineTypename, + variant: defineVariant, const: defineConst, enum: defineEnum, id: (Mask.ID | Mask.STRING | Mask.NUMBER) as unknown as TypeDef, diff --git a/packages/fetchium/src/types.ts b/packages/fetchium/src/types.ts index dd69bba..0004474 100644 --- a/packages/fetchium/src/types.ts +++ b/packages/fetchium/src/types.ts @@ -165,13 +165,24 @@ export const QUERY_ID = Symbol('QUERY_ID'); export interface UnionTypeDefs { [ARRAY_KEY]?: InternalTypeDef; [RECORD_KEY]?: InternalTypeDef; - [key: string]: ObjectDef | EntityDef; + [key: string]: ObjectDef | EntityDef | VariantGroupDef; +} + +/** + * Union shape entry for members that share a typename: a second-level + * dispatch table keyed by each member's variant value. + */ +export interface VariantGroupDef { + variantField: string; + defs: Record; } export interface BaseTypeDef { mask: Mask; typenameField: string; typenameValue: string; + variantField: string | undefined; + variantValue: string | undefined; idField: string | symbol; values: Set | undefined; } @@ -232,6 +243,7 @@ declare global { export interface APITypes { format: (format: K) => TypeDef; typename: (value: T) => TypeDef; + variant: (value: T) => TypeDef; const: (value: T) => TypeDef; enum: { (...values: T): TypeDef; From 446333cda11aca0db7b36c0aae27ba585dc2751a Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Thu, 6 Aug 2026 16:06:30 -0700 Subject: [PATCH 2/4] fix: variant-group aliasing in nested unions, delete-event def resolution, generator support Review findings on the t.variant PR: - Nested-union merging copied VariantGroup entries by reference, so composing a variant union into a larger union mutated the inner union, and merging two variant unions threw. Group handling is now centralized in addVariantDefToUnion, which only creates groups owned by the union being built, so aliasing is impossible by construction and group-into-group merging gets the same duplicate checks. - Delete events resolved to the first def per typename instead of the def the entity satisfies, handing onDelete a different class than onCreate received. - generateUnion produced {} for variant-union fields. - Docs: the variant is fixed for the lifetime of an entity; a mutable tag is a state field, not a variant. Co-Authored-By: Claude Fable 5 --- docs/src/app/core/types/page.md | 3 +- packages/fetchium/src/LiveCollection.ts | 6 +- .../src/__tests__/variant-unions.test.ts | 78 ++++++++++++++++- .../fetchium/src/testing/auto-generate.ts | 19 ++-- packages/fetchium/src/typeDefs.ts | 87 +++++++++++++------ 5 files changed, 157 insertions(+), 36 deletions(-) diff --git a/docs/src/app/core/types/page.md b/docs/src/app/core/types/page.md index a5c7ddf..c7b1762 100644 --- a/docs/src/app/core/types/page.md +++ b/docs/src/app/core/types/page.md @@ -299,10 +299,11 @@ const Post = t.union(t.entity(ImagePost), t.entity(GalleryPost)); `t.typename` establishes the entity's _identity_: all variants share one cache key space, `[typename, id]`, and [mutation events](/core/streaming) target the shared typename. `t.variant` only selects which shape a payload parses as; it is not part of identity, and validates exactly like `t.const(value)`. -Two rules follow from this: +Three rules follow from this: - All members sharing a typename must declare the _same_ variant field. - Each member must have a _unique_ variant value within its typename. +- The variant is _fixed_ for the lifetime of an entity. A tag that can change at runtime is a state field, not a variant: use `t.enum` on a single shape instead. Because ids are shared across variants of a typename, the API must guarantee that ids never collide between two variants; two values with the same id are the same entity as far as the cache is concerned. diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index bf307a1..be6b1b8 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -150,7 +150,11 @@ export class LiveCollectionBinding { const entityInstance = this._queryClient.entityMap.getEntity(entityKey); if (eventType === 'delete') { - const def = resolveEventDef(defs, entityInstance?.data ?? deleteData) ?? defs[0]; + let def = resolveEventDef(defs, entityInstance?.data ?? deleteData); + if (def === undefined && entityInstance !== undefined) { + def = defs.find(d => entityInstance.satisfiesDef(d as unknown as ValidatorDef)); + } + def ??= defs[0]; const entity = entityInstance !== undefined ? entityInstance.getProxy(def as unknown as EntityDef) : deleteData; if (entity !== undefined) { this.instance.onEvent(entityKey, entity, deleteData ?? entityInstance?.data ?? {}, 'delete'); diff --git a/packages/fetchium/src/__tests__/variant-unions.test.ts b/packages/fetchium/src/__tests__/variant-unions.test.ts index 15386ed..2014c88 100644 --- a/packages/fetchium/src/__tests__/variant-unions.test.ts +++ b/packages/fetchium/src/__tests__/variant-unions.test.ts @@ -4,6 +4,7 @@ import { Entity } from '../proxy.js'; import { RESTQuery } from '../rest/index.js'; import { fetchQuery } from '../query.js'; import { parseValue } from '../parseEntities.js'; +import { generateEntityData } from '../testing/auto-generate.js'; import { testWithClient, sleep, setupTestClient } from './utils.js'; import type { MutationEvent } from '../types.js'; @@ -155,6 +156,62 @@ describe('variant unions', () => { }); }); + describe('union composition', () => { + const Text = t.object({ __typename: t.typename('Media'), kind: t.variant('text'), body: t.string }); + const Link = t.object({ __typename: t.typename('Media'), kind: t.variant('link'), url: t.string }); + const Gallery = t.object({ __typename: t.typename('Media'), kind: t.variant('gallery'), count: t.number }); + + it('does not mutate an inner union composed into an outer union', () => { + const Inner = t.union(Text, Link); + const Outer = t.union(Inner, Gallery); + + const gallery = parseValue({ __typename: 'Media', kind: 'gallery', count: 2 }, Outer, ''); + expect((gallery as { count: number }).count).toBe(2); + const text = parseValue({ __typename: 'Media', kind: 'text', body: 'hi' }, Outer, ''); + expect((text as { body: string }).body).toBe('hi'); + + // the inner union must not gain the outer union's variant + expect(() => parseValue({ __typename: 'Media', kind: 'gallery', count: 2 }, Inner, '')).toThrow( + /Unknown variant 'gallery'/, + ); + }); + + it('composes in either order', () => { + const Inner = t.union(Text, Link); + const Outer = t.union(Gallery, Inner); + + const link = parseValue({ __typename: 'Media', kind: 'link', url: 'https://example.com' }, Outer, ''); + expect((link as { url: string }).url).toBe('https://example.com'); + }); + + it('merges variant groups from two nested unions, with duplicate checks', () => { + const A = t.union(Text, Gallery); + const B = t.union(Link, Gallery); // sharing the same def is allowed + const Merged = t.union(A, B); + + const link = parseValue({ __typename: 'Media', kind: 'link', url: 'https://example.com' }, Merged, ''); + expect((link as { url: string }).url).toBe('https://example.com'); + const text = parseValue({ __typename: 'Media', kind: 'text', body: 'hi' }, Merged, ''); + expect((text as { body: string }).body).toBe('hi'); + + const OtherText = t.object({ __typename: t.typename('Media'), kind: t.variant('text'), words: t.string }); + expect(() => t.union(A, t.union(OtherText, Gallery))).toThrow(/Duplicate variant value 'text'/); + }); + + it('auto-generates data for variant-union fields', () => { + class Widget extends Entity { + __typename = t.typename('Widget'); + id = t.id; + media = t.union(Text, Link); + } + + const data = generateEntityData(Widget); + const media = data.media as Record; + expect(media.kind).toBe('text'); + expect(typeof media.body).toBe('string'); + }); + }); + describe('parse dispatch', () => { it('dispatches each payload to the member matching its variant', () => { const text = parseValue(textRow('p1', 1), PostRow, ''); @@ -327,6 +384,10 @@ describe('variant unions', () => { status = t.string; } + const reduced: string[] = []; + const tag = (e: unknown) => + e instanceof ReadingMetric ? 'ReadingMetric' : e instanceof StatusMetric ? 'StatusMetric' : 'unknown'; + class LiveMetrics extends RESTQuery { params = { user: t.string }; path = '/metrics'; @@ -336,9 +397,15 @@ describe('variant unions', () => { t.number, [ReadingMetric, StatusMetric] as Array ReadingMetric | StatusMetric>, { - onCreate: (v: number) => v + 1, + onCreate: (v: number, e: unknown) => { + reduced.push(`create:${tag(e)}`); + return v + 1; + }, onUpdate: (v: number) => v, - onDelete: (v: number) => v - 1, + onDelete: (v: number, e: unknown) => { + reduced.push(`delete:${tag(e)}`); + return v - 1; + }, }, ), }; @@ -359,6 +426,7 @@ describe('variant unions', () => { it('routes events for every def sharing the typename, not just the last', async () => { const { client, mockFetch } = getClient(); + reduced.length = 0; mockFetch.get('/metrics', { eventCount: 0 }); @@ -381,8 +449,12 @@ describe('variant unions', () => { }); expect(count()).toBe(2); - await emit('m1', { type: 'delete', typename: 'Metric', id: 'r1', data: 'r1' }); + await emit('m1', { type: 'delete', typename: 'Metric', id: 's1', data: 's1' }); expect(count()).toBe(1); + + // Deletes resolve the def the same way creates do; previously they + // fell back to the first def, handing onDelete the wrong class. + expect(reduced).toEqual(['create:ReadingMetric', 'create:StatusMetric', 'delete:StatusMetric']); }); }); }); diff --git a/packages/fetchium/src/testing/auto-generate.ts b/packages/fetchium/src/testing/auto-generate.ts index c1daca2..855cae4 100644 --- a/packages/fetchium/src/testing/auto-generate.ts +++ b/packages/fetchium/src/testing/auto-generate.ts @@ -1,4 +1,4 @@ -import { ValidatorDef, getEntityDef, CaseInsensitiveSet } from '../typeDefs.js'; +import { ValidatorDef, VariantGroup, getEntityDef, CaseInsensitiveSet } from '../typeDefs.js'; import { Mask, type InternalTypeDef, type InternalObjectShape } from '../types.js'; import type { Entity } from '../proxy.js'; import type { FieldGenerator } from './types.js'; @@ -217,15 +217,24 @@ function generateEntity(def: ValidatorDef, ctx: GeneratorContext): Record, fieldName: string, ctx: GeneratorContext): unknown { - const shape = def.shape as Record> | undefined; + const shape = def.shape as Record | VariantGroup> | undefined; if (shape === undefined) return undefined; for (const key of Object.keys(shape)) { if (typeof key === 'string') { - const variant = shape[key]; - if (variant instanceof ValidatorDef) { - return generateFromValidatorDef(variant, fieldName, ctx); + const member = shape[key]; + if (member instanceof VariantGroup) { + for (const variantKey of Object.keys(member.defs)) { + const variant = member.defs[variantKey]; + if (variant instanceof ValidatorDef) { + return generateFromValidatorDef(variant, fieldName, ctx); + } + } + continue; + } + if (member instanceof ValidatorDef) { + return generateFromValidatorDef(member, fieldName, ctx); } } } diff --git a/packages/fetchium/src/typeDefs.ts b/packages/fetchium/src/typeDefs.ts index 1efde06..e28990c 100644 --- a/packages/fetchium/src/typeDefs.ts +++ b/packages/fetchium/src/typeDefs.ts @@ -517,6 +517,48 @@ export function defineObject>(shape: T): TypeD >; } +function duplicateTypenameError(typename: string): Error { + return new Error( + `Duplicate typename value '${typename}' in union. Union members must be unique by typename, or by (typename, variant) when members sharing a typename declare a t.variant(...) field`, + ); +} + +/** + * Add one variant def under `typename`, creating or extending its + * VariantGroup. The group is always owned by `unionShape` (never a caller's), + * so nested unions can be merged without aliasing. + */ +function addVariantDefToUnion( + unionShape: UnionTypeDefs, + typename: string, + variantField: string, + variantValue: string, + def: ObjectDef | EntityDef, +): void { + const existing = unionShape[typename]; + + if (existing === undefined) { + const group = new VariantGroup(variantField); + group.defs[variantValue] = def; + unionShape[typename] = group; + } else if (existing instanceof VariantGroup) { + if (existing.variantField !== variantField) { + throw new Error( + `Union variant field conflict: typename '${typename}' has variants keyed on '${existing.variantField}' and '${variantField}'`, + ); + } + + const duplicate = existing.defs[variantValue]; + if (duplicate !== undefined && duplicate !== def) { + throw new Error(`Duplicate variant value '${variantValue}' for typename '${typename}' in union`); + } + + existing.defs[variantValue] = def; + } else { + throw duplicateTypenameError(typename); + } +} + function addDefToUnion( def: ComplexTypeDef, unionShape: UnionTypeDefs, @@ -541,10 +583,20 @@ function addDefToUnion( if (nestedShape !== undefined) { for (const key of [...keys(nestedShape), ARRAY_KEY, RECORD_KEY] as const) { const value = nestedShape[key]; + if (value === undefined) continue; - if (unionShape[key] !== undefined && unionShape[key] !== value) { + if (value instanceof VariantGroup) { + for (const variantKey of keys(value.defs)) { + addVariantDefToUnion(unionShape, key as string, value.variantField, variantKey, value.defs[variantKey]); + } + continue; + } + + const existing = unionShape[key]; + + if (existing !== undefined && existing !== value) { throw new Error( - `Union merge conflict: Duplicate typename value '${String(key)}' found when merging nested unions (${String(unionShape[key])} vs ${String(value)})`, + `Union merge conflict: Duplicate typename value '${String(key)}' found when merging nested unions`, ); } @@ -579,35 +631,18 @@ function addDefToUnion( unionTypenameField = typenameField; - const variantField = (def as ObjectDef).variantField; const variantValue = (def as ObjectDef).variantValue; - const existing = unionShape[typename]; - if (existing === undefined) { - if (variantValue !== undefined) { - const group = new VariantGroup(variantField!); - group.defs[variantValue] = def as ObjectDef; - unionShape[typename] = group; - } else { - unionShape[typename] = def as ObjectDef; - } - } else if (existing instanceof VariantGroup && variantValue !== undefined) { - if (existing.variantField !== variantField) { - throw new Error( - `Union variant field conflict: typename '${typename}' has variants keyed on '${existing.variantField}' and '${variantField}'`, - ); - } + if (variantValue !== undefined) { + addVariantDefToUnion(unionShape, typename, (def as ObjectDef).variantField!, variantValue, def as ObjectDef); + } else { + const existing = unionShape[typename]; - const duplicate = existing.defs[variantValue]; - if (duplicate !== undefined && duplicate !== def) { - throw new Error(`Duplicate variant value '${variantValue}' for typename '${typename}' in union`); + if (existing !== undefined && existing !== def) { + throw duplicateTypenameError(typename); } - existing.defs[variantValue] = def as ObjectDef; - } else if (existing !== def) { - throw new Error( - `Duplicate typename value '${typename}' in union. Union members must be unique by typename, or by (typename, variant) when members sharing a typename declare a t.variant(...) field`, - ); + unionShape[typename] = def as ObjectDef; } } From 3c5ab37522411015c48c4cb9a48963ce0270ac75 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Thu, 6 Aug 2026 16:34:15 -0700 Subject: [PATCH 3/4] fix: gate live collection events on the variant tag's value satisfiesDef was presence-based, so a variant tag's value never participated in event gating: a single-def binding accepted any sibling variant with the same field profile, and the multi-def fallback matched variants the binding never declared. A VariantSet field now satisfies only on a value match, which also makes entity-array reads variant-aware. Co-Authored-By: Claude Fable 5 --- .../src/__tests__/variant-unions.test.ts | 95 +++++++++++++++++++ packages/fetchium/src/parseEntities.ts | 16 +++- 2 files changed, 110 insertions(+), 1 deletion(-) diff --git a/packages/fetchium/src/__tests__/variant-unions.test.ts b/packages/fetchium/src/__tests__/variant-unions.test.ts index 2014c88..d6c2f2f 100644 --- a/packages/fetchium/src/__tests__/variant-unions.test.ts +++ b/packages/fetchium/src/__tests__/variant-unions.test.ts @@ -366,6 +366,101 @@ describe('variant unions', () => { }); }); + describe('variant gating with overlapping field profiles', () => { + const getClient = setupTestClient(); + + // Three variants with identical field profiles, so presence checks alone + // cannot tell them apart; only the tag's value can. + class InMessage extends Entity { + __typename = t.typename('Message'); + id = t.id; + dir = t.variant('in'); + text = t.string; + } + + class OutMessage extends Entity { + __typename = t.typename('Message'); + id = t.id; + dir = t.variant('out'); + text = t.string; + } + + class SysMessage extends Entity { + __typename = t.typename('Message'); + id = t.id; + dir = t.variant('sys'); + text = t.string; + } + + class Mailbox extends RESTQuery { + params = { user: t.string }; + path = '/mailbox'; + searchParams = { user: this.params.user }; + result = { + inbox: t.liveArray(InMessage), + all: t.liveArray([InMessage, OutMessage] as Array InMessage | OutMessage>), + system: t.liveArray(SysMessage), + }; + + getConfig() { + return { + staleTime: 60_000, + subscribe: (onEvent: (event: MutationEvent) => void) => { + const user = this.params.user as unknown as string; + emitters.set(user, onEvent); + return () => { + emitters.delete(user); + }; + }, + }; + } + } + + const msg = (id: string, dir: string) => ({ __typename: 'Message', id, dir, text: `Message ${id}` }); + + it('only admits events for variants the collection declares', async () => { + const { client, mockFetch } = getClient(); + + mockFetch.get('/mailbox', { + inbox: [msg('i1', 'in')], + all: [msg('i1', 'in'), msg('o1', 'out')], + system: [], + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(Mailbox, { user: 'mb1' }); + await relay; + + const fieldIds = (field: string) => + (relay.value as unknown as Record>)[field].map(m => String(m.id)); + + expect(fieldIds('inbox')).toEqual(['i1']); + expect(fieldIds('all')).toEqual(['i1', 'o1']); + expect(fieldIds('system')).toEqual([]); + + // An out message matches InMessage's field profile but not its tag; + // the single-def inbox must not admit it. + await emit('mb1', { type: 'create', typename: 'Message', data: msg('o2', 'out') }); + expect(fieldIds('inbox')).toEqual(['i1']); + expect(fieldIds('all')).toEqual(['i1', 'o1', 'o2']); + expect(fieldIds('system')).toEqual([]); + + // A sys message is registered on the client but undeclared by the + // multi-def collection; it must not fall through the satisfies gate. + await emit('mb1', { type: 'create', typename: 'Message', data: msg('s1', 'sys') }); + expect(fieldIds('inbox')).toEqual(['i1']); + expect(fieldIds('all')).toEqual(['i1', 'o1', 'o2']); + expect(fieldIds('system')).toEqual(['s1']); + + // Declared variants still insert everywhere they belong. + await emit('mb1', { type: 'create', typename: 'Message', data: msg('i2', 'in') }); + expect(fieldIds('inbox')).toEqual(['i1', 'i2']); + expect(fieldIds('all')).toEqual(['i1', 'o1', 'o2', 'i2']); + expect(fieldIds('system')).toEqual(['s1']); + }); + }); + }); + describe('multi-def typenames without variants', () => { const getClient = setupTestClient(); diff --git a/packages/fetchium/src/parseEntities.ts b/packages/fetchium/src/parseEntities.ts index f5df100..b533145 100644 --- a/packages/fetchium/src/parseEntities.ts +++ b/packages/fetchium/src/parseEntities.ts @@ -7,7 +7,14 @@ import { hashValue } from 'signalium/utils'; import type { QueryClient, PreloadedEntityMap } from './QueryClient.js'; -import { CaseInsensitiveSet, FormattedValue, FORMAT_MASK_SHIFT, ValidatorDef, VariantGroup } from './typeDefs.js'; +import { + CaseInsensitiveSet, + FormattedValue, + FORMAT_MASK_SHIFT, + ValidatorDef, + VariantGroup, + VariantSet, +} from './typeDefs.js'; import { typeError, UnknownUnionVariantError } from './errors.js'; import { ARRAY_KEY, @@ -507,6 +514,13 @@ function objectSatisfiesShape( const fieldDef = shape[key]; + // A variant tag must match by value: an entity of one variant does not + // satisfy a sibling variant's shape even when field profiles overlap. + if (fieldDef instanceof VariantSet) { + if (data[key] !== fieldDef.value) return false; + continue; + } + if (fieldDef instanceof ValidatorDef) { if ((fieldDef.mask & Mask.UNDEFINED) !== 0) continue; if (!(key in data) || data[key] === undefined) return false; From 9b1cbc864119f96c6d8019a3c2e895567c2c4764 Mon Sep 17 00:00:00 2001 From: Jimmy Song Date: Thu, 6 Aug 2026 16:59:40 -0700 Subject: [PATCH 4/4] fix: gate delete events on the variant tag; compare variant groups structurally Two follow-ups to the variant gating work: - Deletes bypassed the gate added for creates and updates: resolveEventDef short-circuited a single variant def without checking the tag, and the delete branch delivered defs[0] unconditionally, so liveValue onDelete reducers fired for sibling variants. Deletes now skip when the entity's tag names a variant the binding does not declare; id-only deletes still route. - fieldTypesCompatible had no VariantGroup branch, and since every union owns fresh groups, two structurally identical variant-union fields compared false and ValidatorDef.merge threw in dev on valid responses. Groups now compare structurally: same variant field, same keys, compatible members. Co-Authored-By: Claude Fable 5 --- packages/fetchium/src/LiveCollection.ts | 21 ++++-- .../src/__tests__/variant-unions.test.ts | 70 +++++++++++++++++++ packages/fetchium/src/typeDefs.ts | 14 ++++ 3 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/fetchium/src/LiveCollection.ts b/packages/fetchium/src/LiveCollection.ts index be6b1b8..d327dc4 100644 --- a/packages/fetchium/src/LiveCollection.ts +++ b/packages/fetchium/src/LiveCollection.ts @@ -13,16 +13,16 @@ import { import { ValidatorDef, WRAPPED_VALUE } from './typeDefs.js'; /** - * Pick the def matching the entity's variant tag. Returns the single def - * directly, and undefined when several defs share the typename but none (or - * no data) resolves a variant. + * Pick the def matching the entity's variant tag. Returns a single + * non-variant def directly, and undefined when the data resolves no declared + * variant. */ function resolveEventDef( defs: ValidatorDef[], data: Record | undefined, ): ValidatorDef | undefined { - if (defs.length === 1) return defs[0]; - if (data === undefined) return undefined; + if (defs.length === 1 && defs[0].variantValue === undefined) return defs[0]; + if (data === undefined) return defs.length === 1 ? defs[0] : undefined; for (const def of defs) { if ( def.variantValue !== undefined && @@ -150,11 +150,18 @@ export class LiveCollectionBinding { const entityInstance = this._queryClient.entityMap.getEntity(entityKey); if (eventType === 'delete') { - let def = resolveEventDef(defs, entityInstance?.data ?? deleteData); + const data = entityInstance?.data ?? deleteData; + let def = resolveEventDef(defs, data); if (def === undefined && entityInstance !== undefined) { def = defs.find(d => entityInstance.satisfiesDef(d as unknown as ValidatorDef)); } - def ??= defs[0]; + if (def === undefined) { + // The tag names a variant this binding does not declare: not ours. + // Deletes carrying no tag (id-only, entity already gone) still route. + const variantField = defs[0].variantField; + if (variantField !== undefined && data?.[variantField] !== undefined) return; + def = defs[0]; + } const entity = entityInstance !== undefined ? entityInstance.getProxy(def as unknown as EntityDef) : deleteData; if (entity !== undefined) { this.instance.onEvent(entityKey, entity, deleteData ?? entityInstance?.data ?? {}, 'delete'); diff --git a/packages/fetchium/src/__tests__/variant-unions.test.ts b/packages/fetchium/src/__tests__/variant-unions.test.ts index d6c2f2f..0738a64 100644 --- a/packages/fetchium/src/__tests__/variant-unions.test.ts +++ b/packages/fetchium/src/__tests__/variant-unions.test.ts @@ -400,6 +400,11 @@ describe('variant unions', () => { inbox: t.liveArray(InMessage), all: t.liveArray([InMessage, OutMessage] as Array InMessage | OutMessage>), system: t.liveArray(SysMessage), + inCount: t.liveValue(t.number, InMessage, { + onCreate: (v: number) => v + 1, + onUpdate: (v: number) => v, + onDelete: (v: number) => v - 1, + }), }; getConfig() { @@ -425,6 +430,7 @@ describe('variant unions', () => { inbox: [msg('i1', 'in')], all: [msg('i1', 'in'), msg('o1', 'out')], system: [], + inCount: 0, }); await testWithClient(client, async () => { @@ -433,10 +439,12 @@ describe('variant unions', () => { const fieldIds = (field: string) => (relay.value as unknown as Record>)[field].map(m => String(m.id)); + const inCount = () => (relay.value as unknown as { inCount: number }).inCount; expect(fieldIds('inbox')).toEqual(['i1']); expect(fieldIds('all')).toEqual(['i1', 'o1']); expect(fieldIds('system')).toEqual([]); + expect(inCount()).toBe(0); // An out message matches InMessage's field profile but not its tag; // the single-def inbox must not admit it. @@ -444,6 +452,7 @@ describe('variant unions', () => { expect(fieldIds('inbox')).toEqual(['i1']); expect(fieldIds('all')).toEqual(['i1', 'o1', 'o2']); expect(fieldIds('system')).toEqual([]); + expect(inCount()).toBe(0); // A sys message is registered on the client but undeclared by the // multi-def collection; it must not fall through the satisfies gate. @@ -457,6 +466,67 @@ describe('variant unions', () => { expect(fieldIds('inbox')).toEqual(['i1', 'i2']); expect(fieldIds('all')).toEqual(['i1', 'o1', 'o2', 'i2']); expect(fieldIds('system')).toEqual(['s1']); + expect(inCount()).toBe(1); + + // Deletes are gated the same way: removing an out message must not + // reach the in-only liveValue reducer. + await emit('mb1', { type: 'delete', typename: 'Message', id: 'o2', data: 'o2' }); + expect(fieldIds('inbox')).toEqual(['i1', 'i2']); + expect(fieldIds('all')).toEqual(['i1', 'o1', 'i2']); + expect(inCount()).toBe(1); + + // A declared variant's delete still routes. + await emit('mb1', { type: 'delete', typename: 'Message', id: 'i1', data: 'i1' }); + expect(fieldIds('inbox')).toEqual(['i2']); + expect(fieldIds('all')).toEqual(['o1', 'i2']); + expect(inCount()).toBe(0); + }); + }); + + it('merges variants whose fields are structurally identical unions', async () => { + const { client, mockFetch } = getClient(); + + const ImgMedia = t.object({ __typename: t.typename('NoteMedia'), kind: t.variant('img'), url: t.string }); + const VidMedia = t.object({ __typename: t.typename('NoteMedia'), kind: t.variant('vid'), duration: t.number }); + + // Each class builds its own t.union instance, so the two `media` defs + // own distinct VariantGroups and can only compare structurally. + class PhotoNote extends Entity { + __typename = t.typename('Note'); + id = t.id; + kind = t.variant('photo'); + media = t.union(ImgMedia, VidMedia); + } + + class TextNote extends Entity { + __typename = t.typename('Note'); + id = t.id; + kind = t.variant('text'); + media = t.union(ImgMedia, VidMedia); + } + + class Notes extends RESTQuery { + path = '/notes'; + result = { + items: t.array(t.union(t.entity(PhotoNote), t.entity(TextNote))), + }; + } + + mockFetch.get('/notes', { + items: [ + { __typename: 'Note', id: 'n1', kind: 'photo', media: { __typename: 'NoteMedia', kind: 'img', url: 'u' } }, + { __typename: 'Note', id: 'n2', kind: 'text', media: { __typename: 'NoteMedia', kind: 'vid', duration: 3 } }, + ], + }); + + await testWithClient(client, async () => { + const relay = fetchQuery(Notes); + // Registering both defs merges them; the dev-only field compatibility + // check must accept the structurally identical `media` unions. + await relay; + + const items = (relay.value as { items: Array<{ id: string }> }).items; + expect(items.map(n => String(n.id))).toEqual(['n1', 'n2']); }); }); }); diff --git a/packages/fetchium/src/typeDefs.ts b/packages/fetchium/src/typeDefs.ts index e28990c..b71743f 100644 --- a/packages/fetchium/src/typeDefs.ts +++ b/packages/fetchium/src/typeDefs.ts @@ -57,6 +57,20 @@ if (IS_DEV) { return setsEqual(a, b); } + // Union shape entries: every union owns fresh VariantGroups, so compare + // structurally rather than by reference. + if (a instanceof VariantGroup && b instanceof VariantGroup) { + if (a.variantField !== b.variantField) return false; + const aKeys = Object.keys(a.defs); + const bKeys = Object.keys(b.defs); + if (aKeys.length !== bKeys.length) return false; + for (const key of aKeys) { + const bDef = b.defs[key]; + if (bDef === undefined || !fieldTypesCompatible(a.defs[key], bDef)) return false; + } + return true; + } + if (a instanceof ValidatorDef && b instanceof ValidatorDef) { const aMask = a.mask as number; const bMask = b.mask as number;