alchemymvc 1.4.5 → 1.4.6

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.
@@ -53,6 +53,9 @@ Publish.setMethod(function beforeFind(criteria) {
53
53
  date_condition = new Date();
54
54
  }
55
55
 
56
- criteria.where('publish_date').lt(date_condition).or().exists(false);
56
+ // isEmpty (not exists false): a record saved WITH an explicit null
57
+ // publish_date has the field present, so `$exists: false` would
58
+ // hide it from every publish-filtered find
59
+ criteria.where('publish_date').lt(date_condition).or().isEmpty();
57
60
  }
58
61
  });
@@ -225,6 +225,16 @@ Revision.setMethod(function beforeSave(record, options, creating) {
225
225
  Model.get(this.model.model_name).findById(record.$pk, function gotRecord(err, result) {
226
226
 
227
227
  if (err || !result) {
228
+
229
+ // Clear any PREVIOUS document's entry: the weakmap is keyed
230
+ // on the (possibly shared) options object, and a stale value
231
+ // would make afterSave diff against another record's data
232
+ revision_before.delete(options);
233
+
234
+ if (err) {
235
+ alchemy.registerError(err, {context: 'Revision behaviour could not load the original ' + that.model.model_name + ' record'});
236
+ }
237
+
228
238
  return pledge.resolve();
229
239
  }
230
240
 
@@ -269,7 +279,11 @@ Revision.setMethod(function afterSave(record, options, created) {
269
279
  if (created) {
270
280
  earlier_data = {};
271
281
  } else {
282
+ // Consume the entry: the weakmap is keyed on the (possibly
283
+ // shared) options object, so it must not leak into the next
284
+ // document's save
272
285
  earlier_data = revision_before.get(options);
286
+ revision_before.delete(options);
273
287
  }
274
288
 
275
289
  // Do we have earlier data to compare to?
@@ -285,6 +299,10 @@ Revision.setMethod(function afterSave(record, options, created) {
285
299
  // Find the complete saved item
286
300
  Model.get(this.model.model_name).findByPk(doc.$pk, async function gotRecord(err, result) {
287
301
 
302
+ if (err) {
303
+ return pledge.reject(err);
304
+ }
305
+
288
306
  try {
289
307
  await createRevision(result);
290
308
  pledge.resolve();
@@ -445,8 +445,12 @@ Sluggable.setMethod(function generateSlug(title, key, record, callback) {
445
445
  return next(err);
446
446
  }
447
447
 
448
- // Don't use the target record for duplicate checks
448
+ // Don't use the target record for duplicate checks.
449
+ // `item` must still be cleared: leaving the PREVIOUS
450
+ // iteration's value would count the record's own slug as
451
+ // taken and skip past it
449
452
  if (found_item && for_record_id && String(for_record_id) == String(found_item._id)) {
453
+ item = null;
450
454
  return next();
451
455
  }
452
456
 
@@ -329,6 +329,15 @@ Mongo.setMethod(function collection(name) {
329
329
 
330
330
  if (!this.collections[name]) {
331
331
  this.collections[name] = result;
332
+
333
+ // A failed connect must not poison the cache: connect() has its
334
+ // own retry logic, but a cached rejected pledge would keep this
335
+ // table broken for the process lifetime
336
+ Swift.done(result, err => {
337
+ if (err && this.collections[name] === result) {
338
+ delete this.collections[name];
339
+ }
340
+ });
332
341
  }
333
342
 
334
343
  return result;
@@ -507,9 +516,12 @@ Mongo.setMethod(function _create(context) {
507
516
  model.nukeCache();
508
517
 
509
518
  if (err != null) {
510
- // In MongoDB driver 6.x, write errors are thrown as MongoServerError
511
- // Check if this is a write error that should be converted to validation violations
512
- if (err.code || err.writeErrors) {
519
+ // In MongoDB driver 6.x, write errors are thrown as MongoServerError.
520
+ // Only actual WRITE errors (duplicate keys, document validation)
521
+ // become validation violations: matching on any `err.code` would
522
+ // also reclassify infrastructure errors (auth failure, stepdown,
523
+ // shutdown) as user-input problems
524
+ if (err.code == 11000 || err.code == 11001 || (err.writeErrors && err.writeErrors.length)) {
513
525
  let violations = new Classes.Alchemy.Error.Validation.Violations();
514
526
 
515
527
  // Handle bulk write errors (array of errors)
@@ -930,11 +930,9 @@ function convertCriteriaGroupToConditions(criteria, group, config, context) {
930
930
  }
931
931
  } else {
932
932
  let item,
933
- not,
934
- obj = {};
933
+ not;
935
934
 
936
- let field_entry = {},
937
- name = entry.target_path,
935
+ let name = entry.target_path,
938
936
  queries_property = name.indexOf('.') > -1;
939
937
 
940
938
  // Do we need to look into an object itself?
@@ -954,6 +952,16 @@ function convertCriteriaGroupToConditions(criteria, group, config, context) {
954
952
  for (let i = 0; i < entry.items.length; i++) {
955
953
  item = entry.items[i];
956
954
 
955
+ // Each item gets its OWN condition objects. These used to be
956
+ // shared across the loop: operators that REASSIGN `obj`
957
+ // (equals/in/contains/isNull) silently discarded every
958
+ // condition a previous item had accumulated, and pushing the
959
+ // same `field_entry` reference once per item duplicated the
960
+ // last condition instead of combining them. Separate entries
961
+ // combine correctly through the surrounding $and.
962
+ let obj = {},
963
+ field_entry = {};
964
+
957
965
  if (context && item.value && typeof item.value == 'object') {
958
966
  item = {
959
967
  ...item,
@@ -28,8 +28,7 @@ Fallback.setProperty('has_offline_cache', true);
28
28
  */
29
29
  Fallback.decorateMethod(Blast.Decorators.memoize({ignore_arguments: true}), function connect() {
30
30
 
31
- var that = this,
32
- tasks = [];
31
+ var that = this;
33
32
 
34
33
  // The "upper" or "local" datasource
35
34
  this.upper = Datasource.get(this.options.upper);
@@ -51,8 +50,6 @@ Fallback.decorateMethod(Blast.Decorators.memoize({ignore_arguments: true}), func
51
50
  next();
52
51
  });
53
52
  }, null);
54
-
55
- return Function.parallel(tasks);
56
53
  });
57
54
 
58
55
  /**
@@ -362,9 +359,17 @@ Fallback.setMethod(function createOrUpdate(method, context) {
362
359
  // Lower data can already be a Document instance!
363
360
  // @TODO: add a check for that?
364
361
  //lower.toApp(model, null, options, saved_data.lower.result, next);
362
+ } else if (saved_data.upper.result) {
363
+ // storeInUpperDatasource already returns app-format data (its
364
+ // last step runs upper.toApp), so converting it AGAIN would
365
+ // corrupt any field whose app and datasource formats differ.
366
+ // This is the normal path for offline saves.
367
+ next(null, saved_data.upper.result);
365
368
  } else {
366
- let toapp_context = context.withDatasourceEntry(saved_data.upper.result);
367
- Swift.done(upper.toApp(toapp_context), next);
369
+ // Both saves failed: surface the actual save error instead of
370
+ // letting a toApp call on `undefined` throw a misleading
371
+ // "Unable to convert data: no data given"
372
+ next(saved_data.upper.err || saved_data.lower.err || new Error('Both datasource saves failed'));
368
373
  }
369
374
 
370
375
  }, function done(err, result) {
@@ -339,9 +339,14 @@ SchemaField.setMethod(function getSubSchemaFromModel(context) {
339
339
  return result;
340
340
  }
341
341
 
342
- let found_schema = this.resolveSchemaPath(model.schema, result, path, path);
343
-
344
- return found_schema;
342
+ // The remote request returned the raw record instead of a schema:
343
+ // resolve the wanted property path within that record
344
+ let found_context = context.createChild();
345
+ found_context.setSchema(model.schema);
346
+ found_context.setHolder(result);
347
+ found_context.setSubSchemaPath(context.getValuePropertyName());
348
+
349
+ return this.resolveSchemaPath(found_context);
345
350
  });
346
351
 
347
352
  return pledge;
@@ -371,7 +376,11 @@ SchemaField.setMethod(function _toDatasource(context, value) {
371
376
  }
372
377
 
373
378
  // @TODO: What about the holder?
374
- let tasks = value.map(entry => this._toDatasourceFromValue(context.withWorkingValue(entry), entry));
379
+ // Nullish entries are junk (older versions stored `[null]` for a null
380
+ // array value) - drop them instead of converting them
381
+ let tasks = value
382
+ .filter(entry => entry != null)
383
+ .map(entry => this._toDatasourceFromValue(context.withWorkingValue(entry), entry));
375
384
 
376
385
  return Pledge.Swift.parallel(tasks);
377
386
  });
@@ -462,8 +471,14 @@ SchemaField.setMethod(function _toDatasourceFromValueWithSubSchema(context, valu
462
471
 
463
472
  Object.assign(record, result[0]);
464
473
 
465
- // Try getting the schema again
466
- sub_schema = that.getSubSchema(record);
474
+ // Try getting the schema again, with the enriched
475
+ // record as the holder (getSubSchema expects an
476
+ // operational context, not a record)
477
+ let schema_context = new Classes.Alchemy.OperationalContext.Schema(context);
478
+ schema_context.setSchema(that.schema);
479
+ schema_context.setHolder(record);
480
+
481
+ sub_schema = that.getSubSchema(schema_context);
467
482
 
468
483
  Pledge.Swift.done(sub_schema, (err, sub_schema) => {
469
484
 
@@ -507,7 +522,12 @@ SchemaField.setMethod(function _toApp(context, value) {
507
522
 
508
523
  value = Array.cast(value);
509
524
 
510
- let tasks = value.map(entry => this._toAppFromValue(context.withWorkingValue(entry), entry));
525
+ // Nullish entries are junk (older versions stored `[null]` for a null
526
+ // array value); converting one used to reject the ENTIRE find when
527
+ // the sub-schema has associations
528
+ let tasks = value
529
+ .filter(entry => entry != null)
530
+ .map(entry => this._toAppFromValue(context.withWorkingValue(entry), entry));
511
531
 
512
532
  let result = Pledge.Swift.waterfall(
513
533
  Pledge.Swift.parallel(tasks),
@@ -604,7 +624,7 @@ SchemaField.setMethod(function _toAppFromValueWithSubSchema(context, value) {
604
624
  });
605
625
 
606
626
  } else {
607
- console.warn('Failed to find sub schema for', this.name, 'in', record);
627
+ console.warn('Failed to find sub schema for', this.name, 'in', context.getHolder());
608
628
  }
609
629
 
610
630
  return Swift.waterfall(
@@ -332,7 +332,11 @@ Document.setStatic(function getDocumentClass(model_param) {
332
332
  DocClass.Model = Blast.Classes.Hawkejs.Model.getClass(model_name);
333
333
  }
334
334
 
335
- class_cache.set(document_name, DocClass);
335
+ // Cache under the same key the lookup uses (the full model_name):
336
+ // storing under the short class name made namespaced models never hit
337
+ // the cache AND let a later root model with the same base name (e.g.
338
+ // `Task` vs `System.Task`) receive the wrong document class
339
+ class_cache.set(model_name, DocClass);
336
340
 
337
341
  return DocClass;
338
342
  });
@@ -769,7 +773,7 @@ Document.setMethod(function setDataRecord(record, options) {
769
773
  } else if (field.is_private) {
770
774
  delete_field = true;
771
775
 
772
- if (options.keep_private_fields) {
776
+ if (options?.keep_private_fields) {
773
777
  delete_field = false;
774
778
  }
775
779
  } else {
@@ -1173,10 +1177,16 @@ Document.setMethod(function save(data, options, callback) {
1173
1177
  // Fields edited while the save was in flight would be silently
1174
1178
  // reverted by adopting the response wholesale (the response only
1175
1179
  // reflects the record as it was sent), so detect and keep them.
1180
+ // A field only counts as an in-flight edit when it differs from
1181
+ // the snapshot AND from the save result: save hooks (beforeSave,
1182
+ // beforeNormalize) mutate the live $main after the snapshot was
1183
+ // taken, and their values DO match the response - treating those
1184
+ // as edits would leave every saved document marked dirty.
1176
1185
  let kept_fields;
1177
1186
 
1178
1187
  if (in_flight_snapshot && that.$main) {
1179
- let key;
1188
+ let saved_main = save_result.$main || {},
1189
+ key;
1180
1190
 
1181
1191
  for (key in that.$main) {
1182
1192
 
@@ -1184,7 +1194,8 @@ Document.setMethod(function save(data, options, callback) {
1184
1194
  continue;
1185
1195
  }
1186
1196
 
1187
- if (!that.alikeWhenStored(that.$main[key], in_flight_snapshot[key])) {
1197
+ if (!that.alikeWhenStored(that.$main[key], in_flight_snapshot[key])
1198
+ && !that.alikeWhenStored(that.$main[key], saved_main[key])) {
1188
1199
 
1189
1200
  if (!kept_fields) {
1190
1201
  kept_fields = {};
@@ -1311,13 +1322,21 @@ Document.setMethod(function checkAndInformDatasource(options, callback) {
1311
1322
  if (DocClass.Model && DocClass.Model.hasServerAction('readDatasource')) {
1312
1323
  // Only store in cache when we can query the server
1313
1324
  pledge.resolve(that.informDatasource());
1325
+ } else {
1326
+ pledge.resolve();
1314
1327
  }
1315
1328
  });
1316
1329
  } else if (DocClass.Model.hasServerAction('readDatasource')) {
1317
1330
  Blast.requestIdleCallback(function tryStore(task_data) {
1318
1331
  pledge.resolve(that.informDatasource());
1319
1332
  });
1333
+ } else {
1334
+ pledge.resolve();
1320
1335
  }
1336
+ } else {
1337
+ // Nothing to do: the pledge still has to settle or callers
1338
+ // awaiting it (or passing a callback) hang forever
1339
+ pledge.resolve();
1321
1340
  }
1322
1341
 
1323
1342
  return pledge;
@@ -1617,13 +1617,19 @@ Model.setMethod(function save(data, _options, _callback) {
1617
1617
  return next();
1618
1618
  }
1619
1619
 
1620
- // Let the user now if it's a new record
1620
+ // Let the user know if it's a new record.
1621
+ // This has to be a per-document copy: writing to the shared
1622
+ // options object would make the FIRST document's newness decide
1623
+ // `create` for every following document (and leak a sticky
1624
+ // `create: true` into the caller's reused options object)
1625
+ let doc_options = options;
1626
+
1621
1627
  if (options.create == null) {
1622
- options.create = document.is_new_record;
1628
+ doc_options = Object.assign({}, options, {create: document.is_new_record});
1623
1629
  }
1624
1630
 
1625
1631
  // Save the data
1626
- that.saveRecord(document, options, function saved(err, result) {
1632
+ that.saveRecord(document, doc_options, function saved(err, result) {
1627
1633
 
1628
1634
  pledge.reportProgressPart(1);
1629
1635
 
@@ -1920,28 +1926,45 @@ Model.setMethod(function saveRecord(document, options, callback) {
1920
1926
  }
1921
1927
  }, function doAssociated(next) {
1922
1928
 
1923
- var tasks = [],
1924
- assoc,
1925
- entry,
1926
- key;
1929
+ var tasks = [];
1927
1930
 
1928
1931
  Object.each(document.$record, function eachEntry(entry, key) {
1929
1932
 
1933
+ // Skip empty entries
1934
+ if (!entry) {
1935
+ return;
1936
+ }
1937
+
1930
1938
  // Skip our own record
1931
1939
  if (key == that.name) {
1932
1940
  return;
1933
1941
  }
1934
1942
 
1935
- // Get the association configuration
1936
- assoc = that.schema.associations[key];
1943
+ // Get the association configuration.
1944
+ // This has to be a per-entry constant: the doSave closures run
1945
+ // later, so a shared variable would make every entry save
1946
+ // through the LAST association's model (creating empty records
1947
+ // in the wrong collection)
1948
+ const assoc = that.schema.associations[key];
1937
1949
 
1938
1950
  // If the association doesn't exist, do nothing
1939
1951
  if (!assoc) {
1940
1952
  return;
1941
1953
  }
1942
1954
 
1943
- // Add the saved _id
1944
- entry[assoc.options.foreignKey] = saved_record[assoc.options.localKey];
1955
+ // Populated documents that were not modified must not be
1956
+ // re-saved as a side effect of saving their parent
1957
+ if (typeof entry.hasChanged == 'function' && !entry.hasChanged()) {
1958
+ return;
1959
+ }
1960
+
1961
+ // Add the saved _id (an `_id: undefined` entry would make
1962
+ // behaviours that look up the existing record choke)
1963
+ let foreign_key_value = saved_record[assoc.options.localKey];
1964
+
1965
+ if (foreign_key_value != null) {
1966
+ entry[assoc.options.foreignKey] = foreign_key_value;
1967
+ }
1945
1968
 
1946
1969
  // Add the task
1947
1970
  tasks.push(function doSave(next) {
@@ -276,6 +276,16 @@ Datasource.setMethod(function toDatasource(context) {
276
276
  continue;
277
277
  }
278
278
 
279
+ // Meta fields (like AssociationAlias) only exist at runtime:
280
+ // persisting them would store entire populated Document wrappers
281
+ // ($options, $record, model instance and all) in the datasource,
282
+ // and a self-referencing object graph in there makes the BSON
283
+ // serializer throw "Cannot convert circular structure to BSON".
284
+ // The toApp path already skips them - mirror that here.
285
+ if (field_context.getField()?.is_meta_field) {
286
+ continue;
287
+ }
288
+
279
289
  tasks[field_name] = this.valueToDatasource(field_context);
280
290
  }
281
291
 
@@ -331,7 +341,10 @@ Datasource.setMethod(function toApp(context) {
331
341
  continue;
332
342
  }
333
343
 
334
- let model = this.getModel(info.modelName, false);
344
+ // Use the non-throwing lookup: `this.getModel()` throws
345
+ // for unknown models, which would reject the whole read
346
+ // instead of skipping the unknown alias
347
+ let model = Model.get(info.modelName, false);
335
348
 
336
349
  if (!model) {
337
350
  continue;
@@ -356,9 +369,14 @@ Datasource.setMethod(function toApp(context) {
356
369
 
357
370
  // Helper to get cached or process entity
358
371
  let processEntity = (entry) => {
359
- // Only cache if entry has an _id
372
+ // Only cache if entry has an _id.
373
+ // The key includes the entry's field names: the same
374
+ // (model, id) can appear with different projections in
375
+ // one read (per-alias $project in the lookup pipeline),
376
+ // and serving a truncated cached version to a fully
377
+ // selected alias would silently drop fields
360
378
  if (entry && entry._id) {
361
- let cache_key = data_schema.name + ':' + entry._id;
379
+ let cache_key = data_schema.name + ':' + entry._id + ':' + Object.keys(entry).sort().join(',');
362
380
  let cached = entity_cache.get(cache_key);
363
381
 
364
382
  if (cached) {
@@ -394,7 +412,7 @@ Datasource.setMethod(function toApp(context) {
394
412
  field = entry.value,
395
413
  value = data[field_name];
396
414
 
397
- if (field.is_meta_field || field == null) {
415
+ if (field == null || field.is_meta_field) {
398
416
  continue;
399
417
  }
400
418
 
@@ -550,11 +568,17 @@ Datasource.setMethod(function read(context) {
550
568
  });
551
569
 
552
570
  // If a cache_pledge was created, make sure it gets rejected on error
553
- // so that subsequent queries with the same hash don't hang forever
571
+ // so that subsequent queries with the same hash don't hang forever.
572
+ // Also evict it: a cached rejection would replay the error for the
573
+ // full cache TTL, long after the datasource recovered
554
574
  if (cache_pledge) {
555
575
  Pledge.Swift.done(read_pledge, (err) => {
556
576
  if (err) {
557
577
  cache_pledge.reject(err);
578
+
579
+ if (model.cache && model.cache.get(hash) === cache_pledge) {
580
+ model.cache.remove(hash);
581
+ }
558
582
  }
559
583
  });
560
584
  }
@@ -461,7 +461,6 @@ Field.setStatic(function unDry(value) {
461
461
  Field.setMethod(function toDry() {
462
462
  return {
463
463
  value: {
464
- bla: 1,
465
464
  schema : this.schema,
466
465
  name : this.name,
467
466
  options : this.getOptionsForDrying(),
@@ -688,7 +687,7 @@ Field.setMethod(function getRecordValue(record, path_to_value_hint) {
688
687
  */
689
688
  Field.setMethod(function castContainedValues(value, to_datasource) {
690
689
 
691
- if (this.is_translatable && value && this.is_translatable) {
690
+ if (this.is_translatable && value) {
692
691
  let prefix;
693
692
 
694
693
  for (prefix in value) {
@@ -907,7 +906,12 @@ Field.setMethod(function regularToDatasource(context, value) {
907
906
  return value;
908
907
  }
909
908
 
910
- let tasks = value.map(entry => this._toDatasource(context.withWorkingValue(entry), entry));
909
+ // The `_toDatasource` contract says entries are never nullish:
910
+ // drop junk entries (like the `[null]` older versions stored for a
911
+ // null array value) instead of feeding them to the field logic
912
+ let tasks = value
913
+ .filter(entry => entry != null)
914
+ .map(entry => this._toDatasource(context.withWorkingValue(entry), entry));
911
915
 
912
916
  return Pledge.Swift.parallel(tasks);
913
917
  });
@@ -926,6 +930,14 @@ Field.setMethod(function regularToDatasource(context, value) {
926
930
  */
927
931
  Field.setMethod(function translatableToDatasource(context, value) {
928
932
 
933
+ // A primitive value would be iterated key-by-key (storing
934
+ // {"0":"f","1":"o",...} for 'foo') - wrap it in the default prefix,
935
+ // mirroring what fromTranslatableToApp does on the way out
936
+ if (value != null && typeof value != 'object') {
937
+ let prefix = Blast.Globals.Prefix?.default?.locale || '__';
938
+ value = {[prefix]: value};
939
+ }
940
+
929
941
  let tasks = {};
930
942
 
931
943
  for (let key in value) {
@@ -987,7 +999,13 @@ Field.setMethod(function fromRegularToApp(context, value) {
987
999
  context.setWorkingValue(value);
988
1000
  }
989
1001
 
990
- let tasks = value.map(entry => this._toApp(context.withWorkingValue(entry), entry));
1002
+ // The `_toApp` contract says entries are never nullish: skip junk
1003
+ // entries (like a stored `[null]`) instead of feeding them to the
1004
+ // field logic - for sub-schemas with associations a null entry even
1005
+ // rejected the ENTIRE find
1006
+ let tasks = value
1007
+ .filter(entry => entry != null)
1008
+ .map(entry => this._toApp(context.withWorkingValue(entry), entry));
991
1009
 
992
1010
  return Pledge.Swift.parallel(tasks);
993
1011
  });
@@ -1091,6 +1109,13 @@ Field.setMethod(function getValue(value) {
1091
1109
 
1092
1110
  var wrapped;
1093
1111
 
1112
+ // A nullish value on an arrayable field must become an empty array:
1113
+ // wrapping it like a regular value would store `[null]`, a junk entry
1114
+ // that every consumer of the array then has to guard against
1115
+ if (value == null && this.is_array && !this.is_translatable) {
1116
+ return [];
1117
+ }
1118
+
1094
1119
  // Make sure the value is an array
1095
1120
  if (Array.isArray(value)) {
1096
1121
  // If this field is not an arrayable field, wrap it again
@@ -99,13 +99,37 @@ Migration.setStatic(async function start() {
99
99
  }
100
100
  });
101
101
 
102
- record.ended = new Date();
103
- await record.save();
104
-
105
102
  } catch (err) {
106
103
  console.log(' »» Migration error:', err);
107
- record.error = err.message + '\n' + err.stack;
104
+
105
+ if (err instanceof Error) {
106
+ record.error = err.message + '\n' + err.stack;
107
+ } else {
108
+ record.error = String(err);
109
+ }
110
+
111
+ try {
112
+ await record.save();
113
+ } catch (save_err) {
114
+ // Without an `error` field the record reads as "has not
115
+ // yet finished", which blocks all migrations with no
116
+ // recovery hint - at least say what happened
117
+ console.log(' »» Could not persist the migration failure:', save_err);
118
+ }
119
+
120
+ console.log('Migrations stopped');
121
+ return;
122
+ }
123
+
124
+ // The success stamp lives OUTSIDE the try: a transient failure
125
+ // while saving `ended` must not mark a COMPLETED migration as
126
+ // failed (which would block all future migrations)
127
+ record.ended = new Date();
128
+
129
+ try {
108
130
  await record.save();
131
+ } catch (save_err) {
132
+ console.log(' »» Migration "' + name + '" finished, but its record could not be updated:', save_err);
109
133
  console.log('Migrations stopped');
110
134
  return;
111
135
  }
@@ -133,10 +157,28 @@ Migration.setMethod(function processRecords(model_name, fnc) {
133
157
  return_raw_data: true,
134
158
  };
135
159
 
136
- return model.eachRecord(options, async (record, index, next) => {
137
- await fnc(model, record[model_name], index);
160
+ let pledge = new Pledge();
161
+
162
+ model.eachRecord(options, async (record, index, next) => {
163
+
164
+ // A throwing handler must reach `next(err)`: without it the
165
+ // iteration waits forever and the migration hangs with its
166
+ // record stuck in the "has not yet finished" state
167
+ try {
168
+ await fnc(model, record[model_name], index);
169
+ } catch (err) {
170
+ return next(err);
171
+ }
172
+
138
173
  next();
139
174
  }, (err) => {
140
- // Done!
175
+
176
+ if (err) {
177
+ pledge.reject(err);
178
+ } else {
179
+ pledge.resolve();
180
+ }
141
181
  });
182
+
183
+ return pledge;
142
184
  });
@@ -976,8 +976,7 @@ Model.setMethod(function saveRecord(document, options, callback) {
976
976
  }
977
977
  }, function doAssociated(next) {
978
978
 
979
- let tasks = [],
980
- assoc;
979
+ let tasks = [];
981
980
 
982
981
  Object.each(document.$record, function eachEntry(entry, key) {
983
982
 
@@ -991,16 +990,31 @@ Model.setMethod(function saveRecord(document, options, callback) {
991
990
  return;
992
991
  }
993
992
 
994
- // Get the association configuration
995
- assoc = that.schema.associations[key];
993
+ // Get the association configuration.
994
+ // This has to be a per-entry constant: the doSave closures run
995
+ // later, so a shared variable would make every entry save
996
+ // through the LAST association's model (creating empty records
997
+ // in the wrong collection)
998
+ const assoc = that.schema.associations[key];
996
999
 
997
1000
  // If the association doesn't exist, do nothing
998
1001
  if (!assoc) {
999
1002
  return;
1000
1003
  }
1001
1004
 
1002
- // Add the saved _id
1003
- entry[assoc.options.foreignKey] = saved_record[assoc.options.localKey];
1005
+ // Populated documents that were not modified must not be
1006
+ // re-saved as a side effect of saving their parent
1007
+ if (typeof entry.hasChanged == 'function' && !entry.hasChanged()) {
1008
+ return;
1009
+ }
1010
+
1011
+ // Add the saved _id (an `_id: undefined` entry would make
1012
+ // behaviours that look up the existing record choke)
1013
+ let foreign_key_value = saved_record[assoc.options.localKey];
1014
+
1015
+ if (foreign_key_value != null) {
1016
+ entry[assoc.options.foreignKey] = foreign_key_value;
1017
+ }
1004
1018
 
1005
1019
  // Add the task
1006
1020
  tasks.push(function doSave(next) {
@@ -1063,33 +1077,37 @@ Model.setMethod(function auditRecord(document, options, callback) {
1063
1077
  }
1064
1078
 
1065
1079
  tasks[indexName] = function auditIndex(next) {
1066
- var query = {},
1067
- fieldName;
1068
1080
 
1069
- for (fieldName in index.fields) {
1081
+ // This used to call `datasource.read()` with its pre-1.4.0
1082
+ // signature, which threw since the OperationalContext
1083
+ // refactor - go through a regular criteria find instead
1084
+ let criteria = that.find();
1085
+
1086
+ for (let fieldName in index.fields) {
1070
1087
  if (document[fieldName] != null) {
1071
- query[fieldName] = document[fieldName];
1088
+ let value = document[fieldName];
1072
1089
 
1073
1090
  // @todo: should run through the FieldType instance
1074
- if (String(query[fieldName]).isObjectId()) {
1075
- query[fieldName] = alchemy.castObjectId(query[fieldName]);
1091
+ if (String(value).isObjectId()) {
1092
+ value = alchemy.castObjectId(value);
1076
1093
  }
1094
+
1095
+ criteria.where(fieldName).equals(value);
1077
1096
  }
1078
1097
  }
1079
1098
 
1080
- that.datasource.read(that, query, {}, function gotRecordInfo(err, records) {
1099
+ that.find('first', criteria, function gotRecordInfo(err, record) {
1081
1100
 
1082
1101
  if (err != null) {
1083
1102
  return next(err);
1084
1103
  }
1085
1104
 
1086
- if (records[0] != null) {
1087
- results[indexName] = records[0];
1105
+ if (record != null) {
1106
+ results[indexName] = record;
1088
1107
  }
1089
1108
 
1090
1109
  next();
1091
1110
  });
1092
-
1093
1111
  };
1094
1112
  });
1095
1113
 
@@ -1455,6 +1473,11 @@ Model.setMethod(function eachRecord(options, task, callback) {
1455
1473
 
1456
1474
  var tasks = [];
1457
1475
 
1476
+ if (err) {
1477
+ pledge.reject(err);
1478
+ return callback(err);
1479
+ }
1480
+
1458
1481
  if (!result.length) {
1459
1482
  pledge.reportProgress(100);
1460
1483
  pledge.resolve();
package/lib/class/task.js CHANGED
@@ -339,6 +339,12 @@ Task.setMethod(async function start(payload) {
339
339
  this[RUNNING_PLEDGE] = new Pledge();
340
340
  this[INITIALIZED_PLEDGE] = new Pledge();
341
341
 
342
+ // These pledges may have no consumer (start()'s own promise is what
343
+ // most callers await), so a rejection must not log a spurious
344
+ // "Uncaught Pledge error"
345
+ this[RUNNING_PLEDGE].warn_uncaught_errors = false;
346
+ this[INITIALIZED_PLEDGE].warn_uncaught_errors = false;
347
+
342
348
  let document = this[HISTORY_DOC];
343
349
 
344
350
  if (!document) {
@@ -357,7 +363,25 @@ Task.setMethod(async function start(payload) {
357
363
  // Register this command as running
358
364
  running.push(this);
359
365
 
360
- await document.save();
366
+ try {
367
+ await document.save();
368
+ } catch (err) {
369
+ // Without settling these pledges, a failed init (e.g. a transient
370
+ // DB error on the history save) would wedge every
371
+ // `waitUntilInitialized()` caller - the schedule runner would
372
+ // consider the task running forever and never fire it again
373
+ this[STATUS] = STOPPED;
374
+ this[INITIALIZED_PLEDGE].reject(err);
375
+ this[RUNNING_PLEDGE].reject(err);
376
+
377
+ let index = running.indexOf(this);
378
+
379
+ if (index > -1) {
380
+ running.splice(index, 1);
381
+ }
382
+
383
+ throw err;
384
+ }
361
385
 
362
386
  // Set the id
363
387
  this.id = String(document.$pk);
@@ -402,6 +426,19 @@ Task.setMethod(async function start(payload) {
402
426
  this.report('done');
403
427
  }
404
428
  } finally {
429
+ // Terminal bookkeeping for EVERY exit path (done, stopped, failed):
430
+ // mark the task as stopped (nothing did before, so `has_stopped`
431
+ // stayed false forever), deregister it from the shared `running`
432
+ // array (which used to grow unboundedly), and settle the running
433
+ // pledge (which used to stay pending on stop/error).
434
+ this[STATUS] = STOPPED;
435
+
436
+ let index = running.indexOf(this);
437
+
438
+ if (index > -1) {
439
+ running.splice(index, 1);
440
+ }
441
+
405
442
  // Always close out the history document so it doesn't get
406
443
  // stuck as a zombie "running forever" row.
407
444
  document.ended_at = new Date();
@@ -414,9 +451,13 @@ Task.setMethod(async function start(payload) {
414
451
  // executor error on its way out of the catch above.
415
452
  alchemy.registerError(save_err);
416
453
  }
417
- }
418
454
 
419
- this[RUNNING_PLEDGE].resolve(result);
455
+ if (this.error) {
456
+ this[RUNNING_PLEDGE].reject(this.error);
457
+ } else {
458
+ this[RUNNING_PLEDGE].resolve(result);
459
+ }
460
+ }
420
461
 
421
462
  return result;
422
463
  });
@@ -737,11 +778,29 @@ Task.execute = function execute(name, options, callback) {
737
778
  }
738
779
 
739
780
  if (!constructor) {
740
- return callback(new Error('Could not find "' + name + '" task'));
781
+ let err = new Error('Could not find "' + name + '" task');
782
+
783
+ if (callback) {
784
+ callback(err);
785
+ return;
786
+ }
787
+
788
+ throw err;
741
789
  }
742
790
 
743
791
  task = new constructor();
744
- task.start(options, callback);
792
+
793
+ // `start()` only takes a payload - the callback has to be attached
794
+ // to its returned promise (it used to be passed as a second argument,
795
+ // where it was silently ignored)
796
+ let promise = task.start(options);
797
+
798
+ if (callback) {
799
+ Pledge.done(promise, callback);
800
+ } else {
801
+ // Prevent an unhandled rejection when nobody is listening
802
+ promise.catch(err => alchemy.registerError(err, {context: 'Task.execute(' + name + ')'}));
803
+ }
745
804
 
746
805
  return task;
747
806
  };
package/lib/core/stage.js CHANGED
@@ -195,6 +195,13 @@ Stage.setMethod(function _addTask(type, fnc) {
195
195
  }
196
196
 
197
197
  task_map.set(fnc, task_result);
198
+
199
+ // Settle the replacement pledge once the previous state AND this
200
+ // task are done: it used to stay pending forever, which made
201
+ // `_launch` hang on `await task_map[STATUS_PLEDGE]` whenever a
202
+ // task was added to an already-started type (and a rejecting
203
+ // task_result was an unhandled rejection)
204
+ new_pledge.resolve(Pledge.Swift.waterfall(pledge, () => task_result));
198
205
  } else {
199
206
  task_map.set(fnc, null);
200
207
  }
@@ -563,16 +570,24 @@ Stage.setMethod(async function _launch(child_stages) {
563
570
  this[STATUS] = PRE_STATUS;
564
571
  }
565
572
 
566
- await this._doTasks('pre_tasks');
573
+ try {
574
+ await this._doTasks('pre_tasks');
567
575
 
568
- if (this[STATUS] == PRE_STATUS) {
569
- this[STATUS] = MAIN_STATUS;
570
- }
576
+ if (this[STATUS] == PRE_STATUS) {
577
+ this[STATUS] = MAIN_STATUS;
578
+ }
571
579
 
572
- await this._doTasks('main_tasks');
580
+ await this._doTasks('main_tasks');
573
581
 
574
- await this.pre_tasks[STATUS_PLEDGE];
575
- await this.main_tasks[STATUS_PLEDGE];
582
+ await this.pre_tasks[STATUS_PLEDGE];
583
+ await this.main_tasks[STATUS_PLEDGE];
584
+ } catch (err) {
585
+ // Reject the stage pledge too: without this, only _launch's own
586
+ // caller sees the failure and everything waiting on the stage
587
+ // (afterStages, dependent stages) hangs forever
588
+ this.pledge.reject(err);
589
+ throw err;
590
+ }
576
591
 
577
592
  if (this[STATUS] != POST_STATUS) {
578
593
  this[STATUS] = CHILD_STATUS;
@@ -594,7 +609,16 @@ Stage.setMethod(async function refreshStatus() {
594
609
  return;
595
610
  }
596
611
 
597
- await this._doTasks('post_tasks');
612
+ try {
613
+ await this._doTasks('post_tasks');
614
+ } catch (err) {
615
+ // Surface the failure to everything waiting on this stage:
616
+ // leaving the pledge pending made every `afterStages()` waiter
617
+ // hang silently, and when called fire-and-forget (from a child's
618
+ // completion below) the rejection was entirely unhandled
619
+ this.pledge.reject(err);
620
+ throw err;
621
+ }
598
622
 
599
623
  if (!this.ended) {
600
624
  this.ended = Date.now();
@@ -605,7 +629,9 @@ Stage.setMethod(async function refreshStatus() {
605
629
  this.pledge.resolve();
606
630
 
607
631
  if (this.parent) {
608
- this.parent.refreshStatus();
632
+ this.parent.refreshStatus().catch(err => {
633
+ alchemy.registerError(err, {context: 'Post-tasks of stage "' + this.parent.id + '" failed'});
634
+ });
609
635
  }
610
636
  });
611
637
 
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.5",
4
+ "version": "1.4.6",
5
5
  "author": "Jelle De Loecker <jelle@elevenways.be>",
6
6
  "keywords": [
7
7
  "alchemy",