alchemymvc 1.4.4 → 1.4.5

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.
@@ -285,6 +285,16 @@ Revision.setMethod(function afterSave(record, options, created) {
285
285
  // Find the complete saved item
286
286
  Model.get(this.model.model_name).findByPk(doc.$pk, async function gotRecord(err, result) {
287
287
 
288
+ try {
289
+ await createRevision(result);
290
+ pledge.resolve();
291
+ } catch (err) {
292
+ pledge.reject(err);
293
+ }
294
+ });
295
+
296
+ async function createRevision(result) {
297
+
288
298
  if (result) {
289
299
 
290
300
  // Get the new data
@@ -320,13 +330,13 @@ Revision.setMethod(function afterSave(record, options, created) {
320
330
  [that.revision_model.model_name] : revision_data
321
331
  };
322
332
 
323
- // Save the data (but do not wait for it)
324
- that.revision_model.save(revision_data, {allowFields: true});
333
+ // Save the revision. This IS awaited: a `revert()` right after
334
+ // a save has to see this revision, and a fire-and-forget save
335
+ // would also swallow any insert error.
336
+ await that.revision_model.save(revision_data, {allowFields: true});
325
337
  }
326
338
  }
327
-
328
- pledge.resolve();
329
- });
339
+ }
330
340
 
331
341
  return pledge;
332
342
  });
