@effectstream/node-sdk 0.103.3 → 0.104.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/README.md CHANGED
@@ -29,6 +29,38 @@ import { getConnection } from "@effectstream/node-sdk/db";
29
29
  import { ConfigBuilder, ConfigNetworkType } from "@effectstream/node-sdk/config";
30
30
  ```
31
31
 
32
+ ## Single-file applications
33
+
34
+ The root entrypoint also exposes a small in-process facade for concise,
35
+ read-only nodes:
36
+
37
+ ```typescript
38
+ import { midnightContract, pglite, runNode } from "@effectstream/node-sdk";
39
+
40
+ await runNode({
41
+ appName: "counter-watch",
42
+ database: pglite(),
43
+ sources: {
44
+ counter: midnightContract({
45
+ network: "preview",
46
+ address: "<64-character-contract-address>",
47
+ startBlockHeight: "latest",
48
+ ledger: { round: "uint128" },
49
+ }),
50
+ },
51
+ transitions: {
52
+ counter: ({ state }) => console.log(state.round),
53
+ },
54
+ });
55
+ ```
56
+
57
+ `runNode` uses the normal Effectstream sync, primitive, STM, migration, and
58
+ configuration-snapshot paths. It owns PGlite and runtime cleanup in the same
59
+ process; it does not launch another script. A fresh `"latest"` source begins at
60
+ the current indexed block. When `pglite({ dataDir: "..." })` is persistent, a
61
+ restart reads the saved numeric start height and resumes the stored checkpoint
62
+ instead of jumping to a new tip.
63
+
32
64
  The subpaths are thin re-exports - semantics are identical to importing
33
65
  from the underlying packages.
34
66
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effectstream/node-sdk",
3
- "version": "0.103.3",
3
+ "version": "0.104.0",
4
4
  "license": "(MIT OR Apache-2.0)",
5
5
  "homepage": "https://effectstream.github.io/docs/",
6
6
  "repository": {
@@ -83,18 +83,19 @@
83
83
  }
84
84
  },
85
85
  "dependencies": {
86
- "@effectstream/runtime": "0.103.3",
87
- "@effectstream/sm": "0.103.3",
88
- "@effectstream/sync": "0.103.3",
89
- "@effectstream/db": "0.103.3",
90
- "@effectstream/db-emulator": "0.103.3",
91
- "@effectstream/coroutine": "0.103.3",
92
- "@effectstream/event-server": "0.103.3",
93
- "@effectstream/config": "0.103.3",
94
- "@effectstream/chain-types": "0.103.3",
95
- "@effectstream/precompile": "0.103.3",
96
- "@effectstream/utils": "0.103.3",
97
- "@effectstream/concise": "0.103.3"
86
+ "@effectstream/runtime": "0.104.0",
87
+ "@effectstream/sm": "0.104.0",
88
+ "@effectstream/sync": "0.104.0",
89
+ "@effectstream/db": "0.104.0",
90
+ "@effectstream/db-emulator": "0.104.0",
91
+ "@effectstream/coroutine": "0.104.0",
92
+ "@effectstream/event-server": "0.104.0",
93
+ "@effectstream/config": "0.104.0",
94
+ "@effectstream/chain-types": "0.104.0",
95
+ "@effectstream/precompile": "0.104.0",
96
+ "@effectstream/utils": "0.104.0",
97
+ "@effectstream/concise": "0.104.0",
98
+ "effection": "^3.5.0"
98
99
  },
99
100
  "description": "Main application node SDK for EffectStream"
100
101
  }
package/src/mod.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "@effectstream/runtime";
2
+ export * from "./single-file.ts";
@@ -0,0 +1,458 @@
1
+ import {
2
+ ConfigNetworkType,
3
+ ConfigSyncProtocolType,
4
+ type LedgerFieldType,
5
+ type LedgerSchema,
6
+ type SyncProtocolWithNetwork,
7
+ withEffectstreamStaticConfig,
8
+ } from "@effectstream/config";
9
+ import { World, type SyncStateUpdateStream } from "@effectstream/coroutine";
10
+ import {
11
+ startPglite,
12
+ type PgliteHandle,
13
+ } from "@effectstream/db/start-pglite";
14
+ import {
15
+ init,
16
+ start,
17
+ type StartConfigApiRouter,
18
+ type StartConfigGameStateTransitions,
19
+ type VERSION,
20
+ } from "@effectstream/runtime";
21
+ import { Stm, type BaseStfInput } from "@effectstream/sm";
22
+ import { PrimitiveTypeMidnightGeneric } from "@effectstream/sm/builtin";
23
+ import { builtinGrammars } from "@effectstream/sm/grammar";
24
+ import { call, ensure, run } from "effection";
25
+
26
+ export type MidnightNetwork = "preview" | "preprod";
27
+
28
+ export type PgliteOptions = {
29
+ /** `memory://` starts a fresh watch each time; a path resumes after restart. */
30
+ dataDir?: string;
31
+ /** TCP gateway port. Zero asks the OS for a free port. */
32
+ port?: number;
33
+ };
34
+
35
+ export type PgliteDatabase = Readonly<{
36
+ kind: "pglite";
37
+ dataDir: string;
38
+ port: number;
39
+ }>;
40
+
41
+ /** Configure the embedded database that `runNode` owns and closes. */
42
+ export function pglite(options: PgliteOptions = {}): PgliteDatabase {
43
+ return {
44
+ kind: "pglite",
45
+ dataDir: options.dataDir ?? "memory://",
46
+ port: options.port ?? 0,
47
+ };
48
+ }
49
+
50
+ type LedgerValue<Field extends LedgerFieldType> =
51
+ Field extends { type: "option"; value: infer Value extends LedgerFieldType }
52
+ ? LedgerValue<Value> | null
53
+ : Field extends { type: "map"; value: infer Value extends LedgerFieldType }
54
+ ? Record<string, LedgerValue<Value>>
55
+ : Field extends "boolean" ? boolean
56
+ : string;
57
+
58
+ export type LedgerState<Schema extends LedgerSchema> = {
59
+ [Field in keyof Schema]: LedgerValue<Schema[Field]>;
60
+ };
61
+
62
+ export type MidnightContractOptions<Schema extends LedgerSchema> = {
63
+ network: MidnightNetwork;
64
+ address: string;
65
+ startBlockHeight: "latest" | number;
66
+ ledger: Schema;
67
+ /** Override the hosted v4 endpoint, mainly for compatible private indexers. */
68
+ indexer?: string;
69
+ };
70
+
71
+ export type MidnightContractSource<Schema extends LedgerSchema> = Readonly<{
72
+ kind: "midnight-contract";
73
+ network: MidnightNetwork;
74
+ address: string;
75
+ startBlockHeight: "latest" | number;
76
+ ledger: Schema;
77
+ indexer: string;
78
+ }>;
79
+
80
+ /** Declare a read-only Midnight contract source without generated Compact code. */
81
+ export function midnightContract<const Schema extends LedgerSchema>(
82
+ options: MidnightContractOptions<Schema>,
83
+ ): MidnightContractSource<Schema> {
84
+ const address = options.address.replace(/^0x/, "").toLowerCase();
85
+ if (!/^[0-9a-f]{64}$/.test(address)) {
86
+ throw new Error("A Midnight contract address must contain exactly 64 hexadecimal characters.");
87
+ }
88
+ if (
89
+ options.startBlockHeight !== "latest" &&
90
+ (!Number.isSafeInteger(options.startBlockHeight) || options.startBlockHeight < 0)
91
+ ) {
92
+ throw new Error("startBlockHeight must be \"latest\" or a non-negative safe integer.");
93
+ }
94
+ if (Object.keys(options.ledger).length === 0) {
95
+ throw new Error("A Midnight ledger schema must declare at least one field.");
96
+ }
97
+ for (const [name, field] of Object.entries(options.ledger)) {
98
+ validateLedgerField(field, name);
99
+ }
100
+
101
+ return {
102
+ kind: "midnight-contract",
103
+ network: options.network,
104
+ address,
105
+ startBlockHeight: options.startBlockHeight,
106
+ ledger: options.ledger,
107
+ indexer: options.indexer ?? midnightIndexer(options.network),
108
+ };
109
+ }
110
+
111
+ export type MidnightTransition<Schema extends LedgerSchema> = (event: {
112
+ state: LedgerState<Schema>;
113
+ blockHeight: number;
114
+ blockTimestamp: number;
115
+ network: MidnightNetwork;
116
+ address: string;
117
+ }) => void | Promise<void>;
118
+
119
+ type AnyMidnightSource = MidnightContractSource<LedgerSchema>;
120
+ type TransitionFor<Source> = Source extends MidnightContractSource<infer Schema>
121
+ ? MidnightTransition<Schema>
122
+ : never;
123
+
124
+ export type RunNodeOptions<
125
+ Sources extends Record<string, AnyMidnightSource>,
126
+ > = {
127
+ appName: string;
128
+ appVersion?: VERSION;
129
+ apiPort?: number;
130
+ database: PgliteDatabase;
131
+ sources: Sources;
132
+ transitions: { [Name in keyof Sources]: TransitionFor<Sources[Name]> };
133
+ api?: StartConfigApiRouter;
134
+ };
135
+
136
+ /**
137
+ * Run the database, Effectstream runtime, named sources, transitions, and API
138
+ * in this process. SIGINT/SIGTERM halt the task and close owned resources.
139
+ */
140
+ export async function runNode<
141
+ const Sources extends Record<string, AnyMidnightSource>,
142
+ >(options: RunNodeOptions<Sources>): Promise<void> {
143
+ if (options.database.kind !== "pglite") {
144
+ throw new Error("runNode currently requires the database returned by pglite().");
145
+ }
146
+ validateName(options.appName, "appName");
147
+
148
+ const sourceEntries = Object.entries(options.sources) as [
149
+ keyof Sources & string,
150
+ AnyMidnightSource,
151
+ ][];
152
+ if (sourceEntries.length === 0) {
153
+ throw new Error("runNode requires at least one source.");
154
+ }
155
+ for (const [name, source] of sourceEntries) {
156
+ validateName(name, "source name");
157
+ if (name === "clock") throw new Error('The source name "clock" is reserved.');
158
+ if (source.kind !== "midnight-contract") {
159
+ throw new Error(`Unsupported source kind for "${name}".`);
160
+ }
161
+ if (typeof options.transitions[name] !== "function") {
162
+ throw new Error(`Source "${name}" requires a matching transition.`);
163
+ }
164
+ }
165
+
166
+ const restoreEnvironment = applyEnvironment({
167
+ PGLITE: "true",
168
+ PGLITE_DATA_DIR: options.database.dataDir,
169
+ DB_HOST: "127.0.0.1",
170
+ DB_USER: "postgres",
171
+ DB_NAME: "postgres",
172
+ EFFECTSTREAM_API_PORT: String(options.apiPort ?? 9999),
173
+ MQTT_BROKER: "false",
174
+ });
175
+
176
+ let database: PgliteHandle | undefined;
177
+ let task: ReturnType<typeof run<void>> | undefined;
178
+ let signalHandler: (() => void) | undefined;
179
+ let shutdownRequested = false;
180
+ try {
181
+ database = await startPglite(options.database.port);
182
+ process.env.DB_PORT = String(database.port);
183
+ await database.db.waitReady;
184
+
185
+ const clockSnapshot = await readConfigSnapshot(database, "clock");
186
+ const clockStartTime = numberFromSnapshot(clockSnapshot, "startTime") ?? Date.now();
187
+ const resolvedSources = await resolveStartHeights(database, sourceEntries);
188
+ const grammar = Object.fromEntries(
189
+ sourceEntries.map(([name]) => [name, builtinGrammars.midnightGeneric]),
190
+ );
191
+ const stm = new Stm(grammar as any);
192
+
193
+ for (const [name, source] of sourceEntries) {
194
+ (stm.addStateTransition as any)(name, function* (data: any) {
195
+ const result = options.transitions[name]({
196
+ state: data.parsedInput.payload,
197
+ blockHeight: Number(data.blockHeight),
198
+ blockTimestamp: Number(data.blockTimestamp),
199
+ network: source.network,
200
+ address: source.address,
201
+ });
202
+ if (isPromiseLike(result)) yield* World.promise(result);
203
+ });
204
+ }
205
+
206
+ const gameStateTransitions: StartConfigGameStateTransitions = function* (
207
+ _blockHeight: number,
208
+ input: BaseStfInput,
209
+ ): SyncStateUpdateStream<void> {
210
+ yield* stm.processInput(input);
211
+ };
212
+ const syncInfo = makeSyncInfo(clockStartTime, resolvedSources);
213
+ const staticConfig = {
214
+ securityNamespace: options.appName,
215
+ allNetworks: { viemNetworks: {} },
216
+ };
217
+ const ownedDatabase = database;
218
+
219
+ task = run(function* () {
220
+ yield* ensure(function* () {
221
+ yield* call(() => ownedDatabase.close());
222
+ });
223
+ yield* init();
224
+ yield* withEffectstreamStaticConfig(staticConfig, function* () {
225
+ yield* start({
226
+ appName: options.appName,
227
+ appVersion: options.appVersion ?? "1.0.0",
228
+ syncInfo,
229
+ grammar: grammar as any,
230
+ gameStateTransitions,
231
+ apiRouter: options.api,
232
+ events: false,
233
+ });
234
+ });
235
+ });
236
+
237
+ signalHandler = () => {
238
+ shutdownRequested = true;
239
+ void task?.halt().catch((error) => {
240
+ console.error("Effectstream shutdown failed:", error);
241
+ });
242
+ };
243
+ process.once("SIGINT", signalHandler);
244
+ process.once("SIGTERM", signalHandler);
245
+ try {
246
+ await task;
247
+ } catch (error) {
248
+ if (!shutdownRequested || (error as Error).message !== "halted") throw error;
249
+ }
250
+ } finally {
251
+ if (signalHandler) {
252
+ process.off("SIGINT", signalHandler);
253
+ process.off("SIGTERM", signalHandler);
254
+ }
255
+ if (!task && database) await database.close();
256
+ restoreEnvironment();
257
+ }
258
+ }
259
+
260
+ type ResolvedSource = {
261
+ name: string;
262
+ source: AnyMidnightSource;
263
+ startBlockHeight: number;
264
+ };
265
+
266
+ async function resolveStartHeights(
267
+ database: PgliteHandle,
268
+ sources: [string, AnyMidnightSource][],
269
+ ): Promise<ResolvedSource[]> {
270
+ const latestByIndexer = new Map<string, Promise<number>>();
271
+ return await Promise.all(sources.map(async ([name, source]) => {
272
+ const protocolName = protocolNameFor(name);
273
+ const snapshot = await readConfigSnapshot(database, protocolName);
274
+ const savedHeight = numberFromSnapshot(snapshot, "startBlockHeight");
275
+ if (savedHeight !== undefined) {
276
+ return { name, source, startBlockHeight: savedHeight };
277
+ }
278
+ if (source.startBlockHeight !== "latest") {
279
+ return { name, source, startBlockHeight: source.startBlockHeight };
280
+ }
281
+
282
+ let latest = latestByIndexer.get(source.indexer);
283
+ if (!latest) {
284
+ latest = fetchLatestHeight(source.indexer, source.network);
285
+ latestByIndexer.set(source.indexer, latest);
286
+ }
287
+ return { name, source, startBlockHeight: await latest };
288
+ }));
289
+ }
290
+
291
+ async function fetchLatestHeight(
292
+ indexer: string,
293
+ network: MidnightNetwork,
294
+ ): Promise<number> {
295
+ const response = await fetch(indexer, {
296
+ method: "POST",
297
+ headers: { "content-type": "application/json" },
298
+ body: JSON.stringify({ query: "query { block { height } }" }),
299
+ signal: AbortSignal.timeout(15_000),
300
+ });
301
+ if (!response.ok) {
302
+ throw new Error(
303
+ `Unable to read the Midnight ${network} tip: ${response.status} ${response.statusText}.`,
304
+ );
305
+ }
306
+ const body = await response.json() as {
307
+ data?: { block?: { height?: unknown } };
308
+ errors?: unknown;
309
+ };
310
+ const height = body.data?.block?.height;
311
+ if (!Number.isSafeInteger(height) || (height as number) < 0) {
312
+ throw new Error(
313
+ `The Midnight ${network} indexer returned an invalid latest block height.`,
314
+ );
315
+ }
316
+ return height as number;
317
+ }
318
+
319
+ async function readConfigSnapshot(
320
+ database: PgliteHandle,
321
+ protocolName: string,
322
+ ): Promise<Record<string, unknown> | undefined> {
323
+ try {
324
+ const result = await database.db.query<{ immutable_config: unknown }>(
325
+ `SELECT immutable_config
326
+ FROM effectstream.sync_protocol_config_snapshot
327
+ WHERE protocol_name = $1`,
328
+ [protocolName],
329
+ );
330
+ const value = result.rows[0]?.immutable_config;
331
+ if (typeof value === "string") return JSON.parse(value);
332
+ if (value && typeof value === "object") return value as Record<string, unknown>;
333
+ return undefined;
334
+ } catch (error) {
335
+ if ((error as { code?: string }).code === "42P01") return undefined;
336
+ throw error;
337
+ }
338
+ }
339
+
340
+ function makeSyncInfo(
341
+ clockStartTime: number,
342
+ sources: ResolvedSource[],
343
+ ): SyncProtocolWithNetwork[] {
344
+ const clock = {
345
+ networkType: ConfigNetworkType.NTP,
346
+ syncProtocolType: ConfigSyncProtocolType.NTP_MAIN,
347
+ network: {
348
+ name: "clock",
349
+ type: ConfigNetworkType.NTP,
350
+ startTime: clockStartTime,
351
+ blockTimeMS: 1_000,
352
+ },
353
+ syncProtocol: {
354
+ name: "clock",
355
+ type: ConfigSyncProtocolType.NTP_MAIN,
356
+ startBlockHeight: 1,
357
+ stopBlockHeight: null,
358
+ pollingInterval: 1_000,
359
+ requestTimeoutMs: 15_000,
360
+ stepSize: 1_000,
361
+ },
362
+ primitives: [],
363
+ };
364
+
365
+ return [clock, ...sources.map(({ name, source, startBlockHeight }) => {
366
+ const protocolName = protocolNameFor(name);
367
+ return {
368
+ networkType: ConfigNetworkType.MIDNIGHT,
369
+ syncProtocolType: ConfigSyncProtocolType.MIDNIGHT_PARALLEL,
370
+ network: {
371
+ name: protocolName,
372
+ type: ConfigNetworkType.MIDNIGHT,
373
+ networkId: source.network,
374
+ },
375
+ syncProtocol: {
376
+ name: protocolName,
377
+ type: ConfigSyncProtocolType.MIDNIGHT_PARALLEL,
378
+ indexer: source.indexer,
379
+ startBlockHeight,
380
+ stopBlockHeight: null,
381
+ pollingInterval: 1_000,
382
+ requestTimeoutMs: 15_000,
383
+ stepSize: 10,
384
+ paginationLimit: 50,
385
+ confirmationDepth: 3,
386
+ delayMs: 20_000,
387
+ },
388
+ primitives: [{
389
+ id: name,
390
+ syncProtocol: protocolName,
391
+ primitive: {
392
+ name,
393
+ type: PrimitiveTypeMidnightGeneric,
394
+ startBlockHeight,
395
+ contractAddress: source.address,
396
+ stateMachinePrefix: name,
397
+ ledgerSchema: source.ledger,
398
+ networkId: source.network,
399
+ },
400
+ }],
401
+ };
402
+ })] as unknown as SyncProtocolWithNetwork[];
403
+ }
404
+
405
+ function midnightIndexer(network: MidnightNetwork): string {
406
+ return `https://indexer.${network}.midnight.network/api/v4/graphql`;
407
+ }
408
+
409
+ function protocolNameFor(sourceName: string): string {
410
+ return `midnight-${sourceName}`;
411
+ }
412
+
413
+ function numberFromSnapshot(
414
+ snapshot: Record<string, unknown> | undefined,
415
+ field: string,
416
+ ): number | undefined {
417
+ const value = snapshot?.[field];
418
+ return typeof value === "number" && Number.isSafeInteger(value) ? value : undefined;
419
+ }
420
+
421
+ function validateName(value: string, label: string): void {
422
+ if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(value)) {
423
+ throw new Error(`${label} must start with a letter and contain only letters, numbers, _ or -.`);
424
+ }
425
+ }
426
+
427
+ function validateLedgerField(field: LedgerFieldType, path: string): void {
428
+ if (typeof field === "string") {
429
+ if (["uint8", "uint16", "uint32", "uint64", "uint128", "bytes", "boolean"].includes(field)) {
430
+ return;
431
+ }
432
+ throw new Error(`Unsupported ledger type at "${path}": ${field}.`);
433
+ }
434
+ if (field.type === "map" || field.type === "option") {
435
+ validateLedgerField(field.value, path);
436
+ return;
437
+ }
438
+ throw new Error(`Unsupported ledger type at "${path}".`);
439
+ }
440
+
441
+ function isPromiseLike(value: unknown): value is Promise<void> {
442
+ return !!value && typeof (value as Promise<void>).then === "function";
443
+ }
444
+
445
+ function applyEnvironment(values: Record<string, string>): () => void {
446
+ const previous = new Map<string, string | undefined>();
447
+ for (const [key, value] of Object.entries(values)) {
448
+ previous.set(key, process.env[key]);
449
+ process.env[key] = value;
450
+ }
451
+ previous.set("DB_PORT", process.env.DB_PORT);
452
+ return () => {
453
+ for (const [key, value] of previous) {
454
+ if (value === undefined) delete process.env[key];
455
+ else process.env[key] = value;
456
+ }
457
+ };
458
+ }
@@ -23,3 +23,49 @@ test("README: concise re-export keeps generateStmInput in shape", async () => {
23
23
  expect(typeof concise.generateStmInput).toBe("function");
24
24
  expect(typeof concise.parseStmInput).toBe("function");
25
25
  });
