mumpix 1.0.20 → 1.1.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 +82 -11
- package/README.md +278 -8
- package/bin/mumpix.js +1 -405
- package/examples/agent-memory.js +1 -1
- package/examples/basic.js +1 -1
- package/examples/behavioral-primitives.js +50 -0
- package/examples/recall-quality-suite.js +156 -0
- package/examples/verified-mode.js +1 -1
- package/package.json +24 -13
- package/scripts/bench-semantic.cjs +76 -0
- package/scripts/test-license-modes.cjs +87 -0
- package/scripts/test-phase0.cjs +137 -0
- package/scripts/test-semantic.cjs +411 -0
- package/src/brp/index.js +1 -0
- package/src/collapse/index.js +1 -0
- package/src/core/MumpixDB.js +264 -323
- package/src/core/audit.js +1 -173
- package/src/core/auth.js +1 -232
- package/src/core/inverted-index.js +249 -0
- package/src/core/license.js +1 -267
- package/src/core/ml-dsa.mjs +1 -25
- package/src/core/ml-kem.mjs +1 -32
- package/src/core/recall.js +226 -112
- package/src/core/semantic-sidecar.js +312 -0
- package/src/core/store.js +365 -288
- package/src/core/wal-writer.js +83 -0
- package/src/index.js +20 -34
- package/src/integrations/developer-sdk.js +1 -165
- package/src/integrations/langchain-official.js +1 -0
- package/src/integrations/langchain.js +1 -131
- package/src/integrations/llamaindex-official.js +1 -0
- package/src/integrations/llamaindex.js +1 -86
- package/src/integrations/vector-sidecar.js +325 -0
- package/src/rlp/index.js +1 -0
- package/src/temporal/engine.js +1 -1894
- package/src/temporal/indexes.js +1 -178
- package/src/temporal/operators.js +1 -186
- package/scripts/postinstall-auth.js +0 -101
package/src/core/store.js
CHANGED
|
@@ -1,64 +1,61 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
26
|
-
this.lockPath = this.filePath
|
|
27
|
-
this.header
|
|
28
|
-
this.records
|
|
29
|
-
this._nextId
|
|
30
|
-
this._fd
|
|
31
|
-
this._lockFd
|
|
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.
|
|
34
|
-
this.
|
|
35
|
-
this.
|
|
37
|
+
this._walWriter = null;
|
|
38
|
+
this._index = null;
|
|
39
|
+
this._recallIndexEnabled = true;
|
|
36
40
|
}
|
|
37
41
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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:
|
|
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(
|
|
75
|
-
hashAlg:
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
118
|
-
|
|
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
|
|
122
|
-
const
|
|
123
|
-
const
|
|
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);
|
|
134
135
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
136
|
+
this._appendWal({ op: "write", entry: record, ts: Date.now() });
|
|
137
|
+
|
|
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
|
-
|
|
143
|
-
this.
|
|
145
|
+
this._clearWAL();
|
|
146
|
+
this.records.push(record);
|
|
147
|
+
if (this._index) this._index.add(record);
|
|
148
|
+
return record;
|
|
149
|
+
}
|
|
144
150
|
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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(
|
|
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:
|
|
210
|
+
path: this.filePath,
|
|
197
211
|
consistency: this.header.consistency,
|
|
198
|
-
records:
|
|
199
|
-
created:
|
|
200
|
-
sizeBytes:
|
|
201
|
-
version:
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
indexedCandidates(query, cap = 400) {
|
|
221
|
+
if (!this._index) return null;
|
|
222
|
+
return this._index.candidates(query, cap);
|
|
223
|
+
}
|
|
209
224
|
|
|
210
|
-
|
|
211
|
-
if (
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
282
|
+
_indexPath() {
|
|
283
|
+
return `${this.filePath}.idx`;
|
|
284
|
+
}
|
|
237
285
|
|
|
238
286
|
_load() {
|
|
239
|
-
const
|
|
240
|
-
|
|
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 !==
|
|
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
|
-
|
|
252
|
-
|
|
299
|
+
const seen = new Set();
|
|
300
|
+
|
|
253
301
|
for (let i = 1; i < lines.length; i++) {
|
|
254
302
|
try {
|
|
255
|
-
const
|
|
256
|
-
if (
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
lastId =
|
|
271
|
-
if (
|
|
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
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
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
|
|
292
|
-
if (
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
const
|
|
296
|
-
const
|
|
297
|
-
if (!
|
|
298
|
-
this.records.push(
|
|
299
|
-
if (
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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=${
|
|
358
|
+
throw new Error(`mumpix: rejected WAL write id=${record.id}`);
|
|
306
359
|
}
|
|
307
|
-
} else if (
|
|
360
|
+
} else if (event.op === "clear") {
|
|
308
361
|
this.records = [];
|
|
309
362
|
this._nextId = 1;
|
|
310
|
-
|
|
311
|
-
prevH = '0';
|
|
363
|
+
seen.clear();
|
|
312
364
|
lastId = 0;
|
|
313
|
-
|
|
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 (
|
|
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)
|
|
379
|
+
fs.writeFileSync(this.filePath, `${JSON.stringify(this.header)}\n`, "utf8");
|
|
329
380
|
}
|
|
330
381
|
|
|
331
382
|
_rewriteFull() {
|
|
332
|
-
|
|
333
|
-
|
|
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
|
|
336
|
-
fs.writeFileSync(tmp, lines.join(
|
|
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,
|
|
397
|
+
this._fd = fs.openSync(this.filePath, "a");
|
|
343
398
|
}
|
|
344
399
|
}
|
|
345
400
|
|
|
346
401
|
_clearWAL() {
|
|
347
|
-
if (
|
|
348
|
-
|
|
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(
|
|
353
|
-
const line = JSON.stringify(
|
|
354
|
-
|
|
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
|
|
420
|
+
fs.writeSync(fd, `${line}\n`, null, "utf8");
|
|
357
421
|
fs.fdatasyncSync(fd);
|
|
358
422
|
} finally {
|
|
359
423
|
fs.closeSync(fd);
|
|
@@ -362,54 +426,90 @@ 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) :
|
|
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
|
-
for (let attempt = 0; attempt <
|
|
432
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
369
433
|
try {
|
|
370
|
-
this._lockFd = fs.openSync(this.lockPath,
|
|
371
|
-
fs.writeSync(this._lockFd, payload, null,
|
|
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 !==
|
|
439
|
+
if (!err || err.code !== "EEXIST") throw err;
|
|
376
440
|
try {
|
|
377
|
-
const
|
|
378
|
-
if (
|
|
441
|
+
const lock = this._readLockPayload();
|
|
442
|
+
if (lock && Number.isInteger(lock.pid) && lock.pid > 0 && !this._pidIsAlive(lock.pid)) {
|
|
443
|
+
fs.unlinkSync(this.lockPath);
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const stat = fs.statSync(this.lockPath);
|
|
448
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
379
449
|
fs.unlinkSync(this.lockPath);
|
|
380
450
|
continue;
|
|
381
451
|
}
|
|
382
452
|
} catch (_) {
|
|
383
|
-
// lock disappeared between checks; retry once
|
|
384
453
|
continue;
|
|
385
454
|
}
|
|
386
455
|
throw new Error(`mumpix: database is locked: ${this.filePath}`);
|
|
387
456
|
}
|
|
388
457
|
}
|
|
458
|
+
|
|
389
459
|
throw new Error(`mumpix: failed to acquire lock: ${this.filePath}`);
|
|
390
460
|
}
|
|
391
461
|
|
|
462
|
+
_readLockPayload() {
|
|
463
|
+
try {
|
|
464
|
+
const raw = fs.readFileSync(this.lockPath, "utf8").trim();
|
|
465
|
+
if (!raw) return null;
|
|
466
|
+
const parsed = JSON.parse(raw);
|
|
467
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
468
|
+
} catch (_) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
_pidIsAlive(pid) {
|
|
474
|
+
if (pid === process.pid) return true;
|
|
475
|
+
try {
|
|
476
|
+
process.kill(pid, 0);
|
|
477
|
+
return true;
|
|
478
|
+
} catch (err) {
|
|
479
|
+
return !(err && err.code === "ESRCH");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
392
483
|
_releaseLock() {
|
|
393
484
|
if (this._lockFd !== null) {
|
|
394
|
-
try {
|
|
485
|
+
try {
|
|
486
|
+
fs.closeSync(this._lockFd);
|
|
487
|
+
} catch (_) {}
|
|
395
488
|
this._lockFd = null;
|
|
396
489
|
}
|
|
397
|
-
try {
|
|
490
|
+
try {
|
|
491
|
+
fs.unlinkSync(this.lockPath);
|
|
492
|
+
} catch (_) {}
|
|
398
493
|
}
|
|
399
494
|
|
|
400
495
|
_canRecoverFromIntegrityError(err) {
|
|
401
496
|
if (!this._strictIntegrity) return false;
|
|
402
497
|
if (!fs.existsSync(this.walPath)) return false;
|
|
403
|
-
const
|
|
404
|
-
return
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
498
|
+
const message = String((err && err.message) || err || "");
|
|
499
|
+
return (
|
|
500
|
+
message.includes("integrity hash mismatch") ||
|
|
501
|
+
message.includes("invalid record order/id") ||
|
|
502
|
+
message.includes("Unexpected token") ||
|
|
503
|
+
err instanceof SyntaxError ||
|
|
504
|
+
message.includes("corrupt or empty file")
|
|
505
|
+
);
|
|
408
506
|
}
|
|
409
507
|
|
|
410
508
|
_recoverFromTailCorruption() {
|
|
411
|
-
const
|
|
412
|
-
|
|
509
|
+
const lines = fs
|
|
510
|
+
.readFileSync(this.filePath, "utf8")
|
|
511
|
+
.split("\n")
|
|
512
|
+
.filter((line) => line.trim());
|
|
413
513
|
if (!lines.length) return false;
|
|
414
514
|
|
|
415
515
|
let header;
|
|
@@ -419,107 +519,84 @@ class MumpixStore {
|
|
|
419
519
|
return false;
|
|
420
520
|
}
|
|
421
521
|
|
|
422
|
-
const
|
|
423
|
-
const
|
|
522
|
+
const recovered = [];
|
|
523
|
+
const seen = new Set();
|
|
424
524
|
let lastId = 0;
|
|
425
|
-
let
|
|
525
|
+
let truncated = false;
|
|
426
526
|
|
|
427
527
|
for (let i = 1; i < lines.length; i++) {
|
|
428
528
|
try {
|
|
429
|
-
const
|
|
430
|
-
const
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
529
|
+
const record = JSON.parse(lines[i]);
|
|
530
|
+
const shape =
|
|
531
|
+
record &&
|
|
532
|
+
Number.isInteger(record.id) &&
|
|
533
|
+
typeof record.content === "string" &&
|
|
534
|
+
record.content.length > 0;
|
|
535
|
+
const ordered = shape && record.id > lastId;
|
|
536
|
+
const duplicate = shape && seen.has(record.id);
|
|
537
|
+
const validHash = shape && this._verifyHash(record);
|
|
538
|
+
if (!shape || !ordered || duplicate || !validHash) {
|
|
539
|
+
truncated = true;
|
|
436
540
|
break;
|
|
437
541
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
lastId =
|
|
542
|
+
recovered.push(record);
|
|
543
|
+
seen.add(record.id);
|
|
544
|
+
lastId = record.id;
|
|
441
545
|
} catch (_) {
|
|
442
|
-
|
|
546
|
+
truncated = true;
|
|
443
547
|
break;
|
|
444
548
|
}
|
|
445
549
|
}
|
|
446
550
|
|
|
447
|
-
if (!
|
|
448
|
-
this._rewriteSnapshot(header,
|
|
551
|
+
if (!truncated) return false;
|
|
552
|
+
this._rewriteSnapshot(header, recovered);
|
|
449
553
|
return true;
|
|
450
554
|
}
|
|
451
555
|
|
|
452
556
|
_rewriteSnapshot(header, records) {
|
|
453
|
-
const tmp = this.filePath
|
|
557
|
+
const tmp = `${this.filePath}.tmp.${process.pid}`;
|
|
454
558
|
const lines = [JSON.stringify(header)];
|
|
455
|
-
for (const
|
|
456
|
-
fs.writeFileSync(tmp, lines.join(
|
|
559
|
+
for (const record of records) lines.push(JSON.stringify(record));
|
|
560
|
+
fs.writeFileSync(tmp, `${lines.join("\n")}\n`, "utf8");
|
|
457
561
|
fs.renameSync(tmp, this.filePath);
|
|
458
562
|
}
|
|
459
563
|
|
|
460
|
-
_hash(
|
|
461
|
-
const secret = process.env.MUMPIX_INTEGRITY_SECRET
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
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;
|
|
564
|
+
_hash(content) {
|
|
565
|
+
const secret = process.env.MUMPIX_INTEGRITY_SECRET;
|
|
566
|
+
if (secret && secret.trim()) {
|
|
567
|
+
return `hmac256:${crypto
|
|
568
|
+
.createHmac("sha256", secret)
|
|
569
|
+
.update(String(content), "utf8")
|
|
570
|
+
.digest("hex")}`;
|
|
571
|
+
}
|
|
475
572
|
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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;
|
|
573
|
+
let hash = 2166136261;
|
|
574
|
+
for (let i = 0; i < content.length; i++) {
|
|
575
|
+
hash ^= content.charCodeAt(i);
|
|
576
|
+
hash = Math.imul(hash, 16777619);
|
|
488
577
|
}
|
|
578
|
+
return `0x${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
579
|
+
}
|
|
489
580
|
|
|
490
|
-
|
|
491
|
-
if (record.h
|
|
581
|
+
_verifyHash(record) {
|
|
582
|
+
if (!record || typeof record.content !== "string" || typeof record.h !== "string") {
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
if (record.h.startsWith("hmac256:")) {
|
|
492
586
|
const secret = process.env.MUMPIX_INTEGRITY_SECRET;
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
.createHmac('sha256', secret)
|
|
496
|
-
.update(String(record.content), 'utf8')
|
|
497
|
-
.digest('hex');
|
|
498
|
-
return `hmac256:${digest}` === record.h;
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
// Backward compatibility for legacy FNV-1a records while migrating.
|
|
587
|
+
return !!(secret && secret.trim()) && this._hash(record.content) === record.h;
|
|
588
|
+
}
|
|
502
589
|
return this._hashLegacy(record.content) === record.h;
|
|
503
590
|
}
|
|
504
591
|
|
|
505
|
-
_hashLegacy(
|
|
506
|
-
let
|
|
507
|
-
for (let i = 0; i <
|
|
508
|
-
|
|
509
|
-
|
|
592
|
+
_hashLegacy(content) {
|
|
593
|
+
let hash = 2166136261;
|
|
594
|
+
for (let i = 0; i < content.length; i++) {
|
|
595
|
+
hash ^= content.charCodeAt(i);
|
|
596
|
+
hash = Math.imul(hash, 16777619);
|
|
510
597
|
}
|
|
511
|
-
return
|
|
598
|
+
return `0x${(hash >>> 0).toString(16).padStart(8, "0")}`;
|
|
512
599
|
}
|
|
513
600
|
}
|
|
514
601
|
|
|
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
602
|
module.exports = { MumpixStore };
|