igniter-ledger 0.5.2
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.
- checksums.yaml +7 -0
- data/README.md +481 -0
- data/examples/intelligent_ledger/availability_boundary_ledger.rb +1190 -0
- data/examples/intelligent_ledger/availability_deriver.rb +150 -0
- data/examples/intelligent_ledger/availability_ledger.rb +197 -0
- data/examples/intelligent_ledger/ledger_boundary.rb +180 -0
- data/examples/store_poc.rb +45 -0
- data/exe/igniter-ledger-server +111 -0
- data/exe/igniter-store-server +6 -0
- data/ext/igniter_store_native/Cargo.toml +28 -0
- data/ext/igniter_store_native/extconf.rb +6 -0
- data/ext/igniter_store_native/src/fact.rs +303 -0
- data/ext/igniter_store_native/src/fact_log.rs +180 -0
- data/ext/igniter_store_native/src/file_backend.rs +91 -0
- data/ext/igniter_store_native/src/lib.rs +55 -0
- data/lib/igniter/ledger.rb +7 -0
- data/lib/igniter/store/access_path.rb +84 -0
- data/lib/igniter/store/change_event.rb +65 -0
- data/lib/igniter/store/changefeed_buffer.rb +585 -0
- data/lib/igniter/store/codecs.rb +253 -0
- data/lib/igniter/store/contractable_receipt_sink.rb +172 -0
- data/lib/igniter/store/fact.rb +121 -0
- data/lib/igniter/store/fact_log.rb +103 -0
- data/lib/igniter/store/file_backend.rb +269 -0
- data/lib/igniter/store/http_adapter.rb +413 -0
- data/lib/igniter/store/igniter_store.rb +838 -0
- data/lib/igniter/store/mcp_adapter.rb +403 -0
- data/lib/igniter/store/native.rb +80 -0
- data/lib/igniter/store/network_backend.rb +159 -0
- data/lib/igniter/store/protocol/handlers/access_path_handler.rb +38 -0
- data/lib/igniter/store/protocol/handlers/command_handler.rb +59 -0
- data/lib/igniter/store/protocol/handlers/derivation_handler.rb +27 -0
- data/lib/igniter/store/protocol/handlers/effect_handler.rb +65 -0
- data/lib/igniter/store/protocol/handlers/history_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/projection_handler.rb +41 -0
- data/lib/igniter/store/protocol/handlers/relation_handler.rb +43 -0
- data/lib/igniter/store/protocol/handlers/store_handler.rb +24 -0
- data/lib/igniter/store/protocol/handlers/subscription_handler.rb +24 -0
- data/lib/igniter/store/protocol/interpreter.rb +447 -0
- data/lib/igniter/store/protocol/receipt.rb +96 -0
- data/lib/igniter/store/protocol/sync_profile.rb +53 -0
- data/lib/igniter/store/protocol/wire_envelope.rb +214 -0
- data/lib/igniter/store/protocol.rb +27 -0
- data/lib/igniter/store/read_cache.rb +163 -0
- data/lib/igniter/store/schema_graph.rb +248 -0
- data/lib/igniter/store/segmented_file_backend.rb +699 -0
- data/lib/igniter/store/server_config.rb +55 -0
- data/lib/igniter/store/server_logger.rb +64 -0
- data/lib/igniter/store/server_metrics.rb +222 -0
- data/lib/igniter/store/store_server.rb +597 -0
- data/lib/igniter/store/subscription_registry.rb +73 -0
- data/lib/igniter/store/tbackend_adapter_descriptor.rb +307 -0
- data/lib/igniter/store/tcp_adapter.rb +127 -0
- data/lib/igniter/store/wire_protocol.rb +42 -0
- data/lib/igniter/store.rb +64 -0
- data/lib/igniter-ledger.rb +4 -0
- data/lib/igniter-store.rb +5 -0
- metadata +212 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 15baa7aa1060cade9f02f3265551529d8b35bc36de43587d454ab0f2eceaee93
|
|
4
|
+
data.tar.gz: cdd1f1b480166e014417473d28aba24ad019d33217cf047761eae6bfde52faa1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 5f8d61e119598a18e148a4d876965bee268e86d1627c6d178e0a8200234a8adfcab13cf7c6a32f836e7898f71be907a5a9d5bfa1b62d0a00ca13c0fc5cc3f3cb
|
|
7
|
+
data.tar.gz: 4d9fdaad822758f15f3367574ba316d472c53410e9dc8c1ec56ae6c063236eb4c736b9a7e04461dbdd0ea194ab5f3d8335f09177f76c0a381efe3efe03b32733
|
data/README.md
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
# igniter-ledger
|
|
2
|
+
|
|
3
|
+
Pre-v1 Ledger substrate for Igniter facts, histories, receipts, replay, and
|
|
4
|
+
protocol-facing storage surfaces.
|
|
5
|
+
|
|
6
|
+
Status: active platform lane, still POC/pre-v1. APIs, storage formats, and
|
|
7
|
+
transport contracts may change before v1.
|
|
8
|
+
|
|
9
|
+
## Compatibility Note
|
|
10
|
+
|
|
11
|
+
New code should use `igniter-ledger`, `require "igniter-ledger"`,
|
|
12
|
+
`Igniter::Ledger::LedgerStore`, and `igniter-ledger-server`.
|
|
13
|
+
|
|
14
|
+
This package was previously exposed as `igniter-store`. During the pre-v1 rename
|
|
15
|
+
window, compatibility shims remain for `require "igniter-store"`,
|
|
16
|
+
`igniter-store-server`, and the `Igniter::Store` constants. The internal Ruby
|
|
17
|
+
namespace and file path still use `Igniter::Store` / `lib/igniter/store/**`;
|
|
18
|
+
treat that as implementation structure until a later deep-rename track.
|
|
19
|
+
|
|
20
|
+
## Purpose
|
|
21
|
+
|
|
22
|
+
`igniter-ledger` is broader than persistence. It is the hot fact engine behind
|
|
23
|
+
Ledger-backed companion systems:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
write/append fact
|
|
27
|
+
-> immutable fact log
|
|
28
|
+
-> current and time-travel reads
|
|
29
|
+
-> indexes, access paths, relations, projections
|
|
30
|
+
-> changefeed / replay / receipts
|
|
31
|
+
-> compaction activity and LedgerBoundary proofs
|
|
32
|
+
-> Ledger Open Protocol / LedgerServer / MCP / SSE reads
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
The package intentionally sits below the application-facing `Record` /
|
|
36
|
+
`History` facade in `igniter-durable-model`. App code should usually begin
|
|
37
|
+
there; this package owns the fact substrate, protocol, and operational storage
|
|
38
|
+
model.
|
|
39
|
+
|
|
40
|
+
## Current Surface
|
|
41
|
+
|
|
42
|
+
- immutable content-addressed facts with stable `id` and value hash
|
|
43
|
+
- record-like `Store[T]` and append-only `History[T]` semantics
|
|
44
|
+
- fact-id causation chains for unambiguous temporal history
|
|
45
|
+
- transaction time, valid time, producer, and derivation metadata
|
|
46
|
+
- current reads, time-travel reads, and replay windows
|
|
47
|
+
- scope access paths, relation rules, projection descriptors, derivation rules,
|
|
48
|
+
scatter rules, and metadata snapshots
|
|
49
|
+
- CRC32-framed WAL, snapshot checkpoint/replay, segmented storage hardening, and
|
|
50
|
+
durability policy work
|
|
51
|
+
- retention, compaction lifecycle, prune/purge executors, compaction activity,
|
|
52
|
+
and LedgerBoundary cleanup/provenance/redirect proofs
|
|
53
|
+
- bounded changefeed with replay cursors, SSE `/v1/events`, async fan-out,
|
|
54
|
+
delivery policy, diagnostics, and server config
|
|
55
|
+
- Ledger Open Protocol interpreter, wire envelope, LedgerServer, HTTP status,
|
|
56
|
+
MCP adapter surface, and sync/replay profiles
|
|
57
|
+
|
|
58
|
+
## Does Not Own
|
|
59
|
+
|
|
60
|
+
- public contract persistence DSL (`persist`, `history`) as a stable user API
|
|
61
|
+
- `Record` / `History` application ergonomics; that belongs to
|
|
62
|
+
`igniter-durable-model`
|
|
63
|
+
- SQL schema generation, ORM semantics, or migration execution
|
|
64
|
+
- arbitrary application workflows or side effects inside storage
|
|
65
|
+
- cluster consensus or deployment guarantees
|
|
66
|
+
- AI/agent authority decisions
|
|
67
|
+
|
|
68
|
+
## Docs
|
|
69
|
+
|
|
70
|
+
Start with:
|
|
71
|
+
|
|
72
|
+
- [docs/README.md](docs/README.md) — package documentation index
|
|
73
|
+
- [docs/progress.md](docs/progress.md) — compact current status
|
|
74
|
+
- [docs/pre-v1-core-model-proposal.md](docs/pre-v1-core-model-proposal.md) —
|
|
75
|
+
core fact model proposal before v1
|
|
76
|
+
- [docs/open-protocol.md](docs/open-protocol.md) — Ledger Open Protocol
|
|
77
|
+
- [docs/server-api-proposal.md](docs/server-api-proposal.md) — server/API layer
|
|
78
|
+
above the protocol
|
|
79
|
+
- [docs/intelligent-ledger/README.md](docs/intelligent-ledger/README.md) —
|
|
80
|
+
inference, derivation, routes, and boundary research horizon
|
|
81
|
+
- [docs/tracks/](docs/tracks/) — completed and active implementation slices
|
|
82
|
+
- [docs/research/](docs/research/) — older compressed iteration history
|
|
83
|
+
|
|
84
|
+
## Strategic Position
|
|
85
|
+
|
|
86
|
+
`igniter-ledger` began as `igniter-store`, a persistence proof. The model has
|
|
87
|
+
grown toward Ledger semantics: append-only facts, causation, receipts, replay,
|
|
88
|
+
boundaries, compaction, explainability, and protocol reads.
|
|
89
|
+
|
|
90
|
+
The likely product-language migration is:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
Store package name in older docs
|
|
94
|
+
-> igniter-ledger package
|
|
95
|
+
-> Store[T] / History[T] typed capability semantics
|
|
96
|
+
-> Durable Model Record/History app facade
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Do not collapse these layers into one object model. `persist` and `history` in
|
|
100
|
+
future contract DSL should remain sugar lowerable to Store/History capability
|
|
101
|
+
manifests.
|
|
102
|
+
|
|
103
|
+
## Example
|
|
104
|
+
|
|
105
|
+
```ruby
|
|
106
|
+
require "igniter-ledger"
|
|
107
|
+
|
|
108
|
+
store = Igniter::Ledger::LedgerStore.new
|
|
109
|
+
|
|
110
|
+
store.write(
|
|
111
|
+
store: :reminders,
|
|
112
|
+
key: "r1",
|
|
113
|
+
value: { title: "Buy milk", status: :open }
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
store.read(store: :reminders, key: "r1")
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Contractable Receipt Sink
|
|
120
|
+
|
|
121
|
+
`ContractableReceiptSink` is a durable store adapter for Embed contractable
|
|
122
|
+
observation/event receipts. Wire it as the `store:` option on any contractable:
|
|
123
|
+
|
|
124
|
+
```ruby
|
|
125
|
+
require "igniter-ledger"
|
|
126
|
+
|
|
127
|
+
sink = Igniter::Ledger::ContractableReceiptSink.new(
|
|
128
|
+
store: Igniter::Ledger::LedgerStore.new
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Pass as store adapter to any igniter-embed contractable:
|
|
132
|
+
runner = Igniter::Embed.contractable(:marketing_executor) do |config|
|
|
133
|
+
config.primary LegacyExecutor
|
|
134
|
+
config.candidate ContractExecutor
|
|
135
|
+
config.async false
|
|
136
|
+
config.store sink
|
|
137
|
+
config.normalize_primary ExecutorNormalizer
|
|
138
|
+
config.normalize_candidate ExecutorNormalizer
|
|
139
|
+
config.redact_inputs ->(**inputs) { inputs.slice(:request_id) }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
runner.call(request_id: "r1", provider_token: "secret")
|
|
143
|
+
|
|
144
|
+
# Query:
|
|
145
|
+
sink.observation("obs_abc123") # current state by id
|
|
146
|
+
sink.events_for("obs_abc123") # all events in commit order
|
|
147
|
+
sink.observations(status: :diverged, limit: 20) # recent diverged observations
|
|
148
|
+
sink.error_events(limit: 10) # recent error-severity events
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Registers `contractable_observations` (store) and `contractable_events`
|
|
152
|
+
(history) protocol descriptors on construction. Custom store names:
|
|
153
|
+
|
|
154
|
+
```ruby
|
|
155
|
+
sink = Igniter::Ledger::ContractableReceiptSink.new(
|
|
156
|
+
store: Igniter::Ledger::LedgerStore.new,
|
|
157
|
+
observations_store: :spark_observations,
|
|
158
|
+
events_store: :spark_events,
|
|
159
|
+
producer: { type: :embed, name: :spark_sink }
|
|
160
|
+
)
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
The sink can also use the protocol boundary through `igniter-ledger-client`
|
|
164
|
+
instead of depending on the embedded store API:
|
|
165
|
+
|
|
166
|
+
```ruby
|
|
167
|
+
require "igniter-ledger"
|
|
168
|
+
require "igniter-ledger-client"
|
|
169
|
+
|
|
170
|
+
ledger = Igniter::Ledger::LedgerStore.new
|
|
171
|
+
client = Igniter::LedgerClient.wrap(ledger.protocol)
|
|
172
|
+
|
|
173
|
+
sink = Igniter::Ledger::ContractableReceiptSink.new(client: client)
|
|
174
|
+
sink.record_observation(receipt)
|
|
175
|
+
sink.events_for("obs_abc123")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
This is the preferred direction for packages that should talk to Ledger through
|
|
179
|
+
a stable client/protocol boundary.
|
|
180
|
+
|
|
181
|
+
Run the POC smoke:
|
|
182
|
+
|
|
183
|
+
```bash
|
|
184
|
+
ruby -I packages/igniter-ledger/lib packages/igniter-ledger/examples/store_poc.rb
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Run package specs:
|
|
188
|
+
|
|
189
|
+
```bash
|
|
190
|
+
bundle exec rspec packages/igniter-ledger/spec
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Model Decisions & Pressure Log
|
|
194
|
+
|
|
195
|
+
### [2026-04-30] Causation: fact.id, not fact.value_hash
|
|
196
|
+
|
|
197
|
+
**Change**: `IgniterStore#write` now sets `causation: previous&.id` (UUID) instead
|
|
198
|
+
of `causation: previous&.value_hash`.
|
|
199
|
+
|
|
200
|
+
**Why**: `value_hash` is a *content address* — it identifies what a fact *contains*.
|
|
201
|
+
`causation` is a *temporal pointer* — it identifies which fact *came before*. Using
|
|
202
|
+
`value_hash` for causation creates an ambiguous chain: if the same value is written
|
|
203
|
+
twice, `f2.causation == f2.value_hash` (self-referential), and following the chain
|
|
204
|
+
by hash lookup returns multiple candidates. `fact.id` (UUID) is an unambiguous
|
|
205
|
+
pointer to one specific event.
|
|
206
|
+
|
|
207
|
+
**Impact on consumers**: `causation_chain` entries now include `id:` and show the
|
|
208
|
+
full UUID causation instead of a truncated hash prefix. The Durable Model package
|
|
209
|
+
passes `causation_chain(...).length` — count is unaffected.
|
|
210
|
+
|
|
211
|
+
**Candidate pressure on `igniter-durable-model`**: the `WriteReceipt` currently
|
|
212
|
+
forwards `fact.causation` to app receipts. Now causation is a UUID; if the app
|
|
213
|
+
ever exposes it, document as a temporal pointer to a fact identity, not a
|
|
214
|
+
content address.
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
### [2026-04-30] WAL format v2: length-prefix + CRC32 framing
|
|
219
|
+
|
|
220
|
+
**Change**: `FileBackend` replaced JSON-Lines (`puts + readlines`) with a binary
|
|
221
|
+
framed format:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
[4-byte BE uint32: body_len][body_len bytes: JSON][4-byte BE uint32: CRC32(body)]
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**Why**: JSON-Lines is silently lossy on truncation. A process killed mid-`puts`
|
|
228
|
+
leaves a partial line that is indistinguishable from a valid-but-empty line, and
|
|
229
|
+
was previously dropped with `rescue JSON::ParserError` — the write appeared
|
|
230
|
+
committed but the fact was lost on replay.
|
|
231
|
+
|
|
232
|
+
The framed format makes truncation *detectable*: a partial frame has a wrong or
|
|
233
|
+
missing CRC. Replay stops at the first integrity failure and returns all facts
|
|
234
|
+
from complete frames. The last incomplete frame is treated as an uncommitted write.
|
|
235
|
+
|
|
236
|
+
**Breaking change**: existing v1 JSONL WAL files are not readable by the v2 reader.
|
|
237
|
+
This is acceptable at POC stage. A migration path (detect v1 by absence of valid
|
|
238
|
+
frame header, warn and skip) can be added under app pressure.
|
|
239
|
+
|
|
240
|
+
**Candidate pressure on Rust FileBackend** (from plan): the planned Rust FileBackend
|
|
241
|
+
uses MessagePack + CRC32 — same framing principle, binary body instead of JSON.
|
|
242
|
+
The v2 Ruby format is a stepping stone to that target; the framing structure is
|
|
243
|
+
intentionally compatible.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
### [2026-04-30] Materialized scope index + scope-aware invalidation
|
|
248
|
+
|
|
249
|
+
**Change**: `IgniterStore` now maintains a per-scope materialized index in
|
|
250
|
+
`@scope_index: { [store, scope] => Set<key> }`, initialized lazily on the
|
|
251
|
+
first `query` call for each scope and maintained on every subsequent `write`.
|
|
252
|
+
|
|
253
|
+
**Before**: `query_scope` scanned O(all keys in store) on every call. Any write
|
|
254
|
+
to a store invalidated ALL scope caches and notified ALL scope consumers —
|
|
255
|
+
a thundering herd even when the write touched an unrelated scope.
|
|
256
|
+
|
|
257
|
+
**After**:
|
|
258
|
+
- `query` (non–time-travel): O(matched keys) — reads the Set, fetches latest fact
|
|
259
|
+
per key. Full scan only on the very first call.
|
|
260
|
+
- `write` evaluates scope predicates for the written key only, updating the Set
|
|
261
|
+
in O(registered scopes) per write.
|
|
262
|
+
- `ReadCache.invalidate` now accepts `scope_changes: { scope => :changed | :unchanged | :unknown }`.
|
|
263
|
+
Consumers are skipped for `:unchanged` scopes — their membership did not change.
|
|
264
|
+
`:unknown` (index not yet warm) fires conservatively; `:changed` fires normally.
|
|
265
|
+
|
|
266
|
+
**Time-travel** (`as_of:` non-nil) bypasses the scope index and still does a full
|
|
267
|
+
log scan — the index reflects current state only.
|
|
268
|
+
|
|
269
|
+
**Evidence**: 8 new specs covering index accuracy, lazy init, scope entry/exit,
|
|
270
|
+
and suppressed false-positive notifications.
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
### [2026-04-30] History partition index
|
|
275
|
+
|
|
276
|
+
**Change**: `IgniterStore` now maintains a per-(store, partition_key) materialized index
|
|
277
|
+
`@partition_index: { [store, partition_key] => { partition_value => [fact, ...] } }`.
|
|
278
|
+
A new `#history_partition` method provides O(partition slice) reads instead of O(total events).
|
|
279
|
+
`#append` accepts an optional `partition_key:` parameter; when provided and the index is warm,
|
|
280
|
+
the new fact is appended to the correct partition bucket in O(1).
|
|
281
|
+
|
|
282
|
+
**Before**: `DurableModel::Store#replay(partition:)` called `@inner.history(...)` (full scan of
|
|
283
|
+
all events in the store), then filtered in Ruby. For a store with N total events split across P
|
|
284
|
+
partitions, each `replay` was O(N) regardless of partition size.
|
|
285
|
+
|
|
286
|
+
**After**:
|
|
287
|
+
- First `history_partition` call for a (store, partition_key) pair: O(N) full scan that builds
|
|
288
|
+
the index — one-time cost identical to the old path.
|
|
289
|
+
- Subsequent `history_partition` calls: O(partition slice) — read the pre-grouped bucket directly.
|
|
290
|
+
- New `append` calls: O(1) bucket append when the index is already warm.
|
|
291
|
+
- `since:` / `as_of:` time filters applied at read time over the cached slice; they do NOT
|
|
292
|
+
prevent the index from being used.
|
|
293
|
+
|
|
294
|
+
**Durable Model impact**: `DurableModel::Store#append` now passes `partition_key: history_class._partition_key`
|
|
295
|
+
to `@inner.append`; `#replay(partition:)` delegates to `@inner.history_partition` when a
|
|
296
|
+
partition key is declared. The public API of Durable Model is unchanged.
|
|
297
|
+
|
|
298
|
+
**Index correctness edge**: appends without `partition_key:` (or where the event does not
|
|
299
|
+
contain the partition field) do NOT update the index. The caller is responsible for passing
|
|
300
|
+
`partition_key:` consistently — Durable Model always does so via `_partition_key`.
|
|
301
|
+
|
|
302
|
+
---
|
|
303
|
+
|
|
304
|
+
### [2026-04-30] Read cache LRU cap for time-travel entries
|
|
305
|
+
|
|
306
|
+
**Change**: `ReadCache` now accepts `lru_cap:` (default: 1 000). All time-travel
|
|
307
|
+
cache entries — point reads and scope reads with `as_of: non-nil` — are tracked
|
|
308
|
+
in an ordered `@lru_order` hash and evicted LRU when the count exceeds the cap.
|
|
309
|
+
|
|
310
|
+
**Before**: every unique `as_of` timestamp produced a permanent cache entry.
|
|
311
|
+
A workload running time-travel queries across N timestamps (e.g. animation,
|
|
312
|
+
audit replay) would accumulate O(N) entries that were never freed, growing
|
|
313
|
+
unboundedly until the process restarted.
|
|
314
|
+
|
|
315
|
+
**After**:
|
|
316
|
+
- Time-travel entries are evicted LRU when `@lru_order.size > lru_cap`.
|
|
317
|
+
- Accessed entries are promoted to MRU (delete + reinsert in the ordered hash)
|
|
318
|
+
so frequently re-read checkpoints are not the first to be evicted.
|
|
319
|
+
- Current-state entries (`as_of: nil`) are **not** counted against the LRU cap
|
|
320
|
+
and are never evicted by this mechanism — they live until `invalidate` is
|
|
321
|
+
called by a normal write, which is the correct existing behaviour.
|
|
322
|
+
- `invalidate` removes evicted keys from `@lru_order` so the tracker stays
|
|
323
|
+
consistent when writes race with time-travel reads.
|
|
324
|
+
|
|
325
|
+
**Tuning**: pass `lru_cap:` to `IgniterStore.new` or `IgniterStore.open` to
|
|
326
|
+
override the default. Example: `IgniterStore.new(lru_cap: 5_000)`.
|
|
327
|
+
|
|
328
|
+
**Candidate pressure on igniter-durable-model**: `DurableModel::Store.new` could
|
|
329
|
+
expose `lru_cap:` as a top-level option and forward it to the inner store.
|
|
330
|
+
Not done here — defer under app pressure.
|
|
331
|
+
|
|
332
|
+
---
|
|
333
|
+
|
|
334
|
+
### [2026-04-30] Schema version coercion hook
|
|
335
|
+
|
|
336
|
+
**Change**: `IgniterStore#register_coercion(store_name) { |value, schema_version| ... }` registers
|
|
337
|
+
a read-path migration block. On every read — `read`, `time_travel`, `query`, `history`,
|
|
338
|
+
`history_partition` — the block is called with the raw stored value and its `schema_version`.
|
|
339
|
+
The return value replaces the value seen by the caller. Raw facts are never mutated.
|
|
340
|
+
|
|
341
|
+
When a coercion changes the value, the fact is wrapped in a `CoercedFact` struct that
|
|
342
|
+
delegates all identity fields (`id`, `key`, `timestamp`, `causation`, `value_hash`,
|
|
343
|
+
`schema_version`) to the underlying fact. If the coercion returns the same object
|
|
344
|
+
(`equal?`), the original fact is returned unchanged (zero allocation on no-op coercions).
|
|
345
|
+
|
|
346
|
+
**Why on the read path, not write path**: the schema_version field was written at insert time
|
|
347
|
+
and is correct for that version. Mutating facts on write would require migrating all existing
|
|
348
|
+
WAL entries and invalidating causation chains. A read-path transform is zero-cost for
|
|
349
|
+
unchanged facts and allows progressive migration.
|
|
350
|
+
|
|
351
|
+
**Pattern — field rename across schema versions**:
|
|
352
|
+
```ruby
|
|
353
|
+
store.register_coercion(:tasks) do |value, schema_version|
|
|
354
|
+
next value if schema_version >= 2
|
|
355
|
+
# v1 stored :title; v2 renamed to :name
|
|
356
|
+
value.merge(name: value.delete(:title))
|
|
357
|
+
end
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
**Candidate pressure on igniter-durable-model**: `DurableModel::Store` should expose
|
|
361
|
+
`register_coercion` as a passthrough to the inner store, possibly mapped from
|
|
362
|
+
schema class migration declarations. Not done here — defer under app pressure.
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
### [2026-04-30] Snapshot checkpoint
|
|
367
|
+
|
|
368
|
+
**Change**: `IgniterStore#checkpoint` writes all current facts from `FactLog#all_facts`
|
|
369
|
+
to a snapshot file (`<wal_path>.snap`) via `FileBackend#write_snapshot`. On subsequent
|
|
370
|
+
`IgniterStore.open`, `FileBackend#replay` loads the snapshot first, deduplicates WAL
|
|
371
|
+
facts by ID against the snapshot set, and returns `snapshot_facts + delta_wal_facts`
|
|
372
|
+
sorted by timestamp. Startup cost is O(snapshot_size + delta) instead of O(total_facts).
|
|
373
|
+
|
|
374
|
+
**Snapshot file format** (`<wal_path>.snap`):
|
|
375
|
+
```
|
|
376
|
+
[header frame: JSON { type: "snapshot_header", fact_count: N, written_at: T }]
|
|
377
|
+
[fact frame 1] ... [fact frame N]
|
|
378
|
+
```
|
|
379
|
+
Same CRC32-framed format as the WAL — a corrupt snapshot is detected by a bad CRC on
|
|
380
|
+
the header frame and the backend falls back to full WAL replay automatically.
|
|
381
|
+
|
|
382
|
+
**Atomicity**: `write_snapshot` writes to a `.tmp` file and renames atomically, so a
|
|
383
|
+
process kill mid-checkpoint never corrupts an existing snapshot.
|
|
384
|
+
|
|
385
|
+
**Deduplication**: WAL facts whose `id` is already in the snapshot set are skipped,
|
|
386
|
+
not by byte offset or fact count. This tolerates WAL facts that were written
|
|
387
|
+
concurrently with snapshot creation.
|
|
388
|
+
|
|
389
|
+
**Scope / partition indices**: not included in the snapshot — they are rebuilt lazily on
|
|
390
|
+
first query after reopen, which is already the lazy-init behaviour.
|
|
391
|
+
|
|
392
|
+
**Availability**:
|
|
393
|
+
- Ruby fallback (`NATIVE = false`): fully implemented and tested (6 specs).
|
|
394
|
+
- NATIVE (`NATIVE = true`): Rust `FactLog` does not yet expose `all_facts` — `checkpoint`
|
|
395
|
+
is a no-op. `FileBackend#write_snapshot` is also not implemented in the Rust backend.
|
|
396
|
+
Both are candidate pressures for the Rust tier.
|
|
397
|
+
|
|
398
|
+
**Candidate pressure on Rust backend**:
|
|
399
|
+
- `RubyFactLog`: add `all_facts()` → `Vec<RubyFact>` method exposed to Ruby
|
|
400
|
+
- `RubyFileBackend`: add `write_snapshot(facts)` using the same frame format (body = MessagePack)
|
|
401
|
+
- Match the snapshot header record structure so Ruby-written snapshots are readable by Rust and vice versa
|
|
402
|
+
|
|
403
|
+
---
|
|
404
|
+
|
|
405
|
+
### [2026-04-30] NetworkBackend + LedgerServer (Phase 1 transport abstraction)
|
|
406
|
+
|
|
407
|
+
**Change**: Three new pure-Ruby classes implement the first step of the client-server
|
|
408
|
+
projection model:
|
|
409
|
+
|
|
410
|
+
- `WireProtocol` — shared CRC32-framed encoding module (included by `FileBackend`,
|
|
411
|
+
`NetworkBackend`, and `LedgerServer`).
|
|
412
|
+
- `NetworkBackend` — client-side backend implementing the same `write_fact` / `replay` /
|
|
413
|
+
`write_snapshot` interface as `FileBackend`, but transmitting calls over a TCP or
|
|
414
|
+
Unix socket connection.
|
|
415
|
+
- `LedgerServer` — minimal TCP/Unix server wrapping durable storage (`:memory` or `:file`).
|
|
416
|
+
Each incoming connection is handled in a separate thread; writes are serialised by
|
|
417
|
+
`@write_mutex`; reads snapshot `@in_memory_facts` under the same lock.
|
|
418
|
+
|
|
419
|
+
**Usage** — swap the backend without changing application code:
|
|
420
|
+
|
|
421
|
+
```ruby
|
|
422
|
+
# Server process (or background thread)
|
|
423
|
+
server = Igniter::Ledger::LedgerServer.new(
|
|
424
|
+
address: "127.0.0.1:7400", backend: :file, path: "/var/lib/igniter/store.wal"
|
|
425
|
+
)
|
|
426
|
+
server.start_async
|
|
427
|
+
|
|
428
|
+
# Application process — identical API to :memory and :file
|
|
429
|
+
store = Igniter::DurableModel::Store.new(
|
|
430
|
+
backend: :network, address: "127.0.0.1:7400"
|
|
431
|
+
)
|
|
432
|
+
store.register(Task)
|
|
433
|
+
store.write(Task, key: "t1", title: "Hello", status: :open)
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
**Wire protocol**: CRC32-framed JSON, one request frame + one response frame per RPC.
|
|
437
|
+
Reuses the same framing as the WAL file format — the same `WireProtocol` module is
|
|
438
|
+
shared across both, ensuring consistency.
|
|
439
|
+
|
|
440
|
+
**Replay on reconnect**: a new `NetworkBackend` client sends a `replay` request on
|
|
441
|
+
first use (explicitly called by `DurableModel::Store.new(backend: :network)`). The
|
|
442
|
+
`IgniterStore` on the client side rebuilds all in-memory indices (scope index,
|
|
443
|
+
partition index, cache) from the replayed facts — identical to the `:file` path.
|
|
444
|
+
|
|
445
|
+
**Availability**:
|
|
446
|
+
- Ruby fallback (`NATIVE = false`): fully implemented. 8 specs (all skipped when NATIVE).
|
|
447
|
+
- NATIVE (`NATIVE = true`): `NetworkBackend` and `LedgerServer` both have NATIVE guards —
|
|
448
|
+
they are skipped because `Fact.new(**h)` is not available with the Rust extension.
|
|
449
|
+
Phase 2 will add Rust-native fact deserialisation.
|
|
450
|
+
|
|
451
|
+
**Playground**: demo 07 (`07_network.rb`) exercises the full two-client round-trip
|
|
452
|
+
(write via client 1, reconnect as client 2, verify fact visibility and scope queries).
|
|
453
|
+
|
|
454
|
+
**Candidate pressure on Rust backend**:
|
|
455
|
+
- `RubyFact`: expose a class-level `deserialize(hash)` method that constructs a Fact
|
|
456
|
+
from existing id/timestamp/value_hash fields (without re-generating them via `build`).
|
|
457
|
+
This is the only blocker for NATIVE NetworkBackend support.
|
|
458
|
+
|
|
459
|
+
---
|
|
460
|
+
|
|
461
|
+
## Historical Pressure Log Tail
|
|
462
|
+
|
|
463
|
+
The entries above preserve useful early implementation pressure from the Store
|
|
464
|
+
POC. Several items that were once listed as "open" have since moved into
|
|
465
|
+
completed LedgerServer, changefeed, protocol, and compaction tracks.
|
|
466
|
+
|
|
467
|
+
For current status, use:
|
|
468
|
+
|
|
469
|
+
- [docs/progress.md](docs/progress.md)
|
|
470
|
+
- [docs/README.md](docs/README.md)
|
|
471
|
+
- [docs/tracks/](docs/tracks/)
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## Research Track
|
|
476
|
+
|
|
477
|
+
- [Contract-Native Store Research](docs/research/store-iterations.md)
|
|
478
|
+
- [Contract-Native Store POC](docs/poc-specification.md)
|
|
479
|
+
- [Contract-Native Store Sync Hub](docs/research/sync-hub-iterations.md)
|
|
480
|
+
- [Contract Persistence Development Track](../../playgrounds/docs/research-horizon/contract-persistence-development-track.md)
|
|
481
|
+
- [Contract-Native Store: Server Model](docs/server-model.md)
|