@getxflow/cli 1.2.1 → 1.3.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/bin.js CHANGED
@@ -176,6 +176,10 @@ async function run(args) {
176
176
  await (0, db_1.dbQuery)({ ...args, words: args.words.slice(2) });
177
177
  return;
178
178
  }
179
+ if (second === 'exec') {
180
+ await (0, db_1.dbExec)({ ...args, words: args.words.slice(2) });
181
+ return;
182
+ }
179
183
  if (second === 'move') {
180
184
  await (0, db_1.dbMove)(rest);
181
185
  return;
@@ -184,7 +188,7 @@ async function run(args) {
184
188
  await (0, db_1.dbStatus)({ ...args, words: args.words.slice(2) });
185
189
  return;
186
190
  }
187
- throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, migrate and move');
191
+ throw new errors_1.CliError(`Unknown command: db ${second}`, 'Available: status, list, schema, query, exec, migrate and move');
188
192
  case 'storage':
189
193
  if (second === 'push') {
190
194
  await (0, storage_1.storagePush)(rest);
@@ -4,6 +4,7 @@ exports.dbStatus = dbStatus;
4
4
  exports.dbList = dbList;
5
5
  exports.dbSchema = dbSchema;
6
6
  exports.dbQuery = dbQuery;
7
+ exports.dbExec = dbExec;
7
8
  exports.dbMove = dbMove;
8
9
  exports.dbMigrate = dbMigrate;
9
10
  exports.migrateBeforeDeploy = migrateBeforeDeploy;
@@ -227,6 +228,20 @@ async function dbSchema(args) {
227
228
  ]));
228
229
  (0, ui_1.note)((0, ui_1.dim)(' Columns of one table: xflow db schema <table>'));
229
230
  }
231
+ /**
232
+ * The statement typed after the command, or read from the .sql file named there.
233
+ * The hint is split in lines: an agent terminal cuts a long one at its width.
234
+ */
235
+ function readSql(words, command, example, file) {
236
+ const typed = words.join(' ').trim();
237
+ const sql = /\.sql$/i.test(typed)
238
+ ? (0, text_file_1.readTextFile)(typed, 'An argument ending in .sql is read as a file: check the path, or end the statement with ;')
239
+ : typed;
240
+ if (!sql) {
241
+ throw new errors_1.CliError(typed ? `${typed} is empty` : 'An SQL statement is required', `For example: xflow db ${command} '${example}'\n A long one from a file outside the project: xflow db ${command} ../${file}`);
242
+ }
243
+ return sql;
244
+ }
230
245
  /**
231
246
  * Reading data.
232
247
  *
@@ -234,14 +249,7 @@ async function dbSchema(args) {
234
249
  * database itself rather than by us guessing at the text of the SQL.
235
250
  */
