@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.
@@ -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');
@@ -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
- // Check if data expressions include AppID; if not and config has one, prompt
63
- // Skip AppID prompt for delete-only submissions deletes don't need it
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
@@ -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));
@@ -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 result = await checkToeStepping([{ meta, metaPath }], client, baseline, options, appConfig?.AppShortName);
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
- _contentColumns: ['Content'],
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 result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
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 result = await checkToeStepping(toCheck, client, baseline, options, appConfig?.AppShortName);
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];
@@ -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)));
@@ -14,6 +14,7 @@ const SKIP_COLUMNS = new Set([
14
14
  '_LastUpdatedTicketID',
15
15
  '_entity',
16
16
  '_contentColumns',
17
+ '_companionReferenceColumns',
17
18
  ]);
18
19
 
19
20
  /**
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 metaStat = await stat(metaPath);
173
- // Media file modified more recently than metadata = local change
174
- if (mediaStat.mtimeMs > metaStat.mtimeMs + 2000) {
175
- changedColumns.push('_mediaFile');
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
- return String(value).trim();
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 ────────────────────────────────────────