@jam-overture/ loom/ store
Persisting trees and revisions: the store contract, and an in-memory implementation.
Everything below is exported from that import. The names, the signatures and the sentences are read from the package itself rather than written here, so this page says what the copy of Loom in your node_modules says — and it changes in the same pull request the code does.
54 exports, in 9 modules. Generated from ./dist/store/index.d.ts, which is the declaration file this package publishes for @jam-overture/loom/store.
Nothing to install first. Everything this import loads arrives with @jam-overture/loom itself.
No import here has everything behind it. @jam-overture/loom/store publishes 54 of the 1,298 names this package publishes. The other 1,244 are behind one of the 16 other imports, and not one of those imports publishes a single name this one does. The imports do not nest: a name that is not on this page is not a name that does not exist, and the search at the top of the page says which import it comes from.
Attribution
store/attribution
Who put this node here.
NodeChangetype
What a revision did to a node that was already in the tree.
type NodeChange = "configured" | "moved";
NodeEffecttype
What a revision did to a node that is in the tree now.
type NodeEffect = "placed" | NodeChange;
NodeTouchtype
One revision that touched one node, and the whole entry it came from.
type NodeTouch<Effect extends NodeEffect = NodeEffect> = {
readonly effect: Effect;
/**
* Whether the operation named this node, as opposed to carrying it in.
*
* An insert brings a whole subtree, so every node in it was placed by that
* revision — but only the subtree's root was asked for. "The model added a
* card" and "the model added the heading inside the card it added" are
* different sentences, and a reviewer deserves the one that is true.
*/
readonly named: boolean;
readonly entry: StoredRevision;
};NodeAttributiontype
What is known about how a node came to be in the tree.
type NodeAttribution = {
readonly outcome: "placed";
readonly nodeId: NodeId;
readonly placed: NodeTouch;
/**
* Every touch since it was placed, oldest first. Never a placement: the
* walk stops at the one that put the node here, so anything it collected
* on the way changed a node that was already there.
*/
readonly since: readonly NodeTouch<NodeChange>[];
}
/** The walk reached the start of the log without finding a placement. */
| {
readonly outcome: "seeded";
readonly nodeId: NodeId;
readonly since: readonly NodeTouch<NodeChange>[];
}
/** The walk ran out of budget first. `since` is what it saw, not all there is. */
| {
readonly outcome: "undetermined";
readonly nodeId: NodeId;
readonly since: readonly NodeTouch<NodeChange>[];
};TreeAttributiontype
type TreeAttribution = {
readonly nodes: ReadonlyMap<NodeId, NodeAttribution>;
/**
* The oldest revision the walk read, or null when it read none.
*
* Reported once here rather than on every `undetermined` node, because it is a
* fact about the read and not about any node. It is also the only thing that
* makes an `undetermined` actionable: the node was placed at or before this.
*/
readonly examinedTo: number | null;
/** True when the walk saw the whole log, which is what turns unplaced into seeded. */
readonly reachedStart: boolean;
};AttributionRequesttype
type AttributionRequest = {
/**
* How many pages of log to read before giving up on whatever is still
* unattributed. Every read in Loom is bounded and this one is no exception; a
* tree edited for long enough has a history no request path should fold.
*/
readonly pages?: number;
};DEFAULT_ATTRIBUTION_PAGESvalue
Five pages, which at the store's default page size is five hundred revisions.
const DEFAULT_ATTRIBUTION_PAGES = 5
attributeTreefunction
Attributes every node in a tree, walking that tree's log backwards.
Shown in use on When something looks wrong — Who put this here?
const attributeTree: (reader: TreeReader, tree: LoomTree, request?: AttributionRequest) => Promise<Result<TreeAttribution, StoreError>>
Driver
store/driver
The two things every Postgres-backed store has to do with a driver's error.
isUniqueViolationfunction
Postgres reports a primary-key collision as SQLSTATE 23505, but Drizzle wraps the driver's error, so the code sits somewhere down the cause chain rather than on the error that was thrown. Walking the chain is what makes this work across drivers instead of against whichever one was tested first.
const isUniqueViolation: (cause: unknown) => boolean
Errors
store/errors
What persistence can refuse, and how to say it.
StoreErrortype
type StoreError =
/** No tree under this id — distinct from a tree with an empty history. */
{
readonly code: "not-found";
readonly treeId: TreeId;
} | {
readonly code: "already-exists";
readonly treeId: TreeId;
}
/**
* The delta names a base revision that is not the current head. This is the
* whole of concurrency control: two writers racing at the same base means the
* second one is refused rather than silently applied to a tree it never saw.
*/
| {
readonly code: "revision-conflict";
readonly treeId: TreeId;
readonly expected: number;
readonly found: number;
} | {
readonly code: "delta-rejected";
readonly treeId: TreeId;
readonly error: TreeError;
} | {
readonly code: "unavailable";
readonly detail: string;
};StoreErrorCodetype
type StoreErrorCode = StoreError["code"];
STORE_ERROR_CODESvalue
Every way persistence can refuse, in the order a reader meets them.
const STORE_ERROR_CODES: readonly StoreErrorCode[]
describeStoreErrorfunction
Shown in use on Going to production — When the database does not answer, your app is told which thing went wrong
const describeStoreError: (error: StoreError) => string
Memory
store/memory
A store in memory: the log, the snapshot, and the invariant that ties them.
memoryTreeStorefunction
Shown in use on The history of a page — Where this actually runs and Going to production — Three places state lives, and only one of them is your pages
const memoryTreeStore: () => TreeStore
Replay
store/replay
Replay: folding the log, and the two questions a fold answers.
ReplayMismatchtype
type ReplayMismatch = {
readonly code: "delta-rejected";
readonly revision: number;
readonly detail: string;
}
/** A gap or a repeat in the log: revisions must be consecutive from the seed. */
| {
readonly code: "revision-gap";
readonly expected: number;
readonly found: number;
};ReplayedTreetype
A fold, and what the fold saw on the way past.
type ReplayedTree = {
readonly tree: LoomTree;
readonly idHistory: IdHistory;
};replayTreefunction
A whole log, from a seed the caller can prove is revision 0.
const replayTree: (seed: LoomTree, entries: readonly StoredRevision[]) => Result<ReplayedTree, ReplayMismatch>
SnapshotAudittype
What the fold noticed about ids on the way, whatever the verdict turned out to be.
type SnapshotAudit = {
readonly outcome: "agrees";
readonly revision: number;
readonly idReturns: readonly IdReturn[];
}
/**
* The fold produced a different tree — the drift this whole module exists for.
*
* Both sides are reported, not only the replayed one. The audit read the
* snapshot to reach this verdict, and a caller that had to read it again to
* find out *how* they differ would be comparing against a head that may have
* moved since — so it could describe a divergence that was never the one
* observed. `compareTrees(stored, replayed)` turns these two into something an
* operator can act on.
*/
| {
readonly outcome: "diverged";
readonly revision: number;
readonly stored: LoomTree;
readonly replayed: LoomTree;
readonly idReturns: readonly IdReturn[];
} | {
readonly outcome: "unreplayable";
readonly mismatch: ReplayMismatch;
};auditSnapshotfunction
Folds a tree's log from a known seed and compares the result to the stored snapshot. A host runs this in a test or a scheduled job; nothing on a request path needs it, which is the point.
Shown in use on The history of a page — The store keeps two things, Going to production — The tables Loom creates and When something looks wrong — Is the page still what its history says?
const auditSnapshot: (store: TreeReader, treeId: TreeId, seed: LoomTree) => Promise<Result<SnapshotAudit, StoreError>>
TreeAtTargettype
One revision of one tree, and the seed to reach it from.
type TreeAtTarget = {
readonly treeId: TreeId;
readonly revision: number;
readonly seed: LoomTree;
};TreeAtTargetstype
The same question asked about several revisions of one tree, in one read.
type TreeAtTargets = {
readonly treeId: TreeId;
readonly revisions: readonly number[];
readonly seed: LoomTree;
};TreeAtRevisiontype
The tree as it was, or why the log cannot say.
type TreeAtRevision =
/**
* The fold reached the named revision. `replayed.tree.revision` equals the
* revision asked for, and `replayed.idHistory` is the tenancy of every id from
* the seed up to it — which is the half a caller reading counters by node id
* needs, because an id that returned in between names two different nodes
* either side of the comparison.
*/
{
readonly outcome: "replayed";
readonly revision: number;
readonly replayed: ReplayedTree;
}
/**
* Not a revision this seed and this log can reach: before the seed, after the
* head, or not an integer — revisions are dense integers and nothing
* lies between two of them, so a fractional one is not a position the log has.
*
* `earliest` is the seed's own revision rather than one past it, which is the
* one place this differs from a revert's span: the tree at the seed is the
* seed, and a revision cannot be undone by inverting a delta the seed already
* contains.
*/
| {
readonly outcome: "out-of-range";
readonly revision: number;
readonly earliest: number;
readonly headRevision: number;
} | {
readonly outcome: "unreplayable";
readonly revision: number;
readonly mismatch: ReplayMismatch;
};describeTreeAtfunction
const describeTreeAt: (answer: TreeAtRevision) => string
treeAtfunction
The tree as it was at a chosen revision.
const treeAt: (reader: TreeReader, target: TreeAtTarget) => Promise<Result<TreeAtRevision, StoreError>>
treesAtfunction
The same answers for several revisions, from one read of the log.
const treesAt: (reader: TreeReader, targets: TreeAtTargets) => Promise<Result<ReadonlyMap<number, TreeAtRevision>, StoreError>>
Revert
store/revert
Planning a revert: reading the log to work out what undoing one of its entries would mean, without deciding anything.
RevertPlantype
type RevertPlan = {
readonly outcome: "revertable";
readonly target: StoredRevision;
/**
* The undo, as operations. They become a delta when something applies
* them; the id and base revision belong to that delta, not to this plan.
*/
readonly operations: readonly TreeOperation[];
/** The head these operations were planned against. */
readonly headRevision: number;
/**
* Revisions after the target that named a node this undo touches, so
* applying it writes over what they did. Empty for a clean undo.
*
* A property of the plan rather than a verdict about it: the Gate
* decides what a contested undo is worth, and it decides it from this,
* carried on the proposal. It travels with the plan because the delta
* cannot show it — undoing revision 1 looks identical whether or not
* anyone built on it.
*/
readonly discards: readonly DiscardedWork[];
}
/** The revision is outside the span this reader and this seed can reach. */
| {
readonly outcome: "out-of-range";
readonly revision: number;
/** One past the seed: the oldest revision this seed can replay up to. */
readonly earliest: number;
readonly headRevision: number;
} | {
readonly outcome: "unreplayable";
readonly mismatch: ReplayMismatch;
}
/**
* The target's delta cannot be inverted against the tree the replay produced.
* In practice this means the seed is not this tree's history: inversion walks
* the delta forward as it inverts, so a delta that applied when it was written
* inverts against the state it was written for.
*/
| {
readonly outcome: "uninvertible";
readonly revision: number;
readonly error: TreeError;
};UnrevertablePlantype
type UnrevertablePlan = Exclude<RevertPlan, {
readonly outcome: "revertable";
}>;describeRevertPlanfunction
const describeRevertPlan: (plan: RevertPlan) => string
RevertTargettype
type RevertTarget = {
readonly treeId: TreeId;
/** The revision to undo. Its delta is the one that gets inverted. */
readonly revision: number;
/**
* A tree whose revision precedes the target's, to replay from. Usually
* revision 0; a host that checkpoints may supply a later one.
*/
readonly seed: LoomTree;
};RevertTargetstype
The same question asked about several revisions of one tree, in one read.
type RevertTargets = {
readonly treeId: TreeId;
readonly revisions: readonly number[];
readonly seed: LoomTree;
};planRevertfunction
What undoing a revision would take, or what stands in the way.
const planRevert: (reader: TreeReader, target: RevertTarget) => Promise<Result<RevertPlan, StoreError>>
planRevertsfunction
The same plans for several revisions, from one read of the log.
const planReverts: (reader: TreeReader, targets: RevertTargets) => Promise<Result<ReadonlyMap<number, RevertPlan>, StoreError>>
Reading a tree back
store/source
The adapter that lets the renderer read from the store.
treeSourceFromStorefunction
The store, as the renderer's TreeSource.
const treeSourceFromStore: (store: TreeReader) => TreeSource
Stores
store/store
Persistence: an append-only log of accepted deltas, and the current tree as a materialised view of it.
StoredRevisiontype
type StoredRevision = {
readonly treeId: TreeId;
/** The revision this entry produced, so `revision - 1` is what it applied to. */
readonly revision: number;
readonly proposalId: ProposalId;
readonly delta: TreeDelta;
readonly provenance: Provenance;
/** When the runtime applied it, which is not when the model produced it. */
readonly appliedAt: string;
/**
* Who allowed it, when a human had to.
*
* Distinct from `provenance.actor`, which is who *asked*. A hold exists to put
* a second person in the way of a change, so recording only the asker would
* make every confirmed change look like somebody waving through their own
* request.
*
* Absent on a change nobody had to allow — one the Gate accepted outright —
* and absent on everything written before the column existed, which nobody can prove the
* approver of. It is never back-filled: the log is append-only, and a
* fact nobody observed does not belong in it.
*/
readonly answeredBy?: string;
};AppendRequesttype
type AppendRequest = {
readonly proposalId: ProposalId;
readonly delta: TreeDelta;
readonly provenance: Provenance;
readonly appliedAt: string;
/** Set only by the confirmation path; see `StoredRevision.answeredBy`. */
readonly answeredBy?: string;
};TreeListingtype
What a listing says about a tree without loading it.
type TreeListing = {
readonly treeId: TreeId;
readonly revision: number;
};ListRequesttype
type ListRequest = {
/**
* Opaque to the caller: the previous page's `cursor`, passed back unread.
* Absent starts at the beginning. A cursor naming a tree that has since been
* removed is not an error — listing resumes from the next key after it.
*/
readonly cursor?: string;
/** Clamped by the implementation — see `clampListingLimit`. */
readonly limit?: number;
};TreeListPagetype
type TreeListPage = {
readonly trees: readonly TreeListing[];
/** `null` when this was the last page. */
readonly cursor: string | null;
};RevisionStarttype
Where a read begins. Two kinds of position, and they are not interchangeable.
type RevisionStart = {
/** A cursor from a previous page's `older`/`newer`, passed back unread. */
readonly cursor?: string;
readonly at?: never;
} | {
/**
* A revision to open at. **Inclusive** — the page contains it — which is
* the difference that matters: a caller naming this has been sent to a
* change and wants to see it, not to resume beside it.
*
* `direction` still says which way the rest of the page runs: `older`
* puts the named revision at the newest end of the page, `newer` at the
* oldest. A revision no entry holds is not an error; the page is whatever
* falls on the named side of it, which may be empty.
*/
readonly at?: number;
readonly cursor?: never;
};RevisionReadRequesttype
type RevisionReadRequest = RevisionStart & {
/**
* Default `newer`, which with no cursor is the oldest page — the read a fold
* wants, and the one `auditSnapshot` takes. `older` with no cursor is the
* newest page, which is what a reader looking at a tree asks for.
*/
readonly direction?: PageDirection;
readonly limit?: number;
};RevisionPagetype
type RevisionPage = PageEnds & {
/**
* Always ascending by `revision`, whichever end the page was taken from.
*
* A log is folded by `replayTree` in the order the entries were applied, and a
* page that sometimes came back reversed would make every consumer responsible
* for knowing which — and silently wrong when it guessed.
*/
readonly revisions: readonly StoredRevision[];
};DEFAULT_LISTING_LIMITvalue
const DEFAULT_LISTING_LIMIT = 50
MAX_LISTING_LIMITvalue
const MAX_LISTING_LIMIT = 200
DEFAULT_REVISION_LIMITvalue
Larger than a tree listing's page and smaller than the journal's. A revision carries a whole delta and its provenance, so a page of these is heavier than a page of listings; but unlike telemetry records, one entry is one complete thing to read, so a page never splits something that has to be folded back together.
const DEFAULT_REVISION_LIMIT = 100
MAX_REVISION_LIMITvalue
const MAX_REVISION_LIMIT = 500
clampRevisionLimitfunction
const clampRevisionLimit: (limit: number | undefined) => number
FIRST_REVISIONvalue
The revision the first log entry produces. A tree is created at revision 0 and nothing in the log corresponds to that, so 1 is the oldest position a read can reach — and revisions are dense from there, one per accepted delta.
const FIRST_REVISION = 1
anchorBoundfunction
An inclusive revision anchor, as the exclusive bound both implementations already page from.
const anchorBound: (at: number, direction: PageDirection) => number
anchorResumesfunction
Whether an anchored page leaves entries on the side it pages away from — the resumed a cursor gets for free and an anchor does not.
const anchorResumes: (at: number, direction: PageDirection, headRevision: number) => boolean
clampListingLimitfunction
Every implementation clamps the same way, so a caller cannot ask a store for everything it holds by omitting a limit or naming a large one. The rule is shared with every other listing in Loom; only the bounds are this one's.
const clampListingLimit: (limit: number | undefined) => number
TreeStoreinterface
Three reads and two writes. head is the O(1) read §3 needs; revisions is the one §6 and the portal's provenance view need; list is how a consumer finds a tree it did not create; append is the only way a tree ever changes, so nothing can advance a revision without leaving a record of why.
Shown in use on The history of a page — Where this actually runs
interface TreeStore {
readonly create: (tree: LoomTree) => Promise<Result<LoomTree, StoreError>>;
readonly head: (treeId: TreeId) => Promise<Result<LoomTree, StoreError>>;
readonly revisions: (treeId: TreeId, request?: RevisionReadRequest) => Promise<Result<RevisionPage, StoreError>>;
readonly list: (request?: ListRequest) => Promise<Result<TreeListPage, StoreError>>;
readonly append: (treeId: TreeId, request: AppendRequest) => Promise<Result<LoomTree, StoreError>>;
}TreeReadertype
The read half, for consumers that never write.
type TreeReader = Pick<TreeStore, "head" | "revisions">;
Undone
store/undone
Which changes in a log have been put back.
undoneRevisionsfunction
Which revisions in this stretch of log are put back, and by which entry.
const undoneRevisions: (entries: readonly StoredRevision[]) => ReadonlyMap<number, StoredRevision>