The extension hook — flow and sample usage
Scope: data/edit-extension.ts. This doc is a walkthrough of the mechanism, not a specification
of it.
Every mutation runs through one hook before it commits. With no plugin installed it is the
identity function — the proposed edits become the committed edits, unchanged. An installed extender
gets one chance to return extra edits, layered on top of what the caller asked for, folded into the
same ChangeSet, the same undo step. A transaction can carry any number of proposed edits — one or
many — and the hook always sees the whole batch at once, never one edit at a time.
Vocabulary
| Name | Shape | What it is |
|---|---|---|
EntryEdit | Partial<EntryInput> minus id | The write shape. One Entry's proposed field changes, dates loose — the same object dataset.entries.update(id, edit) takes |
EntryEdits | ReadonlyMap<EntryId, EntryEdit> | A batch of those, keyed by Entry — what an extender returns, whether the batch holds one entry or many |
ProposedEdit / ProposedEdits | dates as Instant, proposedKeys stated | The read shape. The same edit after core read it. A plugin author reads one off request.proposed and never builds one |
EditRequest | { entries, proposed, entryAfterEdits, addedEntryIds, removedEntryIds, hasChildren, writeTarget, editableOf } | What goes into the hook: the pre-transaction entries (a Map), the caller's whole proposed batch as ProposedEdits, a per-id lookup for post-body state, the two sets below, and three structural questions by id — hasChildren(id), writeTarget(id, field) (WriteTarget), and editableOf(id, field) (FieldEditable) — so a cascade can check before it writes instead of learning after the fact from a derived-values-dropped report or a FieldNotEditableError |
EditExtender | (request: EditRequest) => EntryEdits | The function occupying the hook — identityExtender when nothing is installed |
There is no wrapper type around the extender's return value. An extender returns extra writes, in the
same shape a caller already writes to dataset.entries.update() — one vocabulary for "an edit,"
whoever produces it, and whether it's one entry or a batch. data/ diffs both the caller's
edits and the extender's edits against the store into FieldUpdated rows itself (diffEdit), so
nobody who writes an edit has to compute a diff by hand.
Read one way, write the other. The two shapes are not interchangeable, and neither is assignable to
the other. That asymmetry reversed once. Earlier, every StoredEdit (now
ProposedEdit) was a legal EntryEdit. A complete props record and a props patch are the same
shape, so a plugin author could spread request.proposed's props into a returned edit and propose
every stored key by accident. The fix closed the hole from both sides: ProposedEdit carries a
__brand that refuses the object itself, and EntryEdit's own props?: never refuses the literal. So
a forgotten normalization is a compile error, and no as sits on the hook boundary. Normalization has
one door: DatasetState.extraEditsFor calls the occupant and then toEditsReading, and both the
commit path and the drag preview come through it. A plugin author therefore never resolves a date,
never states proposedKeys, and never computes an envelope.
What a plugin author writes for the two cases that are easy to get wrong:
- Two plugins on one Entry —
mergeEntryEdits(next(request), mine(request)), never aMapspread or an object spread. A spread drops the earlier plugin's write outright. - A whole-Entry move —
moveEntryTo(entry, start). It returns{ start, end }: the Entry's own span translated rigidly, so the move keeps the duration and states both edges together. Stating the envelope by hand used to overwrite an earlier plugin'sendand commit that write away with no error, which is why the helper exists. A row whose children draw on it (childrenAsSegments) needs no special case here: each child is an ordinary Entry with its own dates, so a cascade moves the children and the parent's ownstart/endroll up from them. A direct write to a rolling-up parent's own dates is refused (DerivedFieldNotWritableError) — and a cascade that proposes one is not refused but overwritten by the Rollup and reported once asderived-values-dropped.
A cascade onto a locked Field. A cascade is a caller-side write, so it meets the same lock
entries.update() meets. A 'never' Field refuses the whole batch with FieldNotEditableError
(ADR 0015, "a third door"). A plugin can open one Field on one Entry, or on a whole subtree, without
touching Field.editable itself. ctx.edits.setLockRule installs a per-entry rule the same door
reads. request.editableOf(id, field) lets a cascade check before it writes, instead of learning
about the lock from a throw. See "Edit extender" in docs/06-plugin-authoring.md for the full seam.
A cascade onto an Entry the same transaction adds. This case has no base Entry in the committed
store, so diffEdit cannot produce an update row for it. The cascade still lands: it folds into the
added entity itself, through addedEntitiesForFold (src/data/build-commit-change-set.ts).
The ChangeSet publishes the cascaded value on the added entity, and no separate update row.
Discovering an addition or a removal, not just reading one you already know. entries.get(id) and
entryAfterEdits(id) both answer only when the caller already holds id. Neither tells an extender
which ids this transaction added or removed — a same-transaction addition has no entry in the
committed store to be found by scanning it, and a removal leaves no trace once it lands. addedEntryIds
and removedEntryIds on EditRequest close that gap:
for (const id of request.removedEntryIds) unlinkEverythingTouching(id);
for (const id of request.addedEntryIds) scheduleFrom(request.entryAfterEdits(id)!);
Read a removed id off request.entries — it still holds the pre-transaction row, since removal never
rewrites that snapshot. Read an added id through request.entryAfterEdits(id), never off
request.entries, since an addition has no pre-transaction row to be in.
Both sets are the transaction's net effect, not a call log — computed once, at commit, from the
final pending-add and pending-remove state, not from a running list of every add/remove call the
body made. An Entry the body both adds and removes ends up in neither set, the same way the ChangeSet
publishes no row for it. An Entry the body removes and then re-adds ends up in addedEntryIds only,
with one clean add row — not an update-after-delete. A drag preview frame gets two references to one
shared empty set, so reading either costs nothing per frame.
Flow
The same hook, two occupants: with nothing installed the extender returns no edits; with
cascadeStartDate installed it returns one entry per cascade, for as many proposed edits as it finds
a dependent for. Either way the return value is diffed against the store the same way the caller's own
edits are — the caller's code never branches on which is active, and never branches on batch size
either.
Sample usage
A consumer moves two entries' start dates together, in one transaction — a caller reschedules a whole
phase, not just one bar. Grouping matters here: without it, each update() would commit (and undo)
separately, so a shared "undo the reschedule" click would only undo the second entry.
// caller — harness/main.ts
dataset.transaction(() => {
dataset.entries.update('pour-foundation', { start: '2026-09-03' });
dataset.entries.update('site-survey', { start: '2026-08-29' });
});
A single edit needs none of that — it's still worth showing, because it's the more common call and it needs no ceremony at all. FreeGantt auto-wraps a lone mutation in its own transaction: single mutations outside an explicit transaction get one — there is no second code path.
dataset.entries.update('pour-foundation', { start: '2026-09-03' });
Nothing about either call changes whether an extender is installed — the cascade, if any, happens
inside the hook, not at the call site. cascadeStartDate below runs inside whichever transaction is
open, auto-wrapped or explicit, and sees the whole proposed batch in one call — not once per edit:
// an installed extender — a Dataset plugin claims the hook in its own setup(), with
// ctx.edits.setExtender((next) => (request) => mergeEntryEdits(next(request), cascadeStartDate(request)))
const cascadeStartDate: EditExtender = ({ entries, proposed }) => {
const extraEdits = new Map<EntryId, EntryEdit>();
for (const [id, edit] of proposed) {
if (edit.start === undefined) continue;
const dependent = findDependent(entries, id);
if (dependent) {
extraEdits.set(dependent.id, { start: edit.start });
}
}
return extraEdits;
};
Run against the two-entry transaction above, this extender loops twice — once per proposed edit — and
can return up to two extra edits (frame-walls cascading from pour-foundation, permit-review
cascading from site-survey), all folded into the one ChangeSet the transaction commits.
The public way to install one is new Dataset({ entries, plugins: [myPlugin()] }), with
the plugin claiming the hook through ctx.edits.setExtender. Installing composes — the
wrapper receives the current occupant, so a second plugin adds to the first's writes instead of
evicting it.
Walkthrough
- The caller writes plain
dataset.entries.update(...)calls — one alone, or several grouped indataset.transaction(() => { ... }). It has no idea an extender is installed, and no branch for "if a plugin is present" or "if there's more than one edit." - The transaction collects the whole batch — one proposed edit if the call was auto-wrapped,
or however many the body made — and, at commit, builds one
EditRequest: the current entries plus every proposed edit together. cascadeStartDateruns once, given the entire batch. It loopsproposed, and for each entry with a proposedstartand a dependent, adds one extra edit. Nothing outside the loop's matches is touched.- The transaction diffs every edit map against the store (
diffEdit— the caller'sproposedand the extender's returned edits alike), then rolls up derived spans, and commits everything as oneChangeSet— onechangeevent, one undo step, no matter how many entries moved. - With no extender installed,
identityExtenderreturns an emptyEntryEditsregardless of batch size: same request, same commit path, nothing extra to diff. The caller's code above does not change either way.