@everystack/server 0.2.12 → 0.2.14
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/db.ts +78 -0
- package/src/media.ts +126 -0
- package/src/plugin.ts +112 -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.14",
|
|
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": {
|
package/src/db.ts
CHANGED
|
@@ -42,6 +42,28 @@ export function getDatabaseUrl(): string {
|
|
|
42
42
|
throw new Error('DATABASE_URL not set — link an sst.aws.Postgres or sst.Secret, or set process.env.DATABASE_URL');
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the operator (privileged) database URL, if configured.
|
|
47
|
+
*
|
|
48
|
+
* The API connection (DATABASE_URL) should be least-privilege and RLS-gated.
|
|
49
|
+
* Operator tasks — migrations, seed, console, raw queries — need a role that
|
|
50
|
+
* owns the schema and sees all rows. Keep them on separate credentials so the
|
|
51
|
+
* API Lambda never holds the operator secret.
|
|
52
|
+
*
|
|
53
|
+
* Returns null when no admin URL is configured (single-credential setup).
|
|
54
|
+
*/
|
|
55
|
+
export function getAdminDatabaseUrl(): string | null {
|
|
56
|
+
// SST Secret — PascalCase (Resource.AdminDatabaseUrl.value)
|
|
57
|
+
const pascal = tryResource(() => (Resource as any).AdminDatabaseUrl?.value as string);
|
|
58
|
+
if (pascal) return pascal;
|
|
59
|
+
// SST Secret — raw env name (Resource.ADMIN_DATABASE_URL.value)
|
|
60
|
+
const raw = tryResource(() => (Resource as any).ADMIN_DATABASE_URL?.value as string);
|
|
61
|
+
if (raw) return raw;
|
|
62
|
+
// process.env fallback
|
|
63
|
+
if (process.env.ADMIN_DATABASE_URL) return process.env.ADMIN_DATABASE_URL;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
45
67
|
export function getJwtSecret(): Uint8Array {
|
|
46
68
|
// PascalCase Resource (Resource.JwtSecret.value)
|
|
47
69
|
const pascal = tryResource(() => (Resource as any).JwtSecret?.value as string);
|
|
@@ -94,3 +116,59 @@ export function getSql(): ReturnType<typeof postgres> {
|
|
|
94
116
|
}
|
|
95
117
|
return sqlClient;
|
|
96
118
|
}
|
|
119
|
+
|
|
120
|
+
// Lazy singleton operator DB connection (separate from the API connection)
|
|
121
|
+
let adminDbInstance: ReturnType<typeof drizzle> | null = null;
|
|
122
|
+
let adminSqlClient: ReturnType<typeof postgres> | null = null;
|
|
123
|
+
let adminFallbackWarned = false;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Operator database connection for privileged tasks (migrate, seed, console).
|
|
127
|
+
*
|
|
128
|
+
* Connects with ADMIN_DATABASE_URL when configured. When it isn't, falls back
|
|
129
|
+
* to the regular DATABASE_URL connection with a one-time warning — existing
|
|
130
|
+
* single-credential apps keep working, but RLS is only fail-closed once the
|
|
131
|
+
* credentials are split. See docs/plans/admin-database-url.md.
|
|
132
|
+
*/
|
|
133
|
+
export function createAdminDb<T extends Record<string, unknown>>(
|
|
134
|
+
schema: T,
|
|
135
|
+
options?: { maxConnections?: number }
|
|
136
|
+
): {
|
|
137
|
+
db: ReturnType<typeof drizzle>;
|
|
138
|
+
schema: T;
|
|
139
|
+
} {
|
|
140
|
+
if (adminDbInstance) return { db: adminDbInstance, schema };
|
|
141
|
+
|
|
142
|
+
const adminUrl = getAdminDatabaseUrl();
|
|
143
|
+
if (!adminUrl) {
|
|
144
|
+
if (!adminFallbackWarned) {
|
|
145
|
+
adminFallbackWarned = true;
|
|
146
|
+
console.warn(
|
|
147
|
+
'[everystack/db] ADMIN_DATABASE_URL not set — operator tasks are using the '
|
|
148
|
+
+ 'API connection (DATABASE_URL). If that role has BYPASSRLS, your RLS policies '
|
|
149
|
+
+ 'are not enforced. Split the credentials: docs/plans/admin-database-url.md'
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return createDb(schema, options);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
adminSqlClient = postgres(adminUrl, {
|
|
156
|
+
max: options?.maxConnections ?? 1,
|
|
157
|
+
idle_timeout: 60,
|
|
158
|
+
connect_timeout: 5,
|
|
159
|
+
max_lifetime: 60 * 5,
|
|
160
|
+
ssl: 'require',
|
|
161
|
+
});
|
|
162
|
+
adminDbInstance = drizzle(adminSqlClient, { schema });
|
|
163
|
+
return { db: adminDbInstance, schema };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Raw postgres.js client for the operator connection.
|
|
168
|
+
* Must be called after createAdminDb(). Falls back to the API client when
|
|
169
|
+
* the credentials aren't split (same fallback as createAdminDb).
|
|
170
|
+
*/
|
|
171
|
+
export function getAdminSql(): ReturnType<typeof postgres> {
|
|
172
|
+
if (adminSqlClient) return adminSqlClient;
|
|
173
|
+
return getSql();
|
|
174
|
+
}
|
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
|
@@ -27,6 +27,14 @@ import {
|
|
|
27
27
|
export interface PluginContext {
|
|
28
28
|
/** Drizzle database connection */
|
|
29
29
|
db: any;
|
|
30
|
+
/**
|
|
31
|
+
* Operator (privileged) connection for migrate/seed/console — see
|
|
32
|
+
* createAdminDb(). When present, dbPlugin runs ALL operator actions on it
|
|
33
|
+
* instead of `db`, so `db` can be a least-privilege, RLS-gated role.
|
|
34
|
+
* In a dedicated ops Lambda, set both `db` and `adminDb` to the admin
|
|
35
|
+
* connection.
|
|
36
|
+
*/
|
|
37
|
+
adminDb?: any;
|
|
30
38
|
/** App schema (all tables) */
|
|
31
39
|
schema: Record<string, any>;
|
|
32
40
|
/** Shared JWT verification — one function, used by all plugins */
|
|
@@ -262,6 +270,74 @@ export function createPluginLambdaHandler(
|
|
|
262
270
|
};
|
|
263
271
|
}
|
|
264
272
|
|
|
273
|
+
// --- Ops Lambda Handler (actions only) ---
|
|
274
|
+
|
|
275
|
+
export interface OpsLambdaHandlerOptions {
|
|
276
|
+
/** Creates the shared context. Use createAdminDb() here — this Lambda is
|
|
277
|
+
* the operator entrypoint and should hold the privileged credential. */
|
|
278
|
+
context: () => Promise<PluginContext>;
|
|
279
|
+
/** Plugins to collect actions from. Routes/handlers they contribute are ignored. */
|
|
280
|
+
plugins: Plugin[];
|
|
281
|
+
/** App-level actions merged with plugin actions (app wins on collision). */
|
|
282
|
+
actions?: Record<string, ActionHandler>;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Dedicated operator entrypoint — handles `_action` invokes only.
|
|
287
|
+
*
|
|
288
|
+
* Deploy as a separate Lambda (server/ops.ts) and link ADMIN_DATABASE_URL to
|
|
289
|
+
* it instead of the API function. IAM (lambda:InvokeFunction) is the auth
|
|
290
|
+
* layer, same as the API _action path. HTTP events are rejected: this
|
|
291
|
+
* function must never be wired to a Function URL or the CDN.
|
|
292
|
+
*/
|
|
293
|
+
export function createOpsLambdaHandler(
|
|
294
|
+
options: OpsLambdaHandlerOptions
|
|
295
|
+
): (event: Record<string, unknown>) => Promise<unknown> {
|
|
296
|
+
let cached: { ctx: PluginContext; actions: Record<string, ActionHandler> } | null = null;
|
|
297
|
+
|
|
298
|
+
async function ensureInitialized() {
|
|
299
|
+
if (cached) return cached;
|
|
300
|
+
const ctx = await options.context();
|
|
301
|
+
const actions: Record<string, ActionHandler> = {};
|
|
302
|
+
for (const plugin of options.plugins) {
|
|
303
|
+
const contribution = await plugin(ctx);
|
|
304
|
+
if (contribution.actions) Object.assign(actions, contribution.actions);
|
|
305
|
+
}
|
|
306
|
+
Object.assign(actions, options.actions ?? {});
|
|
307
|
+
cached = { ctx, actions };
|
|
308
|
+
log('info', 'Ops handlers initialized', { actions: Object.keys(actions).length });
|
|
309
|
+
return cached;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return async (event: Record<string, unknown>): Promise<unknown> => {
|
|
313
|
+
if (!('_action' in event) || typeof event._action !== 'string') {
|
|
314
|
+
// Not an operator invoke. Reject HTTP-shaped events explicitly.
|
|
315
|
+
if ('requestContext' in event || 'rawPath' in event) {
|
|
316
|
+
return {
|
|
317
|
+
statusCode: 403,
|
|
318
|
+
headers: { 'Content-Type': 'application/json', 'cache-control': 'no-store' },
|
|
319
|
+
body: JSON.stringify({ error: 'Forbidden' }),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
return { error: 'Missing _action' };
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const { ctx, actions } = await ensureInitialized();
|
|
326
|
+
const actionName = event._action as string;
|
|
327
|
+
const actionHandler = actions[actionName];
|
|
328
|
+
if (!actionHandler) {
|
|
329
|
+
return { error: `Unknown action: ${actionName}` };
|
|
330
|
+
}
|
|
331
|
+
log('info', 'Ops action invoked', { action: actionName });
|
|
332
|
+
try {
|
|
333
|
+
return await actionHandler(event._payload, ctx);
|
|
334
|
+
} catch (error) {
|
|
335
|
+
log('error', 'Ops action error', { action: actionName, error: String(error) });
|
|
336
|
+
return { error: String(error) };
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
265
341
|
// --- SSR Fallback Plugin ---
|
|
266
342
|
|
|
267
343
|
import type { PostProcessResult, WebHandlerOptions } from './ssr';
|
|
@@ -377,10 +453,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
377
453
|
return async (ctx: PluginContext): Promise<PluginContribution> => {
|
|
378
454
|
const actions: Record<string, ActionHandler> = {};
|
|
379
455
|
|
|
456
|
+
// Operator connection: all actions below run privileged tasks (DDL,
|
|
457
|
+
// unrestricted reads). Prefer the dedicated admin connection so the API
|
|
458
|
+
// connection can be least-privilege and RLS-gated.
|
|
459
|
+
const opsDb = ctx.adminDb ?? ctx.db;
|
|
460
|
+
|
|
380
461
|
// --- migrate ---
|
|
381
|
-
actions.migrate = async (_payload,
|
|
462
|
+
actions.migrate = async (_payload, _ctx) => {
|
|
382
463
|
const { runMigrations } = await import('./migrate');
|
|
383
|
-
return await runMigrations(
|
|
464
|
+
return await runMigrations(opsDb, options.migrationsFolder);
|
|
384
465
|
};
|
|
385
466
|
|
|
386
467
|
// --- seed (only when app provides a seed function) ---
|
|
@@ -390,7 +471,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
390
471
|
if (ctx.environment !== 'dev') {
|
|
391
472
|
return { error: 'Seed is only available in dev environment' };
|
|
392
473
|
}
|
|
393
|
-
return await seedFn(
|
|
474
|
+
return await seedFn(opsDb, ctx.schema);
|
|
394
475
|
};
|
|
395
476
|
}
|
|
396
477
|
|
|
@@ -404,14 +485,14 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
404
485
|
const dropStatements = schemas
|
|
405
486
|
.map(s => `DROP SCHEMA IF EXISTS ${s} CASCADE;`)
|
|
406
487
|
.join('\n ');
|
|
407
|
-
await
|
|
488
|
+
await opsDb.execute(sql.raw(`
|
|
408
489
|
${dropStatements}
|
|
409
490
|
CREATE SCHEMA public;
|
|
410
491
|
GRANT ALL ON SCHEMA public TO postgres;
|
|
411
492
|
GRANT ALL ON SCHEMA public TO public;
|
|
412
493
|
`));
|
|
413
494
|
const { runMigrations } = await import('./migrate');
|
|
414
|
-
const result = await runMigrations(
|
|
495
|
+
const result = await runMigrations(opsDb, options.migrationsFolder);
|
|
415
496
|
return { reset: true, ...result };
|
|
416
497
|
};
|
|
417
498
|
|
|
@@ -435,7 +516,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
435
516
|
};
|
|
436
517
|
|
|
437
518
|
// --- db:query ---
|
|
438
|
-
actions['db:query'] = async (payload,
|
|
519
|
+
actions['db:query'] = async (payload, _ctx) => {
|
|
439
520
|
const { sql: querySql } = payload as { sql: string };
|
|
440
521
|
if (!querySql) {
|
|
441
522
|
return { error: 'SQL query is required' };
|
|
@@ -451,7 +532,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
451
532
|
}
|
|
452
533
|
try {
|
|
453
534
|
const { sql } = await import('drizzle-orm');
|
|
454
|
-
let queryDb =
|
|
535
|
+
let queryDb = opsDb;
|
|
455
536
|
const readUrl = options.readDatabaseUrl ?? process.env.READ_DATABASE_URL;
|
|
456
537
|
if (readUrl && readUrl !== process.env.DATABASE_URL) {
|
|
457
538
|
const { drizzle } = await import('drizzle-orm/postgres-js');
|
|
@@ -489,7 +570,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
489
570
|
// Auth signal — helpers write to this during eval, we read it after
|
|
490
571
|
const authSignal = { user: null as Record<string, unknown> | null, changed: false };
|
|
491
572
|
const authHelpers = createAuthHelpers({
|
|
492
|
-
db:
|
|
573
|
+
db: opsDb,
|
|
493
574
|
schema: ctx.schema,
|
|
494
575
|
signal: authSignal,
|
|
495
576
|
...options.consoleAuth,
|
|
@@ -504,6 +585,7 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
504
585
|
ctx,
|
|
505
586
|
db, schema: ctx.schema,
|
|
506
587
|
eq, and, or, gt, lt, gte, lte, ne, count, sum, avg, sql, desc, asc,
|
|
588
|
+
current_user: payloadUser ?? null,
|
|
507
589
|
...models,
|
|
508
590
|
...authHelpers,
|
|
509
591
|
};
|
|
@@ -519,8 +601,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
519
601
|
|
|
520
602
|
let result: unknown;
|
|
521
603
|
|
|
522
|
-
if (
|
|
523
|
-
|
|
604
|
+
if (options.pgSettings) {
|
|
605
|
+
// Always apply pgSettings when configured — enforces RLS.
|
|
606
|
+
// Without a logged-in user, pgSettings(null) returns anon role settings.
|
|
607
|
+
const settings = options.pgSettings(payloadUser ?? null);
|
|
524
608
|
|
|
525
609
|
async function applySettings(tx: any) {
|
|
526
610
|
const role = settings['role'];
|
|
@@ -538,9 +622,8 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
538
622
|
}
|
|
539
623
|
|
|
540
624
|
if (sandbox) {
|
|
541
|
-
// pgSettings + sandbox: apply settings, eval, capture result, rollback
|
|
542
625
|
try {
|
|
543
|
-
await
|
|
626
|
+
await opsDb.transaction(async (tx: any) => {
|
|
544
627
|
await applySettings(tx);
|
|
545
628
|
result = await evalExpression(tx);
|
|
546
629
|
throw SANDBOX_ROLLBACK;
|
|
@@ -549,16 +632,14 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
549
632
|
if (err !== SANDBOX_ROLLBACK) throw err;
|
|
550
633
|
}
|
|
551
634
|
} else {
|
|
552
|
-
|
|
553
|
-
result = await ctx.db.transaction(async (tx: any) => {
|
|
635
|
+
result = await opsDb.transaction(async (tx: any) => {
|
|
554
636
|
await applySettings(tx);
|
|
555
637
|
return evalExpression(tx);
|
|
556
638
|
});
|
|
557
639
|
}
|
|
558
640
|
} else if (sandbox) {
|
|
559
|
-
// Sandbox without auth: wrap in transaction and rollback
|
|
560
641
|
try {
|
|
561
|
-
await
|
|
642
|
+
await opsDb.transaction(async (tx: any) => {
|
|
562
643
|
result = await evalExpression(tx);
|
|
563
644
|
throw SANDBOX_ROLLBACK;
|
|
564
645
|
});
|
|
@@ -566,11 +647,24 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
566
647
|
if (err !== SANDBOX_ROLLBACK) throw err;
|
|
567
648
|
}
|
|
568
649
|
} else {
|
|
569
|
-
result = await evalExpression(
|
|
650
|
+
result = await evalExpression(opsDb);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// Safe-serialize: handle circular refs, Symbols, and complex objects
|
|
654
|
+
// that Lambda can't JSON.stringify (e.g. Drizzle schema objects)
|
|
655
|
+
let safeResult: unknown;
|
|
656
|
+
try {
|
|
657
|
+
JSON.stringify(result);
|
|
658
|
+
safeResult = result;
|
|
659
|
+
} catch {
|
|
660
|
+
// Not JSON-serializable — return a useful string representation
|
|
661
|
+
safeResult = typeof result === 'object' && result !== null
|
|
662
|
+
? `[${result.constructor?.name ?? 'Object'}: keys=${Object.keys(result).join(', ')}]`
|
|
663
|
+
: String(result);
|
|
570
664
|
}
|
|
571
665
|
|
|
572
666
|
// Build response with optional auth side-channel
|
|
573
|
-
const response: Record<string, unknown> = { result };
|
|
667
|
+
const response: Record<string, unknown> = { result: safeResult };
|
|
574
668
|
if (authSignal.changed) {
|
|
575
669
|
response._auth = { user: authSignal.user };
|
|
576
670
|
}
|