@dboio/cli 0.6.11 → 0.6.13

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,6 +157,7 @@ All configuration is **directory-scoped**. Each project folder maintains its own
157
157
  | `AppName` | string | App display name |
158
158
  | `AppShortName` | string | App short name (used for `dbo clone --app`) |
159
159
  | `AppModifyKey` | string | ModifyKey for locked/production apps (set by `dbo clone`, used for submission guards) |
160
+ | `TransactionKeyPreset` | `RowUID` \| `RowID` | Row key type for auto-assembled expressions (set during `dbo init`/`dbo clone`, default `RowUID`) |
160
161
  | `ContentPlacement` | `bin` \| `path` \| `ask` | Where to place content files during clone |
161
162
  | `MediaPlacement` | `bin` \| `fullpath` \| `ask` | Where to place media files during clone |
162
163
  | `<Entity>FilenameCol` | column name | Filename column for entity-dir records (e.g., `ExtensionFilenameCol`) |
@@ -489,6 +490,7 @@ dbo input -d 'RowUID:abc;column:content.Content@file.css' -v
489
490
  | `-C, --confirm <true\|false>` | Commit changes (default: `true`). Use `false` for validation only |
490
491
  | `--ticket <id>` | Override ticket ID (`_OverrideTicketID`) |
491
492
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
493
+ | `--row-key <type>` | Row key type (`RowUID` or `RowID`) — no-op for `-d` passthrough, available for consistency |
492
494
  | `--login` | Auto-login user created by this submission |
493
495
  | `--transactional` | Use transactional processing |
494
496
  | `--json` | Output raw JSON response |
@@ -618,6 +620,7 @@ dbo content deploy albain3dwkofbhnd1qtd1q assets/css/colors.css
618
620
  dbo content deploy rncjivlghu65bmkbjnxynq assets/js/app.js
619
621
  dbo content deploy abc123 docs/readme.md --ticket myTicket
620
622
  dbo content deploy abc123 test.html --confirm false # validate only
623
+ dbo content deploy abc123 image.png --multipart # binary file upload
621
624
  ```
622
625
 
623
626
  This is a shorthand for:
@@ -632,6 +635,8 @@ dbo input -d 'RowUID:albain3dwkofbhnd1qtd1q;column:content.Content@assets/css/co
632
635
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
633
636
  | `--ticket <id>` | Override ticket ID |
634
637
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
638
+ | `--multipart` | Use multipart/form-data upload (for binary files) |
639
+ | `--row-key <type>` | Override row key type for this invocation (`RowUID` or `RowID`) |
635
640
 
636
641
  #### `dbo content pull`
637
642
 
@@ -943,6 +948,7 @@ The `@colors.css` reference tells push to read the content from that file. All o
943
948
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
944
949
  | `--ticket <id>` | Override ticket ID |
945
950
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
951
+ | `--row-key <type>` | Override row key type for this invocation (`RowUID` or `RowID`) |
946
952
  | `--meta-only` | Only push metadata, skip file content |
947
953
  | `--content-only` | Only push file content, skip metadata |
948
954
  | `-y, --yes` | Auto-accept all prompts |
@@ -1106,6 +1112,7 @@ The `@colors.css` reference tells the CLI to read the file content from `colors.
1106
1112
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1107
1113
  | `--ticket <id>` | Override ticket ID |
1108
1114
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1115
+ | `--row-key <type>` | Row key type (`RowUID` or `RowID`) — `add` always uses `RowID:add1` for new records regardless |
1109
1116
  | `-y, --yes` | Auto-accept all prompts |
1110
1117
  | `--json` | Output raw JSON |
1111
1118
  | `--jq <expr>` | Filter JSON response |
@@ -1326,6 +1333,9 @@ dbo deploy js:app --ticket abc123
1326
1333
 
1327
1334
  # Validate without deploying
1328
1335
  dbo deploy css:colors --confirm false
