@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.
Files changed (3) hide show
  1. package/package.json +7 -3
  2. package/src/db.ts +58 -24
  3. 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.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/cli": "0.4.18",
156
- "@everystack/pg-tools": "0.4.0"
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
- * Safely read a Resource property. Returns undefined if the Resource
16
- * or property doesn't exist (avoids runtime throw from SST's proxy).
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 tryResource<T>(fn: () => T): T | undefined {
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
- const val = fn();
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 (Resource.DatabaseUrl.value)
36
- const pascal = tryResource(() => (Resource as any).DatabaseUrl?.value as string);
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 (Resource.DATABASE_URL.value)
39
- const raw = tryResource(() => (Resource as any).DATABASE_URL?.value as string);
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 = tryResource(() => Resource.Database);
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 (Resource.AdminDatabaseUrl.value)
77
- const pascal = tryResource(() => (Resource as any).AdminDatabaseUrl?.value as string);
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 (Resource.ADMIN_DATABASE_URL.value)
80
- const raw = tryResource(() => (Resource as any).ADMIN_DATABASE_URL?.value as string);
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 Resource (Resource.JwtSecret.value)
89
- const pascal = tryResource(() => (Resource as any).JwtSecret?.value as string);
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 Resource (Resource.JWT_SECRET.value)
92
- const raw = tryResource(() => (Resource as any).JWT_SECRET?.value as string);
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 (Resource.ReadDatabaseUrl.value)
292
- const pascal = tryResource(() => (Resource as any).ReadDatabaseUrl?.value as string);
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 (Resource.READ_DATABASE_URL.value)
295
- const raw = tryResource(() => (Resource as any).READ_DATABASE_URL?.value as string);
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: (type: string, payload: unknown, options?: { schedulable?: boolean; runAt?: Date }) => Promise<string>;
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
- migrationsFolder: string;
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
- try {
600
- const { Resource } = await import('sst');
601
- return {
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
- try {
782
- const { Resource } = await import('sst');
783
- const db = (Resource as any).Database;
784
- if (db?.host) connection = { host: db.host, port: db.port, database: db.database };
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(