@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 +89 -0
- package/package.json +12 -12
- package/src/api/http-server.ts +22 -12
- package/src/main.ts +86 -39
- package/src/process-blocks.ts +59 -5
- package/src/version-migrations.ts +2 -1
- package/test/examples.test.ts +14 -0
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.
|
|
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.
|
|
17
|
-
"@effectstream/config": "0.100.
|
|
18
|
-
"@effectstream/coroutine": "0.100.
|
|
19
|
-
"@effectstream/crypto": "0.100.
|
|
20
|
-
"@effectstream/db": "0.100.
|
|
21
|
-
"@effectstream/event-client": "0.100.
|
|
22
|
-
"@effectstream/event-server": "0.100.
|
|
23
|
-
"@effectstream/log": "0.100.
|
|
24
|
-
"@effectstream/sm": "0.100.
|
|
25
|
-
"@effectstream/sync": "0.100.
|
|
26
|
-
"@effectstream/utils": "0.100.
|
|
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",
|
package/src/api/http-server.ts
CHANGED
|
@@ -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.
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
Type.Object({}, { additionalProperties: true }),
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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) ;
|
package/src/process-blocks.ts
CHANGED
|
@@ -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<
|
|
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
|
-
|
|
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<
|
|
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
|
+
});
|