@effectstream/runtime 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,89 @@
1
+ # @effectstream/runtime
2
+
3
+ The state-machine runtime — the loop that ties sync, state machine,
4
+ database, events, and HTTP API together inside an EffectStream node.
5
+ Boot it with `init()` then drive it with `start(config)` and your node
6
+ is up.
7
+
8
+ ## Install
9
+
10
+ ```bash
11
+ bun add @effectstream/runtime
12
+ # or
13
+ npm install @effectstream/runtime
14
+ ```
15
+
16
+ `@effectstream/runtime` is the canonical import for `init` and `start`,
17
+ used directly by every template. The same surface is also reachable
18
+ through `@effectstream/node-sdk/runtime` if you prefer the umbrella.
19
+
20
+ ## Standalone usage
21
+
22
+ This package **owns the node's process model**. You don't pick parts of
23
+ it; you call `init()` once at boot and then `start(config)`. The config
24
+ brings together everything else:
25
+
26
+ ```typescript
27
+ import { init, start } from "@effectstream/runtime";
28
+ import { Stm } from "@effectstream/sm";
29
+ import { config } from "./config.dev.ts";
30
+
31
+ await init();
32
+
33
+ const gameStm = new Stm(grammar);
34
+ gameStm.addStateTransition("join", function* () { /* ... */ });
35
+
36
+ await start({
37
+ config,
38
+ gameStateTransitions: [gameStm],
39
+ apiRouter: undefined, // optional Fastify route plugin
40
+ dbMigrations: [], // SQL migrations
41
+ });
42
+ ```
43
+
44
+ While running, the runtime:
45
+
46
+ - Reads finalized blocks via `@effectstream/sync`.
47
+ - Routes batcher inputs through your registered `Stm`s.
48
+ - Commits all yielded SQL inside a per-block transaction.
49
+ - Publishes lifecycle and app events via `@effectstream/event-server`.
50
+ - Serves the optional Fastify API.
51
+ - Emits OpenTelemetry traces and logs through `@effectstream/log`.
52
+
53
+ ## Inside EffectStream
54
+
55
+ `@effectstream/runtime` is the conductor: it doesn't define any chain
56
+ integrations or queries itself, but every other node package only gets
57
+ exercised when this loop is running. The `StartConfig` shape determines
58
+ which DB migrations apply, which state machines fire, and whether the
59
+ runtime exposes a Fastify router.
60
+
61
+ ## Key exports
62
+
63
+ - `init()` — `Operation<void>`. One-shot setup: OpenTelemetry, config validation, version pinning. Call before `start`.
64
+ - `start(config: StartConfig)` — `Operation<void>`. Run the node loop until cancelled.
65
+
66
+ Types and helpers re-exported alongside `init` / `start`:
67
+
68
+ - `DBMigrations` — versioned SQL migrations passed into `start`.
69
+ - `StartConfig`, `StartConfigGameStateTransitions`, `StartConfigApiRouter` — `start`'s config types. Templates type-check against these implicitly but don't usually import them by name.
70
+ - `PrimitiveConstructor<T>` — extension point for new primitives.
71
+ - `VERSION` — `${number}.${number}.${number}` literal type for version pinning.
72
+ - Pagination helpers re-exported from `./api/pagination.ts`.
73
+
74
+ ## Examples
75
+
76
+ The templates under
77
+ [`templates/`](https://github.com/PaimaStudios/paima-engine/tree/main/templates)
78
+ are full working `init()` + `start()` examples. The simplest is
79
+ [`templates/minimal/`](https://github.com/PaimaStudios/paima-engine/tree/main/templates/minimal).
80
+
81
+ End-to-end EVM sync test:
82
+ [`e2e/evm/sync/`](https://github.com/PaimaStudios/paima-engine/tree/main/e2e/evm/sync).
83
+
84
+ Runnable: [`test/examples.test.ts`](./test/examples.test.ts).
85
+
86
+ ## Links
87
+
88
+ - Docs: https://effectstream.github.io/docs/packages/node/runtime
89
+ - Source: https://github.com/PaimaStudios/paima-engine/tree/main/packages/node-sdk/runtime
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/runtime",
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,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.15",
17
+ "@effectstream/config": "0.100.15",
18
+ "@effectstream/coroutine": "0.100.15",
19
+ "@effectstream/crypto": "0.100.15",
20
+ "@effectstream/db": "0.100.15",
21
+ "@effectstream/event-client": "0.100.15",
22
+ "@effectstream/event-server": "0.100.15",
23
+ "@effectstream/log": "0.100.15",
24
+ "@effectstream/sm": "0.100.15",
25
+ "@effectstream/sync": "0.100.15",
26
+ "@effectstream/utils": "0.100.15",
27
27
  "@fastify/cors": "^11.0.1",
28
28
  "@metamask/json-rpc-engine": "^10.0.3",
29
29
  "pg": "8.17.2",
@@ -46,7 +46,7 @@ import {
46
46
  type TypePaginationQuerySchema,
47
47
  } from "./pagination.ts";
48
48
  import { PrimitiveRegistry } from "@effectstream/sm";
49
- import { ConfigNetworkType } from "@effectstream/config";
49
+ import { ConfigNetworkType, getWriteNamespace, usePaimaStaticConfig } from "@effectstream/config";
50
50
 
51
51
  function tableListContains(
52
52
  list: Array<{ table_name: string | null }>,
@@ -470,26 +470,36 @@ export const startHttpServer = function* (
470
470
  return cleanedProtocols;
471
471
  });
472
472
 
473
+ const staticConfigForRoute = yield* usePaimaStaticConfig();
474
+ const securityNamespaceForRoute = getWriteNamespace(
475
+ staticConfigForRoute.securityNamespace,
476
+ );
477
+
473
478
  server.get("/config", {
474
479
  schema: {
475
480
  tags: ["developer"],
476
481
  response: {
477
- 200: Type.Array(Type.Object({
478
- networkType: Type.String(),
479
- syncProtocolType: Type.String(),
480
- syncProtocol: Type.Object({}, { additionalProperties: true }),
481
- network: Type.Object({}, { additionalProperties: true }),
482
- primitives: Type.Array(
483
- Type.Object({}, { additionalProperties: true }),
484
- ),
485
- }, { additionalProperties: true })),
482
+ 200: Type.Object({
483
+ securityNamespace: Type.Union([Type.String(), Type.Null()]),
484
+ syncProtocols: Type.Array(Type.Object({
485
+ networkType: Type.String(),
486
+ syncProtocolType: Type.String(),
487
+ syncProtocol: Type.Object({}, { additionalProperties: true }),
488
+ network: Type.Object({}, { additionalProperties: true }),
489
+ primitives: Type.Array(
490
+ Type.Object({}, { additionalProperties: true }),
491
+ ),
492
+ }, { additionalProperties: true })),
493
+ }),
486
494
  },
487
495
  },
488
496
  }, () => {
489
497
  const config = syncProtocols.map((syncProtocol) => syncProtocol.config)
490
498
  .flat();
491
- const cleanedConfig = clearBigInts(config);
492
- return cleanedConfig;
499
+ return {
500
+ securityNamespace: securityNamespaceForRoute,
501
+ syncProtocols: clearBigInts(config),
502
+ };
493
503
  });
494
504
  }
495
505
 
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,31 +29,19 @@ 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";
35
36
  import type { EffectstreamBlockHash } from "@effectstream/utils";
36
37
  import { applySystemMigrations } from "./version-migrations.ts";
37
38
  import { getLastBlockHeight, getVersionInfo } from "@effectstream/db/version";
38
- import { type SyncProtocolWithNetwork, ConfigNetworkType } from "@effectstream/config";
39
+ import { ConfigNetworkType, usePaimaStaticConfig } from "@effectstream/config";
40
+ import type { SecurityNamespace, SyncProtocolWithNetwork } from "@effectstream/config";
39
41
  import { builtInPrimitivesMap } from "@effectstream/sm";
40
42
  import { validateAndSnapshotConfig } from "./config-snapshot.ts";
41
43
 
42
44
  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
45
  // initialize OpenTelemetry
57
46
  yield* initTelemetry();
58
47
  }
@@ -137,7 +126,10 @@ export function* start(config: StartConfig): Operation<void> {
137
126
  }
138
127
  });
