@saga-sync/producer 0.1.3 → 0.2.0

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.
Files changed (57) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +473 -0
  3. package/dist/chunk-builder/accumulator.d.ts +4 -4
  4. package/dist/chunk-builder/accumulator.d.ts.map +1 -1
  5. package/dist/chunk-builder/accumulator.js.map +1 -1
  6. package/dist/chunk-builder/archive.d.ts +5 -5
  7. package/dist/chunk-builder/archive.d.ts.map +1 -1
  8. package/dist/chunk-builder/archive.js +1 -1
  9. package/dist/chunk-builder/archive.js.map +1 -1
  10. package/dist/chunk-builder/cli.d.ts +4 -3
  11. package/dist/chunk-builder/cli.d.ts.map +1 -1
  12. package/dist/chunk-builder/cli.js +7 -3
  13. package/dist/chunk-builder/cli.js.map +1 -1
  14. package/dist/keygen.d.ts +1 -1
  15. package/dist/keygen.d.ts.map +1 -1
  16. package/dist/keygen.js +38 -6
  17. package/dist/keygen.js.map +1 -1
  18. package/dist/orchestrator/cli.d.ts +6 -4
  19. package/dist/orchestrator/cli.d.ts.map +1 -1
  20. package/dist/orchestrator/cli.js +96 -32
  21. package/dist/orchestrator/cli.js.map +1 -1
  22. package/dist/orchestrator/pipeline.d.ts +5 -8
  23. package/dist/orchestrator/pipeline.d.ts.map +1 -1
  24. package/dist/orchestrator/pipeline.js +5 -12
  25. package/dist/orchestrator/pipeline.js.map +1 -1
  26. package/dist/scraper/cli.d.ts +1 -0
  27. package/dist/scraper/cli.d.ts.map +1 -1
  28. package/dist/scraper/cli.js +20 -2
  29. package/dist/scraper/cli.js.map +1 -1
  30. package/dist/scraper/config.d.ts +13 -1
  31. package/dist/scraper/config.d.ts.map +1 -1
  32. package/dist/scraper/config.js +58 -2
  33. package/dist/scraper/config.js.map +1 -1
  34. package/dist/scraper/scrape.d.ts.map +1 -1
  35. package/dist/scraper/scrape.js +39 -6
  36. package/dist/scraper/scrape.js.map +1 -1
  37. package/dist/sources/index.d.ts +14 -0
  38. package/dist/sources/index.d.ts.map +1 -0
  39. package/dist/sources/index.js +25 -0
  40. package/dist/sources/index.js.map +1 -0
  41. package/dist/sources/rpc-log-source.d.ts +17 -0
  42. package/dist/sources/rpc-log-source.d.ts.map +1 -0
  43. package/dist/sources/rpc-log-source.js +43 -0
  44. package/dist/sources/rpc-log-source.js.map +1 -0
  45. package/dist/sources/subsquid-source.d.ts +38 -0
  46. package/dist/sources/subsquid-source.d.ts.map +1 -0
  47. package/dist/sources/subsquid-source.js +191 -0
  48. package/dist/sources/subsquid-source.js.map +1 -0
  49. package/dist/sources/types.d.ts +7 -0
  50. package/dist/sources/types.d.ts.map +1 -0
  51. package/dist/sources/types.js +2 -0
  52. package/dist/sources/types.js.map +1 -0
  53. package/dist/storage/gcs-store.d.ts +1 -0
  54. package/dist/storage/gcs-store.d.ts.map +1 -1
  55. package/dist/storage/gcs-store.js +34 -3
  56. package/dist/storage/gcs-store.js.map +1 -1
  57. package/package.json +3 -3