236
251
  async function dbQuery(args) {
237
- const typed = args.words.join(' ').trim();
238
- const sql = /\.sql$/i.test(typed)
239
- ? (0, text_file_1.readTextFile)(typed, 'An argument ending in .sql is read as a file: check the path, or end the query with ;')
240
- : typed;
241
- if (!sql) {
242
- throw new errors_1.CliError(typed ? `${typed} is empty` : 'A query is required', "For example: xflow db query 'select * from orders order by created_at desc limit 20', " +
243
- 'or a long one from a file outside the project: xflow db query ../report.sql');
244
- }
252
+ const sql = readSql(args.words, 'query', 'select * from orders order by created_at desc limit 20', 'report.sql');
245
253
  const { projectId, client } = await (0, session_1.projectTarget)(args);
246
254
  const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/query`, {
247
255
  method: 'POST',
@@ -280,6 +288,46 @@ async function dbQuery(args) {
280
288
  : ` Shown the first ${data.rows.length} rows, more exist: narrow the query, with a where clause or an offset`));
281
289
  }
282
290
  }
291
+ /**
292
+ * A one-off data change: one INSERT, UPDATE, DELETE or MERGE, saved at once and
293
+ * kept out of the migration history. The platform judges it by what the database
294
+ * reports, not by the text: schema changes are refused, a mass change asks for
295
+ * --allow-destructive.
296
+ */
297
+ async function dbExec(args) {
298
+ const sql = readSql(args.words, 'exec', "update orders set status = 'paid' where id = 42", 'fix.sql');
299
+ const { projectId, client } = await (0, session_1.projectTarget)(args);
300
+ const data = await (0, api_1.apiJson)(client, `/api/v1/projects/${projectId}/db/exec`, {
301
+ method: 'POST',
302
+ body: { sql, allow_destructive: (0, args_1.flagBool)(args, 'allow-destructive') },
303
+ timeoutMs: 120_000,
304
+ });
305
+ if ((0, args_1.flagBool)(args, 'json')) {
306
+ (0, ui_1.out)(JSON.stringify(data, null, 2));
307
+ return;
308
+ }
309
+ // A write inside WITH comes tagged SELECT: its count is the rows read, the tables below say what changed.
310
+ (0, ui_1.ok)(data.command === 'SELECT'
311
+ ? 'The writes inside WITH are saved'
312
+ : `${data.command}: ${data.row_count} ${data.row_count === 1 ? 'row' : 'rows'}, saved`);
313
+ for (const t of data.tables) {
314
+ const parts = [
315
+ t.inserted > 0 ? `${t.inserted} inserted` : '',
316
+ t.updated > 0 ? `${t.updated} updated` : '',
317
+ t.deleted > 0 ? `${t.deleted} deleted` : '',
318
+ ].filter(Boolean);
319
+ (0, ui_1.note)((0, ui_1.dim)(` ${t.table}: ${parts.join(', ')}`));
320
+ }
321
+ for (const dump of data.dumps) {
322
+ (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`));
323
+ }
324
+ if (data.rows.length > 0) {
325
+ (0, ui_1.out)('');
326
+ (0, ui_1.table)([data.columns, ...data.rows.map((row) => data.columns.map((column) => cell(row[column])))]);
327
+ if (data.truncated)
328
+ (0, ui_1.note)((0, ui_1.dim)(` The first ${data.rows.length} returned rows: the rest are saved but not sent back`));
329
+ }
330
+ }
283
331
  const MOVE_POLL_MS = 3_000;
284
332
  /** Past this the command lets go: the move carries on, db status follows it. */
285
333
  const MOVE_FOLLOW_MS = 10 * 60_000;
