alchemymvc 1.4.2 → 1.4.4
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/datasource/mongo_datasource.js +66 -43
- package/lib/app/helper_component/paginate_component.js +6 -6
- package/lib/app/helper_datasource/05-fallback_datasource.js +7 -0
- package/lib/app/helper_datasource/remote_datasource.js +1 -1
- package/lib/app/helper_model/document.js +68 -12
- package/lib/class/plugin.js +9 -24
- package/lib/class/schema_client.js +48 -0
- package/lib/core/alchemy.js +30 -0
- package/lib/core/setting.js +138 -2
- package/lib/stages/20-settings.js +25 -1
- package/lib/stages/50-routes.js +11 -1
- package/package.json +1 -1
|
@@ -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
|
});
|
|
@@ -107,12 +107,7 @@ Paginate.setMethod(function find(model, criteria) {
|
|
|
107
107
|
return;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
let PageInfo = conduit.internal('PageInfo');
|
|
111
|
-
|
|
112
|
-
if (PageInfo == null) {
|
|
113
|
-
PageInfo = {};
|
|
114
|
-
conduit.internal('PageInfo', PageInfo);
|
|
115
|
-
}
|
|
110
|
+
let PageInfo = conduit.internal('PageInfo') || {};
|
|
116
111
|
|
|
117
112
|
let entry = {
|
|
118
113
|
page : page,
|
|
@@ -127,6 +122,11 @@ Paginate.setMethod(function find(model, criteria) {
|
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
PageInfo[name] = entry;
|
|
125
|
+
|
|
126
|
+
// Store the populated object, not an empty one we mutate afterwards: the
|
|
127
|
+
// renderer's internal store snapshots/transforms the value on set, so a
|
|
128
|
+
// later mutation of a previously-stored object no longer propagates.
|
|
129
|
+
conduit.internal('PageInfo', PageInfo);
|
|
130
130
|
});
|
|
131
131
|
|
|
132
132
|
return pledge;
|
|
@@ -130,6 +130,13 @@ Fallback.setMethod(function storeInUpperDatasource(model, data, options) {
|
|
|
130
130
|
// Allow extraneous values (the ones we just added in this function)
|
|
131
131
|
options.extraneous = true;
|
|
132
132
|
|
|
133
|
+
// Store the *converted* data (with the temp values added above), not the
|
|
134
|
+
// original app-side record. Without this the upper datasource caches the
|
|
135
|
+
// raw working values: e.g. a class-typed field is structured-cloned by
|
|
136
|
+
// IndexedDB into a plain, prototype-less object that can't be revived on
|
|
137
|
+
// read. The normal create()/update() flow does this same setConvertedData.
|
|
138
|
+
context.setConvertedData(data);
|
|
139
|
+
|
|
133
140
|
let promise;
|
|
134
141
|
|
|
135
142
|
if (options.create === false) {
|
|
@@ -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) {
|
|
@@ -1478,12 +1478,45 @@ Document.setMethod(function hasFieldValue(name) {
|
|
|
1478
1478
|
return Object.hasProperty(this.$main, name);
|
|
1479
1479
|
});
|
|
1480
1480
|
|
|
1481
|
+
/**
|
|
1482
|
+
* Compare two field values, treating them as equal when their datasource
|
|
1483
|
+
* (stored) form matches.
|
|
1484
|
+
*
|
|
1485
|
+
* `hasChanged()` compares the live, normalized field value (a cast object such
|
|
1486
|
+
* as a TagTree, an ObjectId instance, ...) against the original record, which
|
|
1487
|
+
* holds the raw stored form. Without this, a normalized value always looked
|
|
1488
|
+
* different from its own unchanged stored counterpart, so such fields reported
|
|
1489
|
+
* a change on every load. If both sides serialize to the same datasource value,
|
|
1490
|
+
* saving either would write identical data, so the field has not changed.
|
|
1491
|
+
*
|
|
1492
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1493
|
+
* @since 1.4.4
|
|
1494
|
+
* @version 1.4.4
|
|
1495
|
+
*
|
|
1496
|
+
* @param {Mixed} a
|
|
1497
|
+
* @param {Mixed} b
|
|
1498
|
+
*
|
|
1499
|
+
* @return {boolean}
|
|
1500
|
+
*/
|
|
1501
|
+
Document.setMethod(function alikeWhenStored(a, b) {
|
|
1502
|
+
|
|
1503
|
+
if (Object.alike(a, b)) {
|
|
1504
|
+
return true;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
try {
|
|
1508
|
+
return Object.alike(JSON.clone(a, 'toDatasource'), JSON.clone(b, 'toDatasource'));
|
|
1509
|
+
} catch (err) {
|
|
1510
|
+
return false;
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
|
|
1481
1514
|
/**
|
|
1482
1515
|
* Has this document changed since it was created?
|
|
1483
1516
|
*
|
|
1484
1517
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1485
1518
|
* @since 1.0.4
|
|
1486
|
-
* @version 1.
|
|
1519
|
+
* @version 1.4.4
|
|
1487
1520
|
*
|
|
1488
1521
|
* @param {string} name The optional field name
|
|
1489
1522
|
*
|
|
@@ -1505,27 +1538,47 @@ Document.setMethod(function hasChanged(name) {
|
|
|
1505
1538
|
|
|
1506
1539
|
let result;
|
|
1507
1540
|
|
|
1541
|
+
// Fields excluded from change-detection (computed fields, or `track_changes:
|
|
1542
|
+
// false`) never count as changed and are never compared. The `$model` getter
|
|
1543
|
+
// throws for an unresolvable model (a detached document, or a client document
|
|
1544
|
+
// whose model is not registered), so guard against that and compare every
|
|
1545
|
+
// field when no schema is available.
|
|
1546
|
+
let untracked;
|
|
1547
|
+
try {
|
|
1548
|
+
let schema = this.$model && this.$model.schema;
|
|
1549
|
+
untracked = schema ? schema.getUntrackedFieldNames() : null;
|
|
1550
|
+
} catch (err) {
|
|
1551
|
+
untracked = null;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1508
1554
|
// If we only want to check a single field
|
|
1509
1555
|
if (name) {
|
|
1510
1556
|
let current_value,
|
|
1511
1557
|
old_value;
|
|
1512
|
-
|
|
1513
|
-
if (
|
|
1514
|
-
|
|
1515
|
-
old_value = Object.path(this.$attributes.original_record, name);
|
|
1558
|
+
|
|
1559
|
+
if (untracked && untracked.has(name)) {
|
|
1560
|
+
result = false;
|
|
1516
1561
|
} else {
|
|
1517
|
-
current_value = this[name];
|
|
1518
|
-
old_value = this.$attributes.original_record[name];
|
|
1519
|
-
}
|
|
1520
1562
|
|
|
1521
|
-
|
|
1563
|
+
if (name.includes('.')) {
|
|
1564
|
+
current_value = Object.path(this, name);
|
|
1565
|
+
old_value = Object.path(this.$attributes.original_record, name);
|
|
1566
|
+
} else {
|
|
1567
|
+
current_value = this[name];
|
|
1568
|
+
old_value = this.$attributes.original_record[name];
|
|
1569
|
+
}
|
|
1570
|
+
|
|
1571
|
+
result = !this.alikeWhenStored(old_value, current_value);
|
|
1572
|
+
}
|
|
1522
1573
|
} else {
|
|
1523
1574
|
|
|
1524
1575
|
let key;
|
|
1525
1576
|
|
|
1526
1577
|
for (key in this.$attributes.original_record) {
|
|
1527
|
-
if (
|
|
1528
|
-
|
|
1578
|
+
if (untracked && untracked.has(key)) {
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
if (!this.alikeWhenStored(this.$attributes.original_record[key], this[key])) {
|
|
1529
1582
|
result = true;
|
|
1530
1583
|
break;
|
|
1531
1584
|
}
|
|
@@ -1533,7 +1586,10 @@ Document.setMethod(function hasChanged(name) {
|
|
|
1533
1586
|
|
|
1534
1587
|
if (!result) {
|
|
1535
1588
|
for (key in this.$main) {
|
|
1536
|
-
if (
|
|
1589
|
+
if (untracked && untracked.has(key)) {
|
|
1590
|
+
continue;
|
|
1591
|
+
}
|
|
1592
|
+
if (!this.alikeWhenStored(this.$main[key], this.$attributes.original_record[key])) {
|
|
1537
1593
|
result = true;
|
|
1538
1594
|
break;
|
|
1539
1595
|
}
|
package/lib/class/plugin.js
CHANGED
|
@@ -116,7 +116,7 @@ Plugin.setMethod(async function startPlugin() {
|
|
|
116
116
|
*
|
|
117
117
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
118
118
|
* @since 1.4.0
|
|
119
|
-
* @version 1.4.
|
|
119
|
+
* @version 1.4.3
|
|
120
120
|
*
|
|
121
121
|
* @return {boolean}
|
|
122
122
|
*/
|
|
@@ -138,31 +138,16 @@ Plugin.setMethod(function loadSettingDefinitions() {
|
|
|
138
138
|
// Get/create this plugin's group definition
|
|
139
139
|
let group = this.getSettingsGroup();
|
|
140
140
|
|
|
141
|
-
//
|
|
142
|
-
let parent = alchemy.system_settings.get('plugins');
|
|
143
|
-
let existing = parent.get(this.name);
|
|
144
|
-
|
|
145
|
-
if (existing) {
|
|
146
|
-
// Remove the existing settings.
|
|
147
|
-
// They are not linked properly anyway.
|
|
148
|
-
parent.remove(this.name);
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Require the settings.js file now
|
|
141
|
+
// Register the plugin's setting definitions
|
|
152
142
|
this.useFile(settings_path, {client: false});
|
|
153
143
|
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
if (existing) {
|
|
162
|
-
default_group.setValueSilently(existing);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
parent.injectSubGroupValue(this.name, default_group);
|
|
144
|
+
// Rebuild this plugin's sub-group from its real definitions, preserving any
|
|
145
|
+
// values already applied to it (config overrides / an orphan created before
|
|
146
|
+
// the plugin loaded) plus the plugin's hard-coded default_settings. This is
|
|
147
|
+
// the same orphan-fix the general reconciler uses
|
|
148
|
+
// (Setting.GroupValue#rebuildSubGroup) - a single source of truth.
|
|
149
|
+
let parent = alchemy.system_settings.get('plugins');
|
|
150
|
+
parent.rebuildSubGroup(this.name, group, this.default_settings);
|
|
166
151
|
|
|
167
152
|
return true;
|
|
168
153
|
});
|
|
@@ -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/setting.js
CHANGED
|
@@ -1255,6 +1255,118 @@ GroupValue.setMethod(function injectSubGroupValue(name, value) {
|
|
|
1255
1255
|
this[VALUE][name] = value;
|
|
1256
1256
|
});
|
|
1257
1257
|
|
|
1258
|
+
/**
|
|
1259
|
+
* Rebuild a single sub-group value from its real definition, preserving any
|
|
1260
|
+
* values that were already applied to the current (possibly orphaned) node.
|
|
1261
|
+
*
|
|
1262
|
+
* A config-file override that lands before a group's definition is registered
|
|
1263
|
+
* leaves an "orphan" value backed by an ad-hoc, action-less definition (see
|
|
1264
|
+
* `Group#assign`). This rebuilds the node from the real definition so it
|
|
1265
|
+
* regains its full sub-structure and its leaves' actions, then silently
|
|
1266
|
+
* replays the captured values. It fires NO actions - those run once, later,
|
|
1267
|
+
* in the `settings.perform_actions` stage.
|
|
1268
|
+
*
|
|
1269
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1270
|
+
* @since 1.4.3
|
|
1271
|
+
* @version 1.4.3
|
|
1272
|
+
*
|
|
1273
|
+
* @param {string} name Child to rebuild
|
|
1274
|
+
* @param {Alchemy.Setting.Group} group_definition The real definition
|
|
1275
|
+
* @param {Object} [hardcoded_defaults] Defaults to re-apply
|
|
1276
|
+
*
|
|
1277
|
+
* @return {Alchemy.Setting.GroupValue}
|
|
1278
|
+
*/
|
|
1279
|
+
GroupValue.setMethod(function rebuildSubGroup(name, group_definition, hardcoded_defaults) {
|
|
1280
|
+
|
|
1281
|
+
// Capture whatever value currently sits under `name` (orphan or not).
|
|
1282
|
+
let existing = this.get(name);
|
|
1283
|
+
|
|
1284
|
+
if (existing) {
|
|
1285
|
+
// Detach it so the slot is rebuilt cleanly.
|
|
1286
|
+
this.remove(name);
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
// Materialise a fresh value tree from the REAL definition: recursively
|
|
1290
|
+
// creates every real child with its default and the real `.definition`
|
|
1291
|
+
// (which carries the action / global_variable an orphan stub lacks).
|
|
1292
|
+
let fresh = group_definition.generateValue();
|
|
1293
|
+
|
|
1294
|
+
// Re-apply any hard-coded defaults (e.g. a plugin's `default_settings`).
|
|
1295
|
+
if (hardcoded_defaults) {
|
|
1296
|
+
fresh.setDefaultValue(hardcoded_defaults);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
// Replay the previously-applied values WITHOUT firing actions. Only a
|
|
1300
|
+
// group value can be replayed onto a group; a scalar sitting where a group
|
|
1301
|
+
// belongs is a malformed override - drop it (and warn) rather than crash.
|
|
1302
|
+
if (existing) {
|
|
1303
|
+
if (existing.is_group) {
|
|
1304
|
+
fresh.setValueSilently(existing);
|
|
1305
|
+
} else {
|
|
1306
|
+
alchemy.distinctProblem(
|
|
1307
|
+
'setting-scalar-over-group-' + group_definition.setting_id,
|
|
1308
|
+
'Setting "' + group_definition.setting_id + '" is a group but received a non-group override value; ignoring it'
|
|
1309
|
+
);
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
// Slot the rebuilt node back into the live tree, in place.
|
|
1314
|
+
this.injectSubGroupValue(name, fresh);
|
|
1315
|
+
|
|
1316
|
+
return fresh;
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
/**
|
|
1320
|
+
* Walk this group's REAL definition tree and rebuild any sub-group whose live
|
|
1321
|
+
* value was fabricated ad-hoc (by a config-file override that arrived before
|
|
1322
|
+
* the definition was registered). Healthy nodes - identified by definition
|
|
1323
|
+
* identity - are left untouched (recursed only, to catch nested orphans), so
|
|
1324
|
+
* this is a strict no-op when there are no orphans. Fires NO actions.
|
|
1325
|
+
*
|
|
1326
|
+
* Intended to run after all setting definitions are registered and before the
|
|
1327
|
+
* database values are applied / actions are fired (the `settings.reconcile`
|
|
1328
|
+
* stage).
|
|
1329
|
+
*
|
|
1330
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1331
|
+
* @since 1.4.3
|
|
1332
|
+
* @version 1.4.3
|
|
1333
|
+
*/
|
|
1334
|
+
GroupValue.setMethod(function reconcileOrphanGroups() {
|
|
1335
|
+
|
|
1336
|
+
let definition = this.definition;
|
|
1337
|
+
|
|
1338
|
+
if (!definition || !definition.children) {
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
for (let [child_name, child_definition] of definition.children) {
|
|
1343
|
+
|
|
1344
|
+
// Only sub-groups can be orphaned. A leaf either already carries the
|
|
1345
|
+
// real definition, or is an unknown key with nothing to rebuild.
|
|
1346
|
+
if (!child_definition.is_group) {
|
|
1347
|
+
continue;
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
let live = this.get(child_name);
|
|
1351
|
+
|
|
1352
|
+
// Nothing applied here yet: nothing to preserve, nothing orphaned.
|
|
1353
|
+
if (!live) {
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
// Healthy node: its value is backed by the canonical definition.
|
|
1358
|
+
// Leave it byte-for-byte, but recurse to catch nested orphans.
|
|
1359
|
+
if (live.is_group && live.definition === child_definition) {
|
|
1360
|
+
live.reconcileOrphanGroups();
|
|
1361
|
+
continue;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Orphan: the live value carries an ad-hoc definition. Rebuild it from
|
|
1365
|
+
// the real definition, replaying the captured values silently.
|
|
1366
|
+
this.rebuildSubGroup(child_name, child_definition);
|
|
1367
|
+
}
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1258
1370
|
/**
|
|
1259
1371
|
* Get all the setting values with executable actions in order.
|
|
1260
1372
|
*
|
|
@@ -1429,7 +1541,7 @@ GroupValue.setMethod(function _setPath(silent, path, raw_value) {
|
|
|
1429
1541
|
*
|
|
1430
1542
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
1431
1543
|
* @since 1.4.0
|
|
1432
|
-
* @version 1.4.
|
|
1544
|
+
* @version 1.4.3
|
|
1433
1545
|
*
|
|
1434
1546
|
* @param {string|Array} path
|
|
1435
1547
|
* @param {Value}
|
|
@@ -1450,7 +1562,31 @@ GroupValue.setMethod(function forceValueInstanceAtPath(path, value) {
|
|
|
1450
1562
|
|
|
1451
1563
|
while (path.length && current) {
|
|
1452
1564
|
let next = path.shift();
|
|
1453
|
-
|
|
1565
|
+
|
|
1566
|
+
let child = current.get(next);
|
|
1567
|
+
|
|
1568
|
+
// Create missing intermediate group values instead of crashing.
|
|
1569
|
+
// (A partial config-file override of a group can drop sibling
|
|
1570
|
+
// sub-group value instances; recreate them on demand here.)
|
|
1571
|
+
if (!child) {
|
|
1572
|
+
|
|
1573
|
+
// Can only create a child under a group value; a leaf has no
|
|
1574
|
+
// child definitions to generate from.
|
|
1575
|
+
if (!current.is_group) {
|
|
1576
|
+
return;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
let child_definition = current.definition.get(next);
|
|
1580
|
+
|
|
1581
|
+
if (!child_definition) {
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
child = child_definition.generateValue();
|
|
1586
|
+
current.injectSubGroupValue(next, child);
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
current = child;
|
|
1454
1590
|
}
|
|
1455
1591
|
|
|
1456
1592
|
current[VALUE][last] = value;
|
|
@@ -15,13 +15,32 @@ const settings = STAGES.createStage('settings');
|
|
|
15
15
|
// Do not start this stage before the datasources are connected
|
|
16
16
|
settings.dependsOn('datasource.connect');
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* "settings.reconcile"
|
|
20
|
+
* Rebuild any setting groups that were orphaned by a config-file override
|
|
21
|
+
* landing before the group's definition was registered (app definitions load
|
|
22
|
+
* in `app_bootstrap`, plugins in `load_app.plugins` - both after the config
|
|
23
|
+
* merge in the Alchemy constructor). Runs before `load` so the database values
|
|
24
|
+
* land on the real-definition value nodes, and before `perform_actions` so
|
|
25
|
+
* actions fire exactly once over the reconciled tree. Silent: fires no actions.
|
|
26
|
+
*
|
|
27
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
28
|
+
* @since 1.4.3
|
|
29
|
+
* @version 1.4.3
|
|
30
|
+
*
|
|
31
|
+
* @type {Alchemy.Stages.Stage}
|
|
32
|
+
*/
|
|
33
|
+
const reconcile = settings.createStage('reconcile', () => {
|
|
34
|
+
alchemy.system_settings.reconcileOrphanGroups();
|
|
35
|
+
});
|
|
36
|
+
|
|
18
37
|
/**
|
|
19
38
|
* "settings.load"
|
|
20
39
|
* Load the settings from the database
|
|
21
40
|
*
|
|
22
41
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
23
42
|
* @since 1.4.0
|
|
24
|
-
* @version 1.4.
|
|
43
|
+
* @version 1.4.3
|
|
25
44
|
*
|
|
26
45
|
* @type {Alchemy.Stages.Stage}
|
|
27
46
|
*/
|
|
@@ -38,6 +57,11 @@ const load = settings.createStage('load', async () => {
|
|
|
38
57
|
}
|
|
39
58
|
});
|
|
40
59
|
|
|
60
|
+
// Orphan reconciliation must finish before DB values are applied, so the
|
|
61
|
+
// values land on the real-definition nodes. Creation order already runs
|
|
62
|
+
// `reconcile` first; this makes the contract explicit and reorder-proof.
|
|
63
|
+
load.dependsOn('settings.reconcile');
|
|
64
|
+
|
|
41
65
|
/**
|
|
42
66
|
* "settings.perform_actions"
|
|
43
67
|
* Do all the setting-associated actions.
|
package/lib/stages/50-routes.js
CHANGED
|
@@ -204,7 +204,7 @@ const middleware = routes.createStage('middleware', () => {
|
|
|
204
204
|
*
|
|
205
205
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
206
206
|
* @since 1.4.0
|
|
207
|
-
* @version 1.4.
|
|
207
|
+
* @version 1.4.3
|
|
208
208
|
*
|
|
209
209
|
* @type {Alchemy.Stages.Stage}
|
|
210
210
|
*/
|
|
@@ -212,6 +212,16 @@ const app_routes = routes.createStage('app_routes', () => {
|
|
|
212
212
|
try {
|
|
213
213
|
alchemy.useOnce(libpath.resolve(PATH_APP, 'config', 'routes.js'));
|
|
214
214
|
} catch (err) {
|
|
215
|
+
|
|
216
|
+
// A missing routes.js is fine (the file is optional). Any other error
|
|
217
|
+
// is a real problem inside the file and must NOT be swallowed - that
|
|
218
|
+
// would leave the app running with no routes and only a warning.
|
|
219
|
+
let is_not_found = err.code === 'ENOENT' || err.message.indexOf('Cannot find') > -1;
|
|
220
|
+
|
|
221
|
+
if (!is_not_found) {
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
|
|
215
225
|
// Only output warning when not in client mode
|
|
216
226
|
if (!alchemy.getSetting('client_mode')) {
|
|
217
227
|
log.warn('No app routes were found:', err);
|