@everystack/server 0.2.11 → 0.2.13
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/README.md +55 -0
- package/package.json +5 -1
- package/src/console/auth-helpers.ts +140 -0
- package/src/console/meta.ts +138 -0
- package/src/console/models.ts +288 -0
- package/src/media.ts +126 -0
- package/src/plugin.ts +126 -18
package/README.md
CHANGED
|
@@ -16,6 +16,7 @@ pnpm add @everystack/server
|
|
|
16
16
|
| `@everystack/server/db` | SST Resource-linked database connection |
|
|
17
17
|
| `@everystack/server/ssr` | Expo web SSR bundle serving |
|
|
18
18
|
| `@everystack/server/image` | On-demand image resizing via Sharp |
|
|
19
|
+
| `@everystack/server/media` | Fingerprinted media asset URL resolver |
|
|
19
20
|
| `@everystack/server/migrate` | Drizzle migration runner |
|
|
20
21
|
| `@everystack/server/worker` | SQS job queue consumer |
|
|
21
22
|
| `@everystack/server/stubs` | esbuild bundling helpers for client packages |
|
|
@@ -212,6 +213,60 @@ Validates URL query params into transform options:
|
|
|
212
213
|
| `q` | 1–100 | Quality |
|
|
213
214
|
| `dpr` | 1–3 | Device pixel ratio (multiplies w/h) |
|
|
214
215
|
|
|
216
|
+
## Media Assets (`@everystack/server/media`)
|
|
217
|
+
|
|
218
|
+
Resolves logical media asset paths to fingerprinted CDN URLs. During `everystack update`, media assets are uploaded with content-hash suffixes (e.g., `logo-a1b2c3d4.png`) and a `media-manifest.json` is written to the media bucket. This module loads that manifest and resolves URLs at runtime.
|
|
219
|
+
|
|
220
|
+
### `createMediaResolver(config)`
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import { createMediaResolver } from '@everystack/server/media';
|
|
224
|
+
|
|
225
|
+
const media = createMediaResolver({
|
|
226
|
+
bucket: Resource.Media.name, // S3 bucket with media-manifest.json
|
|
227
|
+
cdnUrl: process.env.CDN_URL, // CDN base URL (no trailing slash)
|
|
228
|
+
ttlMs: 60_000, // Manifest cache TTL (default: 1 minute)
|
|
229
|
+
});
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### `resolve(logicalPath)`
|
|
233
|
+
|
|
234
|
+
Async. Returns the fingerprinted CDN URL. Falls back to the logical path if the manifest is unavailable or the path isn't mapped.
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
const logoUrl = await media.resolve('email/logo.png');
|
|
238
|
+
// → "https://cdn.example.com/email/logo-a1b2c3d4.png"
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
### `resolveSync(logicalPath)`
|
|
242
|
+
|
|
243
|
+
Sync. Uses the cached manifest only. Returns `null` if no manifest has been loaded yet.
|
|
244
|
+
|
|
245
|
+
```typescript
|
|
246
|
+
const url = media.resolveSync('og/default.png');
|
|
247
|
+
// → "https://cdn.example.com/og/default-f6e5d4c3.png" or null
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
### `preload()`
|
|
251
|
+
|
|
252
|
+
Loads the manifest during Lambda init. Call this in `createLambdaHandler`'s `init` to amortize the S3 call across all requests.
|
|
253
|
+
|
|
254
|
+
```typescript
|
|
255
|
+
export const handler = createLambdaHandler({
|
|
256
|
+
init: async () => {
|
|
257
|
+
await media.preload();
|
|
258
|
+
return { api };
|
|
259
|
+
},
|
|
260
|
+
// ...
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Caching behavior:**
|
|
265
|
+
- Manifest cached in module scope across warm Lambda invocations
|
|
266
|
+
- After TTL expires, revalidates via `HeadObject` ETag comparison (one cheap HEAD call)
|
|
267
|
+
- Full re-fetch only when the manifest content changes
|
|
268
|
+
- S3 errors return stale cache gracefully
|
|
269
|
+
|
|
215
270
|
## Migrations (`@everystack/server/migrate`)
|
|
216
271
|
|
|
217
272
|
### `runMigrations(db, migrationsFolder)`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"publishConfig": {
|
|
@@ -51,6 +51,10 @@
|
|
|
51
51
|
"./kvs": {
|
|
52
52
|
"types": "./src/kvs.ts",
|
|
53
53
|
"default": "./src/kvs.ts"
|
|
54
|
+
},
|
|
55
|
+
"./media": {
|
|
56
|
+
"types": "./src/media.ts",
|
|
57
|
+
"default": "./src/media.ts"
|
|
54
58
|
}
|
|
55
59
|
},
|
|
56
60
|
"scripts": {
|
|
@@ -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/media.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/media — Fingerprinted media asset URL resolver.
|
|
3
|
+
*
|
|
4
|
+
* Loads the media manifest from S3 and resolves logical asset names
|
|
5
|
+
* to fingerprinted CDN URLs. Lazy-loaded, cached with TTL for
|
|
6
|
+
* revalidation on new deploys.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* const media = createMediaResolver({ bucket: Resource.Media.name, cdnUrl: process.env.CDN_URL });
|
|
10
|
+
* await media.preload();
|
|
11
|
+
* const url = await media.resolve('email/logo.png');
|
|
12
|
+
* // → "https://cdn.example.com/email/logo-a1b2c3d4.png"
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export interface MediaManifest {
|
|
16
|
+
version: number;
|
|
17
|
+
generatedAt: string;
|
|
18
|
+
files: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface MediaResolverConfig {
|
|
22
|
+
/** S3 bucket name for media assets. */
|
|
23
|
+
bucket: string;
|
|
24
|
+
/** AWS region (defaults to process.env.AWS_REGION). */
|
|
25
|
+
region?: string;
|
|
26
|
+
/** CDN base URL (e.g., 'https://cdn.example.com'). No trailing slash. */
|
|
27
|
+
cdnUrl?: string;
|
|
28
|
+
/** Manifest TTL in milliseconds. Default: 60_000 (1 minute). */
|
|
29
|
+
ttlMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface CachedManifest {
|
|
33
|
+
manifest: MediaManifest;
|
|
34
|
+
etag: string;
|
|
35
|
+
loadedAt: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function createMediaResolver(config: MediaResolverConfig): MediaResolver {
|
|
39
|
+
const {
|
|
40
|
+
bucket,
|
|
41
|
+
ttlMs = 60_000,
|
|
42
|
+
} = config;
|
|
43
|
+
const region = config.region || process.env.AWS_REGION;
|
|
44
|
+
const cdnUrl = (config.cdnUrl || '').replace(/\/$/, '');
|
|
45
|
+
|
|
46
|
+
let cached: CachedManifest | null = null;
|
|
47
|
+
|
|
48
|
+
async function loadManifest(): Promise<MediaManifest | null> {
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
|
|
51
|
+
if (cached && (now - cached.loadedAt) < ttlMs) {
|
|
52
|
+
return cached.manifest;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const { S3Client, GetObjectCommand, HeadObjectCommand } = await import('@aws-sdk/client-s3');
|
|
57
|
+
const client = new S3Client({ region });
|
|
58
|
+
|
|
59
|
+
// ETag-based revalidation: cheap HEAD instead of full GET
|
|
60
|
+
if (cached) {
|
|
61
|
+
const head = await client.send(new HeadObjectCommand({
|
|
62
|
+
Bucket: bucket,
|
|
63
|
+
Key: 'media-manifest.json',
|
|
64
|
+
}));
|
|
65
|
+
if (head.ETag === cached.etag) {
|
|
66
|
+
cached.loadedAt = now;
|
|
67
|
+
return cached.manifest;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const response = await client.send(new GetObjectCommand({
|
|
72
|
+
Bucket: bucket,
|
|
73
|
+
Key: 'media-manifest.json',
|
|
74
|
+
}));
|
|
75
|
+
if (!response.Body) return cached?.manifest ?? null;
|
|
76
|
+
|
|
77
|
+
const bytes = await response.Body.transformToByteArray();
|
|
78
|
+
const manifest: MediaManifest = JSON.parse(
|
|
79
|
+
Buffer.from(bytes).toString('utf8'),
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
cached = {
|
|
83
|
+
manifest,
|
|
84
|
+
etag: response.ETag || '',
|
|
85
|
+
loadedAt: now,
|
|
86
|
+
};
|
|
87
|
+
return manifest;
|
|
88
|
+
} catch {
|
|
89
|
+
// S3 error — return stale cache if available
|
|
90
|
+
return cached?.manifest ?? null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
async resolve(logicalPath: string): Promise<string> {
|
|
96
|
+
const manifest = await loadManifest();
|
|
97
|
+
const resolved = manifest?.files[logicalPath] ?? logicalPath;
|
|
98
|
+
return cdnUrl ? `${cdnUrl}/${resolved}` : `/${resolved}`;
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
resolveSync(logicalPath: string): string | null {
|
|
102
|
+
if (!cached) return null;
|
|
103
|
+
const resolved = cached.manifest.files[logicalPath] ?? logicalPath;
|
|
104
|
+
return cdnUrl ? `${cdnUrl}/${resolved}` : `/${resolved}`;
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
async preload(): Promise<void> {
|
|
108
|
+
await loadManifest();
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
clearCache(): void {
|
|
112
|
+
cached = null;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface MediaResolver {
|
|
118
|
+
/** Resolve a logical media path to a fingerprinted CDN URL. Falls back to logical path if manifest unavailable. */
|
|
119
|
+
resolve(logicalPath: string): Promise<string>;
|
|
120
|
+
/** Sync resolve from cached manifest. Returns null if no manifest cached. */
|
|
121
|
+
resolveSync(logicalPath: string): string | null;
|
|
122
|
+
/** Pre-load the manifest. Call during Lambda init. */
|
|
123
|
+
preload(): Promise<void>;
|
|
124
|
+
/** Clear the cached manifest (for testing). */
|
|
125
|
+
clearCache(): void;
|
|
126
|
+
}
|
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,27 +468,126 @@ 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 {
|
|
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
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
};
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
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
|
+
current_user: payloadUser ?? null,
|
|
508
|
+
...models,
|
|
509
|
+
...authHelpers,
|
|
510
|
+
};
|
|
511
|
+
if (ctx.notifications) evalContext.notifications = ctx.notifications;
|
|
512
|
+
if (ctx.rpc) evalContext.rpc = ctx.rpc;
|
|
513
|
+
const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor;
|
|
514
|
+
const fn = new AsyncFunction(
|
|
515
|
+
...Object.keys(evalContext),
|
|
516
|
+
`return (${expression})`,
|
|
517
|
+
);
|
|
518
|
+
return fn(...Object.values(evalContext));
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
let result: unknown;
|
|
522
|
+
|
|
523
|
+
if (options.pgSettings) {
|
|
524
|
+
// Always apply pgSettings when configured — enforces RLS.
|
|
525
|
+
// Without a logged-in user, pgSettings(null) returns anon role settings.
|
|
526
|
+
const settings = options.pgSettings(payloadUser ?? null);
|
|
527
|
+
|
|
528
|
+
async function applySettings(tx: any) {
|
|
529
|
+
const role = settings['role'];
|
|
530
|
+
if (role) {
|
|
531
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(role)) {
|
|
532
|
+
throw new Error(`Invalid role name: ${role}`);
|
|
533
|
+
}
|
|
534
|
+
await tx.execute(sql.raw(`SET LOCAL ROLE ${role}`));
|
|
535
|
+
}
|
|
536
|
+
for (const [key, value] of Object.entries(settings)) {
|
|
537
|
+
if (key !== 'role' && value !== undefined && value !== '') {
|
|
538
|
+
await tx.execute(sql`SELECT set_config(${key}, ${value}, true)`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
if (sandbox) {
|
|
544
|
+
try {
|
|
545
|
+
await ctx.db.transaction(async (tx: any) => {
|
|
546
|
+
await applySettings(tx);
|
|
547
|
+
result = await evalExpression(tx);
|
|
548
|
+
throw SANDBOX_ROLLBACK;
|
|
549
|
+
});
|
|
550
|
+
} catch (err: unknown) {
|
|
551
|
+
if (err !== SANDBOX_ROLLBACK) throw err;
|
|
552
|
+
}
|
|
553
|
+
} else {
|
|
554
|
+
result = await ctx.db.transaction(async (tx: any) => {
|
|
555
|
+
await applySettings(tx);
|
|
556
|
+
return evalExpression(tx);
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
} else if (sandbox) {
|
|
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
|
+
// Safe-serialize: handle circular refs, Symbols, and complex objects
|
|
573
|
+
// that Lambda can't JSON.stringify (e.g. Drizzle schema objects)
|
|
574
|
+
let safeResult: unknown;
|
|
575
|
+
try {
|
|
576
|
+
JSON.stringify(result);
|
|
577
|
+
safeResult = result;
|
|
578
|
+
} catch {
|
|
579
|
+
// Not JSON-serializable — return a useful string representation
|
|
580
|
+
safeResult = typeof result === 'object' && result !== null
|
|
581
|
+
? `[${result.constructor?.name ?? 'Object'}: keys=${Object.keys(result).join(', ')}]`
|
|
582
|
+
: String(result);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Build response with optional auth side-channel
|
|
586
|
+
const response: Record<string, unknown> = { result: safeResult };
|
|
587
|
+
if (authSignal.changed) {
|
|
588
|
+
response._auth = { user: authSignal.user };
|
|
589
|
+
}
|
|
590
|
+
return response;
|
|
483
591
|
} catch (err: any) {
|
|
484
592
|
return { error: err.message || String(err) };
|
|
485
593
|
}
|