Entity / foldkit-entity

Declaring what an input writes

An input says what each key is; a Write says what an operation does with them: Write.update one row, named by a key, setting every other key mapped to a field; Write.insert a row under an id the client chose; or Write.delete one, which sets nothing.

ts
import { Write } from 'foldkit-entity'

const EditPostInput = Schema.Struct({ id: Schema.String, title: Schema.String })
const EditPost = Entity.input(Blog.Post, EditPostInput)
const Edit = Write.update(EditPost, { id: 'id' })

Write.writes(Edit) // the fields and `one` relations any invocation may change
Write.bind(Edit, { id: 'p1', title: 'Compilers' })
// { entity: 'Post', id: 'p1', values: { title: 'Compilers' }, links: {}, increments: {}, assignments: {} }

It is declared data, like a query body: nothing is written by building one, and an Entity having a field grants no one the right to set it. bind encodes each value through its field's schema, so the patch is in the store's shape, and takes the keys to write when an author changed only some. expect names a key holding the revision the row was read at, for an interpreter to write only a row still at it. An unmapped key is not written. A key mapped to a one relation points the row elsewhere: bind gives it as a ref in links (none for an optional relation set to nothing), and each interpreter carries it its own way, a ref key in a store, a foreign key in a table. A many relation or a nested key is refused where the write is declared.

Values no client chooses: Entity.sets

Some values no client should choose: who wrote a row, and when. The Entity says so once, with Entity.sets, after Entity.relate when it names a relation. A value is a term of the write's context, Expr.viewer or Expr.now, and on says which writes set it:

ts
const AuthoredPost = Blog.Post.pipe(Entity.sets({ author: Expr.viewer(Author) }, { on: 'create' }))

Expr.viewer(Author) is whoever asks: a one relation to an Author pointed at them, or a field stored as text holding their id. Expr.now is the time, for a field stored as a number. A path from the viewer is what their row, or a row reached from it, holds: Expr.path(Expr.viewer(Member), Expr.path(Member.relations.team, Team.fields.orgId)) sets a note's orgId to its writer's team's, for a field that holds what the path's end holds. A server reads it through its own sources as the asker may see them; a client reads it from the rows it holds, and predicts nothing where it lacks one. A query body or a guard rule compares with one as with any value (Expr.eq(Note.fields.orgId, orgOfViewer)): SQL reads it in subqueries under each reached table's visible rule, and Judge from the rows its holder has (rows), unknown where it lacks the viewer's row.

on: 'create' sets the member on every insert; on: 'write' on every insert and every update (an editedAt). A delete sets nothing. Every write of the Entity follows from this one declaration (write.context), and a write whose input sets, links, moves or computes such a member is refused where it is declared. Write.contextValues(write, { viewer, now }, read) works out what they come to, read giving one member of one row at a time for a path. Each interpreter fills them from what it has: a server from its principal and its clock, a client as a prediction. A value that comes to null where the member holds none (a visitor) is named unfilled: a server refuses the write, a client predicts nothing for it. Nothing else about what a write does is declared anywhere: what it moves is read from the Entities' derived members and the queries' bodies.

When a write applies: when

An update or a delete may also say when it makes sense: when is what the row must hold for it to land, written as a query body is: () => Expr.eq(Post.fields.title, 'Draft'), or with the input, ({ input }) => Expr.eq(Post.fields.title, input.from). It reads the row as it is before the write and the input, never who is asking (that is a guard's), and only its own Entity: a field of another is a type error, and refused where the write is declared. It is held as a query body (write.when), so an interpreter checks it in the statement that writes, and a row it does not hold of is refused as unmet. A write declared with one is a ConditionedWrite, which is how Mutation.write knows it may be refused so.

Moving a number: increment

An update may move a number field by an amount the input holds, rather than set it: increment: { stock: 'quantity' } adds the input's quantity (an unmapped key, a number; negative takes away) to the stored stock, in the statement that writes, so no concurrent change is lost. A retry must not add twice, so either it expects a revision (a retry of a write that landed is then a conflict), or its writer keeps a ledger, which answers the retry from its record and lets two concurrent increments both land. RemoteServer.write refuses one with neither, and one of a real number with no revision even under a ledger: floating-point addition does not associate, so two concurrent sums could land as two results. The field must be stored as a number, and may not be the id, the revision, or set by another key. Write.bind gives the amounts apart from the values (increments), so a client can add them to the value it holds as it reads it (Remote does), never fixing a sum over a row that may since have moved. Write.reads(write) is what a write reads from its row: what its when reads, each field it increments, what its assignments read, and the revision. How a declaration, its guard, its writer and the client fit together is the declared writes guide.

Computing a field: compute

An update may also compute a field from the row before it and the input: compute: { label: ({ row, input }) => concat(input.prefix, row.name) }, with an operator such as the concat below. It reads neither the row after the write nor another Entity, so assignments never depend on each other, and is refused where it is declared for a field another key writes or a value of a kind the field does not store. A defined operator in it is expanded there, so Write.bind carries it as data (assignments, each a Computation: fields by key, values encoded, operators by name), which a writer compiles into the statement and a client predicts by its own table. A computation does not repeat without effect, so a retry of one is held to it as an increment's is; without a revision it lands only under a ledger, and there only where the writer pins the row it read (a guarded writer with a revision) or by an operator whose commutativity and associativity are tested over every value for each implementation the writer runs (Conformance.laws in memory, Conformance.lawsBy in SQL).

The row a write leaves: Write.apply

Write.apply(row, bound, { operators }) is the row a bound write leaves over the row before it: values set, amounts added (a null stays null, as in SQL), assignments computed. It is the one computation of that row: a guard judges it, a writer explains a miss by it, a client predicts it. unknown names each field it could not work out (one the row lacks, or an operator the table lacks), which keeps what the row holds.

What a client foresees of a supplied member

A count or an exists says what it is, so a client predicts it for a row it writes. A Derived.make member says nothing, so a client waits for the server, unless it says how to foresee it:

  • Derived.make(schema, { predict }): predict({ entity, row, rows, context }) reads the row the write leaves as the holder has it (a HeldRow, with the holder's other rows and the write's context) and returns the value, encoded, or none where it cannot know.
  • Derived.make(schema, { forbids }): forbids(value, surface) says which write to a row the value refuses. surface is a WriteSurface (kind Update or Delete, the members it writes, the relations it points as Link or Unlink), and the answer is Option<{ reason }>, only for a definite refusal. A client holding the value refuses that write where it is made, showing and sending nothing.

foldkit-guard's may declares both.