@effectstream/sync 0.101.1 → 0.103.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 (41) hide show
  1. package/CLAUDE.md +82 -18
  2. package/LICENSE-APACHE +201 -0
  3. package/LICENSE-MIT +21 -0
  4. package/README.md +63 -4
  5. package/docs/ADDING-A-SYNC-PROTOCOL.md +16 -9
  6. package/package.json +8 -8
  7. package/src/sync-protocols/avail/AvailClient.ts +34 -6
  8. package/src/sync-protocols/avail/fetcher.ts +2 -0
  9. package/src/sync-protocols/avail/state.ts +2 -0
  10. package/src/sync-protocols/base/state.ts +51 -0
  11. package/src/sync-protocols/bitcoin/fetcher.ts +18 -10
  12. package/src/sync-protocols/celestia/CelestiaClient.ts +16 -2
  13. package/src/sync-protocols/celestia/fetcher.ts +5 -1
  14. package/src/sync-protocols/celestia/state.ts +5 -1
  15. package/src/sync-protocols/common/http.ts +77 -0
  16. package/src/sync-protocols/midnight/MidnightClient.ts +11 -3
  17. package/src/sync-protocols/midnight/fetcher.ts +18 -11
  18. package/src/sync-protocols/midnight/types.ts +1 -1
  19. package/src/sync-protocols/midnight/zswap-decoder.ts +99 -101
  20. package/src/sync-protocols/near/NearClient.ts +16 -6
  21. package/src/sync-protocols/near/fetcher.ts +5 -1
  22. package/src/sync-protocols/near/state.ts +5 -1
  23. package/src/sync-protocols/orchestration/sync.ts +101 -13
  24. package/src/sync-protocols/solana/SolanaClient.ts +188 -0
  25. package/src/sync-protocols/solana/fetcher.ts +456 -0
  26. package/src/sync-protocols/solana/program-logs.test.ts +113 -0
  27. package/src/sync-protocols/solana/program-logs.ts +84 -0
  28. package/src/sync-protocols/solana/read-data-retry.test.ts +153 -0
  29. package/src/sync-protocols/solana/read-primitives.test.ts +435 -0
  30. package/src/sync-protocols/solana/root-page.test.ts +66 -0
  31. package/src/sync-protocols/solana/state.ts +167 -0
  32. package/src/sync-protocols/solana/types.ts +58 -0
  33. package/src/sync-protocols/types.ts +2 -0
  34. package/src/sync-protocols/utxorpc/WatchMultiplexer.ts +52 -2
  35. package/src/sync-protocols/utxorpc/state.ts +3 -0
  36. package/src/syncProtocolFactory.ts +15 -0
  37. package/test/poll-loop-spin.test.ts +112 -0
  38. package/test/rpc-timeout.test.ts +150 -0
  39. package/test/spin-probe.ts +102 -0
  40. package/test/start-async-supervision.test.ts +139 -0
  41. package/test/zswap-decoder.test.ts +74 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Child process for `poll-loop-spin.test.ts` (sync/CLAUDE.md Finding #3).
3
+ *
4
+ * Drives the REAL `startSync` fetch loop with a fake `SyncState` that is always
5
+ * "caught up" (`stateToInput` → `undefined`) — the steady state of any protocol
6
+ * whose tip advances slowly. The only variable is whether the protocol config
7
+ * carries a `pollingInterval`, because `orchestration/sync.ts` guards all three
8
+ * of its `sleep`s with `if ("pollingInterval" in config.syncProtocol)`.
9
+ *
10
+ * Runs in its own process because a loop with no sleep can starve the event
11
+ * loop outright, which would hang the test runner rather than fail it. The two
12
+ * exit paths below discriminate the outcomes:
13
+ *
14
+ * - `reason: "limit"` — the loop reached `limit` iterations. Reported via
15
+ * `writeSync` + `process.exit`, which work even when
16
+ * the event loop is starved.
17
+ * - `reason: "window"` — a `setTimeout` fired first, so the loop is paced and
18
+ * the event loop is healthy.
19
+ *
20
+ * Usage: bun spin-probe.ts '{"polling":20,"limit":50000,"windowMs":500,"mode":"call"}'
21
+ */
22
+ import { writeSync } from "node:fs";
23
+ import { call, type Operation, run } from "effection";
24
+ import { startSync } from "../src/sync-protocols/orchestration/sync.ts";
25
+
26
+ type ProbeSpec = {
27
+ /** `pollingInterval` for the fake protocol config; `null` omits the key entirely. */
28
+ polling: number | null;
29
+ /** Iteration count at which we declare the loop unpaced and bail out. */
30
+ limit: number;
31
+ /** Wall-clock budget; if this timer fires, the loop is paced. */
32
+ windowMs: number;
33
+ /**
34
+ * How `stateToInput` returns:
35
+ * - "sync" — returns `undefined` with no suspension at all (worst case).
36
+ * - "call" — `yield* call(() => <sync value>)` first, mirroring utxorpc's
37
+ * `stateToInput`, which calls the in-memory `fetcher.lastHeight()`
38
+ * before deciding it is caught up. This is the production shape.
39
+ */
40
+ mode: "sync" | "call";
41
+ };
42
+
43
+ const spec: ProbeSpec = JSON.parse(process.argv[2]);
44
+
45
+ const startedAt = Date.now();
46
+ let iterations = 0;
47
+
48
+ function report(reason: "limit" | "window"): never {
49
+ writeSync(
50
+ 1,
51
+ JSON.stringify({
52
+ reason,
53
+ iterations,
54
+ elapsedMs: Date.now() - startedAt,
55
+ }) + "\n",
56
+ );
57
+ process.exit(0);
58
+ }
59
+
60
+ // Fires only if the loop leaves room for macrotasks.
61
+ setTimeout(() => report("window"), spec.windowMs);
62
+
63
+ const syncProtocol: Record<string, unknown> = { name: "probe" };
64
+ if (spec.polling != null) syncProtocol.pollingInterval = spec.polling;
65
+
66
+ /**
67
+ * Minimal stand-in for a `SyncState`. Only the members `startSync` actually
68
+ * touches are implemented; it is cast at the call site the same way the real
69
+ * states are (`startSync` takes `AllSyncProtocols` and narrows to `ISyncProtocol`).
70
+ */
71
+ const fakeState = {
72
+ name: "probe",
73
+ consecutiveErrors: 0,
74
+ lastErrorTimestamp: 0,
75
+ lastSuccessfulFetchMs: 0,
76
+ getNamespace: () => ["probe", "probe"],
77
+ *startAsync(): Operation<void> {},
78
+ *stateToInput(): Operation<undefined> {
79
+ iterations++;
80
+ if (iterations >= spec.limit) report("limit");
81
+ if (spec.mode === "call") {
82
+ // utxorpc reads an in-memory tip here — a `call` over a synchronous value.
83
+ yield* call(() => 0);
84
+ }
85
+ // "caught up": nothing to fetch this pass.
86
+ return undefined;
87
+ },
88
+ fetcher: {
89
+ config: { syncProtocol },
90
+ producerChannel: { *send(): Operation<void> {} },
91
+ *readData(): Operation<never> {
92
+ throw new Error("unreachable: stateToInput always returns undefined");
93
+ },
94
+ },
95
+ *updateState(): Operation<void> {},
96
+ };
97
+
98
+ await run(function* () {
99
+ yield* startSync(fakeState as never);
100
+ // Park forever; one of the two exit paths above ends the process.
101
+ yield* call(() => new Promise<void>(() => {}));
102
+ });
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Regression guard for sync/CLAUDE.md Finding #5 (unsupervised `startAsync`).
3
+ *
4
+ * `orchestration/sync.ts` used to start the streaming producer with a bare
5
+ * `yield* spawn(function* () { yield* iState.startAsync(); })` — no try/catch,
6
+ * no restart, no liveness check. Streaming chains (utxorpc today, midnight if
7
+ * it moves to subscriptions) put ALL of their data on that path, so both of its
8
+ * exit modes were unhandled:
9
+ *
10
+ * 5a — the stream ENDS CLEANLY. `WatchMultiplexer.runWatcher` logs
11
+ * "stream ended unexpectedly" and returns; the spawned task completed
12
+ * normally. The chain then received nothing forever while every health
13
+ * counter stayed clean, and the merge blocked on its page — a total stall
14
+ * with nothing reporting an error.
15
+ *
16
+ * 5b — the stream THROWS. The error escaped the spawned task and tore down the
17
+ * enclosing scope, which in production is `start()`: one flaky gRPC
18
+ * connection took down every unrelated chain with it.
19
+ *
20
+ * Now supervised — both modes are treated as failure and restarted with capped
21
+ * exponential backoff, counted in `producerRestarts`. A returning producer
22
+ * counts as failure because the observable effect is identical: no more data.
23
+ *
24
+ * Supervision is gated on `hasAsyncProducer` so polled chains, whose
25
+ * `startAsync` is the base-class no-op, are still run exactly once (5c).
26
+ */
27
+ import { expect, test } from "bun:test";
28
+ import { type Operation, run, sleep } from "effection";
29
+ import { startSync } from "../src/sync-protocols/orchestration/sync.ts";
30
+
31
+ const POLL_MS = 20;
32
+
33
+ /**
34
+ * Minimal stand-in for a streaming `SyncState`. Only the members `startSync`
35
+ * touches are implemented. `stateToInput` always reports "caught up", which is
36
+ * what a streaming chain does while it waits for its producer to deliver.
37
+ */
38
+ function makeStreamingState(
39
+ startAsyncBody: () => Operation<void>,
40
+ hasAsyncProducer = true,
41
+ ) {
42
+ const counters = { startAsyncCalls: 0, pollPasses: 0 };
43
+ const state = {
44
+ name: "streamer",
45
+ consecutiveErrors: 0,
46
+ lastErrorTimestamp: 0,
47
+ lastSuccessfulFetchMs: 0,
48
+ hasAsyncProducer,
49
+ producerRestarts: 0,
50
+ producerErrors: 0,
51
+ lastProducerErrorMs: 0,
52
+ pollingIntervalMs: POLL_MS,
53
+ getNamespace: () => ["streamer", "streamer"],
54
+ *startAsync(): Operation<void> {
55
+ counters.startAsyncCalls++;
56
+ yield* startAsyncBody();
57
+ },
58
+ *stateToInput(): Operation<undefined> {
59
+ counters.pollPasses++;
60
+ return undefined;
61
+ },
62
+ fetcher: {
63
+ config: { syncProtocol: { name: "streamer", pollingInterval: POLL_MS } },
64
+ producerChannel: { *send(): Operation<void> {} },
65
+ *readData(): Operation<never> {
66
+ throw new Error("unreachable: stateToInput always returns undefined");
67
+ },
68
+ },
69
+ *updateState(): Operation<void> {},
70
+ };
71
+ return { state, counters };
72
+ }
73
+
74
+ test("5a: a producer that ends cleanly is restarted and counted", async () => {
75
+ // The multiplexer's `for await (const event of stream)` falling through —
76
+ // the stream closed and `runWatcher` returned.
77
+ const { state, counters } = makeStreamingState(function* () {
78
+ // returns immediately
79
+ });
80
+
81
+ await run(function* () {
82
+ yield* startSync(state as never);
83
+ // Long enough for the first restart (1s backoff) plus room to spare.
84
+ yield* sleep(1_500);
85
+ });
86
+
87
+ // Restarted rather than abandoned...
88
+ expect(counters.startAsyncCalls).toBeGreaterThan(1);
89
+ // ...and the restart is visible to health instead of being silent.
90
+ expect(state.producerRestarts).toBeGreaterThan(0);
91
+ // A clean return is not an *error*, so only the restart counter moves.
92
+ expect(state.producerErrors).toBe(0);
93
+ // The sibling polling loop is unaffected.
94
+ expect(counters.pollPasses).toBeGreaterThan(1);
95
+ }, 30_000);
96
+
97
+ test("5b: a producer that throws is restarted and does not kill the scope", async () => {
98
+ const { state, counters } = makeStreamingState(function* () {
99
+ yield* sleep(50);
100
+ throw new Error("gRPC stream died");
101
+ });
102
+
103
+ let rejection: unknown;
104
+ await run(function* () {
105
+ yield* startSync(state as never);
106
+ yield* sleep(1_500);
107
+ }).catch((err) => {
108
+ rejection = err;
109
+ });
110
+
111
+ // The scope survived: no error escaped to tear down `start()`.
112
+ expect(rejection).toBeUndefined();
113
+ // The throw was caught, counted, and the producer restarted.
114
+ expect(counters.startAsyncCalls).toBeGreaterThan(1);
115
+ expect(state.producerRestarts).toBeGreaterThan(0);
116
+ expect(state.producerErrors).toBeGreaterThan(0);
117
+ expect(state.lastProducerErrorMs).toBeGreaterThan(0);
118
+ // Counted on the PRODUCER's own tally, not the fetch loop's. Sharing
119
+ // `consecutiveErrors` conflated two signals: only a successful `readData`
120
+ // clears it, so an idle streaming chain stayed `erroring` long after its
121
+ // producer recovered, and a successful poll erased the record of a flap.
122
+ expect(state.consecutiveErrors).toBe(0);
123
+ // And the sibling polling loop kept running throughout.
124
+ expect(counters.pollPasses).toBeGreaterThan(1);
125
+ }, 30_000);
126
+
127
+ test("5c: a polled chain's no-op producer is run once, not supervised", async () => {
128
+ const { state, counters } = makeStreamingState(function* () {}, false);
129
+
130
+ await run(function* () {
131
+ yield* startSync(state as never);
132
+ yield* sleep(1_500);
133
+ });
134
+
135
+ // Without this gate, every polled chain would "restart" its no-op forever.
136
+ expect(counters.startAsyncCalls).toBe(1);
137
+ expect(state.producerRestarts).toBe(0);
138
+ expect(counters.pollPasses).toBeGreaterThan(1);
139
+ }, 30_000);
@@ -0,0 +1,74 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Buffer } from "node:buffer";
3
+ import { decodeZswapEvent } from "../src/sync-protocols/midnight/zswap-decoder.ts";
4
+
5
+ // Synthetic events built per the real midnight-ledger 8.x wire format:
6
+ // "midnight:event[v9]:" tag + TopoSortedNodes envelope wrapping the event
7
+ // struct. Both blobs round-trip through the native ledger-v8 deserializer.
8
+
9
+ function compactU32(n: number): Buffer {
10
+ if (n < 64) return Buffer.from([n << 2]);
11
+ if (n < 16384) {
12
+ const v = (n << 2) | 0b01;
13
+ return Buffer.from([v & 0xff, (v >> 8) & 0xff]);
14
+ }
15
+ throw new Error("value too large for this helper");
16
+ }
17
+
18
+ const TX_HASH = Buffer.alloc(32, 0xaa);
19
+ const BODY32 = Buffer.alloc(32, 0xbb);
20
+ // logical=3, physical=1
21
+ const SEGMENTS = Buffer.from([0x03, 0x00, 0x01, 0x00]);
22
+ const CONTRACT_NONE = Buffer.from([0x01]);
23
+
24
+ function wrapEnvelope(data: Buffer): string {
25
+ return Buffer.concat([
26
+ Buffer.from("midnight:event[v9]:", "ascii"),
27
+ compactU32(1), // node count
28
+ compactU32(0), // child count of the single node
29
+ compactU32(data.length),
30
+ data,
31
+ ]).toString("hex");
32
+ }
33
+
34
+ describe("decodeZswapEvent", () => {
35
+ test("decodes a ZswapInput (nullifier) event", () => {
36
+ const raw = wrapEnvelope(Buffer.concat([
37
+ TX_HASH,
38
+ SEGMENTS,
39
+ Buffer.from([0x00]), // variant: ZswapInput
40
+ BODY32, // nullifier
41
+ CONTRACT_NONE,
42
+ ]));
43
+ expect(decodeZswapEvent(raw)).toEqual({
44
+ kind: "nullifier",
45
+ nullifier: "bb".repeat(32),
46
+ txHash: "aa".repeat(32),
47
+ logicalSegment: 3,
48
+ });
49
+ });
50
+
51
+ test("decodes a ZswapOutput (commitment) event", () => {
52
+ const raw = wrapEnvelope(Buffer.concat([
53
+ TX_HASH,
54
+ SEGMENTS,
55
+ Buffer.from([0x01]), // variant: ZswapOutput
56
+ BODY32, // commitment
57
+ Buffer.from([0x02]), // ZswapPreimageEvidence::None
58
+ CONTRACT_NONE,
59
+ compactU32(7), // mt_index
60
+ ]));
61
+ expect(decodeZswapEvent(raw)).toEqual({
62
+ kind: "commitment",
63
+ commitment: "bb".repeat(32),
64
+ mtIndex: "7",
65
+ txHash: "aa".repeat(32),
66
+ logicalSegment: 3,
67
+ });
68
+ });
69
+
70
+ test("returns null for undecodable input", () => {
71
+ expect(decodeZswapEvent("deadbeef")).toBeNull();
72
+ expect(decodeZswapEvent("")).toBeNull();
73
+ });
74
+ });