@everystack/cli 0.4.19 → 0.4.22
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 +5 -1
- package/src/cli/commands/db-reconcile.ts +47 -30
- package/src/cli/commands/db.ts +238 -22
- package/src/cli/commands/update.ts +58 -4
- package/src/cli/index.ts +2 -2
- package/src/reconcile.ts +11 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@everystack/cli",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.22",
|
|
4
4
|
"description": "CLI and OTA updates for Expo apps on everystack",
|
|
5
5
|
"license": "AGPL-3.0-only",
|
|
6
6
|
"author": "Scalable Technology, Inc. <licensing@scalable.technology>",
|
|
@@ -37,6 +37,10 @@
|
|
|
37
37
|
"types": "./src/cli/apply-execute.ts",
|
|
38
38
|
"default": "./src/cli/apply-execute.ts"
|
|
39
39
|
},
|
|
40
|
+
"./reconcile": {
|
|
41
|
+
"types": "./src/reconcile.ts",
|
|
42
|
+
"default": "./src/reconcile.ts"
|
|
43
|
+
},
|
|
40
44
|
"./audit/source": {
|
|
41
45
|
"types": "./src/cli/audit-source-api.ts",
|
|
42
46
|
"default": "./src/cli/audit-source-api.ts"
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
* explicit trust that live == source, recorded). A source RE-RENDER over an
|
|
24
24
|
* untouched live object — the db/sql-era → descriptor migration — needs
|
|
25
25
|
* `--rebaseline` once: live is verified (defHash match), the new source hash
|
|
26
|
-
* is re-recorded, nothing rebuilds. `--apply`
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* is re-recorded, nothing rebuilds. `--apply --stage` runs the whole reconcile
|
|
27
|
+
* credential-free in the ops Lambda on the operator connection (the `db:reconcile`
|
|
28
|
+
* action, the db:apply twin) — the deployer holds no URL. `--apply --database-url`
|
|
29
|
+
* runs it direct. A stage DRY-RUN (no `--apply`) reads through the `db:query` path.
|
|
30
30
|
*/
|
|
31
31
|
|
|
32
32
|
import type { QueryRunner } from '../authz-contract.js';
|
|
@@ -380,11 +380,6 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
380
380
|
process.exit(1);
|
|
381
381
|
}
|
|
382
382
|
|
|
383
|
-
if (apply && dbSource.kind !== 'url') {
|
|
384
|
-
fail('--apply needs a direct connection (--database-url or DATABASE_URL) — the ops Lambda query path is read-only by design.');
|
|
385
|
-
process.exit(1);
|
|
386
|
-
}
|
|
387
|
-
|
|
388
383
|
// --baseline only writes under --apply (executeReconcile returns the plan and executes nothing
|
|
389
384
|
// when apply=false). Passing it alone used to print "recording provenance…" and exit 0 while
|
|
390
385
|
// persisting nothing — a silent no-op. Fail loudly instead; the plan already lists needsBaseline.
|
|
@@ -416,29 +411,51 @@ export async function dbReconcileCommand(flags: Record<string, string>): Promise
|
|
|
416
411
|
info(`${declared.objects.length} declared derived object(s) from the models barrel.`);
|
|
417
412
|
}
|
|
418
413
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
414
|
+
const reconcileOptions = {
|
|
415
|
+
apply,
|
|
416
|
+
declared: declared?.objects,
|
|
417
|
+
renamedTables: declared?.renamedTables,
|
|
418
|
+
baseline: flags.baseline === 'true',
|
|
419
|
+
rebaseline: flags.rebaseline === 'true',
|
|
420
|
+
rebuild: flags.rebuild === 'true',
|
|
421
|
+
overwriteDrift: flags['overwrite-drift'] === 'true',
|
|
422
|
+
actor: process.env.USER ?? null,
|
|
423
|
+
gitRef: currentGitRef(),
|
|
424
|
+
};
|
|
429
425
|
|
|
426
|
+
// Resolve the run from one of two venues:
|
|
427
|
+
// - --apply on a deployed stage runs the WHOLE reconcile in the ops Lambda on the operator
|
|
428
|
+
// connection (the db:reconcile action) — a least-privileged operator needs no raw admin URL,
|
|
429
|
+
// the derived-edge twin of db:apply.
|
|
430
|
+
// - otherwise executeReconcile runs locally over a direct URL, or over the read-only db:query
|
|
431
|
+
// path for a stage DRY-RUN (planning only reads).
|
|
432
|
+
let end: (() => Promise<void>) | undefined;
|
|
430
433
|
try {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
434
|
+
let run: ReconcileRun;
|
|
435
|
+
if (apply && dbSource.kind !== 'url') {
|
|
436
|
+
step('Resolving deployed config...');
|
|
437
|
+
const config = await resolveConfig(flags.stage);
|
|
438
|
+
const result: any = await invokeAction(config.region, opsFunction(config), 'db:reconcile', reconcileOptions);
|
|
439
|
+
if (result?.error) {
|
|
440
|
+
fail(`db:reconcile failed: ${result.error}`);
|
|
441
|
+
if (/Unknown action/i.test(String(result.error))) {
|
|
442
|
+
info('The deployed handler predates the db:reconcile ops action (needs @everystack/server >= 0.4.8). Upgrade the server, or apply direct: db:reconcile --apply --database-url <url>.');
|
|
443
|
+
}
|
|
444
|
+
process.exit(1);
|
|
445
|
+
}
|
|
446
|
+
run = { plan: result.plan, applied: result.applied, statements: result.statements ?? [], refusal: result.refusal ?? undefined };
|
|
447
|
+
} else {
|
|
448
|
+
let runner: QueryRunner;
|
|
449
|
+
if (dbSource.kind === 'url') {
|
|
450
|
+
step(connectingVia(dbSource));
|
|
451
|
+
({ runner, end } = await createUrlRunner(dbSource.url));
|
|
452
|
+
} else {
|
|
453
|
+
step('Resolving deployed config...');
|
|
454
|
+
const config = await resolveConfig(flags.stage);
|
|
455
|
+
runner = lambdaRunner(config.region, opsFunction(config));
|
|
456
|
+
}
|
|
457
|
+
run = await executeReconcile(runner, reconcileOptions);
|
|
458
|
+
}
|
|
442
459
|
|
|
443
460
|
if (flags.json === 'true') {
|
|
444
461
|
console.log(JSON.stringify({ ...run.plan, applied: run.applied, refusal: run.refusal ?? null }, null, 2));
|
package/src/cli/commands/db.ts
CHANGED
|
@@ -207,9 +207,103 @@ export function buildProvisionSecretPlan(args: {
|
|
|
207
207
|
return { updates, notes, warnings };
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
/** The provisioning core's shape (mirrors @everystack/server/provision — no compile-time dep,
|
|
211
|
+
* same seam as pipeline-loader). */
|
|
212
|
+
export interface ServerProvision {
|
|
213
|
+
runProvision(
|
|
214
|
+
payload: { authPassword?: string; adminPassword?: string },
|
|
215
|
+
deps: {
|
|
216
|
+
execute: (sql: string) => Promise<unknown>;
|
|
217
|
+
connection?: { host: string; port?: number | string; database: string } | null;
|
|
218
|
+
connectProbe?: (url: string) => Promise<void>;
|
|
219
|
+
},
|
|
220
|
+
): Promise<any>;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Lazily load the provisioning core from the @everystack/server peer (runtime only —
|
|
225
|
+
* the CLI keeps no compile-time dependency on the server package). A missing peer gets
|
|
226
|
+
* the install remedy, not a raw ERR_MODULE_NOT_FOUND.
|
|
227
|
+
*/
|
|
228
|
+
export async function loadServerProvision(importer?: () => Promise<unknown>): Promise<ServerProvision> {
|
|
229
|
+
const spec = '@everystack/server/provision';
|
|
230
|
+
try {
|
|
231
|
+
return (await (importer ? importer() : import(spec))) as ServerProvision;
|
|
232
|
+
} catch (err: unknown) {
|
|
233
|
+
const code = (err as { code?: string })?.code;
|
|
234
|
+
const message = String((err as { message?: string })?.message ?? err);
|
|
235
|
+
if (code === 'ERR_MODULE_NOT_FOUND' || code === 'MODULE_NOT_FOUND' || /Cannot find (package|module)/.test(message)) {
|
|
236
|
+
throw new Error(
|
|
237
|
+
'db:provision --database-url needs @everystack/server (>= 0.4.6) installed in this app — '
|
|
238
|
+
+ 'it carries the provisioning logic:\n pnpm add @everystack/server',
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Resolve the DIRECT-venue privileged URL, keeping the master password OFF argv (shell
|
|
247
|
+
* history / ps / CI logs are all leak surfaces, and the whole point of provision is to STOP
|
|
248
|
+
* using master). An explicit --database-url wins; `--direct` reads the stage's already-stored
|
|
249
|
+
* ADMIN_DATABASE_URL secret; no flag returns null (the ops-Lambda venue). A consumer report:
|
|
250
|
+
* "any workflow that requires a human to re-handle the master DB password is both friction and
|
|
251
|
+
* a secret-leak point."
|
|
252
|
+
*/
|
|
253
|
+
export function resolveProvisionDirectUrl(args: {
|
|
254
|
+
flagUrl?: string;
|
|
255
|
+
direct?: boolean;
|
|
256
|
+
secrets: Record<string, string>;
|
|
257
|
+
}): { url: string; source: 'flag' | 'secret' } | null {
|
|
258
|
+
if (args.flagUrl) return { url: args.flagUrl, source: 'flag' };
|
|
259
|
+
if (args.direct) {
|
|
260
|
+
const url = args.secrets.ADMIN_DATABASE_URL ?? args.secrets.AdminDatabaseUrl;
|
|
261
|
+
if (!url) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
'--direct needs the stage\'s ADMIN_DATABASE_URL secret (the privileged URL) — set it with '
|
|
264
|
+
+ '`everystack secrets set ADMIN_DATABASE_URL <url> --stage <stage>`, or pass --database-url <url> once.',
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
return { url, source: 'secret' };
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The declared non-public schemas (sorted, public trailing) — the API connection's search_path.
|
|
273
|
+
* Empty for an all-public app, so its URL is left byte-identical. */
|
|
274
|
+
export function declaredSearchPath(schemas: string[]): string[] {
|
|
275
|
+
const nonPublic = [...new Set(schemas)].filter((s) => s && s !== 'public').sort();
|
|
276
|
+
return nonPublic.length ? [...nonPublic, 'public'] : [];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Bake the declared schemas into a postgres URL as `?search_path=…`. A multi-schema app's role
|
|
281
|
+
* defaults to `"$user",public`, so every bare-ref query 404s until a hand ALTER ROLE pin
|
|
282
|
+
* (recorded nowhere, repeated every stage) — writing it into the minted DATABASE_URL is
|
|
283
|
+
* declarative and survives redeploys. postgres.js forwards unknown URL params as connection
|
|
284
|
+
* startup params (GridironDB proved search_path this way). No-op for an all-public app.
|
|
285
|
+
*/
|
|
286
|
+
export function withSearchPath(url: string, schemas: string[]): string {
|
|
287
|
+
const path = declaredSearchPath(schemas);
|
|
288
|
+
if (!path.length) return url;
|
|
289
|
+
const u = new URL(url);
|
|
290
|
+
u.searchParams.set('search_path', path.join(','));
|
|
291
|
+
return u.toString();
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** host/port/database from a postgres URL — the direct venue's connection info. */
|
|
295
|
+
export function parseUrlConnection(url: string): { host: string; port: string; database: string } {
|
|
296
|
+
const u = new URL(url);
|
|
297
|
+
return {
|
|
298
|
+
host: u.hostname,
|
|
299
|
+
port: u.port || '5432',
|
|
300
|
+
database: u.pathname.replace(/^\//, ''),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
210
304
|
export async function dbProvisionCommand(flags: Record<string, string>): Promise<void> {
|
|
211
305
|
if (!flags.stage) {
|
|
212
|
-
fail('--stage is required for db:provision (it writes the DATABASE_URL secret
|
|
306
|
+
fail('--stage is required for db:provision (it writes the stage\'s DATABASE_URL secret — the new credentials are never displayed, so they must land in the secret store)');
|
|
213
307
|
process.exit(1);
|
|
214
308
|
}
|
|
215
309
|
|
|
@@ -222,7 +316,37 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
222
316
|
process.exit(1);
|
|
223
317
|
}
|
|
224
318
|
|
|
225
|
-
|
|
319
|
+
// Load the stage secrets once — the direct venue reads the privileged URL from here (never
|
|
320
|
+
// from argv), and the writer below reuses them.
|
|
321
|
+
const { loadSstSecrets, putSstSecrets } = await import('../utils/secrets.js');
|
|
322
|
+
const { parseAppName } = await import('../discover.js');
|
|
323
|
+
const secretsCtx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
324
|
+
let stageSecrets: Record<string, string> = {};
|
|
325
|
+
try {
|
|
326
|
+
stageSecrets = await loadSstSecrets(secretsCtx);
|
|
327
|
+
} catch {
|
|
328
|
+
// Secrets unreadable (e.g. store not bootstrapped) — direct-from-secret is simply unavailable;
|
|
329
|
+
// an explicit --database-url still works, and the writer below surfaces any real failure.
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Venue: DIRECT connection (bring-your-own-RDS / no Ops Lambda) vs the ops-Lambda invoke.
|
|
333
|
+
// --database-url is explicit; --direct reads the ADMIN_DATABASE_URL secret so master never
|
|
334
|
+
// lands on argv. No flag = ops-Lambda venue, with an auto-fallback to the secret-backed
|
|
335
|
+
// direct connection when the deployed handler has no dbPlugin. --stage always names the
|
|
336
|
+
// secret store the new credentials are written to.
|
|
337
|
+
let venue: { url: string; source: 'flag' | 'secret' } | null;
|
|
338
|
+
try {
|
|
339
|
+
venue = resolveProvisionDirectUrl({ flagUrl: flags['database-url'], direct: !!flags.direct, secrets: stageSecrets });
|
|
340
|
+
} catch (err: any) {
|
|
341
|
+
fail(err.message);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
let directUrl = venue?.url;
|
|
345
|
+
if (directUrl) {
|
|
346
|
+
info(`Region: ${config.region}, venue: direct connection (${venue!.source === 'secret' ? 'ADMIN_DATABASE_URL secret' : 'bring-your-own database'})`);
|
|
347
|
+
} else {
|
|
348
|
+
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
349
|
+
}
|
|
226
350
|
info('Creating the least-privilege role chain on your EXISTING database (no database is created).');
|
|
227
351
|
step('Provisioning roles...');
|
|
228
352
|
|
|
@@ -232,13 +356,71 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
232
356
|
const authPassword = randomBytes(24).toString('hex');
|
|
233
357
|
const adminPassword = randomBytes(24).toString('hex');
|
|
234
358
|
|
|
359
|
+
// Run the role chain over a direct connection to `url` — shared by the explicit direct venue
|
|
360
|
+
// and the auto-fallback below.
|
|
361
|
+
const provisionDirect = async (url: string): Promise<any> => {
|
|
362
|
+
const { runProvision } = await loadServerProvision();
|
|
363
|
+
const { createUrlRunner } = await import('../db-source.js');
|
|
364
|
+
const { runner, end } = await createUrlRunner(url);
|
|
365
|
+
try {
|
|
366
|
+
return await runProvision({ authPassword, adminPassword }, {
|
|
367
|
+
execute: async (statement) => runner(statement),
|
|
368
|
+
connection: parseUrlConnection(url),
|
|
369
|
+
// Same probe as the ops-Lambda venue: a real login as the new role, DDL-capable.
|
|
370
|
+
connectProbe: async (probeUrl) => {
|
|
371
|
+
const { default: postgres } = await import('postgres');
|
|
372
|
+
const probe = postgres(probeUrl, { max: 1, connect_timeout: 5, ssl: 'prefer' });
|
|
373
|
+
try {
|
|
374
|
+
await probe`SELECT 1`;
|
|
375
|
+
await probe.unsafe('CREATE TEMP TABLE _everystack_provision_probe (x int)');
|
|
376
|
+
await probe.unsafe('DROP TABLE _everystack_provision_probe');
|
|
377
|
+
} finally {
|
|
378
|
+
await probe.end({ timeout: 1 });
|
|
379
|
+
}
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
} finally {
|
|
383
|
+
await end?.();
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
|
|
235
387
|
let result: any;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
388
|
+
let conn: any;
|
|
389
|
+
if (directUrl) {
|
|
390
|
+
try {
|
|
391
|
+
result = await provisionDirect(directUrl);
|
|
392
|
+
} catch (err: any) {
|
|
393
|
+
fail(`db:provision failed: ${err.message}`);
|
|
394
|
+
process.exit(1);
|
|
395
|
+
}
|
|
396
|
+
conn = parseUrlConnection(directUrl);
|
|
397
|
+
} else {
|
|
398
|
+
try {
|
|
399
|
+
result = await invokeAction(config.region, opsFunction(config), 'db:provision', { authPassword, adminPassword });
|
|
400
|
+
} catch (err: any) {
|
|
401
|
+
// Auto-fallback: no Ops Lambda (Unknown action), but an ADMIN_DATABASE_URL secret exists —
|
|
402
|
+
// provision directly over it. Master never crosses the CLI; the operator sets nothing new.
|
|
403
|
+
const secretUrl = stageSecrets.ADMIN_DATABASE_URL ?? stageSecrets.AdminDatabaseUrl;
|
|
404
|
+
if (/Unknown action/i.test(String(err.message)) && secretUrl) {
|
|
405
|
+
info('No Ops Lambda (the deployed handler has no dbPlugin) — provisioning directly over the ADMIN_DATABASE_URL secret.');
|
|
406
|
+
try {
|
|
407
|
+
result = await provisionDirect(secretUrl);
|
|
408
|
+
directUrl = secretUrl;
|
|
409
|
+
conn = parseUrlConnection(secretUrl);
|
|
410
|
+
} catch (err2: any) {
|
|
411
|
+
fail(`db:provision failed: ${err2.message}`);
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
} else {
|
|
415
|
+
fail(`db:provision failed: ${err.message}`);
|
|
416
|
+
if (/Unknown action/i.test(String(err.message))) {
|
|
417
|
+
info('The deployed handler has no dbPlugin (no Ops Lambda), and no ADMIN_DATABASE_URL secret is set.');
|
|
418
|
+
info('Provision directly: everystack db:provision --stage <stage> --database-url <master-url> (once), or set the ADMIN_DATABASE_URL secret and use --direct.');
|
|
419
|
+
}
|
|
420
|
+
for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
421
|
+
process.exit(1);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
242
424
|
}
|
|
243
425
|
if (result?.error) {
|
|
244
426
|
// Includes the admin-verification refusal: the action set NO authenticator password,
|
|
@@ -252,35 +434,60 @@ export async function dbProvisionCommand(flags: Record<string, string>): Promise
|
|
|
252
434
|
}
|
|
253
435
|
|
|
254
436
|
// Resolve host/port/db (not secret) to build the connection strings in memory.
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
437
|
+
// Direct venue already knows them from the URL itself.
|
|
438
|
+
if (!directUrl) {
|
|
439
|
+
try {
|
|
440
|
+
conn = await invokeAction(config.region, opsFunction(config), 'db:psql', {});
|
|
441
|
+
} catch {
|
|
442
|
+
/* handled below */
|
|
443
|
+
}
|
|
260
444
|
}
|
|
261
445
|
if (!conn?.host) {
|
|
262
446
|
fail('Could not resolve the database host to build DATABASE_URL.');
|
|
263
447
|
info('Provide a db:psql connectionInfo callback, or set the DATABASE_URL secret yourself.');
|
|
264
448
|
process.exit(1);
|
|
265
449
|
}
|
|
266
|
-
|
|
450
|
+
// Echo the NON-SECRET target so the operator can confirm which database the secrets landed on,
|
|
451
|
+
// WITHOUT psql'ing the master in to check the prompt (a consumer burned time on a phantom
|
|
452
|
+
// wrong-database scare, and that check meant re-handling the master credential).
|
|
453
|
+
success(`Target database: ${conn.database} @ ${conn.host}:${conn.port ?? 5432} — secrets for stage "${flags.stage}" will point here.`);
|
|
454
|
+
|
|
455
|
+
// Bake the app's declared schemas into the minted URLs, so a multi-schema API connects with
|
|
456
|
+
// the right search_path instead of going dark on bare refs (best-effort: a modelless app or
|
|
457
|
+
// an all-public one changes nothing).
|
|
458
|
+
let declaredSchemas: string[] = [];
|
|
459
|
+
try {
|
|
460
|
+
const { resolveModelsPath } = await import('../models-path.js');
|
|
461
|
+
const { loadModels } = await import('./db-generate.js');
|
|
462
|
+
const models = await loadModels(resolveModelsPath(flags.models));
|
|
463
|
+
declaredSchemas = models.map((m: any) => m.schema ?? 'public');
|
|
464
|
+
} catch {
|
|
465
|
+
// No models resolvable here — leave the URLs schema-agnostic.
|
|
466
|
+
}
|
|
467
|
+
const searchPath = declaredSearchPath(declaredSchemas);
|
|
468
|
+
if (searchPath.length) info(` search_path baked into DATABASE_URL: ${searchPath.join(', ')}`);
|
|
469
|
+
|
|
470
|
+
const authUrl = withSearchPath(
|
|
471
|
+
`postgresql://${result.loginRole}:${authPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`,
|
|
472
|
+
declaredSchemas,
|
|
473
|
+
);
|
|
267
474
|
const adminUrl = result.adminRole
|
|
268
|
-
?
|
|
475
|
+
? withSearchPath(
|
|
476
|
+
`postgresql://${result.adminRole}:${adminPassword}@${conn.host}:${conn.port ?? 5432}/${conn.database}`,
|
|
477
|
+
declaredSchemas,
|
|
478
|
+
)
|
|
269
479
|
: undefined;
|
|
270
480
|
|
|
271
481
|
// Write the secrets directly to the SST secret store — never display them. One blob
|
|
272
482
|
// write updates every key together, so there is no window where the API side is the
|
|
273
|
-
// authenticator but the operator side is still unset.
|
|
483
|
+
// authenticator but the operator side is still unset. Reuse the secrets loaded up front.
|
|
274
484
|
step('Writing the database secrets (not displayed)...');
|
|
275
485
|
let plan: ProvisionSecretPlan;
|
|
276
486
|
try {
|
|
277
|
-
const {
|
|
278
|
-
const { parseAppName } = await import('../discover.js');
|
|
279
|
-
const ctx = { appName: await parseAppName(), stage: flags.stage, region: config.region };
|
|
280
|
-
const secrets = await loadSstSecrets(ctx);
|
|
487
|
+
const secrets = { ...stageSecrets };
|
|
281
488
|
plan = buildProvisionSecretPlan({ result, authUrl, adminUrl, existing: secrets });
|
|
282
489
|
Object.assign(secrets, plan.updates);
|
|
283
|
-
await putSstSecrets(
|
|
490
|
+
await putSstSecrets(secretsCtx, secrets);
|
|
284
491
|
} catch (err: any) {
|
|
285
492
|
fail(`Failed to write the database secrets: ${err.message}`);
|
|
286
493
|
info('Ensure your IAM role can write the SST secret store (s3:PutObject, kms:Encrypt).');
|
|
@@ -321,17 +528,26 @@ export async function dbDoctorCommand(flags: Record<string, string>): Promise<vo
|
|
|
321
528
|
info(`Region: ${config.region}, Function: ${opsFunction(config)}`);
|
|
322
529
|
step('Probing database connections (api + operator)...');
|
|
323
530
|
|
|
531
|
+
const noOpsHint = () => {
|
|
532
|
+
// Name the cause, not the internal dispatch: a bring-your-own-DB app has no Ops Lambda, so
|
|
533
|
+
// db:doctor's action can't be dispatched. It has no direct venue yet — say so plainly.
|
|
534
|
+
info('This deployed handler has no dbPlugin (no Ops Lambda), so db:doctor has nothing to run against.');
|
|
535
|
+
info('db:doctor has no --database-url venue yet (unlike db:provision). To verify a bring-your-own database meanwhile, apply grants with `db:reconcile --apply --database-url <url>` and inspect roles/grants with psql. A direct db:doctor venue is on the way.');
|
|
536
|
+
};
|
|
537
|
+
|
|
324
538
|
let report: any;
|
|
325
539
|
try {
|
|
326
540
|
report = await invokeAction(config.region, opsFunction(config), 'db:doctor', {});
|
|
327
541
|
} catch (err: any) {
|
|
328
542
|
fail(`db:doctor failed: ${err.message}`);
|
|
329
|
-
|
|
543
|
+
if (/Unknown action/i.test(String(err.message))) noOpsHint();
|
|
544
|
+
else for (const line of opsAdviceLines(err, [IAM_ADVICE])) info(line);
|
|
330
545
|
process.exit(1);
|
|
331
546
|
}
|
|
332
547
|
|
|
333
548
|
if (report?.error) {
|
|
334
549
|
fail(`db:doctor failed: ${report.error}`);
|
|
550
|
+
if (/Unknown action/i.test(String(report.error))) noOpsHint();
|
|
335
551
|
process.exit(1);
|
|
336
552
|
}
|
|
337
553
|
|
|
@@ -32,6 +32,46 @@ export interface UpdateFlags {
|
|
|
32
32
|
export?: string;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
/**
|
|
36
|
+
* The runtimeVersion as a STRING — the only shape the release path can carry.
|
|
37
|
+
* Expo's config allows a policy object; interpolating one into the S3 key
|
|
38
|
+
* produced `releases/dev/[object Object]/…` (a consumer field report). Locally
|
|
39
|
+
* derivable policies resolve here; the build-time ones (fingerprint,
|
|
40
|
+
* nativeVersion — computed by native tooling, undefined for a web publish)
|
|
41
|
+
* are refused with the remedies instead of silently corrupting the key.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveRuntimeVersion(appConfig: {
|
|
44
|
+
runtimeVersion?: unknown;
|
|
45
|
+
version?: string;
|
|
46
|
+
sdkVersion?: string;
|
|
47
|
+
}): string {
|
|
48
|
+
const rv = appConfig.runtimeVersion;
|
|
49
|
+
if (typeof rv === 'string') return rv;
|
|
50
|
+
const policy = (rv as { policy?: string } | undefined)?.policy;
|
|
51
|
+
if (policy === 'appVersion') {
|
|
52
|
+
if (!appConfig.version) {
|
|
53
|
+
throw new Error("runtimeVersion policy 'appVersion' needs a \"version\" field in app.json — add one, or set an explicit string runtimeVersion.");
|
|
54
|
+
}
|
|
55
|
+
return appConfig.version;
|
|
56
|
+
}
|
|
57
|
+
if (policy === 'sdkVersion') {
|
|
58
|
+
if (!appConfig.sdkVersion) {
|
|
59
|
+
throw new Error("runtimeVersion policy 'sdkVersion' needs an \"sdkVersion\" field in app.json — add one, or set an explicit string runtimeVersion.");
|
|
60
|
+
}
|
|
61
|
+
return appConfig.sdkVersion;
|
|
62
|
+
}
|
|
63
|
+
if (policy === 'fingerprint' || policy === 'nativeVersion') {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`runtimeVersion policy '${policy}' is computed by the native build tooling (EAS / @expo/fingerprint) — `
|
|
66
|
+
+ 'everystack update cannot resolve it, and for a web publish it is undefined. '
|
|
67
|
+
+ 'Use an explicit string runtimeVersion, or { "policy": "appVersion" } to track your app version.',
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
throw new Error(
|
|
71
|
+
`Unsupported runtimeVersion ${JSON.stringify(rv)} — use an explicit string, { "policy": "appVersion" }, or { "policy": "sdkVersion" }.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
35
75
|
export async function updateCommand(flags: UpdateFlags & Record<string, string>): Promise<void> {
|
|
36
76
|
// Signal publish context so app.config.js can detect it's not a dev session.
|
|
37
77
|
// Without this, dotenv guards that skip .env.local for EAS_BUILD/EAS_UPDATE
|
|
@@ -98,15 +138,22 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
98
138
|
// Read app config
|
|
99
139
|
step('Reading app config...');
|
|
100
140
|
const appConfig = await loadAppConfig();
|
|
101
|
-
const runtimeVersion = appConfig.runtimeVersion;
|
|
102
141
|
|
|
103
|
-
if (!runtimeVersion) {
|
|
142
|
+
if (!appConfig.runtimeVersion) {
|
|
104
143
|
fail('No runtimeVersion found in app.json/app.config.js');
|
|
105
144
|
info('Add "runtimeVersion" to your app.json, e.g.:');
|
|
106
|
-
info(' { "expo": { "runtimeVersion":
|
|
145
|
+
info(' { "expo": { "runtimeVersion": "1.0.0" } } (explicit string)');
|
|
146
|
+
info(' { "expo": { "runtimeVersion": { "policy": "appVersion" } } } (tracks "version")');
|
|
107
147
|
info('See: https://docs.expo.dev/eas-update/runtime-versions/');
|
|
108
148
|
process.exit(1);
|
|
109
149
|
}
|
|
150
|
+
let runtimeVersion: string;
|
|
151
|
+
try {
|
|
152
|
+
runtimeVersion = resolveRuntimeVersion(appConfig);
|
|
153
|
+
} catch (err: any) {
|
|
154
|
+
fail(err.message);
|
|
155
|
+
process.exit(1);
|
|
156
|
+
}
|
|
110
157
|
success(`Runtime version: ${runtimeVersion}`);
|
|
111
158
|
|
|
112
159
|
const distDir = path.resolve('dist');
|
|
@@ -561,13 +608,20 @@ export async function updateCommand(flags: UpdateFlags & Record<string, string>)
|
|
|
561
608
|
);
|
|
562
609
|
if (result?.error) {
|
|
563
610
|
info(`DB mirror skipped: ${result.error}`);
|
|
611
|
+
// The dispatcher answers Unknown action as result.error, not a throw — explain
|
|
612
|
+
// there too, or the miss reads like a failure with no remedy.
|
|
613
|
+
if (/Unknown action/i.test(String(result.error))) {
|
|
614
|
+
info('This is optional — the S3 manifests are the source of truth for serving.');
|
|
615
|
+
info('For DB-backed release tracking, add updatesPlugin (from @everystack/cli/plugin) to the deployed handler.');
|
|
616
|
+
}
|
|
564
617
|
} else {
|
|
565
618
|
info(`Release mirrored to DB: channel=${result?.channel || channel}`);
|
|
566
619
|
}
|
|
567
620
|
} catch (err: any) {
|
|
568
621
|
// register-web is optional — the S3 manifests are the source of truth.
|
|
569
622
|
if (err.message?.includes('Unknown action')) {
|
|
570
|
-
info('
|
|
623
|
+
info('DB mirror skipped (optional — S3 manifests are the source of truth).');
|
|
624
|
+
info('For DB-backed release tracking, add updatesPlugin (from @everystack/cli/plugin) to the deployed handler.');
|
|
571
625
|
} else {
|
|
572
626
|
info(`DB mirror skipped: ${err.message}`);
|
|
573
627
|
}
|
package/src/cli/index.ts
CHANGED
|
@@ -353,7 +353,7 @@ Usage:
|
|
|
353
353
|
everystack db:psql --stage <name> Interactive ADMIN psql (IAM-gated; resolves the admin URL in-process)
|
|
354
354
|
everystack db:psql [--stage <name>] -c <command> Run one query via Lambda (works for private RDS)
|
|
355
355
|
everystack db:doctor [--stage <name>] Check the DB is least-privilege + RLS-subject (api vs operator connection)
|
|
356
|
-
everystack db:provision
|
|
356
|
+
everystack db:provision --stage <name> [--direct | --database-url <url>] Create the least-privilege role chain on an EXISTING database (idempotent; creates no DB). No flag = ops-Lambda venue (auto-falls to direct via the ADMIN_DATABASE_URL secret if the handler has no dbPlugin). --direct = direct connection reading that secret (master never on argv); --database-url = explicit URL. Declared schemas are baked into the minted DATABASE_URL as search_path
|
|
357
357
|
everystack db:snapshot [--stage <name>] [--instance <id>] Take a physical RDS snapshot (instant DR point; RDS only — use db:backup for portable logical backups)
|
|
358
358
|
everystack db:snapshots [--stage <name>] [--instance <id>] List manual RDS snapshots for the instance
|
|
359
359
|
everystack db:backup:probe [--stage <name>] Verify the pg_dump layer is attached + version-compatible with the server
|
|
@@ -367,7 +367,7 @@ Usage:
|
|
|
367
367
|
everystack db:pull [--stage <name> | --database-url <url>] [--schema public] [--out <dir | file.ts>] [--derived-out <file.ts>] [--abilities public-read] Introspect the live DB → render field() Models (the brownfield on-ramp). --out <dir> writes one file per model + index.ts (the default shape); --out <file.ts> writes a single module; stdout otherwise. --derived-out <file.ts> extracts the derived layer (descriptors + sequences) as its own self-contained module — alone it leaves the models untouched (the hand-maintained-barrel splice); with --out the models render omits the now-external derived layer. Every model scaffolds its authz decision as comments (db:check fails until authored); --abilities public-read stamps the common stanza (public read, admin write) uncommented — explicit generated code, never a runtime default. --matviews-as-tables renders every matview as defineMaterializedTable with INTROSPECTED fields (the canonical-sync flip: a pipeline-owned table everystack migrates but never refreshes) — names land in an exported materializedTables array to spread into your models; fields come back nullable/unkeyed (matviews carry no PK/NOT NULL) — tighten on review; add --suggest-keys to probe the LIVE rows for functionally-unique columns (one scan per matview) and surface each as a commented .primaryKey() suggestion. docs/derived-objects.md#flipping-a-matview-to-a-materialized-table---matviews-as-tables
|
|
368
368
|
Both introspect via the deployed ops Lambda by default; --database-url (or an inherited DATABASE_URL) connects directly — for a schema that exists only on a local Postgres.
|
|
369
369
|
everystack db:fingerprint [--stage <name> | --database-url <url>] [--models <barrel>] [--json] Content-address the live base schema (tables+constraints+authz) and compare against the models — MATCH/MISMATCH (exit 1), plus the unfingerprinted-objects report
|
|
370
|
-
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (
|
|
370
|
+
everystack db:reconcile [--stage <name> | --database-url <url>] [--apply] [--check] [--baseline] [--rebuild] [--overwrite-drift] [--json] Reconcile the derived layer (functions/views/matviews/triggers) against the DECLARED descriptors (defineView/defineMaterializedView/defineFunction/defineSql/trigger() on models, from the barrel) — the single home (db/sql is retired; leftover .sql files fail with the migration path): plan with rebuild-cost estimates by default; --check is the CI gate; --apply executes (atomic — DDL + provenance in one transaction) and records provenance + schema_log; --apply --stage runs credential-free in the ops Lambda (no admin URL on the deployer, the db:apply twin), --apply --database-url runs direct. Hand-edits are drift (never overwritten silently). First contact with existing objects: --baseline TRUSTS live == source (records provenance, verifies nothing), --rebuild GUARANTEES it (drop+create from source). They are mutually exclusive.
|
|
371
371
|
everystack db:sync [--database-url <url>] [--models <barrel>] [--schema-out <file.ts>] [--allow-drops] [--overwrite-drift] [--baseline] [--json] Make the database match your checkout — one verb, both layers: apply the state diff (tables+authz, one transaction, verified by re-diff), reconcile the derived layer against the declared descriptors, report the resulting fingerprint vs the models' declared one. Dev databases only (direct connection required); DROPs held back unless --allow-drops; derived drift refuses unless --overwrite-drift; exit 1 when not converged
|
|
372
372
|
everystack db:diff --from-models <barrel> [--to-models db/models/index.ts] [--allow-drops] [--check] [--json] The state edge between two declared states, NO database: the SQL db:generate would produce, computed purely — CI plan previews (--check exits 1 on a non-empty edge) and computed rollbacks (swap the flags)
|
|
373
373
|
everystack db:plan [--stage <name> | --database-url <url>] [--models <barrel>] [--allow-drops] [--out db.plan.json | --out -] Mint a verified edge against a target: asks the TARGET its fingerprint, diffs the models, writes ONE reviewable plan (edge + both endpoint fingerprints). Held drops refuse the mint (--allow-drops carries destruction explicitly). Read-only — works via the ops Lambda; plans are ephemeral, never committed
|
package/src/reconcile.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @everystack/cli/reconcile — the derived-layer reconcile core, for the ops-Lambda lane.
|
|
3
|
+
*
|
|
4
|
+
* db:apply runs the STATE edge credential-free in the ops Lambda (@everystack/cli/apply). This is
|
|
5
|
+
* its DERIVED-edge twin: the ops `db:reconcile` action loads executeReconcile here and runs the
|
|
6
|
+
* whole reconcile (introspect → plan → apply) on the operator connection, so `db:reconcile --apply
|
|
7
|
+
* --stage` needs no raw admin URL on the deployer's machine.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export { executeReconcile, buildReconcileReport } from './cli/commands/db-reconcile.js';
|
|
11
|
+
export type { ReconcileRun, ExecuteOptions } from './cli/commands/db-reconcile.js';
|