@@ -165,7 +165,7 @@ Fallback.setMethod(function storeInUpperDatasource(model, data, options) {
165
165
  *
166
166
  * @author Jelle De Loecker <jelle@elevenways.be>
167
167
  * @since 1.1.0
168
- * @version 1.4.2
168
+ * @version 1.4.5
169
169
  *
170
170
  * @param {Alchemy.OperationalContext.ReadDocumentFromDatasource} context
171
171
  *
@@ -183,7 +183,28 @@ Fallback.setMethod(function read(context) {
183
183
  let lower_context = context.createChild();
184
184
  lower_context.setCriteria(lower_criteria);
185
185
 
186
- tasks.push(() => this.lower.read(lower_context));
186
+ // A rejected lower (remote) read must not reject the whole query:
187
+ // resolve to null instead, so the waterfall falls through to the upper
188
+ // (local cache) read. Without this, being offline made every find fail
189
+ // even though the cache had the data.
190
+ tasks.push(() => {
191
+ let attempt = new Swift();
192
+
193
+ Swift.done(this.lower.read(lower_context), (err, result) => {
194
+
195
+ if (err) {
196
+ if (Blast.isBrowser && typeof alchemy != 'undefined' && alchemy.distinctProblem) {
197
+ alchemy.distinctProblem('fallback-lower-read', 'Remote read failed, using local cache', {repeat_after: 60000});
198
+ }
199
+
200
+ return attempt.resolve(null);
201
+ }
202
+
203
+ attempt.resolve(result);
204
+ });
205
+
206
+ return attempt;
207
+ });
187
208
  }
188
209
 
189
210
  let upper_criteria = criteria.clone();
@@ -202,7 +223,7 @@ Fallback.setMethod(function read(context) {
202
223
  *
203
224
  * @author Jelle De Loecker <jelle@elevenways.be>
204
225
  * @since 1.1.0
205
- * @version 1.4.2
226
+ * @version 1.4.5
206
227
  *
207
228
  * @param {Model} model
208
229
  *
@@ -220,7 +241,13 @@ Fallback.setMethod(function getRecordsToSync(model) {
220
241
  context.setModel(model);
221
242
  context.setCriteria(criteria);
222
243
 
223
- return this.upper.read(context);
244
+ // Datasource#read resolves with `{items, available}` since 1.4.2, but the
245
+ // consumers of this method (getRecordsToBeSavedRemotely) iterate the result
246
+ // directly - so unwrap to the items array, or offline saves never sync.
247
+ return Swift.waterfall(
248
+ this.upper.read(context),
249
+ result => (result && result.items) ? result.items : (result || [])
250
+ );
224
251
  });
225
252
 
226
253
  /**
@@ -1170,6 +1170,31 @@ Document.setMethod(function save(data, options, callback) {
1170
1170
 
1171
1171
  save_result = save_result[0];
1172
1172
 
1173
+ // Fields edited while the save was in flight would be silently
1174
+ // reverted by adopting the response wholesale (the response only
1175
+ // reflects the record as it was sent), so detect and keep them.
1176
+ let kept_fields;
1177
+
1178
+ if (in_flight_snapshot && that.$main) {
1179
+ let key;
1180
+
1181
+ for (key in that.$main) {
1182
+
1183
+ if (key == pk_name) {
1184
+ continue;
1185
+ }
1186
+
1187
+ if (!that.alikeWhenStored(that.$main[key], in_flight_snapshot[key])) {
1188
+
1189
+ if (!kept_fields) {
1190
+ kept_fields = {};
1191
+ }
1192
+
1193
+ kept_fields[key] = that.$main[key];
1194
+ }
1195
+ }
1196
+ }
1197
+
1173
1198
  // Use the saved data from now on
1174
1199
  that.$main = save_result.$main;
1175
1200
 
@@ -1177,10 +1202,21 @@ Document.setMethod(function save(data, options, callback) {
1177
1202
  that.$attributes.original_record = undefined;
1178
1203
  that.markUnchanged();
1179
1204
 
1180
- if (that.hasObjectFields()) {
1205
+ if (kept_fields || that.hasObjectFields()) {
1181
1206
  that.storeCurrentDataAsOriginalRecord();
1182
1207
  }
1183
1208
 
1209
+ if (kept_fields) {
1210
+ let key;
1211
+
1212
+ // Put the newer, in-flight edits back on top of the saved state
1213
+ // and mark them as changed, so a follow-up save persists them.
1214
+ for (key in kept_fields) {
1215
+ that.markChangedField(key, kept_fields[key]);
1216
+ that.$main[key] = kept_fields[key];
1217
+ }
1218
+ }
1219
+
1184
1220
  pledge.resolve(that);
1185
1221
  }
1186
1222
 
@@ -1218,6 +1254,16 @@ Document.setMethod(function save(data, options, callback) {
1218
1254
  }
1219
1255
  }
1220
1256
 
1257
+ // Snapshot the record as it is being sent, so `updateDoc` can tell which
1258
+ // fields were edited while the save was in flight
1259
+ let in_flight_snapshot = null;
1260
+
1261
+ try {
1262
+ in_flight_snapshot = JSON.clone(main);
1263
+ } catch (err) {
1264
+ // Without a snapshot the response is simply adopted as-is
1265
+ }
1266
+
1221
1267
  sub_pledge = this.$model.save(this, options, updateDoc);
1222
1268
 
1223
1269
  return pledge;
@@ -1892,20 +1892,6 @@ Model.setMethod(function saveRecord(document, options, callback) {
1892
1892
 
1893
1893
  creating = options.create || document.$pk == null;
1894
1894
  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
1895
  }, function doBeforeNormalize(next) {
1910
1896
  // @TODO: make "beforeSave" only use promises
1911
1897
  that.issueDataEvent('beforeNormalize', [document, options], next);
@@ -1402,7 +1402,7 @@ Model.setMethod(function remove(id, callback) {
1402
1402
  *
1403
1403
  * @author Jelle De Loecker <jelle@elevenways.be>
1404
1404
  * @since 0.5.0
1405
- * @version 1.2.0
1405
+ * @version 1.4.5
1406
1406
  *
1407
1407
  * @param {Object} options Find options
1408
1408
  * @param {Function} task Task to perform on each record
@@ -1425,7 +1425,12 @@ Model.setMethod(function eachRecord(options, task, callback) {
1425
1425
  options = {};
1426
1426
  }
1427
1427
 
1428
- if (!callback) {
1428
+ if (callback) {
1429
+ // Errors are also delivered through the callback, and callers that
1430
+ // pass one routinely discard the returned pledge - its rejection
1431
+ // must not be reported as an uncaught error on top of the callback
1432
+ pledge.warn_uncaught_errors = false;
1433
+ } else {
1429
1434
  callback = Function.thrower;
1430
1435
  }
1431
1436
 
@@ -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
  }
@@ -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.5",
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": {