mumpix 1.0.20 → 1.0.29

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/src/core/store.js CHANGED
@@ -1,64 +1,61 @@
1
- 'use strict';
2
-
3
- /**
4
- * MumpixStore — crash-safe, append-only storage engine
5
- *
6
- * File format (.mumpix):
7
- * Line 0: JSON header {"v":1,"consistency":"strict","created":ts}
8
- * Line N: JSON record {"id":1,"content":"...","ts":ts,"h":"0xabc"}
9
- *
10
- * WAL (.mumpix.wal):
11
- * Each line: {"op":"write"|"clear","entry"?:{...},"ts":ts}
12
- * Replayed on open if present, then merged and deleted.
13
- */
14
-
15
- const fs = require('fs');
16
- const path = require('path');
17
- const os = require('os');
18
- const crypto = require('crypto');
19
-
20
- const MAGIC_VERSION = 1;
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const path = require("path");
5
+ const crypto = require("crypto");
6
+ const { WalWriter } = require("./wal-writer");
7
+ const { InvertedIndex } = require("./inverted-index");
8
+ const { tokenize } = require("./recall");
9
+
10
+ function normalizeMeta(meta) {
11
+ if (!meta || typeof meta !== "object") return {};
12
+ const out = {};
13
+ if (Number.isFinite(meta.ts)) out.ts = Number(meta.ts);
14
+ if (typeof meta.workspace === "string" && meta.workspace.trim()) {
15
+ out.workspace = meta.workspace.trim();
16
+ }
17
+ if (typeof meta.repo === "string" && meta.repo.trim()) {
18
+ out.repo = meta.repo.trim();
19
+ }
20
+ if (typeof meta.source === "string" && meta.source.trim()) {
21
+ out.source = meta.source.trim();
22
+ }
23
+ return out;
24
+ }
21
25
 
