@everystack/api 0.1.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,1545 @@
1
+ /**
2
+ * Creates a PostgREST-compatible HTTP handler from a Drizzle db instance and schema.
3
+ *
4
+ * Usage:
5
+ * import { createHandler } from 'everystack/handler';
6
+ * const handler = createHandler(db, schema);
7
+ * // Mount as Expo Router API route or any Web Standard handler
8
+ */
9
+
10
+ import { eq, ne, gt, gte, lt, lte, like, ilike, isNull, isNotNull, inArray, and, or, not, asc, desc, sql } from 'drizzle-orm';
11
+
12
+ /* eslint-disable @typescript-eslint/no-explicit-any */
13
+ type DrizzleDb = any;
14
+ type DrizzleTable = any;
15
+ type DrizzleColumn = any;
16
+
17
+ type Schema = Record<string, DrizzleTable>;
18
+
19
+ type Handler = (request: Request) => Promise<Response>;
20
+
21
+ export interface RoleConfig {
22
+ /** Role required for GET requests on this table */
23
+ read: string;
24
+ /** Role required for POST/PATCH/DELETE requests on this table */
25
+ write: string;
26
+ }
27
+
28
+ export interface AuthConfig {
29
+ /** Verify a Bearer token and return the user payload, or null if invalid */
30
+ verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
31
+ /** HTTP methods that don't require authentication (e.g., ['GET'] for public reads) */
32
+ publicRoutes: string[];
33
+ /** RPC function names that don't require authentication (e.g., ['health', 'issues']) */
34
+ publicRpc?: string[];
35
+ /** Called after successful authentication with the user payload */
36
+ onAuthenticated?: (user: Record<string, unknown>) => void;
37
+ /** Verify client credentials and return client metadata, or null if invalid.
38
+ * When configured, client headers are required on every request (including public routes). */
39
+ verifyClient?: (clientId: string, clientSecret: string) => Promise<Record<string, unknown> | null>;
40
+ /** Custom header names for client credentials.
41
+ * Defaults: { id: 'X-Client-Id', secret: 'X-Client-Secret' } */
42
+ clientHeaders?: { id: string; secret: string };
43
+ /** Called after successful client authentication with the client payload */
44
+ onClientAuthenticated?: (client: Record<string, unknown>) => void;
45
+ /** Per-table role requirements. Key is table name or '*' for wildcard default.
46
+ * When set, overrides publicRoutes for tables that have an entry. */
47
+ roles?: Record<string, RoleConfig>;
48
+ /** Ordered role hierarchy, lowest to highest privilege.
49
+ * Default: ['public', 'authenticated', 'admin'] */
50
+ roleHierarchy?: string[];
51
+ /** Field in the user payload containing the role string. Default: 'role' */
52
+ roleField?: string;
53
+ }
54
+
55
+ export interface RelationConfig {
56
+ /** Target table name (must exist in the schema) */
57
+ table: string;
58
+ /** Column on the source table (e.g., 'authorId' for posts.authorId → authors.id) */
59
+ from: string;
60
+ /** Column on the target table (e.g., 'id') */
61
+ to: string;
62
+ /** If true, this is a one-to-many relation (returns array). Default: false (many-to-one, returns object) */
63
+ many?: boolean;
64
+ }
65
+
66
+ export interface SoftDeleteConfig {
67
+ /** Column name for soft-delete timestamp. Default: 'deletedAt' */
68
+ column?: string;
69
+ /** Tables with soft-delete enabled. If omitted, all tables with the column are treated as soft-delete. */
70
+ tables?: string[];
71
+ }
72
+
73
+ export interface HookConfig {
74
+ /** Called before INSERT. Can modify the body (e.g., inject userId). Throw to abort. */
75
+ beforeCreate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
76
+ /** Called after INSERT with the returned row. Fire-and-forget. */
77
+ afterCreate?: (row: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
78
+ /** Called before UPDATE. Can modify the body. Throw to abort. */
79
+ beforeUpdate?: (body: Record<string, unknown>, user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<Record<string, unknown>>;
80
+ /** Called after UPDATE with all returned rows. Fire-and-forget. */
81
+ afterUpdate?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
82
+ /** Called before DELETE. Throw to abort. */
83
+ beforeDelete?: (user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
84
+ /** Called after DELETE with all deleted rows. Fire-and-forget. */
85
+ afterDelete?: (rows: Record<string, unknown>[], user: Record<string, unknown> | null, client: Record<string, unknown> | null) => Promise<void>;
86
+ }
87
+
88
+ /** Cache-Control header configuration for different response categories. */
89
+ export interface CacheConfig {
90
+ /** Public GET responses (role-based reads allowed without auth).
91
+ * Default: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5' */
92
+ public?: string;
93
+ /** Authenticated/private responses.
94
+ * Default: 'private, no-store' */
95
+ private?: string;
96
+ }
97
+
98
+ export interface HandlerOptions {
99
+ auth?: AuthConfig;
100
+ rpc?: Record<string, (body: unknown, user?: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Promise<unknown>>;
101
+ /** Relation definitions for resource embedding: { [sourceTable]: { [relationName]: RelationConfig } } */
102
+ relations?: Record<string, Record<string, RelationConfig>>;
103
+ /** Base path to strip from request URL (e.g., '/api' when mounted at /api/[...path]) */
104
+ basePath?: string;
105
+ /** Enable soft-delete: DELETE becomes UPDATE SET deleted_at, reads filter deleted rows */
106
+ softDelete?: SoftDeleteConfig;
107
+ /** Drizzle schema relations — enables JOIN queries via db.query API instead of batch WHERE IN */
108
+ drizzleRelations?: Record<string, unknown>;
109
+ /** Compute PostgreSQL session variables from the authenticated user.
110
+ * When provided, queries are wrapped in a transaction with set_config() calls
111
+ * so RLS policies can reference the user context. */
112
+ pgSettings?: (user: Record<string, unknown> | null, client?: Record<string, unknown> | null) => Record<string, string>;
113
+ /** Per-table lifecycle hooks for mutations. */
114
+ hooks?: Record<string, HookConfig>;
115
+ /** API naming convention for query params, request bodies, and responses.
116
+ * 'snake_case': accept snake_case inbound, return snake_case outbound.
117
+ * Default: use Drizzle property names as-is (typically camelCase). */
118
+ naming?: 'camelCase' | 'snake_case';
119
+ /** Override default Cache-Control headers for API responses. */
120
+ cache?: CacheConfig;
121
+ }
122
+
123
+ function getSoftDeleteColumn(table: DrizzleTable, options?: HandlerOptions): DrizzleColumn | null {
124
+ if (!options?.softDelete) return null;
125
+ const colName = options.softDelete.column || 'deletedAt';
126
+ return getColumn(table, colName);
127
+ }
128
+
129
+ function isSoftDeleteTable(tableName: string, table: DrizzleTable, options?: HandlerOptions): boolean {
130
+ if (!options?.softDelete) return false;
131
+ if (options.softDelete.tables) {
132
+ return options.softDelete.tables.includes(tableName);
133
+ }
134
+ // Auto-detect: table has the soft-delete column
135
+ return getSoftDeleteColumn(table, options) != null;
136
+ }
137
+
138
+ /**
139
+ * Execute a function with pgSettings applied in the same transaction.
140
+ *
141
+ * set_config(key, value, true) is LOCAL — transaction-scoped. Without a
142
+ * wrapping transaction the settings and the query may hit different pooled
143
+ * connections, losing RLS context. This helper ensures they share one.
144
+ *
145
+ * The 'role' key is special — it maps to SET LOCAL ROLE instead of set_config.
146
+ * This is required for PostgreSQL RLS policies that use TO role_name clauses.
147
+ * Without SET ROLE, the connection stays as the superuser/owner and BYPASSRLS
148
+ * silently disables all row-level security policies.
149
+ */
150
+ async function executeWithSettings<T>(
151
+ db: DrizzleDb,
152
+ settings: Record<string, string> | undefined,
153
+ fn: (dbOrTx: DrizzleDb) => Promise<T>,
154
+ ): Promise<T> {
155
+ if (settings && Object.keys(settings).length > 0) {
156
+ return db.transaction(async (tx: DrizzleDb) => {
157
+ const role = settings['role'];
158
+ if (role) {
159
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
160
+ throw new Error(`Invalid role name: ${role}`);
161
+ }
162
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
163
+ }
164
+ for (const [key, value] of Object.entries(settings)) {
165
+ if (key !== 'role' && value !== undefined && value !== '') {
166
+ await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
167
+ }
168
+ }
169
+ return fn(tx);
170
+ });
171
+ }
172
+ return fn(db);
173
+ }
174
+
175
+ /**
176
+ * Determine the role required for a given table + HTTP method.
177
+ * Resolution: roles[tableName] → roles['*'] → null (use publicRoutes fallback).
178
+ */
179
+ function getRequiredRole(
180
+ tableName: string,
181
+ method: string,
182
+ auth?: AuthConfig,
183
+ ): string | null {
184
+ if (!auth?.roles) return null;
185
+ const config = auth.roles[tableName] ?? auth.roles['*'];
186
+ if (!config) return null;
187
+ return method === 'GET' ? config.read : config.write;
188
+ }
189
+
190
+ /**
191
+ * Check whether userRole meets or exceeds requiredRole in the hierarchy.
192
+ * Both in hierarchy → index comparison. Otherwise → exact string match.
193
+ */
194
+ function hasRole(
195
+ userRole: string,
196
+ requiredRole: string,
197
+ hierarchy: string[],
198
+ ): boolean {
199
+ const requiredIndex = hierarchy.indexOf(requiredRole);
200
+ const userIndex = hierarchy.indexOf(userRole);
201
+ if (requiredIndex !== -1 && userIndex !== -1) {
202
+ return userIndex >= requiredIndex;
203
+ }
204
+ return userRole === requiredRole;
205
+ }
206
+
207
+ /**
208
+ * Extract Bearer token from request and verify it.
209
+ * Returns { user, error } — error is a Response (401) if auth fails.
210
+ */
211
+ async function verifyUserToken(
212
+ request: Request,
213
+ auth: AuthConfig,
214
+ ): Promise<{ user: Record<string, unknown> | null; error: Response | null }> {
215
+ const authHeader = request.headers.get('Authorization');
216
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
217
+
218
+ if (!token) {
219
+ return { user: null, error: json({ message: 'Authentication required: no auth token provided' }, 401) };
220
+ }
221
+
222
+ let user: Record<string, unknown> | null;
223
+ try {
224
+ user = await auth.verifyToken(token);
225
+ } catch {
226
+ return { user: null, error: json({ message: 'Authentication failed: invalid or expired token' }, 401) };
227
+ }
228
+
229
+ if (!user) {
230
+ return { user: null, error: json({ message: 'Authentication failed: invalid or expired token' }, 401) };
231
+ }
232
+
233
+ return { user, error: null };
234
+ }
235
+
236
+ /**
237
+ * Perform role-based auth check. Returns { user, error }.
238
+ *
239
+ * - 'public' → no token required (extracts user if token present for pgSettings)
240
+ * - 'authenticated' → any valid JWT
241
+ * - named role → valid JWT + role >= required in hierarchy
242
+ */
243
+ async function checkRoleAuth(
244
+ requiredRole: string,
245
+ request: Request,
246
+ auth: AuthConfig,
247
+ ): Promise<{ user: Record<string, unknown> | null; error: Response | null }> {
248
+ if (requiredRole === 'public') {
249
+ return { user: null, error: null };
250
+ }
251
+
252
+ const result = await verifyUserToken(request, auth);
253
+ if (result.error) return result;
254
+
255
+ // 'authenticated' means any valid JWT — role claim is irrelevant
256
+ if (requiredRole === 'authenticated') {
257
+ return result;
258
+ }
259
+
260
+ // Named role: check hierarchy
261
+ const hierarchy = auth.roleHierarchy ?? ['public', 'authenticated', 'admin'];
262
+ const roleField = auth.roleField ?? 'role';
263
+ const userRole = String(result.user![roleField] ?? '');
264
+
265
+ if (!hasRole(userRole, requiredRole, hierarchy)) {
266
+ return {
267
+ user: result.user,
268
+ error: json({ message: `Insufficient permissions: requires ${requiredRole} role` }, 403),
269
+ };
270
+ }
271
+
272
+ return result;
273
+ }
274
+
275
+ /**
276
+ * Determine the Cache-Control header for a GET response based on role config.
277
+ * - pgSettings configured → private header (per-user data via RLS)
278
+ * - read: 'public' → public header
279
+ * - Any other role → private header
280
+ * - No roles config → null (let server package set its default)
281
+ *
282
+ * Headers are configurable via options.cache. Defaults:
283
+ * - public: 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5'
284
+ * - private: 'private, no-store'
285
+ */
286
+ function getCacheControl(tableName: string, options?: HandlerOptions): string | null {
287
+ const publicHeader = options?.cache?.public ?? 'public, max-age=60, s-maxage=2592000, stale-while-revalidate=5';
288
+ const privateHeader = options?.cache?.private ?? 'private, no-store';
289
+
290
+ if (options?.pgSettings) return privateHeader;
291
+ if (!options?.auth?.roles) return null;
292
+ const requiredRole = getRequiredRole(tableName, 'GET', options.auth);
293
+ if (requiredRole === null) return null;
294
+ if (requiredRole === 'public') return publicHeader;
295
+ return privateHeader;
296
+ }
297
+
298
+ /**
299
+ * Serialize roles config into a compact JSON manifest for CloudFront KVS.
300
+ * Format: { "__tables__": ["posts", ...], "*": { "r": "public", "w": "authenticated" }, ... }
301
+ */
302
+ export function serializeRolesManifest(
303
+ schema: Schema,
304
+ roles: Record<string, RoleConfig>,
305
+ ): string {
306
+ const manifest: Record<string, unknown> = { __tables__: Object.keys(schema) };
307
+ for (const [key, config] of Object.entries(roles)) {
308
+ manifest[key] = { r: config.read, w: config.write };
309
+ }
310
+ return JSON.stringify(manifest);
311
+ }
312
+
313
+ // --- Naming convention helpers ---
314
+
315
+ function toCamelCase(str: string): string {
316
+ return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
317
+ }
318
+
319
+ function toSnakeCase(str: string): string {
320
+ return str.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
321
+ }
322
+
323
+ function transformKeys(data: unknown, fn: (key: string) => string): unknown {
324
+ if (Array.isArray(data)) {
325
+ return data.map(item => transformKeys(item, fn));
326
+ }
327
+ if (data !== null && typeof data === 'object' && !(data instanceof Date)) {
328
+ const result: Record<string, unknown> = {};
329
+ for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
330
+ result[fn(key)] = transformKeys(value, fn);
331
+ }
332
+ return result;
333
+ }
334
+ return data;
335
+ }
336
+
337
+ function transformResponse(data: unknown, options?: HandlerOptions): unknown {
338
+ if (!options?.naming || options.naming === 'camelCase') return data;
339
+ return transformKeys(data, toSnakeCase);
340
+ }
341
+
342
+ function transformBodyKeys(body: Record<string, unknown>, table: DrizzleTable): Record<string, unknown> {
343
+ const result: Record<string, unknown> = {};
344
+ for (const [key, value] of Object.entries(body)) {
345
+ if (table[key]) {
346
+ result[key] = value;
347
+ } else if (key.includes('_')) {
348
+ const camelKey = toCamelCase(key);
349
+ result[table[camelKey] ? camelKey : key] = value;
350
+ } else {
351
+ result[key] = value;
352
+ }
353
+ }
354
+ return result;
355
+ }
356
+
357
+ export function createHandler(db: DrizzleDb, schema: Schema, options?: HandlerOptions): Handler {
358
+ const tableNames = new Set(Object.keys(schema));
359
+
360
+ return async (request: Request): Promise<Response> => {
361
+ const url = new URL(request.url);
362
+ let pathname = url.pathname;
363
+ if (options?.basePath) {
364
+ const base = options.basePath.replace(/\/+$/, '');
365
+ if (pathname.startsWith(base)) {
366
+ pathname = pathname.slice(base.length);
367
+ }
368
+ }
369
+ const pathSegments = pathname.replace(/^\/+/, '').split('/').filter(Boolean);
370
+ const method = request.method.toUpperCase();
371
+
372
+ // Phase 1: Client auth (if configured) — identifies which app is calling
373
+ let user: Record<string, unknown> | null = null;
374
+ let client: Record<string, unknown> | null = null;
375
+ if (options?.auth?.verifyClient) {
376
+ const idHeader = options.auth.clientHeaders?.id ?? 'X-Client-Id';
377
+ const secretHeader = options.auth.clientHeaders?.secret ?? 'X-Client-Secret';
378
+ const clientId = request.headers.get(idHeader);
379
+ const clientSecret = request.headers.get(secretHeader);
380
+
381
+ if (!clientId || !clientSecret) {
382
+ return json({ message: 'Client authentication required: missing client credentials' }, 401);
383
+ }
384
+
385
+ try {
386
+ client = await options.auth.verifyClient(clientId, clientSecret);
387
+ } catch {
388
+ return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
389
+ }
390
+
391
+ if (!client) {
392
+ return json({ message: 'Client authentication failed: invalid client credentials' }, 401);
393
+ }
394
+
395
+ if (options.auth.onClientAuthenticated) {
396
+ options.auth.onClientAuthenticated(client);
397
+ }
398
+ }
399
+
400
+ // Root path — table listing, always public
401
+ if (pathSegments.length === 0) {
402
+ return json({ tables: Array.from(tableNames) }, 200);
403
+ }
404
+
405
+ // RPC path — uses publicRoutes/publicRpc for auth, not table roles
406
+ if (pathSegments[0] === 'rpc') {
407
+ if (options?.auth) {
408
+ const isPublicMethod = options.auth.publicRoutes.includes(method);
409
+ const isPublicRpc = pathSegments[1]
410
+ && options.auth.publicRpc?.includes(pathSegments[1]);
411
+ if (!isPublicMethod && !isPublicRpc) {
412
+ const result = await verifyUserToken(request, options.auth);
413
+ if (result.error) return result.error;
414
+ user = result.user;
415
+ if (user && options.auth.onAuthenticated) {
416
+ options.auth.onAuthenticated(user);
417
+ }
418
+ }
419
+ }
420
+
421
+ const fnName = pathSegments[1];
422
+ if (options?.rpc && fnName && options.rpc[fnName]) {
423
+ const body = method === 'POST' ? await request.json().catch(() => ({})) : {};
424
+ const result = await options.rpc[fnName](body, user, client);
425
+ return json(result, 200);
426
+ }
427
+ return json({ message: 'Function not found' }, 404);
428
+ }
429
+
430
+ const tableName = pathSegments[0];
431
+
432
+ if (!tableNames.has(tableName)) {
433
+ return json({ message: `Table '${tableName}' not found` }, 404);
434
+ }
435
+
436
+ // Phase 2: User auth (role-aware) — now we know the table name
437
+ if (options?.auth) {
438
+ const requiredRole = getRequiredRole(tableName, method, options.auth);
439
+
440
+ if (requiredRole !== null) {
441
+ // Role-based path
442
+ const result = await checkRoleAuth(requiredRole, request, options.auth);
443
+ if (result.error) return result.error;
444
+ user = result.user;
445
+ } else {
446
+ // Legacy path: fall back to publicRoutes
447
+ const isPublicMethod = options.auth.publicRoutes.includes(method);
448
+ if (!isPublicMethod) {
449
+ const result = await verifyUserToken(request, options.auth);
450
+ if (result.error) return result.error;
451
+ user = result.user;
452
+ }
453
+ }
454
+
455
+ if (user && options.auth.onAuthenticated) {
456
+ options.auth.onAuthenticated(user);
457
+ }
458
+ }
459
+
460
+ // Compute pgSettings after auth (user now resolved)
461
+ const settings = options?.pgSettings?.(user, client);
462
+ const table = schema[tableName];
463
+
464
+ return executeWithSettings(db, settings, async (dbOrTx) => {
465
+ if (method === 'GET') {
466
+ const cacheControl = getCacheControl(tableName, options);
467
+ return handleGet(dbOrTx, schema, table, tableName, url.searchParams, request, options, cacheControl);
468
+ }
469
+
470
+ if (method === 'POST') {
471
+ return handlePost(dbOrTx, table, tableName, request, options, user, client);
472
+ }
473
+
474
+ if (method === 'PATCH') {
475
+ return handlePatch(dbOrTx, table, tableName, request, url.searchParams, options, user, client);
476
+ }
477
+
478
+ if (method === 'DELETE') {
479
+ return handleDelete(dbOrTx, table, tableName, url.searchParams, options, user, client);
480
+ }
481
+
482
+ return json({ message: `Method ${method} not allowed` }, 405);
483
+ });
484
+ };
485
+ }
486
+
487
+ /**
488
+ * Convert parsed embeds to Drizzle's `with` clause format for relational queries.
489
+ * { name: 'author', columns: null } → { author: true }
490
+ * { name: 'author', columns: ['name'] } → { author: { columns: { name: true } } }
491
+ */
492
+ function buildWithClause(
493
+ embeds: EmbedRequest[],
494
+ tableRelations: Record<string, RelationConfig>,
495
+ ): Record<string, any> | undefined {
496
+ if (embeds.length === 0) return undefined;
497
+
498
+ const withClause: Record<string, any> = {};
499
+ let hasEmbedded = false;
500
+
501
+ for (const embed of embeds) {
502
+ if (!tableRelations[embed.name]) continue;
503
+ hasEmbedded = true;
504
+
505
+ if (embed.columns === null) {
506
+ withClause[embed.name] = true;
507
+ } else {
508
+ const columns: Record<string, boolean> = {};
509
+ for (const col of embed.columns) {
510
+ columns[col] = true;
511
+ }
512
+ withClause[embed.name] = { columns };
513
+ }
514
+ }
515
+
516
+ return hasEmbedded ? withClause : undefined;
517
+ }
518
+
519
+ /**
520
+ * Convert column names array to Drizzle's `columns` selection format.
521
+ * ['id', 'title'] → { id: true, title: true }
522
+ * null → undefined (all columns)
523
+ */
524
+ function buildColumnsSelection(columns: string[] | null): Record<string, boolean> | undefined {
525
+ if (!columns) return undefined;
526
+ const result: Record<string, boolean> = {};
527
+ for (const col of columns) {
528
+ result[col] = true;
529
+ }
530
+ return Object.keys(result).length > 0 ? result : undefined;
531
+ }
532
+
533
+ /**
534
+ * Build a Drizzle select object for aggregate queries.
535
+ * Maps aggregate fields to sql expressions and group-by columns to column refs.
536
+ */
537
+ function buildAggregateSelect(
538
+ table: DrizzleTable,
539
+ aggregates: AggregateField[],
540
+ groupColumns: string[],
541
+ ): Record<string, any> {
542
+ const selection: Record<string, any> = {};
543
+
544
+ for (const colName of groupColumns) {
545
+ const col = getColumn(table, colName);
546
+ if (col) selection[colName] = col;
547
+ }
548
+
549
+ for (const agg of aggregates) {
550
+ if (agg.fn === 'count' && !agg.column) {
551
+ selection[agg.alias] = sql<number>`count(*)`;
552
+ } else if (agg.column) {
553
+ const col = getColumn(table, agg.column);
554
+ if (!col) continue;
555
+ switch (agg.fn) {
556
+ case 'count':
557
+ selection[agg.alias] = sql<number>`count(${col})`;
558
+ break;
559
+ case 'sum':
560
+ selection[agg.alias] = sql<number>`sum(${col})`;
561
+ break;
562
+ case 'avg':
563
+ selection[agg.alias] = sql<number>`avg(${col})`;
564
+ break;
565
+ case 'min':
566
+ selection[agg.alias] = sql<number>`min(${col})`;
567
+ break;
568
+ case 'max':
569
+ selection[agg.alias] = sql<number>`max(${col})`;
570
+ break;
571
+ }
572
+ }
573
+ }
574
+
575
+ return selection;
576
+ }
577
+
578
+ /**
579
+ * Serialize aggregate result rows: convert BigInt/string values to Number
580
+ * for aggregate fields only (group-by column values left untouched).
581
+ */
582
+ function serializeAggregateRows(
583
+ rows: Record<string, unknown>[],
584
+ aggregates: AggregateField[],
585
+ ): Record<string, unknown>[] {
586
+ const aggAliases = new Set(aggregates.map(a => a.alias));
587
+ return rows.map(row => {
588
+ const result: Record<string, unknown> = {};
589
+ for (const [key, value] of Object.entries(row)) {
590
+ if (aggAliases.has(key) && (typeof value === 'bigint' || typeof value === 'string')) {
591
+ result[key] = Number(value);
592
+ } else {
593
+ result[key] = value;
594
+ }
595
+ }
596
+ return result;
597
+ });
598
+ }
599
+
600
+ async function handleGet(
601
+ db: DrizzleDb,
602
+ schema: Schema,
603
+ table: DrizzleTable,
604
+ tableName: string,
605
+ params: URLSearchParams,
606
+ request: Request,
607
+ options?: HandlerOptions,
608
+ cacheControl?: string | null,
609
+ ): Promise<Response> {
610
+ const limitVal = params.has('limit') ? parseInt(params.get('limit')!, 10) : 1000;
611
+ const offsetVal = params.has('offset') ? parseInt(params.get('offset')!, 10) : 0;
612
+ const orderParam = params.get('order');
613
+ const selectParam = params.get('select');
614
+
615
+ // Check if client wants exact count (expensive — requires separate COUNT query)
616
+ const prefer = request.headers.get('Prefer') || '';
617
+ const wantExactCount = prefer.includes('count=exact');
618
+
619
+ // Parse select parameter for relation embedding and aggregates
620
+ let { columns, embeds, aggregates } = parseSelectParam(selectParam, options?.relations?.[tableName]);
621
+
622
+ // Resolve snake_case select column names to Drizzle camelCase properties
623
+ if (options?.naming === 'snake_case' && columns) {
624
+ columns = columns.map(col => {
625
+ if (table[col]) return col;
626
+ const camelCol = toCamelCase(col);
627
+ return table[camelCol] ? camelCol : col;
628
+ });
629
+ }
630
+
631
+ // Parse PostgREST filters (including or()/and() logical groups and not. negation)
632
+ const conditions = buildAllConditions(table, params);
633
+
634
+ // Soft-delete: filter deleted records unless ?deleted=include|only
635
+ if (isSoftDeleteTable(tableName, table, options)) {
636
+ const sdCol = getSoftDeleteColumn(table, options);
637
+ if (sdCol) {
638
+ const deletedParam = params.get('deleted');
639
+ if (deletedParam === 'only') {
640
+ conditions.push(isNotNull(sdCol));
641
+ } else if (deletedParam !== 'include') {
642
+ // Default: exclude deleted records
643
+ conditions.push(isNull(sdCol));
644
+ }
645
+ }
646
+ }
647
+
648
+ // Aggregate query path: SELECT count(*), sum(amount), ... GROUP BY platform
649
+ if (aggregates.length > 0) {
650
+ if (embeds.length > 0) {
651
+ return json({ message: 'Aggregate functions cannot be used with relation embedding' }, 400);
652
+ }
653
+
654
+ const groupColumns = columns || [];
655
+ const selectObj = buildAggregateSelect(table, aggregates, groupColumns);
656
+
657
+ let query: any = db.select(selectObj).from(table);
658
+
659
+ if (conditions.length === 1) {
660
+ query = query.where(conditions[0]);
661
+ } else if (conditions.length > 1) {
662
+ query = query.where(and(...conditions));
663
+ }
664
+
665
+ if (groupColumns.length > 0) {
666
+ const groupByRefs = groupColumns.map(c => getColumn(table, c)).filter(Boolean);
667
+ if (groupByRefs.length > 0) {
668
+ query = query.groupBy(...groupByRefs);
669
+ }
670
+ }
671
+
672
+ if (orderParam) {
673
+ const orderExpr = buildOrderBy(table, orderParam);
674
+ if (orderExpr) query = query.orderBy(orderExpr);
675
+ }
676
+
677
+ query = query.limit(limitVal).offset(offsetVal);
678
+
679
+ const aggRows = await query;
680
+ const serialized = serializeAggregateRows(aggRows as Record<string, unknown>[], aggregates);
681
+
682
+ const rowCount = serialized.length;
683
+ let contentRange: string;
684
+ if (rowCount === 0) {
685
+ contentRange = '*/*';
686
+ } else {
687
+ contentRange = `${offsetVal}-${offsetVal + rowCount - 1}/*`;
688
+ }
689
+
690
+ const aggHeaders: Record<string, string> = {
691
+ 'Content-Type': 'application/json',
692
+ 'Content-Range': contentRange,
693
+ };
694
+ if (cacheControl) aggHeaders['Cache-Control'] = cacheControl;
695
+
696
+ return new Response(JSON.stringify(transformResponse(serialized, options)), {
697
+ status: 200,
698
+ headers: aggHeaders,
699
+ });
700
+ }
701
+
702
+ // Detect if Drizzle relational query (JOINs) should be used
703
+ const tableRelations = options?.relations?.[tableName];
704
+ const useRelationalQuery = embeds.length > 0
705
+ && tableRelations
706
+ && options?.drizzleRelations
707
+ && (db as any).query?.[tableName];
708
+
709
+ let rows: unknown[];
710
+
711
+ if (useRelationalQuery) {
712
+ // JOIN path — single query via Drizzle relational API (db.query.findMany)
713
+ const queryOptions: Record<string, any> = {
714
+ limit: limitVal,
715
+ offset: offsetVal,
716
+ };
717
+
718
+ const colSelection = buildColumnsSelection(columns);
719
+ if (colSelection) queryOptions.columns = colSelection;
720
+
721
+ const withClause = buildWithClause(embeds, tableRelations!);
722
+ if (withClause) queryOptions.with = withClause;
723
+
724
+ if (conditions.length === 1) {
725
+ queryOptions.where = conditions[0];
726
+ } else if (conditions.length > 1) {
727
+ queryOptions.where = and(...conditions);
728
+ }
729
+
730
+ if (orderParam) {
731
+ const orderExpr = buildOrderBy(table, orderParam);
732
+ if (orderExpr) queryOptions.orderBy = orderExpr;
733
+ }
734
+
735
+ rows = await (db as any).query[tableName].findMany(queryOptions);
736
+ } else {
737
+ // Batch path — standard select + separate relation queries
738
+ let query: any = db.select().from(table);
739
+
740
+ if (conditions.length === 1) {
741
+ query = query.where(conditions[0]);
742
+ } else if (conditions.length > 1) {
743
+ query = query.where(and(...conditions));
744
+ }
745
+
746
+ if (orderParam) {
747
+ const orderExpr = buildOrderBy(table, orderParam);
748
+ if (orderExpr) {
749
+ query = query.orderBy(orderExpr);
750
+ }
751
+ }
752
+
753
+ query = query.limit(limitVal).offset(offsetVal);
754
+ rows = await query;
755
+ }
756
+
757
+ // Content-Range header: PostgREST convention for pagination metadata
758
+ let total: string = '*';
759
+ if (wantExactCount) {
760
+ let countQuery: any = db.select({ count: sql<number>`count(*)` }).from(table);
761
+ if (conditions.length === 1) {
762
+ countQuery = countQuery.where(conditions[0]);
763
+ } else if (conditions.length > 1) {
764
+ countQuery = countQuery.where(and(...conditions));
765
+ }
766
+ const [{ count }] = await countQuery;
767
+ total = String(count);
768
+ }
769
+
770
+ const rowCount = (rows as unknown[]).length;
771
+ let contentRange: string;
772
+ if (rowCount === 0) {
773
+ contentRange = `*/${total}`;
774
+ } else {
775
+ const rangeStart = offsetVal;
776
+ const rangeEnd = offsetVal + rowCount - 1;
777
+ contentRange = `${rangeStart}-${rangeEnd}/${total}`;
778
+ }
779
+
780
+ // Resolve relation embeds (batch path only — JOIN path already has nested data)
781
+ let responseData: unknown[] = rows as unknown[];
782
+ if (!useRelationalQuery && embeds.length > 0 && rowCount > 0) {
783
+ responseData = await resolveEmbeds(db, schema, tableName, rows as Record<string, unknown>[], embeds, options!);
784
+ }
785
+
786
+ // Apply column selection (batch path only — JOIN path handles via queryOptions.columns)
787
+ if (!useRelationalQuery && columns) {
788
+ responseData = (responseData as Record<string, unknown>[]).map(row => {
789
+ const filtered: Record<string, unknown> = {};
790
+ for (const col of columns) {
791
+ if (col in row) {
792
+ filtered[col] = row[col];
793
+ }
794
+ }
795
+ for (const embed of embeds) {
796
+ if (embed.name in row) {
797
+ filtered[embed.name] = row[embed.name];
798
+ }
799
+ }
800
+ return filtered;
801
+ });
802
+ }
803
+
804
+ const getHeaders: Record<string, string> = {
805
+ 'Content-Type': 'application/json',
806
+ 'Content-Range': contentRange,
807
+ };
808
+ if (cacheControl) getHeaders['Cache-Control'] = cacheControl;
809
+
810
+ return new Response(JSON.stringify(transformResponse(responseData, options)), {
811
+ status: 200,
812
+ headers: getHeaders,
813
+ });
814
+ }
815
+
816
+ /**
817
+ * Parse select param to separate plain columns from relation embeds.
818
+ * Examples:
819
+ * "*,author(*)" → columns: null (all), embeds: [{ name: 'author', columns: null }]
820
+ * "id,title,author(name)" → columns: ['id','title'], embeds: [{ name: 'author', columns: ['name'] }]
821
+ */
822
+ interface EmbedRequest {
823
+ name: string;
824
+ columns: string[] | null; // null = all columns (*)
825
+ }
826
+
827
+ type AggregateFunction = 'count' | 'sum' | 'avg' | 'min' | 'max';
828
+
829
+ interface AggregateField {
830
+ alias: string;
831
+ fn: AggregateFunction;
832
+ column: string | null; // null for bare count()
833
+ }
834
+
835
+ // Pattern: optional_alias:optional_column.function()
836
+ // Matches: count(), amount.sum(), total:amount.sum(), row_count:count()
837
+ const AGG_REGEX = /^(?:(\w+):)?(?:(\w+)\.)?(count|sum|avg|min|max)\(\)$/;
838
+
839
+ function parseSelectParam(
840
+ selectParam: string | null,
841
+ tableRelations?: Record<string, RelationConfig>,
842
+ ): { columns: string[] | null; embeds: EmbedRequest[]; aggregates: AggregateField[] } {
843
+ if (!selectParam) {
844
+ return { columns: null, embeds: [], aggregates: [] };
845
+ }
846
+
847
+ const embeds: EmbedRequest[] = [];
848
+ const columns: string[] = [];
849
+ const aggregates: AggregateField[] = [];
850
+ let hasWildcard = false;
851
+
852
+ // Parse: split on commas, but respect parentheses
853
+ const parts = splitSelectParam(selectParam);
854
+
855
+ for (const part of parts) {
856
+ // Check for aggregate pattern first: count(), amount.sum(), total:amount.sum()
857
+ const aggMatch = part.match(AGG_REGEX);
858
+ if (aggMatch) {
859
+ const alias = aggMatch[1] || aggMatch[3]; // explicit alias or function name
860
+ const column = aggMatch[2] || null;
861
+ const fn = aggMatch[3] as AggregateFunction;
862
+ aggregates.push({ alias, fn, column });
863
+ continue;
864
+ }
865
+
866
+ const embedMatch = part.match(/^(\w+)\((.+)\)$/);
867
+ if (embedMatch && tableRelations && embedMatch[1] in tableRelations) {
868
+ const name = embedMatch[1];
869
+ const innerSelect = embedMatch[2].trim();
870
+ embeds.push({
871
+ name,
872
+ columns: innerSelect === '*' ? null : innerSelect.split(',').map(c => c.trim()),
873
+ });
874
+ } else if (part === '*') {
875
+ hasWildcard = true;
876
+ } else {
877
+ columns.push(part);
878
+ }
879
+ }
880
+
881
+ return {
882
+ columns: hasWildcard ? null : (columns.length > 0 ? columns : null),
883
+ embeds,
884
+ aggregates,
885
+ };
886
+ }
887
+
888
+ /**
889
+ * Split select param respecting parentheses:
890
+ * "id,title,author(name,email),comments(*)" → ["id", "title", "author(name,email)", "comments(*)"]
891
+ */
892
+ function splitSelectParam(param: string): string[] {
893
+ const parts: string[] = [];
894
+ let current = '';
895
+ let depth = 0;
896
+
897
+ for (const char of param) {
898
+ if (char === '(') {
899
+ depth++;
900
+ current += char;
901
+ } else if (char === ')') {
902
+ depth--;
903
+ current += char;
904
+ } else if (char === ',' && depth === 0) {
905
+ parts.push(current.trim());
906
+ current = '';
907
+ } else {
908
+ current += char;
909
+ }
910
+ }
911
+
912
+ if (current.trim()) {
913
+ parts.push(current.trim());
914
+ }
915
+
916
+ return parts;
917
+ }
918
+
919
+ /**
920
+ * Resolve relation embeds by fetching related rows and nesting them.
921
+ */
922
+ async function resolveEmbeds(
923
+ db: DrizzleDb,
924
+ schema: Schema,
925
+ tableName: string,
926
+ rows: Record<string, unknown>[],
927
+ embeds: EmbedRequest[],
928
+ options: HandlerOptions,
929
+ ): Promise<Record<string, unknown>[]> {
930
+ const tableRelations = options.relations?.[tableName];
931
+ if (!tableRelations) return rows;
932
+
933
+ // Clone rows to avoid mutation
934
+ const result = rows.map(r => ({ ...r }));
935
+
936
+ for (const embed of embeds) {
937
+ const relationConfig = tableRelations[embed.name];
938
+ if (!relationConfig) continue;
939
+
940
+ const targetTable = schema[relationConfig.table];
941
+ if (!targetTable) continue;
942
+
943
+ const targetColumn = getColumn(targetTable, relationConfig.to);
944
+ if (!targetColumn) continue;
945
+
946
+ // Build soft-delete filter for the target table
947
+ const targetSdCol = isSoftDeleteTable(relationConfig.table, targetTable, options)
948
+ ? getSoftDeleteColumn(targetTable, options)
949
+ : null;
950
+
951
+ if (relationConfig.many) {
952
+ // One-to-many: source.from is the PK, target.to is the FK
953
+ // Collect all source PKs
954
+ const sourceValues = result
955
+ .map(r => r[relationConfig.from])
956
+ .filter(v => v != null);
957
+
958
+ if (sourceValues.length === 0) {
959
+ // No values to match — set empty arrays
960
+ for (const row of result) {
961
+ row[embed.name] = [];
962
+ }
963
+ continue;
964
+ }
965
+
966
+ // Fetch all related rows in one query
967
+ const embedConditions: any[] = [inArray(targetColumn, sourceValues as string[])];
968
+ if (targetSdCol) embedConditions.push(isNull(targetSdCol));
969
+
970
+ const relatedRows: Record<string, unknown>[] = await db
971
+ .select()
972
+ .from(targetTable)
973
+ .where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
974
+
975
+ // Group by FK value and assign to each source row
976
+ for (const row of result) {
977
+ const sourceVal = row[relationConfig.from];
978
+ const matched = relatedRows.filter(r => r[relationConfig.to] === sourceVal);
979
+ row[embed.name] = embed.columns
980
+ ? matched.map(r => pickColumns(r, embed.columns!))
981
+ : matched;
982
+ }
983
+ } else {
984
+ // Many-to-one: source.from is the FK, target.to is the PK
985
+ // Collect all FK values
986
+ const fkValues = result
987
+ .map(r => r[relationConfig.from])
988
+ .filter(v => v != null);
989
+
990
+ if (fkValues.length === 0) {
991
+ // All FKs are null — set null
992
+ for (const row of result) {
993
+ row[embed.name] = null;
994
+ }
995
+ continue;
996
+ }
997
+
998
+ // Fetch all referenced rows in one query (batch, no N+1)
999
+ const embedConditions: any[] = [inArray(targetColumn, fkValues as string[])];
1000
+ if (targetSdCol) embedConditions.push(isNull(targetSdCol));
1001
+
1002
+ const relatedRows: Record<string, unknown>[] = await db
1003
+ .select()
1004
+ .from(targetTable)
1005
+ .where(embedConditions.length === 1 ? embedConditions[0] : and(...embedConditions));
1006
+
1007
+ // Index by target key for O(1) lookup
1008
+ const index = new Map<unknown, Record<string, unknown>>();
1009
+ for (const r of relatedRows) {
1010
+ index.set(r[relationConfig.to], r);
1011
+ }
1012
+
1013
+ // Assign to each source row
1014
+ for (const row of result) {
1015
+ const fkVal = row[relationConfig.from];
1016
+ if (fkVal == null) {
1017
+ row[embed.name] = null;
1018
+ } else {
1019
+ const related = index.get(fkVal) || null;
1020
+ row[embed.name] = related && embed.columns ? pickColumns(related, embed.columns) : related;
1021
+ }
1022
+ }
1023
+ }
1024
+ }
1025
+
1026
+ return result;
1027
+ }
1028
+
1029
+ function pickColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
1030
+ const result: Record<string, unknown> = {};
1031
+ for (const col of columns) {
1032
+ if (col in row) {
1033
+ result[col] = row[col];
1034
+ }
1035
+ }
1036
+ return result;
1037
+ }
1038
+
1039
+ async function handlePost(
1040
+ db: DrizzleDb,
1041
+ table: DrizzleTable,
1042
+ tableName: string,
1043
+ request: Request,
1044
+ options?: HandlerOptions,
1045
+ user: Record<string, unknown> | null = null,
1046
+ client: Record<string, unknown> | null = null,
1047
+ ): Promise<Response> {
1048
+ let body: Record<string, unknown>;
1049
+ try {
1050
+ body = await request.json();
1051
+ } catch {
1052
+ return json({ message: 'Invalid JSON body' }, 400);
1053
+ }
1054
+
1055
+ // Map body keys to Drizzle property names when naming convention is active
1056
+ if (options?.naming === 'snake_case') {
1057
+ body = transformBodyKeys(body, table);
1058
+ }
1059
+
1060
+ const hook = options?.hooks?.[tableName];
1061
+
1062
+ if (hook?.beforeCreate) {
1063
+ try {
1064
+ body = await hook.beforeCreate(body, user, client);
1065
+ } catch {
1066
+ return json({ message: 'Hook error' }, 500);
1067
+ }
1068
+ }
1069
+
1070
+ const rows = await db.insert(table).values(body).returning();
1071
+
1072
+ if (hook?.afterCreate && rows.length > 0) {
1073
+ try { await hook.afterCreate(rows[0], user, client); } catch {}
1074
+ }
1075
+
1076
+ return json(transformResponse(rows[0], options), 201);
1077
+ }
1078
+
1079
+ async function handlePatch(
1080
+ db: DrizzleDb,
1081
+ table: DrizzleTable,
1082
+ tableName: string,
1083
+ request: Request,
1084
+ params: URLSearchParams,
1085
+ options?: HandlerOptions,
1086
+ user: Record<string, unknown> | null = null,
1087
+ client: Record<string, unknown> | null = null,
1088
+ ): Promise<Response> {
1089
+ let body: Record<string, unknown>;
1090
+ try {
1091
+ body = await request.json();
1092
+ } catch {
1093
+ return json({ message: 'Invalid JSON body' }, 400);
1094
+ }
1095
+
1096
+ // Map body keys to Drizzle property names when naming convention is active
1097
+ if (options?.naming === 'snake_case') {
1098
+ body = transformBodyKeys(body, table);
1099
+ }
1100
+
1101
+ const hook = options?.hooks?.[tableName];
1102
+
1103
+ if (hook?.beforeUpdate) {
1104
+ try {
1105
+ body = await hook.beforeUpdate(body, user, client);
1106
+ } catch {
1107
+ return json({ message: 'Hook error' }, 500);
1108
+ }
1109
+ }
1110
+
1111
+ const conditions = buildAllConditions(table, params);
1112
+
1113
+ // Soft-delete: exclude deleted records from updates
1114
+ if (isSoftDeleteTable(tableName, table, options)) {
1115
+ const sdCol = getSoftDeleteColumn(table, options);
1116
+ if (sdCol) conditions.push(isNull(sdCol));
1117
+ }
1118
+
1119
+ let query: any = db.update(table).set(body);
1120
+
1121
+ if (conditions.length === 1) {
1122
+ query = query.where(conditions[0]);
1123
+ } else if (conditions.length > 1) {
1124
+ query = query.where(and(...conditions));
1125
+ }
1126
+
1127
+ const rows = await query.returning();
1128
+
1129
+ if (hook?.afterUpdate && rows.length > 0) {
1130
+ try { await hook.afterUpdate(rows, user, client); } catch {}
1131
+ }
1132
+
1133
+ return json(transformResponse(rows, options), 200);
1134
+ }
1135
+
1136
+ async function handleDelete(
1137
+ db: DrizzleDb,
1138
+ table: DrizzleTable,
1139
+ tableName: string,
1140
+ params: URLSearchParams,
1141
+ options?: HandlerOptions,
1142
+ user: Record<string, unknown> | null = null,
1143
+ client: Record<string, unknown> | null = null,
1144
+ ): Promise<Response> {
1145
+ const filters = parseFilters(params);
1146
+ const hasLogicalGroup = params.has('or') || params.has('and');
1147
+
1148
+ // Safety: require at least one filter to prevent accidental mass deletion
1149
+ if (filters.length === 0 && !hasLogicalGroup) {
1150
+ return json({ message: 'DELETE requires at least one filter to prevent accidental mass deletion' }, 400);
1151
+ }
1152
+
1153
+ const hook = options?.hooks?.[tableName];
1154
+
1155
+ if (hook?.beforeDelete) {
1156
+ try {
1157
+ await hook.beforeDelete(user, client);
1158
+ } catch {
1159
+ return json({ message: 'Hook error' }, 500);
1160
+ }
1161
+ }
1162
+
1163
+ const conditions = buildAllConditions(table, params);
1164
+
1165
+ // Soft-delete: convert DELETE to UPDATE SET deleted_at = NOW()
1166
+ if (isSoftDeleteTable(tableName, table, options)) {
1167
+ const sdCol = getSoftDeleteColumn(table, options);
1168
+ if (sdCol) {
1169
+ // Only soft-delete records that aren't already deleted
1170
+ conditions.push(isNull(sdCol));
1171
+ const colName = options?.softDelete?.column || 'deletedAt';
1172
+ let query: any = db.update(table).set({ [colName]: new Date() });
1173
+
1174
+ if (conditions.length === 1) {
1175
+ query = query.where(conditions[0]);
1176
+ } else {
1177
+ query = query.where(and(...conditions));
1178
+ }
1179
+
1180
+ const rows = await query.returning();
1181
+
1182
+ if (hook?.afterDelete && rows.length > 0) {
1183
+ try { await hook.afterDelete(rows, user, client); } catch {}
1184
+ }
1185
+
1186
+ return json(transformResponse(rows, options), 200);
1187
+ }
1188
+ }
1189
+
1190
+ // Hard delete (no soft-delete configured for this table)
1191
+ let query: any = db.delete(table);
1192
+
1193
+ if (conditions.length === 1) {
1194
+ query = query.where(conditions[0]);
1195
+ } else {
1196
+ query = query.where(and(...conditions));
1197
+ }
1198
+
1199
+ const rows = await query.returning();
1200
+
1201
+ if (hook?.afterDelete && rows.length > 0) {
1202
+ try { await hook.afterDelete(rows, user, client); } catch {}
1203
+ }
1204
+
1205
+ return json(transformResponse(rows, options), 200);
1206
+ }
1207
+
1208
+ interface JsonPath {
1209
+ baseColumn: string;
1210
+ steps: Array<{ key: string; asText: boolean }>;
1211
+ }
1212
+
1213
+ interface Filter {
1214
+ column: string;
1215
+ operator: string;
1216
+ value: string;
1217
+ negated?: boolean;
1218
+ jsonPath?: JsonPath;
1219
+ }
1220
+
1221
+ function getColumn(table: DrizzleTable, columnName: string): DrizzleColumn | null {
1222
+ // Drizzle tables store columns directly as properties
1223
+ if (table[columnName]) return table[columnName];
1224
+
1225
+ // Try camelCase → snake_case lookup
1226
+ const snakeCase = columnName.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
1227
+ if (table[snakeCase]) return table[snakeCase];
1228
+
1229
+ // Try snake_case → camelCase lookup (for Rails-origin databases)
1230
+ if (columnName.includes('_')) {
1231
+ const camelCase = toCamelCase(columnName);
1232
+ if (table[camelCase]) return table[camelCase];
1233
+ }
1234
+
1235
+ return null;
1236
+ }
1237
+
1238
+ /**
1239
+ * Parse a column name that may contain JSON path operators (-> and ->>).
1240
+ * Returns null if the column has no JSON path operators.
1241
+ *
1242
+ * Examples:
1243
+ * "metadata->>theme" → { baseColumn: "metadata", steps: [{ key: "theme", asText: true }] }
1244
+ * "data->settings->>mode" → { baseColumn: "data", steps: [{ key: "settings", asText: false }, { key: "mode", asText: true }] }
1245
+ * "name" → null
1246
+ */
1247
+ function parseJsonPath(column: string): JsonPath | null {
1248
+ if (!column.includes('->')) return null;
1249
+
1250
+ const steps: Array<{ key: string; asText: boolean }> = [];
1251
+ let remaining = column;
1252
+
1253
+ // Tokenize by splitting on arrow operators (->> and ->)
1254
+ const parts: string[] = [];
1255
+ const operators: boolean[] = []; // true = asText (->>), false = JSON (->)
1256
+
1257
+ while (remaining.length > 0) {
1258
+ const textIdx = remaining.indexOf('->>');
1259
+ const jsonIdx = remaining.indexOf('->');
1260
+
1261
+ if (jsonIdx === -1) {
1262
+ // No more arrows
1263
+ parts.push(remaining);
1264
+ break;
1265
+ }
1266
+
1267
+ if (textIdx === jsonIdx) {
1268
+ // ->> found at same position as -> (because ->> starts with ->)
1269
+ parts.push(remaining.substring(0, textIdx));
1270
+ operators.push(true);
1271
+ remaining = remaining.substring(textIdx + 3);
1272
+ } else {
1273
+ // -> found (not ->>)
1274
+ parts.push(remaining.substring(0, jsonIdx));
1275
+ operators.push(false);
1276
+ remaining = remaining.substring(jsonIdx + 2);
1277
+ }
1278
+ }
1279
+
1280
+ if (parts.length < 2) return null;
1281
+
1282
+ const baseColumn = parts[0];
1283
+ for (let i = 1; i < parts.length; i++) {
1284
+ steps.push({ key: parts[i], asText: operators[i - 1] });
1285
+ }
1286
+
1287
+ return { baseColumn, steps };
1288
+ }
1289
+
1290
+ /**
1291
+ * Build a SQL condition for a JSON path filter.
1292
+ * Generates expressions like: "metadata"->>'theme' = 'dark'
1293
+ */
1294
+ function buildJsonCondition(table: DrizzleTable, filter: Filter): any {
1295
+ const jp = filter.jsonPath!;
1296
+ const baseCol = getColumn(table, jp.baseColumn);
1297
+ if (!baseCol) return null;
1298
+
1299
+ // Build the JSON path expression
1300
+ let expr: any = sql`${baseCol}`;
1301
+ for (const step of jp.steps) {
1302
+ const arrow = step.asText ? sql.raw(`->>'${step.key}'`) : sql.raw(`->'${step.key}'`);
1303
+ expr = sql`${expr}${arrow}`;
1304
+ }
1305
+
1306
+ // Apply operator
1307
+ let condition: any = null;
1308
+
1309
+ switch (filter.operator) {
1310
+ case 'eq':
1311
+ condition = sql`${expr} = ${filter.value}`;
1312
+ break;
1313
+ case 'neq':
1314
+ condition = sql`${expr} != ${filter.value}`;
1315
+ break;
1316
+ case 'gt':
1317
+ condition = sql`(${expr})::numeric > ${filter.value}::numeric`;
1318
+ break;
1319
+ case 'gte':
1320
+ condition = sql`(${expr})::numeric >= ${filter.value}::numeric`;
1321
+ break;
1322
+ case 'lt':
1323
+ condition = sql`(${expr})::numeric < ${filter.value}::numeric`;
1324
+ break;
1325
+ case 'lte':
1326
+ condition = sql`(${expr})::numeric <= ${filter.value}::numeric`;
1327
+ break;
1328
+ case 'like':
1329
+ condition = sql`${expr} LIKE ${filter.value.replace(/\*/g, '%')}`;
1330
+ break;
1331
+ case 'ilike':
1332
+ condition = sql`${expr} ILIKE ${filter.value.replace(/\*/g, '%')}`;
1333
+ break;
1334
+ case 'is':
1335
+ if (filter.value === 'null') condition = sql`${expr} IS NULL`;
1336
+ else if (filter.value === 'not.null') condition = sql`${expr} IS NOT NULL`;
1337
+ break;
1338
+ case 'in': {
1339
+ const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
1340
+ const placeholders = values.map(v => sql`${v}`);
1341
+ condition = sql`${expr} IN (${sql.join(placeholders, sql`, `)})`;
1342
+ break;
1343
+ }
1344
+ default:
1345
+ return null;
1346
+ }
1347
+
1348
+ if (condition && filter.negated) {
1349
+ if (filter.operator === 'is' && filter.value === 'null') {
1350
+ return sql`${expr} IS NOT NULL`;
1351
+ }
1352
+ return sql`NOT (${condition})`;
1353
+ }
1354
+
1355
+ return condition;
1356
+ }
1357
+
1358
+ function buildCondition(table: DrizzleTable, filter: Filter): any {
1359
+ if (filter.jsonPath) {
1360
+ return buildJsonCondition(table, filter);
1361
+ }
1362
+
1363
+ const column = getColumn(table, filter.column);
1364
+ if (!column) return null;
1365
+
1366
+ let condition: any = null;
1367
+
1368
+ switch (filter.operator) {
1369
+ case 'eq':
1370
+ condition = eq(column, filter.value);
1371
+ break;
1372
+ case 'neq':
1373
+ condition = ne(column, filter.value);
1374
+ break;
1375
+ case 'gt':
1376
+ condition = gt(column, filter.value);
1377
+ break;
1378
+ case 'gte':
1379
+ condition = gte(column, filter.value);
1380
+ break;
1381
+ case 'lt':
1382
+ condition = lt(column, filter.value);
1383
+ break;
1384
+ case 'lte':
1385
+ condition = lte(column, filter.value);
1386
+ break;
1387
+ case 'like':
1388
+ // PostgREST uses * for wildcard, SQL uses %
1389
+ condition = like(column, filter.value.replace(/\*/g, '%'));
1390
+ break;
1391
+ case 'ilike':
1392
+ condition = ilike(column, filter.value.replace(/\*/g, '%'));
1393
+ break;
1394
+ case 'is':
1395
+ if (filter.value === 'null') condition = isNull(column);
1396
+ else if (filter.value === 'not.null') condition = isNotNull(column);
1397
+ break;
1398
+ case 'in': {
1399
+ // PostgREST format: in.(val1,val2,val3)
1400
+ const values = filter.value.replace(/^\(/, '').replace(/\)$/, '').split(',');
1401
+ condition = inArray(column, values);
1402
+ break;
1403
+ }
1404
+ default:
1405
+ return null;
1406
+ }
1407
+
1408
+ // Apply negation
1409
+ if (condition && filter.negated) {
1410
+ // Special case: not.is.null → isNotNull (cleaner SQL)
1411
+ if (filter.operator === 'is' && filter.value === 'null') {
1412
+ return isNotNull(column);
1413
+ }
1414
+ return not(condition);
1415
+ }
1416
+
1417
+ return condition;
1418
+ }
1419
+
1420
+ function buildOrderBy(table: DrizzleTable, orderParam: string): any {
1421
+ // Format: column.direction (e.g., created_at.desc)
1422
+ const dotIndex = orderParam.lastIndexOf('.');
1423
+ if (dotIndex === -1) return null;
1424
+
1425
+ const columnName = orderParam.substring(0, dotIndex);
1426
+ const direction = orderParam.substring(dotIndex + 1);
1427
+
1428
+ const column = getColumn(table, columnName);
1429
+ if (!column) return null;
1430
+
1431
+ return direction === 'desc' ? desc(column) : asc(column);
1432
+ }
1433
+
1434
+ function parseFilters(params: URLSearchParams): Filter[] {
1435
+ const filters: Filter[] = [];
1436
+ const reserved = new Set(['limit', 'offset', 'order', 'select', 'deleted', 'or', 'and']);
1437
+
1438
+ for (const [key, value] of params.entries()) {
1439
+ if (reserved.has(key)) continue;
1440
+
1441
+ // Check for NOT modifier: not.eq.value → negated=true, operator=eq
1442
+ let negated = false;
1443
+ let remaining = value;
1444
+ if (remaining.startsWith('not.')) {
1445
+ negated = true;
1446
+ remaining = remaining.slice(4);
1447
+ }
1448
+
1449
+ // PostgREST format: column=operator.value
1450
+ const dotIndex = remaining.indexOf('.');
1451
+ if (dotIndex === -1) continue;
1452
+
1453
+ const operator = remaining.substring(0, dotIndex);
1454
+ const filterValue = remaining.substring(dotIndex + 1);
1455
+
1456
+ const jsonPath = parseJsonPath(key);
1457
+ filters.push({
1458
+ column: jsonPath ? jsonPath.baseColumn : key,
1459
+ operator,
1460
+ value: filterValue,
1461
+ negated,
1462
+ ...(jsonPath ? { jsonPath } : {}),
1463
+ });
1464
+ }
1465
+
1466
+ return filters;
1467
+ }
1468
+
1469
+ /**
1470
+ * Parse a logical group value into individual filters.
1471
+ * Input: "(status.eq.draft,status.eq.pending)" → [{column:'status', operator:'eq', value:'draft'}, ...]
1472
+ */
1473
+ function parseLogicalGroup(value: string): Filter[] {
1474
+ let content = value;
1475
+ if (content.startsWith('(') && content.endsWith(')')) {
1476
+ content = content.slice(1, -1);
1477
+ }
1478
+
1479
+ const filters: Filter[] = [];
1480
+ for (const part of content.split(',')) {
1481
+ const dotIndex = part.indexOf('.');
1482
+ if (dotIndex === -1) continue;
1483
+
1484
+ const column = part.substring(0, dotIndex);
1485
+ const rest = part.substring(dotIndex + 1);
1486
+
1487
+ let negated = false;
1488
+ let remaining = rest;
1489
+ if (remaining.startsWith('not.')) {
1490
+ negated = true;
1491
+ remaining = remaining.slice(4);
1492
+ }
1493
+
1494
+ const opDot = remaining.indexOf('.');
1495
+ if (opDot === -1) continue;
1496
+
1497
+ const operator = remaining.substring(0, opDot);
1498
+ const filterValue = remaining.substring(opDot + 1);
1499
+ const jsonPath = parseJsonPath(column);
1500
+ filters.push({
1501
+ column: jsonPath ? jsonPath.baseColumn : column,
1502
+ operator,
1503
+ value: filterValue,
1504
+ negated,
1505
+ ...(jsonPath ? { jsonPath } : {}),
1506
+ });
1507
+ }
1508
+ return filters;
1509
+ }
1510
+
1511
+ /**
1512
+ * Build all WHERE conditions from query params, including individual filters
1513
+ * and or()/and() logical groups.
1514
+ */
1515
+ function buildAllConditions(table: DrizzleTable, params: URLSearchParams): any[] {
1516
+ const filters = parseFilters(params);
1517
+ const conditions = filters.map(f => buildCondition(table, f)).filter(Boolean);
1518
+
1519
+ const orParam = params.get('or');
1520
+ if (orParam) {
1521
+ const orFilters = parseLogicalGroup(orParam);
1522
+ const orConditions = orFilters.map(f => buildCondition(table, f)).filter(Boolean);
1523
+ if (orConditions.length > 0) {
1524
+ conditions.push(or(...orConditions));
1525
+ }
1526
+ }
1527
+
1528
+ const andParam = params.get('and');
1529
+ if (andParam) {
1530
+ const andFilters = parseLogicalGroup(andParam);
1531
+ const andConditions = andFilters.map(f => buildCondition(table, f)).filter(Boolean);
1532
+ if (andConditions.length > 0) {
1533
+ conditions.push(and(...andConditions));
1534
+ }
1535
+ }
1536
+
1537
+ return conditions;
1538
+ }
1539
+
1540
+ function json(data: unknown, status: number): Response {
1541
+ return new Response(JSON.stringify(data), {
1542
+ status,
1543
+ headers: { 'Content-Type': 'application/json' },
1544
+ });
1545
+ }