@getxflow/cli 1.9.0 → 1.11.0

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.
@@ -295,6 +295,9 @@ async function migrate(client, projectId, migrations, dryRun, allowDestructive,
295
295
  (0, ui_1.note)((0, ui_1.dim)(` ${hit}`));
296
296
  }
297
297
  if (result.dry_run) {
298
+ if (result.failed) {
299
+ throw new errors_1.CliError(`${result.failed.name}: ${result.failed.error}`, 'Checked on a copy of the database: the database is unchanged');
300
+ }
298
301
  if (result.pending.length === 0) {
299
302
  (0, ui_1.ok)('Nothing to apply: every migration is already in the database');
300
303
  return;
@@ -303,12 +306,18 @@ async function migrate(client, projectId, migrations, dryRun, allowDestructive,
303
306
  (0, ui_1.out)(`Will be applied (${result.pending.length}):`);
304
307
  for (const name of result.pending)
305
308
  (0, ui_1.out)(` ${name}`);
306
- (0, ui_1.note)((0, ui_1.dim)(' Dry run: the database was not changed'));
309
+ (0, ui_1.note)((0, ui_1.dim)(result.checked
310
+ ? ' They ran on a copy of the database as it is now; the database was not changed'
311
+ : result.checked === false
312
+ ? ' Not checked: a copy of the database could not be made; the database was not changed'
313
+ : ' Dry run: the database was not changed'));
307
314
  return;
308
315
  }
309
316
  for (const dump of result.dumps) {
310
317
  (0, ui_1.note)((0, ui_1.dim)(` Dump of ${dump.table}: ${dump.rows} rows${dump.truncated ? ' (cut at the cap)' : ''}, kept for ${result.dump_retention_days} days`));
311
318
  }
319
+ if (result.checked)
320
+ (0, ui_1.note)((0, ui_1.dim)(' Checked on a copy of the database first'));
312
321
  if (result.applied.length > 0) {
313
322
  (0, ui_1.ok)(`Applied: ${result.applied.join(', ')}`);
314
323
  }
@@ -318,9 +327,14 @@ async function migrate(client, projectId, migrations, dryRun, allowDestructive,
318
327
  if (result.failed) {
319
328
  throw new errors_1.CliError(`${result.failed.name}: ${result.failed.error}`, result.applied.length > 0
320
329
  ? `What ran before it stays in the database (${result.applied.join(', ')}), the rest did not run`
321
- : 'The database is unchanged: the migration rolled back as a whole');
330
+ : 'The database is unchanged: nothing was applied');
322
331
  }
323
332
  }
333
+ /** Time a branch has left, `5h` or `3d`. */
334
+ function left(iso) {
335
+ const h = Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 3_600_000));
336
+ return h < 48 ? `${h}h left` : `${Math.round(h / 24)}d left`;
337
+ }
324
338
  /** Branches of the project database: list, create, delete. */