22
26
  class MumpixStore {
23
27
  constructor(filePath) {
24
28
  this.filePath = path.resolve(filePath);
25
- this.walPath = this.filePath + '.wal';
26
- this.lockPath = this.filePath + '.lock';
27
- this.header = null;
28
- this.records = []; // { id, content, ts, h }
29
- this._nextId = 1;
30
- this._fd = null; // open file descriptor for appends
31
- this._lockFd = null; // lock file descriptor
29
+ this.walPath = `${this.filePath}.wal`;
30
+ this.lockPath = `${this.filePath}.lock`;
31
+ this.header = null;
32
+ this.records = [];
33
+ this._nextId = 1;
34
+ this._fd = null;
35
+ this._lockFd = null;
32
36
  this._strictIntegrity = false;
33
- this._mlDsa = null; // ML-DSA-65 engine for Verified mode
34
- this._signingKey = null; // Private key for signing (Verified mode)
35
- this._publicKey = null; // Public key for verification (Verified mode)
37
+ this._walWriter = null;
38
+ this._index = null;
39
+ this._recallIndexEnabled = true;
36
40
  }
37
41
 
38
- // ── Public ──────────────────────────────────────
39
-
40
- async open(opts = {}) {
41
- const consistency = opts.consistency || 'eventual';
42
- this._strictIntegrity = consistency === 'strict' || consistency === 'verified';
42
+ open(opts = {}) {
43
+ const consistency = opts.consistency || "eventual";
44
+ this._strictIntegrity = consistency === "strict" || consistency === "verified";
45
+ this._recallIndexEnabled = opts.recallIndex !== false;
43
46
  this._acquireLock(opts);
44
-
45
- if (consistency === 'verified') {
46
- await this._initQuantumEngine();
47
- }
48
47
 
49
48
  if (fs.existsSync(this.filePath)) {
50
49
  try {
51
50
  this._load();
52
51
  } catch (err) {
53
- const recovered = this._canRecoverFromIntegrityError(err) && this._recoverFromTailCorruption();
54
- if (!recovered) {
52
+ if (!(this._canRecoverFromIntegrityError(err) && this._recoverFromTailCorruption())) {
55
53
  this._releaseLock();
56
54
  throw err;
57
55
  }
58
56
  this._load();
59
57
  }
60
58
 
61
- // Replay any uncommitted WAL
62
59
  try {
63
60
  this._replayWAL();
64
61
  } catch (err) {
@@ -67,212 +64,267 @@ class MumpixStore {
67
64
  }
68
65
  } else {
69
66
  this.header = {
70
- v: MAGIC_VERSION,
67
+ v: 1,
71
68
  consistency,
72
69
  created: Date.now(),
73
70
  path: path.basename(this.filePath),
74
- fileId: crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex'),
75
- hashAlg: 'hmac-sha256',
71
+ fileId: crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString("hex"),
72
+ hashAlg: "hmac-sha256",
76
73
  };
77
74
  this._writeHeader();
78
75
  }
79
76
 
80
77
  if (!this.header.fileId) {
81
- this.header.fileId = crypto.randomUUID ? crypto.randomUUID() : crypto.randomBytes(16).toString('hex');
78
+ this.header.fileId = crypto.randomUUID
79
+ ? crypto.randomUUID()
80
+ : crypto.randomBytes(16).toString("hex");
82
81
  this._rewriteFull();
83
82
  }
84
83
  if (!this.header.hashAlg) {
85
- this.header.hashAlg = 'hmac-sha256';
84
+ this.header.hashAlg = "hmac-sha256";
86
85
  this._rewriteFull();
87
86
  }
88
-
89
- // Update consistency if caller changed it
90
87
  if (this.header.consistency !== consistency) {
91
88
  this.header.consistency = consistency;
92
89
  this._rewriteFull();
93
90
  }
94
91
 
95
- // Open append FD
92
+ this._walWriter = new WalWriter(this.walPath, consistency, {
93
+ intervalMs: Number.isFinite(opts.walFlushIntervalMs) ? opts.walFlushIntervalMs : undefined,
94
+ maxPendingBytes: Number.isFinite(opts.walMaxPendingBytes) ? opts.walMaxPendingBytes : undefined,
95
+ });
96
+
96
97
  try {
97
- this._fd = fs.openSync(this.filePath, 'a');
98
+ this._fd = fs.openSync(this.filePath, "a");
98
99
  } catch (err) {
99
100
  this._releaseLock();
100
101
  throw err;
101
102
  }
103
+
104
+ this._initIndex();
102
105
  return this;
103
106
  }
104
107
 
105
108
  close() {
109
+ if (this._index) {
110
+ try {
111
+ this._index.persist();
112
+ } catch (_) {}
113
+ }
114
+ if (this._walWriter) {
115
+ this._walWriter.close({ flush: true });
116
+ this._walWriter = null;
117
+ }
106
118
  if (this._fd !== null) {
107
119
  fs.closeSync(this._fd);
108
120
  this._fd = null;
109
121
  }
110
122
  this._releaseLock();
111
- // Zero out sensitive key material in memory
112
- if (this._signingKey) this._signingKey.fill(0);
113
- this._signingKey = null;
114
123
  }
115
124
 
116
- /**
117
- * Append a record. WAL-first for crash safety.
118
- * Returns the new record.
119
- */
125
+ flush() {
126
+ if (this._walWriter) this._walWriter.flush();
127
+ if (this._fd !== null) fs.fdatasyncSync(this._fd);
128
+ if (this._index) this._index.persist();
129
+ }
130
+
120
131
  write(content, meta = {}) {
121
- const trimmed = content.trim();
122
- const sanitizedMeta = sanitizeMeta(meta);
123
- const prevH = this.records.length > 0 ? this.records[this.records.length - 1].h : '0';
124
-
125
- const record = {
126
- id: this._nextId++,
127
- content: trimmed,
128
- ts: Number.isFinite(sanitizedMeta.ts) ? Number(sanitizedMeta.ts) : Date.now(),
129
- h: this._hash(trimmed, prevH, this._nextId - 1),
130
- ...(sanitizedMeta.workspace ? { workspace: sanitizedMeta.workspace } : {}),
131
- ...(sanitizedMeta.repo ? { repo: sanitizedMeta.repo } : {}),
132
- ...(sanitizedMeta.source ? { source: sanitizedMeta.source } : {}),
133
- };
132
+ const text = String(content).trim();
133
+ const cleanMeta = normalizeMeta(meta);
134
+ const record = this._buildRecord(text, cleanMeta);
135
+
136
+ this._appendWal({ op: "write", entry: record, ts: Date.now() });
134
137
 
135
- // If Verified mode and engine/keys are ready, sign the hash chain link
136
- if (this._mlDsa && this._signingKey) {
137
- const msg = Buffer.from(record.h, 'utf8');
138
- const sig = this._mlDsa.sign(msg, this._signingKey);
139
- record.sig = Buffer.from(sig).toString('hex');
138
+ try {
139
+ fs.writeSync(this._fd, `${JSON.stringify(record)}\n`, null, "utf8");
140
+ if (this._requiresPerRecordSync()) fs.fdatasyncSync(this._fd);
141
+ } catch (err) {
142
+ throw err;
140
143
  }
141
144
 
142
- // 1. Write to WAL and force it to disk before touching main file.
143
- this._appendWal({ op: 'write', entry: record, ts: Date.now() });
145
+ this._clearWAL();
146
+ this.records.push(record);
147
+ if (this._index) this._index.add(record);
148
+ return record;
149
+ }
144
150
 
145
- // 2. Commit to main file
146
- try {
147
- const line = JSON.stringify(record) + '\n';
148
- fs.writeSync(this._fd, line, null, 'utf8');
151
+ writeBatch(items) {
152
+ if (!Array.isArray(items) || !items.length) return [];
149
153
 
150
- // 3. Sync to disk (strict/verified modes flush immediately)
151
- const consistency = this.header.consistency;
152
- if (consistency === 'strict' || consistency === 'verified') {
153
- fs.fdatasyncSync(this._fd);
154
+ const records = items.map((item) => {
155
+ if (item && typeof item === "object" && !Array.isArray(item)) {
156
+ return this._buildRecord(String(item.content || "").trim(), normalizeMeta(item));
154
157
  }
158
+ return this._buildRecord(String(item || "").trim(), {});
159
+ });
160
+
161
+ const walLines = records.map((record) =>
162
+ JSON.stringify({ op: "write", entry: record, ts: Date.now() })
163
+ );
164
+
165
+ if (this._walWriter) {
166
+ this._walWriter.appendBatch(walLines);
167
+ } else {
168
+ for (const line of walLines) this._appendWal(JSON.parse(line));
169
+ }
170
+
171
+ try {
172
+ fs.writeSync(
173
+ this._fd,
174
+ records.map((record) => JSON.stringify(record)).join("\n") + "\n",
175
+ null,
176
+ "utf8"
177
+ );
178
+ if (this._requiresPerRecordSync()) fs.fdatasyncSync(this._fd);
155
179
  } catch (err) {
156
- // WAL remains present; write will be replayed on next open.
157
180
  throw err;
158
181
  }
159
182
 
160
- // 4. Remove WAL entry (write succeeded)
161
183
  this._clearWAL();
162
-
163
- this.records.push(record);
164
- return record;
184
+ for (const record of records) {
185
+ this.records.push(record);
186
+ if (this._index) this._index.add(record);
187
+ }
188
+ return records;
165
189
  }
166
190
 
167
- /**
168
- * Clear all records — WAL-first.
169
- */
170
191
  clear() {
171
192
  const count = this.records.length;
172
-
173
- this._appendWal({ op: 'clear', count, ts: Date.now() });
174
-
193
+ this._appendWal({ op: "clear", count, ts: Date.now() });
175
194
  this.records = [];
176
195
  this._nextId = 1;
196
+ if (this._index) this._index.reset();
177
197
  this._rewriteFull();
178
198
  this._clearWAL();
179
-
199
+ if (this._index) this._index.persist();
180
200
  return count;
181
201
  }
182
202
 
183
- /**
184
- * Return all records (immutable copy).
185
- */
186
203
  all() {
187
- return this.records.map(r => ({ ...r }));
204
+ return this.records.map((record) => ({ ...record }));
188
205
  }
189
206
 
190
- /**
191
- * Return store metadata.
192
- */
193
207
  stats() {
194
208
  const stat = fs.existsSync(this.filePath) ? fs.statSync(this.filePath) : null;
195
209
  return {
196
- path: this.filePath,
210
+ path: this.filePath,
197
211
  consistency: this.header.consistency,
198
- records: this.records.length,
199
- created: this.header.created,
200
- sizeBytes: stat ? stat.size : 0,
201
- version: this.header.v,
212
+ records: this.records.length,
213
+ created: this.header.created,
214
+ sizeBytes: stat ? stat.size : 0,
215
+ version: this.header.v,
216
+ index: this.indexStats(),
202
217
  };
203
218
  }
204
219
 
205
- async _initQuantumEngine() {
206
- const mlDsaPath = 'file://' + path.resolve(__dirname, 'ml-dsa.mjs');
207
- const { ml_dsa65 } = await import(mlDsaPath);
208
- this._mlDsa = ml_dsa65;
220
+ indexedCandidates(query, cap = 400) {
221
+ if (!this._index) return null;
222
+ return this._index.candidates(query, cap);
223
+ }
209
224
 
210
- const keyPath = this.filePath + '.key';
211
- if (fs.existsSync(keyPath)) {
212
- // Load existing key
213
- const keys = JSON.parse(fs.readFileSync(keyPath, 'utf8'));
214
- this._signingKey = Buffer.from(keys.sk, 'hex');
215
- this._publicKey = Buffer.from(keys.pk, 'hex');
216
- } else {
217
- // Generate fresh ML-DSA-65 keypair for this database
218
- console.log(`[Quantum] Generating fresh Level 3 keypair for ${path.basename(this.filePath)}...`);
219
- const { publicKey, secretKey } = this._mlDsa.keygen();
220
- this._signingKey = secretKey;
221
- this._publicKey = publicKey;
222
-
223
- // Persist locally
224
- fs.writeFileSync(keyPath, JSON.stringify({
225
- pk: Buffer.from(publicKey).toString('hex'),
226
- sk: Buffer.from(secretKey).toString('hex')
227
- }), 'utf8');
228
-
229
- if (this.header) {
230
- this.header.pk = Buffer.from(publicKey).toString('hex');
231
- this._rewriteFull();
225
+ rebuildIndex() {
226
+ if (!this._recallIndexEnabled) return null;
227
+ this._index = new InvertedIndex(tokenize, this._indexPath());
228
+ for (const record of this.records) this._index.add(record);
229
+ this._index.persist();
230
+ return this.indexStats();
231
+ }
232
+
233
+ indexStats() {
234
+ if (!this._index) {
235
+ return {
236
+ enabled: false,
237
+ terms: 0,
238
+ docCount: 0,
239
+ highWaterSeq: 0,
240
+ sidecarPath: this._indexPath(),
241
+ sizeBytes: 0,
242
+ };
243
+ }
244
+ return { enabled: true, ...this._index.stats() };
245
+ }
246
+
247
+ _buildRecord(text, meta) {
248
+ const record = {
249
+ id: this._nextId++,
250
+ content: text,
251
+ ts: Number.isFinite(meta.ts) ? Number(meta.ts) : Date.now(),
252
+ h: this._hash(text),
253
+ };
254
+ if (meta.workspace) record.workspace = meta.workspace;
255
+ if (meta.repo) record.repo = meta.repo;
256
+ if (meta.source) record.source = meta.source;
257
+ return record;
258
+ }
259
+
260
+ _requiresPerRecordSync() {
261
+ return this.header.consistency === "strict" || this.header.consistency === "verified";
262
+ }
263
+
264
+ _initIndex() {
265
+ if (!this._recallIndexEnabled) {
266
+ this._index = null;
267
+ return;
268
+ }
269
+
270
+ this._index = new InvertedIndex(tokenize, this._indexPath());
271
+ const highWater = this._index.loadSidecar();
272
+ if (highWater > 0) {
273
+ this._index.hydrate(this.records.filter((record) => record.id <= highWater));
274
+ for (const record of this.records) {
275
+ if (record.id > highWater) this._index.add(record);
232
276
  }
277
+ } else {
278
+ for (const record of this.records) this._index.add(record);
233
279
  }
234
280
  }
235
281
 
236
- // ── Private ─────────────────────────────────────
282
+ _indexPath() {
283
+ return `${this.filePath}.idx`;
284
+ }
237
285
 
238
286
  _load() {
239
- const raw = fs.readFileSync(this.filePath, 'utf8');
240
- const lines = raw.split('\n').filter(l => l.trim());
241
-
287
+ const lines = fs
288
+ .readFileSync(this.filePath, "utf8")
289
+ .split("\n")
290
+ .filter((line) => line.trim());
242
291
  if (!lines.length) throw new Error(`mumpix: corrupt or empty file: ${this.filePath}`);
243
292
 
244
293
  this.header = JSON.parse(lines[0]);
245
- if (this.header.v !== MAGIC_VERSION) {
246
- throw new Error(`mumpix: unsupported file version ${this.header.v}`);
247
- }
294
+ if (this.header.v !== 1) throw new Error(`mumpix: unsupported file version ${this.header.v}`);
248
295
 
249
296
  this.records = [];
297
+ this._nextId = 1;
250
298
  let lastId = 0;
251
- let prevH = '0';
252
- const seenIds = new Set();
299
+ const seen = new Set();
300
+
253
301
  for (let i = 1; i < lines.length; i++) {
254
302
  try {
255
- const r = JSON.parse(lines[i]);
256
- if (r && Number.isInteger(r.id) && typeof r.content === 'string' && r.content.length > 0) {
257
- const monotonic = r.id > lastId;
258
- const duplicate = seenIds.has(r.id);
259
- const hashOk = this._verifyHash(r, prevH);
260
- if (!monotonic || duplicate || !hashOk) {
261
- const msg = !hashOk
262
- ? `mumpix: integrity hash mismatch for id=${r.id}`
263
- : `mumpix: invalid record order/id at id=${r.id}`;
264
- if (this._strictIntegrity) throw new Error(msg);
303
+ const record = JSON.parse(lines[i]);
304
+ if (
305
+ record &&
306
+ Number.isInteger(record.id) &&
307
+ typeof record.content === "string" &&
308
+ record.content.length > 0
309
+ ) {
310
+ const ordered = record.id > lastId;
311
+ const duplicate = seen.has(record.id);
312
+ const validHash = this._verifyHash(record);
313
+ if (!ordered || duplicate || !validHash) {
314
+ const message = validHash
315
+ ? `mumpix: invalid record order/id at id=${record.id}`
316
+ : `mumpix: integrity hash mismatch for id=${record.id}`;
317
+ if (this._strictIntegrity) throw new Error(message);
265
318
  continue;
266
319
  }
267
- this.records.push(r);
268
- prevH = r.h;
269
- seenIds.add(r.id);
270
- lastId = r.id;
271
- if (r.id >= this._nextId) this._nextId = r.id + 1;
320
+
321
+ this.records.push(record);
322
+ seen.add(record.id);
323
+ lastId = record.id;
324
+ if (record.id >= this._nextId) this._nextId = record.id + 1;
272
325
  }
273
326
  } catch (err) {
274
327
  if (this._strictIntegrity) throw err;
275
- // skip corrupt lines in eventual mode
276
328
  }
277
329
  }
278
330
  }
@@ -280,80 +332,92 @@ class MumpixStore {
280
332
  _replayWAL() {
281
333
  if (!fs.existsSync(this.walPath)) return;
282
334
 
283
- const raw = fs.readFileSync(this.walPath, 'utf8');
284
- const lines = raw.split('\n').filter(l => l.trim());
285
- let dirty = false;
335
+ const lines = fs
336
+ .readFileSync(this.walPath, "utf8")
337
+ .split("\n")
338
+ .filter((line) => line.trim());
339
+ let changed = false;
286
340
  let lastId = this.records.length ? this.records[this.records.length - 1].id : 0;
287
- const seenIds = new Set(this.records.map(r => r.id));
341
+ const seen = new Set(this.records.map((record) => record.id));
288
342
 
289
343
  for (const line of lines) {
290
344
  try {
291
- const entry = JSON.parse(line);
292
- if (entry.op === 'write' && entry.entry) {
293
- const candidate = entry.entry;
294
- const exists = seenIds.has(candidate.id);
295
- const monotonic = Number.isInteger(candidate.id) && candidate.id > lastId;
296
- const hashOk = this._verifyHash(candidate, prevH);
297
- if (!exists && monotonic && hashOk) {
298
- this.records.push(entry.entry);
299
- if (entry.entry.id >= this._nextId) this._nextId = entry.entry.id + 1;
300
- seenIds.add(entry.entry.id);
301
- prevH = entry.entry.h;
302
- lastId = entry.entry.id;
303
- dirty = true;
345
+ const event = JSON.parse(line);
346
+ if (event.op === "write" && event.entry) {
347
+ const record = event.entry;
348
+ const duplicate = seen.has(record.id);
349
+ const ordered = Number.isInteger(record.id) && record.id > lastId;
350
+ const validHash = this._verifyHash(record);
351
+ if (!duplicate && ordered && validHash) {
352
+ this.records.push(record);
353
+ if (record.id >= this._nextId) this._nextId = record.id + 1;
354
+ seen.add(record.id);
355
+ lastId = record.id;
356
+ changed = true;
304
357
  } else if (this._strictIntegrity) {
305
- throw new Error(`mumpix: rejected WAL write id=${candidate.id}`);
358
+ throw new Error(`mumpix: rejected WAL write id=${record.id}`);
306
359
  }
307
- } else if (entry.op === 'clear') {
360
+ } else if (event.op === "clear") {
308
361
  this.records = [];
309
362
  this._nextId = 1;
310
- seenIds.clear();
311
- prevH = '0';
363
+ seen.clear();
312
364
  lastId = 0;
313
- dirty = true;
365
+ changed = true;
314
366
  }
315
367
  } catch (err) {
316
368
  if (this._strictIntegrity) throw err;
317
- // skip corrupt WAL lines in eventual mode
318
369
  }
319
370
  }
320
371
 
321
- if (dirty) this._rewriteFull();
372
+ if (changed) this._rewriteFull();
322
373
  this._clearWAL();
323
374
  }
324
375
 
325
376
  _writeHeader() {
326
377
  const dir = path.dirname(this.filePath);
327
378
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
328
- fs.writeFileSync(this.filePath, JSON.stringify(this.header) + '\n', 'utf8');
379
+ fs.writeFileSync(this.filePath, `${JSON.stringify(this.header)}\n`, "utf8");
329
380
  }
330
381
 
331
382
  _rewriteFull() {
332
- // Atomic rewrite via temp file
333
- const tmp = this.filePath + '.tmp.' + process.pid;
383
+ if (this._walWriter) {
384
+ try {
385
+ this._walWriter.flush();
386
+ } catch (_) {}
387
+ }
388
+
389
+ const tmp = `${this.filePath}.tmp.${process.pid}`;
334
390
  const lines = [JSON.stringify(this.header)];
335
- for (const r of this.records) lines.push(JSON.stringify(r));
336
- fs.writeFileSync(tmp, lines.join('\n') + '\n', 'utf8');
391
+ for (const record of this.records) lines.push(JSON.stringify(record));
392
+ fs.writeFileSync(tmp, `${lines.join("\n")}\n`, "utf8");
337
393
  fs.renameSync(tmp, this.filePath);
338
394
 
339
- // Re-open append FD if needed
340
395
  if (this._fd !== null) {
341
396
  fs.closeSync(this._fd);
342
- this._fd = fs.openSync(this.filePath, 'a');
397
+ this._fd = fs.openSync(this.filePath, "a");
343
398
  }
344
399
  }
345
400
 
346
401
  _clearWAL() {
347
- if (fs.existsSync(this.walPath)) {
348
- fs.unlinkSync(this.walPath);
402
+ if (this._walWriter) {
403
+ this._walWriter.unlink({ flush: this._requiresPerRecordSync() });
404
+ } else {
405
+ try {
406
+ fs.unlinkSync(this.walPath);
407
+ } catch (_) {}
349
408
  }
350
409
  }
351
410
 
352
- _appendWal(entry) {
353
- const line = JSON.stringify(entry) + '\n';
354
- const fd = fs.openSync(this.walPath, 'a');
411
+ _appendWal(event) {
412
+ const line = JSON.stringify(event);
413
+ if (this._walWriter) {
414
+ this._walWriter.append(line);
415
+ return;
416
+ }
417
+
418
+ const fd = fs.openSync(this.walPath, "a");
355
419
  try {
356
- fs.writeSync(fd, line, null, 'utf8');
420
+ fs.writeSync(fd, `${line}\n`, null, "utf8");
357
421
  fs.fdatasyncSync(fd);
358
422
  } finally {
359
423
  fs.closeSync(fd);
@@ -362,54 +426,62 @@ class MumpixStore {
362
426
 
363
427
  _acquireLock(opts = {}) {
364
428
  if (opts.lock === false) return;
365
- const staleMs = Number.isFinite(opts.lockStaleMs) ? Number(opts.lockStaleMs) : 5 * 60 * 1000;
429
+ const staleMs = Number.isFinite(opts.lockStaleMs) ? Number(opts.lockStaleMs) : 300000;
366
430
  const payload = JSON.stringify({ pid: process.pid, ts: Date.now() });
367
431
 
368
432
  for (let attempt = 0; attempt < 2; attempt++) {
369
433
  try {
370
- this._lockFd = fs.openSync(this.lockPath, 'wx');
371
- fs.writeSync(this._lockFd, payload, null, 'utf8');
434
+ this._lockFd = fs.openSync(this.lockPath, "wx");
435
+ fs.writeSync(this._lockFd, payload, null, "utf8");
372
436
  fs.fdatasyncSync(this._lockFd);
373
437
  return;
374
438
  } catch (err) {
375
- if (!err || err.code !== 'EEXIST') throw err;
439
+ if (!err || err.code !== "EEXIST") throw err;
376
440
  try {
377
- const st = fs.statSync(this.lockPath);
378
- if (Date.now() - st.mtimeMs > staleMs) {
441
+ const stat = fs.statSync(this.lockPath);
442
+ if (Date.now() - stat.mtimeMs > staleMs) {
379
443
  fs.unlinkSync(this.lockPath);
380
444
  continue;
381
445
  }
382
446
  } catch (_) {
383
- // lock disappeared between checks; retry once
384
447
  continue;
385
448
  }
386
449
  throw new Error(`mumpix: database is locked: ${this.filePath}`);
387
450
  }
388
451
  }
452
+
389
453
  throw new Error(`mumpix: failed to acquire lock: ${this.filePath}`);
390
454
  }
391
455
 
392
456
  _releaseLock() {
393
457
  if (this._lockFd !== null) {
394
- try { fs.closeSync(this._lockFd); } catch (_) {}
458
+ try {
459
+ fs.closeSync(this._lockFd);
460
+ } catch (_) {}
395
461
  this._lockFd = null;
396
462
  }
397
- try { fs.unlinkSync(this.lockPath); } catch (_) {}
463
+ try {
464
+ fs.unlinkSync(this.lockPath);
465
+ } catch (_) {}
398
466
  }
399
467
 
400
468
  _canRecoverFromIntegrityError(err) {
401
469
  if (!this._strictIntegrity) return false;
402
470
  if (!fs.existsSync(this.walPath)) return false;
403
- const msg = String((err && err.message) || err || '');
404
- return msg.includes('integrity hash mismatch') ||
405
- msg.includes('invalid record order/id') ||
406
- msg.includes('Unexpected token') ||
407
- msg.includes('corrupt or empty file');
471
+ const message = String((err && err.message) || err || "");
472
+ return (
473
+ message.includes("integrity hash mismatch") ||
474
+ message.includes("invalid record order/id") ||
475
+ message.includes("Unexpected token") ||
476
+ message.includes("corrupt or empty file")
477
+ );
408
478
  }
409
479
 
410
480
  _recoverFromTailCorruption() {
411
- const raw = fs.readFileSync(this.filePath, 'utf8');
412
- const lines = raw.split('\n').filter(l => l.trim());
481
+ const lines = fs
482
+ .readFileSync(this.filePath, "utf8")
483
+ .split("\n")
484
+ .filter((line) => line.trim());
413
485
  if (!lines.length) return false;
414
486
 
415
487
  let header;
@@ -419,107 +491,84 @@ class MumpixStore {
419
491
  return false;
420
492
  }
421
493
 
422
- const good = [];
423
- const seenIds = new Set();
494
+ const recovered = [];
495
+ const seen = new Set();
424
496
  let lastId = 0;
425
- let badFound = false;
497
+ let truncated = false;
426
498
 
427
499
  for (let i = 1; i < lines.length; i++) {
428
500
  try {
429
- const r = JSON.parse(lines[i]);
430
- const validShape = r && Number.isInteger(r.id) && typeof r.content === 'string' && r.content.length > 0;
431
- const monotonic = validShape && r.id > lastId;
432
- const duplicate = validShape && seenIds.has(r.id);
433
- const hashOk = validShape && this._verifyHash(r);
434
- if (!validShape || !monotonic || duplicate || !hashOk) {
435
- badFound = true;
501
+ const record = JSON.parse(lines[i]);
502
+ const shape =
503
+ record &&
504
+ Number.isInteger(record.id) &&
505
+ typeof record.content === "string" &&
506
+ record.content.length > 0;
507
+ const ordered = shape && record.id > lastId;
508
+ const duplicate = shape && seen.has(record.id);
509
+ const validHash = shape && this._verifyHash(record);
510
+ if (!shape || !ordered || duplicate || !validHash) {
511
+ truncated = true;
436
512
  break;
437
513
  }
438
- good.push(r);
439
- seenIds.add(r.id);
440
- lastId = r.id;
514
+ recovered.push(record);
515
+ seen.add(record.id);
516
+ lastId = record.id;
441
517
  } catch (_) {
442
- badFound = true;
518
+ truncated = true;
443
519
  break;
444
520
  }
445
521
  }
446
522
 
447
- if (!badFound) return false;
448
- this._rewriteSnapshot(header, good);
523
+ if (!truncated) return false;
524
+ this._rewriteSnapshot(header, recovered);
449
525
  return true;
450
526
  }
451
527
 
452
528
  _rewriteSnapshot(header, records) {
453
- const tmp = this.filePath + '.tmp.' + process.pid;
529
+ const tmp = `${this.filePath}.tmp.${process.pid}`;
454
530
  const lines = [JSON.stringify(header)];
455
- for (const r of records) lines.push(JSON.stringify(r));
456
- fs.writeFileSync(tmp, lines.join('\n') + '\n', 'utf8');
531
+ for (const record of records) lines.push(JSON.stringify(record));
532
+ fs.writeFileSync(tmp, `${lines.join("\n")}\n`, "utf8");
457
533
  fs.renameSync(tmp, this.filePath);
458
534
  }
459
535
 
460
- _hash(s, prevH = '0', id = 0) {
461
- const secret = process.env.MUMPIX_INTEGRITY_SECRET || '';
462
- const h = crypto.createHmac('sha256', secret)
463
- .update(String(s) + String(prevH) + String(id))
464
- .digest('hex');
465
- return `sha256:${h}`;
466
- }
467
-
468
- _verifyHash(record, prevH = '0') {
469
- if (!record || typeof record.content !== 'string' || typeof record.h !== 'string') return false;
470
-
471
- // Support modern SHA-256 chaining
472
- if (record.h.startsWith('sha256:')) {
473
- const match = this._hash(record.content, prevH, record.id) === record.h;
474
- if (!match) return false;
536
+ _hash(content) {
537
+ const secret = process.env.MUMPIX_INTEGRITY_SECRET;
538
+ if (secret && secret.trim()) {
539
+ return `hmac256:${crypto
540
+ .createHmac("sha256", secret)
541
+ .update(String(content), "utf8")
542
+ .digest("hex")}`;
543
+ }
475
544
 
476
- // In Verified mode, also check the ML-DSA signature if present
477
- if (this.header.consistency === 'verified' && this._mlDsa && this._publicKey) {
478
- if (!record.sig) return false; // Signature mandatory in Verified mode
479
- try {
480
- const msg = Buffer.from(record.h, 'utf8');
481
- const sig = Buffer.from(record.sig, 'hex');
482
- return this._mlDsa.verify(sig, msg, this._publicKey);
483
- } catch {
484
- return false;
485
- }
486
- }
487
- return true;
545
+ let hash = 2166136261;
546
+ for (let i = 0; i < content.length; i++) {
547
+ hash ^= content.charCodeAt(i);
548
+ hash = Math.imul(hash, 16777619);
488
549
  }
550
+ return `0x${(hash >>> 0).toString(16).padStart(8, "0")}`;
551
+ }
489
552
 
490
- // Support legacy hmac256 (non-chained)
491
- if (record.h.startsWith('hmac256:')) {
553
+ _verifyHash(record) {
554
+ if (!record || typeof record.content !== "string" || typeof record.h !== "string") {
555
+ return false;
556
+ }
557
+ if (record.h.startsWith("hmac256:")) {
492
558
  const secret = process.env.MUMPIX_INTEGRITY_SECRET;
493
- if (!secret || !secret.trim()) return false;
494
- const digest = crypto
495
- .createHmac('sha256', secret)
496
- .update(String(record.content), 'utf8')
497
- .digest('hex');
498
- return `hmac256:${digest}` === record.h;
559
+ return !!(secret && secret.trim()) && this._hash(record.content) === record.h;
499
560
  }
500
-
501
- // Backward compatibility for legacy FNV-1a records while migrating.
502
561
  return this._hashLegacy(record.content) === record.h;
503
562
  }
504
563
 
505
- _hashLegacy(s) {
506
- let h = 0x811c9dc5;
507
- for (let i = 0; i < s.length; i++) {
508
- h ^= s.charCodeAt(i);
509
- h = Math.imul(h, 0x01000193);
564
+ _hashLegacy(content) {
565
+ let hash = 2166136261;
566
+ for (let i = 0; i < content.length; i++) {
567
+ hash ^= content.charCodeAt(i);
568
+ hash = Math.imul(hash, 16777619);
510
569
  }
511
- return '0x' + (h >>> 0).toString(16).padStart(8, '0');
570
+ return `0x${(hash >>> 0).toString(16).padStart(8, "0")}`;
512
571
  }
513
572
  }
514
573
 
515
- function sanitizeMeta(meta) {
516
- if (!meta || typeof meta !== 'object') return {};
517
- const out = {};
518
- if (Number.isFinite(meta.ts)) out.ts = Number(meta.ts);
519
- if (typeof meta.workspace === 'string' && meta.workspace.trim()) out.workspace = meta.workspace.trim();
520
- if (typeof meta.repo === 'string' && meta.repo.trim()) out.repo = meta.repo.trim();
521
- if (typeof meta.source === 'string' && meta.source.trim()) out.source = meta.source.trim();
522
- return out;
523
- }
524
-
525
574
  module.exports = { MumpixStore };