aster.UI / Docs
Aster products
Showcase ↗

Aster UI / Guides

Data core internals

This advanced guide explains the data core's implementation and detailed contracts. For the main concepts and a practical starting point, read the shared data model, then connect a grid and form.

Examples below use the public sample metadata: Projects, Tasks (a hierarchy through parentId), People, Clients, TimeEntries, Members, ShareLinks and Notes. The record-edit and page-rule examples add the M6 model, a questionnaire's Fields, their Answers and rule Clauses (fixtures/aster/model.ts).

The mental model

A DataContext is one client's view of one OData service. It owns one RecordStore, which holds the only copy of each record, keyed by entity set and key, with its ETag. A Collection is a query plus the positions of its results; it holds keys, not records, so it's a live view. Every write goes through the context, and when the store changes, every collection adds, moves or removes the record. Uncommitted edits live in an EditModel (one record), a ListEdit (a collection's rows) or a RecordEdit (a record with the collections it owns), apart from the store, until a save. A save produces a ChangeSet: plain data that can be inverted for Undo, stored and replayed.

Around a page there are more pieces. A Selection is the keys chosen in a collection, as data. A PageScope is a map from keys to what components publish: a list its selection, a form its values and flags. Rules are criteria over paths into the scope (project.values.health), and their results are signals that fields and buttons bind to. A collection's query can be a signal over the scope, so a detail list follows a selection. A Groups is the server's counts and totals by one field. A Tree is a self-referencing set seen as levels, each one a collection, with open state of its own.

Everything is plain TypeScript with signals (@preact/signals-core) and no DOM. The examples assume:

import { DataContext, keyText, type Criteria } from "@getaster/data";
import { createMockService } from "@getaster/mock";
import metadata from "../fixtures/aster/metadata.json";
import data from "../fixtures/aster/data.json";

const mock = await createMockService({ metadata, data });
const ctx = await DataContext.connect(mock.transport);

Schema

What it's for. parseCsdl reads a CSDL JSON $metadata document into a frozen Schema: entity sets, entity types, fields, keys, navigations and annotations (packages/data/src/schema/csdl.ts:211). It's the only field model. Validation, criteria checks, labels, form controls and the "what may I send" filter all read from it.

Metadata Becomes Used by
$Type, $Nullable, $MaxLength, $Precision, $Scale Field.type, nullable, maxLength, … Validation, $filter literals, comparisons, the control a form picks
Core.Description, Core.LongDescription Field.title, prompt Labels, error messages
Core.Computed, Core.Immutable Field.computed, immutable Read-only with a reason; left out of writes
$DefaultValue Field.defaultValue A create leaves out values equal to it (or null without one)
AsterData.EmptyAsNull Field.emptyAsNull Empty text entered is null (EditModel.set)
AsterData.RootValue on a parent field Field.rootValue A Tree's root value when the app gives none (tree/tree.ts:108)
Validation.Minimum/Maximum/Exclusive, Pattern, AllowedValues; AsterData.MinLength Field constraints Validation; allowed values become a select
Core.OptimisticConcurrency on the set EntitySet.etag Says writes need If-Match; the client sends it whenever it holds an ETag regardless
Capabilities.FilterRestrictions, SortRestrictions nonFilterable, nonSortable Criteria rejected before they're sent
AsterData.PluralTitle, AsterData.TitleField pluralTitle, titleField Screens; the picker's display text
$ReferentialConstraint, $Partner Navigation.keys, partner Lookups
AsterData.Owned on a navigation Navigation.owned The collections a RecordEdit edits with its record, saved as one deep update

Invariants.

Criteria

What it's for. A typed AST for filters (criteria/ast.ts:22). The same tree is compiled to $filter for the server and evaluated locally for sync, aggregates and page rules. A comparison is property against literal or property against another property of the same type (other, criteria/ast.ts:31).

Operators: and, or, not, eq, ne, gt, ge, lt, le, in, contains/startswith/endswith with an optional caseInsensitive flag, any/all over a to-many navigation, and via through a single related record (below).

import { matches, toFilter } from "@getaster/data";

const overdue: Criteria = {
  op: "and",
  args: [
    { op: "eq", field: "done", value: false },
    { op: "lt", field: "due", value: "2026-10-15" },
  ],
};
const tasks = ctx.schema.set("Tasks");
toFilter(overdue, tasks.type, tasks);        // "done eq false and due lt 2026-10-15"
matches(overdue, { id: 9, done: false, due: null }, tasks.type);  // false

const overspent: Criteria = { op: "gt", field: "spent", other: "budget" };
toFilter(overspent, ctx.schema.set("Projects").type);             // "spent gt budget"

compile.ts formats literals by the field's Edm type (criteria/compile.ts:8) and rejects unknown fields, wrong-typed values, and fields the set marks non-filterable or non-sortable (:29, :129). A property comparison must pair two fields of one type (otherOf, :37). evaluate.ts implements OData's rules: null equals only null; an ordering comparison with a null operand is false; string functions of null are unknown and exclude the row, even under not (criteria/evaluate.ts:81–108). Strings compare by code point, Decimal and Int64 exactly with bigints, DateTimeOffset by instant (criteria/values.ts:213, :238).

any and all. { op: "any", field: "answers", where: … } holds when some related record matches where, all when every one does, so all over no records holds (criteria/ast.ts:50). field is a to-many navigation and where names the related records' own properties, including another any or all. A lambda needs the schema to follow the navigation, so toFilter takes it as a fourth argument; each level gets its own variable, x0, x1 and so on (criteria/compile.ts:65–74, :115). A navigation that isn't a collection, or isn't there, is a CriteriaError (:67–70).

const fields = ctx.schema.set("Fields");
const offersOther: Criteria = { op: "any", field: "answers", where: { op: "eq", field: "key", value: "other" } };
toFilter(offersOther, fields.type, fields, ctx.schema);   // "answers/any(x0:x0/key eq 'other')"

Locally, matches(criteria, entity, type, related) reads the related records as a list under the navigation's name in the entity, and related says each navigation's record type (Related, criteria/evaluate.ts:21, :47–57); without them it throws. A related record whose test is unknown counts as not matching, as in OData. A page rule supplies both, from a repeating group (see "PageScope and rules"). tests/integration/page-rules-contract.test.ts:61 checks that the engine's answers are the model data's.

via: a single related record's fields. { op: "via", field: "project", where: … } holds when the record that a single-valued navigation leads to matches where, which names that record's own properties (criteria/ast.ts:57). It compiles to a path, with brackets where the precedence needs them, inside a lambda as well (criteria/compile.ts:75):

const tasks = ctx.schema.set("Tasks");
const late: Criteria = { op: "via", field: "project", where: { op: "eq", field: "health", value: "behind" } };
toFilter(late, tasks.type, tasks, ctx.schema);    // "project/health eq 'behind'"

Locally, matches needs the related record under the navigation's name. With none (null), its properties read as null, as on the server, so health eq null holds. A record that isn't there at all throws (criteria/evaluate.ts:58). A collection can't place a changed record from the record alone, so it refreshes, as for any and all. It also refreshes when the related set changes (see "Collection"). tests/integration/via.test.ts checks that the engine's answers are the fixture's. The shared filter channel uses nested via nodes for relationship paths.

Relative dates. A comparison on a date field may take a RelativeDate instead of a literal (criteria/ast.ts:9): { relative: "today" | "now", startOf?: "week" | "month", months?, weeks?, days? }. This week is ge { relative: "today", startOf: "week" } and le { relative: "today", startOf: "week", days: 6 }. The start moves the date to Monday or the first of the month, then months, then weeks and days are added, all in UTC (criteria/relative.ts:54–59). A relative date stays symbolic in a query or a published filter. resolveRelative(criteria, type, now) replaces each with a literal typed by its field: a YYYY-MM-DD date for Edm.Date, an instant for Edm.DateTimeOffset, a CriteriaError for any other type (:29, :54). hasRelative and isRelative ask whether there are any (:11, :7). The compiler and matches take resolved criteria only: an unresolved one reaches plain() and throws (criteria/values.ts:286). Collections, groups and rules resolve at each fetch or evaluation, against their clock (see "Collection" and "PageScope and rules").

The differential test. tests/integration/criteria-differential.test.ts runs every row of the truth table (packages/data/test/truth-table.ts) twice, through matches() and as $filter through the WebAssembly build of aster-odata's engine; both must give the expected answer (:35). The property-comparison rows (truth-table.ts:143–153) cover nulls on one or both sides, not over a null comparison, decimals across scales and beyond double precision, code-point order and instants across offsets. The same file checks that sortComparator orders rows exactly as $orderby does (:54).

Invariants.

Transport and ODataSource

A Transport is one function, request in and response out (transport.ts:27). fetchTransport(serviceRoot) talks to a real server; the mock's transport calls the WebAssembly engine in process. ODataSource speaks OData 4.01 over either (odata/source.ts:20): connect, query, aggregate, get, create, update, delete. Most code goes through DataContext instead.

update(set, key, changes, etag, deltas) can change owned collections in the same request. deltas is a Deltas (odata/source.ts:185): by navigation, the DeltaEntrys of that collection (:180). Each becomes an entry of <nav>@delta in the PATCH body (:98–112):

Entry Sent as
{ removed: key } { "@removed": { "reason": "deleted" }, "@id": "Answers(2)" } (:116)
{ values } The child's values. For an existing child they include its key properties and the changed ones
{ values, nested } A new child, with its own new children under the navigation each is named by: out[nav] = [...] (:119–122)

Invariants.

RecordStore

What it's for. One normalised, frozen copy of each record by set and key, with its ETag (store/store.ts:43). Each record has its own signal, so anything that reads it follows it.

const project = ctx.store.record("Projects", keyText([1]));   // keys are JSON arrays: '[1]'
const stop = ctx.store.subscribe((change) => {
  if (change.origin.kind === "external") console.log("someone else changed", change.set, change.key);
});

Every change is a StoreChange with before, after and an Origin (store/store.ts:21): fetch (a read; the fetching collection doesn't re-place it), save with by (the writer ignores its own echo), or external (another user's change; edit models treat it as theirs).

Partial records. A $select result is merged into the stored record, never blanking the fields it didn't fetch (put, store/store.ts:70; partial, :136). Its known set lists the fields that are current (:14). Stored fields stay current only when the ETag is the same version. A collection whose filter or sort reads a field outside known refreshes instead of guessing (collection/collection.ts:250).

Hidden fields. A field the server hides on one record is null, with the record's hidden set naming it (store/store.ts:25). That null means "not yours to see", not empty, so the store never counts a hidden field as known: a whole record is known except its hidden fields (whole, :150), and a $select result decides again for the fields it carries while the others stay as last heard (partial, :164). A hidden field holds null even where an older version had a value (blanked, :182). Everything that acts on a field only when it's known follows: a collection filtered or sorted by it refetches, page rules read it as unknown, and edit models leave it alone.

Invariants.

Collection

What it's for. A Query (set, filter, sort, select) and a window over its results (collection/collection.ts:25). keys[i] is the record at server position i, or undefined where nothing is loaded. Create collections through ctx.collection, which subscribes them to the store.

Member Meaning
pageSize (option, default 100) Rows per request
keepPages (option, default 20) Loaded pages view keeps
now (option) The clock relative dates in the filter resolve against, at each fetch and each local placement (:18). The context passes its own
exactCount (option) Asks for an exact count (Prefer: aster.count=exact), for a figure that matters; it costs the server a full count (:24). A tree counts node positions this way (tree/tree.ts:288)
ensure(start, end) / loadAll() Loads every page touching the range / everything. Missing pages that are next to each other load in one request
view(start, end) Shows a range: loads it, prefetches a page ahead, forgets the farthest pages
refresh() Refetches the pages in view, or every loaded page if view hasn't been used, adjacent pages together; current rows stay until answers arrive
setQuery(query) Replaces the query on the same set; see "Queries that follow the page"
keys, items Signals; items reads each record's signal, so edits show
count, counted, complete The server's $count, or without one the rows loaded plus a page; whether that's the true total; counted and every position loaded
estimated count is the server's estimate: right for sizing a scrollbar, not for stating as a fact (:82)
status, error, stale "idle", "loading" or "error"; a change whose position couldn't be decided, refresh running
const active = ctx.collection({
  set: "Projects",
  filter: { op: "eq", field: "state", value: "active" },
  orderBy: [{ field: "updatedAt", descending: true }],
});
await active.ensure(0, 50);
await ctx.update("Projects", keyText([1]), { state: "paused" });
// Project 1 has left `active` and joined any loaded `paused` collection; both counts changed.
ctx.release(active);

Viewed through a window. A grid over 100,000 rows shows a range, not everything it has loaded. view(start, end) (collection/collection.ts:145) loads the pages touching the range, then, without waiting, the next page in the direction of travel: the one below, or the one above when the range moved up. It then forgets loaded pages beyond keepPages, the farthest from the range first. A page in view or in flight is never forgotten. The count and keys.length don't change; a forgotten position is undefined again and loads when it's next viewed. refresh() reloads only the viewed pages and forgets the others, which may be stale; a collection never viewed refreshes everything it loaded. A collection that has forgotten pages isn't complete, so an aggregate over it is undefined: aggregates belong on collections loaded whole, or on Groups. tests/integration/windowed.test.ts covers each case.

Adjacent pages in one request. A view that straddles two pages, or a refresh of the pages in view, asks for the missing pages together. Pages that are next to each other form a run of at most five, and a run is one request with $skip at its first page and $top of the whole run (runsOf, collection/collection.ts:416; #loadPages, :314; #fetchPages, :353–357). Each page of the run counts as in flight until the answer lands, so another ensure for one of them waits for that request instead of asking again (:134–136, :336). Two pages cost one round trip, not two in turn (windowed.test.ts:71, :81).

Relative dates. A filter may hold relative dates (see "Criteria"). The collection keeps the query symbolic, collection.query.filter unchanged, and resolves it against its now for each request and each local placement (#resolved, collection/collection.ts:379). A collection built by the context has the context's clock, DataContext.connect(transport, { now }) (context.ts:36, :61), so a refresh a week later asks for the new week (tests/integration/page-rules-contract.test.ts:100).

Sort keys that are paths. A sort key can be a path through single-valued navigations, person/name (criteria/compile.ts:129, :154). It's checked against the schema: an unknown navigation, a collection-valued one ("a path can only follow single records") and a binary field are CriteriaErrors. It's sent as $orderby=person/name, so the server orders by the related value. The client can't: the related values aren't in the collection's records, so there's no comparator (collection.ts:431). A record that changes can't be placed with certainty, so the collection goes stale and refreshes rather than guess (:256–260). Key tie-breakers are appended as for any sort. The grid uses a path for a lookup column, personId sorting by person/name.

How it stays in sync (apply, collection/collection.ts:223). For each store change on its set, a collection ignores records it fetched itself and does nothing before its first page. If the change lacks a field the query reads, it refreshes. Otherwise it takes the record out if it was there, and puts it back if it now matches the filter, at its sort position when that's certain from loaded neighbours (#certainPosition, :273). When it isn't, the collection marks itself stale and refreshes its loaded pages. A fully loaded collection is always certain, so it never refetches. A filter with any, all or via is the exception: whether a record belongs depends on related records that aren't in it, so every change to the set makes the collection stale, loaded or not (:241–244, hasLambda, :436). A save to one of those related sets does too, since it can move records in or out (:225, relatedSets, :454); a fetch there doesn't. tests/integration/data-core.test.ts:70–114 covers each case.

Invariants.

Aggregates and lookups

aggregate, countWhere, countBy, sum and subtractExact (collection/aggregate.ts) are computed signals over a collection. Each is undefined until its collection is complete (:14), so a figure never shows a partial answer as the whole one. Decimal and Int64 sums are exact, with bigints, at the largest scale seen (:47). For totals over a set too big to load, Groups asks the server.

ctx.lookup(set, key) returns a signal of one record, for a foreign key's display value (context.ts:92). Missing records asked for in the same task share one in request per set (:185); the answer goes into the store as an ordinary fetch, and a failed request is asked again by a later lookup (:200).

Groups

What it's for. The groups of a query by one field, each with a count and any aggregates, computed by the server in one $apply request (collection/groups.ts:25). Group headers in the grid use it, so do a tree's child counts, and so does the project page's list of who has logged time. ctx.groups(query or signal, field, aggregates) makes one (context.ts:76).

const byHealth = ctx.groups(
  { set: "Projects", filter: { op: "ne", field: "state", value: "archived" } },
  "health",
  [{ field: "budget", method: "sum", as: "budget" }],
);
byHealth.rows.value;   // undefined until the first answer, then one row per group:
// [{ value: "behind", count: 3, totals: { budget: "120000.00" } }, …]
byHealth.dispose();

A GroupRow is value (null is a group of its own), count and totals by alias. An Aggregate is { field, method, as }, with method one of sum, min, max, average or countdistinct. The request is $apply=filter(…)/groupby((health),aggregate($count as count,budget with sum as budget)) (criteria/compile.ts:200, ODataSource.aggregate). Rows come in the group value's order, descending when the query's sort has that field descending. The query can be a signal: a change replaces it, sets rows back to undefined and counts again. Any save or external change to the set recounts after 50 ms, once for a burst (:18, :117), so an edit that moves a record between groups changes the counts without any collection being loaded. A relative date in the query resolves against the context's clock for each count (:94). error holds a failed request.

Invariants.

Selection

What it's for. The keys chosen in a collection, as data in the core rather than a signal the UI keeps valid (selection/selection.ts:26). Any view can read it, and a listbox or a rule can follow it.

import { Selection } from "@getaster/data";

const people = ctx.collection({ set: "People", orderBy: [{ field: "name" }] });
const chosen = new Selection(ctx.store, people);   // single, with fallback
await people.loadAll();
chosen.key.value;            // the first row's key: nothing chosen yet, so it falls back
chosen.select(keyText([4]));
chosen.selected.value?.name; // live from the store: an edit to person 4 shows at once
Member Meaning
keys, key Selected keys / the first one
records, selected The selected records / the first one, read from the store
count, any, many Flags that rules read
select(key), toggle(key), selectAll(keys), clear() Writes
option multiple Several keys; toggle adds and removes
option fallback On by default for single selection

The collection can be a signal (:45). The app's project selection follows the current tab's collection (apps/aster/src/model.ts:120).

Invariants.

EditModel

What it's for. Uncommitted values for one record, apart from the store (edit/edit-model.ts:60). It tracks the baseline (the stored record the edit started from, or the defaults for a new one), values, errors, a form-level error, busy, conflicts and deletion. Construct it with (ctx, set, key?, defaults?, { rules? }).

import { EditModel } from "@getaster/data";

const edit = new EditModel(ctx, "Projects", keyText([2]));
edit.set("name", "");
edit.valid.value;                   // false, and nothing shown yet
edit.validate();                    // false; errors are shown now
edit.errors.value.name;             // ["Project can't be blank."]
edit.set("name", "Brand refresh 2027");
edit.errors.value.name;             // undefined: the error followed the value
edit.pending("name").value;         // true
edit.state("updatedAt").value;      // { readOnly: true, reasons: { readOnly: "Updated is set by the server." }, … }

const saved = await edit.save();    // Saved, or undefined with the reason in errors, formError, conflict or deleted
edit.dispose();

An app rule is a function from values to messages by field, as the project page's "Spent can't be more than the budget" (apps/aster/src/pages/project.ts:203).

Field state (state(name), :349). A FieldState signal: visible, enabled, readOnly, required and a reason for each that isn't the default. It combines the field's page rules (below) with what metadata and the record say: computed fields are read-only ("set by the server"), immutable ones once saved, every field of a deleted record; a non-nullable, non-computed field is required (:357–369).

Hidden values. A field the server hides on the record (hidden, :101; see "RecordStore") is read-only with the reason "You can't see Budget on this record." (:383–385). It's never pending, even when forced, never validated, and never sent: every diff the model takes leaves it out (#diff, :621), so neither dirty, changes() nor a save can act on its null. The server would write a hidden field it was sent, so this matters. A field that becomes hidden while it's edited here is a conflict, since its value moved to null; rebase() then drops the edit (#theirs). A deleted record's change set leaves its hidden fields out, so an undo doesn't write null back (context.ts, list-edit.ts).

Page rules on fields. applyRules(scope, target, rules) (:262) takes visible, enabled, readOnly and required conditions per field, each optionally with a reason, and registers them in the scope as <target>.<field> for explain. edit.explain(name) returns them with what they read now (:276). The model applies these states as follows:

Validation. validate() shows errors; valid is the same check as a signal, with nothing shown (:102). The check (#check, :429) runs the metadata validators (on update, changed fields only), required-by-rule, then app rules, whose messages win. Once a field shows an error, the error follows the value: set() replaces it with the current problem or clears it (:228–233). Errors first appear only on validate or save.

Conflicts and deletion. The model watches the store for its record (:121). Another change (#theirs, :560) is followed when there are no local edits, rebased under them when it touched other fields, and raised as conflict = { theirs, fields } when it touched the same ones; then save() refuses until rebase() (keep mine) or discard() (take theirs). A removal sets deleted and a form error, makes every field read-only, and save() refuses (:125–130, :471).

Save (:470) validates, then creates, or sends only the changed fields with the baseline's ETag. Server field errors land on their fields; other failures go to formError. On a 412 the context reloads the record; if that rebased silently, the save is validated and sent once more (:479–489), so a change to other fields doesn't stop the save. If it raised a conflict, conflict says so. Values typed while the save was in flight stay, on top of the saved record, and remain pending.

Empty text. set() turns "" into null on a field marked AsterData.EmptyAsNull; on other fields the text is kept (:221).

Saved elsewhere. accept(saved?) takes a save made elsewhere, such as a parent's deep update, as the new starting point: the saved record if given, otherwise the current values. It clears forced pending, errors and the form error (:157). etag is the ETag of the version the edit started from (:149). showErrors(errors) puts errors the server gave for this record on its fields, replacing the ones shown; it's how a deep update's error on a child reaches the child's row (:174).

Drafts. draft() is the working state as plain data: the key, baseline, values, ETag and the fields forced pending (:179). resume(draft) takes it up, and throws for a draft of another record (:196). If the stored record has moved on since the draft was made, that's handled as any external change: followed where nothing was edited here, rebased under the edits when the other change touched other fields, a conflict when it touched the same ones (:204–206, #theirs, :560). A draft survives JSON.stringify, so storing it is the app's to do; the demo keeps them in IndexedDB (apps/aster/src/drafts.ts:14).

Replay. EditModel.replay(ctx, set, before, after, forced?) shows a past change: before is the baseline and after the values, so pending marks exactly the fields that differ, and those in forced (:140). Every field is read-only with the reason "This is a past change." (:357–359) and save() returns undefined (:471). It's built as a record with no key, so isNew is true: a replayed field has no Revert, and the bound field draws it as pending but doesn't describe it as changed in words.

Invariants.

PageScope and rules

What it's for. How components on a page react to each other without references to each other (scope/scope.ts:130). A component publishes named signals under a key; anything reads them by path, key.name.property.

Member Does
publish(key, publication) Adds a publication; returns the function that withdraws it (:150)
child() A scope whose keys shadow the parent's and whose misses fall through (:145)
value(path) A signal of the path's value; undefined while the key is unpublished. From a typed record it follows single-valued navigations (below) (:215)
select(cases) A derived value: the first case whose rule holds (below) (:241)
field(path) The CSDL field a path reads, when the publication gives the record type (:271)
when(condition) Whether a condition holds, as a signal (:287)
state(target, rules) A FieldState signal with reasons, registered under target (:304)
explain(target), readers(path), keys() Which rules decided and what they read; which targets read a path; who published what (:334, :347, :165)
schema() Every published key's names as plain data: where a publication gives a record type, the type's name and its fields' CSDL types. For rule builders and tools (:175)
check(path) Why a path can't be read, or undefined: nothing published under the key, no such name, no such field on the record type, a path that goes past a field (:194)
now The clock relative dates in rules resolve against: new PageScope(parent?, { now }); a child has its parent's (:133, :141)
ctx The DataContext paths through related records read from: new PageScope(parent?, { ctx }); a child has its parent's (:136)

A Condition (:25) is criteria whose fields are paths, or a bare path meaning "is true", combined with and, or and not at any depth. toCriteria spells it out as plain criteria (:32), so rules are evaluated by the same matches() as queries. A path into a publication that gives its record type is typed by its CSDL field; otherwise by its value, or, while it's null, by what the rule compares it with (#ruleType, :419). A comparison between two paths uses other.

A field hidden on the record a publication holds is unknown to a rule, not null. A publication says which fields are hidden through hidden, by name (selectionPublication, editPublication and inputPublication do). Any test of such a field is unknown, so eq null doesn't hold and neither does its not; a rule holds only when it's true, as a filter does (when, :216; #hidden, :234; matches(c, e, type, related, unknown), criteria/evaluate.ts:120). explain lists those paths as unknown. Rows that any and all range over aren't checked for hidden fields.

Standard publications (scope/publications.ts) give every component of a kind the same names:

Function Publishes
selectionPublication (:14) selected, records, keys, key, count, any, many; selected is typed
editPublication (:30) values, record (as saved), dirty, valid, errors, mode, busy, deleted; values and record are typed
collectionPublication (:47) total, loading, complete, stale
listPublication (:62) A repeating group: rows, the values of a ListEdit's rows that aren't being removed, typed by the list's record type; and count
inputPublication (:72) A page's input: key, plain text, and record, looked up from it and typed
valuesPublication (:77) App signals under names the app chooses

The project page's rules (apps/aster/src/pages/project.ts:34–36, :216–241): Days behind shows and is required while Status is Behind; an archived project is read-only, with its reason; Save is enabled while the project or its tasks have changes.

const BEHIND: Condition = { op: "eq", field: "project.values.health", value: "behind" };
const ARCHIVED: Condition = { op: "eq", field: "project.record.state", value: "archived" };
const LOCKED = { when: ARCHIVED, reason: "Archived projects can't be edited. Set State to Active to restore it." };

scope.publish("project", editPublication(edit));
edit.applyRules(scope, "project", {
  name: { readOnly: LOCKED },
  behindDays: { visible: BEHIND, required: BEHIND, readOnly: LOCKED, clearWhenHidden: true },
  // leadId, health, budget, spent: { readOnly: LOCKED }
});
scope.publish("tasks", valuesPublication({ dirty: tasks.dirty, valid: tasks.valid }));
const save = scope.state("save", {
  enabled: { when: { op: "or", args: ["project.dirty", "tasks.dirty"] }, reason: "No changes to save." },
});

ARCHIVED reads record, the saved values, so changing State in the form doesn't unlock the other fields until the change is saved. scope.explain("project.behindDays") returns each rule with holds and reads such as { "project.values.health": "onTrack" }; the demo's ?debug panel shows it for the focused field (apps/aster/src/debug.ts:25).

Invariants.

Rules over a repeating group. A list's rows are published with listPublication, and a rule ranges over them with any and all. The rule names the list by its path, <key>.rows, and where names the rows' properties:

scope.publish("answers", listPublication(answersList));          // a ListEdit of a field's answers
const hasOther = scope.when({ op: "any", field: "answers.rows", where: { op: "eq", field: "key", value: "other" } });
const allLabelled = scope.when({ op: "all", field: "answers.rows", where: { op: "ne", field: "label", value: "" } });
answersList.add({ key: "other", label: "" });                    // hasOther: true; allLabelled: false

The scope hands matches the rows as a list and tells it their type from the publication (when, scope/scope.ts:292–299). The rule follows edits: a new row counts as soon as it's added, a row marked for removal stops counting (publications.ts:63). The list's path must be one the publication types (rows is), or the rule throws; so does a path that isn't a list (scope.ts:540). The scope can type only the lists it publishes, so an any inside an any works in a query and throws in a rule (scope.ts:299). tests/integration/page-rules-contract.test.ts:73 covers a single level.

Relative dates in rules. A rule with a relative date resolves it against the scope's now each time the rule is evaluated (scope.ts:298), which is when something it reads changes. Nothing re-evaluates a rule because the clock moved. scope.check("task.values.due") and the typed scope.schema() tell a rule builder which paths exist before a rule is written.

Criteria validators. criteriaRule(type, field, validator, now?) makes an EditModel rule from criteria (validate/criteria-rule.ts:30). The value passes while passWhen holds, or fails while failWhen does; exactly one is given (:32). A test without a field is about the validated field itself, and one that names fields reads the record (OwnCriteria, :11; own, :41). A null value isn't checked: required is its own rule (:35). Relative dates resolve against now, which defaults to the wall clock, so pass the context's.

const type = ctx.schema.set("Tasks").type;
const rules = [
  criteriaRule(type, "title", { failWhen: { op: "startswith", value: "TODO" }, message: "Give it a real title." }),
  criteriaRule(type, "due", { passWhen: { op: "ge", value: { relative: "today", days: -365 } }, message: "Due dates go back a year at most." }, ctx.now),
  criteriaRule(type, "assigneeId", { failWhen: { op: "and", args: [{ op: "eq", field: "done", value: true }, { op: "eq", value: 12 }] }, message: "Ken can't hold done tasks." }),
];
const edit = new EditModel(ctx, "Tasks", key, {}, { rules });   // the message lands on the field, and wins over metadata's

Templates. parseTemplate("{name} ({count == 1 ? 'one task' : count})") (template/template.ts:39) parses display text with values in it. Inside braces it reads paths (project.name), string, number and boolean literals, null, == != < <= > >=, &&, ||, !, the conditional a ? b : c and parentheses; {{ and }} are literal braces (:1–9). The result is a tree, walked by render(lookup); nothing is compiled, so text from an untrusted source can't run code and a strict CSP holds. Anything else, a call, +, ;, an unclosed brace, is a TemplateError with its offset (:30, :35). template.paths lists what it reads, to follow them in the scope (:12). A null or missing value renders as nothing (:204); == is strict, so "1" doesn't equal 1; ordering compares numbers with numbers and text with text and is false otherwise (:185, :189–194). A } can't appear in a string literal, and there are no negative literals (:56, :37).

Paths through related records. From a publication that gives its record type, a path step that names a single-valued navigation reads the related record from the store: task.values.project.lead.name follows the task's project and then its lead (value, scope.ts:215; #steps, :358; #related, :382). The scope needs a data context for that, new PageScope(parent, { ctx }); the demo's root scope has one. A record that isn't loaded is fetched through ctx.lookup, batched, and the path reads undefined until it arrives. A missing foreign key is no related record, so the path reads null. The path follows the foreign key as it changes. It's typed at the end, so scope.field("task.values.project.createdAt") is the project's Edm.DateTimeOffset field, and a rule compares it as one. A path follows at most MAX_HOPS (3) navigations (:93). check(path) names the problem with a bad one: an unknown field, a to-many navigation (use any or all), a step past a field, too many hops, or no data context. Related lists support aggregate cases in scope.select:

const lowest = scope.select([
  { aggregate: "min", path: "task.values.project.tasks", field: "id" },
], "lowestTask");
// Read lowest.value; dispose when the consumer goes away.
lowest.dispose();

min, max and average query the entire related list through $apply, including unloaded rows. A path follows at most three navigations and ends on a list with foreign keys. average needs a numeric field; min and max also accept text and temporal fields. Nulls are ignored, an empty or absent list yields null, and loading yields undefined. Parent key changes invalidate old answers immediately; writes to the target set trigger a recount. Request errors leave the value undecided and appear in scope.explain(target). An edit model owns and disposes the signals for its case-based derived fields.

Derived values. scope.select(cases) (:241) gives the value of the first case whose when holds, and a case without when is the default. A case's value is a literal (value), another path (path), or a template (template, parsed as below). The result is undefined when no case applies. It's also undefined while a path it needs reads undefined (an unpublished key, a related record still loading), so nothing is decided on data that isn't there yet. A template waits for all its paths the same way. A null still counts as a value.

const label = scope.select([
  { when: { op: "eq", field: "p.health", value: "behind" }, template: "{p.days == 1 ? 'A day' : p.days} behind" },
  { when: { op: "eq", field: "p.health", value: "atRisk" }, value: "At risk" },
  { path: "p.health" },
]);

On an edit-model field, derived in applyRules takes cases or a signal (edit-model.ts:247). The design's rule:

The demo's Time grid suggests billable from the entry's project (apps/aster/src/pages/time.ts:37):

billable: { derived: [{ when: { op: "eq", field: "row.values.project.clientId", value: null }, value: false }, { value: true }] },

edit.explain(name) also shows derived cases, their zero-based selected case, input paths, result, current field value and whether the user overrode it. The demo debug panel shows this alongside state rules.

tests/integration/derived-values.test.ts covers the cases, both kinds of field, existing records, drafts, loops and related paths.

Page inputs. inputPublication(ctx, set, key) publishes the record a page was opened with as key and record (publications.ts:72). The key is plain text, so a page opened in another tab or window from the same address has the same input (page-rules-contract.test.ts:197).

ListEdit

What it's for. Editing a collection's rows together: changes to existing rows, new rows and rows marked for deletion are all pending until save() (edit/list-edit.ts:63). The project page's task list uses it, and a RecordEdit holds one for each collection its record owns.

import { ListEdit } from "@getaster/data";

const rows = ctx.collection({ set: "Tasks", filter: { op: "eq", field: "projectId", value: 3 } });
await rows.loadAll();
const tasks = new ListEdit(ctx, rows, { defaults: () => ({ projectId: 3, title: "", done: false }) });
tasks.rows.value[0]!.edit.set("done", true);   // an ordinary EditModel per row
const added = tasks.add({ title: "Write the brief" });
tasks.remove(tasks.rows.value[1]!.id);         // marked, not deleted
tasks.changes();                               // [delete, update, create] change sets
const { saved, failed } = await tasks.save();

rows lists existing rows in the collection's order, then new rows in the order added; each is { id, edit, isNew, removing, links? } (:10). dirty, valid and busy are signals; restore(id) takes back a pending deletion, and revert() throws away everything (:229). set and type say which entity the rows are (:64–65).

Order. With a positionField (:30) the rows are shown in that field's order, ties in the collection's order, and ordered is true (:85, :121–124). add gives a new row the next place: the number of rows not being removed, plus one (:173). move(id, index) puts a row at index among the rows not being removed and renumbers the field from 1, as ordinary edits to each row whose number changed (:202–211), so a reorder is pending, revertible and in the change set like any other edit.

Links between new rows. add(values, links) takes links: by field, the id of another new row whose key that field takes (:170). save() creates the rows in the order added and sets the field from the key the other row got (:265–279). A RecordEdit doesn't create them in turn: it sends the child inside its parent's entry (see "RecordEdit").

Saved elsewhere. accept() takes the list's state as saved by something else, such as a deep update of the parent: changed rows keep their values as the new baseline, new rows and pending deletions are dropped, and the collection's refresh brings the saved rows (:293).

Drafts and replay. draft() and resume(draft) keep the new rows (with their links), pending deletions and edited rows as plain data (ListDraft, :40, :304, :316); rows a draft names that are no longer in the list are dropped. ListEdit.replay(ctx, set, before, changes) shows past changes over the rows as they were: updated rows pending in the fields that changed, removed rows marked for removal, added rows pending in full (:137). A replay is read-only: add throws, the others do nothing (:171, :182, :244), and it has no collection (:67).

Invariants.

RecordEdit

What it's for. Editing a record together with the collections it owns (edit/record-edit.ts:57). A navigation marked AsterData.Owned, such as a field's answers or rules, holds children that can't exist without the parent. So the record and its children are edited as one thing: one change set, one validation, one save, one conflict check. The owned collections come from $metadata; nothing is written per record type.

import { RecordEdit } from "@getaster/data";

await ctx.collection({ set: "Fields", filter: { op: "eq", field: "id", value: 4 } }).ensure(0, 1);   // the record must be in the store
const field = new RecordEdit(ctx, "Fields", keyText([4]), { owned: { answers: { positionField: "position" } } });
await field.ready;                                   // its answers and clauses have loaded
field.edit.set("label", "Type of client");           // the record's fields: an EditModel
const answers = field.owned.answers!;                // a ListEdit for each owned collection
answers.remove(answers.rows.value[0]!.id);
answers.add({ key: "club", label: "Club" });
field.changes();     // one update: changes.label, and owned.answers = [delete, create]
field.problems();    // { record: [], owned: {} }
const result = await field.save();   // one PATCH; { ok: true, change, before, failed: [] }
Member Meaning
edit The record's own fields, an EditModel (:60)
owned A ListEdit for each owned collection, by navigation name. Its rows are the target's rows whose foreign key is the record's, in positionField order or by key, and a new row's foreign key is the record's (:92–100, :314–325). options.owned gives each list its ListEditOptions (:25)
ready Settles when every owned collection has loaded (:103)
dirty, valid The record or any list has changes; the record and every list are valid (:106–107)
changes() The change a save would make, or undefined (:143)
problems() What fails validation (:161)
snapshot() The record and its existing owned rows as they were when editing began (:134)
revert(), dispose() Throws away every change; releases the owned collections (:282, :307)
failure Why the last save failed, when it isn't about a field (:280)

One change set. changes() is a single update change set: the record's field changes, and owned, the child change sets of each list that has any, by navigation name. A list's are in the order it saves them: removals, updates, creations (:143–158; list-edit.ts:214). A child is identified by its key, not its position.

Validation. problems() validates every field of the record and of every row not being removed, shown or not, so a tab nobody opened can still be named. It shows the errors it finds and returns the record's field names and, for each owned collection, the failing rows by ListRow.id with their field names (:161–169).

Saving. save() (:180) returns undefined for a replay, when nothing changed, and when problems() finds anything. Otherwise it sends one PATCH with the record's changed fields and <navigation>@delta for each list that changed (#deltas, :211), with If-Match set from the record's ETag:

The server applies the whole request or none of it (tests/integration/record-edit.test.ts:174). On success the record takes the response as its baseline and ETag, each list accepts, and the collections refresh to bring the new rows' keys (:191–195). The RecordSave has ok, change (what was sent; new rows have no key yet), before (the snapshot taken before) and failed (:31). On a refusal ok is false, nothing is saved and everything stays pending.

Errors land on rows. A detail whose target is answers[2]/label is the third answers entry of the request, its label: it is shown on that row (EditModel.showErrors). A bare name goes to the record's own field. If no detail lands on a row or the record, failure is the server's message (#showErrors, :257–276).

412. ctx.update reloads the record on a 412 as an external change, so the edit model follows it, or raises edit.conflict when it touched the same fields (context.ts:215; edit-model.ts:560). With no conflict, save() sends once more with the new ETag; with one, it returns ok: false and conflict says so (record-edit.ts:199).

Replay. RecordEdit.replay(ctx, before, change) shows a saved change over the record and rows as they were: the record's fields and each list as replays, read-only, pending marking exactly what the change did (:115). before is a RecordSnapshot and both it and change are plain data, so a log can keep them as JSON. A delete shows every owned row as removed; a create shows only the values (:123–126). save() on a replay returns undefined (:181).

Drafts. draft() is the working copy as plain data, the record's EditDraft and a ListDraft for each list (:289). resume(draft) waits for ready, then takes each up; a stored record that moved on since is followed or becomes a conflict, as in EditModel.resume (:296–305).

The model screen uses it for a field: Definition, Answers, Visibility and Messages are one RecordEdit (apps/aster/src/pages/field-editor.ts:119). It logs a save only when ok is true, with before and change as JSON (apps/aster/src/pages/infomodel.ts:213); the log's replay is RecordEdit.replay (:485–489).

Invariants.

Queries that follow the page

What it's for. A list whose query depends on something else on the page, such as the selected person. ctx.collection takes a ReadonlySignal<Query> as well as a query (context.ts:55). The collection follows it: each new query goes through setQuery, which drops the rows at once, so nothing from the old query shows as an answer to the new one, and reloads the first page if the collection had loaded (collection/collection.ts:89). The People page calls loadAll() again when the selection changes.

The People page's detail lists follow the selection through the scope (apps/aster/src/pages/people.ts:154–167):

const personId = scope.value("people.selected.id");
const follow = (build: (id: number) => Query) => computed(() => build((personId.value as number | undefined) ?? -1));
const leads = ctx.collection(
  follow((id) => ({ set: "Projects", filter: { op: "eq", field: "leadId", value: id }, orderBy: [{ field: "name" }] })),
  { pageSize: 200 },
);

The listbox publishes people; the lists don't know it exists. page-rules.test.ts:223 checks that a detail list never shows the last person's rows.

Invariants.

Shared filters

What it's for. Dashboards: a period, a chart's bar or a list's selection narrows other lists on the page without wiring them together (scope/filters.ts:90). A SharedFilters holds named contributions, and collections opt in to combining them with their own query. Nothing contributes or consumes by default. See the dashboard guide for a complete application pattern.

Member Does
set(contribution) Adds or replaces the contributor's entry, in place. Criteria on a set are checked against it at once (:103)
remove(by), clear(), removable(by) Withdraws one entry, or all that can be removed (:113, :128, :123)
share(by, source, clear?) Follows a signal of a contribution; undefined withdraws. clear is how the contributor withdraws when someone else removes it (:139)
consume(set, options) How the contributions apply to a collection on set: criteria (ANDed), applied and skipped with reasons (:155)
query(base, options) base (a query or a signal of one) with the applied criteria ANDed on, for ctx.collection or ctx.groups (:176)
contributions The entries, as a signal

A Contribution (:20) has by (the contributor's name), label (said in words, for chips: "Status: Approved"), criteria, and optionally set, related, fields, only and kind.

const filters = new SharedFilters(ctx.schema);
scope.publish("filters", filtersPublication(filters));   // filters.any, filters.count, filters.labels for rules and labels
filters.set({ by: "health", label: "Project health: Behind", set: "Projects", criteria: { op: "eq", field: "health", value: "behind" } });
filters.set({ by: "period", label: "Last 30 days", set: "TimeEntries", criteria: { op: "ge", field: "date", value: { relative: "today", days: -29 } } });
const entries = ctx.collection(filters.query({ set: "TimeEntries", orderBy: [{ field: "date", descending: true }] }));
// $filter=project/health eq 'behind' and date ge 2026-09-08

Matching (#match, :208):

Loops. A component that publishes and consumes could feed itself. The rules that stop it:

Relative dates stay symbolic in the channel: contributions holds { relative: "today", days: -29 }. Each collection resolves them at each fetch against its clock, through a hop too (project/createdAt ge …).

Invariants.

tests/integration/shared-filters.test.ts covers each rule. The demo's Reports page uses it with an aster-slicer (elements.md).

Saving shared filters with a screen

filters.snapshot() returns { version: 1, contributions } as plain data. Save it with the screen, then pass it to filters.restore(snapshot) before opening its collections. Relative dates remain symbolic. Snapshots exclude identity selections and signal-following contributors, whose state belongs to their owners. Restore validates every set-scoped criterion before replacing manual slices; a failed restore leaves the channel untouched. Live owners are preserved and a saved entry cannot overwrite one.

Reports uses the browser key aster-screen:reports:filters, restores before querying, and saves changes including Clear all. Storage failures leave the current screen usable. This is browser-local screen state.

Tree

What it's for. A self-referencing set seen as a hierarchy: tasks under tasks by parentId (tree/tree.ts:72). The grid's tree mode and the drill-down navigator are two views of one Tree, and two trees over the same data keep their own state. It needs a single-field key and a parent field; otherwise the constructor throws.

import { Tree } from "@getaster/data";

const tree = new Tree(ctx, {
  set: "Tasks",
  parentField: "parentId",
  base: computed((): Criteria => ({ op: "eq", field: "projectId", value: 3 })),
  orderBy: computed(() => [{ field: "id" }]),
});
tree.level(undefined);                          // the top level: a Collection
await tree.expand(keyText([12]));               // open task 12, load its first page
tree.rows.value;                                // the lines shown, top to bottom
await tree.reveal(keyText([40]));               // open the path to task 40; its row index
await tree.move(keyText([40]), keyText([7]));   // put task 40 under task 7
tree.dispose();
Option Meaning
set, parentField, rootValue The set, the field holding a parent's key, and the parent value of top-level nodes: the parent field's AsterData.RootValue when the app gives none, else null (:15–16, :108)
base A signal of criteria limiting the whole tree, such as one project's tasks
filter, filterMode A signal of criteria, and how it applies (below)
orderBy A signal of the sort among siblings
pageSize Rows per request in a level (100)
matchLimit, openLimit Matches beyond which keep-ancestors gives up (8,000), and folders opened to show where matches are (200)

Levels are collections. Each level is a Collection of the nodes under one parent: the parent criterion (the root value for the top), the view's filter, base and orderBy (#levelQuery, :329). level(parent) makes it on first use (:129) and returns the same one after. A level loads by page and follows the store's sync rules like any collection, so a node whose parent changes leaves one level and joins another, and an insert, update or delete reaches every open level of every tree. rows lays the open levels out as lines from the top (:362). A TreeRow has key (undefined for a placeholder not loaded yet), parent, level (1 at the top), position and siblings, folder, open, and its level's collection and index, so a view can load by page.

Open state. A set of keys, apart from the records: open, expand(key), collapse(key), toggle(key) and setOpen(keys) (:152, :175). It survives reloads, refresh and filter changes, and each tree has its own. expand opens the folder and loads the level's first page. A key that no longer exists is simply never shown, and a row is shown open only if it's a folder.

Child counts. Each level has a Groups of its loaded nodes by the parent field, under the same filter as the level, so one request per level says which nodes have children (#countsFor, :346). A row's folder is true, false or undefined before the counts arrive; hasChildren(key, parent) is the same answer. A leaf that gains a child becomes a folder when the counts follow the change.

Filtering. filter is a signal, a grid's filter row for example. base always applies.

ancestors, reveal and descendants. ancestors(key) is the keys from the top down to the node's parent, reading each record's parent field and loading records it doesn't have; a cycle in the data throws instead of looping (:188, :196). reveal(key) (:209) opens every folder on that path and loads each level's pages until the node is in it, resolving to the node's row index, or -1 if the node isn't in this tree (filtered out, or elsewhere). A node's place in its level is counted by the server: the siblings that sort before it, as a filter built from the sort keys (#position, :271). So a deep link to the last of 3,000 siblings loads one page around it, not every page before. Where the sort can't be written as a filter, a path into a related record or a null in a sort value, it loads page by page until the node is in. descendants(key) (:238) lists every node under a key, nearest first, with one request per level (in chunks of 200 parents), so a subtree can be shown before it's removed or moved.

move. move(key, parent) (:303) sets the parent field with ctx.update: a node's key, or undefined for the top level. It refuses a move into the node itself or its own subtree, by throwing. The store's sync takes the node out of the old level and into the new one, and it keeps its open state. A move is one write with the record's ETag, so a conflict is a 412 like any other. Moving doesn't reorder: siblings stay in the sort's order. The Tasks page undoes a move by moving back.

Sync. Because levels are collections over the store, a tree needs no change feed of its own. refresh() reloads every level's viewed pages and keeps the open folders open (:313); dispose() releases every collection and count (:317).

Invariants.

ChangeSet

What a save changed, as plain serialisable data (changes/changeset.ts:17): create (values), update (key plus { from, to } per field, and optionally owned) or delete (key plus the old values). Every DataContext write and EditModel.changes() returns one.

import { invert, merge } from "@getaster/data";

const saved = await ctx.update("Projects", keyText([1]), { state: "paused" });
saved.change;   // { kind: "update", set: "Projects", key: [1], changes: { state: { from: "active", to: "paused" } } }
invert(saved.change, ctx.schema.set("Projects").type);   // state: paused → active
merge({ name: { from: "A", to: "B" } }, { name: { from: "B", to: "A" } });  // {}: ended where it began

invert of a delete is a create of the old values minus computed fields (:52–65); merge keeps the first from and last to (:69); writable removes computed properties before anything is sent (:83). creatable (:96) is what a create sends: the writable values that differ from the field's $DefaultValue, or from null without one.

Owned changes. An update can carry owned (OwnedChanges, :10, :24): by navigation name, the change sets of that collection's children, each a create with its values, an update with its key and field changes, or a delete with its key and old values. RecordEdit.changes() builds one, so a record's edit across its nested editors is a single change set that can be stored and replayed by key (RecordEdit.replay). diff, apply and merge work on a record's own fields and leave owned alone. invert of an update that has owned throws: undo each child by itself (:60). DataContext.run of an update runs its field changes and nothing else (context.ts:161–167).

DataContext

What it's for. The entry point (context.ts:25). It holds the source, the store and every live collection, and all writes go through it so sync sees them.

Method Does
DataContext.connect(transport, { now }) Reads $metadata and builds the context. now is the clock relative dates resolve against, Date.now by default; the collections and groups it makes use it (:36, :47, :61, :77)
collection(query or signal, options) / release(c) A live collection, following the signal if given / stops it
groups(query or signal, field, aggregates) The query's groups by a field; see Groups
lookup(set, key) See above
create, update, remove (each with an optional by) Writes; update sends only the changes, with the stored ETag or the ifMatch it's given, and takes deltas to change owned collections in the same request (:108, :120, :131)
reload(set, key, origin?) Reads one record again; removes it if it's gone
run(change) / undo(saved) Runs a change set / its inverse

Invariants.

Against aster-server

The data core talks to aster-odata's HTTP server through fetchTransport, as it talks to the mock through the mock's transport; nothing else changes.

These repository commands are integration-test tooling. They do not establish production readiness for an application or its identity provider.

Implementation boundaries

The following boundaries apply to the client and demo tooling described here. They are not a roadmap for the server: