Repository navigation
The event history is a SQLite file, history.db #629
Description
Part of #623.
Scope
After this PR the event history is one SQLite file, history.db, in the data directory. A fresh install writes no events.jsonl. simlock events --since and GET /v1/events?since= read the new file, with or without a running daemon, on a worker and on a gateway. Once the daemon holds its socket, and every hour, old event rows are pruned by age and by their stored size, and the freed space goes back to the disk. A damaged file is set aside and a fresh one started. simlock status says when the history is not written or was set aside. The history module offers a projection step inside the event's transaction; #630 adds the first projection. No figures yet. ADR 0032 §1, §2, §3, §4 (the set-aside half) and §5 (the events.replay half) fix the rules.
In short
Every event the daemon emits is written to <data dir>/history.db instead of events.jsonl. Reading back and pruning become indexed queries. Nothing a user types changes. simlock status gains up to two lines, and simlock config set drops a key that no longer exists.
Set aside: a damaged history.db is renamed to history.db.damaged-<epoch ms>, with its -wal and -shm files when they exist, kept, never deleted, and a fresh file takes its place. Example: history.db.damaged-1760605920000.
Stored size of the event rows: the sum, over every event row, of the UTF-8 byte length of its stored envelope JSON. Example: 1 000 000 events of about 230 bytes each are about 230 MB, under the 256 MiB default.
flowchart LR
E[Module emits] --> B[Bus]
B --> H["History writer (changed)<br/>BEGIN; INSERT OR IGNORE event; projections; COMMIT"]
H --> D[(history.db)]
D -->|"timestamp index (changed)"| R[events.replay / events --since]
S["Daemon holds its socket"] --> Q{"quick_check (new)"}
Q -->|not ok| A["Set aside (new)"] --> D
Q -->|ok| D
T["Prune (new): after the claim + hourly<br/>rows, vacuum, WAL checkpoint"] --> D
Session, before and after:
$ simlock events --since 1h # daemon running or stopped
{"id":"evt_…","seq":12,"timestamp":…,"event":"lease.granted",…} # same output before and after
$ ls ~/.simlock
before: config.json daemon.log events.jsonl state.json …
after: config.json daemon.log history.db state.json … (an old events.jsonl is left untouched)
$ simlock status # after a write failed
...
History: the event history is not being written; see daemon.log.
$ simlock status # after this start set a damaged file aside
...
History: the event history was damaged and set aside at 2026-10-16 09:12; a fresh one was started.
$ simlock config set log.level debug # config.json still has eventLog.rotateBytes
Removed eventLog.rotateBytes from /Users/me/.simlock/config.json: it is no longer used. # stderr, once
Updated log.level in /Users/me/.simlock/config.json; takes effect on daemon restart.
The Removed line's variable part is the config file's path.
| Input | Today | After |
|---|---|---|
eventLog.rotateBytes: 1048576 in config.json, daemon start |
accepted, sizes one generation | Unknown config key: "eventLog.rotateBytes" warning; the daemon starts |
simlock config set log.level debug with eventLog.rotateBytes in the file |
writes the file, key kept | writes the file without the key; the Removed line on stderr |
eventLog.maxBytes: 8388608 (8 MiB) |
accepted | refused at load, naming eventLog.maxBytes (at least 16 MiB) |
eventLog.maxBytes: 16777216 (16 MiB) |
accepted | accepted |
simlock events --since 7d, no daemon, no history.db |
prints nothing, exit 0 | prints nothing, exit 0 |
simlock events --since 7d, no daemon, an unreadable history.db |
— | one JSON error line on stderr, exit 1 |
simlock events --since 7d, no daemon, only an old events.jsonl |
prints its events | prints nothing: the old file is not read |
Quantities: eventLog.retention is milliseconds, counted back from the prune's Clock.now(), compared with each event's timestamp. eventLog.maxBytes caps the stored size of the event rows (above), checked after the age prune; projection rows do not count (feature #623 Shared values, Config keys and Prune).
If this goes wrong, a user sees simlock events --since miss events from before a restart, or simlock status say the history is not written on a healthy machine.
Technical spec
Facts
| # | Fact | Source |
|---|---|---|
| F1 | EventHistory subscribes to every event and writes one JSON line per envelope through a LogSink. A throwing write sets #writing = false and logs once at error. |
src/bus/event-file.ts:129-140 |
| F2 | replay() with no sinceTs answers the ring; with sinceTs and not writing, the ring; a read that throws logs warn and answers the ring. |
src/bus/event-file.ts:147-160 |
| F3 | Today the event file opens before the mode branch and before the socket claim, on a worker and a gateway. An open that throws logs one error. |
src/daemon/main.ts:196-203, :1359-1392 |
| F4 | Both dispatchers answer events.replay from eventHistory.replay, typed Pick<EventHistory, "replay">. |
src/daemon/dispatcher.ts:131, :772-773; src/gateway/dispatcher.ts:99, :264-265; src/daemon/server.ts:149 |
| F5 | A gateway puts every relayed worker event on its own bus with republish, keeping the worker's id and timestamp. |
src/gateway/worker-link.ts:538-554; src/bus/index.ts:472-474 |
| F6 | A relayed id matches ^evt_[A-Za-z0-9_-]{1,64}$ and timestamp is bounded; event and module are unbounded strings. |
src/contract/schemas.ts:775-786 |
| F7 | With --since and no daemon answering, the CLI reads the event file itself through environment.readEventFile; a refusal is rethrown. |
src/cli/index.ts:1203-1217, :362-363 |
| F8 | Config keys today: eventLog.rotateBytes (5 MiB), retention (7 days), maxBytes (256 MiB); maxBytes < 2 × rotateBytes is refused at load. |
src/core/config.ts:756-760, :910-914, :464-468, :418 |
| F9 | An unknown config key logs Unknown config key: "<path>" and is dropped. |
src/core/config.ts:832 |
| F10 | simlock config set validates the merged file and fails on any unknown-key warning, so a file holding eventLog.rotateBytes could no longer be changed. |
src/cli/index.ts:339-351, :1371-1388 |
| F11 | config.get output has eventLog { rotateBytes, retention?, maxBytes? }, optional because a gateway parses a worker's answer. |
src/contract/schemas.ts:896-903 |
| F12 | status.get output has no history field. While starting a worker answers daemon, host, load only; a gateway the same. |
src/contract/operations.ts:93-127; src/daemon/dispatcher.ts:366; src/gateway/dispatcher.ts:240-245 |
| F13 | simlock status text is built by formatStatus, with a short form while starting. |
src/cli/index.ts:2070-2085, :1290-1297 |
| F14 | NodeFileLogSink's retentionMs and totalMaxBytes options (generations, sweep) have one production caller, the event file. |
src/daemon/main.ts:1372-1378; src/ports/logger.ts:196-259 |
| F15 | engines.node is >=22; CI sets node-version: 22 in every job. |
package.json:113-115; .github/workflows/ci.yml:35, :61, :83, :110, :207, :245 |
| F16 | node:sqlite is unflagged but still experimental from v22.13.0; below it, loading it fails without a flag. |
Node v22 docs, cli.json (--no-experimental-sqlite), via ctx7 docs /websites/nodejs_latest-v22_x_api |
| F17 | Loading node:sqlite prints ExperimentalWarning: SQLite is an experimental feature and might change at any time on stderr. |
node -e 'require("node:sqlite")' on v24.14.0 |
| F18 | INSERT OR IGNORE of an existing primary key returns changes: 0. |
node -e with DatabaseSync on v24.14.0: { changes: 0, lastInsertRowid: 1 } |
| F19 | Opening a file that is not SQLite succeeds; the first statement (PRAGMA quick_check) throws file is not a database. A missing file and an unreadable one both fail a readOnly open with unable to open database file (errcode 14). |
node -e with DatabaseSync on v24.14.0; spec check of #629 |
| F20 | A WAL database left by a process that exited without close() opens readOnly and reads its rows, with or without its -shm file. |
node -e with DatabaseSync on v24.14.0: { c: 1 } both times |
| F21 | In WAL mode a DELETE grows the -wal file, and incremental_vacuum shrinks only the main file: 22.47 MB before, 34.30 MB after the delete, 23.19 MB after the vacuum, 11.36 MB only after wal_checkpoint(TRUNCATE). |
spec check of #629, measured with node:sqlite |
| F22 | auto_vacuum takes effect only when set before the first table is created, and must be set before journal_mode=WAL. |
spec check of #629 |
| F23 | The two process entries are the CLI bin and the daemon's main.ts. |
src/cli/main.ts:1-4; src/daemon/main.ts:1438 |
| F24 | onSocketClaimed is called synchronously right after converge starts, on a worker and a gateway; a daemon that loses the claim never reaches it. A failed converge then calls stop("convergence-failed"). |
src/daemon/server.ts:482-503; src/daemon/main.ts:579, :898 |
| F25 | daemon.stopping is emitted inside #stop, before settle and dispose, which can still emit. |
src/daemon/server.ts:633-660 |
| F26 | The ring keeps the last eventBuffer.capacity events of this run (1000 by default). |
src/bus/index.ts:440-449; src/core/config.ts:754 |
| F27 | The Filesystem port has async rename and stat. Daemon tests pass a MemoryFilesystem for the daemon's other files. |
src/ports/filesystem.ts:52-74; src/daemon/main.test.ts:79, :135 |
| F28 | A port's fake may be "none; tests inline one". | src/ports/AGENTS.md:28-29 |
| F29 | Existing tests read events.jsonl to see what was written. |
src/daemon/main.test.ts:236, :661, :1966, :1981, :2004; src/daemon/optional-modules.test.ts:846; src/agent-device/gateway-front.test.ts:1255; e2e/event-history.test.ts:46 |
Modules touched
Searches (in src, e2e, ui, docs, scripts, .github, package.json, ADRs left out): events\.jsonl|EVENT_FILE_NAME 163 hits in 26 files; EventHistory|eventHistory 61 in 14; readEventFile|readEventHistory 36 in 6; eventLog\. 34 in 11; rotateBytes 87 in 30 (most are log.rotateBytes or test configs); retentionMs:|totalMaxBytes 61 in 15.
src/ports/sqlite.ts(new): thenode:sqliteport. One interface, one Node adapter, no fake (F28): tests use the adapter over a temp file. Only this file namesnode:sqlite, and it loads it with a dynamicimport()on first open, so the warning filter is in place first.index.tsand the ports manual's table gain it.src/history/(new component): ownshistory.db. Placement: a new module, not insidesrc/bus. Why:- Architecture rule 4: a durable store with a schema, prune timer and damage handling is its own functionality; the bus is the in-memory fact stream.
- Rule 15: the history is optional. The composition root can wire it or nothing, and leasing behaves the same. The bus never imports it.
- Rule 18: a new module hands out one factory and a handle. The bus's
index.tspredates the rule and should not grow. - Daemon state stores in SQLite (state.db) #624 will reuse the SQLite port, not this module.
src/bus/:event-file.ts, its test, and theEventHistory/readEventFile/readEventHistory/EVENT_FILE_NAMEexports go.eventKeystays.src/daemon/main.ts:openEventHistorybuilds the history before any event, with aNodeFilesystemof its own, whateveroptions.filesystemis, becausenode:sqlitewrites the real disk (F27).void history.start()runs fromonSocketClaimedin both modes (F24);history.stop()runs after#stop(F25). The warning filter is installed at the top of the entry (F23,:1438).dispatcher.ts,server.ts: the replay type, thehistoryfield instatus.get.src/gateway/dispatcher.ts: the replay type,historyin itsstatus.get.worker-link.tsunchanged (F5).src/cli/index.ts:readEventFile→ the history reader; the two status lines;config setdropseventLog.rotateBytes.src/cli/main.ts: the warning filter.src/core/config.ts: droprotateBytes;validateEventLogBoundschecks the 16 MiB floor instead.src/contract/operations.ts,schemas.ts:status.gethistory;config.geteventLogwithoutrotateBytes.src/ports/logger.ts: theretentionMsandtotalMaxBytesoptions and the generation code behind them go (F14);daemon.logkeeps its one rotated file. Their tests inlogger.test.tsgo with them.- Tests named in F29 and the test configs with
eventLog.rotateBytes. package.jsonengines→>=22.13. CI unchanged: it runs the latest 22.x (F15).- Docs:
docs/CLI.md(2238-2245, 2260, 2515,config set),docs/CONFIGURATION.md(66-68),docs/HTTP-API.md(1493-1496, andGET /v1/status),docs/EVENTS.md(155, 166-170, 186-195),docs/internal/EVENTS.md(13, 146, 239, 266-274),docs/internal/flows/startup.md:11,docs/internal/agent-rules/events.mdrule 7,docs/internal/ARCHITECTURE.md(bus row 55, a new history row), manuals of bus, daemon, cli, ports, and a newsrc/history/AGENTS.mdandCLAUDE.md.
flowchart LR
main["daemon/main.ts (changed)"] --> hist["history (new)"]
cli["cli (changed)"] --> hist
hist --> sqlite["ports/sqlite (new)"]
hist --> fs["ports/filesystem"]
hist --> bus["bus (changed: file code out)"]
main --> bus
disp["daemon/dispatcher (changed)"] -. replay, status .-> hist
gw["gateway/dispatcher (changed)"] -. replay, status .-> hist
wl[gateway/worker-link] --> bus
config["core/config (changed)"]
contract["contract (changed)"]
sequenceDiagram
participant C as CLI
participant D as Daemon
participant H as history.db
C->>D: events.replay { sinceTs }
alt daemon answers
D->>H: SELECT … WHERE timestamp > ? ORDER BY timestamp, seq, rowid
H-->>D: rows
D-->>C: envelopes
else no daemon
C->>C: stat history.db
alt missing
C-->>C: print nothing, exit 0
else present
C->>H: open readOnly; same SELECT
alt open or read throws
C-->>C: one JSON error line on stderr, exit 1
end
end
end
Surface and extension points
- Port
src/ports/sqlite.ts:SqliteOpener.open(path, { readOnly }) → Promise<SqliteDatabase>withexec,prepare(sql) → { run, get, all },close. Node adapterNodeSqliteOpener. As small as its callers need. - Module
src/history/index.tsexports:createEventHistory({ bus, sqlite, filesystem, clock, logger, dataDirectory, config: { retention, maxBytes }, projections })→ handle{ replay({ sinceTs? }), status(): { opened, writing, setAsideAt? }, start(): Promise<void>, stop(): Promise<void> };openHistoryReader({ sqlite, filesystem, dataDirectory })→{ read({ sinceTs }) }for the CLI;- the
HistoryProjectionDefinitiontype andHISTORY_DB_NAME. Nothing else.testing.tsholds a reader for tests (F29).
- Lifecycle (architecture rule 12; one named state at every exit):
created: the factory only subscribes to the bus; it touches no file.status()is{ opened: false, writing: false }.replayanswers the ring, with or withoutsinceTs.start(): load the port; open;auto_vacuum=INCREMENTALon a new file, thenjournal_mode=WAL(F22),synchronous=NORMAL,busy_timeout=5000; schema;quick_check; set aside if damaged; write this run's ring events (F26, deduplicated byid; events past the ring's capacity beforestart()are not written); prune; arm the hourly timer. Thenopened: true, writing: true.stop(): setsstopped, cancels the timer, closes the database. Astart()still awaiting seesstoppedafter each await, closes what it opened, and returns: it never opens a file or arms the timer afterstop(). Astart()called afterstop()does nothing.
- Extension point: projections. Projections: request, minute and device fact rows #630's projector is the named implementation (architecture rule 19). Shape per rule 20:
HistoryProjectionDefinition { name, create(db) → { apply(envelope), prune(now) } }. Projections: request, minute and device fact rows #630 adds the version, migration and rebuild members it needs.- One registry,
HISTORY_PROJECTIONS, keyed by name, empty in this PR. One directory per implementation undersrc/history/projections/. One conformance suite (describe.eachover the registry):applyof a duplicate never runs, a throwingapplywrites nothing. - The history never names a projection (rule 21).
- Transaction per event:
BEGIN; INSERT OR IGNORE INTO events …; if changes = 1 then each projection's apply; COMMIT. A duplicate id (F18) runs no projection. - Prune, in order, each step its own transaction:
- Delete event rows with
timestampolder thannow − retention. - Size: each row stores its
size, the UTF-8 byte length of its envelope JSON. The total is kept in memory, read once withSUM(size)atstart()and updated by each insert and delete. While the total is overmaxBytes, delete the 1 000 oldest rows (bytimestamp, thenrowid). The loop ends when the total fits or a step deletes no row, so it runs at most ⌈rows / 1000⌉ + 1 steps. - Each projection's
prune(now). PRAGMA incremental_vacuum, thenPRAGMA wal_checkpoint(TRUNCATE)(F21).
- Delete event rows with
- Warning filter: both entries (F23) install it before anything loads
node:sqlite. It drops only anExperimentalWarningwhose message startsSQLite is an experimental feature; every other warning prints as Node prints it.
Contract and event changes
status.getgainshistory: { opened: boolean, writing: boolean, setAsideAt?: number }(feature Operational metrics kept over hours and shown in the console #623 Shared values,status.gethistory).setAsideAtis a finite epoch ms. Sent on a worker and a gateway, in thestartinganswer too.config.get'seventLoglosesrotateBytes.- This PR bumps the protocol by one. Published surfaces rerun: MCP
tools/list, HTTPGET /v1/status, CLI help. - No event added or changed.
docs/EVENTS.mdanddocs/internal/EVENTS.mdchange their text on where events are stored. - Rows read back from
history.dbare envelopes this daemon wrote; a row whoseidfailsEVENT_ID_PATTERNis skipped, as the file reader does today (src/bus/event-file.ts:92-101).
Other code on the same state
Searches for the state itself: the event file path (events\.jsonl|EVENT_FILE_NAME, 163 hits), subscribeAll (bus subscribers), the data directory's file names (git grep -l "daemon\.log\|state\.json", 16 files: none deletes or lists the data directory), republish (2 production hits, F5), config.json writers (writeConfigFile, 1 production caller, F10).
| Component | What happens when both act | Who wins |
|---|---|---|
| A second daemon on the same data dir | It never reaches start() (F24), so it never opens, checks, renames, prunes or writes history.db. |
The claim holder. |
| CLI history reader while a daemon writes | WAL: the reader sees the last commit; it takes no write lock. | Both. |
Gateway WorkerLink.republish (F5) |
A worker event comes twice (re-push): the second insert is ignored by id. |
The first row. |
| The hourly prune and an event write | Same connection, same thread: one runs after the other. | Both. |
simlock config set and the daemon reading config.json |
The daemon reads it only at start; a set drops eventLog.rotateBytes from the file. |
The file as written. |
| #630's projector | Runs inside this transaction. Its rows are pruned by its own prune, before the vacuum. |
Shared values: #629 writer, #630 step. |
#631 usage.get |
Reads the projection tables, not events. |
— |
An old events.jsonl |
Never opened, renamed or deleted. | — |
Failure modes
| Call | Outcome | What happens | Limit, counted from |
|---|---|---|---|
load node:sqlite |
throws (Node below 22.13) | Not damage: nothing is set aside. opened: false; one error line naming Node 22.13; replay answers the ring. |
— |
open history.db in start() |
ok | Pragmas in the order above, schema, quick_check. |
5 s per lock wait, from the statement's start |
| open | throws (e.g. a directory at the path) | Set aside, open a fresh file. setAsideAt set. One error line naming both paths. |
— |
quick_check |
not ok, or throws (F19) |
Same as above. | — |
| rename aside | ok | -wal, then -shm, then history.db, each to <name>.damaged-<now> with one now; a missing -wal or -shm is skipped. The main file goes last so a fresh file never meets an old -wal. |
— |
| rename aside | throws | opened: false; nothing is deleted; one error line. |
— |
| open the fresh file | throws | opened: false, writing: false; replay answers the ring; one error line. Leasing goes on. |
— |
| open, check, write or prune | a lock held past 5 s (SQLITE_BUSY, SQLITE_LOCKED) |
Never damage. At open or check: opened: false for this run. At a write: as a failed write. At a prune: as a failed prune. |
5 s, from the statement's start |
| write transaction | ok | Event row and projection rows committed. | none |
| write transaction | throws (full disk, I/O error, a projection throws, a lock past 5 s) | Rolled back. writing: false until restart; one error line; the emitter and other subscribers see nothing (events rule 5). |
5 s per lock wait |
replay with sinceTs |
before start() opened the file |
Answers the ring. | — |
replay with sinceTs |
throws | One warn; answers the ring (as F2). |
— |
| prune step, vacuum or checkpoint | throws | One warn; writing goes on; the next prune tries again. |
next try one hour later |
stop() while start() awaits |
— | start() closes what it opened and returns at its next step; no timer is armed. |
— |
CLI reader stat |
missing | Prints nothing, exit 0. | — |
| CLI reader open or read | throws | One JSON error line on stderr, exit 1. | — |
State lines:
history.db: a crash mid-transaction leaves the last commit; a power loss can lose the last few (ADR 0032 §2). Tested by the restart e2e line, not by power loss.- Set aside: a crash after the
-walrename leaves a main file with no WAL; the next start checks it again and sets it aside if still damaged. A crash after the main rename leaves nohistory.db; the next start opens a fresh one and sets nothing aside. - Prune: each step commits alone; a crash between steps leaves fewer deletions, redone at the next start.
- Two writers at once: only the socket-claim holder opens the file (F24).
- A stop during converge, or after
convergence-failed(F24):stop()runs; astart()in flight returns without a file or timer.
Rules in play
- Architecture rule 9, "every external API is accessed through a port":
node:sqliteonly insrc/ports/sqlite.ts. - Rule 15, "dependencies point from the optional to the required": the history subscribes to the bus; nothing required imports it.
- Rules 18-21: one factory; the projection extension point has the rule 20 shape and a named implementation (Projections: request, minute and device fact rows #630); the host names none.
- Rule 11, "every wait that crosses a process boundary is bounded": a wait on another process's lock is at most 5 s.
- Rule 12, "every exit … leaves its subject in exactly one named state": the lifecycle above; no open or timer after
stop(). - Events rules 5 and 7: a failing write never reaches the emitter; rule 7's text changes from
events.jsonltohistory.db. - Safety: the history deletes rows, never files; a set-aside file is renamed, never deleted.
- ADR 0006 §7, "the event file is an observer": kept for the database. ADR 0014 §4: identity is
id;INSERT OR IGNOREenforces it. - Documentation rule 2: end-user docs name
history.dband its keys, no ADR.
Tests
Seam: createEventHistory over a real node:sqlite file in a temp directory with a NodeFilesystem, driven by EventBus.emit and a FakeClock, in a new src/history/history.test.ts modelled on src/bus/event-file.test.ts. CLI lines through runCli with a test environment (src/cli/index.test.ts), e2e through withDaemon in e2e/event-history.test.ts.
- writes every emitted and every republished event as one row equal to its envelope, read back by
replay({ sinceTs })in timestamp, then seq order - a second insert of an event with an id already stored adds no row and runs no projection step
- a test projection's rows and the event row commit together: a projection that throws leaves neither, and stops writing
- a write that throws logs one error, sets
writingfalse, and later events still reach the ring and other subscribers - replays the ring without
sinceTs; withsinceTsbeforestart(), once writing has stopped, and when the read throws - a new file has
auto_vacuum=INCREMENTAL, WAL,synchronous=NORMALand a 5 s busy timeout - the factory touches no file and
status()isopened: false;start()writes the events this run emitted before it, once each, and later events as they come stop()whilestart()awaits its open: no file stays open, no timer is armed, a laterstart()does nothing, and no prune runs an hour later- a file that fails
quick_checkand a path that will not open are each renamed tohistory.db.damaged-<now>,-waland-shmwith it (each skipped when missing, the main file last), a fresh file is written, andstatus()givessetAsideAtequal tonow; a healthy file is not renamed - a lock another connection holds past 5 s at open, check, write or prune never sets the file aside; at open it leaves
openedfalse - a failed load of
node:sqliteleaves a healthyhistory.dbbyte-identical and in place, andopenedfalse - when the fresh file cannot be opened either,
openedandwritingare false and replay answers the ring - prune deletes event rows older than
retentionand keeps one stamped exactly at the cutoff - prune deletes the oldest event rows, 1 000 per step, until their stored size fits
maxBytes, keeps the newest, and the size on disk ofhistory.dbplus-walis smaller after the prune than before it - prune runs again one hour after
start()on the clock, not before (straddled), andstop()cancels it; a prune step that throws logs one warning and writing goes on - the conformance suite runs every registered projection (empty registry passes; the suite runs over a test registry of one)
- a row whose id the envelope schema refuses is skipped on replay
- the history reader reads rows a writer committed while that writer is still open; answers empty when
history.dbis missing; throws when it is present and unreadable status.getcarrieshistory.opened,writingandsetAsideAton a worker and on a gateway, in thestartinganswer too (extends the status tests indispatcher.test.tsandgateway/dispatcher.test.ts)simlock statusprints the not-written line whenwritingis false, the set-aside line with the local time whensetAsideAtis set, both when both hold, and neither otherwisesimlock events --sincewith no daemon readshistory.dband launches none (extendssrc/cli/index.test.ts:5220); with an unreadablehistory.dbit prints one JSON error and exits 1simlock config seton a file holdingeventLog.rotateByteswrites it without that key and prints theRemovedline once; on a file without it, no such line- a config with
eventLog.rotateBytesloads with the unknown-key warning, on a worker and a gateway;eventLog.maxBytesof 16 MiB loads and one byte less fails naming the key (invertsconfig.test.ts:189,:235,:1729) config.getreportseventLog.retentionandmaxBytesand norotateBytes- the daemon and the CLI print no
ExperimentalWarningfor SQLite, and another warning still prints - a daemon whose history path is a directory starts, sets it aside and writes a fresh file (extends
main.test.ts:1966); a daemon that loses the socket claim leaves a damagedhistory.dbbyte-identical and in place - only
src/ports/sqlite.tsnamesnode:sqlite(one scan of every file undersrc/) - (e2e)
simlock events --since 1haftersimlock daemon stopandstartprints a lease granted before the restart with its id, with the daemon running and with it stopped (replaces the four file-generation tests ine2e/event-history.test.ts) - (e2e) with
eventLog.retentiona few seconds, events of a first run are gone after the next start's prune, and a second run's are there; a fresh data directory hashistory.dband noevents.jsonl, and anevents.jsonlplaced there before start is byte-identical after - (e2e) a
history.dboverwritten with junk while the daemon is stopped is renamed aside at the next start, a lease is granted, andsimlock statusprints the set-aside line
Done when
- On the fake driver, a fresh install writes
history.dband noevents.jsonl;simlock events --since 1hprints the run's events with the daemon running and stopped. - A
history.dbcorrupted while the daemon is stopped is renamed aside at the next start; leasing works;simlock statussays the history was set aside; the damaged file is still there. - With
eventLog.retentionset to a few seconds, events older than that are gone after the next start. eventLog.rotateBytesin a config gives the unknown-key warning, andsimlock config setremoves it.- No file under
docs/or the manuals namesevents.jsonlas the current history, except to say an old one is left untouched.
Out of scope
- Projections, figures,
metrics.retention,rebuiltAt,rebuildFailed(Projections: request, minute and device fact rows #630). - Importing an old
events.jsonl. daemon.logrotation.- Registry, tokens, workers in SQLite (Daemon state stores in SQLite (state.db) #624).
Depends on
Written by an agent.
Activity
feat(history): write, read and prune the event history in history.db;…
feat: the daemon, CLI and contract use history.db; the event file cod…
docs: history.db in the manuals, the user docs and the event catalogs (…
test(ports): the SQLite warning filter drops one warning and no other (…
chore(ports): drop three re-exports nothing imports (#629)
test(history): seq tie order, wal truncation, stored-size accounting,…
fix: gateway manual back to 150 lines; the starting status e2e expect…
fix(history): a failed prune recounts the stored size; collecting pre…
docs(contract): manual lists history, not bus, as an importer (#629)
feat(history): history.db, usage projections, simlock stats, GET /v1/…