1336
+
1337
+ # Deploy a multipart (binary) entry from manifest
1338
+ dbo deploy img:logo
1329
1339
  ```
1330
1340
 
1331
1341
  | Flag | Description |
@@ -1335,6 +1345,7 @@ dbo deploy css:colors --confirm false
1335
1345
  | `-C, --confirm <true\|false>` | Commit (default: `true`) |
1336
1346
  | `--ticket <id>` | Override ticket ID |
1337
1347
  | `--modify-key <key>` | Provide ModifyKey directly (skips interactive prompt) |
1348
+ | `--row-key <type>` | Override row key type for this invocation (`RowUID` or `RowID`) |
1338
1349
  | `--json` | Output raw JSON |
1339
1350
  | `-v, --verbose` | Show HTTP request details |
1340
1351
  | `--domain <host>` | Override domain |
@@ -1361,6 +1372,17 @@ Create a `dbo.deploy.json` file in your project root to define named deployments
1361
1372
  "file": "docs/colors.md",
1362
1373
  "entity": "extension",
1363
1374
  "column": "Text"
1375
+ },
1376
+ "img:logo": {
1377
+ "uid": "x9fk2m3npqrs7tuvwyz1ab",
1378
+ "file": "assets/images/logo.png",
1379
+ "multipart": true
1380
+ },
1381
+ "upload:icons": {
1382
+ "uid": "7ddf10982a96457fa4f440",
1383
+ "file": "assets/css/launchpad-icons.css",
1384
+ "multipart": true,
1385
+ "filename": "launchpad-icons.css"
1364
1386
  }
1365
1387
  }
1366
1388
  }
@@ -1370,8 +1392,10 @@ Create a `dbo.deploy.json` file in your project root to define named deployments
1370
1392
  |-------|-------------|---------|
1371
1393
  | `uid` | Target record UID (required) | — |
1372
1394
  | `file` | Local file path (required) | — |
1373
- | `entity` | Target entity name | `content` |
1374
- | `column` | Target column name | `Content` |
1395
+ | `entity` | Target entity name | `content` (`media` when `multipart: true`) |
1396
+ | `column` | Target column name | `Content` (`File` when `multipart: true`) |
1397
+ | `multipart` | Use multipart/form-data upload (for binary files) | `false` |
1398
+ | `filename` | Set the `Filename` column on the target record | basename of `file` |
1375
1399
 
1376
1400
  This replaces the curl commands typically embedded in `package.json` scripts.
1377
1401
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dboio/cli",
3
- "version": "0.6.11",
3
+ "version": "0.6.13",
4
4
  "description": "CLI for the DBO.io framework",
5
5
  "type": "module",
6
6
  "bin": {
@@ -19,6 +19,7 @@ export const addCommand = new Command('add')
19
19
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
20
20
  .option('--ticket <id>', 'Override ticket ID')
21
21
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
22
+ .option('--row-key <type>', 'Row key type (RowUID or RowID) — add uses RowID:add1 for new records regardless')
22
23
  .option('-y, --yes', 'Auto-accept all prompts')
23
24
  .option('--json', 'Output raw JSON')
24
25
  .option('--jq <expr>', 'Filter JSON response')
@@ -2,7 +2,7 @@ import { Command } from 'commander';
2
2
  import { readFile, writeFile, mkdir, access } from 'fs/promises';
3
3
  import { join, basename, extname } from 'path';
4
4
  import { DboClient } from '../lib/client.js';
5
- import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey } from '../lib/config.js';
5
+ import { loadConfig, updateConfigWithApp, loadClonePlacement, saveClonePlacement, ensureGitignore, saveEntityDirPreference, loadEntityDirPreference, saveEntityContentExtractions, loadEntityContentExtractions, saveAppJsonBaseline, addDeleteEntry, loadCollisionResolutions, saveCollisionResolutions, loadSynchronize, saveAppModifyKey, loadTransactionKeyPreset, saveTransactionKeyPreset } from '../lib/config.js';
6
6
  import { buildBinHierarchy, resolveBinPath, createDirectories, saveStructureFile, getBinName, findBinByPath, BINS_DIR, DEFAULT_PROJECT_DIRS, ENTITY_DIR_MAP } from '../lib/structure.js';
7
7
  import { log } from '../lib/logger.js';
8
8
  import { setFileTimestamps } from '../lib/timestamps.js';
@@ -511,6 +511,28 @@ export async function performClone(source, options = {}) {
511
511
  log.warn('');
512
512
  }
513
513
 
514
+ // Prompt for TransactionKeyPreset if not already set
515
+ const existingPreset = await loadTransactionKeyPreset();
516
+ if (!existingPreset) {
517
+ if (options.yes || !process.stdin.isTTY) {
518
+ await saveTransactionKeyPreset('RowUID');
519
+ log.dim(' TransactionKeyPreset: RowUID (default)');
520
+ } else {
521
+ const inquirer = (await import('inquirer')).default;
522
+ const { preset } = await inquirer.prompt([{
523
+ type: 'list',
524
+ name: 'preset',
525
+ message: 'Which row key should the CLI use when building input expressions?',
526
+ choices: [
527
+ { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
528
+ { name: 'RowID (numeric IDs)', value: 'RowID' },
529
+ ],
530
+ }]);
531
+ await saveTransactionKeyPreset(preset);
532
+ log.dim(` TransactionKeyPreset: ${preset}`);
533
+ }
534
+ }
535
+
514
536
  // Step 3: Update package.json
515
537
  await updatePackageJson(appJson, config);
516
538
 
@@ -7,6 +7,7 @@ import { formatResponse, formatError } from '../lib/formatter.js';
7
7
  import { saveToDisk } from '../lib/save-to-disk.js';
8
8
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
9
9
  import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
10
+ import { resolveTransactionKey } from '../lib/transaction-key.js';
10
11
  import { log } from '../lib/logger.js';
11
12
 
12
13
  function collect(value, previous) {
@@ -31,6 +32,8 @@ const deployCmd = new Command('deploy')
31
32
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
32
33
  .option('--ticket <id>', 'Override ticket ID')
33
34
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
35
+ .option('--multipart', 'Use multipart/form-data upload (for binary files)')
36
+ .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
34
37
  .option('--json', 'Output raw JSON')
35
38
  .option('-v, --verbose', 'Show HTTP request details')
36
39
  .option('--domain <host>', 'Override domain')
@@ -62,11 +65,14 @@ const deployCmd = new Command('deploy')
62
65
  }
63
66
  }
64
67
 
68
+ const transactionKey = await resolveTransactionKey(options);
65
69
  const extraParams = { '_confirm': options.confirm };
66
70
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
67
71
  if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
68
72
 
69
- const dataExprs = [`RowUID:${uid};column:content.Content@${filepath}`];
73
+ const isMultipart = options.multipart === true;
74
+ const rowKeyExpr = transactionKey === 'RowID' ? `RowID:${uid}` : `RowUID:${uid}`;
75
+ const dataExprs = isMultipart ? [] : [`${rowKeyExpr};column:content.Content@${filepath}`];
70
76
 
71
77
  // Apply stored ticket if no --ticket flag
72
78
  if (!options.ticket) {
@@ -77,16 +83,36 @@ const deployCmd = new Command('deploy')
77
83
  }
78
84
  await applyStoredTicketToSubmission(dataExprs, 'content', uid, uid, options, sessionTicketOverride);
79
85
 
80
- let body = await buildInputBody(dataExprs, extraParams);
81
- let result = await client.postUrlEncoded('/api/input/submit', body);
86
+ // Submit helper handles both URL-encoded and multipart modes
87
+ async function submit() {
88
+ if (isMultipart) {
89
+ const fields = { ...extraParams };
90
+ for (const expr of dataExprs) {
91
+ const eqIdx = expr.indexOf('=');
92
+ if (eqIdx !== -1) {
93
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
94
+ }
95
+ }
96
+ const files = [{
97
+ fieldName: `${rowKeyExpr};column:content.Content`,
98
+ filePath: filepath,
99
+ fileName: filepath.split('/').pop(),
100
+ }];
101
+ return client.postMultipart('/api/input/submit', fields, files);
102
+ } else {
103
+ const body = await buildInputBody(dataExprs, extraParams);
104
+ return client.postUrlEncoded('/api/input/submit', body);
105
+ }
106
+ }
107
+
108
+ let result = await submit();
82
109
 
83
110
  // Reactive ModifyKey retry
84
111
  if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
85
112
  const retryMK = await handleModifyKeyError();
86
113
  if (retryMK.cancel) { log.info('Submission cancelled'); return; }
87
114
  extraParams['_modify_key'] = retryMK.modifyKey;
88
- body = await buildInputBody(dataExprs, extraParams);
89
- result = await client.postUrlEncoded('/api/input/submit', body);
115
+ result = await submit();
90
116
  }
91
117
 
92
118
  // Retry with prompted params if needed (ticket, user, repo mismatch)
@@ -101,8 +127,7 @@ const deployCmd = new Command('deploy')
101
127
  }
102
128
  const params = errorResult.retryParams || errorResult;
103
129
  Object.assign(extraParams, params);
104
- body = await buildInputBody(dataExprs, extraParams);
105
- result = await client.postUrlEncoded('/api/input/submit', body);
130
+ result = await submit();
106
131
  }
107
132
 
108
133
  formatResponse(result, { json: options.json });
@@ -5,6 +5,7 @@ import { buildInputBody, checkSubmitErrors } from '../lib/input-parser.js';
5
5
  import { formatResponse, formatError } from '../lib/formatter.js';
6
6
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
7
7
  import { checkStoredTicket, applyStoredTicketToSubmission, clearGlobalTicket, getGlobalTicket, getRecordTicket } from '../lib/ticketing.js';
8
+ import { resolveTransactionKey } from '../lib/transaction-key.js';
8
9
  import { log } from '../lib/logger.js';
9
10
 
10
11
  const MANIFEST_FILE = 'dbo.deploy.json';
@@ -16,6 +17,7 @@ export const deployCommand = new Command('deploy')
16
17
  .option('-C, --confirm <value>', 'Commit: true (default) or false', 'true')
17
18
  .option('--ticket <id>', 'Override ticket ID')
18
19
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
20
+ .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
19
21
  .option('--json', 'Output raw JSON')
20
22
  .option('-v, --verbose', 'Show HTTP request details')
21
23
  .option('--domain <host>', 'Override domain')
@@ -54,6 +56,9 @@ export const deployCommand = new Command('deploy')
54
56
  process.exit(1);
55
57
  }
56
58
 
59
+ // Resolve transaction key preset once before the entry loop
60
+ const transactionKey = await resolveTransactionKey(options);
61
+
57
62
  // ModifyKey guard — check once before any submissions
58
63
  const modifyKeyResult = await checkModifyKey(options);
59
64
  if (modifyKeyResult.cancel) {
@@ -85,10 +90,12 @@ export const deployCommand = new Command('deploy')
85
90
  continue;
86
91
  }
87
92
 
88
- const entity = entry.entity || 'content';
89
- const column = entry.column || 'Content';
93
+ const isMultipart = entry.multipart === true;
94
+ const entity = entry.entity || (isMultipart ? 'media' : 'content');
95
+ const column = entry.column || (isMultipart ? 'File' : 'Content');
90
96
  const uid = entry.uid;
91
97
  const file = entry.file;
98
+ const filename = entry.filename || file.split('/').pop();
92
99
 
93
100
  if (!uid || !file) {
94
101
  log.warn(`Skipping "${entryName}": missing uid or file.`);
@@ -101,7 +108,10 @@ export const deployCommand = new Command('deploy')
101
108
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
102
109
  if (activeModifyKey) extraParams['_modify_key'] = activeModifyKey;
103
110
 
104
- const dataExprs = [`RowUID:${uid};column:${entity}.${column}@${file}`];
111
+ const rowKeyExpr = transactionKey === 'RowID' ? `RowID:${uid}` : `RowUID:${uid}`;
112
+ const dataExprs = isMultipart
113
+ ? [`${rowKeyExpr};column:${entity}.Filename=${filename}`]
114
+ : [`${rowKeyExpr};column:${entity}.${column}@${file}`];
105
115
 
106
116
  // Apply stored ticket if no --ticket flag
107
117
  if (!options.ticket) {
@@ -112,8 +122,29 @@ export const deployCommand = new Command('deploy')
112
122
  }
113
123
  await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options, sessionTicketOverride);
114
124
 
115
- let body = await buildInputBody(dataExprs, extraParams);
116
- let result = await client.postUrlEncoded('/api/input/submit', body);
125
+ // Submit helper handles both URL-encoded and multipart modes
126
+ async function submit() {
127
+ if (isMultipart) {
128
+ const fields = { ...extraParams };
129
+ for (const expr of dataExprs) {
130
+ const eqIdx = expr.indexOf('=');
131
+ if (eqIdx !== -1) {
132
+ fields[expr.substring(0, eqIdx)] = expr.substring(eqIdx + 1);
133
+ }
134
+ }
135
+ const files = [{
136
+ fieldName: `${rowKeyExpr};column:${entity}.${column}`,
137
+ filePath: file,
138
+ fileName: file.split('/').pop(),
139
+ }];
140
+ return client.postMultipart('/api/input/submit', fields, files);
141
+ } else {
142
+ const body = await buildInputBody(dataExprs, extraParams);
143
+ return client.postUrlEncoded('/api/input/submit', body);
144
+ }
145
+ }
146
+
147
+ let result = await submit();
117
148
 
118
149
  // Reactive ModifyKey retry
119
150
  if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
@@ -121,8 +152,7 @@ export const deployCommand = new Command('deploy')
121
152
  if (retryMK.cancel) { log.info('Submission cancelled'); break; }
122
153
  activeModifyKey = retryMK.modifyKey;
123
154
  extraParams['_modify_key'] = activeModifyKey;
124
- body = await buildInputBody(dataExprs, extraParams);
125
- result = await client.postUrlEncoded('/api/input/submit', body);
155
+ result = await submit();
126
156
  }
127
157
 
128
158
  // Retry with prompted params if needed (ticket, user, repo mismatch)
@@ -141,8 +171,7 @@ export const deployCommand = new Command('deploy')
141
171
  }
142
172
  const params = errorResult.retryParams || errorResult;
143
173
  Object.assign(extraParams, params);
144
- body = await buildInputBody(dataExprs, extraParams);
145
- result = await client.postUrlEncoded('/api/input/submit', body);
174
+ result = await submit();
146
175
  }
147
176
 
148
177
  if (result.successful) {
@@ -1,7 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import { access } from 'fs/promises';
3
3
  import { join } from 'path';
4
- import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore } from '../lib/config.js';
4
+ import { isInitialized, hasLegacyConfig, readLegacyConfig, initConfig, saveCredentials, ensureGitignore, saveTransactionKeyPreset } from '../lib/config.js';
5
5
  import { installOrUpdateClaudeCommands } from './install.js';
6
6
  import { log } from '../lib/logger.js';
7
7
 
@@ -67,6 +67,24 @@ export const initCommand = new Command('init')
67
67
  log.success(`Initialized .dbo/ for ${domain}`);
68
68
  log.dim(' Run "dbo login" to authenticate.');
69
69
 
70
+ // Prompt for TransactionKeyPreset
71
+ if (!options.nonInteractive) {
72
+ const inquirer = (await import('inquirer')).default;
73
+ const { preset } = await inquirer.prompt([{
74
+ type: 'list',
75
+ name: 'preset',
76
+ message: 'Which row key should the CLI use when building input expressions?',
77
+ choices: [
78
+ { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
79
+ { name: 'RowID (numeric IDs)', value: 'RowID' },
80
+ ],
81
+ }]);
82
+ await saveTransactionKeyPreset(preset);
83
+ log.dim(` TransactionKeyPreset: ${preset}`);
84
+ } else {
85
+ await saveTransactionKeyPreset('RowUID');
86
+ }
87
+
70
88
  // Clone if requested
71
89
  if (options.clone || options.app) {
72
90
  let appShortName = options.app;
@@ -18,6 +18,7 @@ export const inputCommand = new Command('input')
18
18
  .option('-C, --confirm <value>', 'Commit changes: true (default) or false for validation only', 'true')
19
19
  .option('--ticket <id>', 'Override ticket ID (_OverrideTicketID)')
20
20
  .option('--modify-key <key>', 'Provide ModifyKey directly (skips interactive prompt)')
21
+ .option('--row-key <type>', 'Row key type (RowUID or RowID) — no-op for -d passthrough, available for consistency')
21
22
  .option('--login', 'Auto-login user created by this submission')
22
23
  .option('--transactional', 'Use transactional processing')
23
24
  .option('--json', 'Output raw JSON response')
@@ -9,6 +9,7 @@ import { shouldSkipColumn } from '../lib/columns.js';
9
9
  import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
10
10
  import { checkStoredTicket, applyStoredTicketToSubmission, clearRecordTicket, clearGlobalTicket } from '../lib/ticketing.js';
11
11
  import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
12
+ import { resolveTransactionKey } from '../lib/transaction-key.js';
12
13
  import { setFileTimestamps } from '../lib/timestamps.js';
13
14
  import { findMetadataFiles } from '../lib/diff.js';
14
15
  import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
@@ -23,6 +24,7 @@ export const pushCommand = new Command('push')
23
24
  .option('--meta-only', 'Only push metadata changes, skip file content')
24
25
  .option('--content-only', 'Only push file content, skip metadata columns')
25
26
  .option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
27
+ .option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
26
28
  .option('--json', 'Output raw JSON')
27
29
  .option('--jq <expr>', 'Filter JSON response')
28
30
  .option('-v, --verbose', 'Show HTTP request details')
@@ -39,15 +41,18 @@ export const pushCommand = new Command('push')
39
41
  }
40
42
  const modifyKey = modifyKeyResult.modifyKey;
41
43
 
44
+ // Resolve transaction key preset
45
+ const transactionKey = await resolveTransactionKey(options);
46
+
42
47
  // Process pending deletions from synchronize.json
43
- await processPendingDeletes(client, options, modifyKey);
48
+ await processPendingDeletes(client, options, modifyKey, transactionKey);
44
49
 
45
50
  const pathStat = await stat(targetPath);
46
51
 
47
52
  if (pathStat.isDirectory()) {
48
- await pushDirectory(targetPath, client, options, modifyKey);
53
+ await pushDirectory(targetPath, client, options, modifyKey, transactionKey);
49
54
  } else {
50
- await pushSingleFile(targetPath, client, options, modifyKey);
55
+ await pushSingleFile(targetPath, client, options, modifyKey, transactionKey);
51
56
  }
52
57
  } catch (err) {
53
58
  formatError(err);
@@ -58,7 +63,7 @@ export const pushCommand = new Command('push')
58
63
  /**
59
64
  * Process pending delete entries from .dbo/synchronize.json
60
65
  */
61
- async function processPendingDeletes(client, options, modifyKey = null) {
66
+ async function processPendingDeletes(client, options, modifyKey = null, transactionKey = 'RowUID') {
62
67
  const sync = await loadSynchronize();
63
68
  if (!sync.delete || sync.delete.length === 0) return;
64
69
 
@@ -140,7 +145,7 @@ async function processPendingDeletes(client, options, modifyKey = null) {
140
145
  /**
141
146
  * Push a single file using its companion .metadata.json
142
147
  */
143
- async function pushSingleFile(filePath, client, options, modifyKey = null) {
148
+ async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
144
149
  // Find the metadata file
145
150
  const dir = dirname(filePath);
146
151
  const base = basename(filePath, extname(filePath));
@@ -154,13 +159,13 @@ async function pushSingleFile(filePath, client, options, modifyKey = null) {
154
159
  process.exit(1);
155
160
  }
156
161
 
157
- await pushFromMetadata(meta, metaPath, client, options, null, modifyKey);
162
+ await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
158
163
  }
159
164
 
160
165
  /**
161
166
  * Push all records found in a directory (recursive)
162
167
  */
163
- async function pushDirectory(dirPath, client, options, modifyKey = null) {
168
+ async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
164
169
  const metaFiles = await findMetadataFiles(dirPath);
165
170
 
166
171
  if (metaFiles.length === 0) {
@@ -272,7 +277,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null) {
272
277
 
273
278
  for (const item of toPush) {
274
279
  try {
275
- const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey);
280
+ const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
276
281
  if (success) {
277
282
  succeeded++;
278
283
  successfulPushes.push(item);
@@ -306,14 +311,37 @@ async function pushDirectory(dirPath, client, options, modifyKey = null) {
306
311
  * @param {string[]|null} changedColumns - Optional array of changed column names (for delta sync)
307
312
  * @returns {Promise<boolean>} - True if push succeeded
308
313
  */
309
- async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null, modifyKey = null) {
310
- const uid = meta.UID || meta._id;
314
+ async function pushFromMetadata(meta, metaPath, client, options, changedColumns = null, modifyKey = null, transactionKey = 'RowUID') {
315
+ const uid = meta.UID;
316
+ const id = meta._id;
311
317
  const entity = meta._entity;
312
318
  const contentCols = new Set(meta._contentColumns || []);
313
319
  const metaDir = dirname(metaPath);
314
320
 
315
- if (!uid) {
316
- throw new Error(`No UID found in ${metaPath}`);
321
+ // Determine the row key prefix and value based on the preset
322
+ let rowKeyPrefix, rowKeyValue;
323
+ if (transactionKey === 'RowID') {
324
+ if (id) {
325
+ rowKeyPrefix = 'RowID';
326
+ rowKeyValue = id;
327
+ } else {
328
+ log.warn(` ⚠ Preset is RowID but no _id found in ${basename(metaPath)} — falling back to RowUID`);
329
+ rowKeyPrefix = 'RowUID';
330
+ rowKeyValue = uid;
331
+ }
332
+ } else {
333
+ if (uid) {
334
+ rowKeyPrefix = 'RowUID';
335
+ rowKeyValue = uid;
336
+ } else if (id) {
337
+ log.warn(` ⚠ Preset is RowUID but no UID found in ${basename(metaPath)} — falling back to RowID`);
338
+ rowKeyPrefix = 'RowID';
339
+ rowKeyValue = id;
340
+ }
341
+ }
342
+
343
+ if (!rowKeyValue) {
344
+ throw new Error(`No UID or _id found in ${metaPath}`);
317
345
  }
318
346
  if (!entity) {
319
347
  throw new Error(`No _entity found in ${metaPath}`);
@@ -351,9 +379,9 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
351
379
  // @filename reference — resolve to actual file path
352
380
  const refFile = strValue.substring(1);
353
381
  const refPath = join(metaDir, refFile);
354
- dataExprs.push(`RowUID:${uid};column:${entity}.${key}@${refPath}`);
382
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}@${refPath}`);
355
383
  } else {
356
- dataExprs.push(`RowUID:${uid};column:${entity}.${key}=${strValue}`);
384
+ dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${key}=${strValue}`);
357
385
  }
358
386
  }
359
387
 
@@ -363,10 +391,10 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
363
391
  }
364
392
 
365
393
  const fieldLabel = changedColumns ? `${dataExprs.length} changed field(s)` : `${dataExprs.length} field(s)`;
366
- log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${uid}) — ${fieldLabel}`);
394
+ log.info(`Pushing ${basename(metaPath, '.metadata.json')} (${entity}:${rowKeyValue}) — ${fieldLabel}`);
367
395
 
