alchemymvc 1.4.0 → 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.
Files changed (44) hide show
  1. package/lib/app/behaviour/revision_behaviour.js +1 -1
  2. package/lib/app/behaviour/sluggable_behaviour.js +2 -2
  3. package/lib/app/datasource/mongo_datasource.js +19 -3
  4. package/lib/app/helper/cron.js +2 -2
  5. package/lib/app/helper_datasource/00-nosql_datasource.js +9 -3
  6. package/lib/app/helper_datasource/05-fallback_datasource.js +10 -13
  7. package/lib/app/helper_datasource/idb_datasource.js +7 -5
  8. package/lib/app/helper_datasource/remote_datasource.js +1 -1
  9. package/lib/app/helper_field/password_field.js +4 -2
  10. package/lib/app/helper_field/schema_field.js +3 -2
  11. package/lib/app/helper_field/time_field.js +1 -1
  12. package/lib/app/helper_model/00-base_criteria.js +14 -0
  13. package/lib/app/helper_model/05-criteria_expressions.js +30 -7
  14. package/lib/app/helper_model/10-model_criteria.js +47 -8
  15. package/lib/app/helper_model/document.js +11 -2
  16. package/lib/app/helper_model/model.js +6 -3
  17. package/lib/app/model/system_task_history_model.js +134 -0
  18. package/lib/class/conduit.js +5 -2
  19. package/lib/class/controller.js +1 -0
  20. package/lib/class/datasource.js +14 -2
  21. package/lib/class/document.js +40 -12
  22. package/lib/class/import_stream_parser.js +299 -0
  23. package/lib/class/inode_file.js +2 -0
  24. package/lib/class/migration.js +5 -2
  25. package/lib/class/model.js +12 -142
  26. package/lib/class/plugin.js +32 -3
  27. package/lib/class/postponement.js +1 -1
  28. package/lib/class/router.js +26 -28
  29. package/lib/class/schema_client.js +39 -8
  30. package/lib/class/sitemap.js +2 -2
  31. package/lib/class/task.js +42 -24
  32. package/lib/core/alchemy.js +110 -162
  33. package/lib/core/alchemy_load_functions.js +64 -5
  34. package/lib/core/base.js +2 -2
  35. package/lib/core/middleware.js +31 -5
  36. package/lib/core/prefix.js +1 -1
  37. package/lib/core/setting.js +12 -9
  38. package/lib/scripts/create_constants.js +5 -1
  39. package/lib/stages/00-load_core.js +8 -2
  40. package/lib/testing/browser.js +1164 -0
  41. package/lib/testing/harness.js +922 -0
  42. package/package.json +13 -6
  43. package/testing/browser.js +27 -0
  44. package/testing.js +37 -0
@@ -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
  });
