runcloud 0.2.163 → 0.2.165

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.
@@ -0,0 +1,192 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { ApiClient, friendlyApiError } from '../api.js';
3
+ import { requireCredentials, resolveProject } from '../config.js';
4
+ import { readLocalMigrations, compareMigrations } from '../migration-plan.js';
5
+ import { authorizeDatabaseAction } from '../database-authorization.js';
6
+ export async function runDatabaseOperation(client, projectId, env, operation, options = {}) {
7
+ const operationId = options.operationId ?? randomUUID();
8
+ const root = `/newly_backend/database/${encodeURIComponent(projectId)}/operations`;
9
+ options.onStarted?.(operationId);
10
+ await client.post(root, { ...operation, env, operation_id: operationId });
11
+ const deadline = Date.now() + (options.timeoutMs ?? 300_000);
12
+ const sleep = options.sleep ?? ((ms) => new Promise((done) => setTimeout(done, ms)));
13
+ for (;;) {
14
+ const result = await client.get(`${root}/${operationId}?env=${env}`);
15
+ if (result.status === 'succeeded' && result.data)
16
+ return result.data;
17
+ if (result.status === 'failed')
18
+ throw new Error(result.message ?? 'Database operation failed');
19
+ if (result.status !== 'pending')
20
+ throw new Error('Unexpected database operation response');
21
+ if (Date.now() >= deadline)
22
+ throw new Error(`Database operation ${operationId} is still pending. Check it with newly db operation ${operationId} --env ${env} --project ${projectId}`);
23
+ await sleep(2000);
24
+ }
25
+ }
26
+ function connection(opts) {
27
+ if (opts.env !== 'dev' && opts.env !== 'prod')
28
+ throw new Error('--env must be dev or prod');
29
+ const creds = requireCredentials();
30
+ return { client: new ApiClient(creds.apiUrl, creds.token), projectId: resolveProject(process.cwd(), opts.project) };
31
+ }
32
+ function common(command) {
33
+ return command.option('--env <env>', 'dev or prod (production requires the owner’s user credential)', 'dev').option('--project <id>', 'project id').option('--json', 'print structured JSON');
34
+ }
35
+ const handleError = (error) => { console.error(friendlyApiError(error)); process.exitCode = 1; };
36
+ export function registerDatabaseHelpers(db) {
37
+ common(db.command('migrations').description('Compare live migration ledgers with this checkout; does not apply SQL'))
38
+ .option('--services <names>', 'comma-separated services from backend/newly.services.json')
39
+ .action(async (opts) => {
40
+ try {
41
+ const { client, projectId } = connection(opts);
42
+ const local = readLocalMigrations(process.cwd(), opts.services);
43
+ if (!local.length)
44
+ throw new Error('No selected service has a Drizzle migration journal and schema');
45
+ const data = await runDatabaseOperation(client, projectId, opts.env, { action: 'migrations', schemas: local.map((set) => set.schema) });
46
+ const ledgers = data.ledgers;
47
+ const services = local.map((set) => {
48
+ if (!ledgers?.[set.schema])
49
+ throw new Error(`Inspection did not return ledger ${set.schema}`);
50
+ return compareMigrations(set, ledgers[set.schema]);
51
+ });
52
+ if (opts.json)
53
+ console.log(JSON.stringify({ observed_at: data.observed_at, env: opts.env, services }, null, 2));
54
+ else {
55
+ console.log(`Migration inspection (${opts.env}), observed ${String(data.observed_at)}`);
56
+ for (const service of services) {
57
+ console.log(`\n${service.service} (${service.schema}): applied ${service.applied.length}, pending ${service.pending.length}`);
58
+ console.log(`Applied: ${service.applied.map((row) => row.tag ?? row.created_at).join(', ') || 'none'}`);
59
+ console.log(`Would execute from this checkout: ${service.would_execute?.join(', ') || (service.problems.length ? 'cannot determine — ledger differs' : 'none')}`);
60
+ for (const problem of service.problems)
61
+ console.log(`Problem: ${problem}`);
62
+ }
63
+ console.log('\nThis is a ledger comparison, not a deployment dry run or a compatibility check. Deployment uses the files submitted at that time.');
64
+ }
65
+ if (services.some((service) => service.problems.length))
66
+ process.exitCode = 1;
67
+ }
68
+ catch (error) {
69
+ handleError(error);
70
+ }
71
+ });
72
+ common(db.command('operation').argument('<id>', 'database operation id').description('Read the status or result of a database operation'))
73
+ .action(async (id, opts) => {
74
+ try {
75
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(id))
76
+ throw new Error('Operation id must be a lowercase UUID');
77
+ const { client, projectId } = connection(opts);
78
+ const result = await client.get(`/newly_backend/database/${encodeURIComponent(projectId)}/operations/${id}?env=${opts.env}`);
79
+ console.log(JSON.stringify(result, null, 2));
80
+ if (result.status === 'failed')
81
+ process.exitCode = 1;
82
+ }
83
+ catch (error) {
84
+ handleError(error);
85
+ }
86
+ });
87
+ const backup = db.command('backup').description('Inspect and create expiring database snapshots')
88
+ .addHelpText('after', '\nSnapshots are created on demand. Newly does not schedule backups or create predeployment snapshots.\nYou can run backup create from your own scheduler; production requires an owner user credential and Newly availability.');
89
+ for (const action of ['status', 'list']) {
90
+ common(backup.command(action).description(action === 'status' ? 'Show recovery history, snapshot availability and automation support' : 'List available database snapshots, including previous branches'))
91
+ .action(async (opts) => {
92
+ try {
93
+ const { client, projectId } = connection(opts);
94
+ console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: `backup-${action}` }), null, 2));
95
+ }
96
+ catch (error) {
97
+ handleError(error);
98
+ }
99
+ });
100
+ }
101
+ common(backup.command('create').description('Create a snapshot and wait for the provider to finish'))
102
+ .option('--name <name>', 'snapshot label (up to 80 characters)')
103
+ .option('--retention-hours <hours>', 'expires after 1 to 168 hours', '24')
104
+ .action(async (opts) => {
105
+ try {
106
+ const hours = Number(opts.retentionHours);
107
+ if (!Number.isInteger(hours) || hours < 1 || hours > 168)
108
+ throw new Error('--retention-hours must be an integer from 1 to 168');
109
+ if (opts.name !== undefined && (!opts.name.trim() || opts.name.length > 80 || [...opts.name].some((char) => char.charCodeAt(0) < 32)))
110
+ throw new Error('--name must be 1 to 80 characters without control characters');
111
+ const { client, projectId } = connection(opts);
112
+ const data = await runDatabaseOperation(client, projectId, opts.env, { action: 'backup-create', name: opts.name, retention_seconds: hours * 3600 }, { onStarted: (id) => console.error(`Backup operation: ${id}`) });
113
+ console.log(JSON.stringify(data, null, 2));
114
+ }
115
+ catch (error) {
116
+ handleError(error);
117
+ }
118
+ });
119
+ const restore = db.command('restore').description('Prepare a restored copy, inspect it, and explicitly switch the database');
120
+ common(restore.command('prepare').argument('<snapshot-id>').description('Restore a snapshot into an isolated copy; keeps the live database in place'))
121
+ .option('--schemas <schemas>', 'comma-separated schemas whose migration ledgers to inspect', 'api,auth')
122
+ .action(async (snapshotId, opts) => {
123
+ try {
124
+ if (!/^[a-z0-9-]{1,60}$/.test(snapshotId))
125
+ throw new Error('Invalid snapshot id');
126
+ const { client, projectId } = connection(opts);
127
+ console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-prepare', snapshot_id: snapshotId, schemas: opts.schemas.split(',').map((schema) => schema.trim()) }, { onStarted: (id) => console.error(`Restore preview operation: ${id}`) }), null, 2));
128
+ }
129
+ catch (error) {
130
+ handleError(error);
131
+ }
132
+ });
133
+ common(restore.command('apply').argument('<preview-operation-id>').description('Switch the whole database to the prepared copy; discards later writes and restarts connections'))
134
+ .requiredOption('--confirm-project <id>', 'type the exact project id to confirm the database switch')
135
+ .option('--accept-data-loss', 'confirm that all writes since the snapshot can be lost')
136
+ .action(async (previewId, opts) => {
137
+ try {
138
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(previewId))
139
+ throw new Error('Invalid restore preview operation id');
140
+ const { client, projectId } = connection(opts);
141
+ if (opts.confirmProject !== projectId || opts.acceptDataLoss !== true)
142
+ throw new Error('Applying a restore requires --confirm-project matching the project id and --accept-data-loss');
143
+ const operationId = randomUUID();
144
+ const stepUp = opts.env === 'prod' ? await authorizeDatabaseAction(client, projectId, opts.env, 'restore-apply', operationId) : undefined;
145
+ console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-apply', preview_operation_id: previewId, confirm_project: opts.confirmProject, accept_data_loss: true, step_up: stepUp }, { operationId, onStarted: (id) => console.error(`Restore apply operation: ${id}`) }), null, 2));
146
+ }
147
+ catch (error) {
148
+ handleError(error);
149
+ }
150
+ });
151
+ common(restore.command('discard').argument('<preview-operation-id>').description('Delete an unapplied restored copy and its preview compute'))
152
+ .action(async (previewId, opts) => {
153
+ try {
154
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(previewId))
155
+ throw new Error('Invalid restore preview operation id');
156
+ const { client, projectId } = connection(opts);
157
+ console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-discard', preview_operation_id: previewId }, { onStarted: (id) => console.error(`Restore discard operation: ${id}`) }), null, 2));
158
+ }
159
+ catch (error) {
160
+ handleError(error);
161
+ }
162
+ });
163
+ common(restore.command('cleanup').argument('<preview-operation-id>').description('Remove the previous branch if an applied restore reported cleanup_required'))
164
+ .action(async (previewId, opts) => {
165
+ try {
166
+ const { client, projectId } = connection(opts);
167
+ const operationId = randomUUID();
168
+ const stepUp = await authorizeDatabaseAction(client, projectId, opts.env, 'restore-cleanup', operationId);
169
+ console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'restore-cleanup', preview_operation_id: previewId, step_up: stepUp }, { operationId }), null, 2));
170
+ }
171
+ catch (error) {
172
+ handleError(error);
173
+ }
174
+ });
175
+ const credentials = db.command('credentials').description('Manage the runtime owner credential shared by API and Auth');
176
+ common(credentials.command('rotate').description('Invalidate saved owner URLs and publish a new database password to API and Auth'))
177
+ .requiredOption('--confirm-project <id>', 'exact project id')
178
+ .option('--accept-connection-restart', 'accept that resetting the password restarts database connections')
179
+ .action(async (opts) => {
180
+ try {
181
+ const { client, projectId } = connection(opts);
182
+ if (opts.confirmProject !== projectId || !opts.acceptConnectionRestart)
183
+ throw new Error('Rotation requires --confirm-project matching the project and --accept-connection-restart');
184
+ const operationId = randomUUID();
185
+ const stepUp = await authorizeDatabaseAction(client, projectId, opts.env, 'credentials-rotate', operationId);
186
+ console.log(JSON.stringify(await runDatabaseOperation(client, projectId, opts.env, { action: 'credentials-rotate', confirm_project: projectId, accept_connection_restart: true, step_up: stepUp }, { operationId, onStarted: (id) => console.error(`Credential rotation operation: ${id}`) }), null, 2));
187
+ }
188
+ catch (error) {
189
+ handleError(error);
190
+ }
191
+ });
192
+ }
@@ -106,6 +106,7 @@ export async function runBundleDeploy(projectId, client, dir, env, allServices,
106
106
  secrets: s.secrets,
107
107
  }));
