@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,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
+ }