Skip to main content

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​

NameShapeWhat it is
EntryEditPartial<EntryInput> minus idThe write shape. One Entry's proposed field changes, dates loose — the same object dataset.entries.update(id, edit) takes
EntryEditsReadonlyMap<EntryId, EntryEdit>A batch of those, keyed by Entry — what an extender returns, whether the batch holds one entry or many
ProposedEdit / ProposedEditsdates as Instant, proposedKeys statedThe 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) => EntryEditsThe 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 a Map spread 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's end and 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 own start/end roll 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 as derived-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​

  1. The caller writes plain dataset.entries.update(...) calls — one alone, or several grouped in dataset.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."
  2. 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.
  3. cascadeStartDate runs once, given the entire batch. It loops proposed, and for each entry with a proposed start and a dependent, adds one extra edit. Nothing outside the loop's matches is touched.
  4. The transaction diffs every edit map against the store (diffEdit — the caller's proposed and the extender's returned edits alike), then rolls up derived spans, and commits everything as one ChangeSet — one change event, one undo step, no matter how many entries moved.
  5. With no extender installed, identityExtender returns an empty EntryEdits regardless of batch size: same request, same commit path, nothing extra to diff. The caller's code above does not change either way.