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..c7b1762 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,39 @@ 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)`. + +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. + ### 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..d327dc4 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 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 && 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 && + 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,23 @@ 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 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)); + } + 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'); @@ -131,6 +171,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..0738a64 --- /dev/null +++ b/packages/fetchium/src/__tests__/variant-unions.test.ts @@ -0,0 +1,626 @@ +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 { generateEntityData } from '../testing/auto-generate.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('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, ''); + 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('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), + inCount: t.liveValue(t.number, InMessage, { + 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); + }; + }, + }; + } + } + + 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: [], + inCount: 0, + }); + + 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)); + 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. + 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([]); + 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. + 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']); + 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']); + }); + }); + }); + + 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; + } + + 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'; + searchParams = { user: this.params.user }; + result = { + eventCount: t.liveValue( + t.number, + [ReadingMetric, StatusMetric] as Array ReadingMetric | StatusMetric>, + { + onCreate: (v: number, e: unknown) => { + reduced.push(`create:${tag(e)}`); + return v + 1; + }, + onUpdate: (v: number) => v, + onDelete: (v: number, e: unknown) => { + reduced.push(`delete:${tag(e)}`); + return 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(); + reduced.length = 0; + + 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: '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/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..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 } from './typeDefs.js'; +import { + CaseInsensitiveSet, + FormattedValue, + FORMAT_MASK_SHIFT, + ValidatorDef, + VariantGroup, + VariantSet, +} from './typeDefs.js'; import { typeError, UnknownUnionVariantError } from './errors.js'; import { ARRAY_KEY, @@ -323,12 +330,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); } @@ -491,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; 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 dbf7762..b71743f 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, @@ -56,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; @@ -120,6 +135,7 @@ function mergeObjectShapes( shapes: (Record | undefined)[], count: number, typename: string, + variantField?: string, ): Record { const allKeys = new Set(); for (const shape of shapes) { @@ -133,6 +149,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 +218,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 +273,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 +304,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 +312,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 +326,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 +407,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 +475,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 +498,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 +519,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> { @@ -417,6 +531,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, @@ -441,10 +597,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`, ); } @@ -478,7 +644,20 @@ function addDefToUnion( } unionTypenameField = typenameField; - unionShape[typename] = def as ObjectDef; + + const variantValue = (def as ObjectDef).variantValue; + + if (variantValue !== undefined) { + addVariantDefToUnion(unionShape, typename, (def as ObjectDef).variantField!, variantValue, def as ObjectDef); + } else { + const existing = unionShape[typename]; + + if (existing !== undefined && existing !== def) { + throw duplicateTypenameError(typename); + } + + unionShape[typename] = def as ObjectDef; + } } return unionTypenameField; @@ -593,6 +772,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 +1124,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;