@selvajs/cli 4.20.0-beta.22 → 4.20.0-beta.23

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@selvajs/cli",
3
- "version": "4.20.0-beta.22",
3
+ "version": "4.20.0-beta.23",
4
4
  "description": "Scaffold and operate a Selva white-label deployment. `npx @selvajs/cli <dir>` to bootstrap, `selva <cmd>` to manage.",
5
5
  "license": "MIT",
6
6
  "author": "VektorNode",
@@ -0,0 +1,156 @@
1
+ // Supabase schema head: what the installed runtime expects, what the installed
2
+ // provider can migrate to, and what the database is at. These are three
3
+ // separate numbers. The runtime bundles the provider's code but not its SQL,
4
+ // so `selva update` can move the runtime past a provider install that stays
5
+ // pinned. Comparing the database against the provider then reports a match
6
+ // while the app degrades /api/health to 503.
7
+
8
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { green, yellow, red } from './result.js';
11
+
12
+ const HEAD_RE = /^\d{14}$/;
13
+
14
+ /** Head the installed @selvajs/selva was built against, or null for a runtime that predates runtime-meta.json. */
15
+ export function readRuntimeMigrationHead(dir) {
16
+ try {
17
+ const meta = JSON.parse(
18
+ readFileSync(
19
+ join(dir, 'node_modules', '@selvajs', 'selva', 'build', 'runtime-meta.json'),
20
+ 'utf8'
21
+ )
22
+ );
23
+ return HEAD_RE.test(meta?.supabaseMigrationHead) ? meta.supabaseMigrationHead : null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /** Newest migration the installed @selvajs/supabase-provider ships, or null when it isn't installed. */
30
+ export function readProviderMigrationHead(dir) {
31
+ const migrationsDir = join(
32
+ dir,
33
+ 'node_modules',
34
+ '@selvajs',
35
+ 'supabase-provider',
36
+ 'supabase',
37
+ 'migrations'
38
+ );
39
+ if (!existsSync(migrationsDir)) return null;
40
+ return (
41
+ readdirSync(migrationsDir)
42
+ .filter((f) => /^\d{14}_.+\.sql$/.test(f))
43
+ .map((f) => f.slice(0, 14))
44
+ .sort()
45
+ .pop() ?? null
46
+ );
47
+ }
48
+
49
+ /** The npm dist-tag matching the installed runtime: a pre-release runtime needs the provider's beta migrations. */
50
+ export function providerTagFor(runtimeVersion) {
51
+ return runtimeVersion?.includes('-') ? 'beta' : 'latest';
52
+ }
53
+
54
+ /**
55
+ * `{ head }` on success, `{ missing: true }` when selva.migration_head() doesn't
56
+ * exist yet, `{ error }` when the database couldn't be asked.
57
+ */
58
+ export async function fetchDatabaseMigrationHead(env) {
59
+ try {
60
+ const controller = new AbortController();
61
+ const timer = setTimeout(() => controller.abort(), 4000);
62
+ const res = await fetch(env.SUPABASE_URL + '/rest/v1/rpc/migration_head', {
63
+ method: 'POST',
64
+ headers: {
65
+ apikey: env.SUPABASE_SERVICE_ROLE_KEY,
66
+ Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
67
+ 'Content-Type': 'application/json',
68
+ // The function lives in the selva schema the app is pinned to.
69
+ 'Content-Profile': 'selva'
70
+ },
71
+ body: '{}',
72
+ signal: controller.signal
73
+ });
74
+ clearTimeout(timer);
75
+ if (res.status === 404) return { missing: true };
76
+ if (!res.ok) return { error: `migration_head RPC responded ${res.status}` };
77
+ return { head: String((await res.json()) ?? '') };
78
+ } catch (err) {
79
+ return { error: err.message ?? String(err) };
80
+ }
81
+ }
82
+
83
+ function migrateSteps(tag, providerBehind) {
84
+ return [
85
+ ...(providerBehind ? [`npm install --prefer-online @selvajs/supabase-provider@${tag}`] : []),
86
+ 'npx selva-supabase',
87
+ 'npx supabase db push'
88
+ ].join(' && ');
89
+ }
90
+
91
+ /**
92
+ * Pure verdict, so the three-way comparison is testable without disk or network.
93
+ * `runtime` is null for an old runtime; the provider head is then the best guess.
94
+ * String comparison is safe: every head is a fixed-width timestamp.
95
+ */
96
+ export function judgeMigrationHeads({ runtime, provider, database, tag }) {
97
+ const expected = runtime ?? provider;
98
+ if (!expected) {
99
+ return red(
100
+ '@selvajs/supabase-provider is not installed, so the migration SQL is missing. ' +
101
+ 'Run `selva migrate` to add it, then `npm install`.'
102
+ );
103
+ }
104
+ const providerBehind = !provider || provider < expected;
105
+ const steps = migrateSteps(tag, providerBehind);
106
+ const providerNote =
107
+ runtime && providerBehind
108
+ ? ` The installed @selvajs/supabase-provider only reaches ${provider ?? '(none)'}, so syncing it copies nothing new.`
109
+ : '';
110
+
111
+ if (database.error) return yellow(`Migration-head check skipped (${database.error})`);
112
+ if (database.missing) {
113
+ return red(
114
+ `Database is missing selva.migration_head(): migrations are pending (expected head ${expected}).${providerNote} Run: ${steps}`
115
+ );
116
+ }
117
+ if (!database.head) {
118
+ return red(
119
+ `Database reports no applied migrations (expected head ${expected}).${providerNote} Run: ${steps}`
120
+ );
121
+ }
122
+ if (database.head < expected) {
123
+ const against = runtime ? "the app's expected" : 'the installed provider';
124
+ return red(
125
+ `Database migration head ${database.head} is behind ${against} ${expected}. ` +
126
+ `/api/health returns 503 and writes that need the newer schema fail.${providerNote} Run: ${steps}`
127
+ );
128
+ }
129
+ if (runtime && providerBehind) {
130
+ // The database is fine today; the next upgrade's sync would silently copy nothing.
131
+ return yellow(
132
+ `Database migration head ${database.head} matches the app, but @selvajs/supabase-provider ` +
133
+ `is at ${provider ?? '(none)'}. Run: npm install --prefer-online @selvajs/supabase-provider@${tag}`
134
+ );
135
+ }
136
+ return green(
137
+ `Database migration head ${database.head} matches the ${runtime ? 'app' : 'installed provider'}`
138
+ );
139
+ }
140
+
141
+ export async function checkSupabaseMigrations(dir, env, runtimeVersion) {
142
+ if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
143
+ return yellow('Migration-head check skipped: Supabase env incomplete');
144
+ }
145
+ const runtime = readRuntimeMigrationHead(dir);
146
+ const provider = readProviderMigrationHead(dir);
147
+ if (!runtime && !provider) {
148
+ return judgeMigrationHeads({ runtime, provider, database: {}, tag: 'latest' });
149
+ }
150
+ return judgeMigrationHeads({
151
+ runtime,
152
+ provider,
153
+ database: await fetchDatabaseMigrationHead(env),
154
+ tag: providerTagFor(runtimeVersion)
155
+ });
156
+ }
@@ -31,6 +31,7 @@ import { satisfiesNodeRange } from '../node-range.js';
31
31
  import { green, yellow, red, fixable } from '../checks/result.js';
32
32
  import { checkBootPersistence } from '../checks/boot.js';
33
33
  import { checkRuntimeEnvironment } from '../checks/runtime.js';
34
+ import { checkSupabaseMigrations } from '../checks/migrations.js';
34
35
  import {
35
36
  checkBodySizeLimit,
36
37
  checkClientAddress,
@@ -77,7 +78,13 @@ export async function runDoctor(argv = []) {
77
78
 
78
79
  if (used.has('supabase')) {
79
80
  checks.push(checkSupabase(env));
80
- checks.push(checkSupabaseMigrations(dir, env));
81
+ checks.push(
82
+ checkSupabaseMigrations(
83
+ dir,
84
+ env,
85
+ readPackageVersion(join(dir, 'node_modules', '@selvajs', 'selva', 'package.json'))
86
+ )
87
+ );
81
88
  }
82
89
 
83
90
  if (providers.auth === 'header') {
@@ -222,83 +229,6 @@ export async function checkSupabase(env) {
222
229
  }
223
230
  }
224
231
 
225
- // Compares the newest migration shipped by @selvajs/supabase-provider against
226
- // what the database reports via selva.migration_head(). Red on skew — the app
227
- // degrades /api/health to 503 at boot in the same state, so catching it here
228
- // saves a confusing deploy.
229
- async function checkSupabaseMigrations(dir, env) {
230
- if (!env.SUPABASE_URL || !env.SUPABASE_SERVICE_ROLE_KEY) {
231
- return yellow('Migration-head check skipped — Supabase env incomplete');
232
- }
233
-
234
- const migrationsDir = join(
235
- dir,
236
- 'node_modules',
237
- '@selvajs',
238
- 'supabase-provider',
239
- 'supabase',
240
- 'migrations'
241
- );
242
- let expected = null;
243
- try {
244
- expected =
245
- readdirSync(migrationsDir)
246
- .filter((f) => /^\d{14}_.+\.sql$/.test(f))
247
- .map((f) => f.slice(0, 14))
248
- .sort()
249
- .pop() ?? null;
250
- } catch {
251
- // Provider package not installed in this deployment.
252
- }
253
- if (!expected) {
254
- return red(
255
- '@selvajs/supabase-provider is not installed, so the migration SQL is missing. ' +
256
- 'Run `selva migrate` to add it, then `npm install`.'
257
- );
258
- }
259
-
260
- try {
261
- const controller = new AbortController();
262
- const timer = setTimeout(() => controller.abort(), 4000);
263
- const res = await fetch(env.SUPABASE_URL + '/rest/v1/rpc/migration_head', {
264
- method: 'POST',
265
- headers: {
266
- apikey: env.SUPABASE_SERVICE_ROLE_KEY,
267
- Authorization: `Bearer ${env.SUPABASE_SERVICE_ROLE_KEY}`,
268
- 'Content-Type': 'application/json',
269
- // The function lives in the selva schema the app is pinned to.
270
- 'Content-Profile': 'selva'
271
- },
272
- body: '{}',
273
- signal: controller.signal
274
- });
275
- clearTimeout(timer);
276
- if (res.status === 404) {
277
- return red(
278
- `Database is missing selva.migration_head() — migrations are pending (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
279
- );
280
- }
281
- if (!res.ok) {
282
- return yellow(`migration_head RPC responded ${res.status} — cannot verify schema head`);
283
- }
284
- const actual = String((await res.json()) ?? '');
285
- if (!actual) {
286
- return red(
287
- `Database reports no applied migrations (expected head ${expected}). Run: npx selva-supabase && npx supabase db push`
288
- );
289
- }
290
- // String comparison — relies on both heads being fixed-width, zero-padded timestamps.
291
- if (actual < expected) {
292
- return red(
293
- `Database migration head ${actual} is behind the installed provider (${expected}). Run: npx selva-supabase && npx supabase db push`
294
- );
295
- }
296
- return green(`Database migration head ${actual} matches the installed provider`);
297
- } catch (err) {
298
- return yellow(`Migration-head check skipped (${err.message ?? err})`);
299
- }
300
- }
301
-
302
232
  function checkPackage(dir, name) {
303
233
  const path = join(dir, 'node_modules', ...name.split('/'), 'package.json');
304
234
  if (!existsSync(path)) return red(`${name} not installed (run npm install)`);
@@ -8,6 +8,9 @@ import { spawnSync, execSync } from 'node:child_process';
8
8
  import * as p from '@clack/prompts';
9
9
  import pc from 'picocolors';
10
10
  import { requireDeploymentDir, resolveDeploymentDir } from '../paths.js';
11
+ import { readEnvFile } from '../env.js';
12
+ import { needsSupabaseProvider } from '../deployment-package.js';
13
+ import { checkSupabaseMigrations, providerTagFor } from '../checks/migrations.js';
11
14
 
12
15
  export const APP_NAME = 'selva-compute';
13
16
 
@@ -147,10 +150,10 @@ export async function runUpdate() {
147
150
  const before = readRuntimeVersion(dir);
148
151
  p.log.info(`Current @selvajs/selva: ${before ?? 'unknown'}`);
149
152
 
150
- // Providers are bundled into @selvajs/selva — the only @selvajs/* packages
151
- // an operator install carries are the runtime and the CLI. The admin-center
152
- // "Run update" button runs the same list — keep them in sync if you edit.
153
+ // The admin-center "Run update" button runs the same list: keep them in sync.
153
154
  const packages = ['@selvajs/cli', '@selvajs/selva'];
155
+ const env = readEnvFile(join(dir, '.env'));
156
+ const supabase = needsSupabaseProvider(env);
154
157
 
155
158
  const confirmed = await p.confirm({
156
159
  message: 'Refresh all @selvajs/* packages and restart the app?',
@@ -185,6 +188,21 @@ export async function runUpdate() {
185
188
  const after = readRuntimeVersion(dir);
186
189
  p.log.info(`New @selvajs/selva: ${after ?? 'unknown'}`);
187
190
 
191
+ if (supabase) {
192
+ // Provider code is bundled into @selvajs/selva, but the migration SQL ships
193
+ // here. Left behind, `selva-supabase` copies nothing new. Installed by tag,
194
+ // not `npm update`d: its pin can sit on another channel than the runtime's.
195
+ const spec = `@selvajs/supabase-provider@${providerTagFor(after)}`;
196
+ s.start(`npm install ${spec}`);
197
+ try {
198
+ execSync(`npm install --save --prefer-online ${spec}`, { cwd: dir, stdio: 'pipe' });
199
+ s.stop(`${spec} installed`);
200
+ } catch (err) {
201
+ s.stop(`npm install ${spec} failed`);
202
+ p.log.warn(String(err.stderr ?? err.message ?? err).trim());
203
+ }
204
+ }
205
+
188
206
  if (before && after && before === after) {
189
207
  p.log.warn(
190
208
  [
@@ -199,12 +217,68 @@ export async function runUpdate() {
199
217
  );
200
218
  }
201
219
 
220
+ // Migrations are not applied here: they aren't reversible, and the old build
221
+ // is already gone. The app serves on an old schema (degraded), so start it and
222
+ // say what is left to do.
223
+ const schema = supabase ? await checkSupabaseMigrations(dir, env, after) : null;
224
+
202
225
  const status = runPm2(dir, ['start', APP_NAME, '--update-env'], { inherit: false });
203
- if (status === 0) {
204
- p.outro(pc.green('Started ' + APP_NAME));
205
- } else {
226
+ if (status !== 0) {
206
227
  p.outro(pc.yellow(`Start failed — investigate with \`pm2 logs ${APP_NAME}\`.`));
228
+ process.exitCode = 1;
229
+ return;
230
+ }
231
+
232
+ const health = await waitForHealth(env);
233
+ if (schema && schema.severity !== 'green') p.log.message(schema.line);
234
+ if (health.ok) {
235
+ p.outro(pc.green(`Started ${APP_NAME}`));
236
+ return;
237
+ }
238
+ p.outro(
239
+ pc.red(
240
+ health.status
241
+ ? `Started ${APP_NAME}, but /api/health returned ${health.status}: ${health.reason}`
242
+ : `Started ${APP_NAME}, but /api/health did not answer within 30s. Check \`pm2 logs ${APP_NAME}\`.`
243
+ )
244
+ );
245
+ process.exitCode = 1;
246
+ }
247
+
248
+ // The app was stopped before the update, so the first answer is the new process.
249
+ async function waitForHealth(env, timeoutMs = 30_000) {
250
+ const host = !env.HOST || env.HOST === '0.0.0.0' || env.HOST === '::' ? '127.0.0.1' : env.HOST;
251
+ const url = `http://${host.includes(':') ? `[${host}]` : host}:${env.PORT || 3000}/api/health`;
252
+ const deadline = Date.now() + timeoutMs;
253
+ while (Date.now() < deadline) {
254
+ try {
255
+ const res = await fetch(url, { signal: AbortSignal.timeout(3000) });
256
+ if (res.ok) return { ok: true };
257
+ return {
258
+ ok: false,
259
+ status: res.status,
260
+ reason: degradedReason(await res.json().catch(() => null))
261
+ };
262
+ } catch {
263
+ await new Promise((r) => setTimeout(r, 1000));
264
+ }
265
+ }
266
+ return { ok: false };
267
+ }
268
+
269
+ /** Names the cause a 503 from /api/health reports in `boot`. */
270
+ export function degradedReason(body) {
271
+ const boot = body?.boot;
272
+ if (boot?.schemaVersion?.ok === false) {
273
+ return (
274
+ boot.schemaVersion.message ??
275
+ `database migration head ${boot.schemaVersion.actual} is behind ${boot.schemaVersion.expected}`
276
+ );
277
+ }
278
+ if (boot?.atRestSecrets?.ok === false) {
279
+ return 'stored compute API keys cannot be decrypted under the current SELVA_AT_REST_KEY';
207
280
  }
281
+ return 'see `pm2 logs selva-compute`';
208
282
  }
209
283
 
210
284
  function readRuntimeVersion(dir) {