tm_record
See also: section_record · Sections concept · Components · common contract
src/core/tm_record/tm_record.ts is the TS module for materializing a single
Time Machine row: one historical version of one component or section
change, stored in the flat matrix_time_machine table.
This page is the module-level reference for tm_record.ts and the Time
Machine (dd15) data model: how every component save also writes a versioned
row, the shape of the matrix_time_machine table, how a TM row is
transformed back into a renderable section record, the read-only tm mode,
and how a value is restored. TM-as-save-side-effect is preserved end to end;
Time Machine is served through the generic section read pipeline rather than
a bespoke viewer (see How it fits).
Role
Time Machine is a handful of stateless modules, each owning one piece. Nothing is instantiated and nothing is cached: one call is one function call.
| module | role |
|---|---|
src/core/tm_record/tm_record.ts |
The row materializer: buildTmSectionRecord() turns the flat Time Machine columns into a synthetic dd15 section_record-shaped MatrixRecord that the normal component pipeline can render. It also owns the dd15 column-tipo constants, ddDateFromTimestamp() and termByTipo(). |
src/core/db/time_machine.ts |
The SQL layer for matrix_time_machine, and its single writer: recordTimeMachine() (one ordinary audit row), the bulk undo-log writers recordBulkPair() / recordBulkBirth() / recordBulkCascadeDelete(), readTimeMachineRow() / readTimeMachineHistory() (visible rows only), the TimeMachineRow / TimeMachineEntry types, TM_ROLE and TM_EXCLUDED_SECTIONS. |
src/core/resolve/read_tm.ts |
The read and list surface: tmReadSource (a SectionReadSource plugged into the generic section-read pipeline), buildTmWhere() / queryTmRows(), and emitTmRow() (per-row cell emission). |
tools/tool_time_machine/server/tool_time_machine.ts |
apply_value — the restore action. |
tools/tool_time_machine/server/bulk_revert.ts |
bulk_revert_process — undo a whole bulk_process_id batch. |
Responsibilities
- Versioning on save —
recordTimeMachine()is the entry point other modules call after a successful save to persist the changed data as a new TM row (with timestamp, user and lang). A save that is part of a bulk run records throughrecordBulkPair()instead (see The bulk undo log). It is invoked fromsrc/core/section/record/save_component.ts(every component save) anddelete_record.ts/duplicate_record.ts(record delete/duplicate snapshots). - Read access —
readTimeMachineRow()(one row by PK) andreadTimeMachineHistory()(a component's history on one source record, newest first). Both see visible rows only. - Transformation to a renderable record —
buildTmSectionRecord(): turn the flat columns (section_id,timestamp,user_id,tipo,section_tipo,bulk_process_id,data) into component-shaped data injected into a syntheticdd15MatrixRecord. - Write guards —
TM_EXCLUDED_SECTIONSrefuses to versiondd15itself, andrecordTimeMachine()skips non-positivesection_ids. - Row deletion — none. The Time Machine surface is append-only: no function deletes a row.
Every component save writes a Time Machine row — outside a bulk run, even an unchanged one
Outside a bulk run, save_component.ts records a row on every save — even
when the new value equals the stored one — unless the caller passed
saveTm: false. Under a bulk id the law is different (every CSV,
MARC21 or Zotero import, propagate, update cache and bulk revert save): the
save writes its undo pair whatever saveTm says, and
a save whose value did not change writes nothing. So re-importing an
unchanged CSV file, in Replace or Append mode, leaves history untouched,
and a bulk run has no switch that turns its history off. An append save
that adds something writes the same visible row a replace save writes —
tagged with the saved language, holding the value after the save. The bulk
transforms that must not version (record relocation, for instance) avoid it
by writing with a direct UPDATE rather than by going through the
component-save path.
Data model
The matrix_time_machine table
The TM table is not the typed-JSONB matrix shape used by normal sections. It
is a flat table whose columns map 1:1 to ontology tipos under the dd15
virtual section. src/core/db/time_machine.ts's TimeMachineRow interface is the
column allowlist:
| Column | TM tipo constant | tipo | temporal model | meaning |
|---|---|---|---|---|
id |
DEDALO_TIME_MACHINE_COLUMN_ID |
dd1573 |
component_number |
row primary key (auto-increment) |
section_id |
DEDALO_TIME_MACHINE_COLUMN_SECTION_ID |
dd1212 |
component_number |
the source record's section_id |
section_tipo |
DEDALO_TIME_MACHINE_COLUMN_SECTION_TIPO |
dd1772 |
component_input_text |
the source record's section_tipo (e.g. oh1) |
tipo |
DEDALO_TIME_MACHINE_COLUMN_TIPO |
dd577 |
component_input_text |
the component tipo that changed (or the section tipo, on delete) |
lang |
— | — | — | language of the changed data |
timestamp |
DEDALO_TIME_MACHINE_COLUMN_TIMESTAMP |
dd559 |
component_date |
when the change happened |
user_id |
DEDALO_TIME_MACHINE_COLUMN_USER_ID |
dd578 |
component_portal |
user who made the change |
bulk_process_id |
DEDALO_TIME_MACHINE_COLUMN_BULK_PROCESS_ID |
dd1371 |
component_number |
bulk-operation id (or null) |
data |
DEDALO_TIME_MACHINE_COLUMN_DATA |
dd1574 |
component_json |
the actual changed data (JSONB) |
tm_role |
— | — | — | NULL for an ordinary, visible row; 1, 3 or 4 for a hidden undo-log row |
The data column is read and written through the shared json_codec.ts — a
$n::text::jsonb binding on write, and a data::text twin selected alongside
data on read. One codec, no per-column classification lists. The tipo constants
above resolve through the ontology (src/core/ontology/resolver.ts).
The section_tipo column does NOT hold dd15
This is the most common Time Machine mistake. In a TM row, the
section_tipo column stores the source data section (oh1,
mdcat2949, …) — not the Time Machine section dd15. The dd15 tipo
appears only in the ontology paths that describe the TM columns.
buildTmWhere() (src/core/resolve/read_tm.ts) therefore never filters by
section_tipo = 'dd15'. Add that filter "for correctness" and every Time
Machine list goes empty.
dd15 is a virtual section
dd15 (TIME_MACHINE_SECTION_TIPO, src/core/db/time_machine.ts) is an
internal virtual section: it has an ontology definition (its columns are
the tipos above) but no rows of its own in the matrix table — its
"records" are the rows of matrix_time_machine. Because of this, TM
components cannot read their value straight from the DB the way ordinary
components do; the data has to be pre-populated into a synthetic record
first (see
How a TM row becomes a record).
The bulk undo log
A bulk run (a dd800 bulk-process record) must be revertible exactly, so
every save it makes records what it replaced, not only what it left. Those extra
rows are marked by tm_role (migration install/db/migrations/0010_tm_role.sql)
and never shown:
tm_role |
row | visible in dd15 |
|---|---|---|
NULL |
every ordinary row — every non-bulk write, and the AFTER row of every bulk save | yes |
1 |
BEFORE image: the exact region the bulk save replaced (data NULL = the region held nothing: the key was absent, stored JSON null, or — for a lang-sliced model — held no item of that language) |
no |
3 |
BIRTH marker: a record the run created (tipo = the section tipo, lg-nolan, data = its birth image: the projects filter and dato_default values the create wrote, or the snapshot a revert's undelete restored) |
no |
4 |
the whole-record snapshot of a record the run's dataframe cascade wiped (delete_target: data emptied, row kept — no policy removes a frame target row, since 2026-09-29), or that a revert's D2 deleted (a born record) |
no (the ordinary wipe rows / delete snapshot stay visible) |
The pair law. A save carrying a bulk id writes, for every key it changes (the
modified stamps excepted, see below), a
role-1 BEFORE row and then its ordinary visible row, both with the bulk id, one
shared timestamp, inside the save's transaction (recordBulkPair()); whatever
saveTm says. A save whose region is byte-equal before and after writes nothing.
The REGION is what a write in that language owns: for a lang-sliced model, every
item that is not another language's (lang-less items included); for any other
model, the whole key (src/core/concepts/lang_region.ts). A main component with
dataframe slots has its pairs in two lanes: one pair
per language it changed (that language's value), and one lg-nolan pair (its
lg-nolan value + every frame) when a frame or the lg-nolan value changed; a save
of one of its slots writes the main's lg-nolan pair, never one under the slot tipo.
BEFORE is cloned from the value read under the save's row lock; AFTER is re-read
from the row inside the same transaction (src/core/section/record/bulk_capture.ts).
The visibility law. Every reader that serves history — the dd15 list and
count, a component's history, the dd1371 filters, a preview, apply_value, the
backfill probes of delete (record and data) / observers — narrows with withTmHistory() or
tmVisiblePredicate() (src/core/db/record_generation.ts), so roles 1, 3 and 4
never reach it. Only bulk_revert_process reads every role. update_cache's
media repair is a named exemption (its files_info is derived from the files, and
files it moves cannot be moved back by a revert). So is the CSV importer's
data-column twin of dd199/dd200 (created_date, created_by_user_id): a
revert re-derives it from the restored component and reports it metadata_twin.
So are the record's modified stamps dd197/dd201 (modified by, modified date):
every save restamps them beside its value without a pair, so after a revert they
show the revert as the record's latest modification, not the pre-run stamps. Only a
run that wrote them itself as columns (a CSV carrying dd197/dd201) gets them
back, through their own units.
If migration 0010 did not land at boot, the boot adds the column itself right after
the migration run, before the first request (runBootSchema() in
install/db/migrate.ts). Should that fail too, it is added by the first caller of
ensureTmHistoryReady() (→ ensureTmRoleColumn()) outside a transaction: every
time-machine reader, and every bulk run as it starts (withLiveBulkRun() in
src/core/tools/bulk_run_registry.ts, before its first write). The undo-log
writers never add it themselves, because each runs inside a transaction. Until the
column exists, every reader and undo-log writer fails closed with a typed error, and
the next caller tries the heal again. The heal's statistics step (ANALYZE) is
best-effort: if it fails, it is logged and the column stays added. An ordinary record
creation never needs the column. A bulk create does, for its birth marker, and its
run's start has healed the column by then.
The installer applies 0010 itself right after restoring the seed.
The dd15 count is TOTAL − HIDDEN in one statement (tmHistoryCountSql() in
src/core/resolve/read_tm.ts) rather than a count of tm_role IS NULL rows, so it
stays index-only on a large table: the hidden half reads the partial index
matrix_time_machine_tm_role_hidden_idx (WHERE tm_role IS NOT NULL). Migration
install/db/migrations/0011_tm_role_hidden_index.sql builds that index and gives
tm_role its planner statistics. The deep-page list (tmLatePageSql()) finds its page
of ids on the partial index matrix_time_machine_history_visible_idx (WHERE tm_role
IS NULL, migration 0012_tm_history_visible_index.sql), so that walk stays index-only
too. 0011 and 0012 are online migrations (install/db/online_migration.ts): they
do not run at boot. After the server's listener binds, they build their index
CONCURRENTLY in the background, outside any transaction. A migration is recorded
as applied only once its index is VALID; a failed or interrupted build is dropped
and retried on the next boot. Until the build lands, the dd15 count and deep page
are correct but slower (no index to walk). Boot migrations run without a statement
timeout, so a long boot migration is never cut off by DB_STATEMENT_TIMEOUT_MS.
Dataframe mains: two lanes
A dataframe frame qualifies one item of its
main component (id_key → the item's id), so the two halves are one statement:
neither can be read or restored without the other. The frames are stored apart, under
the slot's own tipo in the record's relation column; their history is kept under the
main's tipo — never under the slot's — in one of two kinds of lane
(src/core/relations/main_lanes.ts):
| Lane | Row lang |
What the row holds |
|---|---|---|
| a language lane | lg-spa, lg-ell… |
only that language's value — no frame |
| the frame lane | lg-nolan |
the main's lg-nolan value (may be empty) + all frames of all its slots |
On the oh1 (Oral History) section, for example, oh24 (Informants, a
component_portal, not translatable) has the slot oh115 (Role, a
component_dataframe): every history row of oh24 is an lg-nolan row holding the
informant locators followed by every oh115 frame (the dd490 locators), and oh115
has no history rows of its own.
- The lane follows the data shape, never the ontology flag. A main whose data is
not split by language — every relation (
component_portal,component_select,component_check_box,component_radio_button,component_filter, thecomponent_relation_*family…) and every other unsliced model — keeps its whole value inlg-nolan, always: each save writes onelg-nolanrow (value + frames), whatever the node'stranslatableflag says and whatever the working language, and every language's history lists all its rows, whatever language tag an older (Dédalo v6) row carries — v6 tagged a relation marked translatable with the working language, and each such row holds the whole value. The delete doors' backfill probe counts those rows too, so no redundantlg-nolanrow is written over them. A relation holds locators, which are never translatable, so a "translatable portal" is an ontology setting the history ignores. Language lanes exist only for lang-sliced models (component_input_text,component_text_area,component_iri…). - One rule for every sliced main. A non-translatable one keeps its whole value in
lg-nolan, so each save writes onelg-nolanrow: value + frames. A translatable sliced main has nolg-nolanvalue, so itslg-nolanrows hold the frames only — the shared frame lane of all its languages. A transliterable main (with_lang_versions, such as a person's name,rsc85inrsc197) keeps its base form inlg-nolanand each transliteration in its own language (Augustus inlg-nolan, Αύγουστος inlg-ell): itslg-nolanrow is the base + the frames, its language rows the transliterations. No flag is read: every item's ownlangplaces it. A value item with no language at all (a v6 orphan) is recorded in the row of the language that saved it; a translatable main'slg-nolanrow never holds one. - What a save writes. A save of the main in language X writes ONE row in lane X,
plus an
lg-nolanrow only when the save changed the frames (removing an item strips its frames) or thelg-nolanvalue — thelg-nolanrow first, so the language row sits above its own save's frame change and the state at it is the state after the save. A save of a frame — added, edited or removed — writes ONElg-nolanrow of the main it belongs to (the caller's main, else the mains named by the changed frames, else the slot's parent component), whatever the number of languages: no copy per language. A frame save no main can own is refused (engine.uncovered_scope); a record delete, Delete data or a locator removal that strips such a frame removes it and writes no history for it. Every door follows the same rule (recordMainHistory(),src/core/relations/dataframe_slots.ts): the component save, a CSV dataframe column or legacy envelope, a main-item removal's cascade, a portal locator removal, a record delete that strips frames pointing at it, Delete data, a translation (tool_lang: its target language's row only), a duplicate (its frames first, then the copy's value per language) and the restores. - The state at a row. Saves to one record are serialised by the record's row lock,
so row ids follow the real order. The state at row R is rebuilt from the lanes: each
language's value is the newest row of that language with an id up to R; the frames
are those of the newest frame-state row up to R (an
lg-nolanrow, or a Dédalo v6 row carrying frames —readFrameStateRowAt()); thelg-nolanvalue is that of the newestlg-nolanrow up to R (src/core/tm_record/lane_state.ts). - The frame lane is complete. Before a language row is written, the newest
frame-state row must hold the main's frames and
lg-nolanvalue as they are: when it does not (frames written by a door that records no history — a migration, an import made with the time machine off), onelg-nolanrow of that state is written first (recordFrameLaneBaseline()). So restoring a language row never rolls frames back to an older recorded state, or empties frames that were never recorded. - The history list. The history of a lang-sliced main in language X lists the rows of
X and of
lg-nolan— whether its dataframe is declared or only holds its frames. An unsliced main's history is every row of it, whatever the tag (see above). A non-translatable main that keeps language versions (a transliterable name, asked inlg-nolan) lists itslg-nolanrows and the rows of the working data language, so Αύγουστος appears while working in Greek; the preview of such a row shows the base form and the frames as of it, not the transliteration itself (the component read does not yet serve transliterations —transliterate_value), and restoring it puts the transliteration back. Every row's preview shows the full state at that row — its language's value as it stood, and its frames as they stood — so an intermediate state is visible exactly as it was. The row's own language and the frames are shown exactly as a restore would write them (previewLaneValue(), sharingrestoredLaneValue()with the restore); the other lane is shown as it stood, which a restore leaves as it is now. - Restoring a row. Restoring a language row puts that language's value back and the
frames as they stood at that row; the other languages stay as they are. Restoring an
lg-nolanrow puts thelg-nolanvalue and the frames of that row back. Frames of another main in a shared slot are never touched. A frame of an item that existed then and no longer exists in any language is not written back (it would pair nothing). The restore writes its own rows by the same rule. - Any save order. A frame saved before its item, even while the main is still
empty, is simply an
lg-nolanrow: the state at every row is complete, and restoring it returns that state. - Dédalo v6 rows. A v6 save of the main (one language's value + the frames) gives
that language's value, and its frames are the frame state of that moment. A v6 save
of a dataframe (
lg-nolan, the main in every language + the frames) is read as thelg-nolanlane: itslg-nolanitems and its frames; the other languages' items are ignored (for a translatable main, only its frames come back). A dataframe a row is silent about was empty at that time (rowSlotTipos()). A v6 row of one language that carries no frame cannot be told from a v7 language row, so it is read as one: its frames are those of the newest frame state below it. v7 beta rows written before the two lanes are not supported history. - Row shapes are still parsed. A literal main's frames wrapped as
{value: {frame}}by the v6→v7 reformat are unwrapped. - A main with no slot writes exactly its language rows (a non-translatable one: its
one
lg-nolanrow). - Any main model. A dataframe can hang from a relation main (a portal, a select…:
the slot is a ddo of its request config) or from a literal main (text, number, date,
email, iri — including component_iri's fixed
dd560label slot — withhas_dataframe: true), translatable, transliterable or not. The lanes, the restore and the bulk revert are the same for all of them; a literal main's frames stay in therelationcolumn, never in the literal's own.
Reading one row
There is no factory/instance to build — readTimeMachineRow(tmRowId)
(src/core/db/time_machine.ts) is a plain async function that selects one
row by its matrix_time_machine primary key and returns a TimeMachineRow
or null:
import { readTimeMachineRow } from '../db/time_machine.ts';
// load one Time Machine row by its matrix_time_machine id
const row = await readTimeMachineRow(4096);
// row.section_id, row.section_tipo, row.tipo, row.lang,
// row.timestamp, row.user_id, row.bulk_process_id, row.data
How a version is written on save
A TM row is written by the callers, after a successful save, through
recordTimeMachine():
src/core/section/record/save_component.ts— after the component's new data is persisted, it builds aTimeMachineEntry(sectionTipo,sectionId,componentTipo,lang,userId,data) and callsrecordTimeMachine(entry, nowDbTimestamp()). For a lang-sliced translatable component (component_input_text,component_text_area…) the snapshot is the current-lang slice, not the whole value. An unsliced model (every relation component, whatever itstranslatableflag) writes its whole value in onelg-nolanrow (two lanes). For a main with dataframe slots the rows follow the two lanes (finishSaveHistory()→recordMainHistory(),src/core/section/record/bulk_capture.ts), and a slot save writes the main'slg-nolanrow.src/core/section/record/delete_record.ts— before deleting a record it snapshots the whole record's JSONB columns into one TM row (componentTipo === sectionTipo,lang: 'lg-nolan').src/core/section/record/duplicate_record.ts— the per-component back-fill pair described below.
import { recordTimeMachine, nowDbTimestamp } from '../db/time_machine.ts';
await recordTimeMachine(
{
sectionId,
sectionTipo, // source section, e.g. 'oh1'
componentTipo: tipo, // changed component tipo, e.g. 'oh21'
lang,
userId,
data: tmSnapshot,
},
nowDbTimestamp(),
);
The back-fill lives in the callers, not in recordTimeMachine()
recordTimeMachine() is a pure insert — it holds no "self-healing" logic.
A record edited before Time Machine ever ran has no baseline to revert to, so
delete_record.ts and duplicate_record.ts compute a back-fill timestamp
(now - 60s) and back-fill each main through recordMainBackfill()
(src/core/relations/dataframe_slots.ts): the frame lane (lg-nolan) FIRST,
then one row per language lane that has no history yet. Then
recordMainHistory() writes the change's own rows under the same two-lane rule.
The ordinary per-save path in save_component.ts does not back-fill; only the
delete and duplicate flows do.
How a TM row becomes a renderable record
buildTmSectionRecord() (src/core/tm_record/tm_record.ts) is the heart of
the module. It reads the flat columns and injects component-shaped data
into a synthetic dd15 MatrixRecord keyed by the TM row id (not the
source section_id), using the shared substitution API
(src/core/section_record/virtual_record.ts's makeVirtualRecord() /
injectComponentData() / injectColumnData()). The private helper
injectTmField() resolves the column model via getModelByTipo() and the storage
column via getColumnNameByModel().
It populates, in order:
dd1212section_id → a{id, value}number.dd559timestamp → acomponent_datevalue viaddDateFromTimestamp().dd577tipo anddd1772section_tipo → the human term of the tipo, resolved withtermByTipo()(aSELECT term FROM dd_ontologylookup).dd578user_id → add151locator into the users section (dd128); the same locator is also injected underdd200(created-by-user) for metadata compatibility.rsc329annotation → an empty placeholder ([{parent_section_id: null}]).dd1371bulk_process_id → a{id, value}number.data— split by the source tipo's model:- if the source is a whole section (delete snapshot), each component's data is adopted wholesale under its own JSONB column;
- otherwise (a single component change) the row is split so that the normal
component read path finds each half where it lives:
dd1574(the generic data column) gets the raw row, verbatim;- the component's own tipo gets its items without frames
(
splitComposed()— every entry that is not a frame, for every model); - each dataframe slot tipo of the main gets the frames that row recorded for
that main (
snapshotSlotFrames()): a frame naming another main is left out; a frame naming acomponent_dataframeinfrom_component_tipogoes to that slot; a legacy frame naming no slot goes to the main's only slot, and is not shown when there are several; - a row under a
component_dataframeslot's own tipo is not supported history (Dédalo v6 never wrote one; a slot's frames ride in its main's row): only its rawdd1574copy is shown, and its preview answers empty.
The third argument, declaredSlots, is the main's dataframe slot set
(resolveDataframeSlotTipos() in src/core/relations/dataframe_slots.ts). It is
what attributes a legacy frame that names no slot.
import { buildTmSectionRecord } from '../tm_record/tm_record.ts';
import { readTimeMachineRow } from '../db/time_machine.ts';
import { resolveDataframeSlotTipos } from '../relations/dataframe_slots.ts';
const row = await readTimeMachineRow(rowId);
const record =
row !== null
? await buildTmSectionRecord(row, lang, await resolveDataframeSlotTipos(row.tipo))
: null;
// components reading dd15 + rowId in 'tm' mode now read from this record
The read-only tm component mode
Components that need to show a historical value are read in tm mode
(see the Architecture overview datum contract:
modes are edit / list / search / tm). Two rules follow from the data
model, both still true of the TS pipeline:
- Always address
dd15and the TM rowid— not the sourcesection_tipo/section_id.read_tm.ts'semitTmRow()stamps every emitted item'ssection_tipo: 'dd15',section_id: row.id,mode: 'tm'. - Pre-populate first.
emitTmRow()builds (and memoizes, per row) the virtual record viabuildTmSectionRecord()before resolving any of the section's own component columns from it — without it there is nothing to read.
tm mode is read-only in the sense that the TM read source never writes. There is
no "save blocked in tm mode" guard, because the generic write path is never
reached from a TM read at all — the client's Apply button drives a
different, explicit action
(tool_time_machine.apply_value, see Restore),
not a save through the tm-mode component.
Public API
src/core/tm_record/tm_record.ts
| function | purpose |
|---|---|
buildTmSectionRecord(row, lang) |
Transform one TimeMachineRow into a synthetic dd15 MatrixRecord with component-shaped, injected data. |
ddDateFromTimestamp(timestamp) |
Parse a Postgres timestamp string into the dd_date object shape. |
termByTipo(tipo, lang) |
The display term of one ontology node in the request lang, falling back to lg-spa then any populated language, then the bare tipo. |
src/core/db/time_machine.ts
| function | purpose |
|---|---|
readTimeMachineRow(tmRowId) |
Read one row by its matrix_time_machine primary key, or null. |
readTimeMachineHistory(sourceSectionTipo, sourceSectionId, componentTipo, limit?) |
A component's change history on one source record, newest first (ORDER BY timestamp DESC). |
recordTimeMachine(entry, timestamp) |
Insert one ordinary (visible) audit row. No-ops for section_id <= 0 or an excluded section tipo. Never used for a bulk save. |
recordBulkPair(entry) |
A bulk save's undo pair: the hidden BEFORE row, then the visible after-row, both with the bulk id; nothing when before and after are canonically equal. Returns the two ids, or null. |
recordBulkBirth(entry) / recordBulkCascadeDelete(entry) |
The hidden role-3 birth marker / role-4 cascade snapshot (a run's cascade wipe, or a revert's D2 delete) of a bulk run. |
TM_ROLE, decodeTmImage() |
The role codes, and the decoder of an undo-log image (data IS NULL = the region held nothing — select TM_IMAGE_ABSENT_COLUMN; the pair writer never stores a JSON null image, it maps one to absence). |
nowDbTimestamp() |
The current time as a Postgres-style timestamp string. |
TM_EXCLUDED_SECTIONS |
The section tipos never versioned — dd15 itself. |
There is no row-deletion function: Time Machine rows are never deleted by this server. There is likewise no generic multi-row search here — the missing-prior-version lookup is inlined per caller (see the back-fill note above) rather than exposed as a reusable primitive.
src/core/resolve/read_tm.ts
| export | purpose |
|---|---|
tmReadSource |
The SectionReadSource implementation plugged into the generic section-read pipeline for dd15 (getRows/count/emitRow/buildContext). |
readTimeMachineData(rqo) |
Direct-caller adapter: runs the TM query and assembles the standard {sections envelope, per-row data} shape (what the generic pipeline does internally). |
countTimeMachineData(rqo) |
Pagination count over the same query. |
How it fits with the rest of Dédalo
Time Machine is a cross-cutting audit and versioning layer: it is fed by the normal save pipeline and consumed by a read-only viewer, and it never owns the live data.
-
It is written by the save pipeline, not by the UI. A component save (
save_component.ts) and a record delete (delete_record.ts) are the producers of TM rows, viarecordTimeMachine(). Versioning is a side-effect of a successful write — there is no "save to Time Machine" action. -
It is read through the generic section read pipeline, not a bespoke viewer.
dd15is served as a normal section oversrc/core/resolve/read_tm.ts'stmReadSource, wired into the same genericreadSectionRows/envelope/count machinery every other section uses (see the section family); only row acquisition (matrix_time_machine, notmatrix) and per-row cell policy differ.buildTmSectionRecord()is the single place that knows the dd15 field mapping — it used to be duplicated betweenread_tm.tsandtool_time_machine.tsbefore being consolidated intotm_record.ts. -
The TM read owns its own SQL.
buildTmWhere()/queryTmRows()build theWHERE,ORDER BYand pagination directly. There are three scoping surfaces:filter_by_locatorsfor a per-component history, atipocolumn filter for the record-snapshot list, and no scope at all for the baredd15list — which deliberately returns every row. SQO-driven filters againstmatrix_time_machinefrom other entry points go through the_tmtwin branches inside the generic search builders instead (src/core/search/builders/builder_relation.ts). -
Restore is a normal save, wrapped in an explicit tool action.
apply_value(tools/tool_time_machine/server/tool_time_machine.ts) writes the historical snapshot back into the live record through the normal write chokepoint (persistRecordColumns/persistRecordKeys, stripping dataframe frame entries first) and then callsrecordTimeMachine()again — so the restore itself creates a fresh version. A main's restore also rewrites the main's slots, first, with the frames as they stood at the restored row (the two lanes). The consumed TM row is kept: the fresh audit row simply supersedes it in the list.
bulk_revert_process (bulk_revert.ts) is the batch analogue: it replays a
whole bulk_process_id's undo log, restoring each key's
earliest BEFORE image unless the key changed after the run, and records its own
pairs under a new bulk_process_id, so the revert is itself exactly
revertible. Runs recorded before the undo log fall back to inferring the
pre-run state from the history.
- Worker hygiene is a non-issue by construction. There is no
tm_record_data::$instances-style static cache to unset — see the "No cached-instance layer" note above.
flowchart TB
SAVE["save_component.ts<br/>delete_record.ts / duplicate_record.ts"] -->|recordTimeMachine| DBM["time_machine.ts"]
DBM -->|INSERT| DB[("matrix_time_machine")]
DB -->|readTimeMachineRow| DBM2["time_machine.ts (read)"]
DBM2 --> TMR2["tm_record.ts: buildTmSectionRecord()"]
TMR2 -->|inject dd15 record| SR["MatrixRecord (synthetic dd15)"]
SR -->|tm mode read| C["component read (read-only)"]
C -->|user clicks Apply| APPLY["tool_time_machine.apply_value"]
APPLY --> SAVE
SQO["SQO / list read"] --> RT["read_tm.ts: tmReadSource"]
RT --> DB
Examples
List the recent history of one component value
import { readTimeMachineHistory } from '../db/time_machine.ts';
const history = await readTimeMachineHistory('oh1', 42, 'oh21', 20); // newest first
for (const row of history) {
// row.id is the TM row id; row.data is the decoded payload
}
Render a historical row
import { readTimeMachineRow } from '../db/time_machine.ts';
import { buildTmSectionRecord } from '../tm_record/tm_record.ts';
// 1. load the TM row and synthesize its dd15 record (populates the cache for this call)
const row = await readTimeMachineRow(rowId);
const record = row !== null ? await buildTmSectionRecord(row, lang) : null;
// 2. the section read pipeline resolves component 'oh21' in 'tm' mode against
// dd15 + rowId from this record — there is no separate component-instance
// step to drive by hand; read_tm.ts's emitTmRow does it inline per request.
Restore a historical value
// via the tool action, not a direct save:
// POST tool action tool_time_machine.apply_value
// { model: 'component', matrix_id: rowId, tipo: 'oh21', section_tipo: 'oh1', section_id: 42 }
// → writes the TM snapshot back into the live record, then records a fresh
// TM version of the restored value.
Related
- section_record — the per-record DB I/O
object
buildTmSectionRecord()synthesizes fordd15and thatdelete_record.tssnapshots. - Sections concept — the
matrixstorage model TM diverges from (TM is flat, not typed-JSONB). - Components — the fields whose every save writes a
TM version, read back in
tmmode. - common — the shared read/permission contract; the
dd15admin-only clamp when addressed directly is enforced the same way as any other section-level permission gate. - Architecture overview — the datum
{context,data}shape and theedit/list/search/tmmode set. - Services — the client viewer that drives the read-only
tm-mode history and the Apply (restore-as-save) flow. src/core/db/time_machine.ts— the SQL layer formatrix_time_machine.src/core/resolve/read_tm.ts— the search/list read surface for the TM table.