@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.
- package/LICENSE +674 -0
- package/README.md +473 -0
- package/dist/chunk-builder/accumulator.d.ts +4 -4
- package/dist/chunk-builder/accumulator.d.ts.map +1 -1
- package/dist/chunk-builder/accumulator.js.map +1 -1
- package/dist/chunk-builder/archive.d.ts +5 -5
- package/dist/chunk-builder/archive.d.ts.map +1 -1
- package/dist/chunk-builder/archive.js +1 -1
- package/dist/chunk-builder/archive.js.map +1 -1
- package/dist/chunk-builder/cli.d.ts +4 -3
- package/dist/chunk-builder/cli.d.ts.map +1 -1
- package/dist/chunk-builder/cli.js +7 -3
- package/dist/chunk-builder/cli.js.map +1 -1
- package/dist/keygen.d.ts +1 -1
- package/dist/keygen.d.ts.map +1 -1
- package/dist/keygen.js +38 -6
- package/dist/keygen.js.map +1 -1
- package/dist/orchestrator/cli.d.ts +6 -4
- package/dist/orchestrator/cli.d.ts.map +1 -1
- package/dist/orchestrator/cli.js +96 -32
- package/dist/orchestrator/cli.js.map +1 -1
- package/dist/orchestrator/pipeline.d.ts +5 -8
- package/dist/orchestrator/pipeline.d.ts.map +1 -1
- package/dist/orchestrator/pipeline.js +5 -12
- package/dist/orchestrator/pipeline.js.map +1 -1
- package/dist/scraper/cli.d.ts +1 -0
- package/dist/scraper/cli.d.ts.map +1 -1
- package/dist/scraper/cli.js +20 -2
- package/dist/scraper/cli.js.map +1 -1
- package/dist/scraper/config.d.ts +13 -1
- package/dist/scraper/config.d.ts.map +1 -1
- package/dist/scraper/config.js +58 -2
- package/dist/scraper/config.js.map +1 -1
- package/dist/scraper/scrape.d.ts.map +1 -1
- package/dist/scraper/scrape.js +39 -6
- package/dist/scraper/scrape.js.map +1 -1
- package/dist/sources/index.d.ts +14 -0
- package/dist/sources/index.d.ts.map +1 -0
- package/dist/sources/index.js +25 -0
- package/dist/sources/index.js.map +1 -0
- package/dist/sources/rpc-log-source.d.ts +17 -0
- package/dist/sources/rpc-log-source.d.ts.map +1 -0
- package/dist/sources/rpc-log-source.js +43 -0
- package/dist/sources/rpc-log-source.js.map +1 -0
- package/dist/sources/subsquid-source.d.ts +38 -0
- package/dist/sources/subsquid-source.d.ts.map +1 -0
- package/dist/sources/subsquid-source.js +191 -0
- package/dist/sources/subsquid-source.js.map +1 -0
- package/dist/sources/types.d.ts +7 -0
- package/dist/sources/types.d.ts.map +1 -0
- package/dist/sources/types.js +2 -0
- package/dist/sources/types.js.map +1 -0
- package/dist/storage/gcs-store.d.ts +1 -0
- package/dist/storage/gcs-store.d.ts.map +1 -1
- package/dist/storage/gcs-store.js +34 -3
- package/dist/storage/gcs-store.js.map +1 -1
- 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 {
|
|
1
|
+
import type { CanonicalRecord } from "@saga-sync/core";
|
|
2
2
|
export type CompletedChunk = {
|
|
3
|
-
events:
|
|
3
|
+
events: CanonicalRecord[];
|
|
4
4
|
from: bigint;
|
|
5
5
|
to: bigint;
|
|
6
6
|
};
|
|
7
7
|
export type Trailing = {
|
|
8
|
-
events:
|
|
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:
|
|
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,
|
|
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":"AAKA,qEAAqE;AACrE,+EAA+E;AAC/E,0EAA0E;AAC1E,+EAA+E;AAC/E,0DAA0D;AAC1D,EAAE;AACF,6EAA6E;AAC7E,0EAA0E;AAC1E,+EAA+E;AAC/E,2DAA2D;AAC3D,MAAM,OAAO,gBAAgB;IAQR;IACT;IARF,WAAW,
|
|
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 {
|
|
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:
|
|
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:
|
|
13
|
-
writeHotHead(protocolId: string, events:
|
|
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<
|
|
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,
|
|
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
|
|
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":"AAAA,OAAO,EAAE,QAAQ,EAAE,UAAU,EAAE,MAAM,WAAW,CAAC;AACjD,OAAO,EAAE,WAAW,EAAE,MAAM,MAAM,CAAC;AACnC,OAAO,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAO5C,
|
|
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 {
|
|
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:
|
|
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:
|
|
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,
|
|
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 {
|
|
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
|
|
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, {
|
|
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"));
|