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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/variant-union-dispatch.md
Original file line number Diff line number Diff line change
@@ -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.
36 changes: 35 additions & 1 deletion docs/src/app/core/types/page.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` | 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 |
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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:
Expand Down
53 changes: 49 additions & 4 deletions packages/fetchium/src/LiveCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>[],
data: Record<string, unknown> | undefined,
): ValidatorDef<any> | 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<number> {
const keys = new Set<number>();
for (const item of items) {
Expand Down Expand Up @@ -61,7 +84,7 @@ export class LiveCollectionBinding {
_queryClient: QueryClient;
_parent: LiveCollectionParent;
_constraintHashes: Map<string, number>;
_entityDefsByTypename: Map<string, ValidatorDef<any>>;
_entityDefsByTypename: Map<string, ValidatorDef<any>[]>;
_constraintFieldRefs: Map<string, Array<[string, unknown]>>;
readonly instance: LiveInstance;

Expand All @@ -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);
}
}
}

Expand Down Expand Up @@ -117,11 +145,23 @@ export class LiveCollectionBinding {
onMatch?: () => void,
deleteData?: Record<string, unknown>,
): 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<unknown>));
}
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');
Expand All @@ -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<unknown>));
Comment thread
jimmy-phantom marked this conversation as resolved.
if (def === undefined) return;
if (!entityInstance.satisfiesDef(def as unknown as ValidatorDef<unknown>)) return;

onMatch?.();
Expand Down
6 changes: 4 additions & 2 deletions packages/fetchium/src/QueryClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Expand Down
Loading
Loading