368
396
  // Apply stored ticket if no --ticket flag
369
- await applyStoredTicketToSubmission(dataExprs, entity, uid, uid, options);
397
+ await applyStoredTicketToSubmission(dataExprs, entity, uid || id, uid || id, options);
370
398
 
371
399
  const extraParams = { '_confirm': options.confirm };
372
400
  if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
@@ -422,7 +450,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
422
450
  }
423
451
 
424
452
  // Clean up per-record ticket on success
425
- await clearRecordTicket(uid);
453
+ await clearRecordTicket(uid || id);
426
454
 
427
455
  // Update file timestamps from server response
428
456
  try {
@@ -1,5 +1,5 @@
1
1
  import { Command } from 'commander';
2
- import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo, getAllPluginScopes } from '../lib/config.js';
2
+ import { loadConfig, isInitialized, getActiveCookiesPath, loadUserInfo, getAllPluginScopes, loadTransactionKeyPreset } from '../lib/config.js';
3
3
  import { loadCookies } from '../lib/cookie-jar.js';
4
4
  import { access } from 'fs/promises';
5
5
  import { join } from 'path';
@@ -38,6 +38,9 @@ export const statusCommand = new Command('status')
38
38
  log.label('Session', 'No active session. Run "dbo login".');
39
39
  }
