@getxflow/cli 1.8.1 → 1.10.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.
package/dist/args.js CHANGED
@@ -21,6 +21,7 @@ exports.BOOLEAN_FLAGS = new Set([
21
21
  'dry-run',
22
22
  'allow-destructive',
23
23
  'allow-removals',
24
+ 'overwrite',
24
25
  ]);
25
26
  function parseArgs(argv) {
26
27
  const words = [];
package/dist/bin.js CHANGED
@@ -183,11 +183,19 @@ async function run(args) {
183
183
  await (0, logs_1.dbLogs)(args);
184
184
  return;
185
185
  }
186
+ if (second === 'branch' || second === 'branches') {
187
+ await (0, db_1.dbBranch)({ ...args, words: args.words.slice(2) });
188
+ return;
189
+ }
190
+ if (second === 'restore') {
191
+ await (0, db_1.dbRestore)(args);
192
+ return;
193
+ }
186
194
  if (second === undefined || second === 'status') {
187
195
  await (0, db_1.dbStatus)({ ...args, words: args.words.slice(2) });
188
196
  return;
189
197
  }
190
- throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, exec and migrate');
198
+ throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, exec, migrate, branch and restore');
191
199
  case 'storage':
192
200
  if (second === 'push') {
193
201
  await (0, storage_1.storagePush)(rest);
@@ -7,6 +7,8 @@ exports.dbQuery = dbQuery;
7
7
  exports.dbExec = dbExec;
8
8
  exports.dbMigrate = dbMigrate;
9
9
  exports.migrateBeforeDeploy = migrateBeforeDeploy;
10
+ exports.dbBranch = dbBranch;
11
+ exports.dbRestore = dbRestore;
10
12
  const node_fs_1 = require("node:fs");
11
13
  const node_path_1 = require("node:path");
12
14
  const api_1 = require("../api");
@@ -14,6 +16,7 @@ const args_1 = require("../args");
14
16
  const config_1 = require("../config");
15
17
  const errors_1 = require("../errors");
16
18
  const session_1 = require("../session");
19
+ const moment_1 = require("../moment");
17
20
  const text_file_1 = require("../text-file");
18
21
  const ui_1 = require("../ui");
19
22
  const sources_1 = require("./sources");
@@ -171,7 +174,7 @@ async function dbQuery(args) {
171
174
  const { projectId, client } = await (0, session_1.projectTarget)(args);
172
175
  const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/query`, {
173
176
  method: 'POST',
174
- body: { sql },
177
+ body: { sql, branch: (0, args_1.flagString)(args, 'branch') },
175
178
  timeoutMs: 60_000,
176
179
  });
177
180
  // The whole values, machine-readable, and the only thing printed: --json silences
@@ -217,7 +220,7 @@ async function dbExec(args) {
217
220
  const { projectId, client } = await (0, session_1.projectTarget)(args);
218
221
  const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/exec`, {
219
222
  method: 'POST',
220
- body: { sql, allow_destructive: (0, args_1.flagBool)(args, 'allow-destructive') },
223
+ body: { sql, allow_destructive: (0, args_1.flagBool)(args, 'allow-destructive'), branch: (0, args_1.flagString)(args, 'branch') },
221
224
  timeoutMs: 120_000,
222
225
  });
223
226
  if ((0, args_1.flagBool)(args, 'json')) {
@@ -251,11 +254,13 @@ async function dbMigrate(args) {
251
254
  const client = await (0, session_1.connectProject)(config.projectId, root, config);
252
255
  const migrations = requireMigrations(root);
253
256
  const dryRun = (0, args_1.flagBool)(args, 'dry-run');
257
+ const branch = (0, args_1.flagString)(args, 'branch');
254
258
  // Migrations live in the revisions of the project root: without this a project
255
259
  // without functions would lose them from its history, and a clone would miss them.
256
- if (!dryRun)
260
+ // A try on a branch is not history yet.
261
+ if (!dryRun && !branch)
257
262
  await (0, sources_1.pushSources)({ kind: 'project', root, projectId: config.projectId }, client, { force: false });
258
- await migrate(client, config.projectId, migrations, dryRun, (0, args_1.flagBool)(args, 'allow-destructive'));
263
+ await migrate(client, config.projectId, migrations, dryRun, (0, args_1.flagBool)(args, 'allow-destructive'), branch);
259
264
  }
260
265
  /** The functions deploy runs this first: new code must not meet an old schema. */
261
266
  async function migrateBeforeDeploy(client, root, projectId) {
@@ -271,14 +276,16 @@ async function migrateBeforeDeploy(client, root, projectId) {
271
276
  throw e;
272
277
  }
273
278
  }
274
- async function migrate(client, projectId, migrations, dryRun, allowDestructive) {
275
- (0, ui_1.step)(dryRun ? `Checking the migrations (${migrations.length} in the directory)` : `Applying the migrations (${migrations.length} in the directory)`);
279
+ async function migrate(client, projectId, migrations, dryRun, allowDestructive, branch) {
280
+ const where = branch ? ` to branch ${branch}` : '';
281
+ (0, ui_1.step)(dryRun ? `Checking the migrations${where} (${migrations.length} in the directory)` : `Applying the migrations${where} (${migrations.length} in the directory)`);
276
282
  const result = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/migrate`, {
277
283
  method: 'POST',
278
284
  body: {
279
285
  migrations,
280
286
  dry_run: dryRun,
281
287
  allow_destructive: allowDestructive,
288
+ branch,
282
289
  },
283
290
  timeoutMs: 300_000,
284
291
  });
@@ -314,3 +321,98 @@ async function migrate(client, projectId, migrations, dryRun, allowDestructive)
314
321
  : 'The database is unchanged: the migration rolled back as a whole');
315
322
  }
316
323
  }
324
+ /** Time a branch has left, `5h` or `3d`. */
325
+ function left(iso) {
326
+ const h = Math.max(0, Math.round((Date.parse(iso) - Date.now()) / 3_600_000));
327
+ return h < 48 ? `${h}h left` : `${Math.round(h / 24)}d left`;
328
+ }
329
+ /** Branches of the project database: list, create, delete. */
330
+ async function dbBranch(args) {
331
+ const [sub, name] = args.words;
332
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
333
+ const path = `/api/v1/projects/${projectId}/db/branches`;
334
+ if (sub === undefined || sub === 'list') {
335
+ const data = await (0, api_1.apiJson)(client, path);
336
+ if ((0, args_1.flagBool)(args, 'json')) {
337
+ (0, ui_1.out)(JSON.stringify(data, null, 2));
338
+ return;
339
+ }
340
+ if (data.branches.length === 0) {
341
+ (0, ui_1.note)('No branches');
342
+ (0, ui_1.note)((0, ui_1.dim)(' A copy to try things on: xflow db branch create <name> [--at 2h]'));
343
+ return;
344
+ }
345
+ (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)]));
346
+ (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>)`));
347
+ return;
348
+ }
349
+ if (!name)
350
+ throw new errors_1.CliError(`xflow db branch ${sub} needs a name`, `For example: xflow db branch ${sub} fix-orders`);
351
+ if (sub === 'create') {
352
+ const at = (0, args_1.flagString)(args, 'at');
353
+ const ttl = (0, args_1.flagString)(args, 'ttl');
354
+ const data = await (0, api_1.apiJson)(client, path, {
355
+ method: 'POST',
356
+ body: { name, at: at ? (0, moment_1.parseMoment)(at) : undefined, ttl_hours: ttl ? (0, moment_1.parseTtl)(ttl) : undefined },
357
+ timeoutMs: 180_000,
358
+ });
359
+ if ((0, args_1.flagBool)(args, 'json')) {
360
+ (0, ui_1.out)(JSON.stringify(data.branch, null, 2));
361
+ return;
362
+ }
363
+ (0, ui_1.ok)(`Branch ${name}: the project database ${data.branch.at ? `as of ${new Date(data.branch.at).toLocaleString()}` : 'as it is now'}`);
364
+ (0, ui_1.note)((0, ui_1.dim)(` Work in it: xflow db query --branch ${name} '...', db exec and db migrate take --branch too`));
365
+ (0, ui_1.note)((0, ui_1.dim)(` Rows back into the project database: xflow db restore --from ${name} --table <table>`));
366
+ (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}`));
367
+ return;
368
+ }
369
+ if (sub === 'extend') {
370
+ const ttl = (0, args_1.flagString)(args, 'ttl');
371
+ const data = await (0, api_1.apiJson)(client, `${path}/${encodeURIComponent(name)}`, {
372
+ method: 'PATCH',
373
+ body: { ttl_hours: ttl ? (0, moment_1.parseTtl)(ttl) : undefined },
374
+ });
375
+ if ((0, args_1.flagBool)(args, 'json')) {
376
+ (0, ui_1.out)(JSON.stringify(data.branch, null, 2));
377
+ return;
378
+ }
379
+ (0, ui_1.ok)(`Branch ${name}: ${left(data.branch.expires_at)}, until ${new Date(data.branch.expires_at).toLocaleString()}`);
380
+ return;
381
+ }
382
+ if (sub === 'delete' || sub === 'rm') {
383
+ await (0, api_1.apiJson)(client, `${path}/${encodeURIComponent(name)}`, { method: 'DELETE' });
384
+ (0, ui_1.ok)(`Branch ${name} deleted; the project database is unchanged`);
385
+ return;
386
+ }
387
+ throw new errors_1.CliError(`Unknown command: db branch ${sub}`, 'Available: list, create, extend and delete');
388
+ }
389
+ /** Rows of a table from a branch back into the project database, by primary key. */
390
+ async function dbRestore(args) {
391
+ const from = (0, args_1.flagString)(args, 'from');
392
+ const tableName = (0, args_1.flagString)(args, 'table');
393
+ if (!from || !tableName) {
394
+ throw new errors_1.CliError('Name the branch and the table', "For example: xflow db restore --from before-import --table orders --where 'id in (7, 9)'");
395
+ }
396
+ const overwrite = (0, args_1.flagBool)(args, 'overwrite');
397
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
398
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/restore`, {
399
+ method: 'POST',
400
+ body: { branch: from, table: tableName, where: (0, args_1.flagString)(args, 'where'), overwrite },
401
+ timeoutMs: 120_000,
402
+ });
403
+ if ((0, args_1.flagBool)(args, 'json')) {
404
+ (0, ui_1.out)(JSON.stringify(data, null, 2));
405
+ return;
406
+ }
407
+ if (data.read === 0) {
408
+ (0, ui_1.note)(`No rows of ${tableName} in branch ${from} match`);
409
+ return;
410
+ }
411
+ (0, ui_1.ok)(`${data.restored} of ${data.read} rows of ${tableName} from branch ${from} ${overwrite ? 'written' : 'put back'}`);
412
+ for (const dump of data.dumps ?? []) {
413
+ (0, ui_1.note)((0, ui_1.dim)(` Copy of ${dump.table} before the change: ${dump.rows} rows, kept for ${data.dump_retention_days} days`));
414
+ }
415
+ if (!overwrite && data.restored < data.read) {
416
+ (0, ui_1.note)((0, ui_1.dim)(` The other ${data.read - data.restored} are in the database already and kept as they are: --overwrite replaces them`));
417
+ }
418
+ }
package/dist/flags.js CHANGED
@@ -78,10 +78,13 @@ exports.KNOWN = {
78
78
  'db status': ['project'],
79
79
  'db list': [],
80
80
  'db schema': ['project'],
81
- 'db query': ['json', 'project'],
82
- 'db exec': ['allow-destructive', 'json', 'project'],
81
+ 'db query': ['json', 'project', 'branch'],
82
+ 'db exec': ['allow-destructive', 'json', 'project', 'branch'],
83
83
  'db logs': ['limit', 'project', 'cursor'],
84
- 'db migrate': ['dry-run', 'allow-destructive'],
84
+ 'db migrate': ['dry-run', 'allow-destructive', 'branch'],
85
+ 'db branch': ['at', 'json', 'project', 'ttl'],
86
+ 'db branches': ['at', 'json', 'project', 'ttl'],
87
+ 'db restore': ['from', 'table', 'where', 'overwrite', 'json', 'project'],
85
88
  storage: ['json', 'project'],
86
89
  'storage ls': ['json', 'project'],
87
90
  'storage list': ['json', 'project'],
package/dist/help.js CHANGED
@@ -98,6 +98,10 @@ ${(0, ui_1.bold)('Database')}
98
98
  xflow db logs errors the database raised, newest first
99
99
  xflow db migrate [--dry-run] [--allow-destructive]
100
100
  apply the migrations from migrations/*.sql
101
+ xflow db branch create <name> [--at 2h]
102
+ a copy of the database to try things on, now or as it was
103
+ xflow db restore --from <branch> --table <table>
104
+ put rows back from a branch, newer data kept
101
105
 
102
106
  ${(0, ui_1.bold)('Reference')}
103
107
  xflow logs [--limit N] browser errors of the published apps
@@ -290,6 +294,14 @@ will work.`,
290
294
  xflow db logs errors the database raised: failed and timed-out queries
291
295
  with their SQL (cut to 2 KB), dropped connections; 7 days,
292
296
  1 on the Free plan (--limit, --cursor for older)
297
+ xflow db branch [list] branches of the database (--json)
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;
300
+ --ttl 12h or --ttl 3d to delete it sooner than the plan does
301
+ xflow db branch extend <name> more time from now: the plan's longest, or --ttl
302
+ xflow db branch delete <name> the branch goes, the database is unchanged
303
+ xflow db restore --from <branch> --table <table> [--where '...'] [--overwrite]
304
+ rows of a table from a branch back into the database
293
305
 
294
306
  Every project has exactly one database of its own, shared by all of its apps and functions,
295
307
  and every command here works from any folder of the project. ${(0, ui_1.bold)('list')} is about the
@@ -315,6 +327,23 @@ is saved; deleting also needs the right to destroy data on the key, as in migrat
315
327
  below). The rows it holds are locked while it runs, so a statement that waits on a running
316
328
  app is cancelled after a few seconds and changes nothing. There is no undo.
317
329
 
330
+ ${(0, ui_1.bold)('branch')}: a database of its own, as the project database is now or was at a moment of its
331
+ history, made in seconds without copying: try a migration or a fix there (${(0, ui_1.bold)('--branch <name>')} on
332
+ ${(0, ui_1.bold)('query')}, ${(0, ui_1.bold)('exec')} and ${(0, ui_1.bold)('migrate')}), or read the data as it was. Nothing done there
333
+ reaches the project database. A branch sleeps after a quiet minute, so its first query takes a
334
+ second or two; it has no functions and no history of its own, and a branch of a branch is not
335
+ made. Branches per project: 2 on Free, 5 on Start, 15 on Business; delete one you no longer need.
336
+ A branch is deleted on its own when its lifetime ends: at most 1 day on Free, 7 days on Start,
337
+ 30 days on Business, and ${(0, ui_1.bold)('extend')} gives it more from now within that. The history reaches
338
+ back 6 hours on Free, 3 days on Start, 7 days on Business.
339
+
340
+ ${(0, ui_1.bold)('restore')} puts rows back after a mistake without rolling the whole database back: make a
341
+ branch as of a moment before it, check the rows there with ${(0, ui_1.bold)('query --branch')}, then restore
342
+ them. Rows are told apart by the primary key: the missing ones go back in, the ones still there
343
+ stay as they are, and ${(0, ui_1.bold)('--overwrite')} replaces them with the branch version (it needs the
344
+ right to destroy data on the key). Everything the database got after that moment is kept. Up
345
+ to 10 000 rows at a time: narrow with ${(0, ui_1.bold)('--where')}.
346
+
318
347
  ${(0, ui_1.bold)('migrate')}: the files live in the project root (${(0, ui_1.bold)('migrations/0001_init.sql')},
319
348
  ${(0, ui_1.bold)('migrations/0002_orders.sql')} and so on). The order comes from the file name, which
320
349
  is why the leading number is required. The command first stores the project root as a
@@ -325,13 +354,13 @@ itself, and anything already applied is not run again.
325
354
  --dry-run report what would be applied without touching the database
326
355
  --allow-destructive allow operations that destroy data
327
356
 
328
- About destructive ones. The platform keeps no database history and makes no backups,
329
- so ${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} and deleting rows (judged by what
357
+ 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
330
358
  the database did, not by the text) need two things at once: this flag, and the right to destroy data on the
331
359
  access key. That right is off by default, and only its owner turns it on, in the
332
360
  platform settings, where the key permissions live: a flag is something an agent adds by itself, a
333
361
  right is not. With both in place, the contents of the affected tables are dumped and
334
- kept for 7 days: that is a "caught it right away" safety net, not a backup.
362
+ kept for 7 days. Past that, a branch as of a moment before the migration holds the data
363
+ while the history reaches that far (see ${(0, ui_1.bold)('branch')} above).
335
364
  ${(0, ui_1.bold)('DROP DATABASE')} is never allowed.
336
365
 
337
366
  Editing an already applied file achieves nothing: ${(0, ui_1.bold)('migrate')} refuses a name that is
package/dist/limits.js CHANGED
@@ -15,6 +15,7 @@ const DENIAL_LABELS = {
15
15
  limit_seats: 'people in the organization',
16
16
  limit_schedule_interval: 'schedule frequency',
17
17
  limit_schedules: 'schedules in the organization',
18
+ limit_branches: 'database branches in the project',
18
19
  db_write_locked: 'database volume, writes are off',
19
20
  plan_no_schedules: 'schedules',
20
21
  subscription_blocked: 'the subscription is unpaid',
@@ -53,7 +54,7 @@ const QUOTA_LABELS = {
53
54
  people: 'People',
54
55
  space_mb: 'Space (databases and files together)',
55
56
  function_minutes: 'Function minutes',
56
- db_hours: 'Database hours',
57
+ db_hours: 'Database GB-hours',
57
58
  builds: 'Builds this month',
58
59
  };
59
60
  /** Decimal units, 1000 MB per GB: matches how volumes are sold. */
package/dist/moment.js ADDED
@@ -0,0 +1,32 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseMoment = parseMoment;
4
+ exports.parseTtl = parseTtl;
5
+ const errors_1 = require("./errors");
6
+ const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
7
+ /** `30m`/`2h`/`1d` ago, `14:05` local (yesterday if still ahead), or an ISO date. */
8
+ function parseMoment(text, now = new Date()) {
9
+ const ago = /^(\d{1,4})([mhd])$/.exec(text);
10
+ if (ago)
11
+ return new Date(now.getTime() - Number(ago[1]) * UNIT_MS[ago[2]]).toISOString();
12
+ const clock = /^(\d{1,2}):(\d{2})$/.exec(text);
13
+ if (clock && Number(clock[1]) < 24 && Number(clock[2]) < 60) {
14
+ const at = new Date(now);
15
+ at.setHours(Number(clock[1]), Number(clock[2]), 0, 0);
16
+ if (at > now)
17
+ at.setDate(at.getDate() - 1);
18
+ return at.toISOString();
19
+ }
20
+ const at = new Date(text);
21
+ if (!/^\d{4}-\d{2}-\d{2}/.test(text) || Number.isNaN(at.getTime())) {
22
+ 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');
23
+ }
24
+ return at.toISOString();
25
+ }
26
+ /** A branch lifetime in whole hours: `12h` or `3d`. */
27
+ function parseTtl(text) {
28
+ const m = /^(\d{1,4})([hd])$/.exec(text);
29
+ if (!m || Number(m[1]) < 1)
30
+ throw new errors_1.CliError(`--ttl ${text} is not a lifetime`, 'For example: --ttl 12h or --ttl 3d');
31
+ return Number(m[1]) * (m[2] === 'd' ? 24 : 1);
32
+ }
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.8.1';
5
+ exports.CLI_VERSION = '1.10.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,24 +1,24 @@
1
- {
2
- "name": "@getxflow/cli",
3
- "version": "1.8.1",
4
- "description": "CLI for the XFlow platform: projects with apps, functions and a database; source sync, deployment and publishing",
5
- "license": "UNLICENSED",
6
- "engines": {
7
- "node": ">=22"
8
- },
9
- "bin": {
10
- "xflow": "dist/bin.js"
11
- },
12
- "files": [
13
- "dist",
14
- "skills"
15
- ],
16
- "scripts": {
17
- "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
18
- "build": "npm run clean && tsc -p tsconfig.json",
19
- "prepublishOnly": "npm run build"
20
- },
21
- "publishConfig": {
22
- "access": "public"
23
- }
24
- }
1
+ {
2
+ "name": "@getxflow/cli",
3
+ "version": "1.10.0",
4
+ "description": "CLI for the XFlow platform: projects with apps, functions and a database; source sync, deployment and publishing",
5
+ "license": "UNLICENSED",
6
+ "engines": {
7
+ "node": ">=22"
8
+ },
9
+ "bin": {
10
+ "xflow": "dist/bin.js"
11
+ },
12
+ "files": [
13
+ "dist",
14
+ "skills"
15
+ ],
16
+ "scripts": {
17
+ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
18
+ "build": "npm run clean && tsc -p tsconfig.json",
19
+ "prepublishOnly": "npm run build"
20
+ },
21
+ "publishConfig": {
22
+ "access": "public"
23
+ }
24
+ }