package/dist/flags.js CHANGED
@@ -79,6 +79,7 @@ exports.KNOWN = {
79
79
  'db list': [],
80
80
  'db schema': ['project'],
81
81
  'db query': ['json', 'project'],
82
+ 'db exec': ['allow-destructive', 'json', 'project'],
82
83
  'db migrate': ['dry-run', 'allow-destructive'],
83
84
  'db move': ['to', 'yes', 'project'],
84
85
  storage: ['json', 'project'],
package/dist/help.js CHANGED
@@ -94,6 +94,7 @@ ${(0, ui_1.bold)('Database')}
94
94
  xflow db list databases of the projects and the servers of the organization
95
95
  xflow db schema [table] tables of the project, or the columns of one
96
96
  xflow db query 'select ...' read data, in a read-only transaction
97
+ xflow db exec 'update ...' a one-off data change, outside the migration history
97
98
  xflow db migrate [--dry-run] [--allow-destructive]
98
99
  apply the migrations from migrations/*.sql
99
100
  xflow db move --to <server> --yes
@@ -172,7 +173,7 @@ no working copy of its own:
172
173
  xflow db query --project 3f2a… 'select id, title from tasks where done = false'
173
174
  xflow functions invoke kanban --project 3f2a… --data-file card.json
174
175
 
175
- Which commands: ${(0, ui_1.bold)('db status')}, ${(0, ui_1.bold)('db query')}, ${(0, ui_1.bold)('db schema')}, ${(0, ui_1.bold)('db move')}, ${(0, ui_1.bold)('functions list')},
176
+ Which commands: ${(0, ui_1.bold)('db status')}, ${(0, ui_1.bold)('db query')}, ${(0, ui_1.bold)('db exec')}, ${(0, ui_1.bold)('db schema')}, ${(0, ui_1.bold)('db move')}, ${(0, ui_1.bold)('functions list')},
176
177
  ${(0, ui_1.bold)('functions invoke')}, ${(0, ui_1.bold)('functions logs')}, ${(0, ui_1.bold)('logs')}, ${(0, ui_1.bold)('env')}, ${(0, ui_1.bold)('env rm')},
177
178
  ${(0, ui_1.bold)('connections')}, ${(0, ui_1.bold)('connections link')}, ${(0, ui_1.bold)('connections unlink')}, ${(0, ui_1.bold)('schedules')},
178
179
  ${(0, ui_1.bold)('schedules set')}, ${(0, ui_1.bold)('schedules rm')}, ${(0, ui_1.bold)('storage ls')}, ${(0, ui_1.bold)('storage push')},
@@ -234,7 +235,7 @@ project, apps in subfolders:
234
235
 
235
236
  The root and every app are separate histories: apps/** never travels with the root, the
236
237
  root never travels with an app. The app is its appId, so a folder can be renamed freely.
237
- Who opens an app (members of the project, a list, anyone with the link) is set by a person
238
+ Who opens an app (members of the project or anyone with the link) is set by a person
238
239
  in the web interface, not here.
239
240
 
240
241
  Both ${(0, ui_1.bold)('projects')} commands name the project positionally: the id is an argument, not a
@@ -341,6 +342,8 @@ will work.`,
341
342
  xflow db query 'select ...' read data (rows: the query's own limit, 50 without
342
343
  one, 300 at most; --json prints the values whole)
343
344
  xflow db query <file>.sql the same, the query read from a file
345
+ xflow db exec 'update ...' a one-off data change: one INSERT, UPDATE, DELETE or MERGE
346
+ (or <file>.sql; --allow-destructive, --json)
344
347
  xflow db migrate store the project root, then apply migrations/*.sql
345
348
  xflow db move --to <server> --yes
346
349
  move the project database to another server
@@ -374,6 +377,15 @@ long values to keep the columns readable; ${(0, ui_1.bold)('--json')} prints the
374
377
  the applied limit and its ceiling. ${(0, ui_1.bold)('status')} named by id (${(0, ui_1.bold)('--project')}) answers with
375
378
  the applied history alone: comparing against ${(0, ui_1.bold)('migrations/')} needs the folder.
376
379
 
380
+ ${(0, ui_1.bold)('exec')} fixes data once: one INSERT, UPDATE, DELETE or MERGE, saved at once and kept out
381
+ of the migration history, so a one-off fix no longer becomes a migration forever. Schema
382
+ changes are refused: they stay with ${(0, ui_1.bold)('migrate')}. The platform judges the change by what the
383
+ database reports, cascades and triggers included. Deleting rows, or updating more than 100
384
+ in a table, asks for ${(0, ui_1.bold)('--allow-destructive')}, and those tables are copied for 7 days before it
385
+ is saved; deleting also needs the right to destroy data on the key, as in migrations (see
386
+ below). The rows it holds are locked while it runs, so a statement that waits on a running
387
+ app is cancelled after a few seconds and changes nothing. There is no undo.
388
+
377
389
  ${(0, ui_1.bold)('migrate')}: the files live in the project root (${(0, ui_1.bold)('migrations/0001_init.sql')},
378
390
  ${(0, ui_1.bold)('migrations/0002_orders.sql')} and so on). The order comes from the file name, which
379
391
  is why the leading number is required. The command first stores the project root as a
@@ -385,8 +397,8 @@ itself, and anything already applied is not run again.
385
397
  --allow-destructive allow operations that destroy data
386
398
 
387
399
  About destructive ones. The platform keeps no database history and makes no backups,
388
- so ${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} and ${(0, ui_1.bold)('DELETE FROM')} without a
389
- condition need two things at once: this flag, and the right to destroy data on the
400
+ so ${(0, ui_1.bold)('DROP TABLE')}, ${(0, ui_1.bold)('DROP COLUMN')}, ${(0, ui_1.bold)('TRUNCATE')} and deleting rows (judged by what
401
+ the database did, not by the text) need two things at once: this flag, and the right to destroy data on the
390
402
  access key. That right is off by default, and only its owner turns it on, in the
391
403
  platform settings, where the key permissions live: a flag is something an agent adds by itself, a
392
404
  right is not. With both in place, the contents of the affected tables are dumped and
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.2.1';
5
+ exports.CLI_VERSION = '1.3.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.2.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": ">=20"
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.3.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": ">=20"
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
+ }