@everystack/server 0.2.10 → 0.2.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@everystack/server",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
5
5
  "license": "AGPL-3.0-only",
6
6
  "publishConfig": {
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Console auth helpers — login, asUser, logout.
3
+ *
4
+ * These are injected into the console eval scope. They query the DB directly
5
+ * (no HTTP round-trip) since IAM is the trust boundary for console access.
6
+ *
7
+ * Auth state flows through a mutable `AuthSignal` object:
8
+ * - Helpers write to it during eval
9
+ * - Console action reads it after eval and includes `_auth` in the response
10
+ * - CLI picks up `_auth` and updates its session state
11
+ */
12
+
13
+ import { eq } from 'drizzle-orm';
14
+
15
+ // ─── Types ──────────────────────────────────────────────────────────────────
16
+
17
+ export interface AuthSignal {
18
+ /** Set by login/asUser, cleared by logout */
19
+ user: Record<string, unknown> | null;
20
+ /** Whether the signal was touched during this eval */
21
+ changed: boolean;
22
+ }
23
+
24
+ export interface AuthHelperOptions {
25
+ /** Drizzle database connection */
26
+ db: any;
27
+ /** App schema — must contain a users-like table */
28
+ schema: Record<string, any>;
29
+ /** Table name in schema that holds users (default: 'users') */
30
+ usersTable?: string;
31
+ /** Column that stores the email (default: 'email') */
32
+ emailColumn?: string;
33
+ /** Column that stores the hashed password (default: 'encryptedPassword') */
34
+ passwordColumn?: string;
35
+ /** Columns to include in the auth claims (default: ['id', 'email', 'role']) */
36
+ claimColumns?: string[];
37
+ /** The mutable signal that helpers write auth state to */
38
+ signal: AuthSignal;
39
+ }
40
+
41
+ // ─── Factory ────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Create login/asUser/logout functions for the console eval scope.
45
+ *
46
+ * Returns an object that can be spread into the eval context:
47
+ * ```
48
+ * const { login, asUser, logout } = createAuthHelpers({ db, schema, signal });
49
+ * ```
50
+ */
51
+ export function createAuthHelpers(options: AuthHelperOptions): {
52
+ login: (email: string, password: string) => Promise<Record<string, unknown>>;
53
+ asUser: (id: string) => Promise<Record<string, unknown>>;
54
+ logout: () => { logged_out: true };
55
+ } {
56
+ const {
57
+ db,
58
+ schema,
59
+ usersTable = 'users',
60
+ emailColumn = 'email',
61
+ passwordColumn = 'encryptedPassword',
62
+ claimColumns = ['id', 'email', 'role'],
63
+ signal,
64
+ } = options;
65
+
66
+ const table = schema[usersTable];
67
+ if (!table) {
68
+ // Return stubs that explain the problem
69
+ const err = async () => { throw new Error(`Auth helpers require a "${usersTable}" table in schema`); };
70
+ return { login: err as any, asUser: err as any, logout: () => ({ logged_out: true }) };
71
+ }
72
+
73
+ function extractClaims(row: Record<string, unknown>): Record<string, unknown> {
74
+ const claims: Record<string, unknown> = {};
75
+ for (const col of claimColumns) {
76
+ if (row[col] !== undefined) {
77
+ // Map 'id' to 'sub' for JWT convention
78
+ const key = col === 'id' ? 'sub' : col;
79
+ claims[key] = row[col];
80
+ }
81
+ }
82
+ return claims;
83
+ }
84
+
85
+ async function login(email: string, password: string): Promise<Record<string, unknown>> {
86
+ if (!email || !password) throw new Error('Usage: login(email, password)');
87
+
88
+ const emailCol = table[emailColumn];
89
+ if (!emailCol) throw new Error(`Column "${emailColumn}" not found on ${usersTable} table`);
90
+
91
+ const rows = await db.select().from(table).where(eq(emailCol, email)).limit(1);
92
+ const user = rows[0];
93
+ if (!user) throw new Error(`No user found with ${emailColumn}: ${email}`);
94
+
95
+ const storedHash = user[passwordColumn];
96
+ if (!storedHash) throw new Error(`No password set for this user (column: ${passwordColumn})`);
97
+
98
+ // Dynamic import to avoid hard dependency on @everystack/auth
99
+ let verifyPassword: (password: string, stored: string) => Promise<boolean>;
100
+ try {
101
+ const auth = await import('@everystack/auth');
102
+ verifyPassword = auth.verifyPassword;
103
+ } catch {
104
+ throw new Error('login() requires @everystack/auth to be installed');
105
+ }
106
+
107
+ const valid = await verifyPassword(password, storedHash);
108
+ if (!valid) throw new Error('Invalid password');
109
+
110
+ const claims = extractClaims(user);
111
+ signal.user = claims;
112
+ signal.changed = true;
113
+ return { logged_in: true, ...claims };
114
+ }
115
+
116
+ async function asUser(id: string): Promise<Record<string, unknown>> {
117
+ if (!id) throw new Error('Usage: asUser(id)');
118
+
119
+ // Find the primary key column — try 'id' first
120
+ const idCol = table.id;
121
+ if (!idCol) throw new Error(`Column "id" not found on ${usersTable} table`);
122
+
123
+ const rows = await db.select().from(table).where(eq(idCol, id)).limit(1);
124
+ const user = rows[0];
125
+ if (!user) throw new Error(`No user found with id: ${id}`);
126
+
127
+ const claims = extractClaims(user);
128
+ signal.user = claims;
129
+ signal.changed = true;
130
+ return { logged_in: true, ...claims };
131
+ }
132
+
133
+ function logout(): { logged_out: true } {
134
+ signal.user = null;
135
+ signal.changed = true;
136
+ return { logged_out: true };
137
+ }
138
+
139
+ return { login, asUser, logout };
140
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Console schema introspection.
3
+ *
4
+ * Returns table, column, and RPC metadata for dot commands (.tables,
5
+ * .columns, .rpc) and tab completion. Pure function — no DB calls.
6
+ */
7
+
8
+ import {
9
+ is, Table,
10
+ getTableColumns,
11
+ getTableName,
12
+ } from 'drizzle-orm';
13
+ import { getTableConfig } from 'drizzle-orm/pg-core';
14
+
15
+ // ─── Types ───────────────────────────────────────────────────────────────────
16
+
17
+ export interface ColumnMeta {
18
+ /** Database column name */
19
+ name: string;
20
+ /** JS property name on the table object */
21
+ property: string;
22
+ /** Simplified type: uuid, text, timestamp, bigint, etc. */
23
+ type: string;
24
+ notNull: boolean;
25
+ hasDefault: boolean;
26
+ primaryKey: boolean;
27
+ }
28
+
29
+ export interface TableMeta {
30
+ /** PascalCase model name (e.g. "Users") */
31
+ name: string;
32
+ /** Database table name (e.g. "users") */
33
+ dbName: string;
34
+ columns: ColumnMeta[];
35
+ }
36
+
37
+ export interface ConsoleMeta {
38
+ tables: TableMeta[];
39
+ rpcNames: string[];
40
+ scope: string[];
41
+ }
42
+
43
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
44
+
45
+ function toPascalCase(str: string): string {
46
+ return str
47
+ .replace(/(^|[_-])([a-z])/g, (_, __, c) => c.toUpperCase())
48
+ .replace(/^[a-z]/, (c) => c.toUpperCase());
49
+ }
50
+
51
+ /**
52
+ * Simplify Drizzle column type strings.
53
+ * "PgUUID" → "uuid", "PgText" → "text", "PgTimestamp" → "timestamp"
54
+ */
55
+ function simplifyType(columnType: string): string {
56
+ return columnType.replace(/^Pg/, '').toLowerCase();
57
+ }
58
+
59
+ /** Detect which columns form the primary key (composite PK support). */
60
+ function detectPkColumns(table: any): Set<string> {
61
+ const pkProps = new Set<string>();
62
+ const columns = getTableColumns(table);
63
+
64
+ // Single-column PKs
65
+ for (const [prop, col] of Object.entries(columns)) {
66
+ if ((col as any).primary) pkProps.add(prop);
67
+ }
68
+ if (pkProps.size > 0) return pkProps;
69
+
70
+ // Composite PKs via table config
71
+ try {
72
+ const config = getTableConfig(table as any);
73
+ if (config.primaryKeys.length > 0) {
74
+ const pk = config.primaryKeys[0];
75
+ const dbNames = new Set(pk.columns.map((c: any) => c.name));
76
+ for (const [prop, col] of Object.entries(columns)) {
77
+ if (dbNames.has((col as any).name)) pkProps.add(prop);
78
+ }
79
+ }
80
+ } catch {
81
+ // Not a pg table — skip composite PK detection
82
+ }
83
+
84
+ return pkProps;
85
+ }
86
+
87
+ // ─── Main ────────────────────────────────────────────────────────────────────
88
+
89
+ /**
90
+ * Build console introspection metadata from a Drizzle schema and plugin context.
91
+ * No database calls — purely reads schema metadata.
92
+ */
93
+ export function getConsoleMeta(
94
+ schema: Record<string, any>,
95
+ ctx?: { rpc?: Record<string, unknown>; [key: string]: unknown },
96
+ ): ConsoleMeta {
97
+ const tables: TableMeta[] = [];
98
+
99
+ for (const [key, value] of Object.entries(schema)) {
100
+ if (!is(value, Table)) continue;
101
+
102
+ const table = value;
103
+ const pkProps = detectPkColumns(table);
104
+ const columns: ColumnMeta[] = [];
105
+
106
+ for (const [prop, col] of Object.entries(getTableColumns(table))) {
107
+ columns.push({
108
+ name: (col as any).name,
109
+ property: prop,
110
+ type: simplifyType((col as any).columnType ?? 'unknown'),
111
+ notNull: !!(col as any).notNull,
112
+ hasDefault: !!(col as any).hasDefault,
113
+ primaryKey: pkProps.has(prop),
114
+ });
115
+ }
116
+
117
+ tables.push({
118
+ name: toPascalCase(key),
119
+ dbName: getTableName(table),
120
+ columns,
121
+ });
122
+ }
123
+
124
+ // Sort tables alphabetically by model name
125
+ tables.sort((a, b) => a.name.localeCompare(b.name));
126
+
127
+ const rpcNames = ctx?.rpc ? Object.keys(ctx.rpc).sort() : [];
128
+
129
+ // Build scope list: drizzle ops + model names + plugin context
130
+ const drizzleOps = ['eq', 'and', 'or', 'gt', 'lt', 'gte', 'lte', 'ne', 'count', 'sum', 'avg', 'sql', 'desc', 'asc'];
131
+ const modelNames = tables.map(t => t.name);
132
+ const base = ['db', 'schema', 'ctx', ...drizzleOps, ...modelNames];
133
+ if (ctx?.notifications) base.push('notifications');
134
+ if (ctx?.rpc) base.push('rpc');
135
+ const scope = base;
136
+
137
+ return { tables, rpcNames, scope };
138
+ }
@@ -0,0 +1,288 @@
1
+ /**
2
+ * ActiveRecord-style model layer for Drizzle schemas.
3
+ *
4
+ * Generates chainable, thenable query builders from schema tables:
5
+ *
6
+ * await Users.find(123)
7
+ * await Posts.where({ state: 'active' }).orderBy('createdAt', 'desc').limit(10)
8
+ * await Users.where({ role: 'admin' }).count()
9
+ * await Posts.first()
10
+ *
11
+ * Models are auto-generated from the schema and exposed in the console eval scope.
12
+ * PascalCase naming: schema key `users` → model `Users`.
13
+ */
14
+
15
+ import {
16
+ is, Table,
17
+ getTableColumns,
18
+ eq, and, isNull, desc as drizzleDesc, asc as drizzleAsc, count as drizzleCount,
19
+ } from 'drizzle-orm';
20
+ import { getTableConfig } from 'drizzle-orm/pg-core';
21
+
22
+ // ─── Types ───────────────────────────────────────────────────────────────────
23
+
24
+ interface PrimaryKeyInfo {
25
+ /** Single column PK, or null for composite */
26
+ column: any | null;
27
+ /** Composite PK columns (property names), empty for single */
28
+ compositeColumns: string[];
29
+ }
30
+
31
+ // ─── Query Builder ───────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Chainable, thenable query builder. Implements `.then()` so `await` resolves
35
+ * the query directly — no explicit `.execute()` needed.
36
+ */
37
+ export class ModelQuery {
38
+ private _db: any;
39
+ private _table: any;
40
+ private _conditions: any[];
41
+ private _orderBys: any[];
42
+ private _limitVal: number | null;
43
+ private _offsetVal: number | null;
44
+
45
+ constructor(db: any, table: any) {
46
+ this._db = db;
47
+ this._table = table;
48
+ this._conditions = [];
49
+ this._orderBys = [];
50
+ this._limitVal = null;
51
+ this._offsetVal = null;
52
+ }
53
+
54
+ private _clone(): ModelQuery {
55
+ const q = new ModelQuery(this._db, this._table);
56
+ q._conditions = [...this._conditions];
57
+ q._orderBys = [...this._orderBys];
58
+ q._limitVal = this._limitVal;
59
+ q._offsetVal = this._offsetVal;
60
+ return q;
61
+ }
62
+
63
+ /** Filter by column equality. `{ col: null }` maps to `IS NULL`. */
64
+ where(conditions: Record<string, unknown>): ModelQuery {
65
+ const q = this._clone();
66
+ for (const [col, val] of Object.entries(conditions)) {
67
+ const column = this._table[col];
68
+ if (!column) throw new Error(`Unknown column: ${col}`);
69
+ if (val === null) {
70
+ q._conditions.push(isNull(column));
71
+ } else {
72
+ q._conditions.push(eq(column, val));
73
+ }
74
+ }
75
+ return q;
76
+ }
77
+
78
+ /** Order by column. Direction defaults to `'asc'`. */
79
+ orderBy(column: string, direction: 'asc' | 'desc' = 'asc'): ModelQuery {
80
+ const q = this._clone();
81
+ const col = this._table[column];
82
+ if (!col) throw new Error(`Unknown column: ${column}`);
83
+ q._orderBys.push(direction === 'desc' ? drizzleDesc(col) : drizzleAsc(col));
84
+ return q;
85
+ }
86
+
87
+ limit(n: number): ModelQuery {
88
+ const q = this._clone();
89
+ q._limitVal = n;
90
+ return q;
91
+ }
92
+
93
+ offset(n: number): ModelQuery {
94
+ const q = this._clone();
95
+ q._offsetVal = n;
96
+ return q;
97
+ }
98
+
99
+ private _build() {
100
+ let query = this._db.select().from(this._table);
101
+ if (this._conditions.length) {
102
+ query = query.where(
103
+ this._conditions.length === 1
104
+ ? this._conditions[0]
105
+ : and(...this._conditions),
106
+ );
107
+ }
108
+ if (this._orderBys.length) query = query.orderBy(...this._orderBys);
109
+ if (this._limitVal !== null) query = query.limit(this._limitVal);
110
+ if (this._offsetVal !== null) query = query.offset(this._offsetVal);
111
+ return query;
112
+ }
113
+
114
+ /** Thenable — makes `await ModelQuery` resolve the query. */
115
+ then<T>(
116
+ resolve: (value: any[]) => T,
117
+ reject?: (reason: any) => T,
118
+ ): Promise<T> {
119
+ return this._build().then(resolve, reject);
120
+ }
121
+
122
+ /** Return first row or undefined. */
123
+ async first(): Promise<any | undefined> {
124
+ const rows = await this.limit(1);
125
+ return rows[0];
126
+ }
127
+
128
+ /** Return last row (by PK desc) or undefined. Falls back to query order reversed. */
129
+ async last(): Promise<any | undefined> {
130
+ // If no explicit orderBy, try to order by PK descending
131
+ if (this._orderBys.length === 0) {
132
+ const pk = detectPrimaryKey(this._table);
133
+ if (pk.column) {
134
+ return this.orderBy(findColumnPropertyName(this._table, pk.column), 'desc').limit(1).first();
135
+ }
136
+ }
137
+ // With explicit order, just take the last of the full result
138
+ const rows = await this;
139
+ return rows[rows.length - 1];
140
+ }
141
+
142
+ /** Count matching rows. */
143
+ async count(): Promise<number> {
144
+ let query = this._db.select({ count: drizzleCount() }).from(this._table);
145
+ if (this._conditions.length) {
146
+ query = query.where(
147
+ this._conditions.length === 1
148
+ ? this._conditions[0]
149
+ : and(...this._conditions),
150
+ );
151
+ }
152
+ const rows = await query;
153
+ return Number(rows[0]?.count ?? 0);
154
+ }
155
+
156
+ /** Alias for the builder itself (semantics: "all matching rows"). */
157
+ all(): ModelQuery {
158
+ return this._clone();
159
+ }
160
+ }
161
+
162
+ // ─── Model Interface ─────────────────────────────────────────────────────────
163
+
164
+ export interface Model {
165
+ /** Find by primary key. Accepts a value (single PK) or object (composite PK). */
166
+ find(id: unknown): Promise<any | undefined>;
167
+ /** Filter by column equality. Chainable. */
168
+ where(conditions: Record<string, unknown>): ModelQuery;
169
+ /** All rows (chainable). */
170
+ all(): ModelQuery;
171
+ /** First row. */
172
+ first(): Promise<any | undefined>;
173
+ /** Last row (by PK desc). */
174
+ last(): Promise<any | undefined>;
175
+ /** Count all rows. */
176
+ count(): Promise<number>;
177
+ /** Raw drizzle table reference (escape hatch). */
178
+ table: any;
179
+ }
180
+
181
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
182
+
183
+ function toPascalCase(str: string): string {
184
+ return str
185
+ .replace(/(^|[_-])([a-z])/g, (_, __, c) => c.toUpperCase())
186
+ .replace(/^[a-z]/, (c) => c.toUpperCase());
187
+ }
188
+
189
+ function detectPrimaryKey(table: any): PrimaryKeyInfo {
190
+ const columns = getTableColumns(table);
191
+
192
+ // Check single-column PKs first
193
+ for (const [, col] of Object.entries(columns)) {
194
+ if ((col as any).primary) {
195
+ return { column: col, compositeColumns: [] };
196
+ }
197
+ }
198
+
199
+ // Check composite PKs via table config
200
+ try {
201
+ const config = getTableConfig(table as any);
202
+ if (config.primaryKeys.length > 0) {
203
+ const pk = config.primaryKeys[0];
204
+ // Map DB column names back to schema property names
205
+ const dbNames = new Set(pk.columns.map((c: any) => c.name));
206
+ const propNames: string[] = [];
207
+ for (const [prop, col] of Object.entries(columns)) {
208
+ if (dbNames.has((col as any).name)) {
209
+ propNames.push(prop);
210
+ }
211
+ }
212
+ return { column: null, compositeColumns: propNames };
213
+ }
214
+ } catch {
215
+ // Not a pg table, skip composite PK detection
216
+ }
217
+
218
+ return { column: null, compositeColumns: [] };
219
+ }
220
+
221
+ /** Find the schema property name for a column object. */
222
+ function findColumnPropertyName(table: any, column: any): string {
223
+ const columns = getTableColumns(table);
224
+ for (const [prop, col] of Object.entries(columns)) {
225
+ if (col === column) return prop;
226
+ }
227
+ return 'id'; // fallback
228
+ }
229
+
230
+ // ─── Factory ─────────────────────────────────────────────────────────────────
231
+
232
+ /**
233
+ * Generate ActiveRecord-style models from a Drizzle schema.
234
+ *
235
+ * Returns a `Record<PascalCaseName, Model>` that can be spread into the
236
+ * console eval scope.
237
+ */
238
+ export function createModels(
239
+ db: any,
240
+ schema: Record<string, any>,
241
+ ): Record<string, Model> {
242
+ const models: Record<string, Model> = {};
243
+
244
+ for (const [key, value] of Object.entries(schema)) {
245
+ if (!is(value, Table)) continue;
246
+
247
+ const table = value;
248
+ const name = toPascalCase(key);
249
+ const pk = detectPrimaryKey(table);
250
+ const base = () => new ModelQuery(db, table);
251
+
252
+ models[name] = {
253
+ table,
254
+
255
+ find: async (id: unknown) => {
256
+ if (pk.column) {
257
+ // Single PK
258
+ const rows = await db.select().from(table).where(eq(pk.column, id)).limit(1);
259
+ return rows[0];
260
+ }
261
+ if (pk.compositeColumns.length > 0 && id && typeof id === 'object') {
262
+ // Composite PK — id is { userId: '...', postId: '...' }
263
+ const conditions = pk.compositeColumns.map((prop) => {
264
+ const col = (table as any)[prop];
265
+ const val = (id as Record<string, unknown>)[prop];
266
+ if (val === undefined) throw new Error(`Missing composite PK field: ${prop}`);
267
+ return eq(col, val);
268
+ });
269
+ const rows = await db
270
+ .select()
271
+ .from(table)
272
+ .where(conditions.length === 1 ? conditions[0] : and(...conditions))
273
+ .limit(1);
274
+ return rows[0];
275
+ }
276
+ throw new Error(`${name} has no detectable primary key`);
277
+ },
278
+
279
+ where: (conditions) => base().where(conditions),
280
+ all: () => base(),
281
+ first: () => base().first(),
282
+ last: () => base().last(),
283
+ count: () => base().count(),
284
+ };
285
+ }
286
+
287
+ return models;
288
+ }
package/src/plugin.ts CHANGED
@@ -355,6 +355,15 @@ export interface DbPluginOptions {
355
355
  allowConsole?: boolean;
356
356
  /** Read replica URL for db:query (default: process.env.READ_DATABASE_URL) */
357
357
  readDatabaseUrl?: string;
358
+ /** pgSettings callback for console auth context — applied via SET LOCAL when user is present */
359
+ pgSettings?: (user: Record<string, unknown> | null) => Record<string, string>;
360
+ /** Console auth helper config — column names for login/asUser (defaults to everystack conventions) */
361
+ consoleAuth?: {
362
+ usersTable?: string;
363
+ emailColumn?: string;
364
+ passwordColumn?: string;
365
+ claimColumns?: string[];
366
+ };
358
367
  }
359
368
 
360
369
  /**
@@ -459,23 +468,113 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
459
468
 
460
469
  // --- console ---
461
470
  if (options.allowConsole !== false) {
471
+ actions['console:meta'] = async (_payload, ctx) => {
472
+ const { getConsoleMeta } = await import('./console/meta');
473
+ return getConsoleMeta(ctx.schema, ctx);
474
+ };
475
+
462
476
  actions.console = async (payload, ctx) => {
463
- const { expression } = payload as { expression: string };
477
+ const { expression, user: payloadUser, sandbox } = payload as {
478
+ expression: string;
479
+ user?: Record<string, unknown>;
480
+ sandbox?: boolean;
481
+ };
464
482
  if (!expression) return { error: 'No expression provided' };
465
483
  try {
466
- const { eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc } =
467
- await import('drizzle-orm');
468
- const evalContext: Record<string, unknown> = {
469
- db: ctx.db, schema: ctx.schema,
470
- eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc,
471
- };
472
- const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
473
- const fn = new AsyncFunction(
474
- ...Object.keys(evalContext),
475
- `return (${expression})`,
476
- );
477
- const result = await fn(...Object.values(evalContext));
478
- return { result };
484
+ const drizzle = await import('drizzle-orm');
485
+ const { eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc } = drizzle;
486
+ const { createModels } = await import('./console/models');
487
+ const { createAuthHelpers } = await import('./console/auth-helpers');
488
+
489
+ // Auth signal — helpers write to this during eval, we read it after
490
+ const authSignal = { user: null as Record<string, unknown> | null, changed: false };
491
+ const authHelpers = createAuthHelpers({
492
+ db: ctx.db,
493
+ schema: ctx.schema,
494
+ signal: authSignal,
495
+ ...options.consoleAuth,
496
+ });
497
+
498
+ // Sentinel for sandbox rollback
499
+ const SANDBOX_ROLLBACK = Symbol('sandbox_rollback');
500
+
501
+ async function evalExpression(db: any) {
502
+ const models = createModels(db, ctx.schema);
503
+ const evalContext: Record<string, unknown> = {
504
+ ctx,
505
+ db, schema: ctx.schema,
506
+ eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc,
507
+ ...models,
508
+ ...authHelpers,
509
+ };
510
+ if (ctx.notifications) evalContext.notifications = ctx.notifications;
511
+ if (ctx.rpc) evalContext.rpc = ctx.rpc;
512
+ const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
513
+ const fn = new AsyncFunction(
514
+ ...Object.keys(evalContext),
515
+ `return (${expression})`,
516
+ );
517
+ return fn(...Object.values(evalContext));
518
+ }
519
+
520
+ let result: unknown;
521
+
522
+ if (payloadUser && options.pgSettings) {
523
+ const settings = options.pgSettings(payloadUser);
524
+
525
+ async function applySettings(tx: any) {
526
+ const role = settings['role'];
527
+ if (role) {
528
+ if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
529
+ throw new Error(`Invalid role name: ${role}`);
530
+ }
531
+ await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
532
+ }
533
+ for (const [key, value] of Object.entries(settings)) {
534
+ if (key !== 'role' && value !== undefined && value !== '') {
535
+ await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
536
+ }
537
+ }
538
+ }
539
+
540
+ if (sandbox) {
541
+ // pgSettings + sandbox: apply settings, eval, capture result, rollback
542
+ try {
543
+ await ctx.db.transaction(async (tx: any) => {
544
+ await applySettings(tx);
545
+ result = await evalExpression(tx);
546
+ throw SANDBOX_ROLLBACK;
547
+ });
548
+ } catch (err: unknown) {
549
+ if (err !== SANDBOX_ROLLBACK) throw err;
550
+ }
551
+ } else {
552
+ // pgSettings without sandbox: normal transaction
553
+ result = await ctx.db.transaction(async (tx: any) => {
554
+ await applySettings(tx);
555
+ return evalExpression(tx);
556
+ });
557
+ }
558
+ } else if (sandbox) {
559
+ // Sandbox without auth: wrap in transaction and rollback
560
+ try {
561
+ await ctx.db.transaction(async (tx: any) => {
562
+ result = await evalExpression(tx);
563
+ throw SANDBOX_ROLLBACK;
564
+ });
565
+ } catch (err: unknown) {
566
+ if (err !== SANDBOX_ROLLBACK) throw err;
567
+ }
568
+ } else {
569
+ result = await evalExpression(ctx.db);
570
+ }
571
+
572
+ // Build response with optional auth side-channel
573
+ const response: Record<string, unknown> = { result };
574
+ if (authSignal.changed) {
575
+ response._auth = { user: authSignal.user };
576
+ }
577
+ return response;
479
578
  } catch (err: any) {
480
579
  return { error: err.message || String(err) };
481
580
  }