@dboio/cli 0.9.8 → 0.11.1
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 +172 -70
- package/bin/dbo.js +2 -0
- package/bin/postinstall.js +9 -1
- package/package.json +3 -3
- package/plugins/claude/dbo/commands/dbo.md +3 -3
- package/plugins/claude/dbo/skills/cli/SKILL.md +3 -3
- package/src/commands/add.js +50 -0
- package/src/commands/clone.js +720 -552
- package/src/commands/content.js +7 -3
- package/src/commands/deploy.js +22 -7
- package/src/commands/diff.js +41 -3
- package/src/commands/init.js +42 -79
- package/src/commands/input.js +5 -0
- package/src/commands/login.js +2 -2
- package/src/commands/mv.js +3 -0
- package/src/commands/output.js +8 -10
- package/src/commands/pull.js +268 -87
- package/src/commands/push.js +814 -94
- package/src/commands/rm.js +4 -1
- package/src/commands/status.js +12 -1
- package/src/commands/sync.js +71 -0
- package/src/lib/client.js +10 -0
- package/src/lib/config.js +80 -8
- package/src/lib/delta.js +178 -25
- package/src/lib/diff.js +150 -20
- package/src/lib/folder-icon.js +120 -0
- package/src/lib/ignore.js +2 -3
- package/src/lib/input-parser.js +37 -10
- package/src/lib/metadata-templates.js +21 -4
- package/src/lib/migrations.js +75 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/scaffold.js +58 -3
- package/src/lib/structure.js +158 -21
- package/src/lib/toe-stepping.js +381 -0
- package/src/migrations/001-transaction-key-preset-scope.js +35 -0
- package/src/migrations/002-move-entity-dirs-to-lib.js +190 -0
- package/src/migrations/003-move-deploy-config.js +50 -0
- package/src/migrations/004-rename-output-files.js +101 -0
package/src/commands/push.js
CHANGED
|
@@ -1,21 +1,24 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { readFile, stat, writeFile, rename as fsRename, mkdir } from 'fs/promises';
|
|
2
|
+
import { readFile, readdir, stat, writeFile, rename as fsRename, mkdir, access } from 'fs/promises';
|
|
3
3
|
import { join, dirname, basename, extname, relative } from 'path';
|
|
4
4
|
import { DboClient } from '../lib/client.js';
|
|
5
5
|
import { buildInputBody, checkSubmitErrors, getSessionUserOverride } from '../lib/input-parser.js';
|
|
6
6
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
7
7
|
import { log } from '../lib/logger.js';
|
|
8
8
|
import { shouldSkipColumn } from '../lib/columns.js';
|
|
9
|
-
import { loadConfig, loadSynchronize, saveSynchronize, loadAppJsonBaseline, saveAppJsonBaseline, hasBaseline } from '../lib/config.js';
|
|
9
|
+
import { loadConfig, loadAppConfig, 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
12
|
import { resolveTransactionKey } from '../lib/transaction-key.js';
|
|
13
13
|
import { setFileTimestamps } from '../lib/timestamps.js';
|
|
14
|
-
import { stripUidFromFilename, renameToUidConvention, hasUidInFilename } from '../lib/filenames.js';
|
|
15
|
-
import { findMetadataFiles } from '../lib/diff.js';
|
|
14
|
+
import { stripUidFromFilename, renameToUidConvention, hasUidInFilename, buildUidFilename } from '../lib/filenames.js';
|
|
15
|
+
import { findMetadataFiles, findFileInProject, findByUID } from '../lib/diff.js';
|
|
16
16
|
import { loadIgnore } from '../lib/ignore.js';
|
|
17
|
-
import { detectChangedColumns, findBaselineEntry } from '../lib/delta.js';
|
|
18
|
-
import { BINS_DIR, ENTITY_DIR_NAMES } from '../lib/structure.js';
|
|
17
|
+
import { detectChangedColumns, findBaselineEntry, detectOutputChanges, getAllUserColumns, isReference, resolveReferencePath, detectBinChanges, synthesizeBinMetadata } from '../lib/delta.js';
|
|
18
|
+
import { BINS_DIR, ENTITY_DIR_NAMES, loadStructureFile, findBinByPath } from '../lib/structure.js';
|
|
19
|
+
import { ensureTrashIcon } from '../lib/folder-icon.js';
|
|
20
|
+
import { checkToeStepping } from '../lib/toe-stepping.js';
|
|
21
|
+
import { runPendingMigrations } from '../lib/migrations.js';
|
|
19
22
|
|
|
20
23
|
/**
|
|
21
24
|
* Resolve an @reference file path to an absolute filesystem path.
|
|
@@ -28,6 +31,15 @@ function resolveAtReference(refFile, metaDir) {
|
|
|
28
31
|
}
|
|
29
32
|
return join(metaDir, refFile);
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Resolve whether toe-stepping is enabled.
|
|
36
|
+
* --toe-stepping false (or '0', 'no') disables the server conflict check.
|
|
37
|
+
*/
|
|
38
|
+
function isToeStepping(options) {
|
|
39
|
+
const v = String(options.toeStepping ?? 'true').toLowerCase();
|
|
40
|
+
return v !== 'false' && v !== '0' && v !== 'no';
|
|
41
|
+
}
|
|
42
|
+
|
|
31
43
|
import { ENTITY_DEPENDENCIES } from '../lib/dependencies.js';
|
|
32
44
|
|
|
33
45
|
export const pushCommand = new Command('push')
|
|
@@ -39,13 +51,16 @@ export const pushCommand = new Command('push')
|
|
|
39
51
|
.option('--meta-only', 'Only push metadata changes, skip file content')
|
|
40
52
|
.option('--content-only', 'Only push file content, skip metadata columns')
|
|
41
53
|
.option('-y, --yes', 'Auto-accept all prompts (path refactoring, etc.)')
|
|
54
|
+
.option('--toe-stepping <value>', 'Check for server conflicts before push: true (default) or false', 'true')
|
|
42
55
|
.option('--row-key <type>', 'Override row key type for this invocation (RowUID or RowID)')
|
|
43
56
|
.option('--json', 'Output raw JSON')
|
|
44
57
|
.option('--jq <expr>', 'Filter JSON response')
|
|
45
58
|
.option('-v, --verbose', 'Show HTTP request details')
|
|
46
59
|
.option('--domain <host>', 'Override domain')
|
|
60
|
+
.option('--no-migrate', 'Skip pending migrations for this invocation')
|
|
47
61
|
.action(async (targetPath, options) => {
|
|
48
62
|
try {
|
|
63
|
+
await runPendingMigrations(options);
|
|
49
64
|
const client = new DboClient({ domain: options.domain, verbose: options.verbose });
|
|
50
65
|
|
|
51
66
|
// ModifyKey guard — check once before any submissions
|
|
@@ -62,7 +77,62 @@ export const pushCommand = new Command('push')
|
|
|
62
77
|
// Process pending deletions from synchronize.json
|
|
63
78
|
await processPendingDeletes(client, options, modifyKey, transactionKey);
|
|
64
79
|
|
|
65
|
-
|
|
80
|
+
// ── Resolution order ──────────────────────────────────────────
|
|
81
|
+
// 1. Commas → UID list
|
|
82
|
+
// 2. stat() → file/directory (existing behaviour)
|
|
83
|
+
// 3. stat fails:
|
|
84
|
+
// a. No extension + no path separator → search by UID
|
|
85
|
+
// b. Otherwise → bare filename search via findFileInProject()
|
|
86
|
+
|
|
87
|
+
// 1. Comma-separated → treat as UID list
|
|
88
|
+
if (targetPath.includes(',')) {
|
|
89
|
+
const uids = targetPath.split(',').map(u => u.trim()).filter(Boolean);
|
|
90
|
+
await pushByUIDs(uids, client, options, modifyKey, transactionKey);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 2. Try stat (existing path)
|
|
95
|
+
let pathStat;
|
|
96
|
+
try {
|
|
97
|
+
pathStat = await stat(targetPath);
|
|
98
|
+
} catch {
|
|
99
|
+
// stat failed — try smart resolution
|
|
100
|
+
const hasPathSep = targetPath.includes('/') || targetPath.includes('\\');
|
|
101
|
+
const hasExt = extname(targetPath) !== '';
|
|
102
|
+
|
|
103
|
+
if (!hasPathSep && !hasExt) {
|
|
104
|
+
// 3a. Looks like a UID (no extension, no path separator)
|
|
105
|
+
await pushByUIDs([targetPath], client, options, modifyKey, transactionKey);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!hasPathSep) {
|
|
110
|
+
// 3b. Bare filename — search project
|
|
111
|
+
const matches = await findFileInProject(targetPath);
|
|
112
|
+
if (matches.length === 1) {
|
|
113
|
+
const resolved = matches[0];
|
|
114
|
+
log.dim(` Found: ${relative(process.cwd(), resolved)}`);
|
|
115
|
+
const resolvedStat = await stat(resolved);
|
|
116
|
+
if (resolvedStat.isDirectory()) {
|
|
117
|
+
await pushDirectory(resolved, client, options, modifyKey, transactionKey);
|
|
118
|
+
} else {
|
|
119
|
+
await pushSingleFile(resolved, client, options, modifyKey, transactionKey);
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
} else if (matches.length > 1) {
|
|
123
|
+
log.error(`Multiple matches for "${targetPath}":`);
|
|
124
|
+
for (const m of matches) {
|
|
125
|
+
log.plain(` ${relative(process.cwd(), m)}`);
|
|
126
|
+
}
|
|
127
|
+
log.info('Please specify the full path.');
|
|
128
|
+
process.exit(1);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// No match found
|
|
133
|
+
log.error(`Path not found: "${targetPath}"`);
|
|
134
|
+
process.exit(1);
|
|
135
|
+
}
|
|
66
136
|
|
|
67
137
|
if (pathStat.isDirectory()) {
|
|
68
138
|
await pushDirectory(targetPath, client, options, modifyKey, transactionKey);
|
|
@@ -101,37 +171,16 @@ async function processPendingDeletes(client, options, modifyKey = null, transact
|
|
|
101
171
|
try {
|
|
102
172
|
const result = await client.postUrlEncoded('/api/input/submit', body);
|
|
103
173
|
|
|
104
|
-
//
|
|
105
|
-
const
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
remaining.push(entry);
|
|
115
|
-
// Push all remaining entries too
|
|
116
|
-
const currentIdx = sync.delete.indexOf(entry);
|
|
117
|
-
for (let i = currentIdx + 1; i < sync.delete.length; i++) {
|
|
118
|
-
remaining.push(sync.delete[i]);
|
|
119
|
-
}
|
|
120
|
-
break;
|
|
121
|
-
}
|
|
122
|
-
const params = errorResult.retryParams || errorResult;
|
|
123
|
-
Object.assign(extraParams, params);
|
|
124
|
-
const retryBody = await buildInputBody([entry.expression], extraParams);
|
|
125
|
-
const retryResponse = await client.postUrlEncoded('/api/input/submit', retryBody);
|
|
126
|
-
if (retryResponse.successful) {
|
|
127
|
-
log.success(` Deleted "${entry.name}" from server`);
|
|
128
|
-
deletedUids.push(entry.UID);
|
|
129
|
-
} else {
|
|
130
|
-
log.error(` Failed to delete "${entry.name}"`);
|
|
131
|
-
formatResponse(retryResponse, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
132
|
-
remaining.push(entry);
|
|
133
|
-
}
|
|
134
|
-
} else if (result.successful) {
|
|
174
|
+
// Deletes never require ticketing — treat ticket-only errors as success
|
|
175
|
+
const deleteMessages = (result.messages || result.data?.Messages || [])
|
|
176
|
+
.filter(m => typeof m === 'string');
|
|
177
|
+
const isTicketOnlyError = !result.successful
|
|
178
|
+
&& deleteMessages.length > 0
|
|
179
|
+
&& deleteMessages.every(m => m.includes('ticket_error') || m.includes('ticket_lookup'));
|
|
180
|
+
const deleteOk = result.successful || isTicketOnlyError;
|
|
181
|
+
|
|
182
|
+
if (deleteOk) {
|
|
183
|
+
if (isTicketOnlyError) log.dim(' (Ticket error ignored for delete)');
|
|
135
184
|
log.success(` Deleted "${entry.name}" from server`);
|
|
136
185
|
deletedUids.push(entry.UID);
|
|
137
186
|
} else {
|
|
@@ -222,6 +271,11 @@ async function moveWillDeleteToTrash(entry) {
|
|
|
222
271
|
log.warn(` Could not move to trash: ${from} — ${err.message}`);
|
|
223
272
|
}
|
|
224
273
|
}
|
|
274
|
+
|
|
275
|
+
// Re-apply trash icon if files were moved (self-heals after user clears trash)
|
|
276
|
+
if (filesToMove.length > 0) {
|
|
277
|
+
await ensureTrashIcon(trashDir);
|
|
278
|
+
}
|
|
225
279
|
}
|
|
226
280
|
|
|
227
281
|
/**
|
|
@@ -229,9 +283,15 @@ async function moveWillDeleteToTrash(entry) {
|
|
|
229
283
|
*/
|
|
230
284
|
async function pushSingleFile(filePath, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
231
285
|
// Find the metadata file
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
286
|
+
let metaPath;
|
|
287
|
+
if (filePath.endsWith('.metadata.json')) {
|
|
288
|
+
// User passed the metadata file directly — use it as-is
|
|
289
|
+
metaPath = filePath;
|
|
290
|
+
} else {
|
|
291
|
+
const dir = dirname(filePath);
|
|
292
|
+
const base = basename(filePath, extname(filePath));
|
|
293
|
+
metaPath = join(dir, `${base}.metadata.json`);
|
|
294
|
+
}
|
|
235
295
|
|
|
236
296
|
let meta;
|
|
237
297
|
try {
|
|
@@ -241,23 +301,88 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
241
301
|
process.exit(1);
|
|
242
302
|
}
|
|
243
303
|
|
|
304
|
+
// Toe-stepping check for single-file push
|
|
305
|
+
if (isToeStepping(options) && meta.UID) {
|
|
306
|
+
const baseline = await loadAppJsonBaseline();
|
|
307
|
+
if (baseline) {
|
|
308
|
+
const appConfig = await loadAppConfig();
|
|
309
|
+
const proceed = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
|
|
310
|
+
if (!proceed) return;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
244
314
|
await pushFromMetadata(meta, metaPath, client, options, null, modifyKey, transactionKey);
|
|
245
315
|
}
|
|
246
316
|
|
|
317
|
+
/**
|
|
318
|
+
* Ensure manifest.json at project root has companion metadata in bins/app/.
|
|
319
|
+
* If manifest.json exists but no manifest*.metadata.json is in bins/app/,
|
|
320
|
+
* auto-create the metadata so the push flow picks it up.
|
|
321
|
+
*/
|
|
322
|
+
async function ensureManifestMetadata() {
|
|
323
|
+
// Check if manifest.json exists at project root
|
|
324
|
+
try {
|
|
325
|
+
await access(join(process.cwd(), 'manifest.json'));
|
|
326
|
+
} catch {
|
|
327
|
+
return; // No manifest.json — nothing to do
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Check if bins/app/ already has metadata that references @/manifest.json.
|
|
331
|
+
// A filename-only check (startsWith('manifest')) is insufficient because
|
|
332
|
+
// the metadata may have been renamed with a ~UID suffix or prefixed with
|
|
333
|
+
// __WILL_DELETE__. Instead, scan actual metadata content for the reference.
|
|
334
|
+
const binsAppDir = join(process.cwd(), 'bins', 'app');
|
|
335
|
+
try {
|
|
336
|
+
const entries = await readdir(binsAppDir);
|
|
337
|
+
const metaEntries = entries.filter(e => e.endsWith('.metadata.json'));
|
|
338
|
+
for (const entry of metaEntries) {
|
|
339
|
+
try {
|
|
340
|
+
const raw = await readFile(join(binsAppDir, entry), 'utf8');
|
|
341
|
+
const parsed = JSON.parse(raw);
|
|
342
|
+
if (parsed.Content === '@/manifest.json') return; // Already tracked
|
|
343
|
+
} catch { /* skip unreadable files */ }
|
|
344
|
+
}
|
|
345
|
+
} catch {
|
|
346
|
+
// bins/app/ doesn't exist — will create it
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Auto-create manifest.metadata.json
|
|
350
|
+
const appConfig = await loadAppConfig();
|
|
351
|
+
const structure = await loadStructureFile();
|
|
352
|
+
const appBin = findBinByPath('app', structure);
|
|
353
|
+
|
|
354
|
+
await mkdir(binsAppDir, { recursive: true });
|
|
355
|
+
|
|
356
|
+
const meta = {
|
|
357
|
+
_entity: 'content',
|
|
358
|
+
_contentColumns: ['Content'],
|
|
359
|
+
Content: '@/manifest.json',
|
|
360
|
+
Path: 'manifest.json',
|
|
361
|
+
Name: 'manifest.json',
|
|
362
|
+
Extension: 'JSON',
|
|
363
|
+
Public: 1,
|
|
364
|
+
Active: 1,
|
|
365
|
+
Title: 'PWA Manifest',
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
if (appBin) meta.BinID = appBin.binId;
|
|
369
|
+
if (appConfig.AppID) meta.AppID = appConfig.AppID;
|
|
370
|
+
|
|
371
|
+
const metaPath = join(binsAppDir, 'manifest.metadata.json');
|
|
372
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
373
|
+
log.info('Auto-created manifest.metadata.json for manifest.json');
|
|
374
|
+
}
|
|
375
|
+
|
|
247
376
|
/**
|
|
248
377
|
* Push all records found in a directory (recursive)
|
|
249
378
|
*/
|
|
250
379
|
async function pushDirectory(dirPath, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
380
|
+
// Auto-create manifest.metadata.json if manifest.json exists at root without companion metadata
|
|
381
|
+
await ensureManifestMetadata();
|
|
382
|
+
|
|
251
383
|
const ig = await loadIgnore();
|
|
252
384
|
const metaFiles = await findMetadataFiles(dirPath, ig);
|
|
253
385
|
|
|
254
|
-
if (metaFiles.length === 0) {
|
|
255
|
-
log.warn(`No .metadata.json files found in "${dirPath}".`);
|
|
256
|
-
return;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
log.info(`Found ${metaFiles.length} record(s) to push`);
|
|
260
|
-
|
|
261
386
|
// Load baseline for delta detection
|
|
262
387
|
const baseline = await loadAppJsonBaseline();
|
|
263
388
|
|
|
@@ -267,6 +392,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
267
392
|
|
|
268
393
|
// Collect metadata with detected changes
|
|
269
394
|
const toPush = [];
|
|
395
|
+
const outputCompoundFiles = [];
|
|
270
396
|
let skipped = 0;
|
|
271
397
|
|
|
272
398
|
for (const metaPath of metaFiles) {
|
|
@@ -279,18 +405,21 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
279
405
|
continue;
|
|
280
406
|
}
|
|
281
407
|
|
|
282
|
-
if (!meta.
|
|
283
|
-
log.warn(`Skipping "${metaPath}": no
|
|
408
|
+
if (!meta._entity) {
|
|
409
|
+
log.warn(`Skipping "${metaPath}": no _entity found`);
|
|
284
410
|
skipped++;
|
|
285
411
|
continue;
|
|
286
412
|
}
|
|
287
413
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
414
|
+
// Compound output files: handle root + all inline children together
|
|
415
|
+
// These have _entity='output' and inline children under .children
|
|
416
|
+
if (meta._entity === 'output' && meta.children) {
|
|
417
|
+
outputCompoundFiles.push({ meta, metaPath });
|
|
291
418
|
continue;
|
|
292
419
|
}
|
|
293
420
|
|
|
421
|
+
const isNewRecord = !meta.UID && !meta._id;
|
|
422
|
+
|
|
294
423
|
// Verify @file references exist
|
|
295
424
|
const contentCols = meta._contentColumns || [];
|
|
296
425
|
let missingFiles = false;
|
|
@@ -329,9 +458,9 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
329
458
|
if (contentIgnored) { skipped++; continue; }
|
|
330
459
|
}
|
|
331
460
|
|
|
332
|
-
// Detect changed columns (delta detection)
|
|
461
|
+
// Detect changed columns (delta detection) — skip for new records
|
|
333
462
|
let changedColumns = null;
|
|
334
|
-
if (baseline) {
|
|
463
|
+
if (!isNewRecord && baseline) {
|
|
335
464
|
try {
|
|
336
465
|
changedColumns = await detectChangedColumns(metaPath, baseline);
|
|
337
466
|
if (changedColumns.length === 0) {
|
|
@@ -344,18 +473,56 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
344
473
|
}
|
|
345
474
|
}
|
|
346
475
|
|
|
347
|
-
toPush.push({ meta, metaPath, changedColumns });
|
|
476
|
+
toPush.push({ meta, metaPath, changedColumns, isNew: isNewRecord });
|
|
348
477
|
}
|
|
349
478
|
|
|
350
|
-
|
|
351
|
-
|
|
479
|
+
// Toe-stepping: check for server-side conflicts before submitting
|
|
480
|
+
if (isToeStepping(options) && baseline && toPush.length > 0) {
|
|
481
|
+
const toCheck = toPush.filter(item => !item.isNew);
|
|
482
|
+
if (toCheck.length > 0) {
|
|
483
|
+
const appConfig = await loadAppConfig();
|
|
484
|
+
const proceed = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
|
|
485
|
+
if (!proceed) return;
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ── Bin entity push: check if directory maps to a bin ──────────────
|
|
490
|
+
const binPushItems = [];
|
|
491
|
+
try {
|
|
492
|
+
const structure = await loadStructureFile();
|
|
493
|
+
const relDir = relative(process.cwd(), dirPath).replace(/\\/g, '/');
|
|
494
|
+
const binEntry = findBinByPath(relDir, structure);
|
|
495
|
+
if (binEntry && binEntry.uid && baseline) {
|
|
496
|
+
const changedBinCols = detectBinChanges(binEntry, baseline);
|
|
497
|
+
if (changedBinCols.length > 0) {
|
|
498
|
+
const appConfig = await loadAppConfig();
|
|
499
|
+
const binMeta = synthesizeBinMetadata(binEntry, appConfig.AppID);
|
|
500
|
+
binPushItems.push({ meta: binMeta, binEntry, changedColumns: changedBinCols });
|
|
501
|
+
log.info(`Bin "${binEntry.name}" has ${changedBinCols.length} changed column(s): ${changedBinCols.join(', ')}`);
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
} catch { /* structure file missing or bin lookup failed — skip */ }
|
|
505
|
+
|
|
506
|
+
if (toPush.length === 0 && outputCompoundFiles.length === 0 && binPushItems.length === 0) {
|
|
507
|
+
if (metaFiles.length === 0) {
|
|
508
|
+
log.warn(`No .metadata.json files found in "${dirPath}".`);
|
|
509
|
+
} else {
|
|
510
|
+
log.info('No changes to push');
|
|
511
|
+
}
|
|
352
512
|
return;
|
|
353
513
|
}
|
|
354
514
|
|
|
515
|
+
log.info(`Found ${metaFiles.length} record(s) to push`);
|
|
516
|
+
|
|
355
517
|
// Pre-flight ticket validation (only if no --ticket flag)
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const
|
|
518
|
+
const totalRecords = toPush.length + outputCompoundFiles.length + binPushItems.length;
|
|
519
|
+
if (!options.ticket && totalRecords > 0) {
|
|
520
|
+
const recordSummary = [
|
|
521
|
+
...toPush.map(r => basename(r.metaPath, '.metadata.json')),
|
|
522
|
+
...outputCompoundFiles.map(r => basename(r.metaPath, '.json')),
|
|
523
|
+
...binPushItems.map(r => `bin:${r.meta.Name}`),
|
|
524
|
+
].join(', ');
|
|
525
|
+
const ticketCheck = await checkStoredTicket(options, `${totalRecords} record(s): ${recordSummary}`);
|
|
359
526
|
if (ticketCheck.cancel) {
|
|
360
527
|
log.info('Submission cancelled');
|
|
361
528
|
return;
|
|
@@ -366,19 +533,48 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
366
533
|
}
|
|
367
534
|
}
|
|
368
535
|
|
|
369
|
-
//
|
|
370
|
-
toPush.
|
|
536
|
+
// Separate new records (adds) from existing records (edits)
|
|
537
|
+
const toAdd = toPush.filter(item => item.isNew);
|
|
538
|
+
const toEdit = toPush.filter(item => !item.isNew);
|
|
539
|
+
|
|
540
|
+
// Sort each group by dependency level
|
|
541
|
+
const sortByDependency = (a, b) => {
|
|
371
542
|
const levelA = ENTITY_DEPENDENCIES[a.meta._entity] || 0;
|
|
372
543
|
const levelB = ENTITY_DEPENDENCIES[b.meta._entity] || 0;
|
|
373
544
|
return levelA - levelB;
|
|
374
|
-
}
|
|
545
|
+
};
|
|
546
|
+
toAdd.sort(sortByDependency);
|
|
547
|
+
toEdit.sort(sortByDependency);
|
|
375
548
|
|
|
376
|
-
// Process in dependency order
|
|
377
549
|
let succeeded = 0;
|
|
378
550
|
let failed = 0;
|
|
379
551
|
const successfulPushes = [];
|
|
380
552
|
|
|
381
|
-
|
|
553
|
+
// Process adds first
|
|
554
|
+
if (toAdd.length > 0) {
|
|
555
|
+
log.info(`Adding ${toAdd.length} new record(s)...`);
|
|
556
|
+
}
|
|
557
|
+
for (const item of toAdd) {
|
|
558
|
+
try {
|
|
559
|
+
const success = await addFromMetadata(item.meta, item.metaPath, client, options, modifyKey);
|
|
560
|
+
if (success) {
|
|
561
|
+
succeeded++;
|
|
562
|
+
successfulPushes.push(item);
|
|
563
|
+
} else {
|
|
564
|
+
failed++;
|
|
565
|
+
}
|
|
566
|
+
} catch (err) {
|
|
567
|
+
if (err.message === 'SKIP_ALL') {
|
|
568
|
+
log.info('Skipping remaining records');
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
log.error(`Failed to add: ${item.metaPath} — ${err.message}`);
|
|
572
|
+
failed++;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Then process edits
|
|
577
|
+
for (const item of toEdit) {
|
|
382
578
|
try {
|
|
383
579
|
const success = await pushFromMetadata(item.meta, item.metaPath, client, options, item.changedColumns, modifyKey, transactionKey);
|
|
384
580
|
if (success) {
|
|
@@ -397,6 +593,44 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
397
593
|
}
|
|
398
594
|
}
|
|
399
595
|
|
|
596
|
+
// Process compound output files (root + inline children)
|
|
597
|
+
for (const { meta, metaPath } of outputCompoundFiles) {
|
|
598
|
+
try {
|
|
599
|
+
const result = await pushOutputCompound(meta, metaPath, client, options, baseline, modifyKey, transactionKey);
|
|
600
|
+
if (result.pushed > 0) {
|
|
601
|
+
succeeded++;
|
|
602
|
+
successfulPushes.push({ meta, metaPath, changedColumns: null });
|
|
603
|
+
} else {
|
|
604
|
+
skipped++;
|
|
605
|
+
}
|
|
606
|
+
} catch (err) {
|
|
607
|
+
log.error(`Failed compound output push: ${metaPath} — ${err.message}`);
|
|
608
|
+
failed++;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
// Process bin entity changes
|
|
613
|
+
for (const binItem of binPushItems) {
|
|
614
|
+
try {
|
|
615
|
+
// Synthesize a temporary metadata file path for pushFromMetadata
|
|
616
|
+
// (bin records have no .metadata.json — we pass the data inline)
|
|
617
|
+
const success = await pushBinEntity(binItem.meta, binItem.changedColumns, client, options, modifyKey, transactionKey);
|
|
618
|
+
if (success) {
|
|
619
|
+
succeeded++;
|
|
620
|
+
} else {
|
|
621
|
+
failed++;
|
|
622
|
+
}
|
|
623
|
+
} catch (err) {
|
|
624
|
+
log.error(`Failed bin push: ${binItem.meta.Name} — ${err.message}`);
|
|
625
|
+
failed++;
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// Clear server cache so subsequent GETs (diff, pull, toe-stepping) return fresh data
|
|
630
|
+
if (successfulPushes.length > 0) {
|
|
631
|
+
await client.voidCache();
|
|
632
|
+
}
|
|
633
|
+
|
|
400
634
|
// Update baseline after successful pushes
|
|
401
635
|
if (baseline && successfulPushes.length > 0) {
|
|
402
636
|
await updateBaselineAfterPush(baseline, successfulPushes);
|
|
@@ -405,6 +639,294 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
405
639
|
log.info(`Push complete: ${succeeded} succeeded, ${failed} failed, ${skipped} skipped`);
|
|
406
640
|
}
|
|
407
641
|
|
|
642
|
+
/**
|
|
643
|
+
* Push a bin entity record (synthesized metadata, no .metadata.json file).
|
|
644
|
+
* Uses pushFromMetadata with a temporary in-memory metadata path.
|
|
645
|
+
*/
|
|
646
|
+
async function pushBinEntity(binMeta, changedColumns, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
647
|
+
const entity = binMeta._entity;
|
|
648
|
+
const uid = binMeta.UID;
|
|
649
|
+
const id = binMeta._id;
|
|
650
|
+
|
|
651
|
+
// Determine row key
|
|
652
|
+
let rowKeyPrefix, rowKeyValue;
|
|
653
|
+
if (uid) {
|
|
654
|
+
rowKeyPrefix = 'RowUID';
|
|
655
|
+
rowKeyValue = uid;
|
|
656
|
+
} else if (id) {
|
|
657
|
+
rowKeyPrefix = 'RowID';
|
|
658
|
+
rowKeyValue = id;
|
|
659
|
+
} else {
|
|
660
|
+
log.warn(`Bin "${binMeta.Name}" has no UID or ID — skipping`);
|
|
661
|
+
return false;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
const dataExprs = [];
|
|
665
|
+
for (const col of changedColumns) {
|
|
666
|
+
const value = binMeta[col];
|
|
667
|
+
const strValue = value !== null && value !== undefined ? String(value) : '';
|
|
668
|
+
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${entity}.${col}=${strValue}`);
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
if (dataExprs.length === 0) {
|
|
672
|
+
log.warn(`Nothing to push for bin "${binMeta.Name}"`);
|
|
673
|
+
return false;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
log.info(`Pushing bin "${binMeta.Name}" (${entity}:${rowKeyValue}) — ${dataExprs.length} changed field(s)`);
|
|
677
|
+
|
|
678
|
+
const extraParams = { '_confirm': options.confirm || 'true' };
|
|
679
|
+
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
680
|
+
if (modifyKey) extraParams['_modify_key'] = modifyKey;
|
|
681
|
+
const cachedUser = getSessionUserOverride();
|
|
682
|
+
if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
|
|
683
|
+
|
|
684
|
+
const body = await buildInputBody(dataExprs, extraParams);
|
|
685
|
+
const result = await client.postUrlEncoded('/api/input/submit', body);
|
|
686
|
+
|
|
687
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
688
|
+
|
|
689
|
+
if (!result.successful) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
log.success(` Pushed bin "${binMeta.Name}"`);
|
|
694
|
+
return true;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Push records by UID(s). Searches metadata files and structure.json bins.
|
|
699
|
+
*/
|
|
700
|
+
async function pushByUIDs(uids, client, options, modifyKey = null, transactionKey = 'RowUID') {
|
|
701
|
+
const matches = await findByUID(uids);
|
|
702
|
+
|
|
703
|
+
if (matches.length === 0) {
|
|
704
|
+
log.error(`No records found for UID(s): ${uids.join(', ')}`);
|
|
705
|
+
process.exit(1);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
// Report unmatched UIDs
|
|
709
|
+
const foundUids = new Set(matches.map(m => m.uid));
|
|
710
|
+
for (const uid of uids) {
|
|
711
|
+
if (!foundUids.has(uid)) {
|
|
712
|
+
log.warn(`UID not found: ${uid}`);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const baseline = await loadAppJsonBaseline();
|
|
717
|
+
|
|
718
|
+
// Toe-stepping check for UID-targeted push
|
|
719
|
+
if (isToeStepping(options) && baseline) {
|
|
720
|
+
const toCheck = [];
|
|
721
|
+
for (const match of matches) {
|
|
722
|
+
if (match.metaPath && match.meta) {
|
|
723
|
+
toCheck.push({ meta: match.meta, metaPath: match.metaPath });
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
if (toCheck.length > 0) {
|
|
727
|
+
const appConfig = await loadAppConfig();
|
|
728
|
+
const proceed = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
|
|
729
|
+
if (!proceed) return;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
let succeeded = 0;
|
|
734
|
+
let failed = 0;
|
|
735
|
+
|
|
736
|
+
for (const match of matches) {
|
|
737
|
+
if (match.metaPath) {
|
|
738
|
+
// Regular record — push via pushSingleFile path
|
|
739
|
+
try {
|
|
740
|
+
const success = await pushSingleFile(match.metaPath, client, options, modifyKey, transactionKey);
|
|
741
|
+
if (success !== false) succeeded++;
|
|
742
|
+
else failed++;
|
|
743
|
+
} catch (err) {
|
|
744
|
+
log.error(`Failed to push ${match.uid}: ${err.message}`);
|
|
745
|
+
failed++;
|
|
746
|
+
}
|
|
747
|
+
} else if (match.binEntry) {
|
|
748
|
+
// Bin entity — detect changes and push
|
|
749
|
+
try {
|
|
750
|
+
const changedCols = baseline
|
|
751
|
+
? detectBinChanges(match.binEntry, baseline)
|
|
752
|
+
: ['Name', 'Path', 'ParentBinID'];
|
|
753
|
+
|
|
754
|
+
if (changedCols.length === 0) {
|
|
755
|
+
log.dim(` Bin "${match.binEntry.name}" — no changes detected`);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
const appConfig = await loadAppConfig();
|
|
760
|
+
const binMeta = synthesizeBinMetadata(match.binEntry, appConfig.AppID);
|
|
761
|
+
const success = await pushBinEntity(binMeta, changedCols, client, options, modifyKey, transactionKey);
|
|
762
|
+
if (success) succeeded++;
|
|
763
|
+
else failed++;
|
|
764
|
+
} catch (err) {
|
|
765
|
+
log.error(`Failed to push bin ${match.uid}: ${err.message}`);
|
|
766
|
+
failed++;
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (matches.length > 1) {
|
|
772
|
+
log.info(`Push complete: ${succeeded} succeeded, ${failed} failed`);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/**
|
|
777
|
+
* Submit a new record (add) from metadata that has no UID yet.
|
|
778
|
+
* Builds RowID:add1 expressions, submits, then renames files with the returned ~UID.
|
|
779
|
+
*/
|
|
780
|
+
async function addFromMetadata(meta, metaPath, client, options, modifyKey = null) {
|
|
781
|
+
const entity = meta._entity;
|
|
782
|
+
const contentCols = new Set(meta._contentColumns || []);
|
|
783
|
+
const metaDir = dirname(metaPath);
|
|
784
|
+
|
|
785
|
+
const dataExprs = [];
|
|
786
|
+
const addIndex = 1;
|
|
787
|
+
|
|
788
|
+
for (const [key, value] of Object.entries(meta)) {
|
|
789
|
+
if (shouldSkipColumn(key)) continue;
|
|
790
|
+
if (key === 'UID') continue;
|
|
791
|
+
if (value === null || value === undefined) continue;
|
|
792
|
+
|
|
793
|
+
const strValue = String(value);
|
|
794
|
+
|
|
795
|
+
if (strValue.startsWith('@')) {
|
|
796
|
+
const refFile = strValue.substring(1);
|
|
797
|
+
const refPath = resolveAtReference(refFile, metaDir);
|
|
798
|
+
dataExprs.push(`RowID:add${addIndex};column:${entity}.${key}@${refPath}`);
|
|
799
|
+
} else {
|
|
800
|
+
dataExprs.push(`RowID:add${addIndex};column:${entity}.${key}=${strValue}`);
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
if (dataExprs.length === 0) {
|
|
805
|
+
log.warn(`Nothing to submit for ${basename(metaPath)}`);
|
|
806
|
+
return false;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
log.info(`Adding ${basename(metaPath)} (${entity}) — ${dataExprs.length} field(s)`);
|
|
810
|
+
|
|
811
|
+
// Apply stored ticket — add operations always use RowID (not RowUID)
|
|
812
|
+
let storedTicket = null;
|
|
813
|
+
if (!options.ticket) {
|
|
814
|
+
const globalTicket = await (await import('../lib/ticketing.js')).getGlobalTicket();
|
|
815
|
+
if (globalTicket) {
|
|
816
|
+
dataExprs.push(`RowID:add${addIndex};column:${entity}._LastUpdatedTicketID=${globalTicket}`);
|
|
817
|
+
log.dim(` Applying ticket: ${globalTicket}`);
|
|
818
|
+
storedTicket = globalTicket;
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const extraParams = { '_confirm': options.confirm || 'true' };
|
|
823
|
+
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
824
|
+
else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
|
|
825
|
+
if (modifyKey) extraParams['_modify_key'] = modifyKey;
|
|
826
|
+
const cachedUser = getSessionUserOverride();
|
|
827
|
+
if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
|
|
828
|
+
|
|
829
|
+
let body = await buildInputBody(dataExprs, extraParams);
|
|
830
|
+
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
831
|
+
|
|
832
|
+
// Reactive ModifyKey retry
|
|
833
|
+
if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
|
|
834
|
+
const retryMK = await handleModifyKeyError();
|
|
835
|
+
if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
|
|
836
|
+
extraParams['_modify_key'] = retryMK.modifyKey;
|
|
837
|
+
body = await buildInputBody(dataExprs, extraParams);
|
|
838
|
+
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// Retry with prompted params if needed
|
|
842
|
+
const retryResult = await checkSubmitErrors(result);
|
|
843
|
+
if (retryResult) {
|
|
844
|
+
if (retryResult.skipRecord) { log.warn(' Skipping record'); return false; }
|
|
845
|
+
if (retryResult.skipAll) throw new Error('SKIP_ALL');
|
|
846
|
+
if (retryResult.ticketExpressions?.length > 0) dataExprs.push(...retryResult.ticketExpressions);
|
|
847
|
+
const params = retryResult.retryParams || retryResult;
|
|
848
|
+
Object.assign(extraParams, params);
|
|
849
|
+
body = await buildInputBody(dataExprs, extraParams);
|
|
850
|
+
result = await client.postUrlEncoded('/api/input/submit', body);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (!result.successful) {
|
|
854
|
+
const msgs = result.messages || result.data?.Messages || [];
|
|
855
|
+
log.error(`Add failed for ${basename(metaPath)}`);
|
|
856
|
+
if (msgs.length > 0) {
|
|
857
|
+
for (const m of msgs) log.dim(` ${typeof m === 'string' ? m : JSON.stringify(m)}`);
|
|
858
|
+
}
|
|
859
|
+
return false;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// Extract UID from response and rename files to ~uid convention
|
|
863
|
+
const addResults = result.payload?.Results?.Add || result.data?.Payload?.Results?.Add || [];
|
|
864
|
+
if (addResults.length > 0) {
|
|
865
|
+
const returnedUID = addResults[0].UID;
|
|
866
|
+
const returnedLastUpdated = addResults[0]._LastUpdated;
|
|
867
|
+
|
|
868
|
+
if (returnedUID) {
|
|
869
|
+
meta.UID = returnedUID;
|
|
870
|
+
|
|
871
|
+
// Store numeric ID for delete operations (RowID:del<id>)
|
|
872
|
+
const entityIdKey = entity.charAt(0).toUpperCase() + entity.slice(1) + 'ID';
|
|
873
|
+
const returnedId = addResults[0][entityIdKey] || addResults[0]._id || addResults[0].ID;
|
|
874
|
+
if (returnedId) meta._id = returnedId;
|
|
875
|
+
|
|
876
|
+
const currentMetaBase = basename(metaPath, '.metadata.json');
|
|
877
|
+
|
|
878
|
+
// Guard: don't append UID if it's already in the filename
|
|
879
|
+
if (hasUidInFilename(currentMetaBase, returnedUID)) {
|
|
880
|
+
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
881
|
+
log.success(`UID ${returnedUID} already in filename`);
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
const newBase = buildUidFilename(currentMetaBase, returnedUID);
|
|
886
|
+
const newMetaPath = join(metaDir, `${newBase}.metadata.json`);
|
|
887
|
+
|
|
888
|
+
// Update @references in metadata to include ~UID for non-root references
|
|
889
|
+
for (const col of (meta._contentColumns || [])) {
|
|
890
|
+
const ref = meta[col];
|
|
891
|
+
if (ref && String(ref).startsWith('@') && !String(ref).startsWith('@/')) {
|
|
892
|
+
// Local file reference — rename it too
|
|
893
|
+
const oldRefFile = String(ref).substring(1);
|
|
894
|
+
const refExt = extname(oldRefFile);
|
|
895
|
+
const refBase = basename(oldRefFile, refExt);
|
|
896
|
+
const newRefBase = buildUidFilename(refBase, returnedUID);
|
|
897
|
+
const newRefFile = refExt ? `${newRefBase}${refExt}` : newRefBase;
|
|
898
|
+
|
|
899
|
+
const oldRefPath = join(metaDir, oldRefFile);
|
|
900
|
+
const newRefPath = join(metaDir, newRefFile);
|
|
901
|
+
try {
|
|
902
|
+
await fsRename(oldRefPath, newRefPath);
|
|
903
|
+
meta[col] = `@${newRefFile}`;
|
|
904
|
+
} catch { /* content file may be root-relative */ }
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Rename old metadata file, then write updated content
|
|
909
|
+
if (metaPath !== newMetaPath) {
|
|
910
|
+
try { await fsRename(metaPath, newMetaPath); } catch { /* ignore if same */ }
|
|
911
|
+
}
|
|
912
|
+
await writeFile(newMetaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
913
|
+
|
|
914
|
+
// Set timestamps from server
|
|
915
|
+
const config = await loadConfig();
|
|
916
|
+
const serverTz = config.ServerTimezone;
|
|
917
|
+
if (serverTz && returnedLastUpdated) {
|
|
918
|
+
try {
|
|
919
|
+
await setFileTimestamps(newMetaPath, returnedLastUpdated, returnedLastUpdated, serverTz);
|
|
920
|
+
} catch { /* non-critical */ }
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
log.success(`Added: ${basename(metaPath)} → UID ${returnedUID}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
return true;
|
|
928
|
+
}
|
|
929
|
+
|
|
408
930
|
/**
|
|
409
931
|
* Build and submit input expressions from a metadata object
|
|
410
932
|
* @param {Object} meta - Metadata object
|
|
@@ -421,40 +943,37 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
421
943
|
const contentCols = new Set(meta._contentColumns || []);
|
|
422
944
|
const metaDir = dirname(metaPath);
|
|
423
945
|
|
|
424
|
-
// Determine the row key
|
|
946
|
+
// Determine the row key. TransactionKeyPreset only applies when the record
|
|
947
|
+
// carries a UID column (core assets). Data records without a UID always use
|
|
948
|
+
// RowID directly — no preset, no fallback warning.
|
|
425
949
|
let rowKeyPrefix, rowKeyValue;
|
|
426
|
-
|
|
427
|
-
|
|
950
|
+
const hasUid = uid != null && uid !== '';
|
|
951
|
+
|
|
952
|
+
if (hasUid) {
|
|
953
|
+
// Core asset: honour the TransactionKeyPreset
|
|
954
|
+
if (transactionKey === 'RowID') {
|
|
955
|
+
if (!id) throw new Error(`No _id found in ${basename(metaPath)} — required when TransactionKeyPreset is RowID`);
|
|
428
956
|
rowKeyPrefix = 'RowID';
|
|
429
957
|
rowKeyValue = id;
|
|
430
958
|
} else {
|
|
431
|
-
|
|
959
|
+
// RowUID (default)
|
|
432
960
|
rowKeyPrefix = 'RowUID';
|
|
433
961
|
rowKeyValue = uid;
|
|
434
962
|
}
|
|
435
963
|
} else {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
log.warn(` ⚠ Preset is RowUID but no UID found in ${basename(metaPath)} — falling back to RowID`);
|
|
441
|
-
rowKeyPrefix = 'RowID';
|
|
442
|
-
rowKeyValue = id;
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
if (!rowKeyValue) {
|
|
447
|
-
throw new Error(`No UID or _id found in ${metaPath}`);
|
|
964
|
+
// Data record: no UID column — always RowID
|
|
965
|
+
if (!id) throw new Error(`No UID or _id found in ${metaPath}`);
|
|
966
|
+
rowKeyPrefix = 'RowID';
|
|
967
|
+
rowKeyValue = id;
|
|
448
968
|
}
|
|
449
969
|
if (!entity) {
|
|
450
970
|
throw new Error(`No _entity found in ${metaPath}`);
|
|
451
971
|
}
|
|
452
972
|
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
}
|
|
973
|
+
// Path mismatch check disabled: the metadata Path column reflects the
|
|
974
|
+
// server-side path and must not be overwritten by local directory structure.
|
|
975
|
+
// Local bin/lib directory placement is an organizational choice independent
|
|
976
|
+
// of the server Path value.
|
|
458
977
|
|
|
459
978
|
const dataExprs = [];
|
|
460
979
|
let metaUpdated = false;
|
|
@@ -466,7 +985,10 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
466
985
|
if (shouldSkipColumn(key)) continue;
|
|
467
986
|
if (key === 'UID') continue; // UID is the identifier, not a column to update
|
|
468
987
|
if (key === 'children') continue; // Output hierarchy structural field, not a server column
|
|
469
|
-
|
|
988
|
+
|
|
989
|
+
// Skip null/undefined values UNLESS delta detected them as changed
|
|
990
|
+
// (user explicitly set a column to null to clear it on server)
|
|
991
|
+
if ((value === null || value === undefined) && !(columnsToProcess && columnsToProcess.has(key))) continue;
|
|
470
992
|
|
|
471
993
|
// Delta sync: skip columns not in changedColumns
|
|
472
994
|
if (columnsToProcess && !columnsToProcess.has(key)) continue;
|
|
@@ -478,7 +1000,8 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
478
1000
|
// --content-only: skip non-content columns
|
|
479
1001
|
if (options.contentOnly && !isContentCol) continue;
|
|
480
1002
|
|
|
481
|
-
|
|
1003
|
+
// Null values that passed delta check → send as empty string to clear on server
|
|
1004
|
+
const strValue = (value === null || value === undefined) ? '' : String(value);
|
|
482
1005
|
|
|
483
1006
|
if (strValue.startsWith('@')) {
|
|
484
1007
|
// @filename reference — resolve to actual file path
|
|
@@ -800,6 +1323,194 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
|
|
|
800
1323
|
}
|
|
801
1324
|
}
|
|
802
1325
|
|
|
1326
|
+
// ─── Compound Output Push ───────────────────────────────────────────────────
|
|
1327
|
+
|
|
1328
|
+
const _COMPOUND_DOC_KEYS = ['column', 'join', 'filter'];
|
|
1329
|
+
|
|
1330
|
+
/**
|
|
1331
|
+
* Push a compound output file (root + inline children) to the server.
|
|
1332
|
+
* Handles delta detection, dependency ordering, FK preservation,
|
|
1333
|
+
* CustomSQL @reference resolution, and root _lastUpdated stamping.
|
|
1334
|
+
*
|
|
1335
|
+
* @param {Object} meta - Parsed root output JSON
|
|
1336
|
+
* @param {string} metaPath - Absolute path to root output JSON file
|
|
1337
|
+
* @param {DboClient} client - API client
|
|
1338
|
+
* @param {Object} options - Push options
|
|
1339
|
+
* @param {Object} baseline - Loaded baseline
|
|
1340
|
+
* @param {string|null} modifyKey - ModifyKey value
|
|
1341
|
+
* @param {string} transactionKey - RowUID or RowID
|
|
1342
|
+
* @returns {Promise<{ pushed: number }>} - Count of entities pushed
|
|
1343
|
+
*/
|
|
1344
|
+
async function pushOutputCompound(meta, metaPath, client, options, baseline, modifyKey = null, transactionKey = 'RowUID') {
|
|
1345
|
+
const metaDir = dirname(metaPath);
|
|
1346
|
+
|
|
1347
|
+
// Delta detection for compound output
|
|
1348
|
+
let rootChanges, childChanges;
|
|
1349
|
+
if (baseline) {
|
|
1350
|
+
try {
|
|
1351
|
+
const delta = await detectOutputChanges(metaPath, baseline);
|
|
1352
|
+
rootChanges = delta.root;
|
|
1353
|
+
childChanges = delta.children;
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
log.warn(`Compound output delta detection failed for ${metaPath}: ${err.message} — performing full push`);
|
|
1356
|
+
rootChanges = getAllUserColumns(meta);
|
|
1357
|
+
childChanges = null; // null = push all children
|
|
1358
|
+
}
|
|
1359
|
+
} else {
|
|
1360
|
+
rootChanges = getAllUserColumns(meta);
|
|
1361
|
+
childChanges = null;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
const totalChanges = rootChanges.length +
|
|
1365
|
+
(childChanges ? Object.values(childChanges).reduce((s, c) => s + c.length, 0) : 999);
|
|
1366
|
+
|
|
1367
|
+
if (totalChanges === 0) {
|
|
1368
|
+
log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
|
|
1369
|
+
return { pushed: 0 };
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
// Flatten all inline children with depth annotation
|
|
1373
|
+
const allChildren = [];
|
|
1374
|
+
_flattenOutputChildren(meta.children || {}, allChildren);
|
|
1375
|
+
|
|
1376
|
+
// Separate adds (no baseline entry) from edits
|
|
1377
|
+
const adds = [];
|
|
1378
|
+
const edits = [];
|
|
1379
|
+
for (const child of allChildren) {
|
|
1380
|
+
const entry = baseline ? findBaselineEntry(baseline, child._entity, child.UID) : null;
|
|
1381
|
+
const changes = childChanges ? (childChanges[child.UID] || []) : getAllUserColumns(child);
|
|
1382
|
+
if (!entry) {
|
|
1383
|
+
adds.push({ child, changes: getAllUserColumns(child) });
|
|
1384
|
+
} else if (changes.length > 0) {
|
|
1385
|
+
edits.push({ child, changes });
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
// Check if root itself is new
|
|
1390
|
+
const rootEntry = baseline ? findBaselineEntry(baseline, 'output', meta.UID) : null;
|
|
1391
|
+
const rootIsNew = !rootEntry;
|
|
1392
|
+
|
|
1393
|
+
let pushed = 0;
|
|
1394
|
+
|
|
1395
|
+
// EDIT ORDER: deepest children first (highest _depth)
|
|
1396
|
+
edits.sort((a, b) => b.child._depth - a.child._depth);
|
|
1397
|
+
for (const { child, changes } of edits) {
|
|
1398
|
+
const success = await _submitOutputEntity(child, child._entity, changes, metaDir, client, options, modifyKey, transactionKey);
|
|
1399
|
+
if (success) pushed++;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
// ADD ORDER: root first (if new), then children shallowest→deepest
|
|
1403
|
+
if (rootIsNew && rootChanges.length > 0) {
|
|
1404
|
+
const success = await _submitOutputEntity(meta, 'output', rootChanges, metaDir, client, options, modifyKey, transactionKey);
|
|
1405
|
+
if (success) pushed++;
|
|
1406
|
+
}
|
|
1407
|
+
adds.sort((a, b) => a.child._depth - b.child._depth);
|
|
1408
|
+
for (const { child, changes } of adds) {
|
|
1409
|
+
const success = await _submitOutputEntity(child, child._entity, changes, metaDir, client, options, modifyKey, transactionKey);
|
|
1410
|
+
if (success) pushed++;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
// Always update root (with _lastUpdated) — submit root changes or just touch it
|
|
1414
|
+
if (!rootIsNew && (rootChanges.length > 0 || edits.length > 0 || adds.length > 0)) {
|
|
1415
|
+
const success = await _submitOutputEntity(meta, 'output', rootChanges.length > 0 ? rootChanges : ['Name'], metaDir, client, options, modifyKey, transactionKey);
|
|
1416
|
+
if (success) pushed++;
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
log.info(`Compound output push: ${basename(metaPath)} — ${pushed} entity submission(s)`);
|
|
1420
|
+
return { pushed };
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/**
|
|
1424
|
+
* Flatten children object ({ column, join, filter }) into a flat array.
|
|
1425
|
+
* Annotates each child with _depth (1 = direct child of root, 2 = grandchild, etc.)
|
|
1426
|
+
*/
|
|
1427
|
+
function _flattenOutputChildren(childrenObj, result, depth = 1) {
|
|
1428
|
+
for (const docKey of _COMPOUND_DOC_KEYS) {
|
|
1429
|
+
const entityArray = childrenObj[docKey];
|
|
1430
|
+
if (!Array.isArray(entityArray) || entityArray.length === 0) continue;
|
|
1431
|
+
for (const child of entityArray) {
|
|
1432
|
+
child._depth = depth;
|
|
1433
|
+
result.push(child);
|
|
1434
|
+
if (child.children) _flattenOutputChildren(child.children, result, depth + 1);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/**
|
|
1440
|
+
* Submit a single output hierarchy entity to the server.
|
|
1441
|
+
* Resolves @reference values, builds data expressions, and submits.
|
|
1442
|
+
*/
|
|
1443
|
+
async function _submitOutputEntity(entity, physicalEntity, changedColumns, metaDir, client, options, modifyKey, transactionKey) {
|
|
1444
|
+
const uid = entity.UID;
|
|
1445
|
+
if (!uid) {
|
|
1446
|
+
log.warn(` Output entity ${physicalEntity} has no UID — skipping`);
|
|
1447
|
+
return false;
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
const rowKeyPrefix = transactionKey === 'RowID' && entity._id ? 'RowID' : 'RowUID';
|
|
1451
|
+
const rowKeyValue = rowKeyPrefix === 'RowID' ? entity._id : uid;
|
|
1452
|
+
|
|
1453
|
+
const dataExprs = [];
|
|
1454
|
+
|
|
1455
|
+
for (const col of changedColumns) {
|
|
1456
|
+
if (shouldSkipColumn(col)) continue;
|
|
1457
|
+
if (col === 'UID' || col === 'children') continue;
|
|
1458
|
+
|
|
1459
|
+
const val = entity[col];
|
|
1460
|
+
|
|
1461
|
+
// Null values in changedColumns → send empty string to clear on server
|
|
1462
|
+
const strValue = (val === null || val === undefined) ? '' : String(val);
|
|
1463
|
+
if (isReference(strValue)) {
|
|
1464
|
+
const refPath = resolveReferencePath(strValue, metaDir);
|
|
1465
|
+
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}@${refPath}`);
|
|
1466
|
+
} else {
|
|
1467
|
+
dataExprs.push(`${rowKeyPrefix}:${rowKeyValue};column:${physicalEntity}.${col}=${strValue}`);
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
if (dataExprs.length === 0) return false;
|
|
1472
|
+
|
|
1473
|
+
log.info(` Pushing ${physicalEntity}:${uid} — ${dataExprs.length} field(s)`);
|
|
1474
|
+
|
|
1475
|
+
const storedTicket = await applyStoredTicketToSubmission(dataExprs, physicalEntity, uid, uid, options);
|
|
1476
|
+
|
|
1477
|
+
const extraParams = { '_confirm': options.confirm || 'true' };
|
|
1478
|
+
if (options.ticket) extraParams['_OverrideTicketID'] = options.ticket;
|
|
1479
|
+
else if (storedTicket) extraParams['_OverrideTicketID'] = storedTicket;
|
|
1480
|
+
if (modifyKey) extraParams['_modify_key'] = modifyKey;
|
|
1481
|
+
const cachedUser = getSessionUserOverride();
|
|
1482
|
+
if (cachedUser) extraParams['_OverrideUserID'] = cachedUser;
|
|
1483
|
+
|
|
1484
|
+
const body = await buildInputBody(dataExprs, extraParams);
|
|
1485
|
+
let result = await client.postUrlEncoded('/api/input/submit', body);
|
|
1486
|
+
|
|
1487
|
+
// Reactive ModifyKey retry
|
|
1488
|
+
if (!result.successful && result.messages?.some(m => isModifyKeyError(m))) {
|
|
1489
|
+
const retryMK = await handleModifyKeyError();
|
|
1490
|
+
if (retryMK.cancel) { log.info('Submission cancelled'); return false; }
|
|
1491
|
+
extraParams['_modify_key'] = retryMK.modifyKey;
|
|
1492
|
+
const retryBody = await buildInputBody(dataExprs, extraParams);
|
|
1493
|
+
result = await client.postUrlEncoded('/api/input/submit', retryBody);
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
formatResponse(result, { json: options.json, jq: options.jq, verbose: options.verbose });
|
|
1497
|
+
|
|
1498
|
+
if (!result.successful) return false;
|
|
1499
|
+
|
|
1500
|
+
// Update metadata _LastUpdated from server response
|
|
1501
|
+
try {
|
|
1502
|
+
const editResults = result.payload?.Results?.Edit || result.data?.Payload?.Results?.Edit || [];
|
|
1503
|
+
if (editResults.length > 0) {
|
|
1504
|
+
const updated = editResults[0]._LastUpdated || editResults[0].LastUpdated;
|
|
1505
|
+
if (updated) entity._LastUpdated = updated;
|
|
1506
|
+
}
|
|
1507
|
+
} catch { /* non-critical */ }
|
|
1508
|
+
|
|
1509
|
+
return true;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// ─── Baseline Update ────────────────────────────────────────────────────────
|
|
1513
|
+
|
|
803
1514
|
/**
|
|
804
1515
|
* Update baseline file (.app.json) after successful pushes.
|
|
805
1516
|
* Syncs changed column values and timestamps from metadata to baseline.
|
|
@@ -814,11 +1525,14 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
|
|
|
814
1525
|
const uid = meta.UID || meta._id;
|
|
815
1526
|
const entity = meta._entity;
|
|
816
1527
|
|
|
817
|
-
// Find the baseline entry
|
|
818
|
-
|
|
1528
|
+
// Find or create the baseline entry
|
|
1529
|
+
let baselineEntry = findBaselineEntry(baseline, entity, uid);
|
|
819
1530
|
if (!baselineEntry) {
|
|
820
|
-
|
|
821
|
-
|
|
1531
|
+
// New record (from add) — insert into baseline
|
|
1532
|
+
if (!baseline.children) baseline.children = {};
|
|
1533
|
+
if (!Array.isArray(baseline.children[entity])) baseline.children[entity] = [];
|
|
1534
|
+
baselineEntry = { UID: uid };
|
|
1535
|
+
baseline.children[entity].push(baselineEntry);
|
|
822
1536
|
}
|
|
823
1537
|
|
|
824
1538
|
// Update _LastUpdated and _LastUpdatedUserID from metadata
|
|
@@ -836,7 +1550,13 @@ async function updateBaselineAfterPush(baseline, successfulPushes) {
|
|
|
836
1550
|
|
|
837
1551
|
for (const col of columnsToUpdate) {
|
|
838
1552
|
const value = meta[col];
|
|
839
|
-
|
|
1553
|
+
|
|
1554
|
+
// Null/undefined values: store null in baseline (field was cleared)
|
|
1555
|
+
if (value === null || value === undefined) {
|
|
1556
|
+
baselineEntry[col] = null;
|
|
1557
|
+
modified = true;
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
840
1560
|
|
|
841
1561
|
const strValue = String(value);
|
|
842
1562
|
|