package/README.md ADDED
@@ -0,0 +1,473 @@
1
+ # @saga-sync/producer
2
+
3
+ The producer side of **saga-sync** — the reference implementation of
4
+ [privacy-protocol-state-distribution](../../README.md). It scrapes a privacy
5
+ protocol's on-chain events, packages them into immutable compressed chunks with
6
+ integrity digests, and publishes an `index.json` manifest that any number of
7
+ [`@saga-sync/client`](../client) consumers can download and verify.
8
+
9
+ Three stages, each **both a standalone CLI and an importable library**:
10
+
11
+ ```
12
+ RPC ──▶ scraper ──NDJSON──▶ chunk-builder ──▶ Store (disk | gs://…)
13
+ ▲ ▲ │ index.json + *.jsonl.gz
14
+ └──────── orchestrator (cron entry) ────────┘
15
+ ```
16
+
17
+ - **scraper** — connects to an Ethereum JSON-RPC, fetches event logs for a
18
+ protocol over a block range, normalizes them, emits them as NDJSON.
19
+ - **chunk-builder** — consumes that NDJSON, partitions it into size-bounded
20
+ immutable chunks (gzip + sha256), and maintains the append-only manifest. The
21
+ trailing partial chunk is kept as a mutable **hot head**.
22
+ - **orchestrator** — the cron entry point. Loops every protocol in the config,
23
+ runs `scrape → chunk` for each **in-process** in block-range batches, and owns
24
+ the hot-head lifecycle. This is the normal path; the two lower CLIs are for
25
+ backfills, debugging, or composing by hand.
26
+
27
+ Depends on [`@saga-sync/core`](../core) (schema, crypto, `Store`), `viem` (RPC),
28
+ `zod` (config validation), and optionally `@google-cloud/storage` (lazy-loaded,
29
+ only for `gs://` targets).
30
+
31
+ ## Install & build
32
+
33
+ ```bash
34
+ pnpm install
35
+ pnpm build # tsc -b, builds core first
36
+ ```
37
+
38
+ The examples below run the built JS from the repo root (`packages/producer/dist/…`).
39
+
40
+ ---
41
+
42
+ ## Orchestrator CLI — the normal path
43
+
44
+ Loads every protocol from the config, computes each one's next-from-block from the
45
+ manifest (sealed chunks + mutable hot head), and loops `scrape → chunk` in
46
+ **batches** of `--batch-size` blocks (default 100K). Within a tick protocols run
47
+ sequentially, a lockfile prevents overlapping runs, and the trailing partial after
48
+ each batch is persisted as the protocol's **hot head** — subsequent ticks fold new
49
+ events into it until it reaches the size limit and is promoted into the immutable
50
+ list.
51
+
52
+ ```bash
53
+ node packages/producer/dist/orchestrator/cli.js \
54
+ --config ./example-config.json \
55
+ --rpc https://ethereum-rpc.publicnode.com \
56
+ --output-dir ./chunks
57
+ ```
58
+
59
+ stderr summary on a cold start with a forced-promotion size limit:
60
+
61
+ ```
62
+ orchestrator: tornado-cash-1-eth-0.1 ran 3 batch(es), sealed 14 chunk(s) + hot head [0x17f76ce, 0x17f7803)
63
+ orchestrator: 1 ran, 0 skipped, 0 failed [tip 0x17f7802]
64
+ ```
65
+
66
+ What gets created:
67
+
68
+ ```
69
+ ./chunks/
70
+ tornado-cash-1-eth-0.1-[0x17f7000,0x17f71f3).jsonl.gz # immutable sealed chunks
71
+ ...
72
+ tornado-cash-1-eth-0.1-[0x17f76ce,0x17f7803).hot.jsonl.gz # mutable hot head (one per protocol)
73
+ index.json
74
+ .orchestrator.lock # only present during a run
75
+ ```
76
+
77
+ Re-running with no chain advance is a no-op: the start block is derived from
78
+ `max(hotHead.toBlock, lastSealed.toBlock)` and the protocol is skipped when
79
+ there's nothing new. The previous hot-head file is deleted after the manifest is
80
+ updated (its URL changes each time the range advances, so every URL stays
81
+ immutable and CDN-cacheable).
82
+
83
+ ### Cron entry (daily)
84
+
85
+ One entry handles every protocol; a still-running tick makes the next exit quietly
86
+ via the lockfile.
87
+
88
+ ```
89
+ 0 2 * * * cd /path/to/repo && node packages/producer/dist/orchestrator/cli.js \
90
+ --config ./example-config.json --rpc https://ethereum-rpc.publicnode.com \
91
+ --output-dir ./chunks >> /var/log/orchestrator.log 2>&1
92
+ ```
93
+
94
+ For the cloud deployment (Cloud Run Job + Cloud Scheduler + Cloud CDN) see the
95
+ repo-root [DEPLOY.md](../../DEPLOY.md).
96
+
97
+ ### Flags
98
+
99
+ ```
100
+ --config <path> required config JSON (all protocols)
101
+ --rpc <url> required Ethereum JSON-RPC URL (one chain per run)
102
+ --output-dir <path> required chunks + index.json directory, or gs://bucket[/prefix]
103
+ --lock-dir <path> optional directory for .orchestrator.lock (default = output-dir)
104
+ --protocol-id <id> optional restrict to one protocol (backfills / reruns)
105
+ --batch-size <n> optional blocks per batch (default 100000)
106
+ --concurrency <n> optional protocols scraped in parallel (default 4)
107
+ --confirmations <n> optional fallback reorg buffer (default 12)
108
+ --window <n> optional scraper window (default 2000)
109
+ --size-limit <n> optional chunk cap if config has none (default 10 MiB)
110
+ --dry-run optional report what would run, touch nothing
111
+ ```
112
+
113
+ - **`--protocol-id`** still derives the from-block from the manifest, so it
114
+ catches one protocol up to tip without affecting the others.
115
+ - **`--batch-size`** bounds the crash blast radius (in-flight events lost on crash
116
+ ≤ one batch); larger batches reduce hot-head rewrite overhead during cold-start
117
+ backfills. Daily steady-state usually fits in one batch.
118
+ - **Chunk size** comes from each protocol's `chunkSettings.maxSizeBytes` in the
119
+ config, falling back to `--size-limit`.
120
+ - **`--dry-run`** prints per-protocol ranges without acquiring the lockfile — safe
121
+ alongside a live cron.
122
+
123
+ ### Exit codes
124
+
125
+ - `0` — clean run (zero or more protocols ran, or another orchestrator held the lock)
126
+ - `1` — config error, missing flags, or unknown `--protocol-id`
127
+ - `2` — at least one protocol threw mid-run (others may still have succeeded)
128
+
129
+ ---
130
+
131
+ ## Scraper CLI
132
+
133
+ Pure "input range → output events": connects to an RPC, fetches + normalizes logs,
134
+ writes NDJSON to **stdout** and a one-line summary to **stderr**. Writes no chunks
135
+ or manifest.
136
+
137
+ ```bash
138
+ node packages/producer/dist/scraper/cli.js \
139
+ --config ./example-config.json \
140
+ --protocol-id tornado-cash-1-eth-0.1 \
141
+ --rpc https://ethereum-rpc.publicnode.com \
142
+ --from-block 0xC50101 --to-block 0xC50200 \
143
+ --dry-run
144
+ # → NDJSON on stdout; stderr:
145
+ # scraper: 2 event(s) for tornado-cash-1-eth-0.1 [0xc50101, 0xc50200] (dry-run: cursor not updated)
146
+ ```
147
+
148
+ Useful variations:
149
+
150
+ ```bash
151
+ # Persist a cursor (drop --dry-run) — next run with no --from-block resumes from cursor+1
152
+ node .../scraper/cli.js --config ./example-config.json --protocol-id tornado-cash-1-eth-0.1 \
153
+ --rpc <url> --from-block 0xC50000 --to-block 0xC50100
154
+ cat cursor.json # → { "tornado-cash-1-eth-0.1": { "lastScrapedBlock": "0xc50100" } }
155
+
156
+ # Scrape up to the chain's finalized block (omit --to-block)
157
+ node .../scraper/cli.js --config ./example-config.json --protocol-id tornado-cash-1-eth-0.1 \
158
+ --rpc <url> --from-block 0xC50101 --dry-run
159
+
160
+ # Inspect with jq
161
+ node .../scraper/cli.js ... --dry-run | jq -c '{block: .blockNumber, topic: .eventTopic}'
162
+ ```
163
+
164
+ ### Flags
165
+
166
+ ```
167
+ --config <path> required config JSON
168
+ --protocol-id <id> required protocol key to scrape
169
+ --rpc <url> required Ethereum JSON-RPC URL
170
+ --from-block <hex> optional override cursor / config fromBlock
171
+ --to-block <hex> optional override the resolved finalized block
172
+ --confirmations <n> optional fallback reorg buffer (default 12)
173
+ --window <n> optional blocks per eth_getLogs call (default 2000)
174
+ --cursor-dir <path> optional directory for cursor.json (default = config dir)
175
+ --dry-run optional do not persist the cursor
176
+ ```
177
+
178
+ Exit 0 on success, 1 on error.
179
+
180
+ ---
181
+
182
+ ## Chunk-builder CLI
183
+
184
+ Reads `CanonicalEvent` NDJSON on **stdin** (scraper output), partitions the scanned
185
+ range into immutable `.jsonl.gz` chunks, and appends to a local `index.json`.
186
+
187
+ ```bash
188
+ node packages/producer/dist/scraper/cli.js --config ./example-config.json \
189
+ --protocol-id tornado-cash-1-eth-0.1 --rpc <url> \
190
+ --from-block 0xC50101 --to-block 0xC50200 --dry-run \
191
+ | node packages/producer/dist/chunk-builder/cli.js \
192
+ --protocol-id tornado-cash-1-eth-0.1 \
193
+ --from-block 0xC50101 --to-block 0xC50201 \
194
+ --output-dir ./chunks
195
+ ```
196
+
197
+ **Mind the upper bound.** The scraper's `--to-block` is **inclusive**
198
+ (`[from, to]`); the chunk-builder's is **exclusive** (`[from, to)`). To cover the
199
+ same range, pass `scraper.to_block + 1` to the chunk-builder.
200
+
201
+ The standalone CLI always **seals** the trailing partial (no hot heads — that path
202
+ is orchestrator-only). An empty range still emits one zero-byte chunk covering the
203
+ full `[from, to)`, so the manifest asserts the range was scanned.
204
+
205
+ ```bash
206
+ # Force multiple chunks with a small size limit
207
+ node .../scraper/cli.js ... | node .../chunk-builder/cli.js \
208
+ --protocol-id tornado-cash-1-eth-0.1 --from-block 0xC50000 --to-block 0xC50501 \
209
+ --output-dir ./chunks --size-limit 800
210
+
211
+ # Inspect a chunk
212
+ gunzip -c './chunks/tornado-cash-1-eth-0.1-[0xc50101,0xc50201).jsonl.gz' | jq -c .
213
+ ```
214
+
215
+ ### Flags
216
+
217
+ ```
218
+ --protocol-id <id> required manifest key + filename prefix
219
+ --from-block <hex> required inclusive start of the scanned range
220
+ --to-block <hex> required exclusive end of the scanned range
221
+ --output-dir <path> required chunks + index.json directory
222
+ --size-limit <n> optional max uncompressed bytes per chunk (default 10 MiB)
223
+ --dry-run optional compute metadata, write nothing
224
+ ```
225
+
226
+ ---
227
+
228
+ ## Module internals
229
+
230
+ **`scraper/`** — pure "range → events"; writes no chunks or manifest.
231
+
232
+ - **`config.ts`** — reads the config JSON, zod-validates **only** the fields the
233
+ pipeline uses. `loadConfig(path, id)` returns one `ScraperTarget`;
234
+ `loadAllProtocols(path)` returns all. Validated: `chainId`, `fromBlock`,
235
+ `events[]`; optional `chunkSettings.maxSizeBytes`, `protocol`, `protocolMetadata`.
236
+ Derives `trackedAddresses`/`trackedEventTopics` from the unique
237
+ `events[].contractAddress`/`.eventTopic` in first-seen order.
238
+ - **`normalize.ts`** — `normalize(rpcLog)` → `CanonicalEvent`: lowercases every hex
239
+ field, sets `eventTopic = topics[0]`, keeps the full `topics` array (indexed args
240
+ like commitments/nullifiers), rejects pending logs, and drops only
241
+ `transactionHash`/`blockHash`.
242
+ - **`scrape.ts`** — `scrape(client, opts)`, an async generator yielding raw RPC
243
+ logs. Slices `[fromBlock, toBlock]` into `window`-sized sub-ranges, issuing one
244
+ `eth_getLogs` per event filter; on a range/result-size error it **halves the
245
+ window and retries**. Each window is fully buffered and sorted by
246
+ `(blockNumber, logIndex)` before any log is yielded, so a retry never double-emits.
247
+ - **`cursor.ts`** — `Cursor` over a `Store`, recording `lastScrapedBlock` per
248
+ protocol. **Only** the standalone scraper CLI uses it; the orchestrator derives
249
+ resume points from the manifest.
250
+ - **`cli.ts`** — the scraper entry point. Also exports `finalizedBlock(client)`
251
+ and `assertChainId(client, expected)`, reused by the orchestrator.
252
+
253
+ **`chunk-builder/`** — `CanonicalEvent` NDJSON → chunk files + drives core's `Manifest`.
254
+
255
+ - **`accumulator.ts`** — `ChunkAccumulator`, a **pure** block-aligned partition
256
+ state machine. Buffers the in-progress block separately and commits it only once
257
+ the next block arrives — a chunk boundary always falls *between* blocks (a
258
+ multi-event block is never split). `add(event)` returns a completed chunk on a
259
+ size boundary; `finish()` returns the trailing accumulator.
260
+ - **`archive.ts`** — `ChunkArchive` over a `Store`. `seal()` / `writeHotHead()`
261
+ build the JSONL, compute the sha256 of the **uncompressed** bytes, gzip, and
262
+ `put` under a range-derived filename. `readEvents()` is the inverse.
263
+ `buildJsonl()` is exported (the client's integration tests use it as a fixture
264
+ builder).
265
+ - **`cli.ts`** — `processStream(lines, args)` drives the accumulator: optional seed
266
+ events (a prior hot head) → stream events, sealing each completed chunk → the
267
+ trailing accumulator, either **sealed** (`trailingMode: "seal"`, standalone
268
+ default) or **returned** (`trailingMode: "suspend"`, the orchestrator's hot-head
269
+ carry-over).
270
+
271
+ **`orchestrator/`** — the cron entry point; composes the other two in-process.
272
+
273
+ - **`pipeline.ts`** — `runProtocolOnce(opts)` builds the scrape → normalize →
274
+ NDJSON generator and hands it to `processStream`. No subprocess, no stdio: errors
275
+ propagate as plain exceptions.
276
+ - **`cli.ts`** — per tick: acquire the lockfile, load config + manifest, query the
277
+ chain tip, then for each protocol run `processProtocol` (resolve start block,
278
+ load any prior hot head, loop `[start, tip]` in `--batch-size` steps, persist the
279
+ final trailing accumulator as the new hot head). Also exports `acquireLock(path)`.
280
+
281
+ **`storage/`** — the producer-only stores + the factory.
282
+
283
+ - **`GcsStore`** — write-side, backed by a GCS bucket (optional `prefix`). GCS
284
+ writes are atomic + strongly consistent (no temp-file+rename). Sets per-key
285
+ `Content-Type` + `Cache-Control` (sealed chunks immutable/long-lived; `index.json`
286
+ / hot head short TTL). The `@google-cloud/storage` SDK is lazy-loaded behind an
287
+ injectable provider, so the module compiles + unit-tests without the dependency.
288
+ - **`DryRunStore`** — decorates another `Store`; `put`/`delete` become no-ops,
289
+ `get`/`list` pass through. How `--dry-run` is implemented without every other
290
+ class knowing about it.
291
+ - **`createStore(cfg)`** / **`parseStoreTarget(target)`** — the factory mapping
292
+ `disk`→core's `DiskStore`, `http`→core's `HttpStore`, `gcs`→`GcsStore`
293
+ (`s3`/`ftp` throw until added); a `gs://bucket[/prefix]` `--output-dir` selects
294
+ `GcsStore`, anything else is a local disk path.
295
+
296
+ **`sources/`** — where a stream's records come from. The orchestrator drives block
297
+ ranges and chunking; a `ScraperSource` decides what to yield.
298
+
299
+ - **`RpcLogSource`** — windowed `eth_getLogs`, the original path (adaptive window
300
+ halving, rate-limit backoff, `normalize()` to canonical form). Its
301
+ `latestCoveredBlock()` is the chain's finalized block.
302
+ - **`SubsquidSource`** — mirrors a Squid GraphQL index, for state no log carries
303
+ (Railgun's per-transaction operations live only in `transact()` calldata).
304
+ Pagination, retries and termination match kohaku's own client so both fail the
305
+ same way. **Its tip is clamped to the chain's finalized block**: the index runs
306
+ ~75 blocks behind head, i.e. ahead of finality, and sealed chunks are immutable.
307
+
308
+ Selected per stream by the config's `source` block, which defaults to
309
+ `{"kind":"rpc"}` so configs written before sources existed are unchanged. `events`
310
+ is required for an rpc source and rejected for any other. Adding a source kind is
311
+ one `case` in `createSource()` plus one class — the same shape as `createStore()`.
312
+
313
+ **`keygen.ts`** — CLI that mints a manifest-signing keypair (over core's
314
+ `signing`). `keygen` alone gives Ed25519; `keygen --alg secp256k1` gives an
315
+ Ethereum-shaped key. Set `MANIFEST_SIGNING_KEY` and/or
316
+ `MANIFEST_SIGNING_KEY_SECP256K1` (32-byte hex seeds) on the orchestrator /
317
+ chunk-builder and each configured key signs every manifest write; consumers pin
318
+ any of the matching public keys via the client's `--public-key`.
319
+
320
+ A consumer is accepted by **any** one signature, so every additional key is
321
+ another way to forge a manifest. Add a second algorithm for reach (secp256k1
322
+ works with Ethereum tooling and hardware wallets), not for strength — SPEC §9.1.
323
+
324
+ ---
325
+
326
+ ## Data formats
327
+
328
+ ### Config JSON (`--config`)
329
+
330
+ *What* to scrape, not *where/when*. The store target is a per-run CLI argument and
331
+ the schedule is external — all protocols in one config share one store + manifest.
332
+
333
+ ```json
334
+ {
335
+ "protocols": {
336
+ "tornado-cash-1-eth-0.1": {
337
+ "chainId": "0x1",
338
+ "fromBlock": "0x8b1d26",
339
+ "chunkSettings": { "maxSizeBytes": 10485760 },
340
+ "protocol": "tornado-cash",
341
+ "protocolMetadata": { "denomination": "100000000000000000", "asset": "ETH" },
342
+ "events": [
343
+ { "contractAddress": "0x12d66f87…", "eventTopic": "0xa945e51e…" },
344
+ { "contractAddress": "0x12d66f87…", "eventTopic": "0xe9e508ba…",
345
+ "filter": ["0x000…indexed-arg-match"] }
346
+ ]
347
+ }
348
+ }
349
+ }
350
+ ```
351
+
352
+ | Field | Required | Meaning |
353
+ |---|---|---|
354
+ | `chainId` | yes | `0x`-hex chain id; verified against the RPC at startup |
355
+ | `fromBlock` | yes | contract deploy block; the cold-start scrape origin |
356
+ | `events[]` | yes | one entry per `(contractAddress, eventTopic)` to scrape |
357
+ | `events[].filter` | no | extra indexed-topic matchers appended after `eventTopic` |
358
+ | `chunkSettings.maxSizeBytes` | no | per-protocol chunk cap (number or `0x`-hex) |
359
+ | `protocol` | no | family name copied into the manifest (e.g. `tornado-cash`) |
360
+ | `protocolMetadata` | no | free-form passthrough; **immutable per stream** once first published |
361
+
362
+ The protocol key `${protocol}-${chainId}-${instanceId}` is treated as an opaque id
363
+ (chain identity comes from the explicit `chainId`, since the key itself isn't
364
+ safely parseable — protocol names contain hyphens).
365
+
366
+ ### NDJSON (scraper stdout → chunk-builder stdin)
367
+
368
+ One `CanonicalEvent` per line, all-lowercase `0x`-hex, globally ordered by
369
+ `(blockNumber, logIndex)`:
370
+
371
+ ```json
372
+ {"contractAddress":"0x12d6…","eventTopic":"0xa945…","topics":["0xa945…","0x1e8f…"],"data":"0x…","blockNumber":"0xc501f5","logIndex":"0x68"}
373
+ ```
374
+
375
+ ### Chunk file `${protocolId}-[${fromBlock},${toBlock}).jsonl.gz`
376
+
377
+ Gzip-compressed JSONL of the NDJSON above, for the block range in the filename.
378
+ Immutable once written. An empty chunk is a zero-byte payload. The **hot-head file**
379
+ (`…hot.jsonl.gz`) is byte-identical in format — the only differences are the `.hot.`
380
+ infix and that the manifest tracks it in the entry's `hotHead` field, not `chunks`.
381
+ At most one hot head per protocol.
382
+
383
+ ### Manifest `index.json`
384
+
385
+ Format **v1** — see the root [SPEC.md §3.1](../../SPEC.md) for the normative schema
386
+ and the root [README.md](../../README.md) for the protocol-level walkthrough. The
387
+ `Manifest` class that reads/writes it lives in [`@saga-sync/core`](../core).
388
+ `digest.data` is the sha256 of the **uncompressed** JSONL (`gunzip -c <file> |
389
+ shasum -a 256`); `size` is the compressed byte length. With a signing key set, a
390
+ sibling `index.json.sigs` holds the detached signature envelope over the exact
391
+ bytes — one entry per configured algorithm — and `index.json.sig` still holds the
392
+ bare Ed25519 signature for consumers pinned to the original file.
393
+
394
+ ### State files
395
+
396
+ - **`cursor.json`** — the standalone scraper's resume pointer
397
+ (`{ "<id>": { "lastScrapedBlock": "0x…" } }`). The orchestrator does not use it.
398
+ - **`.orchestrator.lock`** — plain text pid, created `O_EXCL` at tick start, removed
399
+ on exit; a stale lock (dead pid) is reclaimed automatically.
400
+
401
+ ---
402
+
403
+ ## Invariants a replica must honor
404
+
405
+ These are what make two independent scrapers produce byte-identical chunks (see
406
+ [SPEC.md](../../SPEC.md) for the normative statements):
407
+
408
+ 1. **Windowed `eth_getLogs` with adaptive split** — slice the range; on a
409
+ "range too large" / "too many results" error, halve the window and retry.
410
+ Buffer + sort each window before yielding.
411
+ 2. **Normalization keeps the whole log** — lowercase all hex; `eventTopic` =
412
+ `topics[0]`; keep `topics[1..]`; drop only `transactionHash`/`blockHash`.
413
+ 3. **Block-aligned chunking** — a chunk covers a half-open `[from, to)`; boundaries
414
+ fall between blocks, so all events of a block land in exactly one chunk.
415
+ 4. **Size-bounded sealing** — seal when accumulated **uncompressed** bytes would
416
+ exceed the limit; the digest is sha256 of that uncompressed JSONL; `size` in the
417
+ manifest is the **compressed** count.
418
+ 5. **Hot heads** — the trailing partial is written mutable (`*.hot.jsonl.gz`,
419
+ tracked in the entry's `hotHead`). The next run loads it, appends, re-writes; on
420
+ crossing the size limit it is **promoted** (overflow sealed, fresh hot head holds
421
+ the remainder).
422
+ 6. **Batching** — process `[start, tip]` in `--batch-size` steps; each batch's
423
+ trailing accumulator seeds the next. The manifest advances only on a completed
424
+ seal, so a re-run is idempotent (`(blockNumber, logIndex)` is the dedup key).
425
+ 7. **Reorg safety** — `toBlock` defaults to the chain's own **finalized** block;
426
+ fallback for RPCs without the tag is `head − confirmations` (default 12).
427
+ 8. **Start-block resolution** — `hotHead.toBlock` → last sealed chunk's `toBlock` →
428
+ `config.fromBlock` (first run only). The manifest self-bootstraps after the first
429
+ chunk.
430
+ 9. **Range-derived, immutable URLs** — every chunk/hot-head file is named
431
+ `${protocolId}-[${fromBlock},${toBlock})…`, so a URL's bytes never change. When a
432
+ hot head advances, a new file is written and the old one deleted; only the
433
+ manifest pointer is mutable.
434
+ 10. **Single-writer lock** — the orchestrator holds `.orchestrator.lock` (`O_EXCL` +
435
+ pid, stale-pid recovery). A second instance exits quietly.
436
+
437
+ ### Block-range conventions
438
+
439
+ - The **scraper** works in **inclusive** ranges `[fromBlock, toBlock]` (what
440
+ `eth_getLogs` takes).
441
+ - **Chunks and the manifest** use **half-open** ranges `[fromBlock, toBlock)`, so
442
+ consecutive chunks compose with no gap or overlap.
443
+ - The orchestrator bridges them: a scraper batch over inclusive `[X, Y]` becomes a
444
+ chunk-builder range of half-open `[X, Y+1)`.
445
+
446
+ Invariant: a protocol's sealed chunks + its hot head partition
447
+ `[firstSealed.fromBlock, hotHead.toBlock)` contiguously.
448
+
449
+ ---
450
+
451
+ ## Publishing to Google Cloud Storage
452
+
453
+ Publishing to GCS is entirely a `Store`-seam concern — the scrape/chunk logic, the
454
+ manifest/chunk format, and the client are all unchanged. Point `--output-dir` at a
455
+ bucket; consumers read the same objects over plain HTTP.
456
+
457
+ - **Producer write** — `--output-dir gs://bucket[/prefix]` routes through
458
+ `parseStoreTarget` → `GcsStore`, writing chunks + `index.json` with per-object
459
+ `Cache-Control` (sealed chunks `max-age=31536000, immutable`; `index.json` / hot
460
+ head `max-age=30`).
461
+ - **Consumer read** — point the client's manifest URL at a CDN in front of the
462
+ bucket, or at the bucket directly. `HttpStore` does plain GETs; nothing in the
463
+ client changes.
464
+
465
+ Operational notes: the lockfile is filesystem-only — for a `gs://` target it
466
+ defaults to the cwd (`--lock-dir` to override), and in a stateless container
467
+ single-execution scheduling is the real single-writer guard. Hot-head objects
468
+ orphan as their range advances; a GCS Object Lifecycle rule (delete
469
+ `*.hot.jsonl.gz` after N days) cleans them up. The bucket is public-read —
470
+ integrity comes from the sha256 digests, not access control.
471
+
472
+ The full cloud runbook (Cloud Run Job, Cloud Scheduler, Secret Manager, Cloud CDN)
473
+ is in the repo-root **[DEPLOY.md](../../DEPLOY.md)**.
@@ -1,11 +1,11 @@
1
- import type { CanonicalEvent } from "../scraper/normalize.js";
1
+ import type { CanonicalRecord } from "@saga-sync/core";
2
2
  export type CompletedChunk = {
3
- events: CanonicalEvent[];
3
+ events: CanonicalRecord[];
4
4
  from: bigint;
5
5
  to: bigint;
6
6
  };
7
7
  export type Trailing = {
8
- events: CanonicalEvent[];
8
+ events: CanonicalRecord[];
9
9
  fromBlock: bigint;
10
10
  };
11
11
  export declare class ChunkAccumulator {
@@ -17,7 +17,7 @@ export declare class ChunkAccumulator {
17
17
  private pendingBytes;
18
18
  private pendingBlock;
19
19
  constructor(sizeLimit: number, chunkFrom: bigint);
20
- add(event: CanonicalEvent): CompletedChunk | null;
20
+ add(event: CanonicalRecord): CompletedChunk | null;
21
21
  finish(): {
22
22
  completed: CompletedChunk | null;
23
23
  trailing: Trailing;
@@ -1 +1 @@
1
- {"version":3,"file":"accumulator.d.ts","sourceRoot":"","sources":["../../src/chunk-builder/accumulator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,MAAM,MAAM,cAAc,GAAG;IAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AACpF,MAAM,MAAM,QAAQ,GAAG;IAAE,MAAM,EAAE,cAAc,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAYvE,qBAAa,gBAAgB;IAQzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,SAAS;IARnB,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,YAAY,CAAuB;gBAGxB,SAAS,EAAE,MAAM,EAC1B,SAAS,EAAE,MAAM;IAG3B,GAAG,CAAC,KAAK,EAAE,cAAc,GAAG,cAAc,GAAG,IAAI;IAoBjD,MAAM,IAAI;QAAE,SAAS,EAAE,cAAc,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,QAAQ,CAAA;KAAE;IAclE,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,GAAG;CAOZ"}
1
+ {"version":3,"file":"accumulator.d.ts","sourceRoot":"","sources":["../../src/chunk-builder/accumulator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,MAAM,MAAM,cAAc,GAAG;IAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AACrF,MAAM,MAAM,QAAQ,GAAG;IAAE,MAAM,EAAE,eAAe,EAAE,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,CAAC;AAYxE,qBAAa,gBAAgB;IAQzB,OAAO,CAAC,QAAQ,CAAC,SAAS;IAC1B,OAAO,CAAC,SAAS;IARnB,OAAO,CAAC,WAAW,CAAyB;IAC5C,OAAO,CAAC,gBAAgB,CAAK;IAC7B,OAAO,CAAC,OAAO,CAAyB;IACxC,OAAO,CAAC,YAAY,CAAK;IACzB,OAAO,CAAC,YAAY,CAAuB;gBAGxB,SAAS,EAAE,MAAM,EAC1B,SAAS,EAAE,MAAM;IAG3B,GAAG,CAAC,KAAK,EAAE,eAAe,GAAG,cAAc,GAAG,IAAI;IAoBlD,MAAM,IAAI;QAAE,SAAS,EAAE,cAAc,GAAG,IAAI,CAAC;QAAC,QAAQ,EAAE,QAAQ,CAAA;KAAE;IAclE,OAAO,CAAC,aAAa;IAOrB,OAAO,CAAC,aAAa;IAQrB,OAAO,CAAC,GAAG;CAOZ"}
@@ -1 +1 @@
1
- {"version":3,"file":"accumulator.js","sourceRoot":"","sources":["../../src/chunk-builder/accumulator.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"accumulator.js","sourceRoot":"","sources":["../../src/chunk-builder/accumulator.ts"],"names":[],"mappings":"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"}
@@ -1,18 +1,18 @@
1
- import type { CanonicalEvent } from "../scraper/normalize.js";
1
+ import type { CanonicalRecord } from "@saga-sync/core";
2
2
  import type { Store } from "@saga-sync/core";
3
3
  import type { ChunkMeta } from "@saga-sync/core";
4
4
  export type Range = {
5
5
  from: bigint;
6
6
  to: bigint;
7
7
  };
8
- export declare function buildJsonl(events: CanonicalEvent[]): Buffer;
8
+ export declare function buildJsonl(events: CanonicalRecord[]): Buffer;
9
9
  export declare class ChunkArchive {
10
10
  private readonly store;
11
11
  constructor(store: Store);
12
- seal(protocolId: string, events: CanonicalEvent[], range: Range): Promise<ChunkMeta>;
13
- writeHotHead(protocolId: string, events: CanonicalEvent[], range: Range): Promise<ChunkMeta>;
12
+ seal(protocolId: string, events: CanonicalRecord[], range: Range): Promise<ChunkMeta>;
13
+ writeHotHead(protocolId: string, events: CanonicalRecord[], range: Range): Promise<ChunkMeta>;
14
14
  private write;
15
- readEvents(meta: ChunkMeta): Promise<CanonicalEvent[]>;
15
+ readEvents(meta: ChunkMeta): Promise<CanonicalRecord[]>;
16
16
  delete(meta: ChunkMeta): Promise<void>;
17
17
  }
18
18
  //# sourceMappingURL=archive.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../../src/chunk-builder/archive.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAC9D,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,MAAM,KAAK,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAKjD,wBAAgB,UAAU,CAAC,MAAM,EAAE,cAAc,EAAE,GAAG,MAAM,CAG3D;AAUD,qBAAa,YAAY;IACX,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAGzC,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,SAAS,CAAC;IAKpF,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,cAAc,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,SAAS,CAAC;YAI9E,KAAK;IA4Bb,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC;IAWtD,MAAM,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;CAG7C"}
1
+ {"version":3,"file":"archive.d.ts","sourceRoot":"","sources":["../../src/chunk-builder/archive.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AACvD,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,MAAM,MAAM,KAAK,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAKjD,wBAAgB,UAAU,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,MAAM,CAG5D;AAUD,qBAAa,YAAY;IACX,OAAO,CAAC,QAAQ,CAAC,KAAK;gBAAL,KAAK,EAAE,KAAK;IAGzC,IAAI,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,SAAS,CAAC;IAKrF,YAAY,CAAC,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,eAAe,EAAE,EAAE,KAAK,EAAE,KAAK,GAAG,OAAO,CAAC,SAAS,CAAC;YAI/E,KAAK;IA4Bb,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,eAAe,EAAE,CAAC;IAWvD,MAAM,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;CAG7C"}
@@ -1,7 +1,7 @@
1
1
  import { gzipSync, gunzipSync } from "node:zlib";
2
2
  import { numberToHex } from "viem";
3
3
  import { sha256Hex } from "@saga-sync/core";
4
- // Build the JSONL bytes for a chunk: one CanonicalEvent per line, trailing
4
+ // Build the JSONL bytes for a chunk: one CanonicalRecord per line, trailing
5
5
  // newline. Empty events list produces zero bytes so an empty chunk file is
6
6
  // genuinely empty rather than a single blank line.
7
7
  export function buildJsonl(events) {
@@ -1 +1 @@
1
- {"version":3,"file":"archive.js","sourceRoot":"","sources":["../../src/chunk-builder/archive.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"archive.js","sourceRoot":"","sources":["../../src/chunk-builder/archive.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import type { CanonicalEvent } from "../scraper/normalize.js";
2
+ import type { CanonicalRecord } from "@saga-sync/core";
3
3
  import { ChunkArchive } from "./archive.js";
4
4
  import { Manifest } from "@saga-sync/core";
5
+ import "@saga-sync/core/secp256k1";
5
6
  import type { ChunkMeta } from "@saga-sync/core";
6
7
  export type ProcessArgs = {
7
8
  protocolId: string;
@@ -11,7 +12,7 @@ export type ProcessArgs = {
11
12
  archive: ChunkArchive;
12
13
  manifest: Manifest;
13
14
  seed?: {
14
- events: CanonicalEvent[];
15
+ events: CanonicalRecord[];
15
16
  chunkFrom: bigint;
16
17
  };
17
18
  trailingMode?: "seal" | "suspend";
@@ -19,7 +20,7 @@ export type ProcessArgs = {
19
20
  export type ProcessResult = {
20
21
  sealed: ChunkMeta[];
21
22
  trailing?: {
22
- events: CanonicalEvent[];
23
+ events: CanonicalRecord[];
23
24
  fromBlock: bigint;
24
25
  toBlock: bigint;
25
26
  };
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/chunk-builder/cli.ts"],"names":[],"mappings":";AAMA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,yBAAyB,CAAC;AAE9D,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAE3C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAuEjD,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,YAAY,CAAC;IACtB,QAAQ,EAAE,QAAQ,CAAC;IAKnB,IAAI,CAAC,EAAE;QAAE,MAAM,EAAE,cAAc,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAGvD,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,SAAS,EAAE,CAAC;IAEpB,QAAQ,CAAC,EAAE;QAAE,MAAM,EAAE,cAAc,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC7E,CAAC;AAMF,wBAAsB,aAAa,CACjC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,EAC5B,IAAI,EAAE,WAAW,GAChB,OAAO,CAAC,aAAa,CAAC,CA+DxB"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../../src/chunk-builder/cli.ts"],"names":[],"mappings":";AAMA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,iBAAiB,CAAC;AAEvD,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAG5C,OAAO,EAAE,QAAQ,EAAE,MAAM,iBAAiB,CAAC;AAG3C,OAAO,2BAA2B,CAAC;AACnC,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAuEjD,MAAM,MAAM,WAAW,GAAG;IACxB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,YAAY,CAAC;IACtB,QAAQ,EAAE,QAAQ,CAAC;IAKnB,IAAI,CAAC,EAAE;QAAE,MAAM,EAAE,eAAe,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;IAGxD,YAAY,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,aAAa,GAAG;IAC1B,MAAM,EAAE,SAAS,EAAE,CAAC;IAEpB,QAAQ,CAAC,EAAE;QAAE,MAAM,EAAE,eAAe,EAAE,CAAC;QAAC,SAAS,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;CAC9E,CAAC;AAMF,wBAAsB,aAAa,CACjC,KAAK,EAAE,aAAa,CAAC,MAAM,CAAC,EAC5B,IAAI,EAAE,WAAW,GAChB,OAAO,CAAC,aAAa,CAAC,CA+DxB"}
@@ -8,7 +8,9 @@ import { createStore, parseStoreTarget } from "../storage/index.js";
8
8
  import { ChunkArchive } from "./archive.js";
9
9
  import { ChunkAccumulator } from "./accumulator.js";
10
10
  import { Manifest } from "@saga-sync/core";
11
- import { signerFromEnv } from "@saga-sync/core";
11
+ import { signersFromEnv } from "@saga-sync/core";
12
+ // Registers secp256k1 so MANIFEST_SIGNING_KEY_SECP256K1 is honoured.
13
+ import "@saga-sync/core/secp256k1";
12
14
  const DEFAULT_SIZE_LIMIT = 10 * 1024 * 1024; // 10 MiB
13
15
  const USAGE = `chunk-builder — partition scraper NDJSON into immutable .jsonl.gz chunks
14
16
 
@@ -70,7 +72,7 @@ function parseCliArgs() {
70
72
  dryRun: values["dry-run"] ?? false,
71
73
  };
72
74
  }
73
- // Reads CanonicalEvent NDJSON, partitions it into chunks via ChunkAccumulator,
75
+ // Reads CanonicalRecord NDJSON, partitions it into chunks via ChunkAccumulator,
74
76
  // and seals each completed chunk through ChunkArchive + Manifest. The trailing
75
77
  // accumulator is either sealed (seal mode) or returned (suspend mode — the
76
78
  // hot-head carry-over path).
@@ -137,7 +139,7 @@ async function main() {
137
139
  const args = parseCliArgs();
138
140
  const store = createStore({ ...parseStoreTarget(args.output), dryRun: args.dryRun });
139
141
  const archive = new ChunkArchive(store);
140
- const manifest = await Manifest.load(store, undefined, { signer: signerFromEnv() });
142
+ const manifest = await Manifest.load(store, undefined, { signers: signersFromEnv() });
141
143
  const rl = createInterface({ input: process.stdin, crlfDelay: Infinity });
142
144
  const { sealed } = await processStream(rl, {
143
145
  protocolId: args.protocolId,
@@ -147,6 +149,8 @@ async function main() {
147
149
  archive,
148
150
  manifest,
149
151
  });
152
+ // Manifest mutations only schedule throttled writes; flush to make them durable.
153
+ await manifest.flush();
150
154
  process.stderr.write(`chunk-builder: ${sealed.length} chunk(s) for ${args.protocolId} ` +
151
155
  `[${numberToHex(args.fromBlock)}, ${numberToHex(args.toBlock)})` +
152
156
  (args.dryRun ? " (dry-run: no files or manifest written)\n" : "\n"));