@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.
- package/package.json +34 -0
- package/src/api/http-server.ts +942 -0
- package/src/api/pagination.ts +456 -0
- package/src/api/rpc-evm/eip1193.ts +920 -0
- package/src/api/rpc-evm/rpc-payloads.ts +297 -0
- package/src/api/rpc-evm/rpc-types.ts +87 -0
- package/src/api/rpc-evm/rpc-utils.ts +50 -0
- package/src/config-snapshot.ts +192 -0
- package/src/main.ts +317 -0
- package/src/mod.ts +3 -0
- package/src/process-blocks.ts +302 -0
- package/src/telemetry.ts +37 -0
- package/src/types.ts +76 -0
- package/src/version-migrations.ts +163 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { type Static, type TSchema, Type } from "@sinclair/typebox";
|
|
2
|
+
import type { Pool } from "pg";
|
|
3
|
+
import {
|
|
4
|
+
getDynamicTables,
|
|
5
|
+
getPrimaryKeyColumns,
|
|
6
|
+
getPublicTables,
|
|
7
|
+
getTableSchema,
|
|
8
|
+
type IGetDynamicTablesResult,
|
|
9
|
+
type IGetPrimaryKeyColumnsResult,
|
|
10
|
+
type IGetPublicTablesResult,
|
|
11
|
+
type IGetTableSchemaResult,
|
|
12
|
+
runPreparedQuery,
|
|
13
|
+
} from "@effectstream/db";
|
|
14
|
+
import { Buffer } from "node:buffer";
|
|
15
|
+
|
|
16
|
+
// Utility functions for SQL injection prevention
|
|
17
|
+
export function validateColumnName(columnName: string): boolean {
|
|
18
|
+
// Only allow alphanumeric characters, underscores, and ensure reasonable length
|
|
19
|
+
const columnRegex = /^[a-zA-Z][a-zA-Z0-9_]{0,63}$/;
|
|
20
|
+
return columnRegex.test(columnName);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateCursorStructure<T extends Record<string, any>>(
|
|
24
|
+
cursor: any,
|
|
25
|
+
expectedFields: (keyof T)[],
|
|
26
|
+
): boolean {
|
|
27
|
+
if (!cursor || typeof cursor !== "object") {
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const cursorKeys = Object.keys(cursor);
|
|
32
|
+
const expectedKeys = expectedFields.map((f) => String(f));
|
|
33
|
+
|
|
34
|
+
// Check that cursor contains exactly the expected fields
|
|
35
|
+
if (cursorKeys.length !== expectedKeys.length) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Check that all expected fields are present and no extra fields exist
|
|
40
|
+
for (const expectedKey of expectedKeys) {
|
|
41
|
+
if (!cursorKeys.includes(expectedKey)) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Validate column name format
|
|
46
|
+
if (!validateColumnName(expectedKey)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Utility functions for SQL injection prevention
|
|
55
|
+
export function escapeColumnName(columnName: string): string {
|
|
56
|
+
// Double-quote column names to prevent SQL injection
|
|
57
|
+
return `"${columnName.replace(/"/g, '""')}"`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateAndEscapeColumnName(columnName: string): string {
|
|
61
|
+
if (!validateColumnName(columnName)) {
|
|
62
|
+
throw new Error(`Invalid column name: ${columnName}`);
|
|
63
|
+
}
|
|
64
|
+
return escapeColumnName(columnName);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function fetchPublicTablePage(
|
|
68
|
+
dbConn: Pool,
|
|
69
|
+
safeTableName: string,
|
|
70
|
+
{
|
|
71
|
+
limit,
|
|
72
|
+
after,
|
|
73
|
+
offset,
|
|
74
|
+
}: { limit: number; after?: Record<string, any>; offset?: number },
|
|
75
|
+
): Promise<PaginatedResponse<any>> {
|
|
76
|
+
// Validate table exists
|
|
77
|
+
const publicTables = await runPreparedQuery<IGetPublicTablesResult>(
|
|
78
|
+
getPublicTables.run(undefined, dbConn),
|
|
79
|
+
"getPublicTables",
|
|
80
|
+
);
|
|
81
|
+
if (!publicTables.some((t) => t.table_name === safeTableName)) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Invalid table name, not found in public schema: ${safeTableName}`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Introspect primary keys
|
|
88
|
+
const pkColumnsResult = await runPreparedQuery<IGetPrimaryKeyColumnsResult>(
|
|
89
|
+
getPrimaryKeyColumns.run({ tableName: `public.${safeTableName}` }, dbConn),
|
|
90
|
+
"getPrimaryKeyColumns",
|
|
91
|
+
);
|
|
92
|
+
const pkColumns = pkColumnsResult
|
|
93
|
+
.map((c) => c.column_name)
|
|
94
|
+
.filter((c): c is string => c !== null);
|
|
95
|
+
|
|
96
|
+
let query: string;
|
|
97
|
+
const params: (string | number)[] = [];
|
|
98
|
+
let cursorFields: string[] = [];
|
|
99
|
+
|
|
100
|
+
if (pkColumns.length > 0) {
|
|
101
|
+
// Keyset pagination
|
|
102
|
+
cursorFields = pkColumns;
|
|
103
|
+
|
|
104
|
+
// Validate cursor structure matches PKs
|
|
105
|
+
if (after) {
|
|
106
|
+
const cursorKeys = Object.keys(after);
|
|
107
|
+
const missingKeys = pkColumns.filter((pk) => !cursorKeys.includes(pk));
|
|
108
|
+
const extraKeys = cursorKeys.filter((k) => !pkColumns.includes(k));
|
|
109
|
+
if (missingKeys.length > 0 || extraKeys.length > 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`Cursor must contain exactly the primary key columns: ${
|
|
112
|
+
pkColumns.join(", ")
|
|
113
|
+
}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let whereClause = "";
|
|
119
|
+
const escapedColumns: string[] = pkColumns.map((c) =>
|
|
120
|
+
validateAndEscapeColumnName(c)
|
|
121
|
+
);
|
|
122
|
+
const orderByClause = `ORDER BY ${
|
|
123
|
+
escapedColumns.map((c) => `${c} ASC`).join(", ")
|
|
124
|
+
}`;
|
|
125
|
+
|
|
126
|
+
if (after) {
|
|
127
|
+
const pkValues = pkColumns.map((c: string) => (after as any)[c]);
|
|
128
|
+
const placeholders = pkColumns.map((_, i: number) => `$${i + 2}`).join(
|
|
129
|
+
", ",
|
|
130
|
+
);
|
|
131
|
+
whereClause = `WHERE (${escapedColumns.join(", ")}) > (${placeholders})`;
|
|
132
|
+
params.push(...pkValues);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
query =
|
|
136
|
+
`SELECT * FROM public.${safeTableName} ${whereClause} ${orderByClause} LIMIT $1`;
|
|
137
|
+
params.unshift(limit + 1);
|
|
138
|
+
} else {
|
|
139
|
+
// Offset pagination fallback
|
|
140
|
+
// console.warn(`[Paima Engine] WARNING: Table "${safeTableName}" has no primary key. Falling back to OFFSET-based pagination.`);
|
|
141
|
+
|
|
142
|
+
const paginationOffset = offset ?? 0;
|
|
143
|
+
|
|
144
|
+
// Find a column to order by
|
|
145
|
+
const tableSchema = await runPreparedQuery<IGetTableSchemaResult>(
|
|
146
|
+
getTableSchema.run({ tableName: safeTableName }, dbConn),
|
|
147
|
+
"table-schema",
|
|
148
|
+
);
|
|
149
|
+
if (tableSchema.length === 0) {
|
|
150
|
+
throw new Error("Table has no columns or does not exist");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate order-by column
|
|
154
|
+
const orderByColumn = tableSchema[0].column_name;
|
|
155
|
+
if (!orderByColumn) {
|
|
156
|
+
throw new Error("Table has no valid column for ordering");
|
|
157
|
+
}
|
|
158
|
+
const escapedOrderByColumn: string = validateAndEscapeColumnName(
|
|
159
|
+
orderByColumn,
|
|
160
|
+
);
|
|
161
|
+
const orderByClause = `ORDER BY ${escapedOrderByColumn} ASC`;
|
|
162
|
+
query =
|
|
163
|
+
`SELECT * FROM public.${safeTableName} ${orderByClause} LIMIT $1 OFFSET $2`;
|
|
164
|
+
params.push(limit + 1, paginationOffset);
|
|
165
|
+
// Build numeric nextCursor for offset mode (not base64 JSON)
|
|
166
|
+
const data = await runPreparedQuery<any[]>(
|
|
167
|
+
new Promise<any[]>((resolve, reject) => {
|
|
168
|
+
return dbConn.query(
|
|
169
|
+
query,
|
|
170
|
+
params,
|
|
171
|
+
(err: any, result: { rows: any[] }) => {
|
|
172
|
+
if (err) reject(err);
|
|
173
|
+
resolve(result.rows);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
}),
|
|
177
|
+
`http-server/tables:${safeTableName}/${query}/${params}`,
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
const hasMore = data.length > limit;
|
|
181
|
+
if (hasMore) data.pop();
|
|
182
|
+
return {
|
|
183
|
+
data,
|
|
184
|
+
pagination: {
|
|
185
|
+
limit,
|
|
186
|
+
hasMore,
|
|
187
|
+
nextCursor: hasMore ? String(paginationOffset + limit) : undefined,
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const data = await runPreparedQuery<any[]>(
|
|
193
|
+
new Promise<any[]>((resolve, reject) => {
|
|
194
|
+
return dbConn.query(
|
|
195
|
+
query,
|
|
196
|
+
params,
|
|
197
|
+
(err: any, result: { rows: any[] }) => {
|
|
198
|
+
if (err) reject(err);
|
|
199
|
+
resolve(result.rows);
|
|
200
|
+
},
|
|
201
|
+
);
|
|
202
|
+
}),
|
|
203
|
+
`http-server/tables:${safeTableName}/${query}/${params}`,
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
const pagination = createPaginationMeta<any>(
|
|
207
|
+
limit,
|
|
208
|
+
data,
|
|
209
|
+
cursorFields as (keyof any)[],
|
|
210
|
+
);
|
|
211
|
+
return { data, pagination };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function fetchPrimitiveTablePage(
|
|
215
|
+
dbConn: Pool,
|
|
216
|
+
safeTableName: string,
|
|
217
|
+
{ limit, after, offset }: {
|
|
218
|
+
limit: number;
|
|
219
|
+
after?: Record<string, any>;
|
|
220
|
+
offset?: number;
|
|
221
|
+
},
|
|
222
|
+
): Promise<PaginatedResponse<any>> {
|
|
223
|
+
// Validate primitive table exists
|
|
224
|
+
const dynamicTables = await runPreparedQuery<IGetDynamicTablesResult>(
|
|
225
|
+
getDynamicTables.run(undefined, dbConn),
|
|
226
|
+
"getDynamicTables",
|
|
227
|
+
);
|
|
228
|
+
if (!dynamicTables.some((t) => t.table_name === safeTableName)) {
|
|
229
|
+
throw new Error(`Table not found: ${safeTableName}`);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Inspect schema to decide strategy
|
|
233
|
+
const tableSchema = await runPreparedQuery<IGetTableSchemaResult>(
|
|
234
|
+
getTableSchema.run({ tableName: safeTableName }, dbConn),
|
|
235
|
+
`primitive-schema:${safeTableName}`,
|
|
236
|
+
);
|
|
237
|
+
if (tableSchema.length === 0) {
|
|
238
|
+
throw new Error("Primitive table has no columns or does not exist");
|
|
239
|
+
}
|
|
240
|
+
const hasIdColumn = tableSchema.some((c) => c.column_name === "id");
|
|
241
|
+
|
|
242
|
+
if (hasIdColumn) {
|
|
243
|
+
// Keyset pagination on id
|
|
244
|
+
let query: string;
|
|
245
|
+
let params: (string | number)[];
|
|
246
|
+
if (after) {
|
|
247
|
+
const afterId = (after as any)["id"];
|
|
248
|
+
if (afterId === undefined) {
|
|
249
|
+
throw new Error("Cursor must contain 'id' when paginating primitives");
|
|
250
|
+
}
|
|
251
|
+
query =
|
|
252
|
+
`SELECT * FROM primitives.${safeTableName} WHERE "id" > $1 ORDER BY "id" ASC LIMIT $2`;
|
|
253
|
+
params = [afterId, limit + 1];
|
|
254
|
+
} else {
|
|
255
|
+
query =
|
|
256
|
+
`SELECT * FROM primitives.${safeTableName} ORDER BY "id" ASC LIMIT $1`;
|
|
257
|
+
params = [limit + 1];
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const data = await runPreparedQuery<any[]>(
|
|
261
|
+
new Promise<any[]>((resolve, reject) => {
|
|
262
|
+
return dbConn.query(
|
|
263
|
+
query,
|
|
264
|
+
params,
|
|
265
|
+
(err: any, result: { rows: any[] }) => {
|
|
266
|
+
if (err) reject(err);
|
|
267
|
+
resolve(result.rows);
|
|
268
|
+
},
|
|
269
|
+
);
|
|
270
|
+
}),
|
|
271
|
+
`http-server/primitives:${safeTableName}/${query}/${params}`,
|
|
272
|
+
);
|
|
273
|
+
const pagination = createPaginationMeta<any>(
|
|
274
|
+
limit,
|
|
275
|
+
data,
|
|
276
|
+
["id"] as (keyof any)[],
|
|
277
|
+
);
|
|
278
|
+
return { data, pagination };
|
|
279
|
+
} else {
|
|
280
|
+
// Fallback to offset-based pagination with numeric nextCursor
|
|
281
|
+
const paginationOffset = offset ?? 0;
|
|
282
|
+
const orderByColumn = tableSchema[0].column_name;
|
|
283
|
+
if (!orderByColumn) {
|
|
284
|
+
throw new Error("Primitive table has no valid column for ordering");
|
|
285
|
+
}
|
|
286
|
+
const escapedOrderByColumn = validateAndEscapeColumnName(orderByColumn);
|
|
287
|
+
const query =
|
|
288
|
+
`SELECT * FROM primitives.${safeTableName} ORDER BY ${escapedOrderByColumn} ASC LIMIT $1 OFFSET $2`;
|
|
289
|
+
const params = [limit + 1, paginationOffset];
|
|
290
|
+
const data = await runPreparedQuery<any[]>(
|
|
291
|
+
new Promise<any[]>((resolve, reject) => {
|
|
292
|
+
return dbConn.query(
|
|
293
|
+
query,
|
|
294
|
+
params,
|
|
295
|
+
(err: any, result: { rows: any[] }) => {
|
|
296
|
+
if (err) reject(err);
|
|
297
|
+
resolve(result.rows);
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
}),
|
|
301
|
+
`http-server/primitives:${safeTableName}/${query}/${params}`,
|
|
302
|
+
);
|
|
303
|
+
const hasMore = data.length > limit;
|
|
304
|
+
if (hasMore) data.pop();
|
|
305
|
+
return {
|
|
306
|
+
data,
|
|
307
|
+
pagination: {
|
|
308
|
+
limit,
|
|
309
|
+
hasMore,
|
|
310
|
+
nextCursor: hasMore ? String(paginationOffset + limit) : undefined,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const DEFAULT_PAGINATION_LIMIT = 100;
|
|
317
|
+
|
|
318
|
+
export const MAX_PAGINATION_LIMIT = 1000;
|
|
319
|
+
|
|
320
|
+
export const PaginationQuerySchema = Type.Object({
|
|
321
|
+
limit: Type.Optional(Type.Integer({
|
|
322
|
+
minimum: 1,
|
|
323
|
+
maximum: MAX_PAGINATION_LIMIT,
|
|
324
|
+
default: DEFAULT_PAGINATION_LIMIT,
|
|
325
|
+
description: "Number of items to return (max 1000)",
|
|
326
|
+
})),
|
|
327
|
+
after: Type.Optional(Type.String({
|
|
328
|
+
description: "Cursor for next page",
|
|
329
|
+
})),
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
export type TypePaginationQuerySchema = Static<typeof PaginationQuerySchema>;
|
|
333
|
+
|
|
334
|
+
export type PaginationQuery = {
|
|
335
|
+
limit?: number;
|
|
336
|
+
after?: string;
|
|
337
|
+
};
|
|
338
|
+
|
|
339
|
+
export const PaginationMetaSchema = Type.Object({
|
|
340
|
+
limit: Type.Integer(),
|
|
341
|
+
nextCursor: Type.Optional(Type.String()),
|
|
342
|
+
hasMore: Type.Optional(
|
|
343
|
+
Type.Boolean({ description: "Whether there are more items available" }),
|
|
344
|
+
),
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
export type PaginationMeta = {
|
|
348
|
+
limit: number;
|
|
349
|
+
nextCursor?: string;
|
|
350
|
+
hasMore?: boolean;
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
export function createPaginatedResponseSchema<T extends TSchema>(schema: T) {
|
|
354
|
+
return Type.Object({
|
|
355
|
+
data: Type.Array(schema),
|
|
356
|
+
pagination: PaginationMetaSchema,
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export type PaginatedResponse<T> = {
|
|
361
|
+
data: T[];
|
|
362
|
+
pagination: PaginationMeta;
|
|
363
|
+
};
|
|
364
|
+
|
|
365
|
+
// Extract and validate pagination parameters from request
|
|
366
|
+
export function getPaginationParams<T extends Object>(
|
|
367
|
+
query: TypePaginationQuerySchema,
|
|
368
|
+
expectedCursorFields?: (keyof T)[],
|
|
369
|
+
): {
|
|
370
|
+
limit: number;
|
|
371
|
+
after: T | undefined;
|
|
372
|
+
offset?: number;
|
|
373
|
+
} {
|
|
374
|
+
const limit = Math.min(
|
|
375
|
+
Math.max(1, parseInt(String(query.limit ?? 1)) || DEFAULT_PAGINATION_LIMIT),
|
|
376
|
+
MAX_PAGINATION_LIMIT,
|
|
377
|
+
);
|
|
378
|
+
|
|
379
|
+
let after: T | undefined;
|
|
380
|
+
let offset: number | undefined;
|
|
381
|
+
|
|
382
|
+
if (query.after) {
|
|
383
|
+
// First try to parse as a number (for offset-based pagination)
|
|
384
|
+
const numericOffset = parseInt(query.after, 10);
|
|
385
|
+
if (!isNaN(numericOffset) && numericOffset >= 0) {
|
|
386
|
+
offset = numericOffset;
|
|
387
|
+
} else {
|
|
388
|
+
// If not a valid number, try to parse as base64-encoded cursor
|
|
389
|
+
const decoded = Buffer.from(query.after, "base64");
|
|
390
|
+
const parsed = JSON.parse(decoded.toString("utf-8"));
|
|
391
|
+
|
|
392
|
+
if (typeof parsed !== "object" || parsed === null) {
|
|
393
|
+
throw new Error("Invalid cursor object");
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (Object.keys(parsed).length === 0) {
|
|
397
|
+
throw new Error("Invalid cursor content");
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// Validate cursor structure if expected fields are provided
|
|
401
|
+
if (
|
|
402
|
+
expectedCursorFields &&
|
|
403
|
+
!validateCursorStructure(parsed, expectedCursorFields)
|
|
404
|
+
) {
|
|
405
|
+
throw new Error("Invalid cursor structure");
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
after = parsed as T;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (offset && offset < 0) {
|
|
413
|
+
throw new Error("Invalid offset: must be non-negative integer");
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return { limit, after, offset };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Create pagination metadata
|
|
420
|
+
export function createPaginationMeta<T extends Record<string, any>>(
|
|
421
|
+
limit: number,
|
|
422
|
+
data: T[],
|
|
423
|
+
cursorFields: (keyof T)[],
|
|
424
|
+
nextCursorSeed?: Record<string, any> | null,
|
|
425
|
+
): PaginationMeta {
|
|
426
|
+
const meta: PaginationMeta = { limit };
|
|
427
|
+
const hasMore = data.length > limit;
|
|
428
|
+
|
|
429
|
+
if (hasMore) {
|
|
430
|
+
// Remove the extra item that was fetched to check for `hasMore`
|
|
431
|
+
data.pop();
|
|
432
|
+
}
|
|
433
|
+
meta.hasMore = hasMore;
|
|
434
|
+
|
|
435
|
+
if (hasMore) {
|
|
436
|
+
let cursorObject: Record<string, any>;
|
|
437
|
+
if (nextCursorSeed) {
|
|
438
|
+
// Use the provided seed for the next cursor (e.g., for offset-based pagination)
|
|
439
|
+
cursorObject = nextCursorSeed;
|
|
440
|
+
} else if (data.length > 0) {
|
|
441
|
+
// Build the cursor from the last item in the dataset
|
|
442
|
+
const lastItem = data[data.length - 1];
|
|
443
|
+
cursorObject = {};
|
|
444
|
+
for (const field of cursorFields) {
|
|
445
|
+
cursorObject[field as string] = lastItem[field];
|
|
446
|
+
}
|
|
447
|
+
} else {
|
|
448
|
+
return meta;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const cursorString = JSON.stringify(cursorObject);
|
|
452
|
+
meta.nextCursor = Buffer.from(cursorString).toString("base64");
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
return meta;
|
|
456
|
+
}
|