alchemymvc 1.4.1 → 1.4.2

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.
@@ -158,7 +158,7 @@ Fallback.setMethod(function storeInUpperDatasource(model, data, options) {
158
158
  *
159
159
  * @author Jelle De Loecker <jelle@elevenways.be>
160
160
  * @since 1.1.0
161
- * @version 1.4.0
161
+ * @version 1.4.2
162
162
  *
163
163
  * @param {Alchemy.OperationalContext.ReadDocumentFromDatasource} context
164
164
  *
@@ -176,7 +176,7 @@ Fallback.setMethod(function read(context) {
176
176
  let lower_context = context.createChild();
177
177
  lower_context.setCriteria(lower_criteria);
178
178
 
179
- tasks.push(() => this.lower.read(context));
179
+ tasks.push(() => this.lower.read(lower_context));
180
180
  }
181
181
 
182
182
  let upper_criteria = criteria.clone();
@@ -195,7 +195,7 @@ Fallback.setMethod(function read(context) {
195
195
  *
196
196
  * @author Jelle De Loecker <jelle@elevenways.be>
197
197
  * @since 1.1.0
198
- * @version 1.1.0
198
+ * @version 1.4.2
199
199
  *
200
200
  * @param {Model} model
201
201
  *
@@ -204,21 +204,16 @@ Fallback.setMethod(function read(context) {
204
204
  Fallback.setMethod(function getRecordsToSync(model) {
205
205
 
206
206
  var that = this,
207
- pledge = new Pledge,
208
207
  criteria = model.find();
209
208
 
210
209
  criteria.where('_$needs_remote_save').equals(1);
211
210
 
212
- this.upper.read(model, criteria, function gotRecords(err, records) {
213
-
214
- if (err) {
215
- return pledge.reject(err);
216
- }
217
-
218
- pledge.resolve(records);
219
- });
211
+ let context = new Classes.Alchemy.OperationalContext.ReadDocumentFromDatasource();
212
+ context.setDatasource(this.upper);
213
+ context.setModel(model);
214
+ context.setCriteria(criteria);
220
215
 
221
- return pledge;
216
+ return this.upper.read(context);
222
217
  });
223
218
 
224
219
  /**
@@ -8,7 +8,7 @@
8
8
  * @version 1.1.0
9
9
  */
10
10
  var Time = Function.inherits('Alchemy.Field', function Time(schema, name, options) {
11
- Time.call(this, schema, name, options);
11
+ Time.super.call(this, schema, name, options);
12
12
  });
13
13
 
14
14
  /**
@@ -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
  });
@@ -508,7 +508,7 @@ Datasource.setMethod(function read(context) {
508
508
 
509
509
  model.emit('reading_datasource', criteria);
510
510
 
511
- return Pledge.Swift.waterfall(
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)),
@@ -427,7 +427,7 @@ Document.setMethod(function toHawkejs(wm) {
427
427
 
428
428
  result.setDataRecord(record, options);
429
429
  } else {
430
- record.$record = record;
430
+ result.$record = record;
431
431
  }
432
432
 
433
433
  // Clone $hold values if they are available
@@ -74,6 +74,8 @@ File.setStatic(function getMimetype(path) {
74
74
 
75
75
  pledge.resolve(result);
76
76
  });
77
+ } else {
78
+ pledge.resolve(File.guessMimetypeFromPath(path));
77
79
  }
78
80
 
79
81
  return pledge;
@@ -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 = name[0];
537
+ alias = split[0];
538
538
 
539
539
  if (this.schema.associations[alias] == null) {
540
540
  model = this;
@@ -466,7 +466,7 @@ Postponement.setMethod(function putInQueue() {
466
466
  return;
467
467
  }
468
468
 
469
- let new_length = QUEUE.push(this) - 1;
469
+ let new_length = QUEUE.push(this);
470
470
 
471
471
  this.last_queue_position = new_length - 1;
472
472
 
@@ -233,7 +233,8 @@ RouterClass.setMethod(function getFullMount() {
233
233
  RouterClass.setMethod(function getPrefix(path) {
234
234
 
235
235
  var prefix,
236
- begin;
236
+ begin,
237
+ key;
237
238
 
238
239
  // See if the path starts with any set prefix
239
240
  for (key in prefixes) {
@@ -1184,7 +1184,7 @@ Schema.setMethod(function addIndex(_field_or_name, _options) {
1184
1184
  }
1185
1185
  };
1186
1186
 
1187
- if (typeof options.order == 'number') {
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
- result = await this.executor();
375
- } catch (err) {
376
- if (err == 'stopped') {
377
- this.report('stopped', 'Stopped');
378
- return;
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
- // Set the error
382
- this.error = err;
383
-
384
- // Report failed
385
- let report = this.report('failed');
386
- report.error = err;
387
-
388
- throw err;
389
- }
390
-
391
- // If the command hasn't been manually stopped,
392
- // report it as done
393
- if (!this.manual_stop_end) {
394
- this.report('done');
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
- console.log('Waiting for system to be less busy', tries)
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());
@@ -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
  }
@@ -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
- this._mongo_uri = await MongoUnit.start({ verbose: false });
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
- this._mongo_unit.stop();
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.1",
4
+ "version": "1.4.2",
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" : "~10.2.0",
67
- "mongo-unit" : "~3.3.0",
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
+ }