smart-home-engine 0.18.0 → 0.18.1

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.
@@ -112,7 +112,10 @@ 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._viewsPath = dbPath.endsWith('.json')
117
+ ? dbPath.slice(0, -5) + '-views.json'
118
+ : dbPath + '-views.json';
116
119
  this._worker = null;
117
120
 
118
121
  this._spawnWorker();
@@ -124,12 +127,13 @@ class SheDBCore extends EventEmitter {
124
127
  // -------------------------------------------------------------------------
125
128
 
126
129
  _load() {
130
+ let legacyViews = null;
127
131
  try {
128
132
  const data = JSON.parse(fs.readFileSync(this.dbPath, 'utf8'));
129
133
  this.rev = data.rev || 0;
130
134
  this.docs = data.docs || {};
131
135
  this.queries = data.queries || {};
132
- this.views = data.views || {};
136
+ if (data.views && Object.keys(data.views).length > 0) legacyViews = data.views;
133
137
  this.log.info('shedb loaded ' + Object.keys(this.docs).length + ' docs, ' + Object.keys(this.queries).length + ' views from ' + this.dbPath);
134
138
  } catch (err) {
135
139
  if (err.code !== 'ENOENT') {
@@ -137,6 +141,30 @@ class SheDBCore extends EventEmitter {
137
141
  }
138
142
  }
139
143
 
144
+ try {
145
+ const vdata = JSON.parse(fs.readFileSync(this._viewsPath, 'utf8'));
146
+ this.views = vdata.views || {};
147
+ } catch (err) {
148
+ if (err.code !== 'ENOENT') {
149
+ this.log.warn('shedb: could not load views from ' + this._viewsPath + ': ' + err.message);
150
+ }
151
+ if (legacyViews) {
152
+ // Migrate: split legacy single-file format into two files
153
+ this.views = legacyViews;
154
+ this.log.info('shedb: migrating views to separate file ' + this._viewsPath);
155
+ try {
156
+ const vtmp = this._viewsPath + '.tmp';
157
+ fs.writeFileSync(vtmp, JSON.stringify({ views: this.views }), 'utf8');
158
+ fs.renameSync(vtmp, this._viewsPath);
159
+ const tmp = this.dbPath + '.tmp';
160
+ fs.writeFileSync(tmp, JSON.stringify({ rev: this.rev, docs: this.docs, queries: this.queries }), 'utf8');
161
+ fs.renameSync(tmp, this.dbPath);
162
+ } catch (e) {
163
+ this.log.error('shedb: migration failed: ' + e.message);
164
+ }
165
+ }
166
+ }
167
+
140
168
  // Syntax-check persisted views and send state to worker
141
169
  for (const id of Object.keys(this.queries)) {
142
170
  this._syntaxCheck(id, this.queries[id]);
@@ -151,7 +179,7 @@ class SheDBCore extends EventEmitter {
151
179
  this._saveTimer = setTimeout(() => {
152
180
  const tmp = this.dbPath + '.tmp';
153
181
  try {
154
- fs.writeFileSync(tmp, JSON.stringify({ rev: this.rev, docs: this.docs, queries: this.queries, views: this.views }), 'utf8');
182
+ fs.writeFileSync(tmp, JSON.stringify({ rev: this.rev, docs: this.docs, queries: this.queries }), 'utf8');
155
183
  fs.renameSync(tmp, this.dbPath); // atomic on Linux (same FS)
156
184
  } catch (err) {
157
185
  this.log.error('shedb: save failed: ' + err.message);
@@ -159,6 +187,19 @@ class SheDBCore extends EventEmitter {
159
187
  }, 250);
160
188
  }
161
189
 
190
+ _saveViews() {
191
+ clearTimeout(this._saveViewsTimer);
192
+ this._saveViewsTimer = setTimeout(() => {
193
+ const tmp = this._viewsPath + '.tmp';
194
+ try {
195
+ fs.writeFileSync(tmp, JSON.stringify({ views: this.views }), 'utf8');
196
+ fs.renameSync(tmp, this._viewsPath);
197
+ } catch (err) {
198
+ this.log.error('shedb: save views failed: ' + err.message);
199
+ }
200
+ }, 250);
201
+ }
202
+
162
203
  // -------------------------------------------------------------------------
163
204
  // Rev tracking — mirrors mqttDB pattern exactly
164
205
  // -------------------------------------------------------------------------
@@ -215,7 +256,7 @@ class SheDBCore extends EventEmitter {
215
256
  this.rev++;
216
257
  this._save();
217
258
  this.emit('update', id, this.docs[id]);
218
- this._sendDb();
259
+ this._sendPatch(id, this.docs[id]);
219
260
  return true;
220
261
  }
221
262
  this._setRev(id, rev);
@@ -244,7 +285,7 @@ class SheDBCore extends EventEmitter {
244
285
  this.rev++;
245
286
  this._save();
246
287
  this.emit('update', id, this.docs[id]);
247
- this._sendDb();
288
+ this._sendPatch(id, this.docs[id]);
248
289
  return true;
249
290
  }
250
291
  this._setRev(id, rev);
@@ -256,7 +297,7 @@ class SheDBCore extends EventEmitter {
256
297
  this.rev++;
257
298
  this._save();
258
299
  this.emit('update', id, '');
259
- this._sendDb();
300
+ this._sendPatch(id, null);
260
301
  }
261
302
 
262
303
  /**
@@ -287,7 +328,7 @@ class SheDBCore extends EventEmitter {
287
328
  this.rev++;
288
329
  this._save();
289
330
  this.emit('update', id, this.docs[id]);
290
- this._sendDb();
331
+ this._sendPatch(id, this.docs[id]);
291
332
  return true;
292
333
  }
293
334
  this._setRev(id, rev);
@@ -309,6 +350,7 @@ class SheDBCore extends EventEmitter {
309
350
  delete this._viewEnvs[id];
310
351
  delete this.views[id];
311
352
  this._save();
353
+ this._saveViews();
312
354
  if (this._worker) this._worker.postMessage({ type: 'delQuery', id });
313
355
  this.emit('view', id, '');
314
356
  return;
@@ -362,7 +404,7 @@ class SheDBCore extends EventEmitter {
362
404
  delete this.views[id].result;
363
405
  this.log.error('shedb view ' + id + ': ' + msg.error);
364
406
  this.emit('view', id, this.views[id]);
365
- this._save();
407
+ this._saveViews();
366
408
  return;
367
409
  }
368
410
 
@@ -370,7 +412,7 @@ class SheDBCore extends EventEmitter {
370
412
  this.views[id] = { _id: id, _rev: (prev._rev ?? -1) + 1, result: msg.result, length: msg.result.length };
371
413
  delete this.views[id].error;
372
414
  this.emit('view', id, this.views[id]);
373
- this._save();
415
+ this._saveViews();
374
416
  }
375
417
  });
376
418
 
@@ -390,22 +432,20 @@ class SheDBCore extends EventEmitter {
390
432
  });
391
433
  }
392
434
 
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
- });
435
+ /** Send a single-document patch (or deletion) to the worker — O(1). */
436
+ _sendPatch(id, doc) {
437
+ if (!this._worker) return;
438
+ if (doc) {
439
+ this._worker.postMessage({ type: 'patch', id, doc: structuredClone(doc) });
440
+ } else {
441
+ this._worker.postMessage({ type: 'del', id });
442
+ }
403
443
  }
404
444
 
405
- /** Send full state (docs + all queries) to the worker — used on init and respawn. */
445
+ /** Send full state (docs + all queries) to the worker — used on init and respawn only. */
406
446
  _sendInitialState() {
407
447
  if (!this._worker) return;
408
- this._worker.postMessage({ type: 'db', docs: structuredClone(this.docs) });
448
+ this._worker.postMessage({ type: 'init', docs: structuredClone(this.docs) });
409
449
  for (const id of Object.keys(this.queries)) {
410
450
  this._worker.postMessage({ type: 'query', id, payload: this.queries[id] });
411
451
  }
@@ -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];
@@ -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) => {