108
108
  const bundle = await buildDeployBundle({
109
+ projectId,
109
110
  projectDir: dir,
110
111
  services: bundleServices,
111
112
  run: runCmd,
@@ -144,7 +145,7 @@ export function registerDeploy(program) {
144
145
  .option('--region <region>', "Newly server region hint for first-time dev provisioning (defaults to 'auto')", 'auto')
145
146
  .option('--project <id>', 'project id (defaults to the linked directory)')
146
147
  .option('--service <names>', 'comma-separated service names from backend/newly.services.json')
147
- .option('--git-sha <sha>', 'prod git revision to deploy from canonical project code', 'HEAD')
148
+ .option('--git-sha <sha>', 'legacy prod deploys only; ignored when the project deploys your local backend/ folder', 'HEAD')
148
149
  .option('--yes', 'skip the prod confirmation prompt', false)
149
150
  .action(async (opts) => {
150
151
  try {
@@ -10,6 +10,7 @@ export function registerLink(program) {
10
10
  writeProjectLink(dir, {
11
11
  projectId,
12
12
  pulledSha: existing?.projectId === projectId ? existing.pulledSha : null,
13
+ pulledBackendHash: existing?.projectId === projectId ? existing.pulledBackendHash : undefined,
13
14
  pulledAt: new Date().toISOString(),
14
15
  });
15
16
  console.log(`Linked ${dir} to project ${projectId}. You can now run \`newly deploy\`, \`newly status\`, etc. here.`);
@@ -1,5 +1,6 @@
1
1
  import { ApiClient, friendlyApiError } from '../api.js';
2
2
  import { requireCredentials, resolveProject } from '../config.js';
3
+ import { registerDatabaseHelpers } from './database.js';
3
4
  export function assertDevOnly(env, command) {
4
5
  if (env !== 'dev') {
5
6
  throw new Error(`\`newly ${command}\` is dev-only from the CLI. Prod ${command} requires the Newly web app (a browser session).`);
@@ -85,8 +86,16 @@ export async function setSecret(client, projectId, env, service, key, value) {
85
86
  value,
86
87
  });
87
88
  }
88
- export async function getDbUrl(client, projectId, env) {
89
- const out = await client.get(`/newly_backend/readonly_db_url?project_id=${encodeURIComponent(projectId)}&env=${env}`);
89
+ export async function getDbUrl(client, projectId, env, role = 'readonly') {
90
+ if (role === 'owner' || env === 'prod') {
91
+ const exportId = randomUUID();
92
+ const proof = await authorizeDatabaseAction(client, projectId, env, `${role}-export`, exportId);
93
+ const out = await client.post(`/newly_backend/database/${encodeURIComponent(projectId)}/connection`, { env, role, export_id: exportId, step_up: proof });
94
+ return out.database_url;
95
+ }
96
+ const out = await client.get(env === 'dev' && role === 'readonly'
97
+ ? `/newly_backend/readonly_db_url?project_id=${encodeURIComponent(projectId)}&env=${env}`
98
+ : `/newly_backend/database/${encodeURIComponent(projectId)}/connection?env=${env}&role=${role}`);
90
99
  return out.database_url ?? out.url ?? JSON.stringify(out);
91
100
  }
92
101
  function makeClient(project) {
@@ -162,15 +171,19 @@ export function registerMisc(program) {
162
171
  }
163
172
  });
164
173
  const db = program.command('db').description('Database helpers');
174
+ registerDatabaseHelpers(db);
165
175
  db.command('url')
166
- .description('Print a readonly URL for the Newly DB')
176
+ .description('Print a direct database connection URL; owner role permits database management')
167
177
  .option('--env <env>', 'dev or prod', 'dev')
178
+ .option('--role <role>', 'readonly or owner (owner export requires the project owner’s user credential)', 'readonly')
168
179
  .option('--project <id>', 'project id')
169
180
  .action(async (opts) => {
170
181
  try {
171
- assertDevOnly(opts.env, 'db url');
182
+ assertKnownEnv(opts.env, 'db url');
183
+ if (opts.role !== 'readonly' && opts.role !== 'owner')
184
+ throw new Error('--role must be readonly or owner');
172
185
  const { client, projectId } = makeClient(opts.project);
173
- console.log(await getDbUrl(client, projectId, opts.env));
186
+ console.log(await getDbUrl(client, projectId, opts.env, opts.role));
174
187
  }
175
188
  catch (e) {
176
189
  console.error(friendlyApiError(e));
@@ -178,3 +191,5 @@ export function registerMisc(program) {
178
191
  }
179
192
  });
180
193
  }
194
+ import { randomUUID } from 'node:crypto';
195
+ import { authorizeDatabaseAction } from '../database-authorization.js';
@@ -1,7 +1,9 @@
1
- import { readdirSync } from 'node:fs';
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { join } from 'node:path';
2
3
  import { ApiClient, friendlyApiError } from '../api.js';
3
4
  import { requireCredentials, resolveProject, writeProjectLink } from '../config.js';
4
- import { extractSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
5
+ import { extractSnapshot, listSnapshot, pruneStaleTopLevel, snapshotTopLevelEntries } from '../archive.js';
6
+ import { hashBackendSource } from '../deploy-bundle.js';
5
7
  export function assertPullTarget(dir, force) {
6
8
  const entries = readdirSync(dir).filter((e) => e !== '.newly');
7
9
  if (entries.length > 0 && !force) {
@@ -38,10 +40,12 @@ export function registerPull(program) {
38
40
  }
39
41
  await extractTarball(data, dir);
40
42
  const pulledBranch = res.headers.get('x-newly-branch');
43
+ const pulledPaths = new Set((await listSnapshot(data)).map((name) => name.replace(/^\.?\//, '')));
41
44
  writeProjectLink(dir, {
42
45
  projectId,
43
46
  pulledSha: res.headers.get('x-newly-head-sha'),
44
47
  pulledAt: new Date().toISOString(),
48
+ pulledBackendHash: existsSync(join(dir, 'backend')) ? hashBackendSource(dir, pulledPaths) : undefined,
45
49
  });
46
50
  console.log(`Pulled${pulledBranch ? ` branch ${pulledBranch}` : ''} into ${dir} and linked to project ${projectId}.`);
47
51
  }
@@ -0,0 +1,23 @@
1
+ import { createInterface } from 'node:readline/promises';
2
+ export async function authorizeDatabaseAction(client, projectId, env, action, resourceId, readCode) {
3
+ const response = await client.post(`/newly_backend/database/${encodeURIComponent(projectId)}/step-up`, { env, action, resource_id: resourceId });
4
+ if (typeof response.challenge_id !== 'string')
5
+ throw new Error('Database authorization did not return a challenge id');
6
+ console.error(`Database authorization: ${response.challenge_id}`);
7
+ let code;
8
+ if (readCode)
9
+ code = await readCode();
10
+ else {
11
+ const input = createInterface({ input: process.stdin, output: process.stderr, terminal: false });
12
+ try {
13
+ code = await input.question('Enter the verification code sent to your account email: ');
14
+ }
15
+ finally {
16
+ input.close();
17
+ }
18
+ }
19
+ code = code.trim();
20
+ if (!/^\d{6}$/.test(code))
21
+ throw new Error('A six-digit email code is required. Credential export and production restore require fresh authorization.');
22
+ return { challenge_id: response.challenge_id, code };
23
+ }
@@ -1,9 +1,11 @@
1
1
  import { spawn } from 'node:child_process';
2
+ import { createHash } from 'node:crypto';
2
3
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync, } from 'node:fs';
3
4
  import { tmpdir } from 'node:os';
4
5
  import { join } from 'node:path';
5
6
  import { Buffer } from 'node:buffer';
6
7
  import { create as tarCreate } from 'tar';
8
+ import { readProjectLink } from './config.js';
7
9
  export const NEWLY_BACKEND_DEPLOY_BUNDLE_CONTENT_TYPE = 'application/vnd.newly.deploy-bundle+gzip';
8
10
  export const NEWLY_BACKEND_DEPLOY_MODE_BUNDLE = 'bundle';
9
11
  const MANIFEST_PATH = 'newly-deploy.json';
@@ -17,7 +19,7 @@ export class DeployBuildError extends Error {
17
19
  }
18
20
  }
19
21
  export async function buildDeployBundle(options) {
20
- const { projectDir, services, run, log } = options;
22
+ const { projectId, projectDir, services, run, log } = options;
21
23
  const bunVersion = await run('bun', ['--version'], projectDir);
22
24
  if (bunVersion.code !== 0) {
23
25
  throw new DeployBuildError('newly deploy needs bun on your PATH (https://bun.sh)');
@@ -26,11 +28,11 @@ export async function buildDeployBundle(options) {
26
28
  const servicesDir = join(bundleDir, 'services');
27
29
  mkdirSync(servicesDir, { recursive: true });
28
30
  try {
31
+ const gitSha = await resolveGitSha(run, projectDir, projectId);
29
32
  const manifestServices = [];
30
33
  for (const service of services) {
31
34
  manifestServices.push(await buildService(service, projectDir, servicesDir, run, log));
32
35
  }
33
- const gitSha = await resolveGitSha(run, projectDir);
34
36
  const manifest = { version: 1, git_sha: gitSha, services: manifestServices };
35
37
  writeFileSync(join(bundleDir, MANIFEST_PATH), JSON.stringify(manifest, null, 2));
36
38
  const bundle = await tarBundle(bundleDir);
@@ -110,12 +112,40 @@ function stageMigrations(dir, serviceDir, name) {
110
112
  }
111
113
  return `services/${name}/drizzle`;
112
114
  }
113
- async function resolveGitSha(run, projectDir) {
114
- const result = await run('git', ['rev-parse', 'HEAD'], projectDir);
115
- if (result.code !== 0)
115
+ async function resolveGitSha(run, projectDir, projectId) {
116
+ const link = readProjectLink(projectDir);
117
+ if (link?.projectId === projectId && link.pulledSha && link.pulledBackendHash) {
118
+ try {
119
+ if (link.pulledBackendHash === hashBackendSource(projectDir))
120
+ return link.pulledSha;
121
+ }
122
+ catch {
123
+ }
124
+ }
125
+ const head = await run('git', ['rev-parse', 'HEAD'], projectDir);
126
+ const sha = head.code === 0 ? head.output.trim() : '';
127
+ if (!sha)
128
+ return null;
129
+ const tracked = await run('git', ['ls-files', '--error-unmatch', '--', 'backend'], projectDir);
130
+ if (tracked.code !== 0)
116
131
  return null;
117
- const sha = result.output.trim();
118
- return sha || null;
132
+ const changes = await run('git', ['--no-optional-locks', 'status', '--porcelain', '--', 'backend'], projectDir);
133
+ return changes.code === 0 && changes.output.trim() === '' ? sha : null;
134
+ }
135
+ export function hashBackendSource(projectDir, only) {
136
+ const digest = (data) => createHash('sha256').update(data).digest('hex');
137
+ const files = backendSourceFiles(projectDir, 'backend').filter((path) => !only || only.has(path));
138
+ return digest(files.sort().map((path) => `${path}\0${digest(readFileSync(join(projectDir, path)))}`).join('\n'));
139
+ }
140
+ function backendSourceFiles(projectDir, rel) {
141
+ return readdirSync(join(projectDir, rel), { withFileTypes: true }).flatMap((entry) => {
142
+ if (entry.name.startsWith('.') || entry.name === 'node_modules')
143
+ return [];
144
+ const path = `${rel}/${entry.name}`;
145
+ if (entry.isDirectory())
146
+ return backendSourceFiles(projectDir, path);
147
+ return entry.isFile() ? [path] : [];
148
+ });
119
149
  }
120
150
  async function tarBundle(bundleDir) {
121
151
  const chunks = [];
@@ -0,0 +1,74 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join, relative, resolve } from 'node:path';
4
+ import { readBackendServices, assertKnownServices, parseServiceFilter } from './services-manifest.js';
5
+ export function readLocalMigrations(dir, servicesFilter) {
6
+ const services = readBackendServices(dir);
7
+ const selected = parseServiceFilter(servicesFilter);
8
+ assertKnownServices(services, selected);
9
+ const root = resolve(dir, 'backend');
10
+ const seenSchemas = new Set();
11
+ return services.filter((service) => !selected.length || selected.includes(service.name)).flatMap((service) => {
12
+ const serviceDir = resolve(root, service.path);
13
+ const path = relative(root, serviceDir);
14
+ if (path.startsWith('..') || path === '')
15
+ throw new Error(`Service ${service.name} must have a path inside backend/`);
16
+ const drizzle = join(serviceDir, 'drizzle');
17
+ const journal = join(drizzle, 'meta', '_journal.json');
18
+ if (!existsSync(journal)) {
19
+ if (service.schema)
20
+ throw new Error(`Service ${service.name} declares a schema but has no migration journal`);
21
+ return [];
22
+ }
23
+ if (!service.schema || !/^[a-z_][a-z0-9_]{0,62}$/.test(service.schema) || service.schema.startsWith('pg_') || service.schema === 'information_schema')
24
+ throw new Error(`Service ${service.name} needs a valid migration schema`);
25
+ if (seenSchemas.has(service.schema))
26
+ throw new Error(`More than one migration journal targets schema ${service.schema}`);
27
+ seenSchemas.add(service.schema);
28
+ const parsed = JSON.parse(readFileSync(journal, 'utf8'));
29
+ if (!Array.isArray(parsed.entries))
30
+ throw new Error(`${journal} has no entries array`);
31
+ let previous = -1;
32
+ const tags = new Set();
33
+ const migrations = parsed.entries.map((entry) => {
34
+ if (typeof entry.tag !== 'string' || !/^[A-Za-z0-9_-]+$/.test(entry.tag) || tags.has(entry.tag) || typeof entry.when !== 'number' || !Number.isSafeInteger(entry.when) || entry.when <= previous)
35
+ throw new Error(`Invalid or unordered migration journal for ${service.name}`);
36
+ tags.add(entry.tag);
37
+ previous = entry.when;
38
+ const sql = readFileSync(join(drizzle, `${entry.tag}.sql`), 'utf8');
39
+ return { tag: entry.tag, when: entry.when, hash: createHash('sha256').update(sql).digest('hex') };
40
+ });
41
+ return [{ service: service.name, schema: service.schema, migrations }];
42
+ });
43
+ }
44
+ export function compareMigrations(local, ledger) {
45
+ const expected = new Map(local.migrations.map((migration) => [migration.when, migration]));
46
+ const applied = new Set(ledger.rows.map((row) => Number(row.created_at)));
47
+ const head = ledger.rows.length ? Math.max(...applied) : -1;
48
+ const problems = [];
49
+ const seen = new Set();
50
+ const rows = ledger.rows.map((row) => {
51
+ const when = Number(row.created_at);
52
+ if (!Number.isSafeInteger(when) || when < 0)
53
+ problems.push(`Invalid ledger timestamp: ${row.created_at}`);
54
+ if (seen.has(when))
55
+ problems.push(`Duplicate ledger timestamp: ${row.created_at}`);
56
+ seen.add(when);
57
+ const migration = expected.get(when);
58
+ if (!migration)
59
+ problems.push(`Applied migration is absent from this checkout: ${row.created_at}`);
60
+ else if (migration.hash !== row.hash)
61
+ problems.push(`Applied SQL hash differs: ${migration.tag}`);
62
+ return { ...row, tag: migration?.tag ?? null, hash_matches: migration ? migration.hash === row.hash : null };
63
+ });
64
+ const pending = local.migrations.filter((migration) => !applied.has(migration.when));
65
+ for (const migration of pending) {
66
+ if (migration.when <= head)
67
+ problems.push(`Migration was skipped before the applied head: ${migration.tag}`);
68
+ }
69
+ return {
70
+ service: local.service, schema: local.schema, ledger_exists: ledger.exists,
71
+ applied: rows, pending: pending.map((migration) => migration.tag), problems,
72
+ would_execute: problems.length ? null : pending.map((migration) => migration.tag),
73
+ };
74
+ }
package/dist/version.js CHANGED
@@ -1 +1 @@
1
- export const CLI_VERSION = '0.2.163';
1
+ export const CLI_VERSION = '0.2.165';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runcloud",
3
- "version": "0.2.163",
3
+ "version": "0.2.165",
4
4
  "description": "Create and control run.cloud remote mobile simulators and cloud sandboxes",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
Binary file
Binary file