@dboio/cli 0.15.2 → 0.16.2
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 +103 -25
- package/package.json +1 -1
- package/plugins/claude/dbo/docs/dbo-cli-readme.md +103 -25
- package/src/commands/add.js +18 -18
- package/src/commands/clone.js +390 -157
- package/src/commands/init.js +42 -1
- package/src/commands/input.js +2 -32
- package/src/commands/mv.js +3 -3
- package/src/commands/push.js +29 -11
- package/src/commands/rm.js +2 -2
- package/src/lib/columns.js +1 -0
- package/src/lib/config.js +83 -1
- package/src/lib/delta.js +31 -7
- package/src/lib/dependencies.js +217 -2
- package/src/lib/diff.js +9 -11
- package/src/lib/filenames.js +2 -2
- package/src/lib/ignore.js +1 -0
- package/src/lib/logger.js +35 -0
- package/src/lib/metadata-schema.js +492 -0
- package/src/lib/save-to-disk.js +1 -1
- package/src/lib/schema.js +53 -0
- package/src/lib/structure.js +3 -3
- package/src/lib/tagging.js +1 -1
- package/src/lib/ticketing.js +18 -2
- package/src/lib/toe-stepping.js +9 -6
- package/src/migrations/007-natural-entity-companion-filenames.js +5 -2
- package/src/migrations/009-fix-media-collision-metadata-names.js +161 -0
- package/src/migrations/010-delete-paren-media-orphans.js +61 -0
- package/src/migrations/011-schema-driven-metadata.js +120 -0
package/src/commands/init.js
CHANGED
|
@@ -9,6 +9,10 @@ import { log } from '../lib/logger.js';
|
|
|
9
9
|
import { checkDomainChange, writeAppJsonDomain } from '../lib/domain-guard.js';
|
|
10
10
|
import { performLogin } from './login.js';
|
|
11
11
|
import { runPendingMigrations } from '../lib/migrations.js';
|
|
12
|
+
import { fetchSchema, saveSchema, SCHEMA_FILE } from '../lib/schema.js';
|
|
13
|
+
import { loadMetadataSchema, saveMetadataSchema, generateMetadataFromSchema } from '../lib/metadata-schema.js';
|
|
14
|
+
import { syncDependencies } from '../lib/dependencies.js';
|
|
15
|
+
import { mergeDependencies } from '../lib/config.js';
|
|
12
16
|
|
|
13
17
|
export const initCommand = new Command('init')
|
|
14
18
|
.description('Initialize DBO CLI configuration for the current directory')
|
|
@@ -25,6 +29,8 @@ export const initCommand = new Command('init')
|
|
|
25
29
|
.option('--dboignore', 'Create or reset .dboignore to defaults (use with --force to overwrite)')
|
|
26
30
|
.option('--media-placement <placement>', 'Set media placement when cloning: fullpath or binpath (default: bin)')
|
|
27
31
|
.option('--no-migrate', 'Skip pending migrations for this invocation')
|
|
32
|
+
.option('--no-deps', 'Skip dependency cloning after init')
|
|
33
|
+
.option('--dependencies <apps>', 'Sync specific dependency apps (comma-separated short-names)')
|
|
28
34
|
.action(async (options) => {
|
|
29
35
|
// Merge --yes into nonInteractive
|
|
30
36
|
if (options.yes) options.nonInteractive = true;
|
|
@@ -102,7 +108,7 @@ export const initCommand = new Command('init')
|
|
|
102
108
|
}
|
|
103
109
|
|
|
104
110
|
// Ensure sensitive files are gitignored
|
|
105
|
-
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/scripts.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r']);
|
|
111
|
+
await ensureGitignore(['.dbo/credentials.json', '.dbo/cookies.txt', '.dbo/config.local.json', '.dbo/ticketing.local.json', '.dbo/scripts.local.json', '.dbo/errors.log', 'trash/', 'Icon\\r', 'schema.json', '.dbo/dependencies/']);
|
|
106
112
|
|
|
107
113
|
const createdIgnore = await createDboignore();
|
|
108
114
|
if (createdIgnore) log.dim(' Created .dboignore');
|
|
@@ -135,6 +141,41 @@ export const initCommand = new Command('init')
|
|
|
135
141
|
await performLogin(domain, username);
|
|
136
142
|
}
|
|
137
143
|
|
|
144
|
+
// Attempt schema fetch (best-effort — silently skip if not authenticated yet)
|
|
145
|
+
try {
|
|
146
|
+
const schemaData = await fetchSchema({ domain, verbose: options.verbose });
|
|
147
|
+
await saveSchema(schemaData);
|
|
148
|
+
log.dim(` Saved ${SCHEMA_FILE}`);
|
|
149
|
+
|
|
150
|
+
const existing = await loadMetadataSchema();
|
|
151
|
+
const updated = generateMetadataFromSchema(schemaData, existing ?? {});
|
|
152
|
+
await saveMetadataSchema(updated);
|
|
153
|
+
log.dim(` Updated .dbo/metadata_schema.json`);
|
|
154
|
+
} catch (err) {
|
|
155
|
+
log.warn(` Could not fetch schema (${err.message}) — run 'dbo clone --schema' after login.`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Sync dependency apps (e.g., _system) — best-effort, non-blocking
|
|
159
|
+
if (!options.noDeps) {
|
|
160
|
+
const explicitDeps = options.dependencies
|
|
161
|
+
? options.dependencies.split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
162
|
+
: null;
|
|
163
|
+
if (explicitDeps && explicitDeps.length > 0) {
|
|
164
|
+
await mergeDependencies(explicitDeps);
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
await syncDependencies({
|
|
168
|
+
domain,
|
|
169
|
+
force: explicitDeps ? true : undefined,
|
|
170
|
+
verbose: options.verbose,
|
|
171
|
+
systemSchemaPath: join(process.cwd(), SCHEMA_FILE),
|
|
172
|
+
only: explicitDeps || undefined,
|
|
173
|
+
});
|
|
174
|
+
} catch (err) {
|
|
175
|
+
log.warn(` Dependency sync failed: ${err.message}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
138
179
|
// TransactionKeyPreset — always RowUID (stable across domains)
|
|
139
180
|
await saveTransactionKeyPreset('RowUID');
|
|
140
181
|
log.dim(' TransactionKeyPreset: RowUID');
|
package/src/commands/input.js
CHANGED
|
@@ -2,7 +2,6 @@ import { Command } from 'commander';
|
|
|
2
2
|
import { DboClient } from '../lib/client.js';
|
|
3
3
|
import { buildInputBody, parseFileArg, checkSubmitErrors } from '../lib/input-parser.js';
|
|
4
4
|
import { formatResponse, formatError } from '../lib/formatter.js';
|
|
5
|
-
import { loadAppConfig } from '../lib/config.js';
|
|
6
5
|
import { checkStoredTicket, clearGlobalTicket } from '../lib/ticketing.js';
|
|
7
6
|
import { checkModifyKey, isModifyKeyError, handleModifyKeyError } from '../lib/modify-key.js';
|
|
8
7
|
import { log } from '../lib/logger.js';
|
|
@@ -59,37 +58,8 @@ export const inputCommand = new Command('input')
|
|
|
59
58
|
}
|
|
60
59
|
if (modifyKeyResult.modifyKey) extraParams['_modify_key'] = modifyKeyResult.modifyKey;
|
|
61
60
|
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
const allDataText = options.data.join(' ');
|
|
65
|
-
const isDeleteOnly = options.data.every(d => /RowID:del\d+/.test(d));
|
|
66
|
-
const hasAppId = isDeleteOnly || /\.AppID[=@]/.test(allDataText) || /AppID=/.test(allDataText);
|
|
67
|
-
if (!hasAppId) {
|
|
68
|
-
const appConfig = await loadAppConfig();
|
|
69
|
-
if (appConfig.AppID) {
|
|
70
|
-
const inquirer = (await import('inquirer')).default;
|
|
71
|
-
const { appIdChoice } = await inquirer.prompt([{
|
|
72
|
-
type: 'list',
|
|
73
|
-
name: 'appIdChoice',
|
|
74
|
-
message: `You're submitting data without an AppID, but your config has information about the current App. Do you want me to add that Column information along with your submission?`,
|
|
75
|
-
choices: [
|
|
76
|
-
{ name: `Yes, use AppID ${appConfig.AppID}`, value: 'use_config' },
|
|
77
|
-
{ name: 'No', value: 'none' },
|
|
78
|
-
{ name: 'Enter custom AppID', value: 'custom' },
|
|
79
|
-
],
|
|
80
|
-
}]);
|
|
81
|
-
if (appIdChoice === 'use_config') {
|
|
82
|
-
extraParams['AppID'] = String(appConfig.AppID);
|
|
83
|
-
log.dim(` Using AppID ${appConfig.AppID} from config`);
|
|
84
|
-
} else if (appIdChoice === 'custom') {
|
|
85
|
-
const { customAppId } = await inquirer.prompt([{
|
|
86
|
-
type: 'input', name: 'customAppId',
|
|
87
|
-
message: 'Custom AppID:',
|
|
88
|
-
}]);
|
|
89
|
-
if (customAppId.trim()) extraParams['AppID'] = customAppId.trim();
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
}
|
|
61
|
+
// dbo input is a low-level command — don't prompt for AppID.
|
|
62
|
+
// AppID prompting belongs in push/deploy where it's contextually required.
|
|
93
63
|
|
|
94
64
|
if (options.file.length > 0) {
|
|
95
65
|
// Multipart mode
|
package/src/commands/mv.js
CHANGED
|
@@ -607,8 +607,8 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
|
|
|
607
607
|
if (newRelativePath) metaUpdates.Path = newRelativePath;
|
|
608
608
|
|
|
609
609
|
// Update content column references if file was renamed
|
|
610
|
-
if (conflict.action === 'rename' && finalContentName && meta._contentColumns) {
|
|
611
|
-
for (const col of meta._contentColumns) {
|
|
610
|
+
if (conflict.action === 'rename' && finalContentName && (meta._companionReferenceColumns || meta._contentColumns)) {
|
|
611
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns)) {
|
|
612
612
|
if (meta[col] && String(meta[col]).startsWith('@')) {
|
|
613
613
|
metaUpdates[col] = `@${finalContentName}`;
|
|
614
614
|
}
|
|
@@ -677,7 +677,7 @@ async function mvFile(sourceFile, targetBinId, structure, options) {
|
|
|
677
677
|
// Update deploy config: remove old entry (by UID), re-insert with new path + correct key
|
|
678
678
|
await removeDeployEntry(uid);
|
|
679
679
|
if (newContentPath) {
|
|
680
|
-
const col = (meta._contentColumns || [])[0] || 'Content';
|
|
680
|
+
const col = (meta._companionReferenceColumns || meta._contentColumns || [])[0] || 'Content';
|
|
681
681
|
await upsertDeployEntry(newContentPath, uid, entity, col);
|
|
682
682
|
} else if (meta._mediaFile && String(meta._mediaFile).startsWith('@')) {
|
|
683
683
|
const movedMediaPath = join(targetDir, String(meta._mediaFile).substring(1));
|
package/src/commands/push.js
CHANGED
|
@@ -269,7 +269,7 @@ async function moveWillDeleteToTrash(entry) {
|
|
|
269
269
|
// Read the __WILL_DELETE__ metadata to find associated content files
|
|
270
270
|
const rawMeta = await readFile(willDeleteMeta, 'utf8');
|
|
271
271
|
const deletedMeta = JSON.parse(rawMeta);
|
|
272
|
-
for (const col of (deletedMeta._contentColumns || [])) {
|
|
272
|
+
for (const col of (deletedMeta._companionReferenceColumns || deletedMeta._contentColumns || [])) {
|
|
273
273
|
const ref = deletedMeta[col];
|
|
274
274
|
if (ref && String(ref).startsWith('@')) {
|
|
275
275
|
const refFile = String(ref).substring(1);
|
|
@@ -358,7 +358,8 @@ async function pushSingleFile(filePath, client, options, modifyKey = null, trans
|
|
|
358
358
|
const baseline = await loadAppJsonBaseline();
|
|
359
359
|
if (baseline) {
|
|
360
360
|
const appConfig = await loadAppConfig();
|
|
361
|
-
const
|
|
361
|
+
const cfg = await loadConfig();
|
|
362
|
+
const result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName, cfg.ServerTimezone);
|
|
362
363
|
if (result === false || result instanceof Set) return;
|
|
363
364
|
}
|
|
364
365
|
}
|
|
@@ -438,7 +439,7 @@ async function ensureManifestMetadata() {
|
|
|
438
439
|
|
|
439
440
|
const meta = {
|
|
440
441
|
_entity: 'content',
|
|
441
|
-
|
|
442
|
+
_companionReferenceColumns: ['Content'],
|
|
442
443
|
Content: '@/manifest.json',
|
|
443
444
|
Path: 'manifest.json',
|
|
444
445
|
Name: 'manifest.json',
|
|
@@ -534,9 +535,14 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
534
535
|
log.warn('No .app.json baseline found — performing full push (run "dbo clone" to enable delta sync)');
|
|
535
536
|
}
|
|
536
537
|
|
|
538
|
+
// Load server timezone for delta date comparisons
|
|
539
|
+
const pushConfig = await loadConfig();
|
|
540
|
+
const serverTz = pushConfig.ServerTimezone || 'America/Los_Angeles';
|
|
541
|
+
|
|
537
542
|
// Collect metadata with detected changes
|
|
538
543
|
const toPush = [];
|
|
539
544
|
const outputCompoundFiles = [];
|
|
545
|
+
const seenUIDs = new Map(); // UID → metaPath (deduplicate same-UID metadata in different dirs)
|
|
540
546
|
let skipped = 0;
|
|
541
547
|
|
|
542
548
|
for (const metaPath of metaFiles) {
|
|
@@ -555,6 +561,16 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
555
561
|
continue;
|
|
556
562
|
}
|
|
557
563
|
|
|
564
|
+
// Deduplicate: skip if another metadata file for the same UID was already processed.
|
|
565
|
+
// This happens when the same server record has stale metadata in a previous directory
|
|
566
|
+
// (e.g. after a BinID change on the server). The first match wins.
|
|
567
|
+
if (meta.UID && seenUIDs.has(meta.UID)) {
|
|
568
|
+
log.dim(` Skipping duplicate UID ${meta.UID} at ${basename(metaPath)} (already seen at ${basename(seenUIDs.get(meta.UID))})`);
|
|
569
|
+
skipped++;
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
if (meta.UID) seenUIDs.set(meta.UID, metaPath);
|
|
573
|
+
|
|
558
574
|
// AUTO-ADD DISABLED: justAddedUIDs skip removed (server-first workflow)
|
|
559
575
|
|
|
560
576
|
// Output hierarchy entities: only push compound output files (root with inline children).
|
|
@@ -571,7 +587,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
571
587
|
const isNewRecord = !meta.UID && !meta._id;
|
|
572
588
|
|
|
573
589
|
// Verify @file references exist
|
|
574
|
-
const contentCols = meta._contentColumns || [];
|
|
590
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
575
591
|
let missingFiles = false;
|
|
576
592
|
for (const col of contentCols) {
|
|
577
593
|
const ref = meta[col];
|
|
@@ -612,7 +628,7 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
612
628
|
let changedColumns = null;
|
|
613
629
|
if (!isNewRecord && baseline) {
|
|
614
630
|
try {
|
|
615
|
-
changedColumns = await detectChangedColumns(metaPath, baseline);
|
|
631
|
+
changedColumns = await detectChangedColumns(metaPath, baseline, serverTz);
|
|
616
632
|
if (changedColumns.length === 0) {
|
|
617
633
|
log.dim(` Skipping ${basename(metaPath)} — no changes detected`);
|
|
618
634
|
skipped++;
|
|
@@ -631,7 +647,8 @@ async function pushDirectory(dirPath, client, options, modifyKey = null, transac
|
|
|
631
647
|
const toCheck = toPush.filter(item => !item.isNew);
|
|
632
648
|
if (toCheck.length > 0) {
|
|
633
649
|
const appConfig = await loadAppConfig();
|
|
634
|
-
const
|
|
650
|
+
const cfg = await loadConfig();
|
|
651
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName, cfg.ServerTimezone);
|
|
635
652
|
if (result === false) return; // user cancelled entirely
|
|
636
653
|
if (result instanceof Set) {
|
|
637
654
|
// Filter out skipped UIDs
|
|
@@ -934,7 +951,8 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
|
|
|
934
951
|
}
|
|
935
952
|
if (toCheck.length > 0) {
|
|
936
953
|
const appConfig = await loadAppConfig();
|
|
937
|
-
const
|
|
954
|
+
const cfg3 = await loadConfig();
|
|
955
|
+
const result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName, cfg3.ServerTimezone);
|
|
938
956
|
if (result === false) return; // user cancelled entirely
|
|
939
957
|
if (result instanceof Set) {
|
|
940
958
|
// Filter out skipped UIDs from matches
|
|
@@ -995,7 +1013,7 @@ async function pushByUIDs(uids, client, options, modifyKey = null, transactionKe
|
|
|
995
1013
|
*/
|
|
996
1014
|
async function addFromMetadata(meta, metaPath, client, options, modifyKey = null) {
|
|
997
1015
|
const entity = meta._entity;
|
|
998
|
-
const contentCols = new Set(meta._contentColumns || []);
|
|
1016
|
+
const contentCols = new Set(meta._companionReferenceColumns || meta._contentColumns || []);
|
|
999
1017
|
const metaDir = dirname(metaPath);
|
|
1000
1018
|
|
|
1001
1019
|
const dataExprs = [];
|
|
@@ -1121,7 +1139,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
1121
1139
|
const uid = meta.UID;
|
|
1122
1140
|
const id = meta._id;
|
|
1123
1141
|
const entity = meta._entity;
|
|
1124
|
-
const contentCols = new Set(meta._contentColumns || []);
|
|
1142
|
+
const contentCols = new Set(meta._companionReferenceColumns || meta._contentColumns || []);
|
|
1125
1143
|
const metaDir = dirname(metaPath);
|
|
1126
1144
|
|
|
1127
1145
|
// Determine the row key. TransactionKeyPreset only applies when the record
|
|
@@ -1347,7 +1365,7 @@ async function pushFromMetadata(meta, metaPath, client, options, changedColumns
|
|
|
1347
1365
|
await writeFile(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
1348
1366
|
await setFileTimestamps(metaPath, meta._CreatedOn, updated, serverTz);
|
|
1349
1367
|
// Update content file mtime too
|
|
1350
|
-
const contentCols = meta._contentColumns || [];
|
|
1368
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
1351
1369
|
for (const col of contentCols) {
|
|
1352
1370
|
const ref = meta[col];
|
|
1353
1371
|
if (ref && String(ref).startsWith('@')) {
|
|
@@ -1437,7 +1455,7 @@ async function checkPathMismatch(meta, metaPath, entity, options) {
|
|
|
1437
1455
|
const metaBase = parseMetaFilename(basename(metaPath))?.naturalBase ?? basename(metaPath, '.metadata.json');
|
|
1438
1456
|
|
|
1439
1457
|
// Find the content file referenced by @filename
|
|
1440
|
-
const contentCols = meta._contentColumns || [];
|
|
1458
|
+
const contentCols = meta._companionReferenceColumns || meta._contentColumns || [];
|
|
1441
1459
|
let contentFileName = null;
|
|
1442
1460
|
for (const col of contentCols) {
|
|
1443
1461
|
const ref = meta[col];
|
package/src/commands/rm.js
CHANGED
|
@@ -91,7 +91,7 @@ async function rmFileRecord(metaPath, options, { skipPrompt = false } = {}) {
|
|
|
91
91
|
const metaDir = dirname(metaPath);
|
|
92
92
|
const localFiles = [metaPath];
|
|
93
93
|
|
|
94
|
-
for (const col of (meta._contentColumns || [])) {
|
|
94
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
95
95
|
const ref = meta[col];
|
|
96
96
|
if (ref && String(ref).startsWith('@')) {
|
|
97
97
|
localFiles.push(join(metaDir, String(ref).substring(1)));
|
|
@@ -197,7 +197,7 @@ async function rmFile(filePath, options) {
|
|
|
197
197
|
// Collect local files for display
|
|
198
198
|
const metaDir = dirname(metaPath);
|
|
199
199
|
const localFiles = [metaPath];
|
|
200
|
-
for (const col of (meta._contentColumns || [])) {
|
|
200
|
+
for (const col of (meta._companionReferenceColumns || meta._contentColumns || [])) {
|
|
201
201
|
const ref = meta[col];
|
|
202
202
|
if (ref && String(ref).startsWith('@')) {
|
|
203
203
|
localFiles.push(join(metaDir, String(ref).substring(1)));
|
package/src/lib/columns.js
CHANGED
package/src/lib/config.js
CHANGED
|
@@ -66,7 +66,7 @@ export async function readLegacyConfig() {
|
|
|
66
66
|
|
|
67
67
|
export async function initConfig(domain) {
|
|
68
68
|
await mkdir(dboDir(), { recursive: true });
|
|
69
|
-
await writeFile(configPath(), JSON.stringify({ domain }, null, 2) + '\n');
|
|
69
|
+
await writeFile(configPath(), JSON.stringify({ domain, dependencies: ['_system'] }, null, 2) + '\n');
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
export async function saveCredentials(username) {
|
|
@@ -167,6 +167,88 @@ export async function updateConfigWithApp({ AppID, AppUID, AppName, AppShortName
|
|
|
167
167
|
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
+
// ─── Dependency helpers ───────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Get the dependencies array from .dbo/config.json.
|
|
174
|
+
* Returns ["_system"] if the key is absent.
|
|
175
|
+
*/
|
|
176
|
+
export async function getDependencies() {
|
|
177
|
+
try {
|
|
178
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
179
|
+
const config = JSON.parse(raw);
|
|
180
|
+
const deps = config.dependencies;
|
|
181
|
+
if (!Array.isArray(deps)) return ['_system'];
|
|
182
|
+
if (!deps.includes('_system')) deps.unshift('_system');
|
|
183
|
+
return deps;
|
|
184
|
+
} catch {
|
|
185
|
+
return ['_system'];
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Merge new short-names into the dependencies array (union, no duplicates).
|
|
191
|
+
* Persists the result to .dbo/config.json.
|
|
192
|
+
*/
|
|
193
|
+
export async function mergeDependencies(shortnames) {
|
|
194
|
+
await mkdir(dboDir(), { recursive: true });
|
|
195
|
+
let existing = {};
|
|
196
|
+
try {
|
|
197
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
198
|
+
} catch { /* no config */ }
|
|
199
|
+
const current = Array.isArray(existing.dependencies) ? existing.dependencies : ['_system'];
|
|
200
|
+
for (const s of shortnames) {
|
|
201
|
+
if (s && !current.includes(s)) current.push(s);
|
|
202
|
+
}
|
|
203
|
+
if (!current.includes('_system')) current.unshift('_system');
|
|
204
|
+
existing.dependencies = current;
|
|
205
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
206
|
+
return current;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Replace the full dependencies array in .dbo/config.json.
|
|
211
|
+
* Always ensures _system is present.
|
|
212
|
+
*/
|
|
213
|
+
export async function setDependencies(shortnames) {
|
|
214
|
+
await mkdir(dboDir(), { recursive: true });
|
|
215
|
+
let existing = {};
|
|
216
|
+
try {
|
|
217
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
218
|
+
} catch { /* no config */ }
|
|
219
|
+
const deps = [...new Set(['_system', ...shortnames.filter(Boolean)])];
|
|
220
|
+
existing.dependencies = deps;
|
|
221
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Get dependencyLastUpdated.<shortname> from .dbo/config.json.
|
|
226
|
+
* Returns null if absent.
|
|
227
|
+
*/
|
|
228
|
+
export async function getDependencyLastUpdated(shortname) {
|
|
229
|
+
try {
|
|
230
|
+
const raw = await readFile(configPath(), 'utf8');
|
|
231
|
+
const config = JSON.parse(raw);
|
|
232
|
+
return (config.dependencyLastUpdated && config.dependencyLastUpdated[shortname]) || null;
|
|
233
|
+
} catch {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Set dependencyLastUpdated.<shortname> in .dbo/config.json.
|
|
240
|
+
*/
|
|
241
|
+
export async function setDependencyLastUpdated(shortname, timestamp) {
|
|
242
|
+
await mkdir(dboDir(), { recursive: true });
|
|
243
|
+
let existing = {};
|
|
244
|
+
try {
|
|
245
|
+
existing = JSON.parse(await readFile(configPath(), 'utf8'));
|
|
246
|
+
} catch { /* no config */ }
|
|
247
|
+
if (!existing.dependencyLastUpdated) existing.dependencyLastUpdated = {};
|
|
248
|
+
existing.dependencyLastUpdated[shortname] = timestamp;
|
|
249
|
+
await writeFile(configPath(), JSON.stringify(existing, null, 2) + '\n');
|
|
250
|
+
}
|
|
251
|
+
|
|
170
252
|
/**
|
|
171
253
|
* Load app-related fields from .dbo/config.json.
|
|
172
254
|
*/
|
package/src/lib/delta.js
CHANGED
|
@@ -2,6 +2,7 @@ import { readFile, stat } from 'fs/promises';
|
|
|
2
2
|
import { join, dirname } from 'path';
|
|
3
3
|
import { log } from './logger.js';
|
|
4
4
|
import { loadAppJsonBaseline, saveAppJsonBaseline } from './config.js';
|
|
5
|
+
import { parseServerDate } from './timestamps.js';
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Load the baseline file from disk.
|
|
@@ -98,9 +99,10 @@ export async function compareFileContent(filePath, baselineValue) {
|
|
|
98
99
|
*
|
|
99
100
|
* @param {string} metaPath - Path to metadata.json file
|
|
100
101
|
* @param {Object} baseline - The baseline JSON
|
|
102
|
+
* @param {string} [serverTz] - Server timezone for date parsing (e.g. 'America/Los_Angeles')
|
|
101
103
|
* @returns {Promise<string[]>} - Array of changed column names
|
|
102
104
|
*/
|
|
103
|
-
export async function detectChangedColumns(metaPath, baseline) {
|
|
105
|
+
export async function detectChangedColumns(metaPath, baseline, serverTz) {
|
|
104
106
|
// Load current metadata
|
|
105
107
|
const metaRaw = await readFile(metaPath, 'utf8');
|
|
106
108
|
const metadata = JSON.parse(metaRaw);
|
|
@@ -164,15 +166,28 @@ export async function detectChangedColumns(metaPath, baseline) {
|
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
|
|
167
|
-
// Check _mediaFile for binary file changes (media entities)
|
|
169
|
+
// Check _mediaFile for binary file changes (media entities).
|
|
170
|
+
// Compare the media file's mtime against the baseline's _LastUpdated (the sync point)
|
|
171
|
+
// rather than the metadata file's mtime, because migrations and other operations can
|
|
172
|
+
// rewrite metadata without touching the companion, skewing the mtime relationship.
|
|
168
173
|
if (metadata._mediaFile && isReference(metadata._mediaFile)) {
|
|
169
174
|
const mediaPath = resolveReferencePath(metadata._mediaFile, metaDir);
|
|
170
175
|
try {
|
|
171
176
|
const mediaStat = await stat(mediaPath);
|
|
172
|
-
const
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
177
|
+
const baselineDate = baselineEntry?._LastUpdated
|
|
178
|
+
? parseServerDate(baselineEntry._LastUpdated, serverTz)
|
|
179
|
+
: null;
|
|
180
|
+
if (baselineDate) {
|
|
181
|
+
// Media file modified after baseline sync point = local change
|
|
182
|
+
if (mediaStat.mtimeMs > baselineDate.getTime() + 2000) {
|
|
183
|
+
changedColumns.push('_mediaFile');
|
|
184
|
+
}
|
|
185
|
+
} else {
|
|
186
|
+
// No baseline date — fall back to metadata mtime comparison
|
|
187
|
+
const metaStat = await stat(metaPath);
|
|
188
|
+
if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
|
|
189
|
+
changedColumns.push('_mediaFile');
|
|
190
|
+
}
|
|
176
191
|
}
|
|
177
192
|
} catch { /* missing file, skip */ }
|
|
178
193
|
}
|
|
@@ -245,7 +260,16 @@ export function normalizeValue(value) {
|
|
|
245
260
|
return JSON.stringify(value);
|
|
246
261
|
}
|
|
247
262
|
|
|
248
|
-
|
|
263
|
+
const str = String(value).trim();
|
|
264
|
+
|
|
265
|
+
// Normalize ISO date strings: strip trailing Z so that
|
|
266
|
+
// "2026-03-11T03:12:35" and "2026-03-11T03:12:35Z" compare as equal.
|
|
267
|
+
// Both values come from the same server timezone — only the Z suffix differs.
|
|
268
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(str)) {
|
|
269
|
+
return str.replace(/Z$/, '');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return str;
|
|
249
273
|
}
|
|
250
274
|
|
|
251
275
|
// ─── Compound Output Delta Detection ────────────────────────────────────────
|