smart-home-engine 0.18.0 → 0.19.0

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.
@@ -1,4 +1,4 @@
1
- import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-CygE8hzz.js";/*!-----------------------------------------------------------------------------
1
+ import{m as O}from"./monaco-langs-BW2J83t5.js";import{t as I}from"./index-xPpby8cF.js";/*!-----------------------------------------------------------------------------
2
2
  * Copyright (c) Microsoft Corporation. All rights reserved.
3
3
  * Version: 0.52.2(404545bded1df6ffa41ea0af4e8ddb219018c6c1)
4
4
  * Released under the MIT license
@@ -155,10 +155,10 @@
155
155
  }
156
156
  })();
157
157
  </script>
158
- <script type="module" crossorigin src="/assets/index-CygE8hzz.js"></script>
158
+ <script type="module" crossorigin src="/assets/index-xPpby8cF.js"></script>
159
159
  <link rel="modulepreload" crossorigin href="/assets/monaco-langs-BW2J83t5.js">
160
160
  <link rel="stylesheet" crossorigin href="/assets/monaco-langs-DyX1CsEw.css">
161
- <link rel="stylesheet" crossorigin href="/assets/index-ZA04d_Jz.css">
161
+ <link rel="stylesheet" crossorigin href="/assets/index-CtuJWQQl.css">
162
162
  </head>
163
163
  <body>
164
164
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smart-home-engine",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Node.js based script runner for use in MQTT based Smart Home environments",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -392,7 +392,7 @@ function stateChange(topic, state, oldState, msg) {
392
392
  const match = mqttWildcards(topic, subs.topic);
393
393
 
394
394
  if (match && typeof options.condition === 'function') {
395
- if (!options.condition(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), state.val, state, oldState, msg)) {
395
+ if (!options.condition(topic, state.val, state, oldState, msg)) {
396
396
  return;
397
397
  }
398
398
  }
@@ -418,13 +418,13 @@ function stateChange(topic, state, oldState, msg) {
418
418
  setTimeout(() => {
419
419
  /**
420
420
  * @callback subscribeCallback
421
- * @param {string} topic - the topic that triggered this callback. +/status/# will be replaced by +//#
421
+ * @param {string} topic - the topic that triggered this callback
422
422
  * @param {mixed} val - the val property of the new state
423
423
  * @param {object} obj - new state - the whole state object (e.g. {"val": true, "ts": 12346345, "lc": 12346345} )
424
424
  * @param {object} objPrev - previous state - the whole state object
425
425
  * @param {object} msg - the mqtt message as received from MQTT.js
426
426
  */
427
- subs.callback(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), state.val, state, oldState, msg);
427
+ subs.callback(topic, state.val, state, oldState, msg);
428
428
  }, delay);
429
429
  }
430
430
  });
@@ -564,8 +564,6 @@ function runScript(script, name, origin) {
564
564
  }
565
565
 
