@effectstream/runtime 0.100.12 → 0.100.14

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.12",
3
+ "version": "0.100.14",
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.12",
17
- "@effectstream/config": "0.100.12",
18
- "@effectstream/coroutine": "0.100.12",
19
- "@effectstream/crypto": "0.100.12",
20
- "@effectstream/db": "0.100.12",
21
- "@effectstream/event-client": "0.100.12",
22
- "@effectstream/event-server": "0.100.12",
23
- "@effectstream/log": "0.100.12",
24
- "@effectstream/sm": "0.100.12",
25
- "@effectstream/sync": "0.100.12",
26
- "@effectstream/utils": "0.100.12",
16
+ "@effectstream/concise": "0.100.14",
17
+ "@effectstream/config": "0.100.14",
18
+ "@effectstream/coroutine": "0.100.14",
19
+ "@effectstream/crypto": "0.100.14",
20
+ "@effectstream/db": "0.100.14",
21
+ "@effectstream/event-client": "0.100.14",
22
+ "@effectstream/event-server": "0.100.14",
23
+ "@effectstream/log": "0.100.14",
24
+ "@effectstream/sm": "0.100.14",
25
+ "@effectstream/sync": "0.100.14",
26
+ "@effectstream/utils": "0.100.14",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
29
  "pg": "8.17.2",
package/src/main.ts CHANGED
@@ -7,11 +7,13 @@ import {
7
7
  acquireDBMutex,
8
8
  createDynamicTables,
9
9
  getConnection,
10
+ getLastNonEmptyBlockHash,
10
11
  releaseDBMutex,
11
12
  resetPublicTables,
12
13
  runSnapshotLoop,
13
14
  } from "@effectstream/db";
14
15
  import { EventBroker } from "@effectstream/event-server";
