@effectstream/runtime 0.100.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.
@@ -0,0 +1,942 @@
1
+ import fastify, { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
2
+ import { evmRpcEngine } from "./rpc-evm/eip1193.ts";
3
+ import type { Pool } from "pg";
4
+ import cors from "@fastify/cors";
5
+ import { run, until } from "effection";
6
+ import {
7
+ acquireDBMutex,
8
+ getAllAddresses,
9
+ getAllScheduledData,
10
+ getAllTableNames,
11
+ getPrimitivePrefix,
12
+ getSyncAndLastPage,
13
+ getTableSchema,
14
+ type IGetAllAddressesResult,
15
+ type IGetAllTableNamesResult,
16
+ releaseDBMutex,
17
+ runPreparedQuery,
18
+ waitUntilFree,
19
+ } from "@effectstream/db";
20
+ import { ENV } from "@effectstream/utils/node-env";
21
+ import type {
22
+ AllSyncProtocols,
23
+ AvailFetcher,
24
+ BitcoinFetcher,
25
+ EvmFetcher,
26
+ MidnightFetcher,
27
+ NtpFetcher,
28
+ UtxoRpcFetcher,
29
+ } from "@effectstream/sync";
30
+ import fastifySwagger, {
31
+ type FastifyDynamicSwaggerOptions,
32
+ } from "@fastify/swagger";
33
+ import fastifySwaggerUi, {
34
+ type FastifySwaggerUiOptions,
35
+ } from "@fastify/swagger-ui";
36
+ import { Type } from "@sinclair/typebox";
37
+ import type { StartConfigApiRouter } from "../types.ts";
38
+ import type { GrammarDefinition } from "@effectstream/concise";
39
+ import {
40
+ createPaginatedResponseSchema,
41
+ createPaginationMeta,
42
+ fetchPrimitiveTablePage,
43
+ fetchPublicTablePage,
44
+ getPaginationParams,
45
+ PaginationQuerySchema,
46
+ type TypePaginationQuerySchema,
47
+ } from "./pagination.ts";
48
+ import { PrimitiveRegistry } from "@effectstream/sm";
49
+ import { ConfigNetworkType } from "@effectstream/config";
50
+
51
+ function tableListContains(
52
+ list: Array<{ table_name: string | null }>,
53
+ name: string,
54
+ ): boolean {
55
+ return list.some((t) => t.table_name === name);
56
+ }
57
+
58
+ // Utility functions for SQL injection prevention moved to pagination.ts
59
+
60
+ export enum RpcPaths {
61
+ Root = "rpc",
62
+ EVM = "evm",
63
+ }
64
+ /**
65
+ * Register the OpenAPI documentation for the Paima Engine HTTP server.
66
+ * Documentation is available at /documentation /documentation/json /documentation/yaml
67
+ * @param server - The Fastify instance.
68
+ * @param port - The port to listen on.
69
+ */
70
+ function* registerOpenApiDocumentation(
71
+ server: FastifyInstance,
72
+ port: number,
73
+ ) {
74
+ // Generate OpenAPI documentation
75
+ // Documentation is available at /documentation /documentation/json /documentation/yaml
76
+ const openApiOptions: FastifyDynamicSwaggerOptions = {
77
+ openapi: {
78
+ info: {
79
+ title: "Paima Engine",
80
+ description: "Paima Engine API",
81
+ version: "0.1.0",
82
+ },
83
+ tags: [
84
+ {
85
+ name: "user",
86
+ description: "Paima Engine User related end-points",
87
+ },
88
+ {
89
+ name: "status",
90
+ description: "Paima Engine Status related end-points",
91
+ },
92
+ {
93
+ name: "developer",
94
+ description: "Developer related end-points",
95
+ },
96
+ ],
97
+ servers: [
98
+ {
99
+ url: `http://localhost:${port}`,
100
+ description: "Local Paima Engine",
101
+ },
102
+ ],
103
+ },
104
+ hideUntagged: true,
105
+ };
106
+
107
+ const uiOptions: FastifySwaggerUiOptions = {
108
+ routePrefix: "/documentation",
109
+ uiConfig: {
110
+ docExpansion: "list",
111
+ deepLinking: false,
112
+ },
113
+ uiHooks: {
114
+ onRequest: function (request, reply, next) {
115
+ next();
116
+ },
117
+ preHandler: function (request, reply, next) {
118
+ next();
119
+ },
120
+ },
121
+ staticCSP: true,
122
+ transformStaticCSP: (header) => {
123
+ return header.replace(/ frame-ancestors 'self';/, "");
124
+ },
125
+ transformSpecification: (swaggerObject, request, reply) => {
126
+ return swaggerObject;
127
+ },
128
+ transformSpecificationClone: true,
129
+ theme: {
130
+ css: [
131
+ {
132
+ filename: "custom.css",
133
+ content: `
134
+ .swagger-ui .topbar {
135
+ display: none;
136
+ }
137
+ `,
138
+ },
139
+ ],
140
+ },
141
+ };
142
+
143
+ yield* until(server.register(fastifySwagger, openApiOptions));
144
+
145
+ yield* until(server.register(fastifySwaggerUi, uiOptions));
146
+ }
147
+
148
+ // TODO This should add user defined endpoints.
149
+
150
+ /**
151
+ * Start the Paima Engine HTTP server.
152
+ * @param dbConn - The database connection.
153
+ * @param syncProtocols - The sync protocols.
154
+ */
155
+ export const startHttpServer = function* (
156
+ dbConn: Pool,
157
+ syncProtocols: AllSyncProtocols[],
158
+ apiRouter?: StartConfigApiRouter,
159
+ grammar?: GrammarDefinition,
160
+ ) {
161
+ // Use dbConn directly; queries are executed via pgtyped PreparedQuery.run
162
+ // Allow any webpage to access the server.
163
+ // This node is not specific for a specific website.
164
+ const server = fastify({ maxParamLength: 300 });
165
+ // OpenAPI Docs
166
+ yield* registerOpenApiDocumentation(server, ENV.EFFECTSTREAM_API_PORT);
167
+
168
+ // Register error-catching handler
169
+ server.setErrorHandler((error: any, request, reply) => {
170
+ console.error("[HTTP SERVER] Error: ", error, request.url);
171
+ reply.status(500).send({ ok: false, error: error?.message ?? "Unknown error" });
172
+ });
173
+
174
+ yield* until(
175
+ server.register(cors, {
176
+ origin: "*",
177
+ }),
178
+ );
179
+
180
+ // Explorer API key preHandler for protecting data exploration endpoints
181
+ const explorerApiKeyPreHandler = async <
182
+ T extends FastifyRequest,
183
+ Q extends FastifyReply,
184
+ >(request: T, reply: Q) => {
185
+ const apiKey =
186
+ (request.headers["x-api-key"] as string) ||
187
+ (request.query as Record<string, string>)?.apiKey;
188
+
189
+ if (apiKey !== ENV.API_KEY_OPEN_ENDPOINTS_EXPLORER) {
190
+ return reply.status(401).send({
191
+ error: "Unauthorized. Provide a valid API key via x-api-key header or apiKey query parameter.",
192
+ });
193
+ }
194
+ };
195
+
196
+ // Fetch raw blocks for a given sync protocol and page/range
197
+ server.get(
198
+ "/sync-protocols/:protocolName/blocks",
199
+ {
200
+ schema: {
201
+ tags: ["developer"],
202
+ querystring: Type.Object({
203
+ page: Type.Optional(Type.Number()),
204
+ from: Type.Optional(Type.Number()),
205
+ to: Type.Optional(Type.Number()),
206
+ // EVM-only toggle to include full transactions if supported by the client
207
+ includeTransactions: Type.Optional(Type.Boolean()),
208
+ }),
209
+ response: {
210
+ 200: Type.Object({
211
+ protocol_name: Type.String(),
212
+ from: Type.Number(),
213
+ to: Type.Number(),
214
+ blocks: Type.Array(Type.Object({}, { additionalProperties: true })),
215
+ }),
216
+ },
217
+ },
218
+ },
219
+ async (
220
+ request: FastifyRequest<
221
+ {
222
+ Params: { protocolName: string };
223
+ Querystring: {
224
+ page?: number;
225
+ from?: number;
226
+ to?: number;
227
+ includeTransactions?: boolean;
228
+ };
229
+ }
230
+ >,
231
+ reply,
232
+ ) => {
233
+ const { protocolName } = request.params;
234
+ const {
235
+ page,
236
+ from: fromQuery,
237
+ to: toQuery,
238
+ includeTransactions = false,
239
+ } = request.query;
240
+ const validPage = typeof page === "number" && !Number.isNaN(page);
241
+ // Resolve range
242
+ const from = validPage ? page : (fromQuery ?? 0);
243
+ const to = validPage ? page : (toQuery ?? from);
244
+ if (!validPage && (typeof from !== "number" || typeof to !== "number")) {
245
+ return reply.status(400).send({ error: "Specify page or from/to" });
246
+ }
247
+ if (to < from) {
248
+ return reply.status(400).send({ error: "Invalid range: to < from" });
249
+ }
250
+
251
+ try {
252
+ const protocol = syncProtocols.find((p) => p.name === protocolName);
253
+ if (!protocol) {
254
+ return reply.status(404).send({ error: "Protocol not found" });
255
+ }
256
+
257
+ const blocks: any[] = [];
258
+ const fetcher = protocol.fetcher;
259
+ const networkType = protocol.config.networkType;
260
+
261
+ switch (networkType) {
262
+ case ConfigNetworkType.EVM: {
263
+ const evmFetcher = fetcher as EvmFetcher;
264
+ for (let n = from; n <= to; n++) {
265
+ // viem: includeTransactions option is { includeTransactions?: boolean }
266
+ const block = await evmFetcher.client.getBlock({
267
+ blockNumber: BigInt(n),
268
+ includeTransactions,
269
+ });
270
+ blocks.push(block);
271
+ }
272
+ break;
273
+ }
274
+ case ConfigNetworkType.MIDNIGHT: {
275
+ const midnightFetcher = fetcher as MidnightFetcher;
276
+ for (let n = from; n <= to; n++) {
277
+ const result = await midnightFetcher.client.fetchBlock(n);
278
+ if (result?.block) blocks.push(result.block);
279
+ }
280
+ break;
281
+ }
282
+ case ConfigNetworkType.NTP:
283
+ {
284
+ const ntpFetcher = fetcher as NtpFetcher;
285
+ const cfg = ntpFetcher.config.network;
286
+ if (!cfg?.startTime || !cfg?.blockTimeMS) {
287
+ return reply.status(500).send({ error: "NTP config missing" });
288
+ }
289
+ for (let n = from; n <= to; n++) {
290
+ const timestamp = BigInt(cfg.startTime) +
291
+ BigInt(cfg.blockTimeMS) * BigInt(n);
292
+ blocks.push({
293
+ blockNumber: n,
294
+ timestamp,
295
+ hash: `0x${timestamp.toString(16)}`,
296
+ });
297
+ }
298
+ }
299
+ break;
300
+ case ConfigNetworkType.CARDANO: {
301
+ const cardanoFetcher = fetcher as UtxoRpcFetcher;
302
+ const res = cardanoFetcher.client.fetchBlocks(from, to);
303
+ for (const item of res) {
304
+ blocks.push(item.output);
305
+ }
306
+ break;
307
+ }
308
+ case ConfigNetworkType.AVAIL: {
309
+ const availFetcher = fetcher as AvailFetcher;
310
+ for (let n = from; n <= to; n++) {
311
+ const result = await availFetcher.client.getBlockHeaderFromHeight(
312
+ n,
313
+ );
314
+ if (result) blocks.push(result);
315
+ }
316
+ break;
317
+ }
318
+ case ConfigNetworkType.BITCOIN: {
319
+ const bitcoinFetcher = fetcher as BitcoinFetcher;
320
+ for (let n = from; n <= to; n++) {
321
+ const block = await bitcoinFetcher.rpcClient.getBlockByHeight(n);
322
+ blocks.push(block);
323
+ }
324
+ break;
325
+ }
326
+ default:
327
+ return reply.status(400).send(
328
+ `Unsupported network type: ${networkType}`,
329
+ );
330
+ }
331
+
332
+ return clearBigInts({
333
+ protocol_name: protocolName,
334
+ from,
335
+ to,
336
+ blocks,
337
+ });
338
+ } catch (error) {
339
+ console.error("Error fetching blocks: ", error);
340
+ return reply.status(500).send({ error: "Internal server error" });
341
+ }
342
+ },
343
+ );
344
+ if (apiRouter) {
345
+ yield* until(apiRouter(server, dbConn));
346
+ }
347
+
348
+ server.get("/health", {
349
+ schema: {
350
+ tags: ["status"],
351
+ response: {
352
+ 200: Type.Object({
353
+ status: Type.String(),
354
+ }),
355
+ },
356
+ },
357
+ }, () => {
358
+ return {
359
+ status: "ok",
360
+ };
361
+ });
362
+
363
+ server.get("/addresses", {
364
+ preHandler: explorerApiKeyPreHandler<
365
+ FastifyRequest<{ Querystring: TypePaginationQuerySchema }>,
366
+ FastifyReply
367
+ >,
368
+ schema: {
369
+ tags: ["status"],
370
+ querystring: Type.Object({
371
+ limit: PaginationQuerySchema.properties.limit,
372
+ after: Type.Optional(Type.Union([
373
+ Type.String({
374
+ description:
375
+ "Cursor for next page (base64-encoded JSON object with primary key values)",
376
+ examples: ["eyJhY2NvdW50X2lkIjoxMjMsImFkZHJlc3MiOiIwMXQzIn0="],
377
+ }),
378
+ Type.Number({
379
+ description: "Offset for pagination (0-based)",
380
+ minimum: 0,
381
+ examples: [0, 100, 500],
382
+ }),
383
+ ])),
384
+ }),
385
+ response: {
386
+ 200: createPaginatedResponseSchema(Type.Object({
387
+ account_id: Type.Union([Type.Number(), Type.Null()]),
388
+ address: Type.String(),
389
+ primary_address: Type.Union([Type.String(), Type.Null()]),
390
+ })),
391
+ },
392
+ },
393
+ }, async (
394
+ request: FastifyRequest<{
395
+ Querystring: TypePaginationQuerySchema;
396
+ }>,
397
+ ) => {
398
+ const { limit, after } = getPaginationParams<{
399
+ account_id: number;
400
+ address: string;
401
+ }>(request.query);
402
+ let addresses: IGetAllAddressesResult[] = [];
403
+ try {
404
+ // @ts-ignore - pgtyped overload resolution is failing in this context
405
+ addresses = await runPreparedQuery(
406
+ getAllAddresses.run(
407
+ {
408
+ limit,
409
+ after_account_id: after?.account_id ?? null,
410
+ after_address: after?.address ?? null,
411
+ },
412
+ dbConn,
413
+ ),
414
+ "addresses",
415
+ );
416
+ } catch (error) {
417
+ console.error("Error fetching addresses:", error);
418
+ throw error;
419
+ }
420
+
421
+ const pagination = createPaginationMeta(
422
+ limit,
423
+ addresses,
424
+ ["account_id", "address"],
425
+ );
426
+
427
+ return {
428
+ data: addresses,
429
+ pagination,
430
+ };
431
+ });
432
+
433
+ server.get("/block-heights", {
434
+ schema: {
435
+ tags: ["status"],
436
+ response: {
437
+ 200: Type.Array(Type.Object({
438
+ protocol_name: Type.String(),
439
+ synced_page: Type.Number({ nullable: true }),
440
+ fetched_page: Type.Number({ nullable: true }),
441
+ })),
442
+ },
443
+ },
444
+ }, async () => {
445
+ const blockHeights = await runPreparedQuery(
446
+ getSyncAndLastPage.run(undefined, dbConn),
447
+ "block-heights",
448
+ );
449
+ return blockHeights;
450
+ });
451
+
452
+ if (ENV.ENABLE_DEV_AND_DEBUG_ENDPOINTS) {
453
+ server.get("/debug/sync-protocols", {
454
+ schema: {
455
+ tags: ["developer"],
456
+ response: {
457
+ 200: Type.Array(Type.Object({
458
+ fetcher: Type.Object({}, { additionalProperties: true }),
459
+ pageRelation: Type.Object({}, { additionalProperties: true }),
460
+ bufferedData: Type.Object({}, { additionalProperties: true }),
461
+ newDataCondVar: Type.Object({}, { additionalProperties: true }),
462
+ newPageCondVar: Type.Object({}, { additionalProperties: true }),
463
+ lastPage: Type.Object({}, { additionalProperties: true }),
464
+ config: Type.Object({}, { additionalProperties: true }),
465
+ }, { additionalProperties: true })),
466
+ },
467
+ },
468
+ }, () => {
469
+ const cleanedProtocols = clearBigInts(syncProtocols);
470
+ return cleanedProtocols;
471
+ });
472
+
473
+ server.get("/config", {
474
+ schema: {
475
+ tags: ["developer"],
476
+ 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 })),
486
+ },
487
+ },
488
+ }, () => {
489
+ const config = syncProtocols.map((syncProtocol) => syncProtocol.config)
490
+ .flat();
491
+ const cleanedConfig = clearBigInts(config);
492
+ return cleanedConfig;
493
+ });
494
+ }
495
+
496
+ server.get("/grammar", {
497
+ schema: {
498
+ tags: ["developer"],
499
+ response: {
500
+ 200: Type.Object({}, { additionalProperties: true }),
501
+ },
502
+ },
503
+ }, () => {
504
+ return grammar;
505
+ });
506
+
507
+ server.get("/scheduled-data", {
508
+ preHandler: explorerApiKeyPreHandler<
509
+ FastifyRequest<{ Querystring: TypePaginationQuerySchema }>,
510
+ FastifyReply
511
+ >,
512
+ schema: {
513
+ tags: ["status"],
514
+ querystring: Type.Object({
515
+ limit: PaginationQuerySchema.properties.limit,
516
+ after: Type.Optional(Type.Union([
517
+ Type.String({
518
+ description:
519
+ "Cursor for next page (base64-encoded JSON object with primary key values)",
520
+ examples: ["eyJpZCI6MTIzfQ=="],
521
+ }),
522
+ Type.Number({
523
+ description: "Offset for pagination (0-based)",
524
+ minimum: 0,
525
+ examples: [0, 100, 500],
526
+ }),
527
+ ])),
528
+ }),
529
+ response: {
530
+ 200: createPaginatedResponseSchema(Type.Object({
531
+ caip2: Type.Union([Type.String(), Type.Null()]),
532
+ contract_address: Type.Union([Type.String(), Type.Null()]),
533
+ from_address: Type.Union([Type.String(), Type.Null()]),
534
+ future_block_height: Type.Union([Type.Number(), Type.Null()]),
535
+ future_ms_timestamp: Type.Union([Type.String(), Type.Null()]), // Date as string
536
+ id: Type.Union([Type.Number(), Type.Null()]),
537
+ input_data: Type.Union([Type.String(), Type.Null()]),
538
+ origin_tx_hash: Type.Union([Type.String(), Type.Null()]), // Buffer as string
539
+ primitive_name: Type.Union([Type.String(), Type.Null()]),
540
+ })),
541
+ },
542
+ },
543
+ }, async (
544
+ request: FastifyRequest<{
545
+ Querystring: TypePaginationQuerySchema;
546
+ }>,
547
+ ) => {
548
+ const { limit, after } = getPaginationParams<{
549
+ id: number;
550
+ }>(request.query);
551
+
552
+ let scheduledData: any[] = [];
553
+ try {
554
+ // @ts-ignore - pgtyped overload resolution is failing in this context
555
+ scheduledData = await runPreparedQuery(
556
+ getAllScheduledData.run(
557
+ {
558
+ limit,
559
+ after_id: after?.id ?? null,
560
+ },
561
+ dbConn,
562
+ ),
563
+ "scheduled-data",
564
+ );
565
+ } catch (error) {
566
+ console.error("Error fetching scheduled data:", error);
567
+ throw error;
568
+ }
569
+
570
+ const pagination = createPaginationMeta(
571
+ limit,
572
+ scheduledData,
573
+ ["id"],
574
+ );
575
+
576
+ return {
577
+ data: scheduledData,
578
+ pagination,
579
+ };
580
+ });
581
+
582
+ server.get("/tables", {
583
+ preHandler: explorerApiKeyPreHandler<
584
+ FastifyRequest<{ Querystring: TypePaginationQuerySchema }>,
585
+ FastifyReply
586
+ >,
587
+ schema: {
588
+ tags: ["developer"],
589
+ response: {
590
+ 200: Type.Array(Type.Object({
591
+ table_name: Type.String(),
592
+ })),
593
+ },
594
+ },
595
+ }, async (
596
+ request: FastifyRequest<{ Querystring: TypePaginationQuerySchema }>,
597
+ reply: FastifyReply,
598
+ ) => {
599
+ const tables = (await runPreparedQuery(
600
+ getAllTableNames.run(undefined, dbConn),
601
+ "tables",
602
+ )) as IGetAllTableNamesResult[];
603
+ return tables
604
+ .filter((t): t is { tablename: string } => t.tablename !== null)
605
+ .map((t) => ({ table_name: t.tablename }));
606
+ });
607
+
608
+ // TODO How to only select user defined tables?
609
+ server.get("/table-schema/:tableName", {
610
+ preHandler: explorerApiKeyPreHandler<
611
+ FastifyRequest<{ Params: { tableName: string } }>,
612
+ FastifyReply
613
+ >,
614
+ schema: {
615
+ tags: ["developer"],
616
+ response: {
617
+ 200: Type.Array(Type.Object({
618
+ column_name: Type.String(),
619
+ data_type: Type.String(),
620
+ character_maximum_length: Type.Number({ nullable: true }),
621
+ column_default: Type.String(),
622
+ is_nullable: Type.String(),
623
+ })),
624
+ },
625
+ },
626
+ }, async (
627
+ request: FastifyRequest<{ Params: { tableName: string } }>,
628
+ _,
629
+ ) => {
630
+ const { tableName } = request.params;
631
+
632
+ const result = await runPreparedQuery(
633
+ getTableSchema.run({ tableName: tableName.toLowerCase() }, dbConn),
634
+ `table-schema:${tableName}`,
635
+ );
636
+
637
+ return result;
638
+ });
639
+
640
+ server.get(
641
+ "/tables/:tableName",
642
+ {
643
+ preHandler: explorerApiKeyPreHandler<
644
+ FastifyRequest<{ Querystring: TypePaginationQuerySchema }>,
645
+ FastifyReply
646
+ >,
647
+ schema: {
648
+ tags: ["developer"],
649
+ querystring: Type.Object({
650
+ limit: PaginationQuerySchema.properties.limit,
651
+ after: Type.Optional(Type.Union([
652
+ Type.String({
653
+ description:
654
+ "Cursor for next page (base64-encoded JSON object with primary key values)",
655
+ examples: ["eyJpZCI6MTIzfQ=="],
656
+ }),
657
+ Type.Number({
658
+ description:
659
+ "Offset for pagination when no primary key is available (0-based)",
660
+ minimum: 0,
661
+ examples: [0, 100, 500],
662
+ }),
663
+ ])),
664
+ }),
665
+ response: {
666
+ 200: createPaginatedResponseSchema(
667
+ Type.Object({}, { additionalProperties: true }),
668
+ ),
669
+ },
670
+ },
671
+ },
672
+ async (
673
+ request: FastifyRequest<
674
+ {
675
+ Params: { tableName: string };
676
+ Querystring: TypePaginationQuerySchema;
677
+ }
678
+ >,
679
+ reply,
680
+ ) => {
681
+ const { tableName } = request.params;
682
+ const { limit, after, offset } = getPaginationParams(request.query);
683
+
684
+ try {
685
+ // Sanitize table name
686
+ const safeTableName = tableName.toLowerCase().replace(
687
+ /[^a-z0-9_.]/g,
688
+ "",
689
+ );
690
+ if (safeTableName.length > 128 || safeTableName.length === 0) {
691
+ return reply.status(400).send({ error: "Invalid table name" });
692
+ }
693
+ const { data, pagination } = await fetchPublicTablePage(
694
+ dbConn,
695
+ safeTableName,
696
+ { limit, after, offset },
697
+ );
698
+ return { data, pagination };
699
+ } catch (error: any) {
700
+ console.error(`Error fetching table ${tableName}:`, error);
701
+ return reply.status(500).send({
702
+ error: error.message || "Internal server error",
703
+ });
704
+ }
705
+ },
706
+ );
707
+
708
+ function getPrimitivePrefixWrapper(
709
+ primitiveName: string,
710
+ ): string | undefined {
711
+ const primitiveTry = PrimitiveRegistry.getPrimitive(primitiveName);
712
+ // TODO map/find the results generated bad TS Types (too hard to represent)
713
+ const findPrimitive = (syncProtocols: AllSyncProtocols[]) => {
714
+ for (const syncProtocol of syncProtocols) {
715
+ for (const primitive of syncProtocol.config.primitives) {
716
+ if (primitive.primitive.name === primitiveName) {
717
+ return primitive;
718
+ }
719
+ }
720
+ }
721
+ return undefined;
722
+ };
723
+ const primitive = findPrimitive(syncProtocols);
724
+ if (!primitive) {
725
+ return undefined;
726
+ }
727
+ return getPrimitivePrefix((primitive.primitive as any).type)[0];
728
+ }
729
+
730
+ server.get("/primitives-schema/:primitiveName", {
731
+ preHandler: explorerApiKeyPreHandler<
732
+ FastifyRequest<{ Params: { primitiveName: string } }>,
733
+ FastifyReply
734
+ >,
735
+ schema: {
736
+ tags: ["developer"],
737
+ response: {
738
+ 200: Type.Array(Type.Object({
739
+ column_name: Type.String(),
740
+ data_type: Type.String(),
741
+ character_maximum_length: Type.Number({ nullable: true }),
742
+ column_default: Type.String(),
743
+ is_nullable: Type.String(),
744
+ })),
745
+ },
746
+ },
747
+ }, async (
748
+ request: FastifyRequest<{ Params: { primitiveName: string } }>,
749
+ reply,
750
+ ) => {
751
+ const { primitiveName } = request.params;
752
+ const prefix = getPrimitivePrefixWrapper(primitiveName);
753
+ if (!prefix) {
754
+ return reply.status(404).send({
755
+ error: "Primitive does not have aggregated data",
756
+ });
757
+ }
758
+ const result = await runPreparedQuery(
759
+ getTableSchema.run({
760
+ tableName: `${prefix}${primitiveName.toLowerCase()}`,
761
+ }, dbConn),
762
+ `primitives-schema:${primitiveName}`,
763
+ );
764
+ return result;
765
+ });
766
+
767
+ server.get(
768
+ "/primitives/:primitiveName",
769
+ {
770
+ preHandler: explorerApiKeyPreHandler,
771
+ schema: {
772
+ tags: ["developer"],
773
+ querystring: Type.Object({
774
+ limit: PaginationQuerySchema.properties.limit,
775
+ after: Type.Optional(Type.Union([
776
+ Type.String({
777
+ description:
778
+ "Cursor for next page (base64-encoded JSON object with primary key values)",
779
+ examples: ["eyJpZCI6MTIzfQ=="],
780
+ }),
781
+ Type.Number({
782
+ description:
783
+ "Offset for pagination when no primary key is available (0-based)",
784
+ minimum: 0,
785
+ examples: [0, 100, 500],
786
+ }),
787
+ ])),
788
+ }),
789
+ response: {
790
+ // TODO
791
+ 200: createPaginatedResponseSchema(
792
+ Type.Object({}, { additionalProperties: true }),
793
+ ),
794
+ },
795
+ },
796
+ },
797
+ async (
798
+ request: FastifyRequest<
799
+ {
800
+ Params: { primitiveName: string };
801
+ Querystring: TypePaginationQuerySchema;
802
+ }
803
+ >,
804
+ reply,
805
+ ) => {
806
+ const { primitiveName } = request.params;
807
+ const { limit, after, offset } = getPaginationParams(request.query);
808
+ const prefix = getPrimitivePrefixWrapper(primitiveName);
809
+ if (!prefix) {
810
+ return reply.status(404).send({
811
+ error: "Primitive does not have aggregated data",
812
+ });
813
+ }
814
+
815
+ try {
816
+ const safeTableName = prefix + primitiveName.toLowerCase().replace(
817
+ /[^a-z0-9_]/g,
818
+ "",
819
+ );
820
+ if (safeTableName.length > 128 || safeTableName.length === 0) {
821
+ return reply.status(400).send({ error: "Invalid table name" });
822
+ }
823
+ const { data, pagination } = await fetchPrimitiveTablePage(
824
+ dbConn,
825
+ safeTableName,
826
+ { limit, after, offset },
827
+ );
828
+ return { data, pagination };
829
+ } catch (error: any) {
830
+ console.error(`Error fetching primitive ${primitiveName}:`, error);
831
+ return reply.status(500).send({
832
+ error:
833
+ `Internal server error fetching primitive data: ${error.message}`,
834
+ });
835
+ }
836
+ },
837
+ );
838
+
839
+ server.get("/db_status", () => {
840
+ return waitUntilFree();
841
+ });
842
+ // These endpoints are only used by the e2e tests to ensure that only one query is executed at a time.
843
+ if (ENV.ENABLE_DEV_AND_DEBUG_ENDPOINTS) {
844
+ server.get(
845
+ "/db_acquire_lock",
846
+ {
847
+ schema: {
848
+ tags: ["developer"],
849
+ response: {
850
+ 200: Type.String(),
851
+ },
852
+ querystring: Type.Object({
853
+ name: Type.String(),
854
+ }),
855
+ },
856
+ },
857
+ async (
858
+ request: FastifyRequest<{ Querystring: { name: string } }>,
859
+ reply,
860
+ ) => {
861
+ const { name } = request.query;
862
+ await run(() => acquireDBMutex(`http-server:${name}`));
863
+ return "ok";
864
+ },
865
+ );
866
+
867
+ server.get("/db_release_lock", {
868
+ schema: {
869
+ tags: ["developer"],
870
+ response: {
871
+ 200: Type.String(),
872
+ },
873
+ querystring: Type.Object({
874
+ name: Type.String(),
875
+ }),
876
+ },
877
+ }, (
878
+ request: FastifyRequest<{ Querystring: { name: string } }>,
879
+ reply,
880
+ ) => {
881
+ const { name } = request.query;
882
+ releaseDBMutex(`http-server:${name}`);
883
+ return "ok";
884
+ });
885
+ }
886
+
887
+ const rpcEngine = evmRpcEngine(dbConn);
888
+ server.post(`/${RpcPaths.Root}/${RpcPaths.EVM}`, {
889
+ schema: {
890
+ tags: ["user"],
891
+ body: Type.Object({
892
+ // TODO When this is activated some test stop passing.
893
+ // Usign viem public client. e.g., rpcClient.getBlockNumber();
894
+ // jsonrpc: Type.Literal("2.0"),
895
+ // method: Type.String(),
896
+ // params: Type.Array(Type.Any()),
897
+ // id: Type.Number(),
898
+ }, { additionalProperties: true }),
899
+ externalDocs: {
900
+ url:
901
+ "https://github.com/etclabscore/ethereum-json-rpc-specification/blob/master/openrpc.json",
902
+ description:
903
+ "Partial Implementation of Ethereum JSON-RPC Specification",
904
+ },
905
+ response: {
906
+ 200: Type.Object({
907
+ jsonrpc: Type.Literal("2.0"),
908
+ id: Type.Number(),
909
+ result: Type.Any(),
910
+ }),
911
+ },
912
+ },
913
+ }, (request, _) => {
914
+ return rpcEngine.handle(
915
+ request.body as any,
916
+ (err: unknown, result: unknown) => {
917
+ if (err) throw err;
918
+ return result;
919
+ },
920
+ );
921
+ });
922
+
923
+ // Start the server
924
+ server.listen(
925
+ { port: ENV.EFFECTSTREAM_API_PORT, host: "0.0.0.0" },
926
+ (err: Error | null, address: string) => {
927
+ if (err) {
928
+ console.error("err", err);
929
+ }
930
+ console.log(`Paima Engine HTTP server running on ${address}`);
931
+ },
932
+ );
933
+ };
934
+
935
+ export function clearBigInts<T>(value: T): T {
936
+ return JSON.parse(
937
+ JSON.stringify(
938
+ value,
939
+ (_, v) => typeof v === "bigint" ? v.toString() : v,
940
+ ),
941
+ );
942
+ }