@@ -1825,7 +1825,7 @@ Conduit.setMethod(function end(message) {
1825
1825
  }
1826
1826
 
1827
1827
  // Use regular JSON if DRY has been disabled in settings
1828
- if (alchemy.settings.network.use_json_dry_response === false || this.json_dry === false) {
1828
+ if (alchemy.settings.network.use_json_dry_responses === false || this.json_dry === false) {
1829
1829
  json_type = 'json';
1830
1830
  json_fnc = JSON.stringify;
1831
1831
  } else {
@@ -2153,7 +2153,7 @@ Conduit.setTypedMethod([Types.String, Types.Object.optional()], function serveFi
2153
2153
  *
2154
2154
  * @author Jelle De Loecker <jelle@elevenways.be>
2155
2155
  * @since 0.2.0
2156
- * @version 1.4.0
2156
+ * @version 1.4.1
2157
2157
  *
2158
2158
  * @param {Alchemy.Inode.File} file The file to serve
2159
2159
  * @param {Object} options Options, including headers
@@ -2171,6 +2171,9 @@ Conduit.setTypedMethod([Types.Alchemy.Inode.File, Types.Object.optional()], asyn
2171
2171
  } catch (err) {
2172
2172
 
2173
2173
  if (err.code == 'ENOENT') {
2174
+ if (options.onError) {
2175
+ return options.onError(err);
2176
+ }
2174
2177
  return this.notFound(err);
2175
2178
  }
2176
2179
 
@@ -432,6 +432,7 @@ Controller.setAction(async function readDatasource(conduit) {
432
432
  user_id = this.getUserId();
433
433
 
434
434
  // @TODO: non-users should also be able to send criteria :/
435
+ // We allow everyone to do so for now, fix later.
435
436
  if (true || user_id) {
436
437
  conduit.body = JSON.undry(conduit.body);
437
438
  }
@@ -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
@@ -908,35 +908,63 @@ Document.setMethod(function exportToStream(output) {
908
908
  *
909
909
  * @author Jelle De Loecker <jelle@elevenways.be>
910
910
  * @since 1.0.5
911
- * @version 1.0.5
911
+ * @version 1.4.1
912
912
  *
913
913
  * @param {Buffer} buffer
914
914
  *
915
915
  * @return {Pledge}
916
916
  */
917
- Document.setMethod(function importFromBuffer(buffer) {
917
+ Document.setMethod(function importFromBuffer(buffer, options) {
918
918
 
919
919
  var that = this,
920
920
  pledge = new Pledge();
921
921
 
922
+ if (!options) {
923
+ options = {};
924
+ }
925
+
922
926
  zlib.gunzip(buffer, async function unzipped(err, data) {
923
927
 
924
928
  if (err) {
925
929
  return pledge.reject(err);
926
930
  }
927
931
 
928
- data = JSON.undry(data.toString());
932
+ try {
933
+ data = JSON.undry(data.toString());
929
934
 
930
- that.$main = data;
935
+ that.$main = data;
931
936
 
932
- await that.save(null, {
933
- validate : false,
934
- override_created : true,
935
- set_updated : false,
936
- importing : true
937
- });
937
+ let save_options = {
938
+ validate : false,
939
+ override_created : true,
940
+ set_updated : false,
941
+ importing : true,
942
+ };
938
943
 
939
- pledge.resolve();
944
+ // If replace_existing is true, don't force create (allow update)
945
+ // Otherwise, force INSERT
946
+ if (!options.replace_existing) {
947
+ save_options.create = true;
948
+ }
949
+
950
+ await that.save(null, save_options);
951
+
952
+ pledge.resolve();
953
+ } catch (save_err) {
954
+ // Ensure we have a proper Error object with a message
955
+ let error = save_err;
956
+
957
+ if (!error) {
958
+ error = new Error('Unknown error during document import');
959
+ } else if (!(error instanceof Error)) {
960
+ // If it's not an Error object, wrap it
961
+ let message = error.message || error.errmsg || String(error);
962
+ error = new Error('Import save failed: ' + message);
963
+ error.originalError = save_err;
964
+ }
965
+
966
+ pledge.reject(error);
967
+ }
940
968
  });
941
969
 
942
970
  return pledge;
@@ -0,0 +1,299 @@
1
+ const libstream = require('stream');
2
+
3
+ /**
4
+ * The ImportStreamParser class:
5
+ * Handles parsing of Alchemy's binary import stream format.
6
+ *
7
+ * Stream format:
8
+ * - 0x01 [1-byte size] [model name]: Model header
9
+ * - 0x02 [4-byte size BE] [document data]: Document data
10
+ * - 0xFF [4-byte size BE] [extra data]: Extra import data for current document
11
+ *
12
+ * @author Jelle De Loecker <jelle@elevenways.be>
13
+ * @since 1.4.1
14
+ * @version 1.4.1
15
+ */
16
+ const ImportStreamParser = Function.inherits('Alchemy.Base', function ImportStreamParser(input, options) {
17
+
18
+ // The input stream to parse
19
+ this.input = input;
20
+
21
+ // Options
22
+ this.options = options || {};
23
+
24
+ // The model resolver function - receives model name, returns Model instance
25
+ // Can throw an error to abort parsing
26
+ this.model_resolver = null;
27
+
28
+ // State machine variables
29
+ this.current_type = null;
30
+ this.extra_stream = null;
31
+ this.stopped = false;
32
+ this.paused = false;
33
+ this.buffer = null;
34
+ this.model = null;
35
+ this.value = null;
36
+ this.seen = 0;
37
+ this.left = 0;
38
+ this.size = 0;
39
+ this.doc = null;
40
+
41
+ // Track stream end and pending imports
42
+ this.stream_ended = false;
43
+ this.pending_import = false;
44
+
45
+ // The pledge that resolves when parsing is complete
46
+ this.pledge = new Pledge();
47
+ });
48
+
49
+ /**
50
+ * Set the model resolver function.
51
+ * Called when a 0x01 model header is encountered.
52
+ *
53
+ * The resolver receives (model_name, current_model) and should:
54
+ * - Return a Model instance to use for subsequent documents
55
+ * - Throw an error to abort parsing
56
+ *
57
+ * @author Jelle De Loecker <jelle@elevenways.be>
58
+ * @since 1.4.1
59
+ * @version 1.4.1
60
+ *
61
+ * @param {Function} resolver (model_name, current_model) => Model
62
+ */
63
+ ImportStreamParser.setMethod(function setModelResolver(resolver) {
64
+ this.model_resolver = resolver;
65
+ });
66
+
67
+ /**
68
+ * Start parsing the input stream.
69
+ * Returns a Pledge that resolves when parsing is complete.
70
+ *
71
+ * @author Jelle De Loecker <jelle@elevenways.be>
72
+ * @since 1.4.1
73
+ * @version 1.4.1
74
+ *
75
+ * @return {Pledge}
76
+ */
77
+ ImportStreamParser.setMethod(function parse() {
78
+
79
+ let that = this;
80
+
81
+ if (!this.model_resolver) {
82
+ return Pledge.reject(new Error('No model resolver has been set'));
83
+ }
84
+
85
+ this.input.on('data', function onData(data) {
86
+
87
+ if (that.stopped) {
88
+ return;
89
+ }
90
+
91
+ if (that.buffer) {
92
+ that.buffer = Buffer.concat([that.buffer, data]);
93
+ } else {
94
+ that.buffer = data;
95
+ }
96
+
97
+ that.handleBuffer();
98
+ });
99
+
100
+ this.input.on('end', function onEnd() {
101
+ that.stream_ended = true;
102
+
103
+ // Only resolve if we're not in the middle of importing a document
104
+ if (!that.stopped && !that.pending_import) {
105
+ that.pledge.resolve();
106
+ }
107
+ });
108
+
109
+ this.input.on('error', function onError(err) {
110
+ that.stopped = true;
111
+ that.pledge.reject(err);
112
+ });
113
+
114
+ return this.pledge;
115
+ });
116
+
117
+ /**
118
+ * Handle the current buffer data.
119
+ * Implements the state machine for parsing packet headers.
120
+ *
121
+ * State machine:
122
+ * - current_type = null: waiting for a new packet header
123
+ * - current_type = 0x01/0x02/0xFF: header parsed, processing payload
124
+ *
125
+ * We must NOT set current_type until we have the FULL header,
126
+ * otherwise a TCP chunk boundary could leave us in an invalid state.
127
+ *
128
+ * @author Jelle De Loecker <jelle@elevenways.be>
129
+ * @since 1.4.1
130
+ * @version 1.4.1
131
+ */
132
+ ImportStreamParser.setMethod(function handleBuffer() {
133
+
134
+ if (this.paused) {
135
+ return;
136
+ }
137
+
138
+ if (!this.current_type) {
139
+ // Need at least 1 byte to peek at the marker type
140
+ if (this.buffer.length < 1) {
141
+ return;
142
+ }
143
+
144
+ let marker = this.buffer.readUInt8(0);
145
+
146
+ if (marker == 0x01) {
147
+ // Type 0x01: 1-byte marker + 1-byte size = 2 bytes header
148
+ if (this.buffer.length < 2) {
149
+ return; // Wait for more data (don't consume the marker yet)
150
+ }
151
+ this.current_type = marker;
152
+ this.size = this.buffer.readUInt8(1);
153
+ this.buffer = this.buffer.slice(2);
154
+ } else if (marker == 0x02) {
155
+ // Type 0x02: 1-byte marker + 4-byte size = 5 bytes header
156
+ if (this.buffer.length < 5) {
157
+ return; // Wait for more data (don't consume the marker yet)
158
+ }
159
+ this.current_type = marker;
160
+ this.size = this.buffer.readUInt32BE(1);
161
+ this.buffer = this.buffer.slice(5);
162
+ } else if (marker == 0xFF) {
163
+ // Type 0xFF: 1-byte marker + 4-byte size = 5 bytes header
164
+ if (this.buffer.length < 5) {
165
+ return; // Wait for more data (don't consume the marker yet)
166
+ }
167
+ this.current_type = marker;
168
+ this.size = this.buffer.readUInt32BE(1);
169
+ this.buffer = this.buffer.slice(5);
170
+ this.seen = 0;
171
+
172
+ if (!this.doc) {
173
+ this.stopped = true;
174
+ this.pledge.reject(new Error('Found extra import data, but no active document'));
175
+ } else {
176
+ this.extra_stream = new libstream.PassThrough();
177
+ this.doc.extraImportFromStream(this.extra_stream);
178
+ }
179
+ } else {
180
+ // Unknown marker - this shouldn't happen in valid data
181
+ this.stopped = true;
182
+ this.pledge.reject(new Error('Unknown marker byte: 0x' + marker.toString(16)));
183
+ return;
184
+ }
185
+ }
186
+
187
+ this.handlePayload();
188
+ });
189
+
190
+ /**
191
+ * Handle the payload data after a header has been parsed.
192
+ *
193
+ * @author Jelle De Loecker <jelle@elevenways.be>
194
+ * @since 1.4.1
195
+ * @version 1.4.1
196
+ */
197
+ ImportStreamParser.setMethod(function handlePayload() {
198
+
199
+ let that = this;
200
+
201
+ // Handle extra data streaming (0xFF)
202
+ if (this.current_type == 0xFF) {
203
+ this.left = this.size - this.seen;
204
+ this.value = this.buffer.slice(0, this.left);
205
+
206
+ this.seen += this.value.length;
207
+
208
+ if (this.value.length == this.buffer.length) {
209
+ this.buffer = null;
210
+ } else if (this.value.length < this.buffer.length) {
211
+ this.buffer = this.buffer.slice(this.left);
212
+ }
213
+
214
+ this.extra_stream.write(this.value);
215
+
216
+ if (this.value.length == this.left) {
217
+ this.extra_stream.end();
218
+ this.current_type = null;
219
+
220
+ if (this.buffer) {
221
+ this.handleBuffer();
222
+ }
223
+ }
224
+
225
+ return;
226
+ }
227
+
228
+ // Wait for full payload
229
+ if (this.buffer.length >= this.size) {
230
+ this.value = this.buffer.slice(0, this.size);
231
+ this.buffer = this.buffer.slice(this.size);
232
+ } else {
233
+ // Wait for next call
234
+ return;
235
+ }
236
+
237
+ // Handle model header (0x01)
238
+ if (this.current_type == 0x01) {
239
+ let model_name = this.value.toString();
240
+
241
+ try {
242
+ this.model = this.model_resolver(model_name, this.model);
243
+ this.doc = null;
244
+ } catch (err) {
245
+ this.stopped = true;
246
+ return this.pledge.reject(err);
247
+ }
248
+
249
+ if (!this.model) {
250
+ this.stopped = true;
251
+ return this.pledge.reject(new Error('Model resolver returned no model for "' + model_name + '"'));
252
+ }
253
+
254
+ this.current_type = null;
255
+ this.size = 0;
256
+ }
257
+ // Handle document data (0x02)
258
+ else if (this.current_type == 0x02) {
259
+ this.doc = this.model.createDocument();
260
+ this.input.pause();
261
+ this.paused = true;
262
+ this.pending_import = true;
263
+
264
+ this.doc.importFromBuffer(this.value, this.options).done(function done(err, result) {
265
+
266
+ that.pending_import = false;
267
+
268
+ if (err) {
269
+ that.stopped = true;
270
+ return that.pledge.reject(err);
271
+ }
272
+
273
+ that.current_type = null;
274
+ that.paused = false;
275
+
276
+ // Check if there's more data to process
277
+ if (that.buffer && that.buffer.length > 0) {
278
+ that.input.resume();
279
+ that.handleBuffer();
280
+ return;
281
+ }
282
+
283
+ // No more data in buffer
284
+ that.input.resume();
285
+
286
+ // If the stream has ended and there's no more data, resolve
287
+ if (that.stream_ended && !that.stopped) {
288
+ that.pledge.resolve();
289
+ }
290
+ });
291
+
292
+ return;
293
+ }
294
+
295
+ // Continue processing remaining buffer
296
+ if (this.buffer && this.buffer.length) {
297
+ this.handleBuffer();
298
+ }
299
+ });
@@ -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;
@@ -20,7 +20,7 @@ const Migration = Function.inherits('Alchemy.Base', function Migration(document)
20
20
  *
21
21
  * @author Jelle De Loecker <jelle@elevenways.be>
22
22
  * @since 1.2.0
23
- * @version 1.2.0
23
+ * @version 1.4.1
24
24
  */
25
25
  Migration.setStatic(async function start() {
26
26
 
@@ -32,7 +32,10 @@ Migration.setStatic(async function start() {
32
32
 
33
33
  await dir.loadContents();
34
34
 
35
- for (let entry of dir) {
35
+ // Sort entries alphabetically so numbered prefixes (001_, 002_) run in order
36
+ let entries = [...dir].sort((a, b) => a.name.localeCompare(b.name));
37
+
38
+ for (let entry of entries) {
36
39
 
37
40
  let name = entry.name.beforeLast('.js');
38
41