16
+ import { ENV } from "@effectstream/utils/node-env";
15
17
  import {
16
18
  BuiltinEvents,
17
19
  EventManager,
@@ -27,7 +29,7 @@ import {
27
29
  until,
28
30
  } from "effection";
29
31
  import { initTelemetry } from "./telemetry.ts";
30
- import { processFinalizedBlock } from "./process-blocks.ts";
32
+ import { type PendingEvent, processFinalizedBlock } from "./process-blocks.ts";
31
33
  import { startHttpServer } from "./api/http-server.ts";
32
34
  import type { StartConfig } from "./types.ts";
33
35
  import type { Client } from "pg";
@@ -39,19 +41,6 @@ import { builtInPrimitivesMap } from "@effectstream/sm";
39
41
  import { validateAndSnapshotConfig } from "./config-snapshot.ts";
40
42
 
41
43
  export function* init() {
42
- // Prevent transient network errors (broken pipes, closed connections) from
43
- // crashing the process via unhandled promise rejections. The sync loop's
44
- // tryYield already retries on error — this just keeps the process alive.
45
- globalThis.addEventListener("unhandledrejection", (event) => {
46
- log.local(
47
- ComponentNames.EFFECTSTREAM_RUNTIME,
48
- "unhandled-rejection",
49
- SeverityNumber.WARN,
50
- (l) => l("Suppressed unhandled rejection:", event.reason),
51
- );
52
- event.preventDefault();
53
- });
54
-
55
44
  // initialize OpenTelemetry
56
45
  yield* initTelemetry();
57
46
  }
@@ -83,7 +72,9 @@ export function* start(config: StartConfig): Operation<void> {
83
72
  }
84
73
 
85
74
  // Create MQTT Broker
86
- new EventBroker("effectstream-engine").createServer();
75
+ if (ENV.MQTT_BROKER) {
76
+ new EventBroker("effectstream-engine").createServer();
77
+ }
87
78
 
88
79
  yield* spawn(function* () {
89
80
  yield* startHttpServer(
@@ -134,7 +125,10 @@ export function* start(config: StartConfig): Operation<void> {
134
125
  }
135
126
  });
136
127
 
137
- let blockHash: EffectstreamBlockHash | null = null;
128
+ const [lastHashRow] = yield* until(getLastNonEmptyBlockHash.run(undefined, dbConn));
129
+ let blockHash: EffectstreamBlockHash | null = lastHashRow
130
+ ? lastHashRow.effectstream_block_hash!.toString() as EffectstreamBlockHash
131
+ : null;
138
132
  if (config.snapshotConfig) {
139
133
  yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
140
134
  }
@@ -144,16 +138,25 @@ export function* start(config: StartConfig): Operation<void> {
144
138
  // For PGLite, this is not enough, as the can only be one connection at a time.
145
139
  // So we request a DBMutex as well.
146
140
  let dbClient: Client | undefined;
141
+ // Custom app events emitted by STFs during this block. Collected by
142
+ // processFinalizedBlock and flushed below ONLY after it returns — i.e.
143
+ // strictly after the block's COMMIT. See plan invariant I1.
144
+ let blockAppEvents: PendingEvent[] = [];
147
145
  try {
148
146
  yield* acquireDBMutex(`processing-blocks:${value.blockNumber}`);
149
147
  dbClient = yield* until((dbConn as any).connect()); // Client,
150
148
 
151
- blockHash = yield* processFinalizedBlock(
149
+ const result = yield* processFinalizedBlock(
152
150
  value,
153
151
  config,
154
152
  dbClient as any, // Client,
155
153
  blockHash,
156
154
  );
155
+ const resultHash = result.blockHash;
156
+ blockAppEvents = result.events;
157
+ if (resultHash !== "0x0") {
158
+ blockHash = resultHash;
159
+ }
157
160
  } finally {
158
161
  releaseDBMutex(`processing-blocks:${value.blockNumber}`);
159
162
  if (dbClient) {
@@ -163,14 +166,38 @@ export function* start(config: StartConfig): Operation<void> {
163
166
 
164
167
  // Used to emit & log the block range for each protocol.
165
168
  const contentBlocksForProtocol = getRangesForSyncProtocols(value);
166
-
167
- yield* until(
168
- emitLatestBlocks(
169
- value.blockNumber,
170
- value.timestamp,
171
- contentBlocksForProtocol,
172
- ),
169
+
170
+ // Fire-and-forget: do not stall the sync loop on broker acks.
171
+ //
172
+ // Opifex's TcpClient assigns packet IDs synchronously in client.publish()
173
+ // and writes to the socket via Node's net.Socket, which has its own write
174
+ // queue — so concurrent publish() calls land on the wire in call order.
175
+ // MQTT 3.1.1 §4.6 then guarantees in-order delivery to each subscriber
176
+ // per (publisher, topic, QoS). We don't need a wrapper queue.
177
+ //
178
+ // TODO(scaling): the broker runs in-process (see `new EventBroker(...)`
179
+ // above). Fan-out to N subscribers consumes the same event loop as block
180
+ // processing. At ~thousands of subscribers, consider:
181
+ // 1) moving the broker to a worker / separate process,
182
+ // 2) a publisher-side circuit breaker if ctx.unresolvedPublish.size > N
183
+ // (drop events with a counter for observability).
184
+ // Today: localhost, in-process, expected O(10²) subscribers — non-issue.
185
+ emitLatestBlocks(
186
+ value.blockNumber,
187
+ value.timestamp,
188
+ contentBlocksForProtocol,
173
189
  );
190
+ for (const { event, payload } of blockAppEvents) {
191
+ EventManager.Instance.sendMessage(event, payload as any).catch((err) => {
192
+ log.local(
193
+ ComponentNames.EFFECTSTREAM_RUNTIME,
194
+ "event-publish",
195
+ SeverityNumber.WARN,
196
+ (l) =>
197
+ l(`publish ${event.path.join("/")} failed: ${String(err)}`),
198
+ );
199
+ });
200
+ }
174
201
 
175
202
  const lagMs = Date.now() - value.timestamp;
176
203
  const lagSuffix = lagThresholdMs != null && lagMs > lagThresholdMs
@@ -205,24 +232,40 @@ function getRangesForSyncProtocols(value: ChainBlock): Record<string, [number, n
205
232
  return contentBlocksForProtocol;
206
233
  }
207
234
 
208
- async function emitLatestBlocks(
235
+ /**
236
+ * Publish built-in block-level events.
237
+ *
238
+ * Fire-and-forget for the same reasons as the app-event flush above
239
+ * (do not stall the sync loop; ordering preserved by Opifex + MQTT 3.1.1).
240
+ *
241
+ * Errors are caught locally so they don't escape to the global
242
+ * `unhandledrejection` handler without context.
243
+ */
244
+ function emitLatestBlocks(
209
245
  rollUpBlockHeight: number,
210
246
  rollUpBlockTimestamp: number,
211
247
  syncChains: Record<string, [number, number]>,
212
- ) {
213
- return await Promise.all([
214
- EventManager.Instance.sendMessage(BuiltinEvents.RollupBlock, {
215
- block: rollUpBlockHeight,
216
- timestamp: rollUpBlockTimestamp,
217
- }),
218
- ...Object.entries(syncChains).map(([chainName, [_, toBlock]]) =>
219
- EventManager.Instance.sendMessage(BuiltinEvents.SyncChains, {
220
- chain: chainName,
221
- block: toBlock,
222
- rollup: rollUpBlockHeight
223
- })
224
- ),
225
- ]);
248
+ ): void {
249
+ const logFailure = (topic: string) => (err: unknown) =>
250
+ log.local(
251
+ ComponentNames.EFFECTSTREAM_RUNTIME,
252
+ "event-publish",
253
+ SeverityNumber.WARN,
254
+ (l) => l(`publish ${topic} failed: ${String(err)}`),
255
+ );
256
+
257
+ EventManager.Instance.sendMessage(BuiltinEvents.RollupBlock, {
258
+ block: rollUpBlockHeight,
259
+ timestamp: rollUpBlockTimestamp,
260
+ }).catch(logFailure("RollupBlock"));
261
+
262
+ for (const [chainName, [_, toBlock]] of Object.entries(syncChains)) {
263
+ EventManager.Instance.sendMessage(BuiltinEvents.SyncChains, {
264
+ chain: chainName,
265
+ block: toBlock,
266
+ rollup: rollUpBlockHeight,
267
+ }).catch(logFailure(`SyncChains/${chainName}`));
268
+ }
226
269
  }
227
270
 
228
271
  function* startup(
@@ -1,7 +1,7 @@
1
1
  import type { ChainBlock } from "@effectstream/sync";
2
2
  import { call, type Operation, until } from "effection";
3
3
  import type { Pool } from "pg";
4
- import { type BaseStfInput, primitiveTransitionFunction } from "@effectstream/sm";
4
+ import { type BaseStfInput, type EmitFn, primitiveTransitionFunction } from "@effectstream/sm";
5
5
  import { PreparedQuery } from "@pgtyped/runtime";
6
6
  import type {
7
7
  ExecPromise,
@@ -10,6 +10,7 @@ import type {
10
10
  } from "@effectstream/coroutine";
11
11
  import {
12
12
  blockHeightDone,
13
+ deleteEmptyBlocks,
13
14
  deleteScheduled,
14
15
  getFutureGameInputByBlockHeight,
15
16
  getFutureGameInputByMaxTimestamp,
@@ -23,6 +24,24 @@ import type { StartConfig } from "./types.ts";
23
24
  import type { BlockNumber, EffectstreamBlockHash } from "@effectstream/utils";
24
25
  import { generateEffectstreamBlockHash, Prando } from "@effectstream/crypto";
25
26
  import { applyUserMigrations } from "./version-migrations.ts";
27
+ import type { EventPathAndDef } from "@effectstream/event-client";
28
+
29
+ /**
30
+ * A pending app event collected during STF execution, flushed to MQTT by
31
+ * the caller (main.ts) only after the block-level `COMMIT` succeeds.
32
+ *
33
+ * See plan invariants I1 (post-COMMIT delivery) and I2 (rollback drops events)
34
+ * in /Users/edwardalvarado/.claude/plans/wild-kindling-reddy.md.
35
+ */
36
+ export type PendingEvent = {
37
+ event: EventPathAndDef;
38
+ payload: Record<string, unknown>;
39
+ };
40
+
41
+ export type ProcessFinalizedBlockResult = {
42
+ blockHash: EffectstreamBlockHash;
43
+ events: PendingEvent[];
44
+ };
26
45
 
27
46
  /** Helper to check if a SyncStateUpdateStream object is a WorldResolve */
28
47
  function isWorldResolve(value: any): value is QueuedUpdate {
@@ -127,12 +146,17 @@ export function* processFinalizedBlock(
127
146
  config: StartConfig,
128
147
  dbConn: Pool,
129
148
  previousBlockHash: EffectstreamBlockHash | null,
130
- ): Operation<EffectstreamBlockHash> {
149
+ ): Operation<ProcessFinalizedBlockResult> {
131
150
  const { gameStateTransitions, migrations } = config;
132
151
  const blockHash: EffectstreamBlockHash = generateEffectstreamBlockHash(
133
152
  value,
134
153
  previousBlockHash,
135
154
  );
155
+ // Per-block event buffer. Only events from STF runs that succeeded
156
+ // (and survived to the post-COMMIT return) end up here. On block-level
157
+ // ROLLBACK below, this buffer goes out of scope unflushed.
158
+ const blockEvents: PendingEvent[] = [];
159
+ let hasOperations = false;
136
160
  try {
137
161
  /* STEP 0: Start the transaction. */
138
162
  yield* until(dbConn.query("BEGIN"));
@@ -151,8 +175,9 @@ export function* processFinalizedBlock(
151
175
  );
152
176
 
153
177
  /* STEP 2: Process the migrations. */
178
+ let hasMigrations = false;
154
179
  if (migrations) {
155
- yield* applyUserMigrations(
180
+ hasMigrations = yield* applyUserMigrations(
156
181
  value.blockNumber,
157
182
  dbConn as any, // Client,
158
183
  migrations,
@@ -190,6 +215,18 @@ export function* processFinalizedBlock(
190
215
  randomGenerator.skip();
191
216
  for (const data of scheduledData) {
192
217
  let success = true;
218
+ // Per-input event buffer. If the STF throws below, this buffer is
219
+ // dropped (events from rolled-back inputs never reach MQTT — I2).
220
+ // On success, we promote into `blockEvents` after the catch block.
221
+ const inputEvents: PendingEvent[] = [];
222
+ const emit: EmitFn = (event, payload) => {
223
+ inputEvents.push({
224
+ event,
225
+ // Auto-inject blockHeight (I5). registerEvents prepends it as the
226
+ // first indexed field; apps never set it themselves.
227
+ payload: { ...payload, blockHeight: value.blockNumber },
228
+ });
229
+ };
193
230
  try {
194
231
  const input: BaseStfInput = {
195
232
  blockTimestamp: value.timestamp,
@@ -198,6 +235,7 @@ export function* processFinalizedBlock(
198
235
  signerAddress: data.from_address,
199
236
  signerAddressType: data.from_address_type,
200
237
  randomGenerator,
238
+ emit,
201
239
  // TODO: We might want to add this to the scheduled data.
202
240
  //
203
241
  // Or do we resolve it here. Account might change.
@@ -210,6 +248,12 @@ export function* processFinalizedBlock(
210
248
  input,
211
249
  );
212
250
  yield* executeGeneratorStepByStep(gameSTFGenerator, dbConn);
251
+ // STF succeeded: promote buffered events to the block buffer.
252
+ // Failure path falls through the catch below and `inputEvents`
253
+ // simply goes out of scope.
254
+ if (inputEvents.length > 0) {
255
+ blockEvents.push(...inputEvents);
256
+ }
213
257
  } catch (err) {
214
258
  success = false;
215
259
  log.remote(
@@ -251,9 +295,15 @@ export function* processFinalizedBlock(
251
295
  }
252
296
 
253
297
  /* STEP 6: Mark the block as done. */
298
+ hasOperations = hasMigrations ||
299
+ value.primitives.length > 0 ||
300
+ scheduledData.length > 0;
301
+ if (!hasOperations) {
302
+ yield* call(() => deleteEmptyBlocks.run(undefined, dbConn));
303
+ }
254
304
  yield* call(() =>
255
305
  blockHeightDone.run({
256
- block_hash: Buffer.from(blockHash),
306
+ block_hash: Buffer.from(hasOperations ? blockHash : "0x0"),
257
307
  block_height: value.blockNumber,
258
308
  }, dbConn)
259
309
  );
@@ -298,5 +348,9 @@ export function* processFinalizedBlock(
298
348
  // We cannot recover from this error.
299
349
  throw err;
300
350
  }
301
- return blockHash;
351
+
352
+ return {
353
+ blockHash: hasOperations ? blockHash : "0x0" as PaimaBlockHash,
354
+ events: blockEvents
355
+ };
302
356
  }
@@ -95,7 +95,7 @@ export function* applyUserMigrations(
95
95
  currentBlockHeight: number,
96
96
  dbConn: Client,
97
97
  migrationOrder: DBMigrations[] | undefined,
98
- ): Operation<void> {
98
+ ): Operation<boolean> {
99
99
  const migrations = yield* getAllUserMigrations(
100
100
  EFFECTSTREAM_ENGINE_VERSION,
101
101
  migrationOrder,
@@ -114,6 +114,7 @@ export function* applyUserMigrations(
114
114
  ),
115
115
  );
116
116
  }
117
+ return migrations.length > 0;
117
118
  }
118
119
 
119
120
  function* getAllUserMigrations(