325
339
  async function dbBranch(args) {
326
340
  const [sub, name] = args.words;
@@ -329,7 +343,7 @@ async function dbBranch(args) {
329
343
  if (sub === undefined || sub === 'list') {
330
344
  const data = await (0, api_1.apiJson)(client, path);
331
345
  if ((0, args_1.flagBool)(args, 'json')) {
332
- (0, ui_1.out)(JSON.stringify(data.branches, null, 2));
346
+ (0, ui_1.out)(JSON.stringify(data, null, 2));
333
347
  return;
334
348
  }
335
349
  if (data.branches.length === 0) {
@@ -337,16 +351,18 @@ async function dbBranch(args) {
337
351
  (0, ui_1.note)((0, ui_1.dim)(' A copy to try things on: xflow db branch create <name> [--at 2h]'));
338
352
  return;
339
353
  }
340
- (0, ui_1.table)(data.branches.map((b) => [b.name, b.at ? `as of ${new Date(b.at).toLocaleString()}` : 'as made', (0, ui_1.formatAge)(b.created_at)]));
354
+ (0, ui_1.table)(data.branches.map((b) => [b.name, b.at ? `as of ${new Date(b.at).toLocaleString()}` : 'as made', (0, ui_1.formatAge)(b.created_at), left(b.expires_at)]));
355
+ (0, ui_1.note)((0, ui_1.dim)(` ${data.branches.length} of ${data.plan.branches}; a branch lives up to ${data.plan.ttl_hours / 24}d on this plan (xflow db branch extend <name>)`));
341
356
  return;
342
357
  }
343
358
  if (!name)
344
359
  throw new errors_1.CliError(`xflow db branch ${sub} needs a name`, `For example: xflow db branch ${sub} fix-orders`);
345
360
  if (sub === 'create') {
346
361
  const at = (0, args_1.flagString)(args, 'at');
362
+ const ttl = (0, args_1.flagString)(args, 'ttl');
347
363
  const data = await (0, api_1.apiJson)(client, path, {
348
364
  method: 'POST',
349
- body: { name, at: at ? (0, moment_1.parseMoment)(at) : undefined },
365
+ body: { name, at: at ? (0, moment_1.parseMoment)(at) : undefined, ttl_hours: ttl ? (0, moment_1.parseTtl)(ttl) : undefined },
350
366
  timeoutMs: 180_000,
351
367
  });
352
368
  if ((0, args_1.flagBool)(args, 'json')) {
@@ -356,6 +372,20 @@ async function dbBranch(args) {
356
372
  (0, ui_1.ok)(`Branch ${name}: the project database ${data.branch.at ? `as of ${new Date(data.branch.at).toLocaleString()}` : 'as it is now'}`);
357
373
  (0, ui_1.note)((0, ui_1.dim)(` Work in it: xflow db query --branch ${name} '...', db exec and db migrate take --branch too`));
358
374
  (0, ui_1.note)((0, ui_1.dim)(` Rows back into the project database: xflow db restore --from ${name} --table <table>`));
375
+ (0, ui_1.note)((0, ui_1.dim)(` Deleted on its own in ${left(data.branch.expires_at)}; more time: xflow db branch extend ${name}`));
376
+ return;
377
+ }
378
+ if (sub === 'extend') {
379
+ const ttl = (0, args_1.flagString)(args, 'ttl');
380
+ const data = await (0, api_1.apiJson)(client, `${path}/${encodeURIComponent(name)}`, {
381
+ method: 'PATCH',
382
+ body: { ttl_hours: ttl ? (0, moment_1.parseTtl)(ttl) : undefined },
383
+ });
384
+ if ((0, args_1.flagBool)(args, 'json')) {
385
+ (0, ui_1.out)(JSON.stringify(data.branch, null, 2));
386
+ return;
387
+ }
388
+ (0, ui_1.ok)(`Branch ${name}: ${left(data.branch.expires_at)}, until ${new Date(data.branch.expires_at).toLocaleString()}`);
359
389
  return;
360
390
  }
361
391
  if (sub === 'delete' || sub === 'rm') {
@@ -363,7 +393,7 @@ async function dbBranch(args) {
363
393
  (0, ui_1.ok)(`Branch ${name} deleted; the project database is unchanged`);
364
394
  return;
365
395
  }
366
- throw new errors_1.CliError(`Unknown command: db branch ${sub}`, 'Available: list, create and delete');
396
+ throw new errors_1.CliError(`Unknown command: db branch ${sub}`, 'Available: list, create, extend and delete');
367
397
  }
368
398
  /** Rows of a table from a branch back into the project database, by primary key. */
369
399
  async function dbRestore(args) {
@@ -109,7 +109,7 @@ function printNotes(started) {
109
109
  (0, ui_1.note)(` Unfinished: ${item.message}${item.file ? (0, ui_1.dim)(` (${item.file})`) : ''}`);
110
110
  }
111
111
  }
112
- async function finishFunctions(client, base, started) {
112
+ async function finishFunctions(client, base, started, rollback) {
113
113
  printNotes(started);
114
114
  const build = await waitForBuild(client, base, started);
115
115
  if (build.phase === 'failed')
@@ -118,6 +118,9 @@ async function finishFunctions(client, base, started) {
118
118
  for (const line of openLines(build.log_tail))
119
119
  (0, ui_1.warn)(line);
120
120
  (0, ui_1.note)((0, ui_1.dim)(' Every app of the project calls the new code already: there is no publish step for functions'));
121
+ if (rollback) {
122
+ (0, ui_1.note)((0, ui_1.dim)(` The database is not rolled back. As it was when this revision first went live: xflow db branch create rev-${build.revision} --at r${build.revision}`));
123
+ }
121
124
  }
122
125
  async function finishApp(client, base, started) {
123
126
  printNotes(started);
@@ -165,6 +168,7 @@ function targetsHint(projectRoot) {
165
168
  async function deployTarget(target, args, comment, revision) {
166
169
  const base = (0, sources_1.streamPath)(target);
167
170
  const { client } = target;
171
+ const rollback = revision !== undefined;
168
172
  if (revision === undefined) {
169
173
  if (!target.root)
170
174
  throw new errors_1.CliError('Nothing to send without a folder', 'Build a revision the platform holds: --revision <n>');
@@ -189,7 +193,7 @@ async function deployTarget(target, args, comment, revision) {
189
193
  if (target.kind === 'app')
190
194
  await finishApp(client, base, started);
191
195
  else
192
- await finishFunctions(client, base, started);
196
+ await finishFunctions(client, base, started, rollback);
193
197
  }
194
198
  async function deploy(args) {
195
199
  const stated = statedRevision((0, args_1.flagNumber)(args, 'revision'), (0, args_1.flagBool)(args, 'force'));
package/dist/flags.js CHANGED
@@ -82,8 +82,8 @@ exports.KNOWN = {
82
82
  'db exec': ['allow-destructive', 'json', 'project', 'branch'],
83
83
  'db logs': ['limit', 'project', 'cursor'],
84
84
  'db migrate': ['dry-run', 'allow-destructive', 'branch'],
85
- 'db branch': ['at', 'json', 'project'],
86
- 'db branches': ['at', 'json', 'project'],
85
+ 'db branch': ['at', 'json', 'project', 'ttl'],
86
+ 'db branches': ['at', 'json', 'project', 'ttl'],
87
87
  'db restore': ['from', 'table', 'where', 'overwrite', 'json', 'project'],
88
88
  storage: ['json', 'project'],
89
89
  'storage ls': ['json', 'project'],
package/dist/help.js CHANGED
@@ -296,7 +296,10 @@ will work.`,
296
296
  1 on the Free plan (--limit, --cursor for older)
297
297
  xflow db branch [list] branches of the database (--json)
298
298
  xflow db branch create <name> a branch as the database is now; --at 2h, --at 14:05 or
299
- --at 2026-10-30T14:05+03:00 for a moment of its history
299
+ --at 2026-10-30T14:05+03:00 for a moment of its history,
300
+ --at r12 for when functions revision 12 went live;
301
+ --ttl 12h or --ttl 3d to delete it sooner than the plan does
302
+ xflow db branch extend <name> more time from now: the plan's longest, or --ttl
300
303
  xflow db branch delete <name> the branch goes, the database is unchanged
301
304
  xflow db restore --from <branch> --table <table> [--where '...'] [--overwrite]
302
305
  rows of a table from a branch back into the database
@@ -331,7 +334,9 @@ ${(0, ui_1.bold)('query')}, ${(0, ui_1.bold)('exec')} and ${(0, ui_1.bold)('migr
331
334
  reaches the project database. A branch sleeps after a quiet minute, so its first query takes a
332
335
  second or two; it has no functions and no history of its own, and a branch of a branch is not
333
336
  made. Branches per project: 2 on Free, 5 on Start, 15 on Business; delete one you no longer need.
334
- The history reaches back 6 hours on Free, 3 days on Start, 7 days on Business.
337
+ A branch is deleted on its own when its lifetime ends: at most 1 day on Free, 7 days on Start,
338
+ 30 days on Business, and ${(0, ui_1.bold)('extend')} gives it more from now within that. The history reaches
339
+ back 6 hours on Free, 3 days on Start, 7 days on Business.
335
340
 
336
341
  ${(0, ui_1.bold)('restore')} puts rows back after a mistake without rolling the whole database back: make a
337
342
  branch as of a moment before it, check the rows there with ${(0, ui_1.bold)('query --branch')}, then restore
@@ -345,9 +350,10 @@ ${(0, ui_1.bold)('migrations/0002_orders.sql')} and so on). The order comes from
345
350
  is why the leading number is required. The command first stores the project root as a
346
351
  revision, so the migrations stay in its history and a clone gets them, then applies what is
347
352
  new. Every migration runs in its own transaction, the history is kept in the database
348
- itself, and anything already applied is not run again.
353
+ itself, and anything already applied is not run again. New migrations run first, all of them,
354
+ on a copy of the database as it is now: if one fails there, nothing is applied.
349
355
 
350
- --dry-run report what would be applied without touching the database
356
+ --dry-run run the new migrations on that copy only; the database is not touched
351
357
  --allow-destructive allow operations that destroy data
352
358
 
353
359
  About destructive ones. ${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} and deleting rows (judged by what
package/dist/limits.js CHANGED
@@ -54,7 +54,7 @@ const QUOTA_LABELS = {
54
54
  people: 'People',
55
55
  space_mb: 'Space (databases and files together)',
56
56
  function_minutes: 'Function minutes',
57
- db_hours: 'Database hours',
57
+ db_hours: 'Database GB-hours',
58
58
  builds: 'Builds this month',
59
59
  };
60
60
  /** Decimal units, 1000 MB per GB: matches how volumes are sold. */
package/dist/moment.js CHANGED
@@ -1,10 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseMoment = parseMoment;
4
+ exports.parseTtl = parseTtl;
4
5
  const errors_1 = require("./errors");
5
6
  const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
6
- /** `30m`/`2h`/`1d` ago, `14:05` local (yesterday if still ahead), or an ISO date. */
7
+ /** `30m`/`2h`/`1d` ago, `14:05` local (yesterday if still ahead), an ISO date, or `r12` (the platform resolves it). */
7
8
  function parseMoment(text, now = new Date()) {
9
+ if (/^r\d{1,6}$/.test(text))
10
+ return text;
8
11
  const ago = /^(\d{1,4})([mhd])$/.exec(text);
9
12
  if (ago)
10
13
  return new Date(now.getTime() - Number(ago[1]) * UNIT_MS[ago[2]]).toISOString();
@@ -18,7 +21,14 @@ function parseMoment(text, now = new Date()) {
18
21
  }
19
22
  const at = new Date(text);
20
23
  if (!/^\d{4}-\d{2}-\d{2}/.test(text) || Number.isNaN(at.getTime())) {
21
- throw new errors_1.CliError(`--at ${text} is not a moment`, 'For example: --at 2h (two hours ago), --at 14:05 (today), --at 2026-10-30T14:05+03:00');
24
+ throw new errors_1.CliError(`--at ${text} is not a moment`, 'For example: --at 2h (two hours ago), --at 14:05 (today), --at 2026-10-30T14:05+03:00, --at r12 (when functions revision 12 went live)');
22
25
  }
23
26
  return at.toISOString();
24
27
  }
28
+ /** A branch lifetime in whole hours: `12h` or `3d`. */
29
+ function parseTtl(text) {
30
+ const m = /^(\d{1,4})([hd])$/.exec(text);
31
+ if (!m || Number(m[1]) < 1)
32
+ throw new errors_1.CliError(`--ttl ${text} is not a lifetime`, 'For example: --ttl 12h or --ttl 3d');
33
+ return Number(m[1]) * (m[2] === 'd' ? 24 : 1);
34
+ }
package/dist/version.js CHANGED
@@ -2,6 +2,6 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.DEFAULT_API_URL = exports.CLI_VERSION = void 0;
4
4
  /** Keep in sync with cli/package.json. */
5
- exports.CLI_VERSION = '1.9.0';
5
+ exports.CLI_VERSION = '1.11.0';
6
6
  /** Overridden by XFLOW_API_URL or the `api` field in xflow.json. */
7
7
  exports.DEFAULT_API_URL = 'https://app.getxflow.com';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getxflow/cli",
3
- "version": "1.9.0",
3
+ "version": "1.11.0",
4
4
  "description": "CLI for the XFlow platform: projects with apps, functions and a database; source sync, deployment and publishing",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
@@ -28,7 +28,7 @@ instead of guessing; give the person https://docs.getxflow.com when they ask how
28
28
 
29
29
  ## Keeping these instructions current
30
30
 
31
- These instructions ship with xflow CLI 1.9.0, so the copy you are reading can be older than
31
+ These instructions ship with xflow CLI 1.11.0, so the copy you are reading can be older than
32
32
  the CLI answering your commands.
33
33
 
34
34
  When the CLI says this file does not match it, run `xflow skills --refresh` and then **read
@@ -119,7 +119,8 @@ schedules may run, and which access levels an app may have. The number of functi
119
119
  and projects is not capped; at most 15 new projects a day (UTC) per organization, deleted ones count. Most are finite; a plan
120
120
  may leave one uncapped, and then `whoami` prints `no limit` instead of a ceiling.
121
121
  A paid plan comes in packages (Start S/M/L, Business M/L/XL): the package sets the space,
122
- database hours and function minutes, the plan sets the rest. More space is a bigger package
122
+ database GB-hours (1 GB of memory a database actually uses for an hour awake; a sleeping
123
+ one uses none) and function minutes, the plan sets the rest. More space is a bigger package
123
124
  chosen by a person in «Тариф» (Plan); there is no separate purchase of space.
124
125
  `xflow whoami` prints every one of them next to what is used, and the rights of your own key
125
126
  with them: destroying data (a migration or `db exec`), deleting files from storage, moving a database and
@@ -552,14 +553,18 @@ There is no undo: read the rows with `db query` first.
552
553
 
553
554
  A branch is a database of its own, as the project database is now or was at a moment of its
554
555
  history (6 hours back on Free, 3 days on Start, 7 days on Business), made in seconds without
555
- copying: `xflow db branch create try-orders [--at 2h|14:05|<ISO date>]`. Try a migration or a fix
556
+ copying: `xflow db branch create try-orders [--at 2h|14:05|<ISO date>|r12]` (`r12`: when functions
557
+ revision 12 went live; rolling functions back with `--revision` leaves the database as it is). Try a migration or a fix
556
558
  there first (`db migrate`, `db exec`, `db query` take `--branch <name>`); nothing reaches the
557
559
  project database. After a data mistake, do not roll anything back: branch as of a moment before
558
560
  it, check the rows with `db query --branch`, then `xflow db restore --from <branch> --table <t>
559
561
  [--where '...']` puts the missing rows back by primary key and keeps all newer data
560
562
  (`--overwrite` replaces rows still there and needs the destroy right). Branches per project: 2,
561
- 5, 15 by plan; delete them when done (`xflow db branch delete <name>`). A branch sleeps after a
562
- quiet minute, has no functions, and a branch of a branch is not made.
563
+ 5, 15 by plan; delete them when done (`xflow db branch delete <name>`). A branch is deleted on its
564
+ own when its lifetime ends: at most 1, 7 or 30 days by plan, shorter with `--ttl 12h`, more from
565
+ now with `xflow db branch extend <name>`. A branch sleeps after a quiet minute, has no functions,
566
+ and a branch of a branch is not made. People see the branches in Databases, open them there, read
567
+ their data and bring missing rows back.
563
568
 
564
569
  Anything that destroys data
565
570
  (`DROP TABLE`, `DROP COLUMN`, `TRUNCATE`, `DELETE`) needs both the `--allow-destructive` flag and
@@ -569,7 +574,12 @@ or deleted rows is rolled back, however the SQL was written. `BEGIN`/`COMMIT` in
569
574
  refused: each one already runs in its own transaction. When the refusal is about the right, the flag changes nothing: say what
570
575
  needs deleting and why, and let the person decide. With both in place the affected tables are
571
576
  dumped first (up to 50 000 rows each) and kept for 7 days; getting a dump back is a request to
572
- the person, no command does it. Check with `--dry-run`.
577
+ the person, no command does it.
578
+
579
+ New migrations run first, all of them, on a copy of the database as it is now: if one fails
580
+ there, nothing is applied and the database is unchanged, so a half-applied set does not happen.
581
+ `--dry-run` runs only that check. A copy that could not be made is skipped, and the migration goes
582
+ straight to the database as before.
573
583
 
574
584
  Pass `DATABASE_URL` as is, without an `ssl` option. The database is PostgreSQL 18, so do not
575
585
  tune it from migrations: