alchemymvc 1.4.4 → 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,20 @@ 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
+
306
+ try {
307
+ await createRevision(result);
308
+ pledge.resolve();
309
+ } catch (err) {
310
+ pledge.reject(err);
311
+ }
312
+ });
313
+
314
+ async function createRevision(result) {
315
+
288
316
  if (result) {
289
317
 
290
318
  // Get the new data
@@ -320,13 +348,13 @@ Revision.setMethod(function afterSave(record, options, created) {
320
348
  [that.revision_model.model_name] : revision_data
321
349
  };
322
350
 
323
- // Save the data (but do not wait for it)
324
- that.revision_model.save(revision_data, {allowFields: true});
351
+ // Save the revision. This IS awaited: a `revert()` right after
352
+ // a save has to see this revision, and a fire-and-forget save
353
+ // would also swallow any insert error.
354
+ await that.revision_model.save(revision_data, {allowFields: true});
325
355
  }
326
356
  }
327
-
328
- pledge.resolve();
329
- });
357
+ }
330
358
 
331
359
  return pledge;
332
360
  });
@@ -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
  /**
@@ -165,7 +162,7 @@ Fallback.setMethod(function storeInUpperDatasource(model, data, options) {
165
162
  *
166
163
  * @author Jelle De Loecker <jelle@elevenways.be>
167
164
  * @since 1.1.0
168
- * @version 1.4.2
165
+ * @version 1.4.5
169
166
  *
170
167
  * @param {Alchemy.OperationalContext.ReadDocumentFromDatasource} context
171
168
  *
@@ -183,7 +180,28 @@ Fallback.setMethod(function read(context) {
183
180
  let lower_context = context.createChild();
184
181
  lower_context.setCriteria(lower_criteria);
185
182
 
186
- tasks.push(() => this.lower.read(lower_context));
183
+ // A rejected lower (remote) read must not reject the whole query:
184
+ // resolve to null instead, so the waterfall falls through to the upper
185
+ // (local cache) read. Without this, being offline made every find fail
186
+ // even though the cache had the data.
187
+ tasks.push(() => {
188
+ let attempt = new Swift();
189
+
190
+ Swift.done(this.lower.read(lower_context), (err, result) => {
191
+
192
+ if (err) {
193
+ if (Blast.isBrowser && typeof alchemy != 'undefined' && alchemy.distinctProblem) {
194
+ alchemy.distinctProblem('fallback-lower-read', 'Remote read failed, using local cache', {repeat_after: 60000});
195
+ }
196
+
197
+ return attempt.resolve(null);
198
+ }
199
+
200
+ attempt.resolve(result);
201
+ });
202
+
203
+ return attempt;
204
+ });
187
205
  }
188
206
 
189
207
  let upper_criteria = criteria.clone();
@@ -202,7 +220,7 @@ Fallback.setMethod(function read(context) {
202
220
  *
203
221
  * @author Jelle De Loecker <jelle@elevenways.be>
204
222
  * @since 1.1.0
205
- * @version 1.4.2
223
+ * @version 1.4.5
206
224
  *
207
225
  * @param {Model} model
208
226
  *
@@ -220,7 +238,13 @@ Fallback.setMethod(function getRecordsToSync(model) {
220
238
  context.setModel(model);
221
239
  context.setCriteria(criteria);
222
240
 
223
- return this.upper.read(context);
241
+ // Datasource#read resolves with `{items, available}` since 1.4.2, but the
242
+ // consumers of this method (getRecordsToBeSavedRemotely) iterate the result
243
+ // directly - so unwrap to the items array, or offline saves never sync.
244
+ return Swift.waterfall(
245
+ this.upper.read(context),
246
+ result => (result && result.items) ? result.items : (result || [])
247
+ );
224
248
  });
225
249
 
226
250
  /**
@@ -335,9 +359,17 @@ Fallback.setMethod(function createOrUpdate(method, context) {
335
359
  // Lower data can already be a Document instance!
336
360
  // @TODO: add a check for that?
337
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);
338
368
  } else {
339
- let toapp_context = context.withDatasourceEntry(saved_data.upper.result);
340
- 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'));
341
373
  }
342
374
 
343
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 {
@@ -1170,6 +1174,38 @@ Document.setMethod(function save(data, options, callback) {
1170
1174
 
1171
1175
  save_result = save_result[0];
1172
1176
 
1177
+ // Fields edited while the save was in flight would be silently
1178
+ // reverted by adopting the response wholesale (the response only
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.
1185
+ let kept_fields;
1186
+
1187
+ if (in_flight_snapshot && that.$main) {
1188
+ let saved_main = save_result.$main || {},
1189
+ key;
1190
+
1191
+ for (key in that.$main) {
1192
+
1193
+ if (key == pk_name) {
1194
+ continue;
1195
+ }
1196
+
1197
+ if (!that.alikeWhenStored(that.$main[key], in_flight_snapshot[key])
1198
+ && !that.alikeWhenStored(that.$main[key], saved_main[key])) {
1199
+
1200
+ if (!kept_fields) {
1201
+ kept_fields = {};
1202
+ }
1203
+
1204
+ kept_fields[key] = that.$main[key];
1205
+ }
1206
+ }
1207
+ }
1208
+
1173
1209
  // Use the saved data from now on
1174
1210
  that.$main = save_result.$main;
1175
1211
 
@@ -1177,10 +1213,21 @@ Document.setMethod(function save(data, options, callback) {
1177
1213
  that.$attributes.original_record = undefined;
1178
1214
  that.markUnchanged();
1179
1215
 
1180
- if (that.hasObjectFields()) {
1216
+ if (kept_fields || that.hasObjectFields()) {
1181
1217
  that.storeCurrentDataAsOriginalRecord();
1182
1218
  }
1183
1219
 
1220
+ if (kept_fields) {
1221
+ let key;
1222
+
1223
+ // Put the newer, in-flight edits back on top of the saved state
1224
+ // and mark them as changed, so a follow-up save persists them.
1225
+ for (key in kept_fields) {
1226
+ that.markChangedField(key, kept_fields[key]);
1227
+ that.$main[key] = kept_fields[key];
1228
+ }
1229
+ }
1230
+
1184
1231
  pledge.resolve(that);
1185
1232
  }
1186
1233
 
@@ -1218,6 +1265,16 @@ Document.setMethod(function save(data, options, callback) {
1218
1265
  }
1219
1266
  }
1220
1267
 
1268
+ // Snapshot the record as it is being sent, so `updateDoc` can tell which
1269
+ // fields were edited while the save was in flight
1270
+ let in_flight_snapshot = null;
1271
+
1272
+ try {
1273
+ in_flight_snapshot = JSON.clone(main);
1274
+ } catch (err) {
1275
+ // Without a snapshot the response is simply adopted as-is
1276
+ }
1277
+
1221
1278
  sub_pledge = this.$model.save(this, options, updateDoc);
1222
1279
 
1223
1280
  return pledge;
@@ -1265,13 +1322,21 @@ Document.setMethod(function checkAndInformDatasource(options, callback) {
1265
1322
  if (DocClass.Model && DocClass.Model.hasServerAction('readDatasource')) {
1266
1323
  // Only store in cache when we can query the server
1267
1324
  pledge.resolve(that.informDatasource());
1325
+ } else {
1326
+ pledge.resolve();
1268
1327
  }
1269
1328
  });
1270
1329
  } else if (DocClass.Model.hasServerAction('readDatasource')) {
1271
1330
  Blast.requestIdleCallback(function tryStore(task_data) {
1272
1331
  pledge.resolve(that.informDatasource());
1273
1332
  });
1333
+ } else {
1334
+ pledge.resolve();
1274
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();
1275
1340
  }
1276
1341
 
1277
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
 
@@ -1892,20 +1898,6 @@ Model.setMethod(function saveRecord(document, options, callback) {
1892
1898
 
1893
1899
  creating = options.create || document.$pk == null;
1894
1900
  next();
1895
-
1896
- return;
1897
-
1898
- // Look through unique indexes if no _id is present
1899
- that.auditRecord(document, options, function afterAudit(err, doc) {
1900
-
1901
- if (err) {
1902
- return next(err);
1903
- }
1904
-
1905
- // Is a new record being created?
1906
- creating = options.create || doc.$pk == null;
1907
- next();
1908
- });
1909
1901
  }, function doBeforeNormalize(next) {
1910
1902
  // @TODO: make "beforeSave" only use promises
1911
1903
  that.issueDataEvent('beforeNormalize', [document, options], next);
@@ -1934,28 +1926,45 @@ Model.setMethod(function saveRecord(document, options, callback) {
1934
1926
  }
1935
1927
  }, function doAssociated(next) {
1936
1928
 
1937
- var tasks = [],
1938
- assoc,
1939
- entry,
1940
- key;
1929
+ var tasks = [];
1941
1930
 
1942
1931
  Object.each(document.$record, function eachEntry(entry, key) {
1943
1932
 
1933
+ // Skip empty entries
1934
+ if (!entry) {
1935
+ return;
1936
+ }
1937
+
1944
1938
  // Skip our own record
1945
1939
  if (key == that.name) {
1946
1940
  return;
1947
1941
  }
1948
1942
 
1949
- // Get the association configuration
1950
- 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];
1951
1949
 
1952
1950
  // If the association doesn't exist, do nothing
1953
1951
  if (!assoc) {
1954
1952
  return;
1955
1953
  }
1956
1954
 
1957
- // Add the saved _id
1958
- 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
+ }
1959
1968
 
1960
1969
  // Add the task
1961
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
 
@@ -1402,7 +1420,7 @@ Model.setMethod(function remove(id, callback) {
1402
1420
  *
1403
1421
  * @author Jelle De Loecker <jelle@elevenways.be>
1404
1422
  * @since 0.5.0
1405
- * @version 1.2.0
1423
+ * @version 1.4.5
1406
1424
  *
1407
1425
  * @param {Object} options Find options
1408
1426
  * @param {Function} task Task to perform on each record
@@ -1425,7 +1443,12 @@ Model.setMethod(function eachRecord(options, task, callback) {
1425
1443
  options = {};
1426
1444
  }
1427
1445
 
1428
- if (!callback) {
1446
+ if (callback) {
1447
+ // Errors are also delivered through the callback, and callers that
1448
+ // pass one routinely discard the returned pledge - its rejection
1449
+ // must not be reported as an uncaught error on top of the callback
1450
+ pledge.warn_uncaught_errors = false;
1451
+ } else {
1429
1452
  callback = Function.thrower;
1430
1453
  }
1431
1454
 
@@ -1450,6 +1473,11 @@ Model.setMethod(function eachRecord(options, task, callback) {
1450
1473
 
1451
1474
  var tasks = [];
1452
1475
 
1476
+ if (err) {
1477
+ pledge.reject(err);
1478
+ return callback(err);
1479
+ }
1480
+
1453
1481
  if (!result.length) {
1454
1482
  pledge.reportProgress(100);
1455
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
  };
@@ -291,6 +291,13 @@ ClientBase.setMethod(function issueEvent(name, args, next) {
291
291
  args = [];
292
292
  }
293
293
 
294
+ if (next) {
295
+ // Errors are also delivered through `next`, and callers that pass a
296
+ // callback routinely discard the returned pledge - its rejection
297
+ // must not be reported as an uncaught error on top of the callback.
298
+ pledge.warn_uncaught_errors = false;
299
+ }
300
+
294
301
  if (this.constructor.event_to_method_map) {
295
302
  method_name = this.constructor.event_to_method_map.get(name);
296
303
  }
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
 
@@ -118,7 +118,17 @@ const hawkejs = routes.createStage('hawkejs', () => {
118
118
  return req.conduit.notFound('Could not find any of the given templates');
119
119
  }
120
120
 
121
- req.conduit.setHeader('cache-control', 'public, max-age=3600, must-revalidate');
121
+ // Only allow long-lived caching when the request is versioned
122
+ // with the CURRENT app version: unversioned (or stale-versioned)
123
+ // urls would keep serving an old template for up to an hour
124
+ // after a deploy.
125
+ let requested_version = req.conduit.param('v');
126
+
127
+ if (requested_version && String(requested_version) === String(alchemy.hawkejs.app_version)) {
128
+ req.conduit.setHeader('cache-control', 'public, max-age=3600, must-revalidate');
129
+ } else {
130
+ req.conduit.setHeader('cache-control', 'no-cache');
131
+ }
122
132
 
123
133
  // Don't use json dry, hawkejs expects regular json
124
134
  req.conduit.json_dry = false;
@@ -24,6 +24,78 @@
24
24
  */
25
25
  'use strict';
26
26
 
27
+ const fs = require('fs'),
28
+ child_process = require('child_process');
29
+
30
+ /**
31
+ * Check if a browser executable actually works in headless mode.
32
+ * Puppeteer's own bundled-Chrome download can be missing/corrupt in some
33
+ * sandboxes (e.g. a pinned old revision missing its ICU data file). Such a
34
+ * binary still exits 0 for `--version`, but hangs and never emits a CDP
35
+ * handshake when actually launched headless - which is what makes
36
+ * `puppeteer.launch()` hang until its connect-timeout instead of failing
37
+ * fast. So a real headless run is checked synchronously up front instead.
38
+ *
39
+ * @author Jelle De Loecker <jelle@elevenways.be>
40
+ * @since 1.4.5
41
+ * @version 1.4.5
42
+ *
43
+ * @param {string} executable_path
44
+ *
45
+ * @return {boolean}
46
+ */
47
+ function isUsableBrowser(executable_path) {
48
+
49
+ if (!executable_path || !fs.existsSync(executable_path)) {
50
+ return false;
51
+ }
52
+
53
+ let result = child_process.spawnSync(executable_path, [
54
+ '--headless',
55
+ '--disable-gpu',
56
+ '--no-sandbox',
57
+ '--dump-dom',
58
+ 'about:blank',
59
+ ], {timeout: 8000});
60
+
61
+ return result.status === 0 && !result.error;
62
+ }
63
+
64
+ /**
65
+ * Resolve which Chrome/Chromium executable to launch.
66
+ * Prefers `PUPPETEER_EXECUTABLE_PATH` (puppeteer already honors it, but we
67
+ * validate it here too) and puppeteer's own bundled download; falls back to
68
+ * common system-installed locations when neither actually works.
69
+ *
70
+ * @author Jelle De Loecker <jelle@elevenways.be>
71
+ * @since 1.4.5
72
+ * @version 1.4.5
73
+ *
74
+ * @param {Object} puppeteer
75
+ *
76
+ * @return {string|undefined}
77
+ */
78
+ function resolveExecutablePath(puppeteer) {
79
+
80
+ let candidates = [
81
+ process.env.PUPPETEER_EXECUTABLE_PATH,
82
+ puppeteer.executablePath(),
83
+ '/usr/bin/chromium',
84
+ '/usr/bin/chromium-browser',
85
+ '/usr/bin/google-chrome',
86
+ ];
87
+
88
+ for (let candidate of candidates) {
89
+ if (isUsableBrowser(candidate)) {
90
+ return candidate;
91
+ }
92
+ }
93
+
94
+ // Nothing usable found: let puppeteer try its own default and produce
95
+ // its normal error/timeout, rather than silently launching with none
96
+ return undefined;
97
+ }
98
+
27
99
  /**
28
100
  * The BrowserHelper class
29
101
  *
@@ -133,7 +205,8 @@ BrowserHelper.setMethod(async function load() {
133
205
  } else {
134
206
  // Launch new browser
135
207
  this.browser = await puppeteer.launch({
136
- headless: this.options.headless,
208
+ headless : this.options.headless,
209
+ executablePath : resolveExecutablePath(puppeteer),
137
210
  });
138
211
  }
139
212
 
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.4",
4
+ "version": "1.4.6",
5
5
  "author": "Jelle De Loecker <jelle@elevenways.be>",
6
6
  "keywords": [
7
7
  "alchemy",
@@ -22,7 +22,7 @@
22
22
  "chokidar" : "~4.0.3",
23
23
  "formidable" : "~3.5.4",
24
24
  "graceful-fs" : "~4.2.11",
25
- "hawkejs" : "~2.4.2",
25
+ "hawkejs" : "~2.4.3",
26
26
  "jsondiffpatch" : "~0.5.0",
27
27
  "mime-types" : "~3.0.2",
28
28
  "minimist" : "~1.2.8",
@@ -32,7 +32,7 @@
32
32
  "ncp" : "~2.0.0",
33
33
  "postcss" : "~8.5.6",
34
34
  "postcss-prune-var": "~1.1.2",
35
- "protoblast" : "~0.9.8",
35
+ "protoblast" : "~0.9.9",
36
36
  "semver" : "~7.7.2",
37
37
  "socket.io" : "~4.7.5",
38
38
  "@11ways/socket.io-stream" : "~0.9.2",
@@ -66,7 +66,7 @@
66
66
  "mocha" : "^11.7.5",
67
67
  "mongo-unit" : "^3.4.0",
68
68
  "nyc" : "^15.1.0",
69
- "puppeteer" : "~21.3.6",
69
+ "puppeteer" : "^24.43.1",
70
70
  "source-map" : "~0.7.3"
71
71
  },
72
72
  "scripts": {