139
128
 
140
- let blockHash: EffectstreamBlockHash | null = null;
129
+ const [lastHashRow] = yield* until(getLastNonEmptyBlockHash.run(undefined, dbConn));
130
+ let blockHash: EffectstreamBlockHash | null = lastHashRow
131
+ ? lastHashRow.effectstream_block_hash!.toString() as EffectstreamBlockHash
132
+ : null;
141
133
  if (config.snapshotConfig) {
142
134
  yield* spawn(() => runSnapshotLoop(config.snapshotConfig!));
143
135
  }
@@ -147,16 +139,25 @@ export function* start(config: StartConfig): Operation<void> {
147
139
  // For PGLite, this is not enough, as the can only be one connection at a time.
148
140
  // So we request a DBMutex as well.
149
141
  let dbClient: Client | undefined;
142
+ // Custom app events emitted by STFs during this block. Collected by
143
+ // processFinalizedBlock and flushed below ONLY after it returns — i.e.
144
+ // strictly after the block's COMMIT. See plan invariant I1.
145
+ let blockAppEvents: PendingEvent[] = [];
150
146
  try {
151
147
  yield* acquireDBMutex(`processing-blocks:${value.blockNumber}`);
152
148
  dbClient = yield* until((dbConn as any).connect()); // Client,
153
149
 
154
- blockHash = yield* processFinalizedBlock(
150
+ const result = yield* processFinalizedBlock(
155
151
  value,
156
152
  config,
157
153
  dbClient as any, // Client,
158
154
  blockHash,
159
155
  );
156
+ const resultHash = result.blockHash;
157
+ blockAppEvents = result.events;
158
+ if (resultHash !== "0x0") {
159
+ blockHash = resultHash;
160
+ }
160
161
  } finally {
161
162
  releaseDBMutex(`processing-blocks:${value.blockNumber}`);
162
163
  if (dbClient) {
@@ -166,14 +167,38 @@ export function* start(config: StartConfig): Operation<void> {
166
167
 
167
168
  // Used to emit & log the block range for each protocol.
168
169
  const contentBlocksForProtocol = getRangesForSyncProtocols(value);
169
-
170
- yield* until(
171
- emitLatestBlocks(
172
- value.blockNumber,
173
- value.timestamp,
174
- contentBlocksForProtocol,
175
- ),
170
+
171
+ // Fire-and-forget: do not stall the sync loop on broker acks.
172
+ //
173
+ // Opifex's TcpClient assigns packet IDs synchronously in client.publish()
174
+ // and writes to the socket via Node's net.Socket, which has its own write
175
+ // queue — so concurrent publish() calls land on the wire in call order.
176
+ // MQTT 3.1.1 §4.6 then guarantees in-order delivery to each subscriber
177
+ // per (publisher, topic, QoS). We don't need a wrapper queue.
178
+ //
179
+ // TODO(scaling): the broker runs in-process (see `new EventBroker(...)`
180
+ // above). Fan-out to N subscribers consumes the same event loop as block
181
+ // processing. At ~thousands of subscribers, consider:
182
+ // 1) moving the broker to a worker / separate process,
183
+ // 2) a publisher-side circuit breaker if ctx.unresolvedPublish.size > N
184
+ // (drop events with a counter for observability).
185
+ // Today: localhost, in-process, expected O(10²) subscribers — non-issue.
186
+ emitLatestBlocks(
187
+ value.blockNumber,
188
+ value.timestamp,
189
+ contentBlocksForProtocol,
176
190
  );
191
+ for (const { event, payload } of blockAppEvents) {
192
+ EventManager.Instance.sendMessage(event, payload as any).catch((err) => {
193
+ log.local(
194
+ ComponentNames.EFFECTSTREAM_RUNTIME,
195
+ "event-publish",
196
+ SeverityNumber.WARN,
197
+ (l) =>
198
+ l(`publish ${event.path.join("/")} failed: ${String(err)}`),
199
+ );
200
+ });
201
+ }
177
202
 
178
203
  const lagMs = Date.now() - value.timestamp;
179
204
  const lagSuffix = lagThresholdMs != null && lagMs > lagThresholdMs
@@ -208,24 +233,40 @@ function getRangesForSyncProtocols(value: ChainBlock): Record<string, [number, n
208
233
  return contentBlocksForProtocol;
209
234
  }
210
235
 
211
- async function emitLatestBlocks(
236
+ /**
237
+ * Publish built-in block-level events.
238
+ *
239
+ * Fire-and-forget for the same reasons as the app-event flush above
240
+ * (do not stall the sync loop; ordering preserved by Opifex + MQTT 3.1.1).
241
+ *
242
+ * Errors are caught locally so they don't escape to the global
243
+ * `unhandledrejection` handler without context.
244
+ */
245
+ function emitLatestBlocks(
212
246
  rollUpBlockHeight: number,
213
247
  rollUpBlockTimestamp: number,
214
248
  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
- ]);
249
+ ): void {
250
+ const logFailure = (topic: string) => (err: unknown) =>
251
+ log.local(
252
+ ComponentNames.EFFECTSTREAM_RUNTIME,
253
+ "event-publish",
254
+ SeverityNumber.WARN,
255
+ (l) => l(`publish ${topic} failed: ${String(err)}`),
256
+ );
257
+
258
+ EventManager.Instance.sendMessage(BuiltinEvents.RollupBlock, {
259
+ block: rollUpBlockHeight,
260
+ timestamp: rollUpBlockTimestamp,
261
+ }).catch(logFailure("RollupBlock"));
262
+
263
+ for (const [chainName, [_, toBlock]] of Object.entries(syncChains)) {
264
+ EventManager.Instance.sendMessage(BuiltinEvents.SyncChains, {
265
+ chain: chainName,
266
+ block: toBlock,
267
+ rollup: rollUpBlockHeight,
268
+ }).catch(logFailure(`SyncChains/${chainName}`));
269
+ }
229
270
  }
230
271
 
231
272
  function* startup(
@@ -235,12 +276,16 @@ function* startup(
235
276
  ): Operation<AllSyncProtocols[]> {
236
277
  const versionInfo = yield* getVersionInfo(dbConn);
237
278
  const lastBlockHeight = yield* getLastBlockHeight(versionInfo, dbConn);
279
+ // Pull the security namespace from the static config so primitives that
280
+ // re-verify batched signatures can access it synchronously.
281
+ const staticConfig = yield* usePaimaStaticConfig();
238
282
  // Create Runtime Primitives Instances
239
283
  syncInfo.forEach((syncProtocol) => {
240
284
  syncProtocol.primitives.forEach((primitive, primitiveIndex) => {
241
285
  processPrimitives(
242
286
  syncProtocol.primitives,
243
287
  primitiveIndex,
288
+ staticConfig.securityNamespace,
244
289
  config.userDefinedPrimitives
245
290
  );
246
291
  });
@@ -287,6 +332,7 @@ function* startup(
287
332
  const processPrimitives = (
288
333
  primitives: {primitive: any, id: string}[],
289
334
  primitiveIndex: number,
335
+ securityNamespace: SecurityNamespace | undefined,
290
336
  userDefinedPrimitives?: Record<string, any>,
291
337
  ) => {
292
338
  const primitiveType = primitives[primitiveIndex].primitive.type;
@@ -309,6 +355,7 @@ const processPrimitives = (
309
355
  const classConfig = {
310
356
  ...primitiveConfig,
311
357
  instanceName: primitiveUniqueName,
358
+ securityNamespace,
312
359
  }
313
360
  if (isBuiltInPrimitive) {
314
361
  p = new builtInPrimitivesMap[primitiveType as keyof typeof builtInPrimitivesMap](classConfig as any) ;
@@ -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(
@@ -0,0 +1,14 @@
1
+ // Examples for the README — verify the public surface resolves.
2
+
3
+ import { test, expect } from "bun:test";
4
+ import * as runtime from "../src/mod.ts";
5
+
6
+ test("README: init and start are exported as generator functions", () => {
7
+ expect(typeof runtime.init).toBe("function");
8
+ expect(typeof runtime.start).toBe("function");
9
+ });
10
+
11
+ test("README: pagination helpers are re-exported", async () => {
12
+ const pagination = await import("../src/api/pagination.ts");
13
+ expect(Object.keys(pagination).length).toBeGreaterThan(0);
14
+ });