@everystack/server 0.4.4 → 0.4.6
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 +7 -3
- package/src/db.ts +58 -24
- package/src/plugin.ts +23 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.6",
|
|
4
4
|
"description": "Server runtime primitives for Lambda — event adapters, routing, SSR, image processing",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -61,6 +61,10 @@
|
|
|
61
61
|
"types": "./src/plugin.ts",
|
|
62
62
|
"default": "./src/plugin.ts"
|
|
63
63
|
},
|
|
64
|
+
"./provision": {
|
|
65
|
+
"types": "./src/provision.ts",
|
|
66
|
+
"default": "./src/provision.ts"
|
|
67
|
+
},
|
|
64
68
|
"./cdn": {
|
|
65
69
|
"types": "./src/cdn/index.ts",
|
|
66
70
|
"default": "./src/cdn/index.ts"
|
|
@@ -152,8 +156,8 @@
|
|
|
152
156
|
"ts-jest": "29.4.9",
|
|
153
157
|
"typescript": "5.9.3",
|
|
154
158
|
"@everystack/auth": "0.4.0",
|
|
155
|
-
"@everystack/
|
|
156
|
-
"@everystack/
|
|
159
|
+
"@everystack/pg-tools": "0.4.0",
|
|
160
|
+
"@everystack/cli": "0.4.20"
|
|
157
161
|
},
|
|
158
162
|
"scripts": {
|
|
159
163
|
"test": "jest",
|
package/src/db.ts
CHANGED
|
@@ -7,24 +7,41 @@
|
|
|
7
7
|
* transparently. Works with createSecrets() or manual sst.Secret declarations.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { Resource } from 'sst';
|
|
11
10
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
12
11
|
import postgres from 'postgres';
|
|
13
12
|
|
|
14
13
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* Read an SST-linked resource WITHOUT importing the `sst` package. SST injects
|
|
15
|
+
* every link as `process.env.SST_RESOURCE_<Name>` (a JSON blob) — SST's own
|
|
16
|
+
* Resource proxy just parses that env var (see sst/dist/resource/shared.js:
|
|
17
|
+
* `raw[name] = JSON.parse(process.env['SST_RESOURCE_' + name])`). Reading it
|
|
18
|
+
* directly means this module never imports `sst`, which is a deploy-time
|
|
19
|
+
* construct: Metro/esbuild cannot bundle it, and SST's own build-time
|
|
20
|
+
* version-check plugin mis-reads a node_module's `sst` import and fails the
|
|
21
|
+
* build. So the API/SSR helpers stayed unbundleable in the exact runtime half
|
|
22
|
+
* the framework targets. Returns undefined when the link is absent — every
|
|
23
|
+
* getter then falls through to process.env, unchanged.
|
|
17
24
|
*/
|
|
18
|
-
function
|
|
25
|
+
function sstLink<T = Record<string, any>>(name: string): T | undefined {
|
|
26
|
+
const raw = process.env[`SST_RESOURCE_${name}`];
|
|
27
|
+
if (!raw) return undefined;
|
|
19
28
|
try {
|
|
20
|
-
|
|
21
|
-
// SST's Resource proxy may return undefined for missing keys
|
|
22
|
-
return val === undefined ? undefined : val;
|
|
29
|
+
return JSON.parse(raw) as T;
|
|
23
30
|
} catch {
|
|
24
31
|
return undefined;
|
|
25
32
|
}
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* Read ANY SST-linked resource by name, without importing `sst` — the
|
|
37
|
+
* bundler-safe replacement for `Resource.<Name>` (e.g. `sstResource('Updates')?.name`
|
|
38
|
+
* for the ssrPlugin's updatesStorage bucket). Undefined when the link is absent,
|
|
39
|
+
* so callers can fall through to process.env or their own config.
|
|
40
|
+
*/
|
|
41
|
+
export function sstResource<T = Record<string, any>>(name: string): T | undefined {
|
|
42
|
+
return sstLink<T>(name);
|
|
43
|
+
}
|
|
44
|
+
|
|
28
45
|
export function getDatabaseUrl(): string {
|
|
29
46
|
// An EXPLICIT DATABASE_URL wins over the component-derived master. This is what
|
|
30
47
|
// lets the API connection be a least-privilege role (`authenticator`) while the SST
|
|
@@ -32,11 +49,11 @@ export function getDatabaseUrl(): string {
|
|
|
32
49
|
// resolved first, setting DATABASE_URL to a least-privilege role would be silently
|
|
33
50
|
// ignored. See docs/plans/credential-split-rls-everywhere.md.
|
|
34
51
|
|
|
35
|
-
// SST Secret — PascalCase (
|
|
36
|
-
const pascal =
|
|
52
|
+
// SST Secret — PascalCase (SST_RESOURCE_DatabaseUrl → { value })
|
|
53
|
+
const pascal = sstLink('DatabaseUrl')?.value as string | undefined;
|
|
37
54
|
if (pascal) return pascal;
|
|
38
|
-
// SST Secret — raw env name (
|
|
39
|
-
const raw =
|
|
55
|
+
// SST Secret — raw env name (SST_RESOURCE_DATABASE_URL → { value })
|
|
56
|
+
const raw = sstLink('DATABASE_URL')?.value as string | undefined;
|
|
40
57
|
if (raw) return raw;
|
|
41
58
|
// process.env fallback
|
|
42
59
|
if (process.env.DATABASE_URL) return process.env.DATABASE_URL;
|
|
@@ -54,8 +71,25 @@ export function getDatabaseUrl(): string {
|
|
|
54
71
|
* no dedicated ADMIN_DATABASE_URL is provisioned — kept separate from getDatabaseUrl() so the
|
|
55
72
|
* operator path never accidentally resolves the least-privilege DATABASE_URL secret.
|
|
56
73
|
*/
|
|
74
|
+
export interface DatabaseComponentInfo {
|
|
75
|
+
username: string;
|
|
76
|
+
password: string;
|
|
77
|
+
host: string;
|
|
78
|
+
port: number;
|
|
79
|
+
database: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The linked SST Postgres component's connection fields, or null when none is linked.
|
|
84
|
+
* The single reader of the `Database` link — callers that only need host/port/database
|
|
85
|
+
* (db:psql, provision probe) use this instead of importing `sst`.
|
|
86
|
+
*/
|
|
87
|
+
export function getDatabaseComponentInfo(): DatabaseComponentInfo | null {
|
|
88
|
+
return sstLink<DatabaseComponentInfo>('Database') ?? null;
|
|
89
|
+
}
|
|
90
|
+
|
|
57
91
|
export function getMasterDatabaseUrl(): string | null {
|
|
58
|
-
const db =
|
|
92
|
+
const db = getDatabaseComponentInfo();
|
|
59
93
|
if (db?.host) {
|
|
60
94
|
return `postgresql://${db.username}:${db.password}@${db.host}:${db.port}/${db.database}`;
|
|
61
95
|
}
|
|
@@ -73,11 +107,11 @@ export function getMasterDatabaseUrl(): string | null {
|
|
|
73
107
|
* Returns null when no admin URL is configured (single-credential setup).
|
|
74
108
|
*/
|
|
75
109
|
export function getAdminDatabaseUrl(): string | null {
|
|
76
|
-
// SST Secret — PascalCase (
|
|
77
|
-
const pascal =
|
|
110
|
+
// SST Secret — PascalCase (SST_RESOURCE_AdminDatabaseUrl → { value })
|
|
111
|
+
const pascal = sstLink('AdminDatabaseUrl')?.value as string | undefined;
|
|
78
112
|
if (pascal) return pascal;
|
|
79
|
-
// SST Secret — raw env name (
|
|
80
|
-
const raw =
|
|
113
|
+
// SST Secret — raw env name (SST_RESOURCE_ADMIN_DATABASE_URL → { value })
|
|
114
|
+
const raw = sstLink('ADMIN_DATABASE_URL')?.value as string | undefined;
|
|
81
115
|
if (raw) return raw;
|
|
82
116
|
// process.env fallback
|
|
83
117
|
if (process.env.ADMIN_DATABASE_URL) return process.env.ADMIN_DATABASE_URL;
|
|
@@ -85,11 +119,11 @@ export function getAdminDatabaseUrl(): string | null {
|
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
export function getJwtSecret(): Uint8Array {
|
|
88
|
-
// PascalCase
|
|
89
|
-
const pascal =
|
|
122
|
+
// PascalCase Secret (SST_RESOURCE_JwtSecret → { value })
|
|
123
|
+
const pascal = sstLink('JwtSecret')?.value as string | undefined;
|
|
90
124
|
if (pascal) return new TextEncoder().encode(pascal);
|
|
91
|
-
// Raw env name
|
|
92
|
-
const raw =
|
|
125
|
+
// Raw env name Secret (SST_RESOURCE_JWT_SECRET → { value })
|
|
126
|
+
const raw = sstLink('JWT_SECRET')?.value as string | undefined;
|
|
93
127
|
if (raw) return new TextEncoder().encode(raw);
|
|
94
128
|
// process.env fallback
|
|
95
129
|
if (process.env.JWT_SECRET) return new TextEncoder().encode(process.env.JWT_SECRET);
|
|
@@ -288,11 +322,11 @@ export function getAdminSql(): ReturnType<typeof postgres> {
|
|
|
288
322
|
* different host. Returns null when no replica is configured.
|
|
289
323
|
*/
|
|
290
324
|
export function getReadDatabaseUrl(): string | null {
|
|
291
|
-
// SST Secret — PascalCase (
|
|
292
|
-
const pascal =
|
|
325
|
+
// SST Secret — PascalCase (SST_RESOURCE_ReadDatabaseUrl → { value })
|
|
326
|
+
const pascal = sstLink('ReadDatabaseUrl')?.value as string | undefined;
|
|
293
327
|
if (pascal) return pascal;
|
|
294
|
-
// SST Secret — raw env name (
|
|
295
|
-
const raw =
|
|
328
|
+
// SST Secret — raw env name (SST_RESOURCE_READ_DATABASE_URL → { value })
|
|
329
|
+
const raw = sstLink('READ_DATABASE_URL')?.value as string | undefined;
|
|
296
330
|
if (raw) return raw;
|
|
297
331
|
// process.env fallback
|
|
298
332
|
if (process.env.READ_DATABASE_URL) return process.env.READ_DATABASE_URL;
|
package/src/plugin.ts
CHANGED
|
@@ -42,8 +42,8 @@ export interface PluginContext {
|
|
|
42
42
|
verifyToken: (token: string) => Promise<Record<string, unknown> | null>;
|
|
43
43
|
/** Runtime environment */
|
|
44
44
|
environment: string;
|
|
45
|
-
/** Publish a background job */
|
|
46
|
-
publishJob
|
|
45
|
+
/** Publish a background job. Optional — a lean app with no @everystack/jobs never sets it. */
|
|
46
|
+
publishJob?: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
|
|
47
47
|
/** Cancel a scheduled job */
|
|
48
48
|
cancelJob?: (jobId: string) => Promise<void>;
|
|
49
49
|
/** Reschedule a pending job */
|
|
@@ -460,8 +460,9 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
|
|
|
460
460
|
// --- Database Actions Plugin ---
|
|
461
461
|
|
|
462
462
|
export interface DbPluginOptions {
|
|
463
|
-
/** Absolute path to drizzle migrations folder
|
|
464
|
-
|
|
463
|
+
/** Absolute path to drizzle migrations folder. Optional — a db:sync app has no migrations;
|
|
464
|
+
* migrate/db:reset then answer with a clear error while provision/console/psql still work. */
|
|
465
|
+
migrationsFolder?: string;
|
|
465
466
|
/** App-provided seed function — action only registered if provided */
|
|
466
467
|
seed?: (db: any, schema: any) => Promise<any>;
|
|
467
468
|
/** PostgreSQL schemas to drop during db:reset (default: ['public', 'drizzle']) */
|
|
@@ -553,8 +554,15 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
553
554
|
return { deps: { bucket, region: options.backupRegion ?? process.env.AWS_REGION, adminUrl } };
|
|
554
555
|
};
|
|
555
556
|
|
|
557
|
+
// The migration-backed actions stay in the inventory even without a migrationsFolder —
|
|
558
|
+
// an explanatory error beats "Unknown action" pointing the operator the wrong way.
|
|
559
|
+
const NO_MIGRATIONS_ERROR =
|
|
560
|
+
'no migrationsFolder configured — this app has no migrations (db:sync manages its schema). '
|
|
561
|
+
+ 'To use migrations, pass migrationsFolder to dbPlugin.';
|
|
562
|
+
|
|
556
563
|
// --- migrate ---
|
|
557
564
|
actions.migrate = async (_payload, _ctx) => {
|
|
565
|
+
if (!options.migrationsFolder) return { error: NO_MIGRATIONS_ERROR };
|
|
558
566
|
const { runMigrations } = await import('./migrate');
|
|
559
567
|
return await runMigrations(opsDb, options.migrationsFolder);
|
|
560
568
|
};
|
|
@@ -575,6 +583,9 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
575
583
|
if (ctx.environment !== 'dev') {
|
|
576
584
|
return { error: 'db:reset is only available in dev environment' };
|
|
577
585
|
}
|
|
586
|
+
// Refuse BEFORE the drop: reset replays migrations after wiping the schemas — with no
|
|
587
|
+
// migrations there is nothing to rebuild from, and the drop would strand the database.
|
|
588
|
+
if (!options.migrationsFolder) return { error: NO_MIGRATIONS_ERROR };
|
|
578
589
|
const { sql } = await import('drizzle-orm');
|
|
579
590
|
const schemas = options.schemas ?? ['public', 'drizzle'];
|
|
580
591
|
const dropStatements = schemas
|
|
@@ -596,18 +607,12 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
596
607
|
if (options.connectionInfo) {
|
|
597
608
|
return await options.connectionInfo();
|
|
598
609
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
host: (Resource as any).Database.host,
|
|
603
|
-
port: (Resource as any).Database.port,
|
|
604
|
-
database: (Resource as any).Database.database,
|
|
605
|
-
username: (Resource as any).Database.username,
|
|
606
|
-
password: (Resource as any).Database.password,
|
|
607
|
-
};
|
|
608
|
-
} catch {
|
|
610
|
+
const { getDatabaseComponentInfo } = await import('./db');
|
|
611
|
+
const db = getDatabaseComponentInfo();
|
|
612
|
+
if (!db) {
|
|
609
613
|
return { error: 'No connectionInfo callback provided and SST Resource.Database not available' };
|
|
610
614
|
}
|
|
615
|
+
return { host: db.host, port: db.port, database: db.database, username: db.username, password: db.password };
|
|
611
616
|
};
|
|
612
617
|
|
|
613
618
|
// --- db:query ---
|
|
@@ -778,13 +783,10 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
778
783
|
connection = { host: info.host, port: info.port, database: info.database };
|
|
779
784
|
}
|
|
780
785
|
} else {
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
} catch {
|
|
786
|
-
// No linked component and no callback — the admin login lands unverified.
|
|
787
|
-
}
|
|
786
|
+
const { getDatabaseComponentInfo } = await import('./db');
|
|
787
|
+
const db = getDatabaseComponentInfo();
|
|
788
|
+
// No linked component and no callback — the admin login lands unverified.
|
|
789
|
+
if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
|
|
788
790
|
}
|
|
789
791
|
|
|
790
792
|
return await runProvision(
|