@meri-imperiumi/signalk-dead-reckoning 0.13.2 → 0.13.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/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.13.3] - 2026-10-15
11
+
12
+ ### Changed
13
+
14
+ - **The state flush no longer writes rows that did not change, and WAL checkpoint bursts are bounded** (`plugin/db.js`; found by file-activity profiling of a boat server, which showed the DR store's WAL parked at the 4 MB auto-checkpoint high-water and ~40 MB/day of flash writes at the 60 s flush cadence): `setState()` now reads the stored value and skips the write when it is identical — while anchored most flush scalars (`dr_log_nm`, `dr_trip_log_nm`, `dr_trip_start_ms`, `dr_log_since_origin`, `dr_underway_since_origin_s`, `dr_origin_error_nm`) are unchanged, so each flush previously dirtied a 4 kB WAL page per row for nothing; suppressing no-op upserts cuts the anchored flush to the rows that genuinely move (`last_known_good_fix`, whose fix timestamp advances, and `dr_elapsed_since_origin_s`, the wall-clock cone input). `updated_at` then stays at the value's last real change, which is the truthful reading of the column; nothing in the codebase reads it. The database also opens with `PRAGMA wal_autocheckpoint = 256` and `PRAGMA journal_size_limit = 1048576`: the default 1000-page (4 MB) threshold turned every WAL generation into a single ~4 MB copy-back burst on SD-card hardware, and the WAL file parked at its high-water size forever — the same bytes now flow as four 1 MB bursts and the file shrinks back between checkpoints.
15
+
10
16
  ## [0.13.2] - 2026-10-11
11
17
 
12
18
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meri-imperiumi/signalk-dead-reckoning",
3
- "version": "0.13.2",
3
+ "version": "0.13.3",
4
4
  "description": "Offline-first dead reckoning and sensor fusion engine for Signal K",
5
5
  "main": "plugin/index.js",
6
6
  "scripts": {
package/plugin/db.js CHANGED
@@ -206,6 +206,15 @@ function openDatabase(dbPath) {
206
206
  // sustained minute after minute on SD-card boat hardware — for that
207
207
  // small window is the right default. FULL would fsync every commit.
208
208
  db.exec("PRAGMA synchronous = NORMAL");
209
+ // Bound the WAL's write bursts: the default auto-checkpoint threshold
210
+ // is 1000 pages (4 MB), so every WAL generation ends in a single ~4 MB
211
+ // copy back into the main database — a periodic flash-write spike on
212
+ // SD-card boat hardware. 256 pages (1 MB) spreads the same bytes over
213
+ // four smaller bursts, and journal_size_limit lets the WAL file shrink
214
+ // back to 1 MB after each checkpoint instead of parking forever at its
215
+ // high-water size (observed at ~4 MB on production stores).
216
+ db.exec("PRAGMA wal_autocheckpoint = 256");
217
+ db.exec("PRAGMA journal_size_limit = 1048576");
209
218
  db.exec("PRAGMA foreign_keys = ON");
210
219
  for (const stmt of SCHEMA_DDL) {
211
220
  db.exec(stmt);
@@ -351,17 +360,33 @@ function getState(db, key) {
351
360
  /**
352
361
  * Writes a scalar value to `dr_state_store`, upserting.
353
362
  *
363
+ * Identical values are not written: the periodic state flush rewrites
364
+ * the same scalars every cycle (log totals, origin error, trip state),
365
+ * and on SD-card boat hardware even a no-op upsert dirties a 4 kB page
366
+ * in the WAL, so suppressing unchanged writes is the single biggest
367
+ * flash-wear lever this store has. `updated_at` then stays at the
368
+ * value's last real change, which is the truthful reading of the
369
+ * column anyway. Column reads elsewhere only consume `value`.
370
+ *
354
371
  * @param {import("node:sqlite").DatabaseSync} db
355
372
  * @param {string} key
356
373
  * @param {string} value
357
- * @returns {void}
374
+ * @returns {boolean} True when a write happened, false when the stored
375
+ * value was already identical.
358
376
  */
359
377
  function setState(db, key, value) {
378
+ const existing = db
379
+ .prepare("SELECT value FROM dr_state_store WHERE key = ?")
380
+ .get(key);
381
+ if (existing && existing.value === value) {
382
+ return false;
383
+ }
360
384
  db.prepare(
361
385
  `INSERT INTO dr_state_store (key, value, updated_at)
362
386
  VALUES (?, ?, ?)
363
387
  ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at`,
364
388
  ).run(key, value, new Date().toISOString());
389
+ return true;
365
390
  }
366
391
 
367
392
  /**
package/tests/db.test.js CHANGED
@@ -243,6 +243,39 @@ test("setState upserts and getState reads back", () => {
243
243
  db.close();
244
244
  });
245
245
 
246
+ test("setState skips identical values (flash-wear guard)", () => {
247
+ const db = openDatabase(join(tempDir, "c.sqlite"));
248
+ assert.strictEqual(setState(db, "dr_log_nm", "123.45"), true);
249
+ const firstUpdatedAt = db
250
+ .prepare("SELECT updated_at FROM dr_state_store WHERE key = ?")
251
+ .get("dr_log_nm").updated_at;
252
+ // Identical write is suppressed: no updated_at churn, no WAL page dirtied
253
+ assert.strictEqual(setState(db, "dr_log_nm", "123.45"), false);
254
+ assert.strictEqual(
255
+ db
256
+ .prepare("SELECT updated_at FROM dr_state_store WHERE key = ?")
257
+ .get("dr_log_nm").updated_at,
258
+ firstUpdatedAt,
259
+ );
260
+ // A changed value still writes
261
+ assert.strictEqual(setState(db, "dr_log_nm", "124.00"), true);
262
+ assert.strictEqual(getState(db, "dr_log_nm"), "124.00");
263
+ db.close();
264
+ });
265
+
266
+ test("openDatabase bounds WAL checkpoint bursts and file high-water", () => {
267
+ const db = openDatabase(join(tempDir, "c.sqlite"));
268
+ assert.strictEqual(
269
+ db.prepare("PRAGMA wal_autocheckpoint").get()["wal_autocheckpoint"],
270
+ 256,
271
+ );
272
+ assert.strictEqual(
273
+ db.prepare("PRAGMA journal_size_limit").get()["journal_size_limit"],
274
+ 1048576,
275
+ );
276
+ db.close();
277
+ });
278
+
246
279
  test("dr_matrix_bins primary key enforces bin-level uniqueness", () => {
247
280
  const db = openDatabase(join(tempDir, "d.sqlite"));
248
281
  const ins = db.prepare(