alchemymvc 1.4.1 → 1.4.3
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/helper_component/paginate_component.js +6 -6
- package/lib/app/helper_datasource/05-fallback_datasource.js +15 -13
- package/lib/app/helper_field/time_field.js +1 -1
- package/lib/app/model/system_task_history_model.js +134 -0
- package/lib/class/datasource.js +14 -2
- package/lib/class/document.js +1 -1
- package/lib/class/inode_file.js +2 -0
- package/lib/class/model.js +2 -2
- package/lib/class/plugin.js +9 -24
- package/lib/class/postponement.js +1 -1
- package/lib/class/router.js +2 -1
- package/lib/class/schema_client.js +1 -1
- package/lib/class/task.js +42 -24
- package/lib/core/prefix.js +1 -1
- package/lib/core/setting.js +138 -2
- package/lib/stages/20-settings.js +25 -1
- package/lib/stages/50-routes.js +11 -1
- package/lib/testing/harness.js +85 -3
- package/package.json +4 -4
|
@@ -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) {
|
|
@@ -158,7 +165,7 @@ Fallback.setMethod(function storeInUpperDatasource(model, data, options) {
|
|
|
158
165
|
*
|
|
159
166
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
160
167
|
* @since 1.1.0
|
|
161
|
-
* @version 1.4.
|
|
168
|
+
* @version 1.4.2
|
|
162
169
|
*
|
|
163
170
|
* @param {Alchemy.OperationalContext.ReadDocumentFromDatasource} context
|
|
164
171
|
*
|
|
@@ -176,7 +183,7 @@ Fallback.setMethod(function read(context) {
|
|
|
176
183
|
let lower_context = context.createChild();
|
|
177
184
|
lower_context.setCriteria(lower_criteria);
|
|
178
185
|
|
|
179
|
-
tasks.push(() => this.lower.read(
|
|
186
|
+
tasks.push(() => this.lower.read(lower_context));
|
|
180
187
|
}
|
|
181
188
|
|
|
182
189
|
let upper_criteria = criteria.clone();
|
|
@@ -195,7 +202,7 @@ Fallback.setMethod(function read(context) {
|
|
|
195
202
|
*
|
|
196
203
|
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
197
204
|
* @since 1.1.0
|
|
198
|
-
* @version 1.
|
|
205
|
+
* @version 1.4.2
|
|
199
206
|
*
|
|
200
207
|
* @param {Model} model
|
|
201
208
|
*
|
|
@@ -204,21 +211,16 @@ Fallback.setMethod(function read(context) {
|
|
|
204
211
|
Fallback.setMethod(function getRecordsToSync(model) {
|
|
205
212
|
|
|
206
213
|
var that = this,
|
|
207
|
-
pledge = new Pledge,
|
|
208
214
|
criteria = model.find();
|
|
209
215
|
|
|
210
216
|
criteria.where('_$needs_remote_save').equals(1);
|
|
211
217
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
}
|
|
218
|
+
let context = new Classes.Alchemy.OperationalContext.ReadDocumentFromDatasource();
|
|
219
|
+
context.setDatasource(this.upper);
|
|
220
|
+
context.setModel(model);
|
|
221
|
+
context.setCriteria(criteria);
|
|
217
222
|
|
|
218
|
-
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
return pledge;
|
|
223
|
+
return this.upper.read(context);
|
|
222
224
|
});
|
|
223
225
|
|
|
224
226
|
/**
|
|
@@ -116,4 +116,138 @@ SystemTaskHistory.constitute(function chimeraConfig() {
|
|
|
116
116
|
route : 'Chimera.Editor#taskMonitor',
|
|
117
117
|
route_params : {task_history_id: '$pk'},
|
|
118
118
|
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Derive a single-word status from the recorded fields.
|
|
123
|
+
*
|
|
124
|
+
* - 'running': is_running is still true
|
|
125
|
+
* - 'failed': had_error is true
|
|
126
|
+
* - 'done': started AND ended cleanly
|
|
127
|
+
* - 'aborted': started but never ended (pre-1.4.2 zombies, or a process
|
|
128
|
+
* that died mid-task without unwinding the try/finally)
|
|
129
|
+
*
|
|
130
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
131
|
+
* @since 1.4.2
|
|
132
|
+
*
|
|
133
|
+
* @return {string}
|
|
134
|
+
*/
|
|
135
|
+
SystemTaskHistory.setDocumentMethod(function getStatus() {
|
|
136
|
+
|
|
137
|
+
if (this.had_error) {
|
|
138
|
+
return 'failed';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (this.is_running) {
|
|
142
|
+
return 'running';
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (this.ended_at) {
|
|
146
|
+
return 'done';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (this.started_at) {
|
|
150
|
+
return 'aborted';
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return 'scheduled';
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Return how long this run took (or has been running so far), in
|
|
158
|
+
* milliseconds. Null when nothing has started yet.
|
|
159
|
+
*
|
|
160
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
161
|
+
* @since 1.4.2
|
|
162
|
+
*
|
|
163
|
+
* @return {number|null}
|
|
164
|
+
*/
|
|
165
|
+
SystemTaskHistory.setDocumentMethod(function getDuration() {
|
|
166
|
+
|
|
167
|
+
if (!this.started_at) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
let end = this.ended_at || (this.is_running ? new Date() : null);
|
|
172
|
+
|
|
173
|
+
if (!end) {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return end.getTime() - this.started_at.getTime();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Query the most recent task runs with optional filters. Centralized
|
|
182
|
+
* here so that both Chimera-side dashboards and MCP / API consumers can
|
|
183
|
+
* use the same paginated query - avoids forking the criteria.
|
|
184
|
+
*
|
|
185
|
+
* Options:
|
|
186
|
+
* type {string} Filter by task type_path (e.g. 'arcana.task.sync_harvest_clients')
|
|
187
|
+
* status {string} 'running' | 'done' | 'failed' | 'aborted'
|
|
188
|
+
* since {Date} Earliest started_at to include
|
|
189
|
+
* limit {number} Page size (1-100, default 25)
|
|
190
|
+
* offset {number} Skip count
|
|
191
|
+
*
|
|
192
|
+
* Returns `{rows, total}` where `total` is the unpaginated count.
|
|
193
|
+
*
|
|
194
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
195
|
+
* @since 1.4.2
|
|
196
|
+
*
|
|
197
|
+
* @param {Object} [options]
|
|
198
|
+
*
|
|
199
|
+
* @return {Promise<{rows: DocumentList, total: number}>}
|
|
200
|
+
*/
|
|
201
|
+
SystemTaskHistory.setMethod(async function findRecent(options) {
|
|
202
|
+
|
|
203
|
+
options = options || {};
|
|
204
|
+
|
|
205
|
+
let crit = this.find();
|
|
206
|
+
|
|
207
|
+
if (options.type) {
|
|
208
|
+
crit.where('type').equals(options.type);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (options.status) {
|
|
212
|
+
switch (options.status) {
|
|
213
|
+
case 'running':
|
|
214
|
+
crit.where('is_running').equals(true);
|
|
215
|
+
break;
|
|
216
|
+
|
|
217
|
+
case 'failed':
|
|
218
|
+
crit.where('had_error').equals(true);
|
|
219
|
+
break;
|
|
220
|
+
|
|
221
|
+
case 'done':
|
|
222
|
+
crit.where('is_running').equals(false);
|
|
223
|
+
crit.where('had_error').not().equals(true);
|
|
224
|
+
crit.where('ended_at').exists(true);
|
|
225
|
+
break;
|
|
226
|
+
|
|
227
|
+
case 'aborted':
|
|
228
|
+
crit.where('is_running').equals(false);
|
|
229
|
+
crit.where('had_error').not().equals(true);
|
|
230
|
+
crit.where('ended_at').isEmpty();
|
|
231
|
+
crit.where('started_at').exists(true);
|
|
232
|
+
break;
|
|
233
|
+
|
|
234
|
+
default:
|
|
235
|
+
throw new Error('Unknown task-run status filter: ' + options.status);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (options.since) {
|
|
240
|
+
crit.where('started_at').gte(options.since);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let limit = Math.min(Math.max(options.limit || 25, 1), 100);
|
|
244
|
+
let offset = Math.max(options.offset || 0, 0);
|
|
245
|
+
|
|
246
|
+
crit.sort({started_at: -1});
|
|
247
|
+
crit.setOption('available', true);
|
|
248
|
+
crit.limit(limit);
|
|
249
|
+
if (offset > 0) crit.skip(offset);
|
|
250
|
+
|
|
251
|
+
let rows = await this.find('all', crit);
|
|
252
|
+
return {rows, total: rows.available || 0};
|
|
119
253
|
});
|
package/lib/class/datasource.js
CHANGED
|
@@ -508,7 +508,7 @@ Datasource.setMethod(function read(context) {
|
|
|
508
508
|
|
|
509
509
|
model.emit('reading_datasource', criteria);
|
|
510
510
|
|
|
511
|
-
|
|
511
|
+
let read_pledge = Pledge.Swift.waterfall(
|
|
512
512
|
that._read(context),
|
|
513
513
|
_result => {
|
|
514
514
|
|
|
@@ -548,6 +548,18 @@ Datasource.setMethod(function read(context) {
|
|
|
548
548
|
}
|
|
549
549
|
);
|
|
550
550
|
});
|
|
551
|
+
|
|
552
|
+
// 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
|
|
554
|
+
if (cache_pledge) {
|
|
555
|
+
Pledge.Swift.done(read_pledge, (err) => {
|
|
556
|
+
if (err) {
|
|
557
|
+
cache_pledge.reject(err);
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return read_pledge;
|
|
551
563
|
});
|
|
552
564
|
|
|
553
565
|
/**
|
|
@@ -603,7 +615,7 @@ Datasource.setMethod(function update(context) {
|
|
|
603
615
|
|
|
604
616
|
let result = Swift.waterfall(
|
|
605
617
|
// Convert the data into something the datasource will understand
|
|
606
|
-
this.toDatasource(context),
|
|
618
|
+
() => this.toDatasource(context),
|
|
607
619
|
|
|
608
620
|
// Actually create the data
|
|
609
621
|
converted_data => this._update(context.setConvertedData(converted_data)),
|
package/lib/class/document.js
CHANGED
package/lib/class/inode_file.js
CHANGED
package/lib/class/model.js
CHANGED
|
@@ -390,7 +390,7 @@ Model.setStatic(async function checkPathValue(value, name, field_name, conduit)
|
|
|
390
390
|
if (result) {
|
|
391
391
|
let found_value = result[field_name];
|
|
392
392
|
|
|
393
|
-
if (found_value != value && !Object.alike(value, found_value)) {
|
|
393
|
+
if (conduit && found_value != value && !Object.alike(value, found_value)) {
|
|
394
394
|
conduit.rewriteRequestRouteParam(name, found_value);
|
|
395
395
|
}
|
|
396
396
|
}
|
|
@@ -534,7 +534,7 @@ Model.setStatic(function getField(name) {
|
|
|
534
534
|
if (name.indexOf('.') > -1) {
|
|
535
535
|
split = name.split('.');
|
|
536
536
|
|
|
537
|
-
alias =
|
|
537
|
+
alias = split[0];
|
|
538
538
|
|
|
539
539
|
if (this.schema.associations[alias] == null) {
|
|
540
540
|
model = this;
|
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
|
});
|
package/lib/class/router.js
CHANGED
|
@@ -1184,7 +1184,7 @@ Schema.setMethod(function addIndex(_field_or_name, _options) {
|
|
|
1184
1184
|
}
|
|
1185
1185
|
};
|
|
1186
1186
|
|
|
1187
|
-
if (typeof options.order == '
|
|
1187
|
+
if (typeof options.order == 'string') {
|
|
1188
1188
|
if (options.order == 'asc') {
|
|
1189
1189
|
options.order = 1;
|
|
1190
1190
|
} else {
|
package/lib/class/task.js
CHANGED
|
@@ -371,33 +371,51 @@ Task.setMethod(async function start(payload) {
|
|
|
371
371
|
let result;
|
|
372
372
|
|
|
373
373
|
try {
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
374
|
+
try {
|
|
375
|
+
result = await this.executor();
|
|
376
|
+
} catch (err) {
|
|
377
|
+
if (err == 'stopped') {
|
|
378
|
+
this.report('stopped', 'Stopped');
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Set the error
|
|
383
|
+
this.error = err;
|
|
384
|
+
|
|
385
|
+
// Report failed
|
|
386
|
+
let report = this.report('failed');
|
|
387
|
+
report.error = err;
|
|
388
|
+
|
|
389
|
+
// Surface the failure on the history document - without
|
|
390
|
+
// this, the row is left with `is_running: true` and no
|
|
391
|
+
// error info, looking identical to a still-running task.
|
|
392
|
+
document.had_error = true;
|
|
393
|
+
document.error_message = String(err && err.message || err);
|
|
394
|
+
document.error_stack = err && err.stack || null;
|
|
395
|
+
|
|
396
|
+
throw err;
|
|
379
397
|
}
|
|
380
398
|
|
|
381
|
-
//
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
399
|
+
// If the command hasn't been manually stopped,
|
|
400
|
+
// report it as done
|
|
401
|
+
if (!this.manual_stop_end) {
|
|
402
|
+
this.report('done');
|
|
403
|
+
}
|
|
404
|
+
} finally {
|
|
405
|
+
// Always close out the history document so it doesn't get
|
|
406
|
+
// stuck as a zombie "running forever" row.
|
|
407
|
+
document.ended_at = new Date();
|
|
408
|
+
document.is_running = false;
|
|
409
|
+
|
|
410
|
+
try {
|
|
411
|
+
await document.save();
|
|
412
|
+
} catch (save_err) {
|
|
413
|
+
// Don't let a history-save failure swallow the original
|
|
414
|
+
// executor error on its way out of the catch above.
|
|
415
|
+
alchemy.registerError(save_err);
|
|
416
|
+
}
|
|
395
417
|
}
|
|
396
418
|
|
|
397
|
-
document.ended_at = new Date();
|
|
398
|
-
document.is_running = false;
|
|
399
|
-
await document.save();
|
|
400
|
-
|
|
401
419
|
this[RUNNING_PLEDGE].resolve(result);
|
|
402
420
|
|
|
403
421
|
return result;
|
|
@@ -567,7 +585,7 @@ async function doAsyncLoopUntilNotBusy(max_tries) {
|
|
|
567
585
|
}
|
|
568
586
|
|
|
569
587
|
do {
|
|
570
|
-
|
|
588
|
+
log.info('Waiting for system to be less busy, try', tries);
|
|
571
589
|
await Pledge.after(500);
|
|
572
590
|
tries++;
|
|
573
591
|
} while (tries < max_tries && alchemy.isTooBusy());
|
package/lib/core/prefix.js
CHANGED
|
@@ -242,7 +242,7 @@ Connection.url = function url(connectionName, options) {
|
|
|
242
242
|
Connection.fill = function fill(url, params) {
|
|
243
243
|
|
|
244
244
|
if (params) {
|
|
245
|
-
for (paramName in params) {
|
|
245
|
+
for (let paramName in params) {
|
|
246
246
|
url = url.replace(':'+paramName, params[paramName]);
|
|
247
247
|
}
|
|
248
248
|
}
|
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);
|
package/lib/testing/harness.js
CHANGED
|
@@ -55,6 +55,12 @@ const TestHarness = Function.inherits('Informer', 'Alchemy.Testing', function Te
|
|
|
55
55
|
// Use mongo-unit for in-memory MongoDB (default: true)
|
|
56
56
|
use_mongo_unit: true,
|
|
57
57
|
|
|
58
|
+
// Extra options forwarded to mongo-unit's start() (e.g. `version`
|
|
59
|
+
// to pin the mongod binary, or `dbpath` to point at a tmpfs dir).
|
|
60
|
+
// `storageEngine` defaults to 'wiredTiger' in startMongo - see why
|
|
61
|
+
// there.
|
|
62
|
+
mongo_unit_options: {},
|
|
63
|
+
|
|
58
64
|
// Server port (default: random between 3470-3570)
|
|
59
65
|
port: 3470 + Math.floor(Math.random() * 100),
|
|
60
66
|
|
|
@@ -243,10 +249,21 @@ TestHarness.setMethod(async function startMongo() {
|
|
|
243
249
|
}
|
|
244
250
|
|
|
245
251
|
let MongoUnit = this.getMongoUnit();
|
|
246
|
-
|
|
252
|
+
|
|
253
|
+
// mongo-unit defaults its standalone storage engine to
|
|
254
|
+
// `ephemeralForTest`, which was removed in MongoDB 6.0 (and
|
|
255
|
+
// `inMemory` is Enterprise-only). Force `wiredTiger` so a modern
|
|
256
|
+
// mongod binary actually boots. Callers can still override it (or
|
|
257
|
+
// pin `version` / set `dbpath`) via `mongo_unit_options`.
|
|
258
|
+
let mongo_opts = Object.assign({
|
|
259
|
+
verbose : false,
|
|
260
|
+
storageEngine : 'wiredTiger',
|
|
261
|
+
}, this.options.mongo_unit_options);
|
|
262
|
+
|
|
263
|
+
this._mongo_uri = await MongoUnit.start(mongo_opts);
|
|
247
264
|
|
|
248
265
|
if (!this._mongo_uri) {
|
|
249
|
-
throw new Error('Failed to start mongo-unit');
|
|
266
|
+
throw new Error('Failed to start mongo-unit (no URI returned)');
|
|
250
267
|
}
|
|
251
268
|
|
|
252
269
|
return this._mongo_uri;
|
|
@@ -278,6 +295,15 @@ TestHarness.setMethod(function startServer() {
|
|
|
278
295
|
STAGES.getStage('datasource').addPostTask(() => {
|
|
279
296
|
Datasource.create('mongo', 'default', { uri: this._mongo_uri });
|
|
280
297
|
});
|
|
298
|
+
} else if (this.options.use_mongo_unit) {
|
|
299
|
+
// mongo-unit was requested but never produced a URI (startMongo
|
|
300
|
+
// failed or wasn't called). Refuse to start - otherwise the app
|
|
301
|
+
// silently falls back to whatever default datasource is
|
|
302
|
+
// configured, which can be a REAL database. That fallback is how
|
|
303
|
+
// a broken in-memory mongo ends up polluting (and reading stale
|
|
304
|
+
// data from) a live dev DB. Fail loud instead.
|
|
305
|
+
return reject(new Error('TestHarness: use_mongo_unit is enabled but no mongo URI is available '
|
|
306
|
+
+ '(did startMongo() fail?). Refusing to start the server to avoid connecting to a non-test database.'));
|
|
281
307
|
}
|
|
282
308
|
|
|
283
309
|
// Register additional module search paths
|
|
@@ -392,7 +418,15 @@ TestHarness.setMethod(async function stop() {
|
|
|
392
418
|
|
|
393
419
|
// Stop mongo-unit
|
|
394
420
|
if (this._mongo_unit && this._mongo_uri) {
|
|
395
|
-
|
|
421
|
+
|
|
422
|
+
// mongodb-memory-server kills a standalone mongod with SIGINT (then
|
|
423
|
+
// SIGKILL) rather than a clean admin shutdown. Under some mongod
|
|
424
|
+
// builds that triggers a coredump on the way down. Ask mongod to
|
|
425
|
+
// shut down cleanly first, so the subsequent signal hits an already
|
|
426
|
+
// gone process and short-circuits.
|
|
427
|
+
await this._gracefullyShutdownMongo(this._mongo_uri);
|
|
428
|
+
|
|
429
|
+
await this._mongo_unit.stop();
|
|
396
430
|
this._mongo_uri = null;
|
|
397
431
|
}
|
|
398
432
|
|
|
@@ -403,6 +437,54 @@ TestHarness.setMethod(async function stop() {
|
|
|
403
437
|
}
|
|
404
438
|
});
|
|
405
439
|
|
|
440
|
+
/**
|
|
441
|
+
* Ask a mongod to shut down cleanly via the admin command, so the
|
|
442
|
+
* subsequent signal-based kill from mongodb-memory-server hits an
|
|
443
|
+
* already-gone process. Best-effort: any failure is swallowed and we
|
|
444
|
+
* fall through to the normal stop path.
|
|
445
|
+
*
|
|
446
|
+
* @author Jelle De Loecker <jelle@elevenways.be>
|
|
447
|
+
* @since 1.4.2
|
|
448
|
+
*
|
|
449
|
+
* @param {string} uri
|
|
450
|
+
*
|
|
451
|
+
* @return {Promise}
|
|
452
|
+
*/
|
|
453
|
+
TestHarness.setMethod(async function _gracefullyShutdownMongo(uri) {
|
|
454
|
+
|
|
455
|
+
if (!uri) {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
let MongoClient;
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
MongoClient = require('mongodb').MongoClient;
|
|
463
|
+
} catch (err) {
|
|
464
|
+
// No mongodb driver available - nothing we can do, let the
|
|
465
|
+
// normal stop path handle it.
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
let client;
|
|
470
|
+
|
|
471
|
+
try {
|
|
472
|
+
client = await MongoClient.connect(uri, { directConnection: true });
|
|
473
|
+
try {
|
|
474
|
+
await client.db('admin').command({ shutdown: 1, force: true, timeoutSecs: 1 });
|
|
475
|
+
} catch (err) {
|
|
476
|
+
// mongod closes the connection mid-command while shutting down,
|
|
477
|
+
// surfacing as a network error. That means it worked - swallow.
|
|
478
|
+
}
|
|
479
|
+
} catch (err) {
|
|
480
|
+
// Couldn't reach mongod - fall through to the signal-based stop.
|
|
481
|
+
} finally {
|
|
482
|
+
if (client) {
|
|
483
|
+
try { await client.close(true); } catch (_) {}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
406
488
|
/**
|
|
407
489
|
* Get a full URL for a path
|
|
408
490
|
*
|
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.3",
|
|
5
5
|
"author": "Jelle De Loecker <jelle@elevenways.be>",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"alchemy",
|
|
@@ -63,8 +63,8 @@
|
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"codecov" : "~3.8.1",
|
|
65
65
|
"istanbul-lib-instrument" : "~6.0.1",
|
|
66
|
-
"mocha" : "
|
|
67
|
-
"mongo-unit" : "
|
|
66
|
+
"mocha" : "^11.7.5",
|
|
67
|
+
"mongo-unit" : "^3.4.0",
|
|
68
68
|
"nyc" : "^15.1.0",
|
|
69
69
|
"puppeteer" : "~21.3.6",
|
|
70
70
|
"source-map" : "~0.7.3"
|
|
@@ -79,4 +79,4 @@
|
|
|
79
79
|
"engines": {
|
|
80
80
|
"node" : ">=16.20.1"
|
|
81
81
|
}
|
|
82
|
-
}
|
|
82
|
+
}
|