566
566
  if (typeof topic === 'string') {
567
- topic = topic.replace(/^([^/]+)\/\//, '$1/status/');
568
-
569
567
  if (typeof options.condition === 'string') {
570
568
  if (options.condition.indexOf('\n') !== -1) {
571
569
  throw new Error('options.condition string must be one-line javascript');
@@ -581,11 +579,11 @@ function runScript(script, name, origin) {
581
579
  subscriptions.push({ topic, options, callback: typeof callback === 'function' && scriptDomain.bind(callback), _script: name });
582
580
 
583
581
  if (options.retain && store.has('mqtt::' + topic) && typeof callback === 'function') {
584
- callback(topic.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), store.get('mqtt::' + topic), store.getObject('mqtt::' + topic));
582
+ callback(topic, store.get('mqtt::' + topic), store.getObject('mqtt::' + topic));
585
583
  } else if (options.retain && (/\/\+\//.test(topic) || /\+$/.test(topic) || /\+/.test(topic) || topic.endsWith('#')) && typeof callback === 'function') {
586
584
  for (const [t, obj] of store.mqttEntries()) {
587
585
  if (mqttWildcards(t, topic)) {
588
- callback(t.replace(/^([^/]+)\/status\/(.+)/, '$1//$2'), obj.val, obj);
586
+ callback(t, obj.val, obj);
589
587
  }
590
588
  }
591
589
  }
@@ -728,7 +726,6 @@ function runScript(script, name, origin) {
728
726
  she.mqttpub(topic, val, { retain: false });
729
727
  }
730
728
  } else {
731
- topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/set/$2');
732
729
  she.mqttpub(topic, val, { retain: false });
733
730
  }
734
731
  },
@@ -739,7 +736,6 @@ function runScript(script, name, origin) {
739
736
  * @returns {mixed} the topics value
740
737
  */
741
738
  getValue: function Sandbox_getValue(topic) {
742
- topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
743
739
  return store.get('mqtt::' + topic);
744
740
  },
745
741
 
@@ -753,7 +749,6 @@ function runScript(script, name, origin) {
753
749
  * she.getProp('hm//Bewegungsmelder Keller/MOTION', 'ts');
754
750
  */
755
751
  getProp: function Sandbox_getProp(topic, ...props) {
756
- topic = topic.replace(/^([^/]+)\/\/(.+)$/, '$1/status/$2');
757
752
  if (props.length > 0) {
758
753
  let tmp = store.getObject('mqtt::' + topic);
759
754
  if (typeof tmp === 'undefined') {
@@ -1143,9 +1138,35 @@ function isLibFile(absFile, scriptRoot) {
1143
1138
  return false;
1144
1139
  }
1145
1140
 
1141
+ /**
1142
+ * Returns true if a .shedisable-<name> sibling marker exists for the given path.
1143
+ * Works for both files and directories.
1144
+ */
1145
+ function isDisabledPath(absPath) {
1146
+ const dir = path.dirname(path.resolve(absPath));
1147
+ const name = path.basename(absPath);
1148
+ return fs.existsSync(path.join(dir, `.shedisable-${name}`));
1149
+ }
1150
+
1151
+ /**
1152
+ * Returns true if any ancestor directory of absFile (between it and scriptRoot)
1153
+ * has a .shedisable-<dirname> sibling in its parent.
1154
+ */
1155
+ function isInDisabledDir(absFile, scriptRoot) {
1156
+ const root = path.resolve(scriptRoot);
1157
+ let dir = path.dirname(path.resolve(absFile));
1158
+ while (dir.length > root.length && dir.startsWith(root)) {
1159
+ if (isDisabledPath(dir)) return true;
1160
+ const parent = path.dirname(dir);
1161
+ if (parent === dir) break;
1162
+ dir = parent;
1163
+ }
1164
+ return false;
1165
+ }
1166
+
1146
1167
  /**
1147
1168
  * Recursively load all user .js scripts from a directory tree.
1148
- * Files inside directories that contain a .shelib marker are skipped.
1169
+ * Files/dirs with a .shedisable-<name> sibling, and files inside .shelib dirs, are skipped.
1149
1170
  */
1150
1171
  function loadDirRecursive(dir, scriptRoot) {
1151
1172
  let entries;
@@ -1158,10 +1179,11 @@ function loadDirRecursive(dir, scriptRoot) {
1158
1179
  entries
1159
1180
  .sort((a, b) => a.name.localeCompare(b.name))
1160
1181
  .forEach((entry) => {
1182
+ if (entry.name.startsWith('.shedisable-')) return; // skip marker files
1161
1183
  const abs = path.join(dir, entry.name);
1162
1184
  if (entry.isDirectory()) {
1163
- loadDirRecursive(abs, scriptRoot);
1164
- } else if (entry.name.endsWith('.js') && !isLibFile(abs, scriptRoot)) {
1185
+ if (!isDisabledPath(abs)) loadDirRecursive(abs, scriptRoot);
1186
+ } else if (entry.name.endsWith('.js') && !isLibFile(abs, scriptRoot) && !isDisabledPath(abs)) {
1165
1187
  loadScript(abs.replace(/\\/g, '/'));
1166
1188
  }
1167
1189
  });
@@ -1175,6 +1197,7 @@ function loadDir(dir) {
1175
1197
  const dirWatcher = chokidar.watch(dir, {
1176
1198
  ignored: (p, stats) => {
1177
1199
  const name = path.basename(p);
1200
+ if (name.startsWith('.shedisable-')) return false; // always watch disable markers
1178
1201
  return stats?.isFile() && !name.endsWith('.js') && name !== '.shelib';
1179
1202
  },
1180
1203
  persistent: true,
@@ -1186,17 +1209,49 @@ function loadDir(dir) {
1186
1209
  filePath = filePath.replace(/\\/g, '/');
1187
1210
  const basename = path.basename(filePath);
1188
1211
 
1189
- // .shelib marker changes — warn only, manual restart required
1212
+ // .shedisable-<name> marker changes - hot-unload or hot-reload the target
1213
+ if (basename.startsWith('.shedisable-')) {
1214
+ const targetName = basename.slice('.shedisable-'.length);
1215
+ const targetPath = path.join(path.dirname(filePath), targetName).replace(/\\/g, '/');
1216
+ if (event === 'add') {
1217
+ if (targetName.endsWith('.js')) {
1218
+ if (scripts[targetPath]) {
1219
+ log.info(targetPath, 'disabled. unloading.');
1220
+ unloadScript(targetPath);
1221
+ }
1222
+ } else {
1223
+ const absTarget = path.resolve(targetPath);
1224
+ Object.keys(scripts).forEach((scriptFile) => {
1225
+ if (path.resolve(scriptFile).startsWith(absTarget + path.sep)) {
1226
+ log.info(scriptFile, 'directory disabled. unloading.');
1227
+ unloadScript(scriptFile);
1228
+ }
1229
+ });
1230
+ }
1231
+ } else if (event === 'unlink') {
1232
+ if (targetName.endsWith('.js')) {
1233
+ if (fs.existsSync(targetPath) && !isLibFile(targetPath, dir) && !scripts[targetPath]) {
1234
+ log.info(targetPath, 're-enabled. loading.');
1235
+ loadScript(targetPath);
1236
+ }
1237
+ } else {
1238
+ if (fs.existsSync(targetPath)) loadDirRecursive(targetPath, path.resolve(dir));
1239
+ }
1240
+ }
1241
+ return;
1242
+ }
1243
+
1244
+ // .shelib marker changes - warn only, manual restart required
1190
1245
  if (basename === '.shelib') {
1191
1246
  if (event === 'add') {
1192
- log.warn(filePath, 'library marker added — .js files in this directory will no longer load as scripts after daemon restart');
1247
+ log.warn(filePath, 'library marker added - .js files in this directory will no longer load as scripts after daemon restart');
1193
1248
  } else if (event === 'unlink') {
1194
- log.warn(filePath, 'library marker removed — .js files in this directory will load as scripts after daemon restart');
1249
+ log.warn(filePath, 'library marker removed - .js files in this directory will load as scripts after daemon restart');
1195
1250
  }
1196
1251
  return;
1197
1252
  }
1198
1253
 
1199
- // Directory events — handle gracefully (no process.exit)
1254
+ // Directory events - handle gracefully (no process.exit)
1200
1255
  if (event === 'addDir') return;
1201
1256
 
1202
1257
  if (event === 'unlinkDir') {
@@ -1213,7 +1268,11 @@ function loadDir(dir) {
1213
1268
 
1214
1269
  if (event === 'change' && filePath.endsWith('.js')) {
1215
1270
  if (isLibFile(filePath, dir)) {
1216
- log.warn(filePath, 'is a library file — scripts that require() it will see the old version until they or the daemon are restarted');
1271
+ log.warn(filePath, 'is a library file - scripts that require() it will see the old version until they or the daemon are restarted');
1272
+ return;
1273
+ }
1274
+ if (isDisabledPath(filePath) || isInDisabledDir(filePath, dir)) {
1275
+ log.debug(filePath, 'is disabled - ignoring change');
1217
1276
  return;
1218
1277
  }
1219
1278
  log.info(filePath, 'change detected. hot-reloading.');
@@ -1221,7 +1280,11 @@ function loadDir(dir) {
1221
1280
  loadScript(filePath);
1222
1281
  } else if (event === 'add' && filePath.endsWith('.js')) {
1223
1282
  if (isLibFile(filePath, dir)) {
1224
- log.debug(filePath, 'is a library file — not loading as script');
1283
+ log.debug(filePath, 'is a library file - not loading as script');
1284
+ return;
1285
+ }
1286
+ if (isDisabledPath(filePath) || isInDisabledDir(filePath, dir)) {
1287
+ log.debug(filePath, 'is disabled - not loading as script');
1225
1288
  return;
1226
1289
  }
1227
1290
  log.info(filePath, 'added. loading.');
@@ -1235,7 +1298,6 @@ function loadDir(dir) {
1235
1298
  });
1236
1299
  }
1237
1300
  }
1238
-
1239
1301
  function start() {
1240
1302
  if (config.file) {
1241
1303
  if (typeof config.file === 'string') {
@@ -91,7 +91,7 @@ function deepExtend(target, source) {
91
91
  class SheDBCore extends EventEmitter {
92
92
  /**
93
93
  * @param {object} opts
94
- * @param {string} opts.dbPath - absolute path to the JSON data file
94
+ * @param {string} opts.dbPath - path to the directory that holds docs.json and views.json
95
95
  * @param {object} opts.log - pino-compatible logger
96
96
  * @param {number} [opts.scriptTimeout=5000] - vm script timeout in ms
97
97
  */
@@ -112,7 +112,9 @@ class SheDBCore extends EventEmitter {
112
112
 
113
113
  this._viewEnvs = {}; // id → { compileError? } — only syntax-check metadata
114
114
  this._saveTimer = null;
115
- this._sendDbScheduled = false;
115
+ this._saveViewsTimer = null;
116
+ this._docsPath = path.join(dbPath, 'docs.json');
117
+ this._viewsPath = path.join(dbPath, 'views.json');
116
118
  this._worker = null;
117
119
 
118
120
  this._spawnWorker();
@@ -125,15 +127,29 @@ class SheDBCore extends EventEmitter {
125
127
 
126
128
  _load() {
127
129
  try {
128
- const data = JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
130
+ fs.mkdirSync(this.dbPath, { recursive: true });
131
+ } catch (err) {
132
+ this.log.error('shedb: could not create directory ' + this.dbPath + ': ' + err.message);
133
+ }
134
+
135
+ try {
136
+ const data = JSON.parse(fs.readFileSync(this._docsPath, 'utf8'));
129
137
  this.rev = data.rev || 0;
130
138
  this.docs = data.docs || {};
131
139
  this.queries = data.queries || {};
132
- this.views = data.views || {};
133
140
  this.log.info('shedb loaded ' + Object.keys(this.docs).length + ' docs, ' + Object.keys(this.queries).length + ' views from ' + this.dbPath);
134
141
  } catch (err) {
135
142
  if (err.code !== 'ENOENT') {
136
- this.log.warn('shedb: could not load ' + this.dbPath + ': ' + err.message);
143
+ this.log.warn('shedb: could not load ' + this._docsPath + ': ' + err.message);
144
+ }
145
+ }
146
+
147
+ try {
148
+ const vdata = JSON.parse(fs.readFileSync(this._viewsPath, 'utf8'));
149
+ this.views = vdata.views || {};
150
+ } catch (err) {
151
+ if (err.code !== 'ENOENT') {
152
+ this.log.warn('shedb: could not load views from ' + this._viewsPath + ': ' + err.message);
137
153
  }
138
154
  }
139
155
 
@@ -149,16 +165,29 @@ class SheDBCore extends EventEmitter {
149
165
  _save() {
150
166
  clearTimeout(this._saveTimer);
151
167
  this._saveTimer = setTimeout(() => {
152
- const tmp = this.dbPath + '.tmp';
168
+ const tmp = this._docsPath + '.tmp';
153
169
  try {
154
- fs.writeFileSync(tmp, JSON.stringify({ rev: this.rev, docs: this.docs, queries: this.queries, views: this.views }), 'utf8');
155
- fs.renameSync(tmp, this.dbPath); // atomic on Linux (same FS)
170
+ fs.writeFileSync(tmp, JSON.stringify({ rev: this.rev, docs: this.docs, queries: this.queries }), 'utf8');
171
+ fs.renameSync(tmp, this._docsPath); // atomic on Linux (same FS)
156
172
  } catch (err) {
157
173
  this.log.error('shedb: save failed: ' + err.message);
158
174
  }
159
175
  }, 250);
160
176
  }
161
177
 
178
+ _saveViews() {
179
+ clearTimeout(this._saveViewsTimer);
180
+ this._saveViewsTimer = setTimeout(() => {
181
+ const tmp = this._viewsPath + '.tmp';
182
+ try {
183
+ fs.writeFileSync(tmp, JSON.stringify({ views: this.views }), 'utf8');
184
+ fs.renameSync(tmp, this._viewsPath);
185
+ } catch (err) {
186
+ this.log.error('shedb: save views failed: ' + err.message);
187
+ }
188
+ }, 250);
189
+ }
190
+
162
191
  // -------------------------------------------------------------------------
163
192
  // Rev tracking — mirrors mqttDB pattern exactly
164
193
  // -------------------------------------------------------------------------
@@ -215,7 +244,7 @@ class SheDBCore extends EventEmitter {
215
244
  this.rev++;
216
245
  this._save();
217
246
  this.emit('update', id, this.docs[id]);
218
- this._sendDb();
247
+ this._sendPatch(id, this.docs[id]);
219
248
  return true;
220
249
  }
221
250
  this._setRev(id, rev);
@@ -244,7 +273,7 @@ class SheDBCore extends EventEmitter {
244
273
  this.rev++;
245
274
  this._save();
246
275
  this.emit('update', id, this.docs[id]);
247
- this._sendDb();
276
+ this._sendPatch(id, this.docs[id]);
248
277
  return true;
249
278
  }
250
279
  this._setRev(id, rev);
@@ -256,7 +285,7 @@ class SheDBCore extends EventEmitter {
256
285
  this.rev++;
257
286
  this._save();
258
287
  this.emit('update', id, '');
259
- this._sendDb();
288
+ this._sendPatch(id, null);
260
289
  }
261
290
 
262
291
  /**
@@ -287,7 +316,7 @@ class SheDBCore extends EventEmitter {
287
316
  this.rev++;
288
317
  this._save();
289
318
  this.emit('update', id, this.docs[id]);
290
- this._sendDb();
319
+ this._sendPatch(id, this.docs[id]);
291
320
  return true;
292
321
  }
293
322
  this._setRev(id, rev);
@@ -309,6 +338,7 @@ class SheDBCore extends EventEmitter {
309
338
  delete this._viewEnvs[id];
310
339
  delete this.views[id];
311
340
  this._save();
341
+ this._saveViews();
312
342
  if (this._worker) this._worker.postMessage({ type: 'delQuery', id });
313
343
  this.emit('view', id, '');
314
344
  return;
@@ -362,7 +392,7 @@ class SheDBCore extends EventEmitter {
362
392
  delete this.views[id].result;
363
393
  this.log.error('shedb view ' + id + ': ' + msg.error);
364
394
  this.emit('view', id, this.views[id]);
365
- this._save();
395
+ this._saveViews();
366
396
  return;
367
397
  }
368
398
 
@@ -370,7 +400,7 @@ class SheDBCore extends EventEmitter {
370
400
  this.views[id] = { _id: id, _rev: (prev._rev ?? -1) + 1, result: msg.result, length: msg.result.length };
371
401
  delete this.views[id].error;
372
402
  this.emit('view', id, this.views[id]);
373
- this._save();
403
+ this._saveViews();
374
404
  }
375
405
  });
376
406
 
@@ -390,22 +420,20 @@ class SheDBCore extends EventEmitter {
390
420
  });
391
421
  }
392
422
 
393
- /** Send full docs snapshot to the worker (debounced via setImmediate). */
394
- _sendDb() {
395
- if (this._sendDbScheduled) return;
396
- this._sendDbScheduled = true;
397
- setImmediate(() => {
398
- this._sendDbScheduled = false;
399
- if (this._worker) {
400
- this._worker.postMessage({ type: 'db', docs: structuredClone(this.docs) });
401
- }
402
- });
423
+ /** Send a single-document patch (or deletion) to the worker — O(1). */
424
+ _sendPatch(id, doc) {
425
+ if (!this._worker) return;
426
+ if (doc) {
427
+ this._worker.postMessage({ type: 'patch', id, doc: structuredClone(doc) });
428
+ } else {
429
+ this._worker.postMessage({ type: 'del', id });
430
+ }
403
431
  }
404
432
 
405
- /** Send full state (docs + all queries) to the worker — used on init and respawn. */
433
+ /** Send full state (docs + all queries) to the worker — used on init and respawn only. */
406
434
  _sendInitialState() {
407
435
  if (!this._worker) return;
408
- this._worker.postMessage({ type: 'db', docs: structuredClone(this.docs) });
436
+ this._worker.postMessage({ type: 'init', docs: structuredClone(this.docs) });
409
437
  for (const id of Object.keys(this.queries)) {
410
438
  this._worker.postMessage({ type: 'query', id, payload: this.queries[id] });
411
439
  }
@@ -7,8 +7,10 @@
7
7
  *
8
8
  * Message protocol:
9
9
  * Main → Worker:
10
- * { type: 'db', docs: {...} } full docs snapshot
11
- * { type: 'query', id, payload: {filter?,map,reduce?} }
10
+ * { type: 'init', docs: {...} } full snapshot (startup/respawn only)
11
+ * { type: 'patch', id, doc } single document created/updated
12
+ * { type: 'del', id } single document deleted
13
+ * { type: 'query', id, payload: {filter?,map,reduce?} }
12
14
  * { type: 'delQuery', id }
13
15
  *
14
16
  * Worker → Main:
@@ -25,7 +27,7 @@ const TIMEOUT = (workerData && workerData.scriptTimeout) || 5000;
25
27
 
26
28
  let docs = {};
27
29
  const queries = {};
28
- let queue = [];
30
+ let queue = new Set();
29
31
  let running = false;
30
32
 
31
33
  // ---------------------------------------------------------------------------
@@ -37,22 +39,31 @@ function getProp(obj, propPath) {
37
39
  return propPath.split('.').reduce((cur, k) => (cur != null ? cur[k] : undefined), obj);
38
40
  }
39
41
 
42
+ // Enqueue only the views whose filter matches the changed doc id.
43
+ // Views with no filter must always re-run (they iterate all docs).
44
+ function enqueueForDoc(docId) {
45
+ for (const [id, q] of Object.entries(queries)) {
46
+ if (!q.filter || mqttWildcard(docId, q.filter)) enqueue(id);
47
+ }
48
+ }
49
+
40
50
  // ---------------------------------------------------------------------------
41
51
  // View queue
42
52
  // ---------------------------------------------------------------------------
43
53
 
44
54
  function enqueue(id) {
45
- if (!queue.includes(id)) queue.push(id);
55
+ queue.add(id);
46
56
  if (!running) scheduleNext();
47
57
  }
48
58
 
49
59
  function scheduleNext() {
50
- if (queue.length === 0) {
60
+ if (queue.size === 0) {
51
61
  running = false;
52
62
  return;
53
63
  }
54
64
  running = true;
55
- const id = queue.shift();
65
+ const id = queue.values().next().value;
66
+ queue.delete(id);
56
67
  setImmediate(() => buildAndRun(id));
57
68
  }
58
69
 
@@ -63,20 +74,7 @@ function buildAndRun(id) {
63
74
  return;
64
75
  }
65
76
 
66
- const { filter, map, reduce } = q;
67
-
68
- // Build script source — same structure as the original in-process approach
69
- let src = `api.map = function() {\n${map}\n};\napi._result = [];\n`;
70
- if (filter) {
71
- src += `api.forEachDocument(docId => { if (api.mqttWildcard(docId, ${JSON.stringify(filter)})) api.map.apply(api.getDocument(docId)); });\n`;
72
- } else {
73
- src += `api.forEachDocument(docId => { api.map.apply(api.getDocument(docId)); });\n`;
74
- }
75
- if (reduce) {
76
- src += `api.reduce = function(result) {\n${reduce}\n};\napi._result = api.reduce(api._result);\n`;
77
- }
78
-
79
- // Sandbox — uses the local docs snapshot
77
+ // Sandbox uses the live docs object (updated by patch/del messages)
80
78
  const sandbox = {
81
79
  api: {
82
80
  forEachDocument: (cb) => Object.keys(docs).forEach(cb),
@@ -89,9 +87,8 @@ function buildAndRun(id) {
89
87
  sandbox.emit = (item) => sandbox.api._result.push(item);
90
88
 
91
89
  try {
92
- const script = new vm.Script(src, { filename: 'shedb-view-' + id });
93
90
  const ctx = vm.createContext(sandbox);
94
- script.runInContext(ctx, { timeout: TIMEOUT });
91
+ q.script.runInContext(ctx, { timeout: TIMEOUT });
95
92
  parentPort.postMessage({ type: 'view', id, result: Array.from(ctx.api._result) });
96
93
  } catch (err) {
97
94
  parentPort.postMessage({ type: 'view', id, error: 'runtime: ' + err.message });
@@ -106,16 +103,44 @@ function buildAndRun(id) {
106
103
 
107
104
  parentPort.on('message', (msg) => {
108
105
  switch (msg.type) {
109
- case 'db':
106
+ case 'init':
110
107
  docs = msg.docs;
111
- // Re-run all registered views with the new docs snapshot
108
+ // Re-run all registered views with the fresh snapshot
112
109
  for (const id of Object.keys(queries)) enqueue(id);
113
110
  break;
114
111
 
115
- case 'query':
116
- queries[msg.id] = msg.payload;
112
+ case 'patch':
113
+ docs[msg.id] = msg.doc;
114
+ enqueueForDoc(msg.id);
115
+ break;
116
+
117
+ case 'del':
118
+ delete docs[msg.id];
119
+ enqueueForDoc(msg.id);
120
+ break;
121
+
122
+ case 'query': {
123
+ const { filter, map, reduce } = msg.payload;
124
+ let src = `api.map = function() {\n${map}\n};\napi._result = [];\n`;
125
+ if (filter) {
126
+ src += `api.forEachDocument(docId => { if (api.mqttWildcard(docId, ${JSON.stringify(filter)})) api.map.apply(api.getDocument(docId)); });\n`;
127
+ } else {
128
+ src += `api.forEachDocument(docId => { api.map.apply(api.getDocument(docId)); });\n`;
129
+ }
130
+ if (reduce) {
131
+ src += `api.reduce = function(result) {\n${reduce}\n};\napi._result = api.reduce(api._result);\n`;
132
+ }
133
+ let script;
134
+ try {
135
+ script = new vm.Script(src, { filename: 'shedb-view-' + msg.id });
136
+ } catch (err) {
137
+ parentPort.postMessage({ type: 'view', id: msg.id, error: 'compile: ' + err.message });
138
+ break;
139
+ }
140
+ queries[msg.id] = { ...msg.payload, script };
117
141
  enqueue(msg.id);
118
142
  break;
143
+ }
119
144
 
120
145
  case 'delQuery':
121
146
  delete queries[msg.id];
@@ -10,7 +10,6 @@ Scripts run in a sandboxed VM. The `she` object is injected automatically.
10
10
  ### MQTT
11
11
  ```
12
12
  she.mqtt.sub(topic, [opts], cb) Subscribe; wildcards: + (1 level) # (multi)
13
- +//sensor → +/status/sensor shorthand
14
13
  opts.change: true = only fire when value changes
15
14
  she.mqtt.pub(topic, payload, [opts]) Publish; opts: { qos, retain }
16
15
  she.mqtt.get(topic) Current retained value (sync)
@@ -28,6 +28,11 @@ function hasShelibMarker(absDir) {
28
28
  return fs.existsSync(path.join(absDir, '.shelib'));
29
29
  }
30
30
 
31
+ /** True if a .shedisable-<name> sibling exists for the given file or directory. */
32
+ function hasShedisableMarker(abs) {
33
+ return fs.existsSync(path.join(path.dirname(abs), `.shedisable-${path.basename(abs)}`));
34
+ }
35
+
31
36
  /** Flat list of all files with metadata and lib flag. */
32
37
  function walk(dir, base, parentIsLib) {
33
38
  let entries;
@@ -40,11 +45,13 @@ function walk(dir, base, parentIsLib) {
40
45
  const results = [];
41
46
  for (const entry of entries) {
42
47
  if (entry.name === '.shelib') continue;
48
+ if (entry.name.startsWith('.shedisable-')) continue;
43
49
  const rel = base ? `${base}/${entry.name}` : entry.name;
44
50
  if (entry.isDirectory()) {
45
51
  results.push(...walk(path.join(dir, entry.name), rel, lib));
46
52
  } else {
47
- const stat = fs.statSync(path.join(dir, entry.name));
53
+ const abs = path.join(dir, entry.name);
54
+ const stat = fs.statSync(abs);
48
55
  results.push({ path: rel, size: stat.size, mtime: stat.mtimeMs, lib });
49
56
  }
50
57
  }
@@ -67,15 +74,19 @@ function buildTree(dir, base, parentIsLib) {
67
74
  const result = [];
68
75
  for (const entry of entries) {
69
76
  if (entry.name === '.shelib') continue;
77
+ if (entry.name.startsWith('.shedisable-')) continue;
70
78
  const rel = base ? `${base}/${entry.name}` : entry.name;
71
79
  const abs = path.join(dir, entry.name);
72
80
  if (entry.isDirectory()) {
73
81
  const childIsLib = lib || hasShelibMarker(abs);
82
+ const disabled = hasShedisableMarker(abs);
74
83
  const children = buildTree(abs, rel, childIsLib);
75
- result.push({ type: 'dir', name: entry.name, path: rel, lib: childIsLib, children });
84
+ result.push({ type: 'dir', name: entry.name, path: rel, lib: childIsLib, disabled, children });
76
85
  } else {
77
86
  const stat = fs.statSync(abs);
78
- result.push({ type: 'file', name: entry.name, path: rel, lib, size: stat.size, mtime: stat.mtimeMs });
87
+ const isJs = entry.name.endsWith('.js');
88
+ const disabled = isJs ? hasShedisableMarker(abs) : false;
89
+ result.push({ type: 'file', name: entry.name, path: rel, lib, size: stat.size, mtime: stat.mtimeMs, ...(isJs ? { disabled } : {}) });
79
90
  }
80
91
  }
81
92
  result.sort((a, b) => {