@everystack/server 0.4.6 → 0.4.9
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-doctor.ts +82 -0
- package/src/db.ts +8 -16
- package/src/plugin.ts +116 -60
- package/src/sst-resources.ts +80 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/server",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.9",
|
|
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>",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"types": "./src/migrate.ts",
|
|
44
44
|
"default": "./src/migrate.ts"
|
|
45
45
|
},
|
|
46
|
+
"./db-doctor": {
|
|
47
|
+
"types": "./src/db-doctor.ts",
|
|
48
|
+
"default": "./src/db-doctor.ts"
|
|
49
|
+
},
|
|
46
50
|
"./worker": {
|
|
47
51
|
"types": "./src/worker.ts",
|
|
48
52
|
"default": "./src/worker.ts"
|
|
@@ -155,9 +159,9 @@
|
|
|
155
159
|
"sst": "4.13.1",
|
|
156
160
|
"ts-jest": "29.4.9",
|
|
157
161
|
"typescript": "5.9.3",
|
|
162
|
+
"@everystack/cli": "0.4.23",
|
|
158
163
|
"@everystack/auth": "0.4.0",
|
|
159
|
-
"@everystack/pg-tools": "0.4.0"
|
|
160
|
-
"@everystack/cli": "0.4.20"
|
|
164
|
+
"@everystack/pg-tools": "0.4.0"
|
|
161
165
|
},
|
|
162
166
|
"scripts": {
|
|
163
167
|
"test": "jest",
|
package/src/db-doctor.ts
CHANGED
|
@@ -593,6 +593,88 @@ export function buildDoctorReport(probe: DoctorProbe, options: DoctorReportOptio
|
|
|
593
593
|
};
|
|
594
594
|
}
|
|
595
595
|
|
|
596
|
+
/** A query runner for ONE connection — returns plain result rows. Venue-agnostic: the
|
|
597
|
+
* dbPlugin wraps drizzle (`db.execute(sql.raw(q))`), the CLI direct venue wraps a
|
|
598
|
+
* postgres.js client. Its errors propagate so the ambient probe can catch a fail-closed. */
|
|
599
|
+
export type DoctorRunner = (query: string) => Promise<any[]>;
|
|
600
|
+
|
|
601
|
+
/** The two connections db:doctor compares: the api credential under scrutiny and the
|
|
602
|
+
* operator credential that carries the catalog introspection. */
|
|
603
|
+
export interface DoctorProbeRunners {
|
|
604
|
+
/** The api (least-privilege / DATABASE_URL) connection. */
|
|
605
|
+
api: DoctorRunner;
|
|
606
|
+
/** The operator (ADMIN_DATABASE_URL) connection — sees the whole catalog. */
|
|
607
|
+
ops: DoctorRunner;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface RunDoctorProbeOptions extends DoctorReportOptions {
|
|
611
|
+
/** Names the api credential source in the connect-failure message (e.g. 'DATABASE_URL'). */
|
|
612
|
+
apiSourceLabel?: string;
|
|
613
|
+
/** The backups lens (pg_dump layer) probe — Lambda-only. Omitted on the direct venue,
|
|
614
|
+
* where there is no layer concept, so the lens is simply not shown. */
|
|
615
|
+
backup?: () => Promise<DoctorProbe['backup']>;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const quoteDoctorIdent = (s: string): string => '"' + String(s).replace(/"/g, '""') + '"';
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Gather the full doctor probe over two connection runners and classify it. This is the
|
|
622
|
+
* single orchestration BOTH venues share:
|
|
623
|
+
*
|
|
624
|
+
* - the dbPlugin `db:doctor` action — drizzle runners, inside the VPC, with the pg_dump
|
|
625
|
+
* layer backups lens and the app's dbPlugin `forceRlsCarveouts` / `functionSchemas`;
|
|
626
|
+
* - the CLI `--database-url` / `--direct` path — postgres.js runners from the operator's
|
|
627
|
+
* shell, no layer lens (there is none locally).
|
|
628
|
+
*
|
|
629
|
+
* The api connection is probed for its identity (role, superuser, BYPASSRLS) and its
|
|
630
|
+
* ambient read; the operator connection carries every catalog query. A failure to even
|
|
631
|
+
* identify the api connection throws (the venue reports it) — every other probe error
|
|
632
|
+
* surfaces as a red check via buildDoctorReport.
|
|
633
|
+
*/
|
|
634
|
+
export async function runDoctorProbe(
|
|
635
|
+
runners: DoctorProbeRunners,
|
|
636
|
+
options: RunDoctorProbeOptions = {},
|
|
637
|
+
): Promise<DoctorReport> {
|
|
638
|
+
const { api: apiRun, ops: opsRun } = runners;
|
|
639
|
+
const sourceLabel = options.apiSourceLabel ?? 'DATABASE_URL';
|
|
640
|
+
|
|
641
|
+
let api: ConnInfo;
|
|
642
|
+
try {
|
|
643
|
+
api = rowToConnInfo((await apiRun(CONN_SQL))[0]);
|
|
644
|
+
} catch (err: any) {
|
|
645
|
+
throw new Error(`api connection probe failed (${sourceLabel}): ${err?.message || String(err)}`);
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const admin = rowToConnInfo((await opsRun(CONN_SQL))[0]);
|
|
649
|
+
const forceRls = (await opsRun(FORCE_RLS_SQL)).map(rowToForceRls);
|
|
650
|
+
const appGrantedRls = (await opsRun(appGrantsRlsSql())).map(rowToAppGranted);
|
|
651
|
+
const secdefFunctions = (await opsRun(SECDEF_FUNCTIONS_SQL)).map(rowToSecdefFn);
|
|
652
|
+
const publicExecutableFunctions = (await opsRun(PUBLIC_EXECUTABLE_FUNCTIONS_SQL)).map(rowToPublicExecutableFn);
|
|
653
|
+
const darkRelations = (await opsRun(DARK_RELATIONS_SQL)).map(rowToDarkRelation);
|
|
654
|
+
|
|
655
|
+
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE? A
|
|
656
|
+
// least-privilege role must fail closed here; a role that returns rows carries ambient
|
|
657
|
+
// privilege (INHERIT + membership in a USING(true) role like the master).
|
|
658
|
+
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
659
|
+
if (forceRls.length > 0) {
|
|
660
|
+
const t = forceRls[0];
|
|
661
|
+
const ref = `${t.schema}.${t.table}`;
|
|
662
|
+
try {
|
|
663
|
+
await apiRun(`SELECT 1 FROM ${quoteDoctorIdent(t.schema)}.${quoteDoctorIdent(t.table)} LIMIT 1`);
|
|
664
|
+
apiAmbientRead = { table: ref, accessible: true };
|
|
665
|
+
} catch {
|
|
666
|
+
apiAmbientRead = { table: ref, accessible: false };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
const backup = options.backup ? await options.backup() : undefined;
|
|
671
|
+
|
|
672
|
+
return buildDoctorReport(
|
|
673
|
+
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations, backup },
|
|
674
|
+
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
675
|
+
);
|
|
676
|
+
}
|
|
677
|
+
|
|
596
678
|
/** Map a PUBLIC_EXECUTABLE_FUNCTIONS_SQL row to PublicExecutableFn. */
|
|
597
679
|
export function rowToPublicExecutableFn(row: any): PublicExecutableFn {
|
|
598
680
|
return {
|
package/src/db.ts
CHANGED
|
@@ -9,27 +9,19 @@
|
|
|
9
9
|
|
|
10
10
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
|
11
11
|
import postgres from 'postgres';
|
|
12
|
+
import { loadSstResources } from './sst-resources';
|
|
12
13
|
|
|
13
14
|
/**
|
|
14
|
-
* Read an SST-linked resource WITHOUT importing the `sst` package.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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
|
|
15
|
+
* Read an SST-linked resource WITHOUT importing the `sst` package. `sst` is a
|
|
16
|
+
* deploy-time construct: importing it makes this module unbundleable (Metro) and
|
|
17
|
+
* trips SST's own build-time version-check plugin. `loadSstResources` reads every
|
|
18
|
+
* mechanism SST uses to deliver links — per-name env, the bundled JSON, and the
|
|
19
|
+
* encrypted key file (SST >= ~4.13's Lambda default) — so this resolves the same
|
|
20
|
+
* links `Resource.<Name>` would. Returns undefined when the link is absent; every
|
|
23
21
|
* getter then falls through to process.env, unchanged.
|
|
24
22
|
*/
|
|
25
23
|
function sstLink<T = Record<string, any>>(name: string): T | undefined {
|
|
26
|
-
|
|
27
|
-
if (!raw) return undefined;
|
|
28
|
-
try {
|
|
29
|
-
return JSON.parse(raw) as T;
|
|
30
|
-
} catch {
|
|
31
|
-
return undefined;
|
|
32
|
-
}
|
|
24
|
+
return loadSstResources()[name] as T | undefined;
|
|
33
25
|
}
|
|
34
26
|
|
|
35
27
|
/**
|
package/src/plugin.ts
CHANGED
|
@@ -420,6 +420,16 @@ export function ssrPlugin(options: SsrPluginOptions = {}): (ctx: PluginContext)
|
|
|
420
420
|
if (options.stripQueryParams) ssrOptions.stripQueryParams = options.stripQueryParams;
|
|
421
421
|
if (options.injectHydrateFlag !== undefined) ssrOptions.injectHydrateFlag = options.injectHydrateFlag;
|
|
422
422
|
|
|
423
|
+
// Fail LOUD on the misconfiguration, not silent 404. Without ctx.updatesStorage the SSR
|
|
424
|
+
// fallback can never serve `/` (getWebHandler has no bundle source) — but /api still works,
|
|
425
|
+
// so it reads like a broken bundle, not a missing link. A consumer lost hours to exactly
|
|
426
|
+
// this silent null. Log once at init with the fix.
|
|
427
|
+
if (!(ctx as SsrContext).updatesStorage) {
|
|
428
|
+
log('error', 'ssrPlugin: no updatesStorage in the plugin context — every non-API route will 404. '
|
|
429
|
+
+ 'Wire it in createAppContext: updatesStorage = await createStorage({ type: "s3", '
|
|
430
|
+
+ 'bucket: sstResource<{name:string}>("Updates")?.name }) and LINK the Updates bucket to this function.');
|
|
431
|
+
}
|
|
432
|
+
|
|
423
433
|
return async (request: Request): Promise<Response> => {
|
|
424
434
|
const webHandler = await getWebHandler(
|
|
425
435
|
(ctx as SsrContext).updatesStorage,
|
|
@@ -684,82 +694,63 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
684
694
|
// (api still on the master), BYPASSRLS, and un-FORCEd RLS. See db-doctor.ts.
|
|
685
695
|
actions['db:doctor'] = async (_payload, ctx) => {
|
|
686
696
|
const { sql } = await import('drizzle-orm');
|
|
687
|
-
const {
|
|
688
|
-
buildDoctorReport, rowToConnInfo, rowToForceRls, rowToAppGranted, rowToSecdefFn,
|
|
689
|
-
rowToPublicExecutableFn, rowToDarkRelation,
|
|
690
|
-
CONN_SQL, FORCE_RLS_SQL, appGrantsRlsSql, SECDEF_FUNCTIONS_SQL,
|
|
691
|
-
PUBLIC_EXECUTABLE_FUNCTIONS_SQL, DARK_RELATIONS_SQL,
|
|
692
|
-
} = await import('./db-doctor');
|
|
697
|
+
const { runDoctorProbe } = await import('./db-doctor');
|
|
693
698
|
const rowsOf = (result: any): any[] => (Array.isArray(result) ? result : result?.rows || []);
|
|
694
699
|
|
|
695
|
-
|
|
696
|
-
|
|
700
|
+
// Two drizzle-backed runners for the shared orchestrator: the api credential under
|
|
701
|
+
// scrutiny (createDb / DATABASE_URL) and the operator connection that carries the
|
|
702
|
+
// catalog introspection (opsDb / ADMIN_DATABASE_URL).
|
|
697
703
|
let apiDb: any;
|
|
698
|
-
let api;
|
|
699
704
|
try {
|
|
700
705
|
const { createDb } = await import('./db');
|
|
701
706
|
apiDb = createDb(ctx.schema).db;
|
|
702
|
-
api = rowToConnInfo(rowsOf(await apiDb.execute(sql.raw(CONN_SQL)))[0]);
|
|
703
707
|
} catch (err: any) {
|
|
704
708
|
return { error: `api connection probe failed (DATABASE_URL): ${err?.message || String(err)}` };
|
|
705
709
|
}
|
|
710
|
+
const apiRun = async (q: string): Promise<any[]> => rowsOf(await apiDb.execute(sql.raw(q)));
|
|
711
|
+
const opsRun = async (q: string): Promise<any[]> => rowsOf(await opsDb.execute(sql.raw(q)));
|
|
706
712
|
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
//
|
|
710
|
-
|
|
711
|
-
const appGrantedRls = rowsOf(await opsDb.execute(sql.raw(appGrantsRlsSql()))).map(rowToAppGranted);
|
|
712
|
-
// SECDEF functions (schema, name, search_path pinned?) — the `functions` teeth read these
|
|
713
|
-
// to assert every SECDEF function in a functions-written schema pins its search_path.
|
|
714
|
-
const secdefFunctions = rowsOf(await opsDb.execute(sql.raw(SECDEF_FUNCTIONS_SQL))).map(rowToSecdefFn);
|
|
715
|
-
// B4 — the derived layer's runtime echoes: PUBLIC-executable functions (NULL proacl
|
|
716
|
-
// included via acldefault) and superuser-only views/matviews.
|
|
717
|
-
const publicExecutableFunctions = rowsOf(await opsDb.execute(sql.raw(PUBLIC_EXECUTABLE_FUNCTIONS_SQL))).map(rowToPublicExecutableFn);
|
|
718
|
-
const darkRelations = rowsOf(await opsDb.execute(sql.raw(DARK_RELATIONS_SQL))).map(rowToDarkRelation);
|
|
719
|
-
|
|
720
|
-
// Ambient-access probe: can the api connection read an RLS table WITHOUT SET ROLE?
|
|
721
|
-
let apiAmbientRead: { table: string; accessible: boolean } | undefined;
|
|
722
|
-
if (forceRls.length > 0) {
|
|
723
|
-
const t = forceRls[0];
|
|
724
|
-
const ref = `${t.schema}.${t.table}`;
|
|
713
|
+
// Backups lens — is the pg_dump layer attached AND version-compatible? Lambda-only:
|
|
714
|
+
// a missing layer leaves pgDump null (the lens warns), never throws. Absent on the
|
|
715
|
+
// CLI direct venue, where there is no layer.
|
|
716
|
+
const backup = async (): Promise<import('./db-doctor').DoctorProbe['backup']> => {
|
|
725
717
|
try {
|
|
726
|
-
|
|
727
|
-
|
|
718
|
+
const { pgClientVersion, parsePgDumpMajor, serverMajorFromNum, isDumpCompatible } = await import('./backup');
|
|
719
|
+
let pgDump: string | null = null;
|
|
720
|
+
let pgDumpMajor: number | null = null;
|
|
721
|
+
try {
|
|
722
|
+
pgDump = await pgClientVersion('pg_dump');
|
|
723
|
+
pgDumpMajor = parsePgDumpMajor(pgDump);
|
|
724
|
+
} catch {
|
|
725
|
+
// layer absent — pgDump stays null, the lens reports "not attached"
|
|
726
|
+
}
|
|
727
|
+
const svnRow = rowsOf(await opsDb.execute(sql.raw('SHOW server_version_num')))[0];
|
|
728
|
+
const svn = Number(svnRow?.server_version_num);
|
|
729
|
+
const serverMajor = Number.isFinite(svn) ? serverMajorFromNum(svn) : null;
|
|
730
|
+
return {
|
|
731
|
+
pgDump,
|
|
732
|
+
pgDumpMajor,
|
|
733
|
+
serverMajor,
|
|
734
|
+
compatible: pgDumpMajor != null && Number.isFinite(svn) ? isDumpCompatible(pgDumpMajor, svn) : false,
|
|
735
|
+
};
|
|
728
736
|
} catch {
|
|
729
|
-
|
|
737
|
+
return undefined; // leave backup out → the lens is skipped, like the other optional probes
|
|
730
738
|
}
|
|
731
|
-
}
|
|
739
|
+
};
|
|
732
740
|
|
|
733
|
-
// Backups lens — is the pg_dump layer attached AND version-compatible? Reuse the
|
|
734
|
-
// db:backup:probe internals; a missing layer leaves pgDump null (the lens warns), never throws.
|
|
735
|
-
let backup: import('./db-doctor').DoctorProbe['backup'];
|
|
736
741
|
try {
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
const serverMajor = Number.isFinite(svn) ? serverMajorFromNum(svn) : null;
|
|
749
|
-
backup = {
|
|
750
|
-
pgDump,
|
|
751
|
-
pgDumpMajor,
|
|
752
|
-
serverMajor,
|
|
753
|
-
compatible: pgDumpMajor != null && Number.isFinite(svn) ? isDumpCompatible(pgDumpMajor, svn) : false,
|
|
754
|
-
};
|
|
755
|
-
} catch {
|
|
756
|
-
// leave backup undefined → the lens is skipped, exactly like the other optional probes
|
|
742
|
+
return await runDoctorProbe(
|
|
743
|
+
{ api: apiRun, ops: opsRun },
|
|
744
|
+
{
|
|
745
|
+
apiSourceLabel: 'DATABASE_URL',
|
|
746
|
+
forceRlsCarveouts: options.forceRlsCarveouts,
|
|
747
|
+
functionSchemas: options.functionSchemas,
|
|
748
|
+
backup,
|
|
749
|
+
},
|
|
750
|
+
);
|
|
751
|
+
} catch (err: any) {
|
|
752
|
+
return { error: err?.message || String(err) };
|
|
757
753
|
}
|
|
758
|
-
|
|
759
|
-
return buildDoctorReport(
|
|
760
|
-
{ api, admin, forceRls, appGrantedRls, apiAmbientRead, secdefFunctions, publicExecutableFunctions, darkRelations, backup },
|
|
761
|
-
{ forceRlsCarveouts: options.forceRlsCarveouts, functionSchemas: options.functionSchemas },
|
|
762
|
-
);
|
|
763
754
|
};
|
|
764
755
|
|
|
765
756
|
// --- db:provision — BOTH sides of the credential split, in the safe order ---
|
|
@@ -978,6 +969,71 @@ export function dbPlugin(options: DbPluginOptions): Plugin {
|
|
|
978
969
|
}
|
|
979
970
|
};
|
|
980
971
|
|
|
972
|
+
// --- db:reconcile — the credential-free DERIVED-layer apply (the db:apply twin) ---
|
|
973
|
+
// The STATE edge (db:apply) runs in the ops Lambda; this is the DERIVED edge's lane so a
|
|
974
|
+
// least-privileged operator never needs the raw admin URL to deploy functions/views/matviews.
|
|
975
|
+
// Unlike db:apply's portable plan, a reconcile plan needs LIVE introspection (source hash vs
|
|
976
|
+
// live def hash), so the whole executeReconcile runs HERE on the operator connection: the CLI
|
|
977
|
+
// ships only the declared derived objects (bundle-independent — they come from the models
|
|
978
|
+
// barrel), and this introspects → plans → applies, emitting its own SET search_path. The DDL
|
|
979
|
+
// batch and its provenance/schema_log land in one transaction, same as the direct path.
|
|
980
|
+
actions['db:reconcile'] = async (payload, _ctx) => {
|
|
981
|
+
const p = (payload ?? {}) as {
|
|
982
|
+
declared?: any[];
|
|
983
|
+
renamedTables?: any;
|
|
984
|
+
apply?: boolean;
|
|
985
|
+
baseline?: boolean;
|
|
986
|
+
rebaseline?: boolean;
|
|
987
|
+
rebuild?: boolean;
|
|
988
|
+
overwriteDrift?: boolean;
|
|
989
|
+
actor?: string | null;
|
|
990
|
+
gitRef?: string | null;
|
|
991
|
+
};
|
|
992
|
+
try {
|
|
993
|
+
const { executeReconcile } = await import('@everystack/cli/reconcile');
|
|
994
|
+
const { sql } = await import('drizzle-orm');
|
|
995
|
+
const runner = async (query: string): Promise<any[]> => {
|
|
996
|
+
const result: any = await opsDb.execute(sql.raw(query));
|
|
997
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
998
|
+
};
|
|
999
|
+
const run = await executeReconcile(runner, {
|
|
1000
|
+
declared: p.declared,
|
|
1001
|
+
renamedTables: p.renamedTables,
|
|
1002
|
+
apply: p.apply,
|
|
1003
|
+
baseline: p.baseline,
|
|
1004
|
+
rebaseline: p.rebaseline,
|
|
1005
|
+
rebuild: p.rebuild,
|
|
1006
|
+
overwriteDrift: p.overwriteDrift,
|
|
1007
|
+
actor: p.actor ?? null,
|
|
1008
|
+
gitRef: p.gitRef ?? null,
|
|
1009
|
+
} as any);
|
|
1010
|
+
return { plan: run.plan, applied: run.applied, statements: run.statements, refusal: run.refusal ?? null };
|
|
1011
|
+
} catch (err: any) {
|
|
1012
|
+
return { error: err?.message || String(err) };
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
|
|
1016
|
+
// --- db:refresh — the credential-free DATA lane's refresh half (the reconcile-lane twin) ---
|
|
1017
|
+
// db:reconcile deploys the derived DEFINITION but no-ops on an unchanged def, so a pure
|
|
1018
|
+
// DATA-changed matview refresh had no credential-free path. This runs the refresh HERE on
|
|
1019
|
+
// the operator connection: the CLI ships the declared matview identities in dependency order,
|
|
1020
|
+
// this REFRESHes each. A least-privileged operator never holds a URL for the routine refresh.
|
|
1021
|
+
actions['db:refresh'] = async (payload, _ctx) => {
|
|
1022
|
+
const p = (payload ?? {}) as { matviews?: string[] };
|
|
1023
|
+
try {
|
|
1024
|
+
const { executeRefresh } = await import('@everystack/cli/refresh');
|
|
1025
|
+
const { sql } = await import('drizzle-orm');
|
|
1026
|
+
const runner = async (query: string): Promise<any[]> => {
|
|
1027
|
+
const result: any = await opsDb.execute(sql.raw(query));
|
|
1028
|
+
return Array.isArray(result) ? result : result?.rows ?? [];
|
|
1029
|
+
};
|
|
1030
|
+
const run = await executeRefresh(runner, { matviews: p.matviews ?? [] });
|
|
1031
|
+
return { refreshed: run.refreshed, statements: run.statements, failed: run.failed ?? null };
|
|
1032
|
+
} catch (err: any) {
|
|
1033
|
+
return { error: err?.message || String(err) };
|
|
1034
|
+
}
|
|
1035
|
+
};
|
|
1036
|
+
|
|
981
1037
|
// --- pipeline:run — the credential-free ingest reproduction (Phase 3, Brick 5) ---
|
|
982
1038
|
// The orchestrator runs HERE, in the ops Lambda, over the operator connection: a
|
|
983
1039
|
// deployed stage's small curated overlays reproduce via IAM, never a held URL. The
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/server/sst-resources — read SST-linked resources WITHOUT importing `sst`.
|
|
3
|
+
*
|
|
4
|
+
* `sst` is a deploy-time construct: importing it makes @everystack/server unbundleable
|
|
5
|
+
* (Metro) and trips SST's own build-time version-check plugin. So we read the resources the
|
|
6
|
+
* way SST delivers them, mirroring `sst/dist/resource/node.js` exactly:
|
|
7
|
+
*
|
|
8
|
+
* 1. per-name env — `process.env.SST_RESOURCE_<Name>` (JSON)
|
|
9
|
+
* 2. the bundled all-resources JSON — `process.env.SST_RESOURCES_JSON`
|
|
10
|
+
* 3. the ENCRYPTED key file — `SST_KEY_FILE` + `SST_KEY` (SST >= ~4.13's Lambda default,
|
|
11
|
+
* aes-256-gcm, 12-byte zero nonce, 16-byte auth tag appended)
|
|
12
|
+
*
|
|
13
|
+
* (1) alone — the 0.4.5 sst-removal's mistake — misses everything on a modern SST Lambda,
|
|
14
|
+
* where only `SST_RESOURCE_App` is plain env and every real link rides the encrypted blob.
|
|
15
|
+
* `node:crypto`/`node:fs` are builtins already shipped in this package's server-bundle path
|
|
16
|
+
* (ssr.ts, backup-run.ts) — never the `sst` package, so nothing here breaks a bundle.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { readFileSync } from 'node:fs';
|
|
21
|
+
|
|
22
|
+
let cache: Record<string, any> | undefined;
|
|
23
|
+
|
|
24
|
+
/** Decrypt the SST key file exactly as sst/dist/resource/node.js does. Throws on any format
|
|
25
|
+
* mismatch (bad key, truncated file) — the caller swallows it and uses the other sources. */
|
|
26
|
+
function decryptKeyFile(keyFile: string, keyB64: string): Record<string, any> {
|
|
27
|
+
const key = Buffer.from(keyB64, 'base64');
|
|
28
|
+
const encrypted = readFileSync(keyFile);
|
|
29
|
+
const nonce = Buffer.alloc(12, 0);
|
|
30
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', key, nonce);
|
|
31
|
+
decipher.setAuthTag(encrypted.subarray(-16));
|
|
32
|
+
const decrypted = Buffer.concat([decipher.update(encrypted.subarray(0, -16)), decipher.final()]);
|
|
33
|
+
return JSON.parse(decrypted.toString());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The merged SST resource map, loaded once and cached (resources are container-stable on a
|
|
38
|
+
* Lambda). Every source is best-effort — a malformed one is skipped, never fatal — so an app
|
|
39
|
+
* that only ever sets plain env still resolves what it has, and local/Metro (no SST env) gets
|
|
40
|
+
* an empty map. Later sources win, matching SST's own precedence.
|
|
41
|
+
*/
|
|
42
|
+
export function loadSstResources(): Record<string, any> {
|
|
43
|
+
if (cache) return cache;
|
|
44
|
+
const raw: Record<string, any> = {};
|
|
45
|
+
const env = process.env;
|
|
46
|
+
|
|
47
|
+
for (const [k, v] of Object.entries(env)) {
|
|
48
|
+
if (v && k.startsWith('SST_RESOURCE_')) {
|
|
49
|
+
try {
|
|
50
|
+
raw[k.slice('SST_RESOURCE_'.length)] = JSON.parse(v);
|
|
51
|
+
} catch {
|
|
52
|
+
/* not JSON — skip this key */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (env.SST_RESOURCES_JSON) {
|
|
58
|
+
try {
|
|
59
|
+
Object.assign(raw, JSON.parse(env.SST_RESOURCES_JSON));
|
|
60
|
+
} catch {
|
|
61
|
+
/* malformed — skip */
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (env.SST_KEY_FILE && env.SST_KEY) {
|
|
66
|
+
try {
|
|
67
|
+
Object.assign(raw, decryptKeyFile(env.SST_KEY_FILE, env.SST_KEY));
|
|
68
|
+
} catch {
|
|
69
|
+
/* undecryptable — fall through to what the env already gave us */
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
cache = raw;
|
|
74
|
+
return raw;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Test seam: drop the cache so a fresh env is re-read. */
|
|
78
|
+
export function resetSstResourceCache(): void {
|
|
79
|
+
cache = undefined;
|
|
80
|
+
}
|