@newly/cli 0.2.163 → 0.2.164

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/README.md CHANGED
@@ -157,9 +157,141 @@ Set a backend secret. **Dev only.** Redeploy to apply.
157
157
  - `--project <id>`
158
158
 
159
159
  ### `newly db url`
160
- Print a readonly URL for the Newly DB. **Dev only.**
160
+ Print a direct PostgreSQL connection URL for the selected database.
161
+ - `--env <dev|prod>` (default `dev`)
162
+ - `--role <readonly|owner>` (default `readonly`)
161
163
  - `--project <id>`
162
164
 
165
+ ```bash
166
+ newly db url --env prod --role owner --project PROJECT_ID
167
+ ```
168
+
169
+ Owner access allows direct SQL, migrations, exports and logical backups using
170
+ PostgreSQL tools such as `psql` and `pg_dump`. Save the URL in your own secret
171
+ manager before an outage. Once saved, it connects directly to the database;
172
+ Newly's API, worker and CLI do not need to be running. The database provider and
173
+ network must remain available. Retrieving a URL still requires Newly to be
174
+ available. This exports the existing app owner credential; changing its password
175
+ also affects the deployed API/Auth services. It does not grant a provider account
176
+ or provider snapshot controls.
177
+
178
+ The URL contains a password. New credential exports require the project owner's
179
+ user credential in either environment; managed agent credentials cannot export
180
+ them. Responses are not cached and credentials do not enter operation results.
181
+ Readonly access retains the role's existing grants; it does not add access to
182
+ custom app schemas. The legacy dev readonly command keeps its existing access
183
+ rules.
184
+
185
+ ### Database inspection, backups and restore
186
+
187
+ These commands keep database provider credentials on the platform. Production
188
+ requires the project owner's user credential from `newly login`; managed agent
189
+ sessions remain dev-only. Dev requires an admin or owner. All commands accept
190
+ `--project` and `--json`; `--env` defaults to `dev`.
191
+
192
+ ```bash
193
+ newly db migrations --env prod --json
194
+ newly db migrations --env prod --services api,auth
195
+ newly db backup status --env prod
196
+ newly db backup list --env prod
197
+ newly db backup create --env prod --name before-catalog --retention-hours 48
198
+ ```
199
+
200
+ Migration inspection compares live ledgers, read in a read-only transaction,
201
+ with the selected services' local journals and SQL hashes. It reports applied
202
+ and pending migrations, edited SQL, missing local entries and skipped
203
+ migrations. A discrepancy returns a nonzero exit code. This is a comparison
204
+ with the current checkout, not a deployment dry run or an application
205
+ compatibility check; it does not apply or select migrations.
206
+
207
+ Manual snapshots expire after 24 hours by default; `--retention-hours` accepts
208
+ 1 to 168. At most five manual snapshots and three restore previews can exist per
209
+ database. Newly does not manage backup schedules or take automatic predeployment
210
+ snapshots. `backup status` reports `managed_backup_scheduling: false` and
211
+ `automatic_predeploy_snapshot: false`.
212
+
213
+ Users can automate the manual command from their own cron or CI job:
214
+
215
+ ```bash
216
+ newly db backup create --env prod --project PROJECT_ID \
217
+ --name owner-backup-job --retention-hours 24
218
+ ```
219
+
220
+ The command runs without an interactive prompt. Supply `NEWLY_API_URL` and an
221
+ owner user credential in `NEWLY_API_TOKEN` through the job's configuration and
222
+ secret store, or use the owner's authenticated CLI profile. Managed agent
223
+ credentials cannot operate on production. Users own the job, its schedule,
224
+ credentials, retention, and monitoring. Leave room below the five-snapshot limit
225
+ for expiration cleanup and extra manual snapshots; alert on nonzero exit codes
226
+ and expired credentials. Inspect a pending or uncertain operation before retrying.
227
+ This automation requires Newly and the database provider to be available. For
228
+ independent access during a Newly outage, save the owner connection URL beforehand
229
+ and run SQL clients or `pg_dump` from infrastructure you control.
230
+
231
+ See [Database backups](https://docs.newly.app/backend/database-backups) for token
232
+ setup, a GitHub Actions example, failure handling, and independent database exports.
233
+
234
+ Restore starts with a separate copy. Its result includes the preview operation
235
+ ID, migration ledgers and whether current API/Auth/read-only database
236
+ credentials work against the copy. Inspect that result before deciding to apply:
237
+
238
+ ```bash
239
+ newly db restore prepare SNAPSHOT_ID --env prod --schemas api,auth
240
+ newly db operation PREVIEW_OPERATION_ID --env prod
241
+ newly db restore apply PREVIEW_OPERATION_ID --env prod \
242
+ --confirm-project PROJECT_ID --accept-data-loss
243
+ # Or remove an unapplied copy and its preview compute:
244
+ newly db restore discard PREVIEW_OPERATION_ID --env prod
245
+ ```
246
+
247
+ Applying switches the whole database, including Auth and every app schema, to
248
+ the snapshot state. Writes after the snapshot can be lost and connections
249
+ restart. App traffic is not paused. The deployed application, secrets, object
250
+ storage and queues are not rolled back. The owner controls when to apply and
251
+ validates application compatibility afterward.
252
+
253
+ An existing deployment blocks restore. Database operations hold the backend row
254
+ lock to block new deployments or teardown during the switch. After verifying the
255
+ switch, Newly attempts to delete the previous branch; snapshots remain listed.
256
+ If `cleanup_required` is reported, discard remaining unapplied previews and run
257
+ `newly db restore cleanup PREVIEW_OPERATION_ID --env prod` to remove the old
258
+ branch. Discard can remove stale or failed previews and never the live branch.
259
+ Pause user-owned
260
+ automation and coordinate application writes before applying a restore, then
261
+ validate the restored application before resuming them. If an external provider
262
+ backup schedule exists, restore refuses to switch until it is disabled through
263
+ its original management tool. Newly does not copy, disable, or configure that
264
+ schedule; the owner manages it after the restore.
265
+
266
+ Operations run asynchronously. A timeout or lost response does not cancel a
267
+ provider operation; inspect the printed operation ID with `newly db operation`
268
+ and check database state before submitting another request. A successful restore
269
+ confirms the provider database switch, not the application's behavior.
270
+
271
+ Production URL export (including read-only), owner export in either environment,
272
+ production restore apply and previous-branch cleanup require
273
+ the project owner's CLI key plus a fresh email verification code. The CLI
274
+ prompts for a six-digit code sent to the account's verified email. Each code is
275
+ bound to one key, project, environment, action and request, expires after five
276
+ minutes and can be consumed once. Web/desktop sessions cannot perform these
277
+ actions. Never share a code with an agent. Manual snapshot creation stays
278
+ noninteractive for user-owned automation.
279
+
280
+ To invalidate saved owner and read-only URLs and update API/Auth database credentials:
281
+
282
+ ```bash
283
+ newly db credentials rotate --env prod --project PROJECT_ID \
284
+ --confirm-project PROJECT_ID --accept-connection-restart
285
+ ```
286
+
287
+ Rotation also requires fresh email authorization. It resets both passwords and
288
+ closes existing connections using these roles; export new URLs afterward and
289
+ update external jobs. A snapshot retains
290
+ its original database credentials, so pre-rotation snapshots can fail restore
291
+ compatibility checks. Export and rotation events have durable audit records
292
+ without URLs or verification codes. One database operation can be queued or
293
+ running per project; wait and inspect results before retrying a busy request.
294
+
163
295
  ### `newly email`
164
296
  Retired. It never had a server to talk to; send mail from your API service with
165
297
  your own provider. Running it prints that and exits non-zero.
@@ -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
+ }
@@ -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';
@@ -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
+ }
@@ -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.164';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@newly/cli",
3
- "version": "0.2.163",
3
+ "version": "0.2.164",
4
4
  "description": "Develop, provision, and deploy Newly backends from your terminal",
5
5
  "homepage": "https://newly.app",
6
6
  "repository": {