40
40
 
41
+ const transactionKeyPreset = await loadTransactionKeyPreset();
42
+ log.label('Transaction Key', transactionKeyPreset || '(not set — defaults to RowUID)');
43
+
41
44
  // Display plugin status
42
45
  const scopes = await getAllPluginScopes();
43
46
  const pluginNames = Object.keys(scopes);
package/src/lib/config.js CHANGED
@@ -555,6 +555,31 @@ export async function loadAppModifyKey() {
555
555
  } catch { return null; }
556
556
  }
557
557
 
558
+ // ─── TransactionKeyPreset ─────────────────────────────────────────────────
559
+
560
+ /**
561
+ * Save TransactionKeyPreset to .dbo/config.json.
562
+ * @param {'RowUID'|'RowID'} preset
563
+ */
564
+ export async function saveTransactionKeyPreset(preset) {
565
+ await mkdir(dboDir(), { recursive: true });
566
+ let existing = {};
567
+ try { existing = JSON.parse(await readFile(configPath(), 'utf8')); } catch {}
568
+ existing.TransactionKeyPreset = preset;
569
+ await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
570
+ }
571
+
572
+ /**
573
+ * Load TransactionKeyPreset from .dbo/config.json.
574
+ * Returns 'RowUID', 'RowID', or null if not set.
575
+ */
576
+ export async function loadTransactionKeyPreset() {
577
+ try {
578
+ const raw = await readFile(configPath(), 'utf8');
579
+ return JSON.parse(raw).TransactionKeyPreset || null;
580
+ } catch { return null; }
581
+ }
582
+
558
583
  // ─── Gitignore ────────────────────────────────────────────────────────────
