alchemymvc 1.4.3 → 1.4.4

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.
@@ -722,64 +722,87 @@ Mongo.setMethod(function _remove(context) {
722
722
  */
723
723
  Mongo.setMethod(function _ensureIndex(model, index, callback) {
724
724
 
725
- this.collection(model.table, async function gotCollection(err, collection) {
725
+ // `collection()` returns a promise since 1.4 (its callback form was removed),
726
+ // so this must await it - the old callback form silently never fired, leaving
727
+ // indexes added via `addIndex` uncreated.
728
+ let pledge = Swift.waterfall(
729
+ this.collection(model.table),
730
+ async (collection) => {
726
731
 
727
- if (err != null) {
728
- return callback(err);
729
- }
732
+ let options = {
733
+ name : index.options.name,
734
+ unique : index.options.unique ? true : false,
735
+ sparse : index.options.sparse ? true : false,
736
+ };
730
737
 
731
- let options = {
732
- name : index.options.name,
733
- unique : index.options.unique ? true : false,
734
- sparse : index.options.sparse ? true : false,
735
- };
738
+ let index_specs;
736
739
 
737
- let index_specs;
740
+ // Hack in the text indexes
741
+ if (options.name == 'text') {
742
+ index_specs = {};
738
743
 
739
- // Hack in the text indexes
740
- if (options.name == 'text') {
741
- let key;
744
+ for (let key in index.fields) {
745
+ index_specs[key] = 'text';
746
+ }
747
+ } else {
748
+ index_specs = index.fields;
749
+ }
742
750
 
743
- index_specs = {};
751
+ // Reconcile any conflicting index to the schema's definition. A single
752
+ // drop can expose a second conflict (the wanted key may already exist
753
+ // under a different name), and concurrent boot-time ensures can drop an
754
+ // index from under us - so loop, dropping the conflicting index each
755
+ // round, until the create succeeds. Bounded, so a genuine problem still
756
+ // surfaces instead of spinning.
757
+ let attempts = 0;
744
758
 
745
- for (key in index.fields) {
746
- index_specs[key] = 'text';
747
- }
748
- } else {
749
- index_specs = index.fields;
750
- }
759
+ while (true) {
751
760
 
752
- try {
753
- await collection.createIndex(index_specs, options);
754
- } catch (err) {
761
+ try {
762
+ await collection.createIndex(index_specs, options);
763
+ return;
764
+ } catch (err) {
755
765
 
756
- // Check for IndexOptionsConflict
757
- if (err.code === 85) {
758
- let index_to_drop;
766
+ // 85 = IndexOptionsConflict (our key held under a different name),
767
+ // 86 = IndexKeySpecsConflict (our name held by a different key).
768
+ if ((err.code !== 85 && err.code !== 86) || attempts >= 5) {
769
+ throw err;
770
+ }
759
771
 
760
- if (err.message.includes('already exists with a different name:')) {
761
- index_to_drop = err.message.after('different name:').trim();
762
- }
772
+ attempts++;
763
773
 
764
- if (!index_to_drop) {
765
- index_to_drop = options.name;
766
- }
774
+ // Find the conflicting index(es) by inspecting the collection -
775
+ // the one holding our name, or one holding our exact key - rather
776
+ // than parsing the version-specific error message.
777
+ let specs_json = JSON.stringify(index_specs),
778
+ dropped = false;
767
779
 
768
- try {
780
+ for (let existing of await collection.indexes()) {
769
781
 
770
- // Index already exists, drop it
771
- await collection.dropIndex(index_to_drop);
782
+ if (existing.name === '_id_') {
783
+ continue;
784
+ }
772
785
 
773
- // Try again
774
- await collection.createIndex(index_specs, options);
775
- } catch (second_err) {
776
- return callback(second_err);
786
+ if (existing.name === options.name || JSON.stringify(existing.key) === specs_json) {
787
+ try {
788
+ await collection.dropIndex(existing.name);
789
+ dropped = true;
790
+ } catch (drop_err) {
791
+ // Already gone (a concurrent ensure dropped it).
792
+ }
793
+ }
794
+ }
795
+
796
+ // Nothing identifiable to drop: surface the error, don't spin.
797
+ if (!dropped) {
798
+ throw err;
799
+ }
777
800
  }
778
- } else {
779
- return callback(err);
780
801
  }
781
802
  }
803
+ );
782
804
 
783
- callback();
784
- });
805
+ pledge.done(callback);
806
+
807
+ return pledge;
785
808
  });
@@ -81,7 +81,7 @@ Remote.setMethod(async function doServerCommand(action, model, data, callback) {
81
81
  let fetch_options = {
82
82
  post : data,
83
83
  headers : {'content-type': 'application/json-dry'},
84
- max_timeout : this.options.max_timeout ?? 3500 // Configurable timeout, default 3.5s
84
+ max_timeout : this.options.max_timeout ?? 15000 // Configurable timeout, default 15s
85
85
  };
86
86
 
87
87
  alchemy.fetch(route_name, fetch_options, function gotResult(err, result) {
@@ -1478,12 +1478,45 @@ Document.setMethod(function hasFieldValue(name) {
1478
1478
  return Object.hasProperty(this.$main, name);
1479
1479
  });
1480
1480
 
1481
+ /**
1482
+ * Compare two field values, treating them as equal when their datasource
1483
+ * (stored) form matches.
1484
+ *
1485
+ * `hasChanged()` compares the live, normalized field value (a cast object such
1486
+ * as a TagTree, an ObjectId instance, ...) against the original record, which
1487
+ * holds the raw stored form. Without this, a normalized value always looked
1488
+ * different from its own unchanged stored counterpart, so such fields reported
1489
+ * a change on every load. If both sides serialize to the same datasource value,
1490
+ * saving either would write identical data, so the field has not changed.
1491
+ *
1492
+ * @author Jelle De Loecker <jelle@elevenways.be>
1493
+ * @since 1.4.4
1494
+ * @version 1.4.4
1495
+ *
1496
+ * @param {Mixed} a
1497
+ * @param {Mixed} b
1498
+ *
1499
+ * @return {boolean}
1500
+ */
1501
+ Document.setMethod(function alikeWhenStored(a, b) {
1502
+
1503
+ if (Object.alike(a, b)) {
1504
+ return true;
1505
+ }
1506
+
1507
+ try {
1508
+ return Object.alike(JSON.clone(a, 'toDatasource'), JSON.clone(b, 'toDatasource'));
1509
+ } catch (err) {
1510
+ return false;
1511
+ }
1512
+ });
1513
+
1481
1514
  /**
1482
1515
  * Has this document changed since it was created?
1483
1516
  *
1484
1517
  * @author Jelle De Loecker <jelle@elevenways.be>
1485
1518
  * @since 1.0.4
1486
- * @version 1.3.0
1519
+ * @version 1.4.4
1487
1520
  *
1488
1521
  * @param {string} name The optional field name
1489
1522
  *
@@ -1505,27 +1538,47 @@ Document.setMethod(function hasChanged(name) {
1505
1538
 
1506
1539
  let result;
1507
1540
 
1541
+ // Fields excluded from change-detection (computed fields, or `track_changes:
1542
+ // false`) never count as changed and are never compared. The `$model` getter
1543
+ // throws for an unresolvable model (a detached document, or a client document
1544
+ // whose model is not registered), so guard against that and compare every
1545
+ // field when no schema is available.
1546
+ let untracked;
1547
+ try {
1548
+ let schema = this.$model && this.$model.schema;
1549
+ untracked = schema ? schema.getUntrackedFieldNames() : null;
1550
+ } catch (err) {
1551
+ untracked = null;
1552
+ }
1553
+
1508
1554
  // If we only want to check a single field
1509
1555
  if (name) {
1510
1556
  let current_value,
1511
1557
  old_value;
1512
-
1513
- if (name.includes('.')) {
1514
- current_value = Object.path(this, name);
1515
- old_value = Object.path(this.$attributes.original_record, name);
1558
+
1559
+ if (untracked && untracked.has(name)) {
1560
+ result = false;
1516
1561
  } else {
1517
- current_value = this[name];
1518
- old_value = this.$attributes.original_record[name];
1519
- }
1520
1562
 
1521
- result = !Object.alike(old_value, current_value);
1563
+ if (name.includes('.')) {
1564
+ current_value = Object.path(this, name);
1565
+ old_value = Object.path(this.$attributes.original_record, name);
1566
+ } else {
1567
+ current_value = this[name];
1568
+ old_value = this.$attributes.original_record[name];
1569
+ }
1570
+
1571
+ result = !this.alikeWhenStored(old_value, current_value);
1572
+ }
1522
1573
  } else {
1523
1574
 
1524
1575
  let key;
1525
1576
 
1526
1577
  for (key in this.$attributes.original_record) {
1527
- if (!Object.alike(this.$attributes.original_record[key], this[key])) {
1528
- // @TODO: some special fields always end up being different
1578
+ if (untracked && untracked.has(key)) {
1579
+ continue;
1580
+ }
1581
+ if (!this.alikeWhenStored(this.$attributes.original_record[key], this[key])) {
1529
1582
  result = true;
1530
1583
  break;
1531
1584
  }
@@ -1533,7 +1586,10 @@ Document.setMethod(function hasChanged(name) {
1533
1586
 
1534
1587
  if (!result) {
1535
1588
  for (key in this.$main) {
1536
- if (!Object.alike(this.$main[key], this.$attributes.original_record[key])) {
1589
+ if (untracked && untracked.has(key)) {
1590
+ continue;
1591
+ }
1592
+ if (!this.alikeWhenStored(this.$main[key], this.$attributes.original_record[key])) {
1537
1593
  result = true;
1538
1594
  break;
1539
1595
  }
@@ -862,6 +862,10 @@ Schema.setMethod(function addField(name, type, options) {
862
862
 
863
863
  this.set(name, field);
864
864
 
865
+ // A new field can change which fields are excluded from change-detection, so
866
+ // drop the cached set (rebuilt lazily by getUntrackedFieldNames).
867
+ this.untracked_field_names = null;
868
+
865
869
  if (options.rules) {
866
870
  let rules = Array.cast(options.rules),
867
871
  i;
@@ -1074,6 +1078,50 @@ Schema.setMethod(function getFieldNames() {
1074
1078
  return Object.keys(this.dict);
1075
1079
  });
1076
1080
 
1081
+ /**
1082
+ * The set of field names excluded from change-detection, computed once and
1083
+ * cached. A field is excluded when `options.track_changes === false`, or - when
1084
+ * `track_changes` is unset - when it is a computed field (`is_computed`), since
1085
+ * its stored value is regenerated from its inputs and so is never independent
1086
+ * state worth (deep-)comparing in `Document#hasChanged()`. Lazily computed and
1087
+ * cached; `addField` clears the cache, so a later field addition is reflected.
1088
+ *
1089
+ * @author Jelle De Loecker <jelle@elevenways.be>
1090
+ * @since 1.4.4
1091
+ * @version 1.4.4
1092
+ *
1093
+ * @return {Set<string>}
1094
+ */
1095
+ Schema.setMethod(function getUntrackedFieldNames() {
1096
+
1097
+ if (this.untracked_field_names) {
1098
+ return this.untracked_field_names;
1099
+ }
1100
+
1101
+ let names = new Set();
1102
+
1103
+ for (let name of this.getFieldNames()) {
1104
+
1105
+ let field = this.getField(name);
1106
+
1107
+ if (!field || !field.options) {
1108
+ continue;
1109
+ }
1110
+
1111
+ let skip = (field.options.track_changes != null)
1112
+ ? (field.options.track_changes === false)
1113
+ : !!field.options.is_computed;
1114
+
1115
+ if (skip) {
1116
+ names.add(name);
1117
+ }
1118
+ }
1119
+
1120
+ this.untracked_field_names = names;
1121
+
1122
+ return names;
1123
+ });
1124
+
1077
1125
  /**
1078
1126
  * Add an index
1079
1127
  *
@@ -2439,6 +2439,36 @@ Alchemy.setMethod(function start(options, callback) {
2439
2439
  // Indicate the server is starting
2440
2440
  starting = true;
2441
2441
 
2442
+ // `--migrate`: apply pending migrations (app/migrations/) and exit, without
2443
+ // starting the HTTP server - so it can run alongside a live instance.
2444
+ if (this.argv.migrate) {
2445
+
2446
+ STAGES.afterStages('settings', async () => {
2447
+
2448
+ let code = 0;
2449
+
2450
+ try {
2451
+ await Classes.Alchemy.Migration.start();
2452
+ } catch (err) {
2453
+ log.error('Migration run failed:', err);
2454
+ code = 1;
2455
+ }
2456
+
2457
+ process.exit(code);
2458
+ });
2459
+
2460
+ STAGES.launch([
2461
+ 'load_app',
2462
+ 'datasource',
2463
+ 'tasks',
2464
+ 'settings',
2465
+ ]);
2466
+
2467
+ Blast.doLoaded();
2468
+
2469
+ return this.ready(callback);
2470
+ }
2471
+
2442
2472
  // Start the stages
2443
2473
  STAGES.launch([
2444
2474
  'load_app',
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "alchemymvc",
3
3
  "description": "MVC framework for Node.js",
4
- "version": "1.4.3",
4
+ "version": "1.4.4",
5
5
  "author": "Jelle De Loecker <jelle@elevenways.be>",
6
6
  "keywords": [
7
7
  "alchemy",