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