559
584
 
560
585
  /**
@@ -0,0 +1,46 @@
1
+ import { loadTransactionKeyPreset, saveTransactionKeyPreset } from './config.js';
2
+ import { log } from './logger.js';
3
+
4
+ /**
5
+ * Resolve the active transaction key preset.
6
+ * Priority: --row-key flag > config > lazy prompt > default RowUID.
7
+ *
8
+ * @param {Object} options - Command options (may have .rowKey)
9
+ * @returns {Promise<'RowUID'|'RowID'>}
10
+ */
11
+ export async function resolveTransactionKey(options = {}) {
12
+ // 1. --row-key flag takes precedence (no config write)
13
+ if (options.rowKey) return options.rowKey;
14
+
15
+ // 2. Check config
16
+ const stored = await loadTransactionKeyPreset();
17
+ if (stored) return stored;
18
+
19
+ // 3. Non-interactive or -y: default to RowUID, save to config
20
+ if (options.yes || options.nonInteractive || !process.stdin.isTTY) {
21
+ await saveTransactionKeyPreset('RowUID');
22
+ return 'RowUID';
23
+ }
24
+
25
+ // 4. Lazy prompt (first submission without preset configured)
26
+ log.plain('');
27
+ log.info('TransactionKeyPreset is not configured.');
28
+ log.dim(' RowUID — stable across domains (recommended)');
29
+ log.dim(' RowID — uses numeric IDs (may differ across domains)');
30
+ log.plain('');
31
+
32
+ const inquirer = (await import('inquirer')).default;
33
+ const { preset } = await inquirer.prompt([{
34
+ type: 'list',
35
+ name: 'preset',
36
+ message: 'Which row key should the CLI use when building input expressions?',
37
+ choices: [
38
+ { name: 'RowUID (recommended — stable across domains)', value: 'RowUID' },
39
+ { name: 'RowID (numeric IDs)', value: 'RowID' },
40
+ ],
41
+ }]);
42
+
43
+ await saveTransactionKeyPreset(preset);
44
+ log.dim(` Saved TransactionKeyPreset: ${preset} to .dbo/config.json`);
45
+ return preset;
46
+ }