@smthrs/journal 0.0.0-stage → 1.0.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +149 -0
- package/LICENSE +21 -0
- package/README.md +206 -2
- package/dist/cjs/CallFact.d.ts +129 -0
- package/dist/cjs/CallFact.d.ts.map +1 -0
- package/dist/cjs/CallFact.js +106 -0
- package/dist/cjs/CallFact.js.map +7 -0
- package/dist/cjs/Consensus.d.ts +440 -0
- package/dist/cjs/Consensus.d.ts.map +1 -0
- package/dist/cjs/Consensus.js +251 -0
- package/dist/cjs/Consensus.js.map +7 -0
- package/dist/cjs/EngineEvent.d.ts +890 -0
- package/dist/cjs/EngineEvent.d.ts.map +1 -0
- package/dist/cjs/EngineEvent.js +383 -0
- package/dist/cjs/EngineEvent.js.map +7 -0
- package/dist/cjs/ExecutionFact.d.ts +216 -0
- package/dist/cjs/ExecutionFact.d.ts.map +1 -0
- package/dist/cjs/ExecutionFact.js +303 -0
- package/dist/cjs/ExecutionFact.js.map +7 -0
- package/dist/cjs/Journal.d.ts +617 -0
- package/dist/cjs/Journal.d.ts.map +1 -0
- package/dist/cjs/Journal.js +190 -0
- package/dist/cjs/Journal.js.map +7 -0
- package/dist/cjs/JournalEvent.d.ts +421 -0
- package/dist/cjs/JournalEvent.d.ts.map +1 -0
- package/dist/cjs/JournalEvent.js +281 -0
- package/dist/cjs/JournalEvent.js.map +7 -0
- package/dist/cjs/JournalGeneration.d.ts +34 -0
- package/dist/cjs/JournalGeneration.d.ts.map +1 -0
- package/dist/cjs/JournalGeneration.js +72 -0
- package/dist/cjs/JournalGeneration.js.map +7 -0
- package/dist/cjs/JournalMetrics.d.ts +72 -0
- package/dist/cjs/JournalMetrics.d.ts.map +1 -0
- package/dist/cjs/JournalMetrics.js +55 -0
- package/dist/cjs/JournalMetrics.js.map +7 -0
- package/dist/cjs/Migrations.d.ts +42 -0
- package/dist/cjs/Migrations.d.ts.map +1 -0
- package/dist/cjs/Migrations.js +58 -0
- package/dist/cjs/Migrations.js.map +7 -0
- package/dist/cjs/OwnerId.d.ts +40 -0
- package/dist/cjs/OwnerId.d.ts.map +1 -0
- package/dist/cjs/OwnerId.js +40 -0
- package/dist/cjs/OwnerId.js.map +7 -0
- package/dist/cjs/Projection.d.ts +29 -0
- package/dist/cjs/Projection.d.ts.map +1 -0
- package/dist/cjs/Projection.js +25 -0
- package/dist/cjs/Projection.js.map +7 -0
- package/dist/cjs/RedactedLogger.d.ts +121 -0
- package/dist/cjs/RedactedLogger.d.ts.map +1 -0
- package/dist/cjs/RedactedLogger.js +235 -0
- package/dist/cjs/RedactedLogger.js.map +7 -0
- package/dist/cjs/Redaction.d.ts +350 -0
- package/dist/cjs/Redaction.d.ts.map +1 -0
- package/dist/cjs/Redaction.js +928 -0
- package/dist/cjs/Redaction.js.map +7 -0
- package/dist/cjs/SqlConsensus.d.ts +44 -0
- package/dist/cjs/SqlConsensus.d.ts.map +1 -0
- package/dist/cjs/SqlConsensus.js +243 -0
- package/dist/cjs/SqlConsensus.js.map +7 -0
- package/dist/cjs/SqlJournal.d.ts +173 -0
- package/dist/cjs/SqlJournal.d.ts.map +1 -0
- package/dist/cjs/SqlJournal.js +1568 -0
- package/dist/cjs/SqlJournal.js.map +7 -0
- package/dist/cjs/StepFact.d.ts +66 -0
- package/dist/cjs/StepFact.d.ts.map +1 -0
- package/dist/cjs/StepFact.js +68 -0
- package/dist/cjs/StepFact.js.map +7 -0
- package/dist/cjs/index.d.ts +95 -0
- package/dist/cjs/index.d.ts.map +1 -0
- package/dist/cjs/index.js +63 -0
- package/dist/cjs/index.js.map +7 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts +37 -0
- package/dist/cjs/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/cjs/internal/JournalInternalFault.js +43 -0
- package/dist/cjs/internal/JournalInternalFault.js.map +7 -0
- package/dist/cjs/migrations/0001_initial.d.ts +15 -0
- package/dist/cjs/migrations/0001_initial.d.ts.map +1 -0
- package/dist/cjs/migrations/0001_initial.js +54 -0
- package/dist/cjs/migrations/0001_initial.js.map +7 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/cjs/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/cjs/migrations/0002_checkpoints.js +48 -0
- package/dist/cjs/migrations/0002_checkpoints.js.map +7 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts +15 -0
- package/dist/cjs/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/cjs/migrations/0003_startup_index.js +40 -0
- package/dist/cjs/migrations/0003_startup_index.js.map +7 -0
- package/dist/cjs/migrations/0004_dedup.d.ts +15 -0
- package/dist/cjs/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/cjs/migrations/0004_dedup.js +57 -0
- package/dist/cjs/migrations/0004_dedup.js.map +7 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/cjs/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/cjs/migrations/0005_run_event_type.js +40 -0
- package/dist/cjs/migrations/0005_run_event_type.js.map +7 -0
- package/dist/cjs/migrations/0006_consensus.d.ts +22 -0
- package/dist/cjs/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/cjs/migrations/0006_consensus.js +84 -0
- package/dist/cjs/migrations/0006_consensus.js.map +7 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/test/Notifying.d.ts +65 -0
- package/dist/cjs/test/Notifying.d.ts.map +1 -0
- package/dist/cjs/test/Notifying.js +62 -0
- package/dist/cjs/test/Notifying.js.map +7 -0
- package/dist/cjs/test/TestJournal.d.ts +43 -0
- package/dist/cjs/test/TestJournal.d.ts.map +1 -0
- package/dist/cjs/test/TestJournal.js +43 -0
- package/dist/cjs/test/TestJournal.js.map +7 -0
- package/dist/esm/CallFact.d.ts +129 -0
- package/dist/esm/CallFact.d.ts.map +1 -0
- package/dist/esm/CallFact.js +96 -0
- package/dist/esm/CallFact.js.map +1 -0
- package/dist/esm/Consensus.d.ts +440 -0
- package/dist/esm/Consensus.d.ts.map +1 -0
- package/dist/esm/Consensus.js +454 -0
- package/dist/esm/Consensus.js.map +1 -0
- package/dist/esm/EngineEvent.d.ts +890 -0
- package/dist/esm/EngineEvent.d.ts.map +1 -0
- package/dist/esm/EngineEvent.js +614 -0
- package/dist/esm/EngineEvent.js.map +1 -0
- package/dist/esm/ExecutionFact.d.ts +216 -0
- package/dist/esm/ExecutionFact.d.ts.map +1 -0
- package/dist/esm/ExecutionFact.js +374 -0
- package/dist/esm/ExecutionFact.js.map +1 -0
- package/dist/esm/Journal.d.ts +617 -0
- package/dist/esm/Journal.d.ts.map +1 -0
- package/dist/esm/Journal.js +323 -0
- package/dist/esm/Journal.js.map +1 -0
- package/dist/esm/JournalEvent.d.ts +421 -0
- package/dist/esm/JournalEvent.d.ts.map +1 -0
- package/dist/esm/JournalEvent.js +517 -0
- package/dist/esm/JournalEvent.js.map +1 -0
- package/dist/esm/JournalGeneration.d.ts +34 -0
- package/dist/esm/JournalGeneration.d.ts.map +1 -0
- package/dist/esm/JournalGeneration.js +65 -0
- package/dist/esm/JournalGeneration.js.map +1 -0
- package/dist/esm/JournalMetrics.d.ts +72 -0
- package/dist/esm/JournalMetrics.d.ts.map +1 -0
- package/dist/esm/JournalMetrics.js +79 -0
- package/dist/esm/JournalMetrics.js.map +1 -0
- package/dist/esm/Migrations.d.ts +42 -0
- package/dist/esm/Migrations.d.ts.map +1 -0
- package/dist/esm/Migrations.js +59 -0
- package/dist/esm/Migrations.js.map +1 -0
- package/dist/esm/OwnerId.d.ts +40 -0
- package/dist/esm/OwnerId.d.ts.map +1 -0
- package/dist/esm/OwnerId.js +33 -0
- package/dist/esm/OwnerId.js.map +1 -0
- package/dist/esm/Projection.d.ts +29 -0
- package/dist/esm/Projection.d.ts.map +1 -0
- package/dist/esm/Projection.js +13 -0
- package/dist/esm/Projection.js.map +1 -0
- package/dist/esm/RedactedLogger.d.ts +121 -0
- package/dist/esm/RedactedLogger.d.ts.map +1 -0
- package/dist/esm/RedactedLogger.js +445 -0
- package/dist/esm/RedactedLogger.js.map +1 -0
- package/dist/esm/Redaction.d.ts +350 -0
- package/dist/esm/Redaction.d.ts.map +1 -0
- package/dist/esm/Redaction.js +1584 -0
- package/dist/esm/Redaction.js.map +1 -0
- package/dist/esm/SqlConsensus.d.ts +44 -0
- package/dist/esm/SqlConsensus.d.ts.map +1 -0
- package/dist/esm/SqlConsensus.js +252 -0
- package/dist/esm/SqlConsensus.js.map +1 -0
- package/dist/esm/SqlJournal.d.ts +173 -0
- package/dist/esm/SqlJournal.d.ts.map +1 -0
- package/dist/esm/SqlJournal.js +1858 -0
- package/dist/esm/SqlJournal.js.map +1 -0
- package/dist/esm/StepFact.d.ts +66 -0
- package/dist/esm/StepFact.d.ts.map +1 -0
- package/dist/esm/StepFact.js +50 -0
- package/dist/esm/StepFact.js.map +1 -0
- package/dist/esm/index.d.ts +95 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +95 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/internal/JournalInternalFault.d.ts +37 -0
- package/dist/esm/internal/JournalInternalFault.d.ts.map +1 -0
- package/dist/esm/internal/JournalInternalFault.js +28 -0
- package/dist/esm/internal/JournalInternalFault.js.map +1 -0
- package/dist/esm/migrations/0001_initial.d.ts +15 -0
- package/dist/esm/migrations/0001_initial.d.ts.map +1 -0
- package/dist/esm/migrations/0001_initial.js +32 -0
- package/dist/esm/migrations/0001_initial.js.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts +27 -0
- package/dist/esm/migrations/0002_checkpoints.d.ts.map +1 -0
- package/dist/esm/migrations/0002_checkpoints.js +38 -0
- package/dist/esm/migrations/0002_checkpoints.js.map +1 -0
- package/dist/esm/migrations/0003_startup_index.d.ts +15 -0
- package/dist/esm/migrations/0003_startup_index.d.ts.map +1 -0
- package/dist/esm/migrations/0003_startup_index.js +18 -0
- package/dist/esm/migrations/0003_startup_index.js.map +1 -0
- package/dist/esm/migrations/0004_dedup.d.ts +15 -0
- package/dist/esm/migrations/0004_dedup.d.ts.map +1 -0
- package/dist/esm/migrations/0004_dedup.js +38 -0
- package/dist/esm/migrations/0004_dedup.js.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts +15 -0
- package/dist/esm/migrations/0005_run_event_type.d.ts.map +1 -0
- package/dist/esm/migrations/0005_run_event_type.js +18 -0
- package/dist/esm/migrations/0005_run_event_type.js.map +1 -0
- package/dist/esm/migrations/0006_consensus.d.ts +22 -0
- package/dist/esm/migrations/0006_consensus.d.ts.map +1 -0
- package/dist/esm/migrations/0006_consensus.js +69 -0
- package/dist/esm/migrations/0006_consensus.js.map +1 -0
- package/dist/esm/test/Notifying.d.ts +65 -0
- package/dist/esm/test/Notifying.d.ts.map +1 -0
- package/dist/esm/test/Notifying.js +62 -0
- package/dist/esm/test/Notifying.js.map +1 -0
- package/dist/esm/test/TestJournal.d.ts +43 -0
- package/dist/esm/test/TestJournal.d.ts.map +1 -0
- package/dist/esm/test/TestJournal.js +27 -0
- package/dist/esm/test/TestJournal.js.map +1 -0
- package/docs/README.md +164 -0
- package/docs/api.md +639 -0
- package/docs/concepts/compaction.md +157 -0
- package/docs/concepts/idempotency.md +127 -0
- package/docs/concepts/owner-fence.md +124 -0
- package/docs/concepts/redaction.md +218 -0
- package/docs/concepts/state-event-authority.md +159 -0
- package/docs/concepts/two-channels.md +167 -0
- package/docs/guides/commit-state-and-entry.md +114 -0
- package/docs/guides/compact-a-run.md +134 -0
- package/docs/guides/fold-a-projection.md +106 -0
- package/docs/guides/read-a-run.md +139 -0
- package/docs/guides/redact-log-output.md +94 -0
- package/docs/guides/testing.md +124 -0
- package/docs/guides/write-lifecycle-events.md +110 -0
- package/docs/installation.md +133 -0
- package/docs/quickstart.md +143 -0
- package/docs/troubleshooting.md +214 -0
- package/package.json +263 -3
- package/src/CallFact.ts +117 -0
- package/src/Consensus.ts +683 -0
- package/src/EngineEvent.ts +823 -0
- package/src/ExecutionFact.ts +433 -0
- package/src/Journal.ts +688 -0
- package/src/JournalEvent.ts +654 -0
- package/src/JournalGeneration.ts +77 -0
- package/src/JournalMetrics.ts +89 -0
- package/src/Migrations.ts +62 -0
- package/src/OwnerId.ts +42 -0
- package/src/Projection.ts +31 -0
- package/src/RedactedLogger.ts +483 -0
- package/src/Redaction.ts +1784 -0
- package/src/SqlConsensus.ts +344 -0
- package/src/SqlJournal.ts +2732 -0
- package/src/StepFact.ts +69 -0
- package/src/index.ts +109 -0
- package/src/internal/JournalInternalFault.ts +40 -0
- package/src/migrations/0001_initial.ts +41 -0
- package/src/migrations/0002_checkpoints.ts +46 -0
- package/src/migrations/0003_startup_index.ts +19 -0
- package/src/migrations/0004_dedup.ts +43 -0
- package/src/migrations/0005_run_event_type.ts +19 -0
- package/src/migrations/0006_consensus.ts +83 -0
- package/src/test/Notifying.ts +101 -0
- package/src/test/TestJournal.ts +52 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Checkpoints and compaction"
|
|
3
|
+
description: "How a checkpoint pins replay state to a sequence, what the compaction floor guarantees, and why a reader below the floor gets a typed compacted failure instead of a shortened history."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 5
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A run that never stops growing eventually costs more to replay than it is worth.
|
|
9
|
+
Compaction is how the journal drops old entries without ever handing a reader a
|
|
10
|
+
history that is quietly missing its beginning.
|
|
11
|
+
|
|
12
|
+
Compaction is off by default. Without a `compaction` policy on the layer, the
|
|
13
|
+
journal never deletes an entry, and checkpointing stays a call you make.
|
|
14
|
+
|
|
15
|
+
## A checkpoint is replay state pinned to a sequence
|
|
16
|
+
|
|
17
|
+
`checkpoint({ runId, seq, state }, owner)` durably records the caller's own
|
|
18
|
+
replay state at a committed sequence. The journal never interprets `state`; it
|
|
19
|
+
round-trips verbatim, and redaction deliberately does not apply to it for the
|
|
20
|
+
same reason it does not apply to any other executable state.
|
|
21
|
+
|
|
22
|
+
The contract on `seq` is what makes replay work: a checkpoint at `seq` must
|
|
23
|
+
subsume every entry at or below `seq`, so replay is `state` plus
|
|
24
|
+
`stream({ runId, afterSequence: seq })`.
|
|
25
|
+
|
|
26
|
+
`seq` must also name a committed entry of the run, and it must lie above the
|
|
27
|
+
run's compaction floor. Otherwise the write fails `checkpoint_invalid`.
|
|
28
|
+
Re-checkpointing an uncompacted `seq` replaces its state: last writer wins.
|
|
29
|
+
|
|
30
|
+
The committed entry at `seq` is what survives compaction, and it survives for a
|
|
31
|
+
mechanical reason. It holds the run's durable `MAX(seq)` allocation floor at or
|
|
32
|
+
above the compaction boundary, so a process restarted after a compaction can
|
|
33
|
+
never re-allocate a truncated sequence.
|
|
34
|
+
|
|
35
|
+
## The floor is what a reader sees
|
|
36
|
+
|
|
37
|
+
`compact({ runId, upTo }, owner)` deletes the run's entries strictly below a
|
|
38
|
+
checkpoint and advances the run's compaction floor, atomically. Omit `upTo` to
|
|
39
|
+
use the run's latest checkpoint.
|
|
40
|
+
|
|
41
|
+
`compacted_at_ms` on a checkpoint row is null until a compaction has truncated
|
|
42
|
+
below it. The largest compacted `seq` for a run is that run's compaction floor.
|
|
43
|
+
|
|
44
|
+
Every read whose cursor starts below the floor fails with `compacted`, carrying
|
|
45
|
+
the floor in `checkpointSeq`. `entries` and `stream` behave identically here, so
|
|
46
|
+
a poller and a follower cannot disagree about what history exists. A reader
|
|
47
|
+
never receives a silently shortened history.
|
|
48
|
+
|
|
49
|
+
The read order is part of the guarantee. `entries` reads the page first and the
|
|
50
|
+
floor second, because truncation and the floor advance commit together: any
|
|
51
|
+
deletion that could have shortened the page is visible in the later floor read.
|
|
52
|
+
Reading the floor first would let a compaction commit between the two reads and
|
|
53
|
+
hand back a gapped history.
|
|
54
|
+
|
|
55
|
+
## Two kinds of reader, two kinds of protection
|
|
56
|
+
|
|
57
|
+
- **Readers this process can see.** A live in-process `stream` registers its
|
|
58
|
+
durable cursor, and `compact` refuses to truncate a sequence one of them
|
|
59
|
+
still needs, failing `reader_behind` with the checkpoint it declined to
|
|
60
|
+
truncate below.
|
|
61
|
+
- **Readers this process cannot see.** A poller of `entries`, or a follower in
|
|
62
|
+
another process, is covered by the read-side `compacted` guard instead. There
|
|
63
|
+
is no way to register their cursors, so the answer is a typed failure and a
|
|
64
|
+
resync point rather than a refusal to compact.
|
|
65
|
+
|
|
66
|
+
## Resyncing
|
|
67
|
+
|
|
68
|
+
A reader that fails with `compacted` recovers in two calls: read
|
|
69
|
+
`latestCheckpoint(runId)`, apply `checkpoint.state`, then continue from
|
|
70
|
+
`stream({ runId, afterSequence: checkpoint.seq })`.
|
|
71
|
+
|
|
72
|
+
`latestCheckpoint` returns the run's most recent checkpoint whether or not it
|
|
73
|
+
has been compacted, so it is the resync point in both cases.
|
|
74
|
+
|
|
75
|
+
## The automatic policy
|
|
76
|
+
|
|
77
|
+
`SqlJournalOptions.compaction` turns the manual calls into a policy:
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
import { SqlJournal } from "@smthrs/journal"
|
|
81
|
+
|
|
82
|
+
const layer = SqlJournal.layer({
|
|
83
|
+
capacity: 1024,
|
|
84
|
+
overflow: "reject",
|
|
85
|
+
compaction: {
|
|
86
|
+
entryThreshold: 5_000,
|
|
87
|
+
capture: (runId, upTo) => captureReplayState(runId, upTo)
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Once a run's committed entry count reaches `entryThreshold`, the journal asks
|
|
93
|
+
`capture` for the caller's replay state at the run's durable tail, writes it as
|
|
94
|
+
a checkpoint at that sequence, and compacts below it.
|
|
95
|
+
|
|
96
|
+
The policy is the journal's own post-commit maintenance rather than a caller's
|
|
97
|
+
entrypoint, so it holds no fence and needs no lease. It only ever
|
|
98
|
+
truncates below a tail the run's own commits produced, and a retry after a
|
|
99
|
+
reclaim compacts the same committed prefix the live owner sees.
|
|
100
|
+
|
|
101
|
+
Three properties of the hook matter when you write `capture`:
|
|
102
|
+
|
|
103
|
+
- It runs after the triggering commit's allocation permit is free. Lossy
|
|
104
|
+
commits schedule one scoped maintenance fiber per compacting run, so capture
|
|
105
|
+
and compaction never occupy the queue-draining fiber. Capture may read or
|
|
106
|
+
emit through the journal while other runs continue committing.
|
|
107
|
+
- It is interrupted after 30 seconds, so caller code cannot wedge journal
|
|
108
|
+
admission indefinitely.
|
|
109
|
+
- A failed or refused attempt, whether a live stream behind the boundary or a
|
|
110
|
+
`capture` failure, is logged at warning, damped for `entryThreshold` further
|
|
111
|
+
committed entries while the run's policy counter is retained, and never
|
|
112
|
+
surfaced to the emit that triggered it.
|
|
113
|
+
|
|
114
|
+
`flush` waits for queued writes and registered automatic maintenance attempts.
|
|
115
|
+
The attempt is registered before its triggering batch settles, so flush cannot
|
|
116
|
+
miss it. Scope closure flushes before interrupting the maintenance scope.
|
|
117
|
+
Durable emits still await their own policy attempt after commit.
|
|
118
|
+
|
|
119
|
+
Flush also retires per-run barriers and policy counters when there are no
|
|
120
|
+
admissions, writes, queued entries, barrier waiters, live readers, or maintenance
|
|
121
|
+
using the run. A later commit seeds its policy counter from durable history
|
|
122
|
+
again, including after a previously damped failure. Sequence allocation floors
|
|
123
|
+
remain in memory: dropped entries and rolled-back transactions can consume
|
|
124
|
+
sequences that cannot be recovered from durable rows. Retirement does not
|
|
125
|
+
permit those sequences to be reused.
|
|
126
|
+
|
|
127
|
+
## Retries are idempotent
|
|
128
|
+
|
|
129
|
+
A `compact` whose checkpoint is already the floor returns `deleted: 0` rather
|
|
130
|
+
than failing. The rows below it are already gone.
|
|
131
|
+
|
|
132
|
+
## Producer identities survive compaction
|
|
133
|
+
|
|
134
|
+
Compaction retains each deleted event's producer identity, event ID, original
|
|
135
|
+
canonical sequence, and SHA-256 fingerprint of its encoded type, payload, and
|
|
136
|
+
metadata in `flows_journal_dedup`. These records commit in the same transaction
|
|
137
|
+
as event deletion and the floor advance. They contain no replay payload.
|
|
138
|
+
|
|
139
|
+
An exact producer retry returns `Duplicate` with the original sequence, even
|
|
140
|
+
after reopening the database. Changed content fails `idempotency_conflict`;
|
|
141
|
+
`dedupe: "identity"` keeps its identity-only behavior. Queued retries cannot
|
|
142
|
+
append another row or enter the checkpoint suffix. Owner fencing still applies.
|
|
143
|
+
The retained identities also preserve each producer's automatic `sourceSeq`
|
|
144
|
+
allocation floor when all its event rows have been compacted.
|
|
145
|
+
|
|
146
|
+
Identity records have no expiry and survive later compactions. Storage therefore
|
|
147
|
+
remains proportional to the number of compacted identities, while event payloads
|
|
148
|
+
can be reclaimed. Migration `0004_dedup` adds this retention for subsequent
|
|
149
|
+
compactions; identities deleted before the upgrade cannot be reconstructed.
|
|
150
|
+
|
|
151
|
+
## Related reading
|
|
152
|
+
|
|
153
|
+
- [Compact a long-running run](../guides/compact-a-run.md) is the task-shaped
|
|
154
|
+
version of this page.
|
|
155
|
+
- [Run a read-only follower](/docs/guides/sync-followers/) covers a follower
|
|
156
|
+
that meets the floor.
|
|
157
|
+
- [Retention](/docs/guides/retention/) covers what a deployment keeps overall.
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Producer identity and idempotency"
|
|
3
|
+
description: "What makes two emissions the same event, why the comparison runs on canonical redacted bytes, what the two dedupe modes mean, and why the in-process index is a cache rather than the authority."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 3
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A producer that retries must not double an entry. The journal answers that with
|
|
9
|
+
one identity and one comparison, both of which are persisted facts rather than
|
|
10
|
+
in-memory bookkeeping.
|
|
11
|
+
|
|
12
|
+
## The identity
|
|
13
|
+
|
|
14
|
+
`(runId, sourceId, sourceSeq)` is the producer identity. It is unique in the
|
|
15
|
+
database, and `JournalEvent.makeEventId` derives the durable `eventId` from it
|
|
16
|
+
deterministically, with length prefixes so an identifier containing the
|
|
17
|
+
separator cannot forge another tuple's id.
|
|
18
|
+
|
|
19
|
+
Deterministic is the point: retrying the same source event must produce the
|
|
20
|
+
same durable id, so the unique index can collapse the retry onto the committed
|
|
21
|
+
row instead of storing it twice.
|
|
22
|
+
|
|
23
|
+
## What makes two emissions the same event
|
|
24
|
+
|
|
25
|
+
Two emissions under one identity are the same event when their `eventType` and
|
|
26
|
+
their canonically encoded, redacted `payload` and `meta` match.
|
|
27
|
+
|
|
28
|
+
Canonical encoding is `JSON.stringify` semantics with object keys sorted, so
|
|
29
|
+
key order does not change the answer. A `Date` is its ISO string, an
|
|
30
|
+
`undefined` member is dropped, and `NaN` is `null`. The sort runs over the
|
|
31
|
+
encoded JSON rather than over the raw value, so canonicalization cannot destroy
|
|
32
|
+
anything the encoder would have kept.
|
|
33
|
+
|
|
34
|
+
Two consequences follow from comparing the persisted, redacted bytes, and both
|
|
35
|
+
are deliberate:
|
|
36
|
+
|
|
37
|
+
- Two different secrets that redact to the same placeholder are the same event
|
|
38
|
+
to the journal. Comparing the pre-redaction value instead would keep
|
|
39
|
+
unredacted secrets resident in the in-process index, which is the leak this
|
|
40
|
+
package exists to prevent.
|
|
41
|
+
- `NaN` and `null` are the same value, because JSON says so.
|
|
42
|
+
|
|
43
|
+
## The two dedupe modes
|
|
44
|
+
|
|
45
|
+
`Input.dedupe` states what a re-emitted identity means. It defaults to
|
|
46
|
+
`content`.
|
|
47
|
+
|
|
48
|
+
`content` is the strict reading: the identity names one set of bytes, so a
|
|
49
|
+
producer that re-emits it with different bytes has a bug, and the journal says
|
|
50
|
+
so with `idempotency_conflict`.
|
|
51
|
+
|
|
52
|
+
`identity` is for a producer that derives the sequence from the event itself.
|
|
53
|
+
There a collision is the same event observed twice, and the bytes that differ
|
|
54
|
+
between the two observations are metadata about the observation rather than the
|
|
55
|
+
event: whether a replayed frame was re-recorded, how long a step took the
|
|
56
|
+
second time a durable engine served it from its record. The first admitted row
|
|
57
|
+
stands and the re-emission settles as `Duplicate`.
|
|
58
|
+
|
|
59
|
+
Declare `identity` only with a sequence derived from the event's own content.
|
|
60
|
+
Otherwise the journal has nothing left with which to notice two different
|
|
61
|
+
events wearing one identity.
|
|
62
|
+
|
|
63
|
+
## The index is a cache, not the authority
|
|
64
|
+
|
|
65
|
+
`SqlJournalOptions.sourceEventCache` (default 4096) bounds the in-process index
|
|
66
|
+
that answers producer idempotency from memory. The database unique constraint
|
|
67
|
+
on `(run_id, source_id, source_seq)` stays authoritative, so a miss changes the
|
|
68
|
+
receipt, not the durable answer.
|
|
69
|
+
|
|
70
|
+
On a miss for an explicit producer sequence, the entry is admitted
|
|
71
|
+
optimistically without a SQL deduplication lookup:
|
|
72
|
+
|
|
73
|
+
- `emitLossy` returns `Accepted` where a resident entry would have returned
|
|
74
|
+
`Duplicate`.
|
|
75
|
+
- The insert collapses onto the committed row through the unique index rather
|
|
76
|
+
than doubling it.
|
|
77
|
+
- A changed retry behind an evicted entry surfaces its `idempotency_conflict`
|
|
78
|
+
from `flush` instead of from the emit.
|
|
79
|
+
|
|
80
|
+
The deduplication lookup is deliberately deferred to the writer. Allocation
|
|
81
|
+
can still read before admission: a cold run needs `MAX(seq) + 1`, and an
|
|
82
|
+
omitted `sourceSeq` with an uncached producer floor also needs
|
|
83
|
+
`MAX(source_seq) + 1` for `(runId, sourceId)`. Only warmed allocation floors
|
|
84
|
+
make admission read-free; an explicit producer sequence avoids only the
|
|
85
|
+
producer-floor read.
|
|
86
|
+
|
|
87
|
+
These reads use the caller's SQL transaction context when the client is shared.
|
|
88
|
+
A caller holding the connection in another transaction without propagating
|
|
89
|
+
that context can deadlock on a cold admission. See
|
|
90
|
+
[the two channels](./two-channels.md) for supported transaction compositions
|
|
91
|
+
and the separate commit of queued lossy entries.
|
|
92
|
+
|
|
93
|
+
Bounding the index also bounds startup. Only the most recent
|
|
94
|
+
`sourceEventCache` events are decoded when the layer is built, so resident
|
|
95
|
+
memory and startup cost are proportional to the bound rather than to the total
|
|
96
|
+
history ever written.
|
|
97
|
+
|
|
98
|
+
## Identifiers are the persisted key
|
|
99
|
+
|
|
100
|
+
`RunId`, `SourceId`, and both an `Input`'s and an `Entry`'s `eventType` are
|
|
101
|
+
non-empty, at most 1,024 UTF-16 code units, free of unpaired UTF-16 surrogates,
|
|
102
|
+
and free of NUL. Each rule closes a way for the identifier the caller decoded
|
|
103
|
+
and the identifier the database stores to stop being the same value:
|
|
104
|
+
|
|
105
|
+
- SQLite binds a lone surrogate as U+FFFD, so two ill-formed identifiers that
|
|
106
|
+
differ only in their surrogates land on one persisted key. The second run's
|
|
107
|
+
first event would dedupe into the first run's row, and a read by either id
|
|
108
|
+
would return the same history.
|
|
109
|
+
- SQLite's `length()` stops at the first NUL, so a NUL-bearing identifier fails
|
|
110
|
+
the column's own non-empty check and the caller is told the sink failed about
|
|
111
|
+
an identifier it had just supplied.
|
|
112
|
+
- The length bound exists because every identifier occupies permanent index
|
|
113
|
+
space and layer-lifetime heap.
|
|
114
|
+
|
|
115
|
+
Valid astral text is ordinary text and round-trips exactly. `entries`,
|
|
116
|
+
`stream`, `checkpoint`, `latestCheckpoint`, and `compact` decode the same
|
|
117
|
+
schemas, so an identifier the writer refuses is refused on every read with
|
|
118
|
+
`invalid_event` rather than answered with an empty page.
|
|
119
|
+
|
|
120
|
+
## Reading a receipt
|
|
121
|
+
|
|
122
|
+
`Duplicate` carries a `status`, which is the one piece of information the tag
|
|
123
|
+
alone does not give you:
|
|
124
|
+
|
|
125
|
+
- `pending` means the original event is in the lossy queue and not yet
|
|
126
|
+
committed.
|
|
127
|
+
- `committed` means the original is already durable.
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "The owner fence"
|
|
3
|
+
description: "Why the durable channel takes an OwnerId, what makes a write fail with fence_lost, and when the unfenced channel is the correct one."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 2
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A run can outlive the process that claimed it. A machine pauses, a supervisor
|
|
9
|
+
declares the run abandoned, another process takes it over, and then the first
|
|
10
|
+
process wakes up holding a half-finished lifecycle write. Without a fence, that
|
|
11
|
+
zombie write lands behind the live successor and the run's history says two
|
|
12
|
+
processes were driving it.
|
|
13
|
+
|
|
14
|
+
The journal refuses that write. `emitDurable`, `checkpoint`, and `compact` each
|
|
15
|
+
take an `OwnerId`, and each lands only while the consensus strategy the
|
|
16
|
+
journal was built with still records that owner as holding the run.
|
|
17
|
+
|
|
18
|
+
## The token
|
|
19
|
+
|
|
20
|
+
`OwnerId.OwnerId` is three fields:
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import type * as OwnerId from "@smthrs/journal/OwnerId"
|
|
24
|
+
|
|
25
|
+
const owner: OwnerId.OwnerId = {
|
|
26
|
+
hostId: "host-1",
|
|
27
|
+
pid: process.pid,
|
|
28
|
+
nonce: "run-1-claim"
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`hostId` and `nonce` are plain strings. `pid` is a real operating-system
|
|
33
|
+
process id, so the schema states that: a non-negative integer.
|
|
34
|
+
|
|
35
|
+
The token lives in this package rather than with the ownership arbitration in
|
|
36
|
+
[`@smthrs/run-store`](/api/run-store) because the journal is what it fences.
|
|
37
|
+
The run store stores the token on runs and decides who holds it, and its
|
|
38
|
+
`Ownership` module re-exports `OwnerId` alongside that arbitration.
|
|
39
|
+
|
|
40
|
+
## What the fence actually checks
|
|
41
|
+
|
|
42
|
+
A fenced write joins the injected `Consensus` strategy's `guard` inside its
|
|
43
|
+
own write transaction. The guard succeeds while the strategy records the
|
|
44
|
+
supplied token as the run's owner and fails `fence_lost` otherwise, and
|
|
45
|
+
because the durable writer serializes write transactions no reclaim can
|
|
46
|
+
commit between the guard and the statements beside it.
|
|
47
|
+
|
|
48
|
+
Two consequences follow:
|
|
49
|
+
|
|
50
|
+
- A run that another process reclaimed fails the write with `fence_lost`.
|
|
51
|
+
- A run whose owner released it, because it suspended or finished, fails the
|
|
52
|
+
same way. A finished run does not accept late lifecycle writes.
|
|
53
|
+
|
|
54
|
+
The strategy is the journal's own service. `SqlJournal.layer` fences through
|
|
55
|
+
`SqlConsensus`, whose lease lives in `flows_consensus_leases`, a table this
|
|
56
|
+
package's migrations create; `SqlJournal.layerWith` fences through whatever
|
|
57
|
+
strategy the composition provides, such as `Consensus.layerLocal` for a
|
|
58
|
+
single process. The same instance must arbitrate the run store's claims, which
|
|
59
|
+
is why `RunStore.layerWith` takes the strategy too.
|
|
60
|
+
|
|
61
|
+
## Who arbitrates
|
|
62
|
+
|
|
63
|
+
The journal owns the rules: one writer per run, fenced and unfenced appends,
|
|
64
|
+
commit-time admission, the generation fence, steal only on staleness plus
|
|
65
|
+
liveness evidence, and ownership transitions as events. A strategy chooses
|
|
66
|
+
where the lease lives and how the fence is checked. The claim lifecycle is
|
|
67
|
+
two-phase — `claim` or `steal` reserves the run and returns a grant
|
|
68
|
+
timestamp, `activate` presents that grant to take ownership, `release` gives
|
|
69
|
+
it back, and `recover` clears a dead claimant's stale claim — and
|
|
70
|
+
[`@smthrs/run-store`](/api/run-store) drives it for a durable run, mirroring
|
|
71
|
+
the outcome on the run row in the same transaction.
|
|
72
|
+
|
|
73
|
+
The run store appends every transition the strategy grants — `claimed`,
|
|
74
|
+
`activated`, `released`, `stolen`, `expired` — as a `flows.consensus.*` fact on
|
|
75
|
+
the run's `run-store` companion stream (`JournalEvent.companionRunId`), in the
|
|
76
|
+
transaction that made it. The facts live beside the run's own stream, so a
|
|
77
|
+
consumer reading that stream by position never sees one. Heartbeats renew the
|
|
78
|
+
lease and never enter the journal.
|
|
79
|
+
|
|
80
|
+
## Reconfirm after a host stall
|
|
81
|
+
|
|
82
|
+
`Consensus.reconfirm(runId, owner, nowMs)` returns `Renewed` only while the
|
|
83
|
+
same owner holds the lease and no claim is pending. Its compare-and-swap
|
|
84
|
+
renews the heartbeat monotonically. A missing lease, foreign owner, or
|
|
85
|
+
pending claim returns `Lost` without modifying the lease.
|
|
86
|
+
|
|
87
|
+
`SqlConsensus` performs that check in one update; `layerLocal` performs it
|
|
88
|
+
atomically in memory. If reconfirm wins a race with `steal`, the refreshed
|
|
89
|
+
heartbeat prevents the steal. If steal wins, its pending claim prevents
|
|
90
|
+
reconfirm even before the new owner activates. Reconfirm preserves the
|
|
91
|
+
owner token and generation. The engine records a `lease-reconfirmed`
|
|
92
|
+
decision when supervision uses it to recover from an unconfirmed interval.
|
|
93
|
+
|
|
94
|
+
## A bad token is not a lost fence
|
|
95
|
+
|
|
96
|
+
An owner that is missing, null, or not an `OwnerId` at all fails
|
|
97
|
+
`invalid_event`. That is a caller contract violation, and reporting it as
|
|
98
|
+
`fence_lost` would send the caller hunting a race that never happened. A
|
|
99
|
+
fractional or negative `pid` is the common case.
|
|
100
|
+
|
|
101
|
+
Read the two codes as different questions:
|
|
102
|
+
|
|
103
|
+
- `fence_lost` asks who owns this run now. Stop writing.
|
|
104
|
+
- `invalid_event` asks what you passed. Fix the argument.
|
|
105
|
+
|
|
106
|
+
## The unfenced escape
|
|
107
|
+
|
|
108
|
+
`emitDurableUnfenced` is the same durability and the same receipt contract with
|
|
109
|
+
no fence. It exists for admissions that are genuinely ownerless, where
|
|
110
|
+
first-writer-wins is the design rather than an accident. The canonical case is
|
|
111
|
+
an external trigger: a deferred completion or a clock-schedule record delivered
|
|
112
|
+
by a sweeper that owns no run, where the producer dedup index rather than the
|
|
113
|
+
fence is the idempotency mechanism.
|
|
114
|
+
|
|
115
|
+
A caller that holds an `OwnerId` uses `emitDurable`. Reaching for the unfenced
|
|
116
|
+
channel to get past a `fence_lost` writes exactly the zombie entry the fence
|
|
117
|
+
exists to reject.
|
|
118
|
+
|
|
119
|
+
## Related reading
|
|
120
|
+
|
|
121
|
+
- [Write a fenced lifecycle event](../guides/write-lifecycle-events.md) is the
|
|
122
|
+
task-shaped version of this page.
|
|
123
|
+
- [Execution IDs and ownership](/docs/concepts/ownership/) covers how a run
|
|
124
|
+
acquires and loses the token in the first place.
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Redaction"
|
|
3
|
+
description: "Why the journal scrubs payloads on the write path, what the default rules catch, where redaction deliberately stops, and the bounds that keep the scrub safe on hostile input."
|
|
4
|
+
sidebar:
|
|
5
|
+
order: 4
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
A journal row is permanent and broadly readable. Every entry is replayed
|
|
9
|
+
verbatim to sync followers, to time-travel consumers, and into a support
|
|
10
|
+
bundle. A credential that reaches `payload_json` is therefore not a transient
|
|
11
|
+
leak, and no reader can undo it.
|
|
12
|
+
|
|
13
|
+
So the journal redacts once, on the write path. Every write funnels through one
|
|
14
|
+
preparation step, and that step scrubs `payload` and `meta` before they are
|
|
15
|
+
encoded. No channel can persist a credential.
|
|
16
|
+
|
|
17
|
+
## What the default rules catch
|
|
18
|
+
|
|
19
|
+
Redaction has two halves.
|
|
20
|
+
|
|
21
|
+
**Structural.** A field whose name reads as a credential has its value replaced
|
|
22
|
+
wholesale, whatever the value is: `authorization`, `cookie`, `apiKey`, `token`,
|
|
23
|
+
`password`, `passphrase`, `secret`, `secretAccessKey`, `credential`, `dsn`,
|
|
24
|
+
`connectionString`, `privateKey`, `sshKey`, `session`, `signature`, `auth`, and
|
|
25
|
+
the rest of that family, plus a trailing `key` that is a word of its own (`key`,
|
|
26
|
+
`api_key`, `x-api-key`). `Redaction.isSensitiveKey` is the predicate, and
|
|
27
|
+
`Redaction.placeholder` is the string that lands, `"[REDACTED]"`.
|
|
28
|
+
|
|
29
|
+
The match is a suffix test on the separator-free lowercase form of the name,
|
|
30
|
+
not a substring test, so `tokenizer`, `secretary`, `monkey`, and
|
|
31
|
+
`idempotencyKey` stay intact. Redacting them would destroy data in a permanent
|
|
32
|
+
row without protecting anything, and `idempotencyKey` in particular is the
|
|
33
|
+
durable identity an effect boundary replays on. Token counters are kept too: a
|
|
34
|
+
non-negative safe integer under `inputTokens`, `outputTokens`,
|
|
35
|
+
`cachedInputTokens`, `reasoningTokens`, or `totalTokens` is accounting, not
|
|
36
|
+
bearer material.
|
|
37
|
+
|
|
38
|
+
**Textual.** `Redaction.defaultRules` scans inside any string for credential
|
|
39
|
+
shapes seen in real bug reports: provider keys, bearer tokens, GitHub, AWS,
|
|
40
|
+
Slack, and Google credentials, URL passwords, Basic authorization, bare JWTs,
|
|
41
|
+
PEM private-key blocks, INI/YAML credential assignments such as
|
|
42
|
+
`SECRET = value` and `password: value`, credential flags such as
|
|
43
|
+
`--password value`, cookie and session assignments, and embedded JSON
|
|
44
|
+
credential members.
|
|
45
|
+
|
|
46
|
+
An assignment's value is read by a scanner rather than a regular expression,
|
|
47
|
+
because where a value ends depends on quotes, escapes, and brackets. Each shape
|
|
48
|
+
below once leaked the part a simpler rule stopped short of:
|
|
49
|
+
|
|
50
|
+
| Shape | Example |
|
|
51
|
+
| ------------------------------------------ | ------------------------------------------------- |
|
|
52
|
+
| Quoted, in any quote `util.inspect` writes | `` password: `it's "a b"` `` |
|
|
53
|
+
| Split by `util.inspect` over several lines | `privateKey: '…\n' +` then `'…'` |
|
|
54
|
+
| Escaped JSON, at any depth | `\"password\":\"a b\"` |
|
|
55
|
+
| A buffer, a cycle, a null prototype | `token: <Buffer 73 65>`, `apiKey: <ref *1> { … }` |
|
|
56
|
+
| A container, brackets in its strings too | `apiKey: { note: '} ', v: '…' }` |
|
|
57
|
+
| A `,` or `;` inside the value | `DB_PASSWORD=Zq7;Syn,thetic` |
|
|
58
|
+
|
|
59
|
+
A quoted value keeps its quotes around the placeholder
|
|
60
|
+
(`PASSWORD="[REDACTED]"`). A name ending in `key` counts only where
|
|
61
|
+
`isSensitiveKey` agrees, so `sortKey` and `idempotencyKey` keep their values,
|
|
62
|
+
and a count under a plural `tokens` name (`max_tokens: 4096`) is kept.
|
|
63
|
+
A credential name that is a URL's user
|
|
64
|
+
(`https://x-access-token:…@github.com/acme/app.git`) takes only the password
|
|
65
|
+
before `@`, so the host and path a failed clone is diagnosed by are kept.
|
|
66
|
+
|
|
67
|
+
In a journal row a bare value is one token, and a string or container the text
|
|
68
|
+
never closes stops at the end of its line: a stray backtick in an agent's
|
|
69
|
+
Markdown must not erase the rest of a permanent row. A PEM or PGP private-key
|
|
70
|
+
block with no footer is redacted to the next header or to the end of the text.
|
|
71
|
+
In an argv array, the element after a credential flag is redacted.
|
|
72
|
+
|
|
73
|
+
The rule set is a best-effort net, not a proof. A value that must never persist
|
|
74
|
+
belongs in a `Redacted` field of the caller's own schema, where the type
|
|
75
|
+
system, rather than a name-suffix guess made at the storage seam, keeps it out.
|
|
76
|
+
|
|
77
|
+
Objects and arrays are rebuilt, a cycle collapses to `"[Circular]"` so the
|
|
78
|
+
result always encodes, and a number, a boolean, `null`, or `undefined` is
|
|
79
|
+
returned untouched. The rules are re-applied to a string until it stops
|
|
80
|
+
changing, so replaying or exporting an entry cannot mutate it again; a string
|
|
81
|
+
that has not settled after eight passes is replaced whole.
|
|
82
|
+
|
|
83
|
+
## Diagnostics
|
|
84
|
+
|
|
85
|
+
A log line, a stderr message, and an error handed to a remote caller are read
|
|
86
|
+
once, so a false positive there costs a word of context rather than data in a
|
|
87
|
+
permanent row. `Redaction.diagnosticRules` reads further than the journal: a
|
|
88
|
+
bare value runs over several words to the end of its line
|
|
89
|
+
(`password=correct horse battery`), an unclosed string or container runs to the
|
|
90
|
+
end of the text (a value cut by a bound), and a value may sit on the line after
|
|
91
|
+
its name. It adds an authorization or cookie header in any scheme, written as
|
|
92
|
+
`name: value` or as an array pair (`[['Authorization', 'Token …']]`). Whitespace
|
|
93
|
+
`Authorization Token value` and `Proxy-Authorization Token value` forms also
|
|
94
|
+
match, with Token, Bearer, or Basic schemes and the value on the scheme's line.
|
|
95
|
+
It adds bare URL userinfo, a signed query parameter, `curl -u user:pass` (or `-uuser:pass`),
|
|
96
|
+
`zip -P`, `redis-cli -a`, and a `-p` flag's value, a password to `mysql` and
|
|
97
|
+
`sshpass`. A path after `-p` and the single-dash words that are not passwords
|
|
98
|
+
(`-print`, `-pthread`) are kept.
|
|
99
|
+
`Redaction.redactDiagnostic` applies them, and every diagnostic path in
|
|
100
|
+
Smithers shares it: `RedactedLogger`, the engine's failure rendering, and the
|
|
101
|
+
CLI's error output.
|
|
102
|
+
|
|
103
|
+
Redact a diagnostic before rendering or bounding it. Redacting a rendering line
|
|
104
|
+
by line, or after a bound, is how a key's body escaped. A stream that must be
|
|
105
|
+
emitted line by line uses `Redaction.lineRedactor()`: it holds lines while the
|
|
106
|
+
rules say a value is still open and redacts the block whole once it closes. A
|
|
107
|
+
value still open after `Redaction.maxHeldLines` lines or 64 KiB withholds the
|
|
108
|
+
rest of the stream, and `flush` emits one placeholder for it.
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
import { Redaction } from "@smthrs/journal"
|
|
112
|
+
|
|
113
|
+
const redactor = Redaction.lineRedactor()
|
|
114
|
+
const emit = (line: string) => redactor.line(line).forEach((safe) => process.stderr.write(`${safe}\n`))
|
|
115
|
+
// at the end of the stream
|
|
116
|
+
redactor.flush().forEach((safe) => process.stderr.write(`${safe}\n`))
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
A custom rule whose extent is not regular sets `rewrite: (text) => string`
|
|
120
|
+
instead of `replace`; `pattern` then names what it looks for.
|
|
121
|
+
|
|
122
|
+
## Where redaction stops
|
|
123
|
+
|
|
124
|
+
Redaction is an observability concern, and it is confined to journal events and
|
|
125
|
+
to display surfaces.
|
|
126
|
+
|
|
127
|
+
It deliberately does not apply to executable state: `flows_runs.state_json`,
|
|
128
|
+
attempt checkpoints, errors, outcomes, cache results, and a journal
|
|
129
|
+
`Checkpoint`'s own `state`. Those values are decoded and re-entered on resume.
|
|
130
|
+
A placeholder there resumes the flow with the wrong data, and replacing a
|
|
131
|
+
non-string value with a placeholder string makes the persisted state fail
|
|
132
|
+
schema decode outright, which leaves the run undrivable.
|
|
133
|
+
|
|
134
|
+
That is why [`@smthrs/run-store`](/api/run-store) and
|
|
135
|
+
[`@smthrs/step-cache`](/api/step-cache) take no `redact` option at all.
|
|
136
|
+
|
|
137
|
+
## Choosing a redactor
|
|
138
|
+
|
|
139
|
+
`SqlJournalOptions.redact` defaults to `Redaction.make()`. Two other choices
|
|
140
|
+
are supported:
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
import { Redaction, SqlJournal } from "@smthrs/journal"
|
|
144
|
+
|
|
145
|
+
const verbatim = SqlJournal.layer({
|
|
146
|
+
capacity: 1024,
|
|
147
|
+
overflow: "reject",
|
|
148
|
+
redact: Redaction.makeNoop()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
const extended = SqlJournal.layer({
|
|
152
|
+
capacity: 1024,
|
|
153
|
+
overflow: "reject",
|
|
154
|
+
redact: Redaction.make({
|
|
155
|
+
rules: [...Redaction.defaultRules, { id: "internal-ticket", pattern: /\bTKT-\d{6}\b/g }]
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`Redaction.makeNoop()` persists payloads verbatim by choice, for a trusted
|
|
161
|
+
single-tenant store or a suite asserting on raw input. A custom rule set
|
|
162
|
+
replaces the defaults, so spread `Redaction.defaultRules` when you mean to add
|
|
163
|
+
to them rather than to swap them out. A non-global custom pattern is normalized
|
|
164
|
+
to global once, and a sticky one keeps its stickiness.
|
|
165
|
+
|
|
166
|
+
## Bounds on the scrub
|
|
167
|
+
|
|
168
|
+
Redaction runs on every write, so it is bounded against hostile input rather
|
|
169
|
+
than trusted to terminate:
|
|
170
|
+
|
|
171
|
+
- **Depth.** The walk traverses at most `Redaction.maxDepth` (256) container
|
|
172
|
+
edges and fails a deeper payload as `invalid_event` rather than overflowing
|
|
173
|
+
the stack. The canonical encoder carries the same ceiling, so a value that
|
|
174
|
+
reaches it through `Redaction.makeNoop()` is refused the same way.
|
|
175
|
+
- **Binary views.** Enumerating a typed array's own properties materializes one
|
|
176
|
+
pair per byte, so a view over `Redaction.binaryWalkLimit` (65,536) bytes, or
|
|
177
|
+
one carrying that many own members, is named rather than walked. The size is
|
|
178
|
+
read from the value's own internal slot, where nothing a caller writes can
|
|
179
|
+
answer for it.
|
|
180
|
+
- **Rule cost.** Every alternation inside a repetition starts its branches
|
|
181
|
+
with disjoint characters, so each rule is linear in the length of the value
|
|
182
|
+
and no input backtracks catastrophically.
|
|
183
|
+
|
|
184
|
+
A redaction that throws, from the redactor itself or from a getter or proxy
|
|
185
|
+
trap on the value, fails the write as `invalid_event` with no `cause`. The
|
|
186
|
+
thrown value can quote the unredacted input, so the error carries only which
|
|
187
|
+
field, `payload` or `meta`, could not be redacted.
|
|
188
|
+
|
|
189
|
+
Values a JSON row cannot hold are named rather than dropped:
|
|
190
|
+
`Redaction.binaryMarker`, `Redaction.functionMarker`,
|
|
191
|
+
`Redaction.symbolMarker`, and `Redaction.depthMarker`.
|
|
192
|
+
|
|
193
|
+
## Rendering a stored column
|
|
194
|
+
|
|
195
|
+
`Redaction.redactJsonString` scrubs an already-encoded JSON string at a display
|
|
196
|
+
surface, leaving the durable row untouched. Use it for a rendered `state_json`
|
|
197
|
+
or a support bundle, where the value is already encoded and must not be decoded
|
|
198
|
+
back into the executable path.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { Redaction } from "@smthrs/journal"
|
|
202
|
+
|
|
203
|
+
const redactor = Redaction.make()
|
|
204
|
+
const forDisplay = (stateJson: string): string => Redaction.redactJsonString(stateJson, redactor)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
A string that does not parse is returned untouched: validation belongs to the
|
|
208
|
+
caller, and rejecting here would turn a redaction concern into a schema error.
|
|
209
|
+
Once parsing succeeds the function fails closed, returning the JSON string
|
|
210
|
+
`"[REDACTED]"` rather than the original text, if the redactor throws or the
|
|
211
|
+
result cannot be re-encoded.
|
|
212
|
+
|
|
213
|
+
## The other half: log output
|
|
214
|
+
|
|
215
|
+
The journal closes the durable half. The operator's terminal is the other place
|
|
216
|
+
the same credential surfaces, and `RedactedLogger` closes it with the same
|
|
217
|
+
rules. See
|
|
218
|
+
[Keep credentials out of log output](../guides/redact-log-output.md).
|