Entity / foldkit-entity

Rows of several shapes: variants

Experimental (#357): the Entity, its Selections and Expr. Remote's store, forms and guards read the stored row for now.

A domain search answers Available, with a price and a term, or Unconfirmed, with neither. Entity.defineVariants declares that as one Entity whose row is one of its cases, each case's fields by its tag, the shape Schema.TaggedUnion takes:

ts
const DomainName = { id: Schema.String, name: Schema.String }
const Domain = Entity.defineVariants('Domain', {
  Available: { ...DomainName, price: Schema.Finite, years: Schema.Int },
  Unconfirmed: DomainName,
})

// The row: Effect's tagged union of the cases, with its own `match`.
const priceOf = (row: typeof Domain.variants.Type) =>
  Domain.variants.match(row, { Available: ({ price }) => price, Unconfirmed: () => 0 })

One Entity has two views of one row:

row          Domain.variants      { _tag: 'Available', id, name, price, years }
                                | { _tag: 'Unconfirmed', id, name }
stored row   Domain.fields        { _tag, id, name, price | null, years | null }
  • The stored row is what fields and schema describe: _tag, every field all cases have as declared, and every other field nullable, since a row of another case holds null there (a field whose stored form holds null already, Schema.OptionFromNullOr, stays as it is). It is what every interpreter reads, so a table, a Remote read and a form work as they do for any Entity.
  • The row is Domain.variants, branched on with its match, never by reading which fields are null. Entity.variantsOf(Domain) holds the codec between the two (stored): a stored row, encoded, decodes to its case, and one holding another case's field is refused.
  • Each case says what it holds. A case with fields of its own gets an invariant (variant_Available: an Available holds price and years; variant_Unconfirmed: an Unconfirmed holds none of them), so Entity.judge and a form refuse a row that mixes cases, and foldkit-remote-drizzle writes each as a CHECK.

A key in several cases is one stored field, so it is declared once and spread into each, as DomainName is. Refused where declared: fewer than two cases, a case declaring _tag, an optional key, and a key with two different schemas.

Expr.is narrows a predicate to a case. Given a function, it hands it the case's fields typed as the case holds them, never null:

ts
const CheapDomains = Query.from(Domain).pipe(
  Query.where(Expr.is(Domain, 'Available', ({ price }) => Expr.lt(price, 20))),
)

It is _tag = 'Available' and the predicate, so it is false, not unknown, on an Unconfirmed: Expr.not of it keeps every Unconfirmed, where Expr.not(Expr.lt(Domain.fields.price, 20)) drops them, since their stored price is null. Given a one relation to a variant Entity, it reads the case of the row the relation reaches, each field as a path (Expr.is( Cart.relations.domain, 'Available', …)). Several cases at once are Expr.in(Domain.fields._tag, ['Available', 'Unconfirmed']).

A Selection that selects _tag reads the row as its case: its schema is a tagged union whose cases hold the selected fields they have, typed as the case holds them, and every selected relation and derived member.

ts
const Quote = Entity.select(Domain, { _tag: true, name: true, price: true })
type Quote = Selected<typeof Quote>
// { _tag: 'Available', name: string, price: number } | { _tag: 'Unconfirmed', name: string }
const shown = (quote: Quote) =>
  Quote.schema.match(quote, { Available: ({ price }) => `$${price}`, Unconfirmed: () => 'taken?' })

Without _tag a Selection is the stored row's Struct, and a field only some cases have is refused there, at the type and where it is written: nothing would say which case a row is.