26
+
27
+ test("single-file facade declares a typed Midnight source and embedded database", async () => {
28
+ const node = await import("../src/mod.ts");
29
+ expect(node.pglite()).toEqual({
30
+ kind: "pglite",
31
+ dataDir: "memory://",
32
+ port: 0,
33
+ });
34
+ expect(node.midnightContract({
35
+ network: "preview",
36
+ address: "a".repeat(64),
37
+ startBlockHeight: "latest",
38
+ ledger: { round: "uint128" },
39
+ })).toEqual({
40
+ kind: "midnight-contract",
41
+ network: "preview",
42
+ address: "a".repeat(64),
43
+ startBlockHeight: "latest",
44
+ ledger: { round: "uint128" },
45
+ indexer: "https://indexer.preview.midnight.network/api/v4/graphql",
46
+ });
47
+ });
48
+
49
+ test("single-file facade rejects malformed declarations early", async () => {
50
+ const { midnightContract } = await import("../src/mod.ts");
51
+ expect(() => midnightContract({
52
+ network: "preview",
53
+ address: "not-an-address",
54
+ startBlockHeight: "latest",
55
+ ledger: { round: "uint128" },
56
+ })).toThrow(/64 hexadecimal/);
57
+ });
58
+
59
+ test("single-file template contains one source file and one exact import", async () => {
60
+ const templateDir = new URL("../../../../templates/single-file/", import.meta.url);
61
+ const files = [...new Bun.Glob("**/*").scanSync({
62
+ cwd: templateDir.pathname,
63
+ onlyFiles: true,
64
+ })];
65
+ expect(files).toEqual(["minimal.ts"]);
66
+
67
+ const source = await Bun.file(new URL("minimal.ts", templateDir)).text();
68
+ const imports = [...source.matchAll(/from\s+["']([^"']+)["']/g)].map((match) => match[1]);
69
+ expect(imports).toEqual(["@effectstream/node-sdk@0.103.4"]);
70
+ expect(source).not.toMatch(/contract-counter|wallet|proofServer|generated|child_process/);
71
+ });