@selvajs/cli 4.20.0-beta.22 → 4.20.0-beta.24
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 +1 -1
- package/src/checks/migrations.js +156 -0
- package/src/commands/doctor.js +8 -78
- package/src/commands/pm2.js +80 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@selvajs/cli",
|
|
3
|
-
"version": "4.20.0-beta.
|
|
3
|
+
"version": "4.20.0-beta.24",
|
|
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
|
+
}
|
package/src/commands/doctor.js
CHANGED
|
@@ -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(
|
|
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)`);
|
package/src/commands/pm2.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
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) {
|