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.
- package/lib/app/behaviour/publishable_behaviour.js +4 -1
- package/lib/app/behaviour/revision_behaviour.js +18 -0
- package/lib/app/behaviour/sluggable_behaviour.js +5 -1
- package/lib/app/datasource/mongo_datasource.js +15 -3
- package/lib/app/helper_datasource/00-nosql_datasource.js +12 -4
- package/lib/app/helper_datasource/05-fallback_datasource.js +11 -6
- package/lib/app/helper_field/schema_field.js +28 -8
- package/lib/app/helper_model/document.js +23 -4
- package/lib/app/helper_model/model.js +34 -11
- package/lib/class/datasource.js +29 -5
- package/lib/class/field.js +29 -4
- package/lib/class/migration.js +49 -7
- package/lib/class/model.js +39 -16
- package/lib/class/task.js +64 -5
- package/lib/core/stage.js +35 -9
- package/package.json +1 -1
|
@@ -53,6 +53,9 @@ Publish.setMethod(function beforeFind(criteria) {
|
|
|
53
53
|
date_condition = new Date();
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
|
|
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
|
-
//
|
|
512
|
-
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
1628
|
+
doc_options = Object.assign({}, options, {create: document.is_new_record});
|
|
1623
1629
|
}
|
|
1624
1630
|
|
|
1625
1631
|
// Save the data
|
|
1626
|
-
that.saveRecord(document,
|
|
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
|
-
|
|
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
|
-
//
|
|
1944
|
-
|
|
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) {
|
package/lib/class/datasource.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
}
|
package/lib/class/field.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/class/migration.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
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
|
-
|
|
175
|
+
|
|
176
|
+
if (err) {
|
|
177
|
+
pledge.reject(err);
|
|
178
|
+
} else {
|
|
179
|
+
pledge.resolve();
|
|
180
|
+
}
|
|
141
181
|
});
|
|
182
|
+
|
|
183
|
+
return pledge;
|
|
142
184
|
});
|
package/lib/class/model.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
1003
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1088
|
+
let value = document[fieldName];
|
|
1072
1089
|
|
|
1073
1090
|
// @todo: should run through the FieldType instance
|
|
1074
|
-
if (String(
|
|
1075
|
-
|
|
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.
|
|
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 (
|
|
1087
|
-
results[indexName] =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
+
try {
|
|
574
|
+
await this._doTasks('pre_tasks');
|
|
567
575
|
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
576
|
+
if (this[STATUS] == PRE_STATUS) {
|
|
577
|
+
this[STATUS] = MAIN_STATUS;
|
|
578
|
+
}
|
|
571
579
|
|
|
572
|
-
|
|
580
|
+
await this._doTasks('main_tasks');
|
|
573
581
|
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
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
|
|