@effectstream/runtime 0.100.13 → 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.13",
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.13",
17
- "@effectstream/config": "0.100.13",
18
- "@effectstream/coroutine": "0.100.13",
19
- "@effectstream/crypto": "0.100.13",
20
- "@effectstream/db": "0.100.13",
21
- "@effectstream/event-client": "0.100.13",
22
- "@effectstream/event-server": "0.100.13",
23
- "@effectstream/log": "0.100.13",
24
- "@effectstream/sm": "0.100.13",
25
- "@effectstream/sync": "0.100.13",
26
- "@effectstream/utils": "0.100.13",
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,6 +7,7 @@ import {
7
7
  acquireDBMutex,
8
8
  createDynamicTables,
9
9
  getConnection,
10
+ getLastNonEmptyBlockHash,
10
11
  releaseDBMutex,
11
12
  resetPublicTables,
12
13
  runSnapshotLoop,
@@ -28,7 +29,7 @@ import {
28
29
  until,
29
30
  } from "effection";
30
31
  import { initTelemetry } from "./telemetry.ts";
31
- import { processFinalizedBlock } from "./process-blocks.ts";
32
+ import { type PendingEvent, processFinalizedBlock } from "./process-blocks.ts";
32
33
  import { startHttpServer } from "./api/http-server.ts";
33
34
  import type { StartConfig } from "./types.ts";
34
35
  import type { Client } from "pg";
@@ -40,19 +41,6 @@ import { builtInPrimitivesMap } from "@effectstream/sm";
40
41
  import { validateAndSnapshotConfig } from "./config-snapshot.ts";
41
42
 
42
43
  export function* init() {
43
- // Prevent transient network errors (broken pipes, closed connections) from
44
- // crashing the process via unhandled promise rejections. The sync loop's
45
- // tryYield already retries on error — this just keeps the process alive.
46
- globalThis.addEventListener("unhandledrejection", (event) => {
47
- log.local(
48
- ComponentNames.EFFECTSTREAM_RUNTIME,
49
- "unhandled-rejection",
50
- SeverityNumber.WARN,
51
- (l) => l("Suppressed unhandled rejection:", event.reason),
52
- );
53
- event.preventDefault();
54
- });
55
-
56
44
  // initialize OpenTelemetry
57
45
  yield* initTelemetry();
58
46
  }
@@ -137,7 +125,10 @@ export function* start(config: StartConfig): Operation<void> {
137
125
  }
138
126
  });
139
127
 
140
- 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;
141
132
  if (config.snapshotConfig) {
142
133
  yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
143
134
  }
@@ -147,16 +138,25 @@ export function* start(config: StartConfig): Operation<void> {
147
138
  // For PGLite, this is not enough, as the can only be one connection at a time.
148
139
  // So we request a DBMutex as well.
149
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[] = [];
150
145
  try {
151
146
  yield* acquireDBMutex(`processing-blocks:${value.blockNumber}`);
152
147
  dbClient = yield* until((dbConn as any).connect()); // Client,
153
148
 
154
- blockHash = yield* processFinalizedBlock(
149
+ const result = yield* processFinalizedBlock(
155
150
  value,
156
151
  config,
157
152
  dbClient as any, // Client,
158
153
  blockHash,
159
154
  );
155
+ const resultHash = result.blockHash;
156
+ blockAppEvents = result.events;
157
+ if (resultHash !== "0x0") {
158
+ blockHash = resultHash;
159
+ }
160
160
  } finally {
161
161
  releaseDBMutex(`processing-blocks:${value.blockNumber}`);
162
162
  if (dbClient) {
@@ -166,14 +166,38 @@ export function* start(config: StartConfig): Operation<void> {
166
166
 
167
167
  // Used to emit & log the block range for each protocol.
168
168
  const contentBlocksForProtocol = getRangesForSyncProtocols(value);
169
-
170
- yield* until(
171
- emitLatestBlocks(
172
- value.blockNumber,
173
- value.timestamp,
174
- contentBlocksForProtocol,
175
- ),
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,
176
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
+ }
177
201
 
178
202
  const lagMs = Date.now() - value.timestamp;
179
203
  const lagSuffix = lagThresholdMs != null && lagMs > lagThresholdMs
@@ -208,24 +232,40 @@ function getRangesForSyncProtocols(value: ChainBlock): Record<string, [number, n
208
232
  return contentBlocksForProtocol;
209
233
  }
210
234
 
211
- 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(
212
245
  rollUpBlockHeight: number,
213
246
  rollUpBlockTimestamp: number,
214
247
  syncChains: Record<string, [number, number]>,
215
- ) {
216
- return await Promise.all([
217
- EventManager.Instance.sendMessage(BuiltinEvents.RollupBlock, {
218
- block: rollUpBlockHeight,
219
- timestamp: rollUpBlockTimestamp,
220
- }),
221
- ...Object.entries(syncChains).map(([chainName, [_, toBlock]]) =>
222
- EventManager.Instance.sendMessage(BuiltinEvents.SyncChains, {
223
- chain: chainName,
224
- block: toBlock,
225
- rollup: rollUpBlockHeight
226
- })
227
- ),
228
- ]);
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
+ }
229
269
  }
230
270
 
231
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(