@effectstream/runtime 0.100.27 → 0.101.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/runtime",
3
- "version": "0.100.27",
3
+ "version": "0.101.0",
4
4
  "license": "MIT",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -13,17 +13,17 @@
13
13
  },
14
14
  "exports": "./src/mod.ts",
15
15
  "dependencies": {
16
- "@effectstream/concise": "0.100.27",
17
- "@effectstream/config": "0.100.27",
18
- "@effectstream/coroutine": "0.100.27",
19
- "@effectstream/crypto": "0.100.27",
20
- "@effectstream/db": "0.100.27",
21
- "@effectstream/event-client": "0.100.27",
22
- "@effectstream/event-server": "0.100.27",
23
- "@effectstream/log": "0.100.27",
24
- "@effectstream/sm": "0.100.27",
25
- "@effectstream/sync": "0.100.27",
26
- "@effectstream/utils": "0.100.27",
16
+ "@effectstream/concise": "0.101.0",
17
+ "@effectstream/config": "0.101.0",
18
+ "@effectstream/coroutine": "0.101.0",
19
+ "@effectstream/crypto": "0.101.0",
20
+ "@effectstream/db": "0.101.0",
21
+ "@effectstream/event-client": "0.101.0",
22
+ "@effectstream/event-server": "0.101.0",
23
+ "@effectstream/log": "0.101.0",
24
+ "@effectstream/sm": "0.101.0",
25
+ "@effectstream/sync": "0.101.0",
26
+ "@effectstream/utils": "0.101.0",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
29
  "pg": "8.21.0",
package/src/coalesce.ts CHANGED
@@ -39,9 +39,13 @@ export function* loadEmptyRunBoundaries(
39
39
  ): Operation<EmptyRunBoundaries> {
40
40
  const [bh] = yield* until(getEarliestScheduledBlockHeight.run(undefined, dbConn));
41
41
  const [ts] = yield* until(getEarliestScheduledTimestamp.run(undefined, dbConn));
42
- // Ignore scheduled block heights already in the past: a stale unprocessed row
43
- // (e.g. from a DB reset) would otherwise permanently suppress coalescing for
44
- // all current blocks, since every block number >= the stale height.
42
+ // getEarliestScheduledBlockHeight now excludes heights at/below the last applied
43
+ // block in SQL, so this is always an upcoming schedule (or null when none pends).
44
+ // The `>= fromBlockNumber` check is a belt-and-suspenders guard. BEWARE the old
45
+ // shape: that query returned the global MIN, and a stale below-head row (e.g. an
46
+ // input that was already skipped) collapsed the boundary to null *here* — which
47
+ // disables coalescing's scheduled-input guard entirely, so every later scheduled
48
+ // input inside an empty run gets coalesced over. One orphan permanently broke it.
45
49
  const minScheduledBlockHeight = bh?.min_block_height != null && bh.min_block_height >= fromBlockNumber
46
50
  ? bh.min_block_height
47
51
  : null;
@@ -70,6 +70,23 @@ function extractImmutableConfig(protocol: SyncProtocolWithNetwork): Record<strin
70
70
  return { startBlockHeight: sp.startBlockHeight };
71
71
  }
72
72
 
73
+ /**
74
+ * Compares a saved snapshot value against the current in-memory value.
75
+ * Falls back to structural JSON comparison for nested objects (which are
76
+ * always freshly constructed plain objects with stable key-insertion order
77
+ * from `extractImmutableConfig`), and to reference/identity comparison for
78
+ * everything else.
79
+ */
80
+ function valuesDiffer(saved: unknown, current: unknown): boolean {
81
+ if (
82
+ typeof saved === "object" && saved !== null &&
83
+ typeof current === "object" && current !== null
84
+ ) {
85
+ return JSON.stringify(saved) !== JSON.stringify(current);
86
+ }
87
+ return saved !== current;
88
+ }
89
+
73
90
  /**
74
91
  * Checks the saved config snapshot for each sync protocol against the current config.
75
92
  *
@@ -85,7 +102,6 @@ export function* validateAndSnapshotConfig(
85
102
  syncInfo: SyncProtocolWithNetwork[],
86
103
  dbConn: Client,
87
104
  ): Operation<void> {
88
- console.log("validateAndSnapshotConfig");
89
105
  const useDbStartHeight = getEnv("USE_DB_STARTHEIGHT") !== undefined;
90
106
 
91
107
  for (const protocol of syncInfo) {
@@ -119,20 +135,9 @@ export function* validateAndSnapshotConfig(
119
135
 
120
136
  const saved = rows[0].immutable_config as Record<string, unknown>;
121
137
  const mismatches: string[] = [];
122
- console.log("saved", saved);
123
138
  for (const [key, savedValue] of Object.entries(saved)) {
124
- console.log("key", key, savedValue, currentImmutable[key]);
125
- // if (currentImmutable[key] !== savedValue) {
126
- // mismatches.push(` ${key}: saved=${JSON.stringify(savedValue)}, current=${JSON.stringify(currentImmutable[key])}`);
127
- // }
128
- if (typeof savedValue === "object" && savedValue !== null) {
129
- for (const [k, v] of Object.entries(savedValue)) {
130
- if ((currentImmutable[key] as any)[k] !== v) {
131
- mismatches.push(` ${key}.${k}: saved=${JSON.stringify(v)},\n current=${JSON.stringify((currentImmutable[key] as any)[k])}`);
132
- }
133
- }
134
- } else if (currentImmutable[key] !== savedValue) {
135
- mismatches.push(` ${key}: saved=${JSON.stringify(savedValue)},\n current=${JSON.stringify(currentImmutable[key])}`);
139
+ if (valuesDiffer(savedValue, currentImmutable[key])) {
140
+ mismatches.push(` ${key}: saved=${JSON.stringify(savedValue)}, current=${JSON.stringify(currentImmutable[key])}`);
136
141
  }
137
142
  }
138
143
 
@@ -260,6 +260,7 @@ export function* processFinalizedBlock(
260
260
  blockEvents.push(...inputEvents);
261
261
  }
262
262
  } catch (err) {
263
+ if (isTransientPgError(err)) throw err;
263
264
  success = false;
264
265
  log.remote(
265
266
  ComponentNames.EFFECTSTREAM_SYNC,
@@ -0,0 +1,99 @@
1
+ import { expect, test, mock, beforeEach, afterEach } from "bun:test";
2
+ import { run } from "effection";
3
+ import type { Client } from "pg";
4
+ import { ConfigNetworkType, ConfigSyncProtocolType } from "@effectstream/config";
5
+ import type { SyncProtocolWithNetwork } from "@effectstream/config";
6
+
7
+ /**
8
+ * In-memory backing store for the mocked `@effectstream/db` query objects.
9
+ * `validateAndSnapshotConfig` only touches two queries — `get…Snapshot`
10
+ * (returns rows) and `upsert…Snapshot` (writes) — so stubbing those two is
11
+ * sufficient to exercise the comparison loop end-to-end without a real DB.
12
+ */
13
+ type StoredRow = { networkType: ConfigNetworkType; immutable_config: unknown };
14
+ let store: Record<string, StoredRow | undefined> = {};
15
+
16
+ mock.module("@effectstream/db", () => ({
17
+ getSyncProtocolConfigSnapshot: {
18
+ run: ({ protocolName }: { protocolName: string }) =>
19
+ Promise.resolve(store[protocolName] ? [store[protocolName]] : []),
20
+ },
21
+ upsertSyncProtocolConfigSnapshot: {
22
+ run: ({
23
+ protocolName,
24
+ networkType,
25
+ immutableConfig,
26
+ }: {
27
+ protocolName: string;
28
+ networkType: ConfigNetworkType;
29
+ immutableConfig: string;
30
+ }) => {
31
+ store[protocolName] = { networkType, immutable_config: JSON.parse(immutableConfig) };
32
+ return Promise.resolve([]);
33
+ },
34
+ },
35
+ }));
36
+
37
+ const { validateAndSnapshotConfig } = await import("../src/config-snapshot.ts");
38
+
39
+ const STUB_DB = {} as Client;
40
+
41
+ function cardanoUtxoRpcProtocol(
42
+ startChainPoint: unknown,
43
+ ): SyncProtocolWithNetwork {
44
+ return {
45
+ networkType: ConfigNetworkType.CARDANO,
46
+ syncProtocolType: ConfigSyncProtocolType.CARDANO_UTXORPC_PARALLEL,
47
+ syncProtocol: { name: "cardano-utxorpc", startChainPoint },
48
+ network: {},
49
+ } as unknown as SyncProtocolWithNetwork;
50
+ }
51
+
52
+ async function runValidate(
53
+ protocols: SyncProtocolWithNetwork[],
54
+ ): Promise<void> {
55
+ await run(function* () {
56
+ yield* validateAndSnapshotConfig(protocols, STUB_DB);
57
+ });
58
+ }
59
+
60
+ beforeEach(() => {
61
+ store = {};
62
+ delete process.env.USE_DB_STARTHEIGHT;
63
+ });
64
+
65
+ afterEach(() => {
66
+ delete process.env.USE_DB_STARTHEIGHT;
67
+ });
68
+
69
+ test("first run persists the snapshot and succeeds", async () => {
70
+ const protocol = cardanoUtxoRpcProtocol({ slot: 1, hash: "0xabc" });
71
+ await expect(runValidate([protocol])).resolves.toBeUndefined();
72
+ expect(store["cardano-utxorpc"]?.immutable_config).toEqual({
73
+ startChainPoint: { slot: 1, hash: "0xabc" },
74
+ });
75
+ });
76
+
77
+ test("matching nested-object snapshot succeeds on subsequent runs", async () => {
78
+ // Seed the snapshot from a first run.
79
+ await runValidate([cardanoUtxoRpcProtocol({ slot: 5, hash: "0xdead" })]);
80
+
81
+ // Same shape on restart — must not throw.
82
+ await expect(
83
+ runValidate([cardanoUtxoRpcProtocol({ slot: 5, hash: "0xdead" })]),
84
+ ).resolves.toBeUndefined();
85
+ });
86
+
87
+ test("reports a clean mismatch (no TypeError) when a saved nested object has no current counterpart", async () => {
88
+ // Snapshot persisted with a nested startChainPoint object.
89
+ await runValidate([cardanoUtxoRpcProtocol({ slot: 42, hash: "0xbeef" })]);
90
+
91
+ // On restart the current config has lost that field (shape drift / undefined).
92
+ const drifted = cardanoUtxoRpcProtocol(undefined);
93
+
94
+ // The robust comparator must surface this as a clean CRITICAL mismatch
95
+ // rather than throwing a `TypeError: Cannot read properties of undefined`.
96
+ await expect(runValidate([drifted])).rejects.toThrow(
97
+ /\[config-snapshot\] CRITICAL/,
98
+ );
99
+ });