@effectstream/sync 0.100.13 → 0.100.15

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/README.md ADDED
@@ -0,0 +1,68 @@
1
+ # @effectstream/sync
2
+
3
+ The blockchain-sync service inside an EffectStream node. Reads finalized
4
+ blocks from every chain you've configured (EVM, Bitcoin, Cardano, Midnight,
5
+ Avail, Celestia, NEAR…), normalizes them into a single rollup ordering,
6
+ and stages the inputs the state machine consumes.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ bun add @effectstream/sync
12
+ # or
13
+ npm install @effectstream/sync
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ This package pairs with [`@effectstream/runtime`](https://www.npmjs.com/package/@effectstream/runtime),
19
+ which boots sync as part of `start()`: it calls `genSyncProtocols(...)`
20
+ against the `syncProtocols` section of your
21
+ [`@effectstream/config`](https://www.npmjs.com/package/@effectstream/config),
22
+ then drives the resulting fetcher + state pairs every block. As an app
23
+ author you declare which protocols to sync in your config; everything
24
+ else runs automatically.
25
+
26
+ If you're building a new chain integration, implement the sync-protocol
27
+ interfaces in [`src/sync-protocols/`](https://github.com/PaimaStudios/paima-engine/tree/main/packages/node-sdk/sync/src/sync-protocols).
28
+
29
+ ## Inside EffectStream
30
+
31
+ `genSyncProtocols(config)` is what the runtime calls during boot. It walks
32
+ the `syncProtocols` section of your `@effectstream/config`, instantiates a
33
+ fetcher for each chain (viem for EVM, UTXORpc for Cardano, the Midnight
34
+ SDK for Midnight, etc.), and writes paginated blocks into PostgreSQL
35
+ through `@effectstream/db`. The state machine then drains that queue.
36
+
37
+ ```typescript
38
+ import { genSyncProtocols } from "@effectstream/sync";
39
+ // inside the runtime startup path:
40
+ const protocols = await genSyncProtocols(config);
41
+ // protocols.parallelEvmRPC_fast.runOne() // poll one block
42
+ ```
43
+
44
+ ## Key exports
45
+
46
+ - `genSyncProtocols(dbConn, syncInfo)` — Effection generator that instantiates a runtime fetcher + state pair for every protocol in `syncInfo` (from `config.syncProtocols`). Called from the runtime's process-blocks loop.
47
+ - `AllSyncProtocols` — union type covering every supported protocol; useful when authoring config that fans out.
48
+ - `ChainBlock`, plus base `Fetcher`/`State` types from `sync-protocols/base/` — the wire shape per chain.
49
+
50
+ Per-chain `Fetcher` / `SyncState` classes (`EvmFetcher`,
51
+ `BitcoinFetcher`, `MidnightFetcher`, `AvailFetcher`, `UtxoRpcFetcher`,
52
+ `NtpFetcher`, `CelestiaFetcher`, `NearFetcher`, and matching `*SyncState`
53
+ classes) are exported but are internal to the factory wiring —
54
+ application code drives them through `genSyncProtocols` rather than
55
+ instantiating them directly. Reach for them only if you're writing a
56
+ custom orchestration layer.
57
+
58
+ ## Examples
59
+
60
+ End-to-end sync test (boots a node, reads blocks, asserts the DB):
61
+ [`e2e/evm/sync/`](https://github.com/PaimaStudios/paima-engine/tree/main/e2e/evm/sync).
62
+
63
+ Runnable: [`test/examples.test.ts`](./test/examples.test.ts).
64
+
65
+ ## Links
66
+
67
+ - Docs: https://effectstream.github.io/docs/packages/node/sync
68
+ - Source: https://github.com/PaimaStudios/paima-engine/tree/main/packages/node-sdk/sync
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/sync",
3
- "version": "0.100.13",
3
+ "version": "0.100.15",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,10 +13,10 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/config": "0.100.13",
17
- "@effectstream/db": "0.100.13",
18
- "@effectstream/log": "0.100.13",
19
- "@effectstream/utils": "0.100.13",
16
+ "@effectstream/config": "0.100.15",
17
+ "@effectstream/db": "0.100.15",
18
+ "@effectstream/log": "0.100.15",
19
+ "@effectstream/utils": "0.100.15",
20
20
  "@midnight-ntwrk/ledger-v8": "8.0.3",
21
21
  "@midnight-ntwrk/midnight-js-indexer-public-data-provider": "4.0.2",
22
22
  "@midnight-ntwrk/onchain-runtime": "npm:@midnight-ntwrk/onchain-runtime-v3@3.0.0",
@@ -48,11 +48,16 @@ export class AvailFetcher extends BaseDataFetcher<
48
48
  lastPage: LastPage<Page, RootPage> | undefined,
49
49
  ): Operation<DataFetched<Output, Page, RootPage>> {
50
50
  const outputs: OutputAndCleanup<Output>[] = [];
51
+ const fetchAllBlocks = this.config.primitives.some(
52
+ (p) => p.primitive.getAllBlockHeaders,
53
+ );
51
54
  console.log(
52
55
  `[Avail] Fetching blocks from ${data.from} to ${data.to}. ${
53
56
  data.isPresync ? "[presync]" : ""
54
57
  }`,
55
58
  );
59
+
60
+ let lastHeader: Output["raw"] | undefined;
56
61
  for (let height = data.from; height <= data.to; height++) {
57
62
  let header;
58
63
  try {
@@ -60,26 +65,28 @@ export class AvailFetcher extends BaseDataFetcher<
60
65
  this.client.getBlockHeaderFromHeight(height)
61
66
  );
62
67
  } catch (_e) {
63
- // Likely reached the tip; stop here
64
68
  break;
65
69
  }
66
70
 
71
+ lastHeader = header;
67
72
  const primitives = yield* this.readPrimitives(
68
73
  height,
69
74
  header,
70
75
  this.config.primitives,
71
76
  );
72
77
 
73
- outputs.push({
74
- output: {
75
- raw: header,
76
- primitives,
77
- },
78
- cleanup: () => {},
79
- });
78
+ if (fetchAllBlocks || primitives.length > 0 || height === data.to) {
79
+ outputs.push({
80
+ output: {
81
+ raw: header,
82
+ primitives,
83
+ },
84
+ cleanup: () => {},
85
+ });
86
+ }
80
87
  }
81
88
 
82
- if (outputs.length === 0) {
89
+ if (!lastHeader) {
83
90
  if (!lastPage) {
84
91
  throw new Error(
85
92
  `Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
@@ -91,16 +98,18 @@ export class AvailFetcher extends BaseDataFetcher<
91
98
  };
92
99
  }
93
100
 
94
- const lastOutput = outputs[outputs.length - 1].output;
95
101
  return {
96
102
  output: outputs,
97
103
  lastPage: {
98
- ownBlockNumber: lastOutput.raw.number,
104
+ ownBlockNumber: lastHeader.number,
99
105
  own: {
100
- height: lastOutput.raw.number,
101
- hash: lastOutput.raw.hash,
106
+ height: lastHeader.number,
107
+ hash: lastHeader.hash,
102
108
  },
103
- root: rootConversion.toRootPage(lastOutput),
109
+ root: rootConversion.toRootPage({
110
+ raw: lastHeader,
111
+ primitives: [],
112
+ }),
104
113
  },
105
114
  };
106
115
  }
@@ -111,24 +111,31 @@ export class BitcoinFetcher
111
111
  );
112
112
  const groupedByPage = this.groupByPage(primitives);
113
113
 
114
+ const fetchAllBlocks = this.config.primitives.some(
115
+ (p) => p.primitive.getAllBlockHeaders,
116
+ );
117
+
114
118
  const outputs: OutputAndCleanup<Output>[] = [];
119
+ let lastBlock: BitcoinBlock | undefined;
115
120
  for (let height = Number(data.from); height <= Number(data.to); height++) {
116
121
  const blockHeight = height as Page;
117
122
  const block = yield* call(() => pageFetcher(blockHeight));
118
- outputs.push({
119
- output: {
120
- raw: block,
121
- primitives: groupedByPage[String(height)] ?? [],
122
- blockHashes: [block.hash],
123
- },
124
- cleanup: () => {},
125
- });
123
+ lastBlock = block;
124
+ const blockPrimitives = groupedByPage[String(height)] ?? [];
125
+ if (fetchAllBlocks || blockPrimitives.length > 0 || height === Number(data.to)) {
126
+ outputs.push({
127
+ output: {
128
+ raw: block,
129
+ primitives: blockPrimitives,
130
+ blockHashes: [block.hash],
131
+ },
132
+ cleanup: () => {},
133
+ });
134
+ }
126
135
  }
127
- const lastOutput = outputs[outputs.length - 1];
128
- if (!lastOutput) {
136
+ if (!lastBlock) {
129
137
  throw new Error("BitcoinFetcher: no block data fetched for interval");
130
138
  }
131
- const lastBlock = lastOutput.output.raw;
132
139
  const lastPageRef = this.asPage(Number(data.to));
133
140
  return {
134
141
  output: outputs,
@@ -47,6 +47,9 @@ export class CelestiaFetcher extends BaseDataFetcher<
47
47
  lastPage: LastPage<Page, RootPage> | undefined,
48
48
  ): Operation<DataFetched<Output, Page, RootPage>> {
49
49
  const outputs: OutputAndCleanup<Output>[] = [];
50
+ const fetchAllBlocks = this.config.primitives.some(
51
+ (p) => p.primitive.getAllBlockHeaders,
52
+ );
50
53
 
51
54
  console.log(
52
55
  `[Celestia] Fetching blocks from ${data.from} to ${data.to}.${
@@ -54,6 +57,7 @@ export class CelestiaFetcher extends BaseDataFetcher<
54
57
  }`,
55
58
  );
56
59
 
60
+ let lastFetchedOutput: Output | undefined;
57
61
  for (let height = data.from; height <= data.to; height++) {
58
62
  let extHeader;
59
63
  try {
@@ -61,7 +65,6 @@ export class CelestiaFetcher extends BaseDataFetcher<
61
65
  this.client.getHeaderAtHeight(height)
62
66
  );
63
67
  } catch (_e) {
64
- // Likely reached the chain tip; stop here
65
68
  break;
66
69
  }
67
70
 
@@ -73,18 +76,23 @@ export class CelestiaFetcher extends BaseDataFetcher<
73
76
  this.config.primitives,
74
77
  );
75
78
 
76
- outputs.push({
77
- output: {
78
- timestamp: extHeader.header.time,
79
- height,
80
- hash: blockHash,
81
- primitives,
82
- },
83
- cleanup: () => {},
84
- });
79
+ const output: Output = {
80
+ timestamp: extHeader.header.time,
81
+ height,
82
+ hash: blockHash,
83
+ primitives,
84
+ };
85
+ lastFetchedOutput = output;
86
+
87
+ if (fetchAllBlocks || primitives.length > 0 || height === data.to) {
88
+ outputs.push({
89
+ output,
90
+ cleanup: () => {},
91
+ });
92
+ }
85
93
  }
86
94
 
87
- if (outputs.length === 0) {
95
+ if (!lastFetchedOutput) {
88
96
  if (!lastPage) {
89
97
  throw new Error(
90
98
  `[Celestia] Could not fetch any blocks from ${data.from} to ${data.to} and no previous page was found.`,
@@ -96,16 +104,15 @@ export class CelestiaFetcher extends BaseDataFetcher<
96
104
  };
97
105
  }
98
106
 
99
- const lastOutput = outputs[outputs.length - 1].output;
100
107
  return {
101
108
  output: outputs,
102
109
  lastPage: {
103
- ownBlockNumber: lastOutput.height,
110
+ ownBlockNumber: lastFetchedOutput.height,
104
111
  own: {
105
- height: lastOutput.height,
106
- hash: lastOutput.hash,
112
+ height: lastFetchedOutput.height,
113
+ hash: lastFetchedOutput.hash,
107
114
  },
108
- root: rootConversion.toRootPage(lastOutput),
115
+ root: rootConversion.toRootPage(lastFetchedOutput),
109
116
  },
110
117
  };
111
118
  }
@@ -25,7 +25,7 @@ import type {
25
25
  import { PageSchema } from "./types.ts";
26
26
  import {
27
27
  fetchNewestPage,
28
- genImmediatePageRequests,
28
+ genOnDemandPageRequests,
29
29
  type PageRange,
30
30
  type PageRequest,
31
31
  } from "../base/page.ts";
@@ -62,15 +62,17 @@ export class EvmFetcher
62
62
  data: Input,
63
63
  rootConversion: RootConversion<Output, RootOutput, RootPage>,
64
64
  ): Operation<DataFetched<Output, Page, RootPage>> {
65
- const allPages: Page[] = [];
66
- for (let p = data.from; p <= data.to; p++) allPages.push(p);
67
- const pageFetcher = genImmediatePageRequests(
68
- allPages,
65
+ const fetchAllBlocks = this.config.primitives.some(
66
+ (p) => p.primitive.getAllBlockHeaders,
67
+ );
68
+
69
+ const pageFetcher = genOnDemandPageRequests(
70
+ data.from,
71
+ data.to,
69
72
  (page) => this.client.getBlock({ blockNumber: BigInt(page) }),
73
+ blockNumberRelation,
70
74
  );
71
75
 
72
- // TODO: if we expect multiple primitives per block
73
- // it can, depending on the chain, be faster to just download the full block and parse it locally
74
76
  const primitives = yield* this.readPrimitives(
75
77
  data,
76
78
  pageFetcher,
@@ -92,40 +94,43 @@ export class EvmFetcher
92
94
  };
93
95
  }),
94
96
  ),
95
- // we always need to get the last page in the parallel case
96
- // so we can use it for pagination
97
+ // we always need the last page for pagination
97
98
  call(() => pageFetcher(Number(data.to))),
98
99
  ],
99
100
  );
100
101
 
101
- // Build an output with all page info, as we need all the hashes to build the effectstream-block-hash
102
- // All blocks are already pre-fetched in parallel by genImmediatePageRequests above
103
102
  const allOutputs: Output[] = [];
104
103
  for (let page = data.from; page <= data.to; page++) {
105
104
  const _output = output.find((o) => o.raw.number === BigInt(page));
106
105
  if (_output) {
107
106
  allOutputs.push(_output);
108
- } else {
107
+ } else if (fetchAllBlocks) {
109
108
  const raw = yield* call(() => pageFetcher(page));
110
109
  allOutputs.push({
111
110
  raw,
112
111
  primitives: [],
113
112
  blockHashes: [raw.hash] as EvmBlockHash[],
114
113
  });
114
+ } else if (page === data.to) {
115
+ allOutputs.push({
116
+ raw: lastPage,
117
+ primitives: [],
118
+ blockHashes: [lastPage.hash] as EvmBlockHash[],
119
+ });
115
120
  }
116
121
  }
117
122
 
118
123
  return {
119
124
  output: allOutputs.map((o) => ({
120
125
  output: o,
121
- cleanup: () => {}, // no cleanup required
126
+ cleanup: () => {},
122
127
  })),
123
128
  lastPage: {
124
129
  own: Number(data.to),
125
130
  ownBlockNumber: Number(data.to),
126
131
  root: rootConversion.toRootPage({
127
132
  blockHashes: [lastPage.hash] as EvmBlockHash[],
128
- primitives: [], // unused in toRootPage
133
+ primitives: [],
129
134
  raw: lastPage,
130
135
  }),
131
136
  },
@@ -98,11 +98,11 @@ export class MidnightFetcher extends BaseDataFetcher<
98
98
  { length: data.to - data.from + 1 },
99
99
  (_, i) => i + data.from,
100
100
  );
101
+ const fetchAllBlocks = this.config.primitives.some(
102
+ (p) => p.primitive.getAllBlockHeaders,
103
+ );
104
+
101
105
  const fetched = yield* all(
102
- // The endpoint does not support range requests,
103
- // so we fetch each block individually in parallel.
104
- // useAbortSignal() ties each fetch's lifetime to its Effection scope,
105
- // so cancellation by all() cleanly aborts the underlying HTTP request.
106
106
  heights.map(function* (height) {
107
107
  const signal = yield* useAbortSignal();
108
108
  const result: MidnightGqlBlockState = yield* call(() =>
@@ -122,9 +122,22 @@ export class MidnightFetcher extends BaseDataFetcher<
122
122
  };
123
123
  }),
124
124
  );
125
- outputs.push(...fetched);
126
125
 
127
- const lastOutput = outputs[outputs.length - 1].output;
126
+ const lastFetched = fetched[fetched.length - 1];
127
+ if (fetchAllBlocks) {
128
+ outputs.push(...fetched);
129
+ } else {
130
+ for (const item of fetched) {
131
+ if (item.output.primitives.length > 0) {
132
+ outputs.push(item);
133
+ }
134
+ }
135
+ if (outputs.length === 0 || outputs[outputs.length - 1] !== lastFetched) {
136
+ outputs.push(lastFetched);
137
+ }
138
+ }
139
+
140
+ const lastOutput = lastFetched.output;
128
141
  return {
129
142
  output: outputs,
130
143
  lastPage: {
@@ -76,6 +76,20 @@ export class WatchMultiplexer {
76
76
  throw new Error("No watchers configured");
77
77
  }
78
78
 
79
+ if (this.slotOriginTimestamp === undefined) {
80
+ try {
81
+ const blocks = await this.syncClient.fetchHistory(undefined, 1);
82
+ if (blocks.length > 0) {
83
+ const blk = blocks[0].parsedBlock;
84
+ if (blk.timestamp && blk.header?.slot != null) {
85
+ this.slotOriginTimestamp = blk.timestamp - blk.header.slot;
86
+ }
87
+ }
88
+ } catch {
89
+ // Chain may not be ready yet — slotOriginTimestamp will be set on the first Apply event
90
+ }
91
+ }
92
+
79
93
  const intersect = point ? [point] : [];
80
94
  const promises: Promise<void>[] = [];
81
95
  for (const [key, watcher] of this.watchers) {
@@ -40,34 +40,36 @@ export class UtxoRpcFetcher
40
40
  );
41
41
  const outputs: OutputAndCleanup<Output>[] = [];
42
42
  const blocks = this.multiplexer.fetchBlocks(data.from, data.to);
43
+ let lastEntry: typeof blocks[number] | undefined;
43
44
  for (const entry of blocks) {
45
+ lastEntry = entry;
44
46
  const completed = entry.output;
45
- outputs.push({
46
- output: {
47
- blockHashes: [completed.hash],
48
- raw: completed.block,
49
- primitives: this.findPrimitivesFromWatchData(completed),
50
- },
51
- cleanup: entry.cleanup,
52
- });
47
+ const primitives = this.findPrimitivesFromWatchData(completed);
48
+ if (primitives.length > 0) {
49
+ outputs.push({
50
+ output: {
51
+ blockHashes: [completed.hash],
52
+ raw: completed.block,
53
+ primitives,
54
+ },
55
+ cleanup: entry.cleanup,
56
+ });
57
+ }
53
58
  }
59
+ const lastCompleted = lastEntry!.output;
54
60
  return {
55
61
  output: outputs,
56
62
  lastPage: {
57
63
  ownBlockNumber: Number(data.to),
58
64
  own: {
59
- slot: Number(
60
- outputs[outputs.length - 1].output.raw.header!.slot,
61
- ),
62
- height: Number(
63
- outputs[outputs.length - 1].output.raw.header!.height,
64
- ),
65
- hash: outputs[outputs.length - 1].output.blockHashes[0],
65
+ slot: Number(lastCompleted.block.header!.slot),
66
+ height: Number(lastCompleted.block.header!.height),
67
+ hash: lastCompleted.hash,
66
68
  },
67
69
  root: rootConversion.toRootPage({
68
70
  blockHashes: [],
69
71
  primitives: [],
70
- raw: outputs[outputs.length - 1].output.raw,
72
+ raw: lastCompleted.block,
71
73
  }),
72
74
  },
73
75
  };
@@ -0,0 +1,18 @@
1
+ // Examples for the README — verify public exports resolve.
2
+
3
+ import { test, expect } from "bun:test";
4
+ import * as sync from "../src/mod.ts";
5
+
6
+ test("README: genSyncProtocols is exported as a generator factory", () => {
7
+ expect(typeof sync.genSyncProtocols).toBe("function");
8
+ });
9
+
10
+ test("README: per-chain fetchers and states are exported", () => {
11
+ // A sampling of the protocols supported by the package.
12
+ expect("EvmFetcher" in sync).toBe(true);
13
+ expect("BitcoinFetcher" in sync).toBe(true);
14
+ expect("MidnightFetcher" in sync).toBe(true);
15
+ expect("AvailFetcher" in sync).toBe(true);
16
+ expect("UtxoRpcFetcher" in sync).toBe(true);
17
+ expect("CelestiaFetcher" in sync).toBe(true);
18
+ });