@pixlcore/xycli 1.0.6 → 1.0.8

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/lib/sync.js CHANGED
@@ -44,7 +44,7 @@ module.exports = {
44
44
 
45
45
  println( "\n " + cli.emoji('🔄') + ' ' + this.color('theme').bold("XYOPS SYNC ENGINE v1") );
46
46
 
47
- var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
47
+ var supported_lists = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
48
48
 
49
49
  // allow sync config in config files and/or cli args
50
50
  var sconfig = this.sconfig = Tools.mergeHashes( this.config.sync || {}, this.args || {} );
@@ -77,6 +77,13 @@ module.exports = {
77
77
  this.die("Delete mode requires up-only sync. Enable --up and disable --down.");
78
78
  }
79
79
 
80
+ // Bucket content needs the replacement API and data timestamps introduced
81
+ // in xyOps 1.1.2. Check before any ordinary sync changes are applied.
82
+ if ((sconfig.up && sconfig.up.includes('buckets')) || (sconfig.down && sconfig.down.includes('buckets')) ||
83
+ (sconfig.delete && sconfig.delete.includes('buckets'))) {
84
+ this.requireBucketSyncVersion();
85
+ }
86
+
80
87
  // scan all base directories, default to cwd
81
88
  var files = [];
82
89
  if (!this.args.other || !this.args.other.length) {
@@ -106,11 +113,15 @@ module.exports = {
106
113
  // longest-first lets each external property file belong to the most specific
107
114
  // source basename instead of also being claimed by every shorter prefix.
108
115
  var source_stems_by_dir = new Map();
116
+ var bucket_extra_dirs = [];
109
117
  files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
110
118
  var xypdf = null;
111
119
  try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
112
120
  catch (err) { return; } // the normal scan below reports malformed JSON
113
121
  if (!xypdf || !Tools.isaHash(xypdf) || (xypdf.type !== 'xypdf')) return;
122
+ if (Array.isArray(xypdf.items) && xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
123
+ bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
124
+ }
114
125
 
115
126
  var dir = Path.dirname(file.path);
116
127
  var stems = source_stems_by_dir.get(dir);
@@ -127,7 +138,7 @@ module.exports = {
127
138
  // load all xypdf files
128
139
  var items = [];
129
140
 
130
- files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
141
+ files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
131
142
  // file: { path, size, mtime }
132
143
  var dir = Path.dirname(file.path);
133
144
  var filename = Path.basename(file.path);
@@ -175,7 +186,7 @@ module.exports = {
175
186
  this.logSyncWarning("Malformed item type in file: " + file.path);
176
187
  return;
177
188
  }
178
- if (item.type.match(/^(bucket|secret|user|role)$/)) {
189
+ if (item.type.match(/^(secret|user|role)$/)) {
179
190
  this.logSyncWarning("Unsupported item type: " + item.type + " in file: " + file.path);
180
191
  return;
181
192
  }
@@ -253,13 +264,13 @@ module.exports = {
253
264
  }
254
265
 
255
266
  // External files may only replace string properties that already exist in
256
- // the definition. Tools.setPath() creates missing keys, so validate first.
257
- if (typeof(Tools.getPath(item.data, prop_path)) != 'string') {
267
+ // the definition. setSyncPropByPath() creates missing keys, so validate first.
268
+ if (typeof(this.getSyncPropByPath(item.data, prop_path)) != 'string') {
258
269
  this.logSyncWarning("Neighbor file does not match an existing string property: " + neighbor.path + " (" + prop_path + ")");
259
270
  neighbor_error = true;
260
271
  return;
261
272
  }
262
- if (!Tools.setPath(item.data, prop_path, content)) {
273
+ if (!this.setSyncPropByPath(item.data, prop_path, content)) {
263
274
  this.logSyncWarning("Invalid neighbor property path: " + neighbor.path + " (" + prop_path + ")");
264
275
  neighbor_error = true;
265
276
  return;
@@ -313,24 +324,8 @@ module.exports = {
313
324
  var changes = this.diffSyncItems(xy_item, item.data);
314
325
  if (!changes.some( change => change.added || change.removed )) continue; // identical
315
326
 
316
- // render diff
317
- var diff = "```diff\n";
318
- changes.forEach( change => {
319
- var prefix = change.added ? '+' : change.removed ? '-' : ' ';
320
- var lines = change.value.replace(/\n$/, '').split(/\n/);
321
-
322
- if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
323
- // reduce context lines to 1 above and 1 below, unless in verbose mode
324
- var top = lines.shift();
325
- var bottom = lines.pop();
326
- lines = [ top, "...", bottom ];
327
- }
328
-
329
- lines.forEach( line => {
330
- diff += prefix + line.trimEnd() + "\n";
331
- } );
332
- } );
333
- diff += "```\n";
327
+ // Use the same highlighted, context-trimmed diff for every sync item.
328
+ var diff = this.formatSyncDiff(changes);
334
329
 
335
330
  // figure out which direction to go
336
331
  var direction = false;
@@ -358,7 +353,7 @@ module.exports = {
358
353
  if (direction == 'up') {
359
354
  // sync change up to xyops
360
355
  try {
361
- await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true });
356
+ await this.callStandardAPI('update_' + item.type, item.data, { text: "Updating " + item.type + "...", throw: true, sync: true });
362
357
 
363
358
  // add flag to run optional command at completion
364
359
  this.upSynced = true;
@@ -383,6 +378,12 @@ module.exports = {
383
378
 
384
379
  // update state db
385
380
  await this.syncState(state);
381
+
382
+ // Bucket metadata is handled above like every other XYPDF item. Its data
383
+ // and binary files have their own storage records and reconciliation pass.
384
+ if (sconfig.up && sconfig.up.includes('buckets') || sconfig.down && sconfig.down.includes('buckets')) {
385
+ await this.syncBucketContents(items.filter( item => item.type == 'bucket' ), sconfig);
386
+ }
386
387
 
387
388
  // Deletion is the destructive final phase, so only begin it after every
388
389
  // update and the sync-state write completed cleanly. Earlier successful
@@ -418,7 +419,7 @@ module.exports = {
418
419
  println( "\n " + cli.emoji('🗑️') + ' ' + red.bold("Deleting " + type_def.id) + ": " + red.bold(xy_item.title) + gray(" (" + xy_item.id + ")") );
419
420
 
420
421
  try {
421
- await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true });
422
+ await this.callStandardAPI('delete_' + type_def.id, { id: xy_item.id }, { text: "Deleting " + type_def.id + "...", throw: true, sync: true });
422
423
 
423
424
  // add flag to run optional command at completion
424
425
  this.upSynced = true;
@@ -703,9 +704,9 @@ module.exports = {
703
704
  if (item.neighbors) {
704
705
  for (var path in item.neighbors) {
705
706
  var file = item.neighbors[path];
706
- var value = Tools.getPath( data, path ) ?? "";
707
+ var value = this.getSyncPropByPath( data, path ) ?? "";
707
708
 
708
- if (!Tools.setPath( data, path, "(External)" )) {
709
+ if (!this.setSyncPropByPath( data, path, "(External)" )) {
709
710
  throw "External neighbor file path not found: " + file + ": " + path;
710
711
  }
711
712
 
@@ -743,6 +744,27 @@ module.exports = {
743
744
  );
744
745
  },
745
746
 
747
+ formatSyncDiff(changes) {
748
+ // Render a diff code block for the CLI Markdown highlighter. Keep long
749
+ // unchanged sections compact unless the user requested verbose output.
750
+ var diff = "```diff\n";
751
+ changes.forEach( change => {
752
+ var prefix = change.added ? '+' : change.removed ? '-' : ' ';
753
+ var lines = change.value.replace(/\n$/, '').split(/\n/);
754
+
755
+ if (!change.added && !change.removed && (lines.length > 2) && !this.verbose) {
756
+ var top = lines.shift();
757
+ var bottom = lines.pop();
758
+ lines = [ top, "...", bottom ];
759
+ }
760
+
761
+ lines.forEach( line => {
762
+ diff += prefix + line.trimEnd() + "\n";
763
+ } );
764
+ } );
765
+ return diff + "```\n";
766
+ },
767
+
746
768
  logSyncWarning(msg) {
747
769
  this.warnings.push(msg);
748
770
 
@@ -769,14 +791,14 @@ module.exports = {
769
791
  // xy sync --setup events --new
770
792
  // xy sync --setup all --marketplace --stock --force
771
793
  if ((this.args.setup === 'all') || (this.args.setup[0] === 'all')) {
772
- this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(buckets|secrets|users|roles)$/) );
794
+ this.args.setup = this.config.ui.list_list.map( list => list.id ).filter( id => !id.match(/^(secrets|users|roles)$/) );
773
795
  }
774
796
 
775
797
  if (typeof(this.args.setup) == 'string') this.args.setup = this.args.setup.split(/\,/);
776
798
  if (!Array.isArray(this.args.setup)) this.dieUsage('sync setup');
777
799
 
778
- if (Tools.includesAny(this.args.setup, ['buckets', 'secrets', 'users', 'roles'])) {
779
- this.die("Sorry, the sync engine cannot process buckets, secrets, users, or roles.");
800
+ if (Tools.includesAny(this.args.setup, ['secrets', 'users', 'roles'])) {
801
+ this.die("Sorry, the sync engine cannot process secrets, users, or roles.");
780
802
  }
781
803
 
782
804
  if (!Tools.includesAll(this.config.ui.list_list.map( list => list.id ), this.args.setup)) {
@@ -784,6 +806,7 @@ module.exports = {
784
806
  }
785
807
 
786
808
  if (!this.args.setup.length) this.die("No item types specified to set up.");
809
+ if (this.args.setup.includes('buckets')) this.requireBucketSyncVersion();
787
810
 
788
811
  if (this.args.file_props) {
789
812
  if (typeof(this.args.file_props) == 'string') this.args.file_props = this.args.file_props.split(/\,/);
@@ -828,6 +851,13 @@ module.exports = {
828
851
  force: !!this.args.force,
829
852
  dry: !!this.dry
830
853
  });
854
+
855
+ if (this.args.setup.includes('buckets')) {
856
+ await this.setupBucketContents(result.bucket_plans, {
857
+ force: !!this.args.force,
858
+ dry: !!this.dry
859
+ });
860
+ }
831
861
 
832
862
  if (new_only && !result.written) println( "\n " + gray("No new definitions found.") );
833
863
 
@@ -870,7 +900,18 @@ module.exports = {
870
900
  });
871
901
 
872
902
  var index = {};
903
+ var bucket_extra_dirs = [];
873
904
  files.filter( file => !!file.path.match(/\.json$/i) ).forEach( file => {
905
+ try {
906
+ var xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') );
907
+ if (xypdf && xypdf.type == 'xypdf' && Array.isArray(xypdf.items) &&
908
+ xypdf.items.length == 1 && xypdf.items[0].type == 'bucket') {
909
+ bucket_extra_dirs.push(file.path.replace(/\.json$/i, ''));
910
+ }
911
+ }
912
+ catch (err) {;} // report malformed source files during the real scan
913
+ });
914
+ files.filter( file => !!file.path.match(/\.json$/i) && !this.isSyncBucketExtraFile(file.path, bucket_extra_dirs) ).forEach( file => {
874
915
  var xypdf = null;
875
916
  try { xypdf = Tools.parseJSON( fs.readFileSync(file.path, 'utf8') ); }
876
917
  catch (err) { this.die("Failed to parse JSON file: " + file.path + ": " + err); }
@@ -889,7 +930,7 @@ module.exports = {
889
930
  if (!item || !Tools.isaHash(item) || !item.type || (typeof(item.type) != 'string')) {
890
931
  this.die("Malformed item type in file: " + file.path);
891
932
  }
892
- if (item.type.match(/^(bucket|secret|user|role)$/)) {
933
+ if (item.type.match(/^(secret|user|role)$/)) {
893
934
  this.die("Unsupported item type: " + item.type + " in file: " + file.path);
894
935
  }
895
936
  if (!item.data || !Tools.isaHash(item.data) || !item.data.id || (typeof(item.data.id) != 'string')) {
@@ -914,6 +955,15 @@ module.exports = {
914
955
  return index;
915
956
  },
916
957
 
958
+ isSyncBucketExtraFile(file, dirs) {
959
+ // Bucket data and uploaded files are opaque to the XYPDF parser, even if
960
+ // a user uploaded a .json file with non-JSON contents.
961
+ return dirs.some( dir => {
962
+ var relative = Path.relative(dir, file);
963
+ return relative && (relative != '..') && !relative.startsWith('..' + Path.sep) && !Path.isAbsolute(relative);
964
+ });
965
+ },
966
+
917
967
  writeSyncSetupFiles(list_names, opts = {}) {
918
968
  // Write selected remote definitions using setup's suggested layout. Keeping
919
969
  // this operation separate allows a future downsync to append newly discovered
@@ -935,7 +985,7 @@ module.exports = {
935
985
  if (type_def) targets.push({ name: list_name, type: type_def.id, items: this[list_name] || [] });
936
986
  });
937
987
 
938
- var result = { written: 0, skipped: 0 };
988
+ var result = { written: 0, skipped: 0, bucket_plans: [] };
939
989
  var existing_keys = opts.existing_keys || {};
940
990
  var base_dir = opts.base_dir || '.';
941
991
  var default_ext = this.normalizeSyncDefaultExt(opts.default_ext);
@@ -1006,10 +1056,22 @@ module.exports = {
1006
1056
  var item = Tools.copyHash(plan.source_item, true);
1007
1057
  var extras = [];
1008
1058
  (opts.file_props || []).forEach( path => {
1009
- var extra = Tools.getPath(item, path);
1059
+ var extra = this.getSyncPropByPath(item, path);
1010
1060
  if ((typeof(extra) == 'string') && extra.match(/\S/)) {
1011
1061
  extras.push({ path, content: extra });
1012
- Tools.setPath( item, path, "(External)" );
1062
+ this.setSyncPropByPath( item, path, "(External)" );
1063
+ }
1064
+
1065
+ // handle workflow nodes separately
1066
+ if (item.workflow && item.workflow.nodes && Array.isArray(item.workflow.nodes)) {
1067
+ item.workflow.nodes.forEach( node => {
1068
+ if (!node.data || !Tools.isaHash(node.data)) return; // sanity
1069
+ var extra = this.getSyncPropByPath(node.data, path);
1070
+ if ((typeof(extra) == 'string') && extra.match(/\S/)) {
1071
+ extras.push({ path: `workflow.${node.id}.${path}`, content: extra });
1072
+ this.setSyncPropByPath( node.data, path, "(External)" );
1073
+ }
1074
+ } );
1013
1075
  }
1014
1076
  });
1015
1077
 
@@ -1017,6 +1079,7 @@ module.exports = {
1017
1079
  var title = plan.title;
1018
1080
  var filename = title + '.json';
1019
1081
  var file = Path.join(dir, filename);
1082
+ if (target.type == 'bucket') result.bucket_plans.push({ id: item.id, file: file });
1020
1083
 
1021
1084
  if (!opts.dry) {
1022
1085
  if (!opts.force && fs.existsSync(file)) this.die("File exists, add '--force' to overwrite all: " + file);
@@ -1188,6 +1251,42 @@ module.exports = {
1188
1251
  }
1189
1252
 
1190
1253
  return ext;
1254
+ },
1255
+
1256
+ getSyncPropByPath(data, path) {
1257
+ // special-case Tools.getPath for handling shorthand for named workflow nodes
1258
+ // e.g. `workflow.nabcd1234.params.foo` == `workflow.nodes[id='nabcd1234'].data.params.foo`
1259
+ if (path.match(/^workflow\.(\w+)\.(.+)$/) && data.workflow && data.workflow.nodes && Array.isArray(data.workflow.nodes)) {
1260
+ var node_id = RegExp.$1;
1261
+ var sub_path = RegExp.$2;
1262
+ if (node_id === 'nodes') return Tools.getPath(data, path);
1263
+
1264
+ var node = Tools.findObject( data.workflow.nodes, { id: node_id } );
1265
+ if (!node || !node.data) return undefined;
1266
+
1267
+ data = node.data;
1268
+ path = sub_path;
1269
+ }
1270
+
1271
+ return Tools.getPath(data, path);
1272
+ },
1273
+
1274
+ setSyncPropByPath(data, path, value) {
1275
+ // special-case Tools.setPath for handling shorthand for named workflow nodes
1276
+ // e.g. `workflow.nabcd1234.params.foo` == `workflow.nodes[id='nabcd1234'].data.params.foo`
1277
+ if (path.match(/^workflow\.(\w+)\.(.+)$/) && data.workflow && data.workflow.nodes && Array.isArray(data.workflow.nodes)) {
1278
+ var node_id = RegExp.$1;
1279
+ var sub_path = RegExp.$2;
1280
+ if (node_id === 'nodes') return Tools.setPath(data, path, value);
1281
+
1282
+ var node = Tools.findObject( data.workflow.nodes, { id: node_id } );
1283
+ if (!node || !node.data) return undefined;
1284
+
1285
+ data = node.data;
1286
+ path = sub_path;
1287
+ }
1288
+
1289
+ return Tools.setPath(data, path, value);
1191
1290
  }
1192
1291
 
1193
1292
  };
package/lib/utils.js CHANGED
@@ -1057,7 +1057,8 @@ module.exports = {
1057
1057
  async callStandardAPI(name, req, opts = {}) {
1058
1058
  // wrapper around APIs that make a change
1059
1059
  // indeterminate progress w/text, handle dry run, verbose req/res, etc.
1060
- this.validateStandardAPIRequest(name, req);
1060
+ if (!opts.sync) this.validateStandardAPIRequest(name, req);
1061
+ delete opts.sync;
1061
1062
 
1062
1063
  // Sensitive callers may supply a redacted display copy while the original
1063
1064
  // request continues through validation and transport unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixlcore/xycli",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "A complete command-line interface for the xyOps workflow automation system.",
5
5
  "author": "Joseph Huckaby <jhuckaby@pixlcore.com>",
6
6
  "homepage": "https://github.com/pixlcore/xycli",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "scripts": {
23
23
  "test": "node test.js",
24
- "test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
24
+ "test:offline": "node --test test/config.test.js test/help.test.js test/markdown.test.js test/sync-buckets-unit.test.js test/sync-unit.test.js test/toast.test.js test/upgrade.test.js test/utils-unit.test.js",
25
25
  "changelog": "node tools/changelog.js"
26
26
  },
27
27
  "dependencies": {
package/test/README.md CHANGED
@@ -8,7 +8,7 @@ Use Node.js 22 or newer and install the CLI dependencies with `npm install`.
8
8
 
9
9
  Start xyOps at `http://localhost:5522` and configure an administrator API key using your normal CLI config file or the `XYOPS_API_KEY` environment variable. The tests read the same configuration as `xy`, including `XYOPS_BASE_URL` overrides. They refuse to run against a different server address.
10
10
 
11
- Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
11
+ Use an xyOps version that supports restoring API key IDs, hashes, and masks through `create_api_key`. The `sync-buckets` suite requires xyOps 1.1.2 or later. The Server and Job tests need a connected server, the Job tests need the enabled stock Shell Plugin, and the Monitor tests also need a server group. The log tests expect the standard log columns and debug entries in the current xyOps log.
12
12
 
13
13
  ## Running Tests
14
14
 
@@ -42,10 +42,10 @@ node --test --test-reporter=spec test/monitors.test.js
42
42
 
43
43
  The controller exits with a nonzero status if any suite fails. A failed lifecycle check stops the remaining dependent steps in that suite, runs cleanup, and allows the next suite to proceed.
44
44
 
45
- The configuration and sync unit suites run offline with disposable directories and do not contact xyOps. Run them directly without configuring a development server:
45
+ The configuration and sync unit suites, including `sync-buckets-unit`, run offline with disposable directories and do not contact xyOps. Run them directly without configuring a development server:
46
46
 
47
47
  ```sh
48
- node --test test/config.test.js test/sync-unit.test.js
48
+ node --test test/config.test.js test/sync-buckets-unit.test.js test/sync-unit.test.js
49
49
  ```
50
50
 
51
51
  ## Coverage
@@ -67,6 +67,8 @@ node --test test/config.test.js test/sync-unit.test.js
67
67
  | `servers` | Active and recently offline lists, local filters, installer generation, historical search, live and historical Server details, charts, processes, connections, pagination, validation, help |
68
68
  | `system` | Admin dashboard, conductors, connected users, export, maintenance, optimization, rate resets, diagnostics, broadcasts, help |
69
69
  | `sync` | Setup export, isolated filesystem discovery, external property files, dry-run, upsync, downsync, composite global-state governance, no-op detection, and guarded delete mode |
70
+ | `sync-buckets` | Disposable Bucket setup, JSON replacement, file upload and download, and timestamp alignment on xyOps 1.1.2 or later |
71
+ | `sync-buckets-unit` | Offline Bucket version gating, content setup, upload and download, timestamp alignment, and pre-delete safety |
70
72
  | `sync-unit` | Offline setup extension fallbacks, new-only exports and setup completion commands, PID locking and stale recovery, up-only deletion guards, pre-delete failure blocking, stock/Marketplace deletion protection, inherited settings and CLI overrides, warning/error exit status, duplicates, quiet/dry runs, completion failures, and notification delivery after warnings or errors |
71
73
  | `tags` | CRUD, metadata search, sparse future-proof updates, JSON input, validation, pagination, help |
72
74
  | `tickets` | Search, CRUD, number resolution, comments, attachment uploads/downloads, Events, related Job lookup, sparse updates, validation, help |
@@ -0,0 +1,188 @@
1
+ const { test } = require('node:test');
2
+ const assert = require('node:assert/strict');
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const Path = require('node:path');
6
+ const bucketSync = require('../lib/sync-buckets.js');
7
+ const sync = require('../lib/sync.js');
8
+ const utils = require('../lib/utils.js');
9
+
10
+ // The bucket pass uses the ordinary sync logging functions, whose output is
11
+ // provided by pixl-cli in production. Keep the test output quiet and local.
12
+ global.println = () => {};
13
+
14
+ function fixture(t, options = {}) {
15
+ const dir = fs.mkdtempSync(Path.join(os.tmpdir(), 'xycli-bucket-sync-unit-'));
16
+ t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
17
+ const item = { type: 'bucket', data: { id: 'bucket_unit', title: 'Changed Title' }, file: Path.join(dir, 'custom', 'Original-Name.json') };
18
+ const paths = bucketSync.getSyncBucketPaths(item);
19
+ fs.mkdirSync(paths.files, { recursive: true });
20
+ fs.writeFileSync(item.file, '{}');
21
+ fs.writeFileSync(paths.data, JSON.stringify(options.localData || { keep: true, removed: true }));
22
+ fs.utimesSync(paths.data, 100, options.localDataTime || 100);
23
+
24
+ const remote = {
25
+ bucket: { id: item.data.id, title: item.data.title },
26
+ data: options.remoteData || { keep: false, server: true },
27
+ meta: { mod: options.remoteDataTime || 90 },
28
+ files: options.remoteFiles || []
29
+ };
30
+ const bytes = options.remoteBytes || {};
31
+ const calls = [];
32
+ const app = {
33
+ ...bucketSync,
34
+ ...sync,
35
+ compareVersions: utils.compareVersions,
36
+ xyopsVersion: options.version || '1.1.2',
37
+ dry: !!options.dry,
38
+ errors: [],
39
+ warnings: [],
40
+ api: {
41
+ async getBucket() { return { data: structuredClone(remote) }; },
42
+ async file(request, settings) {
43
+ const path = 'files/' + request.path;
44
+ if (!Object.hasOwn(bytes, path)) return { err: new Error('Missing remote bytes') };
45
+ fs.writeFileSync(settings.download, bytes[path]);
46
+ return { data: {} };
47
+ }
48
+ },
49
+ async callStandardAPI(method, request, settings = {}) {
50
+ calls.push({ method, request, settings });
51
+ if (method == 'writeBucketData') {
52
+ remote.data = structuredClone(request.data);
53
+ remote.meta.mod = 120;
54
+ return {};
55
+ }
56
+ if (method == 'uploadBucketFiles') {
57
+ settings.files.forEach(path => {
58
+ const name = app.cleanSyncBucketFilename(Path.basename(path));
59
+ let file = remote.files.find(entry => entry.filename == name);
60
+ if (!file) {
61
+ file = { filename: name, path: 'files/bucket/bucket_unit/new/' + name };
62
+ remote.files.push(file);
63
+ }
64
+ file.size = fs.statSync(path).size;
65
+ file.date = 130;
66
+ });
67
+ return { files: structuredClone(remote.files) };
68
+ }
69
+ if (method == 'deleteBucketFile') {
70
+ remote.files = remote.files.filter(file => file.path != request.path);
71
+ return {};
72
+ }
73
+ throw new Error('Unexpected API: ' + method);
74
+ },
75
+ logSyncError(message) { this.errors.push(message); },
76
+ logSyncWarning(message) { this.warnings.push(message); },
77
+ markdown(text) { return text; },
78
+ die(message) { throw new Error(message); }
79
+ };
80
+ return { dir, item, paths, remote, calls, app };
81
+ }
82
+
83
+ test('bucket sync rejects xyOps versions older than 1.1.2', t => {
84
+ const current = fixture(t);
85
+ assert.doesNotThrow(() => current.app.requireBucketSyncVersion());
86
+ current.app.xyopsVersion = '1.1.1';
87
+ assert.throws(() => current.app.requireBucketSyncVersion(), /v1\.1\.2 or later/);
88
+ });
89
+
90
+ test('bucket setup writes data and binary files with server mtimes beside the XYPDF source', async t => {
91
+ const file = { filename: 'Sample.JSON', path: 'files/bucket/bucket_unit/key/Sample.JSON', size: 8, date: 80 };
92
+ const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'not json' } });
93
+ fs.rmSync(env.paths.dir, { recursive: true, force: true });
94
+ await env.app.setupBucketContents([{ id: env.item.data.id, file: env.item.file }], { dry: false, force: false });
95
+ assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
96
+ assert.equal(fs.readFileSync(Path.join(env.paths.files, 'Sample.JSON'), 'utf8'), 'not json');
97
+ assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 90);
98
+ assert.equal(fs.statSync(Path.join(env.paths.files, 'Sample.JSON')).mtimeMs / 1000, 80);
99
+ });
100
+
101
+ test('upsync replaces data, normalizes manifest names, batches uploads, and deletes missing files last', async t => {
102
+ const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
103
+ const env = fixture(t, { remoteFiles: [removed] });
104
+ // Semicolon needs server filename normalization but remains a regular file on Windows.
105
+ const upload = Path.join(env.paths.files, 'New; File.txt');
106
+ fs.writeFileSync(upload, 'new');
107
+ fs.utimesSync(upload, 110, 110);
108
+ await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
109
+ assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData', 'uploadBucketFiles', 'deleteBucketFile']);
110
+ assert.equal(env.calls[0].request.replace, true);
111
+ assert.deepEqual(env.remote.data, { keep: true, removed: true });
112
+ assert.equal(env.remote.files[0].filename, 'New_ File.txt');
113
+ assert.equal(fs.statSync(upload).mtimeMs / 1000, 130);
114
+ assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 120);
115
+ });
116
+
117
+ test('downsync replaces changed files and preserves the server timestamp', async t => {
118
+ const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
119
+ const env = fixture(t, { remoteFiles: [file], remoteBytes: { [file.path]: 'remote' }, remoteDataTime: 150 });
120
+ const localFile = Path.join(env.paths.files, file.filename);
121
+ fs.writeFileSync(localFile, 'old');
122
+ fs.utimesSync(localFile, 100, 100);
123
+ await env.app.syncBucketContents([env.item], { down: ['buckets'] });
124
+ assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
125
+ assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
126
+ assert.deepEqual(JSON.parse(fs.readFileSync(env.paths.data, 'utf8')), env.remote.data);
127
+ assert.equal(fs.statSync(env.paths.data).mtimeMs / 1000, 150);
128
+ });
129
+
130
+ test('two-way sync chooses the newer data and file copies independently', async t => {
131
+ const old = { filename: 'remote.txt', path: 'files/bucket/bucket_unit/key/remote.txt', size: 6, date: 140 };
132
+ const env = fixture(t, {
133
+ localDataTime: 160,
134
+ remoteDataTime: 150,
135
+ remoteFiles: [old],
136
+ remoteBytes: { [old.path]: 'remote'
137
+ }
138
+ });
139
+ const localFile = Path.join(env.paths.files, old.filename);
140
+ fs.writeFileSync(localFile, 'stale');
141
+ fs.utimesSync(localFile, 100, 100);
142
+ await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
143
+ assert.deepEqual(env.calls.map(call => call.method), ['writeBucketData']);
144
+ assert.equal(fs.readFileSync(localFile, 'utf8'), 'remote');
145
+ assert.equal(fs.statSync(localFile).mtimeMs / 1000, 140);
146
+ assert.deepEqual(env.remote.data, env.app.readSyncBucketLocal(env.item).data);
147
+ });
148
+
149
+ test('two-way sync reports equal-time content conflicts instead of guessing', async t => {
150
+ const env = fixture(t, { remoteDataTime: 100 });
151
+ await env.app.syncBucketContents([env.item], { up: ['buckets'], down: ['buckets'] });
152
+ assert.equal(env.app.errors.length, 1);
153
+ assert.match(env.app.errors[0], /equal timestamps/);
154
+ assert.deepEqual(env.calls, []);
155
+ });
156
+
157
+ test('invalid local data blocks every bucket mutation including file deletion', async t => {
158
+ const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
159
+ const env = fixture(t, { remoteFiles: [removed] });
160
+ fs.writeFileSync(env.paths.data, '{invalid');
161
+ await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
162
+ assert.equal(env.app.errors.length, 1);
163
+ assert.deepEqual(env.calls, []);
164
+ });
165
+
166
+ test('bucket dry runs leave data, files, and server records unchanged', async t => {
167
+ const removed = { filename: 'old.txt', path: 'files/bucket/bucket_unit/old/old.txt', size: 3, date: 60 };
168
+ const env = fixture(t, { remoteFiles: [removed], dry: true });
169
+ const added = Path.join(env.paths.files, 'new.txt');
170
+ fs.writeFileSync(added, 'new');
171
+ const originalTime = fs.statSync(env.paths.data).mtimeMs;
172
+ await env.app.syncBucketContents([env.item], { up: ['buckets'], delete: ['buckets'] });
173
+ assert.deepEqual(env.calls, []);
174
+ assert.equal(env.remote.files.length, 1);
175
+ assert.deepEqual(env.remote.data, { keep: false, server: true });
176
+ assert.equal(fs.statSync(env.paths.data).mtimeMs, originalTime);
177
+ });
178
+
179
+ test('a failed download keeps the previous local file intact', async t => {
180
+ const file = { filename: 'same.txt', path: 'files/bucket/bucket_unit/key/same.txt', size: 6, date: 140 };
181
+ const env = fixture(t, { remoteFiles: [file] });
182
+ const localFile = Path.join(env.paths.files, file.filename);
183
+ fs.writeFileSync(localFile, 'before');
184
+ fs.utimesSync(localFile, 100, 100);
185
+ await env.app.syncBucketContents([env.item], { down: ['buckets'] });
186
+ assert.equal(env.app.errors.length, 1);
187
+ assert.equal(fs.readFileSync(localFile, 'utf8'), 'before');
188
+ });