@yeaft/webchat-agent 0.1.519 → 0.1.520
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/package.json +1 -1
- package/unify/storage/atomic.js +107 -0
- package/unify/storage/compact.js +80 -0
- package/unify/storage/index.js +44 -0
- package/unify/storage/jsonl-index.js +122 -0
- package/unify/storage/jsonl-log.js +222 -0
- package/unify/storage/shard-index.js +158 -0
- package/unify/storage/shard-store.js +317 -0
package/package.json
CHANGED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* atomic.js — Crash-safe single-file writes.
|
|
3
|
+
*
|
|
4
|
+
* task-334o §Δ15 / §Δ22 §Δ23 shard-store foundation.
|
|
5
|
+
*
|
|
6
|
+
* Contract: writeAtomic(path, data) never leaves a half-written file at `path`.
|
|
7
|
+
* If the process crashes at any point, `path` is either the pre-existing content
|
|
8
|
+
* or the new content — never a torn mix. Leftover `*.tmp.<pid>.<n>` files are
|
|
9
|
+
* the only debris; they are safe to delete on boot (see sweepTmp()).
|
|
10
|
+
*
|
|
11
|
+
* Implementation:
|
|
12
|
+
* 1. Write bytes to `path.tmp.<pid>.<counter>` via writeFileSync.
|
|
13
|
+
* 2. fsync the tmp file (force bytes to disk before rename).
|
|
14
|
+
* 3. rename(tmp, path) — POSIX-atomic on same filesystem.
|
|
15
|
+
* 4. fsync the parent dir (persist the rename itself).
|
|
16
|
+
*
|
|
17
|
+
* Step 4 is what most naive "atomic write" implementations skip. Without it,
|
|
18
|
+
* a crash after the rename call returns can still lose the rename on ext4
|
|
19
|
+
* with data=ordered. We do the dir fsync on Linux/macOS; on Windows we skip
|
|
20
|
+
* (fsync on a directory is an error there) and accept the minor risk window.
|
|
21
|
+
*
|
|
22
|
+
* This module has no knowledge of VP/task/message — it's a pure primitive.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import {
|
|
26
|
+
writeFileSync,
|
|
27
|
+
renameSync,
|
|
28
|
+
openSync,
|
|
29
|
+
fsyncSync,
|
|
30
|
+
closeSync,
|
|
31
|
+
existsSync,
|
|
32
|
+
unlinkSync,
|
|
33
|
+
readdirSync,
|
|
34
|
+
} from 'fs';
|
|
35
|
+
import { dirname, basename, join } from 'path';
|
|
36
|
+
|
|
37
|
+
let tmpCounter = 0;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Atomically write `data` (string | Buffer) to `path`.
|
|
41
|
+
* Throws on failure; never leaves `path` in a half-written state.
|
|
42
|
+
*/
|
|
43
|
+
export function writeAtomic(path, data) {
|
|
44
|
+
const dir = dirname(path);
|
|
45
|
+
const tmpPath = `${path}.tmp.${process.pid}.${++tmpCounter}`;
|
|
46
|
+
|
|
47
|
+
writeFileSync(tmpPath, data);
|
|
48
|
+
|
|
49
|
+
// fsync the tmp file so the bytes hit disk before we swap.
|
|
50
|
+
try {
|
|
51
|
+
const fd = openSync(tmpPath, 'r+');
|
|
52
|
+
try {
|
|
53
|
+
fsyncSync(fd);
|
|
54
|
+
} finally {
|
|
55
|
+
closeSync(fd);
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// Best-effort; some filesystems / platforms don't support fsync on a file
|
|
59
|
+
// opened r+. The rename below is still the atomic boundary.
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
renameSync(tmpPath, path);
|
|
63
|
+
|
|
64
|
+
// fsync the parent directory so the rename is durable.
|
|
65
|
+
// Windows: cannot fsync a directory; skip.
|
|
66
|
+
if (process.platform !== 'win32') {
|
|
67
|
+
try {
|
|
68
|
+
const dfd = openSync(dir, 'r');
|
|
69
|
+
try {
|
|
70
|
+
fsyncSync(dfd);
|
|
71
|
+
} finally {
|
|
72
|
+
closeSync(dfd);
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
// Directory fsync is best-effort. A failure here does not invalidate
|
|
76
|
+
// the rename itself; it only weakens durability on power loss.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Remove any leftover `*.tmp.*` files in `dir` from a previous crashed write.
|
|
83
|
+
* Safe to call on boot. Returns the count removed.
|
|
84
|
+
*
|
|
85
|
+
* Only matches the specific `<basename>.tmp.<pid>.<counter>` shape — won't
|
|
86
|
+
* touch user files that happen to end in `.tmp`.
|
|
87
|
+
*/
|
|
88
|
+
export function sweepTmp(dir) {
|
|
89
|
+
if (!existsSync(dir)) return 0;
|
|
90
|
+
let removed = 0;
|
|
91
|
+
for (const name of readdirSync(dir)) {
|
|
92
|
+
if (/\.tmp\.\d+\.\d+$/.test(name)) {
|
|
93
|
+
try {
|
|
94
|
+
unlinkSync(join(dir, name));
|
|
95
|
+
removed++;
|
|
96
|
+
} catch {
|
|
97
|
+
// Ignore — another process may have beaten us to it.
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return removed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Check whether a given file path looks like our tmp sidecar. Test helper. */
|
|
105
|
+
export function isTmpPath(path) {
|
|
106
|
+
return /\.tmp\.\d+\.\d+$/.test(basename(path));
|
|
107
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* compact.js — External compaction entry point for 334g (dream).
|
|
3
|
+
*
|
|
4
|
+
* `runCompact({ dir, threshold, schema })` opens a shard store at `dir`,
|
|
5
|
+
* inspects each shard for softCap breach, and rewrites shard files that
|
|
6
|
+
* need it. Unlike the internal `compactShard` used by `remove()`, this is
|
|
7
|
+
* the call dream uses to periodically reclaim space across the whole store.
|
|
8
|
+
*
|
|
9
|
+
* This module does NOT:
|
|
10
|
+
* - decide what's stale enough to delete (that's dream's job, using
|
|
11
|
+
* `supersededBy` chains)
|
|
12
|
+
* - re-score or re-rank entries
|
|
13
|
+
* - touch memory files belonging to other stores (it only touches `dir`)
|
|
14
|
+
*
|
|
15
|
+
* Parameters:
|
|
16
|
+
* dir : shard-store directory
|
|
17
|
+
* schema : shard schema (shards[], softCap)
|
|
18
|
+
* threshold : optional override — compact any shard where
|
|
19
|
+
* entries >= threshold.entries || bytes >= threshold.bytes
|
|
20
|
+
* (if omitted, uses the schema's softCap)
|
|
21
|
+
* deleteIds : optional array of ids that dream has decided to purge
|
|
22
|
+
* (lets dream do "compact + delete" in one pass)
|
|
23
|
+
*
|
|
24
|
+
* Returns:
|
|
25
|
+
* { compacted: [shardName, ...], deleted: [id, ...], stillOver: [shardName, ...] }
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { openShardStore } from './shard-store.js';
|
|
29
|
+
|
|
30
|
+
export async function runCompact({ dir, schema = {}, threshold, deleteIds = [] } = {}) {
|
|
31
|
+
if (!dir) throw new Error('runCompact: dir required');
|
|
32
|
+
const store = openShardStore(dir, schema);
|
|
33
|
+
const stats = store.stats();
|
|
34
|
+
const compacted = [];
|
|
35
|
+
const deleted = [];
|
|
36
|
+
const stillOver = [];
|
|
37
|
+
|
|
38
|
+
// First pass: honour explicit deletions (dream hands us a hitlist).
|
|
39
|
+
const byShard = new Map();
|
|
40
|
+
for (const id of deleteIds) {
|
|
41
|
+
const entry = store.getIndex().entries.find((e) => e.id === id);
|
|
42
|
+
if (!entry) continue;
|
|
43
|
+
const list = byShard.get(entry.shard) || [];
|
|
44
|
+
list.push(id);
|
|
45
|
+
byShard.set(entry.shard, list);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const [shardName] of byShard) {
|
|
49
|
+
// Use public remove() which already calls the internal compacter.
|
|
50
|
+
for (const id of byShard.get(shardName)) {
|
|
51
|
+
if (store.remove(id)) deleted.push(id);
|
|
52
|
+
}
|
|
53
|
+
compacted.push(shardName);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Second pass: defrag shards whose size still exceeds the threshold.
|
|
57
|
+
const thr = threshold || {};
|
|
58
|
+
for (const shardName of Object.keys(stats.shards)) {
|
|
59
|
+
if (compacted.includes(shardName)) continue;
|
|
60
|
+
const bucket = store.stats().shards[shardName];
|
|
61
|
+
if (!bucket) continue;
|
|
62
|
+
const cap = thr.entries != null || thr.bytes != null
|
|
63
|
+
? thr
|
|
64
|
+
: (schema.softCap?.[shardName] || schema.defaultSoftCap);
|
|
65
|
+
if (!cap) continue;
|
|
66
|
+
const overEntries = cap.entries != null && bucket.entries > cap.entries;
|
|
67
|
+
const overBytes = cap.bytes != null && bucket.bytes > cap.bytes;
|
|
68
|
+
if (overEntries || overBytes) {
|
|
69
|
+
store.compact(shardName);
|
|
70
|
+
compacted.push(shardName);
|
|
71
|
+
// Re-read stats after compaction; if still over, surface to caller.
|
|
72
|
+
const newBucket = store.stats().shards[shardName];
|
|
73
|
+
if (newBucket.entries > (cap.entries ?? Infinity) || newBucket.bytes > (cap.bytes ?? Infinity)) {
|
|
74
|
+
stillOver.push(shardName);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { compacted, deleted, stillOver };
|
|
80
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* storage/ — task-334o Storage Layer v1.
|
|
3
|
+
*
|
|
4
|
+
* Business-semantics-free primitives shared by 334b (group messages),
|
|
5
|
+
* 334f (VP memory), 334l (user memory), 334i (migration), 334n (summaries).
|
|
6
|
+
*
|
|
7
|
+
* Modules:
|
|
8
|
+
* - atomic : writeAtomic (tmp → rename + fsync)
|
|
9
|
+
* - jsonl-log : append-only log with size/line rotation
|
|
10
|
+
* - jsonl-index : segment manifest for jsonl-log
|
|
11
|
+
* - shard-store : schema-aware shard storage (get/put/query/remove/compact)
|
|
12
|
+
* - shard-index : manifest for shard-store
|
|
13
|
+
* - compact : external compaction entry point for dream
|
|
14
|
+
*
|
|
15
|
+
* API stability: 334o freezes its public API. Downstream slices must not
|
|
16
|
+
* request new fields — extensions go through `shard-schema.js` versioning
|
|
17
|
+
* (see slice spec §framework §2).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export { writeAtomic, sweepTmp, isTmpPath } from './atomic.js';
|
|
21
|
+
export { openLog } from './jsonl-log.js';
|
|
22
|
+
export {
|
|
23
|
+
emptyIndex,
|
|
24
|
+
loadIndex,
|
|
25
|
+
saveIndex,
|
|
26
|
+
listSegmentFiles,
|
|
27
|
+
statSegmentFromDisk,
|
|
28
|
+
nextSegmentName,
|
|
29
|
+
INDEX_FILE,
|
|
30
|
+
INDEX_VERSION,
|
|
31
|
+
} from './jsonl-index.js';
|
|
32
|
+
export { openShardStore } from './shard-store.js';
|
|
33
|
+
export {
|
|
34
|
+
emptyShardIndex,
|
|
35
|
+
loadShardIndex,
|
|
36
|
+
saveShardIndex,
|
|
37
|
+
rebuildShardIndexFromDisk,
|
|
38
|
+
shardFileName,
|
|
39
|
+
START_MARK,
|
|
40
|
+
END_MARK,
|
|
41
|
+
SHARD_INDEX_FILE,
|
|
42
|
+
SHARD_INDEX_VERSION,
|
|
43
|
+
} from './shard-index.js';
|
|
44
|
+
export { runCompact } from './compact.js';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonl-index.js — Manifest for a jsonl-log directory.
|
|
3
|
+
*
|
|
4
|
+
* The index file tracks which segment files cover which ID/timestamp ranges
|
|
5
|
+
* so `jsonl-log.readRange` can do O(1) segment selection without scanning
|
|
6
|
+
* the whole log. It's rewritten atomically on every rotation.
|
|
7
|
+
*
|
|
8
|
+
* Schema (index.json):
|
|
9
|
+
* {
|
|
10
|
+
* version: 1,
|
|
11
|
+
* nextId: <number | null>, // optional — caller-managed id counter
|
|
12
|
+
* segments: [
|
|
13
|
+
* { file:"000001.jsonl", firstId, lastId, firstTs, lastTs, count, bytes }
|
|
14
|
+
* ]
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* This module owns ONLY the manifest — it does not read or write the jsonl
|
|
18
|
+
* segments themselves. jsonl-log.js drives rotation and hands us updated
|
|
19
|
+
* segment metadata.
|
|
20
|
+
*
|
|
21
|
+
* No business semantics. "id" and "ts" are opaque; we don't care if they're
|
|
22
|
+
* msg_xxx, mem_xxx, numeric, or empty.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { readFileSync, existsSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
26
|
+
import { join } from 'path';
|
|
27
|
+
import { writeAtomic } from './atomic.js';
|
|
28
|
+
|
|
29
|
+
export const INDEX_FILE = 'index.json';
|
|
30
|
+
export const INDEX_VERSION = 1;
|
|
31
|
+
|
|
32
|
+
/** Build an empty manifest. */
|
|
33
|
+
export function emptyIndex() {
|
|
34
|
+
return { version: INDEX_VERSION, nextId: null, segments: [] };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Load the index from `dir/index.json`. Returns `null` if the file is missing,
|
|
39
|
+
* unreadable, or corrupt (caller should rebuild). Does NOT auto-rebuild —
|
|
40
|
+
* the caller decides whether a missing / corrupt index is fatal.
|
|
41
|
+
*/
|
|
42
|
+
export function loadIndex(dir) {
|
|
43
|
+
const path = join(dir, INDEX_FILE);
|
|
44
|
+
if (!existsSync(path)) return null;
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(path, 'utf8');
|
|
47
|
+
const parsed = JSON.parse(raw);
|
|
48
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
49
|
+
if (!Array.isArray(parsed.segments)) return null;
|
|
50
|
+
return {
|
|
51
|
+
version: parsed.version || INDEX_VERSION,
|
|
52
|
+
nextId: parsed.nextId ?? null,
|
|
53
|
+
segments: parsed.segments,
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Atomically persist an index. Creates `dir` if missing. */
|
|
61
|
+
export function saveIndex(dir, index) {
|
|
62
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
63
|
+
const payload = JSON.stringify({
|
|
64
|
+
version: INDEX_VERSION,
|
|
65
|
+
nextId: index.nextId ?? null,
|
|
66
|
+
segments: index.segments,
|
|
67
|
+
}, null, 2);
|
|
68
|
+
writeAtomic(join(dir, INDEX_FILE), payload);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* List *.jsonl segment files in `dir`, sorted lexicographically.
|
|
73
|
+
* Returns names only, not full paths.
|
|
74
|
+
*/
|
|
75
|
+
export function listSegmentFiles(dir) {
|
|
76
|
+
if (!existsSync(dir)) return [];
|
|
77
|
+
return readdirSync(dir)
|
|
78
|
+
.filter((n) => /^\d+\.jsonl$/.test(n))
|
|
79
|
+
.sort();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Produce an index entry by reading a segment file off disk. Used when
|
|
84
|
+
* recovering from a missing/corrupt index.json.
|
|
85
|
+
*
|
|
86
|
+
* Caller provides `parseLine(line) -> { id, ts }` so we can populate the
|
|
87
|
+
* firstId/lastId/firstTs/lastTs fields without this module knowing the schema.
|
|
88
|
+
* Malformed lines are skipped (best-effort rebuild).
|
|
89
|
+
*/
|
|
90
|
+
export function statSegmentFromDisk(dir, fileName, parseLine) {
|
|
91
|
+
const path = join(dir, fileName);
|
|
92
|
+
const bytes = statSync(path).size;
|
|
93
|
+
const raw = readFileSync(path, 'utf8');
|
|
94
|
+
const lines = raw.split('\n').filter((l) => l.length > 0);
|
|
95
|
+
let firstId = null, lastId = null, firstTs = null, lastTs = null;
|
|
96
|
+
let count = 0;
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = parseLine(line);
|
|
101
|
+
} catch {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
if (!parsed) continue;
|
|
105
|
+
count++;
|
|
106
|
+
if (firstId === null) firstId = parsed.id ?? null;
|
|
107
|
+
lastId = parsed.id ?? lastId;
|
|
108
|
+
if (firstTs === null) firstTs = parsed.ts ?? null;
|
|
109
|
+
lastTs = parsed.ts ?? lastTs;
|
|
110
|
+
}
|
|
111
|
+
return { file: fileName, firstId, lastId, firstTs, lastTs, count, bytes };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Pick the highest segment number in `segments` (caller decides next filename). */
|
|
115
|
+
export function nextSegmentName(segments) {
|
|
116
|
+
let max = 0;
|
|
117
|
+
for (const seg of segments) {
|
|
118
|
+
const m = /^(\d+)\.jsonl$/.exec(seg.file);
|
|
119
|
+
if (m) max = Math.max(max, parseInt(m[1], 10));
|
|
120
|
+
}
|
|
121
|
+
return String(max + 1).padStart(6, '0') + '.jsonl';
|
|
122
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* jsonl-log.js — Append-only JSONL log with size-based rotation.
|
|
3
|
+
*
|
|
4
|
+
* task-334o acceptance criterion 1:
|
|
5
|
+
* - append single-line write < 2ms on local SSD
|
|
6
|
+
* - rotate at maxSegmentBytes=1 MiB OR maxSegmentLines=5000 (first to hit)
|
|
7
|
+
* - index.json is atomically updated on every rotation
|
|
8
|
+
*
|
|
9
|
+
* Layout (managed here):
|
|
10
|
+
* <dir>/000001.jsonl
|
|
11
|
+
* <dir>/000002.jsonl
|
|
12
|
+
* <dir>/index.json
|
|
13
|
+
*
|
|
14
|
+
* API:
|
|
15
|
+
* const log = openLog(dir, { maxSegmentBytes, maxSegmentLines, parseLine });
|
|
16
|
+
* log.append(obj) // writes JSON.stringify(obj) + '\n'
|
|
17
|
+
* log.readRange(firstId, lastId) // iterable of records in [firstId, lastId]
|
|
18
|
+
* log.streamAll() // iterable of all records, oldest -> newest
|
|
19
|
+
* log.rotate() // force rotate (test hook)
|
|
20
|
+
* log.close() // close fd, flush index
|
|
21
|
+
*
|
|
22
|
+
* No business semantics: the log does not know what an id means; it only
|
|
23
|
+
* needs the caller to provide a `parseLine(line) -> {id, ts}` so the index
|
|
24
|
+
* metadata can be rebuilt from disk if index.json is lost or corrupt.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
openSync,
|
|
29
|
+
closeSync,
|
|
30
|
+
writeSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
existsSync,
|
|
33
|
+
mkdirSync,
|
|
34
|
+
statSync,
|
|
35
|
+
} from 'fs';
|
|
36
|
+
import { join } from 'path';
|
|
37
|
+
import {
|
|
38
|
+
loadIndex,
|
|
39
|
+
saveIndex,
|
|
40
|
+
listSegmentFiles,
|
|
41
|
+
statSegmentFromDisk,
|
|
42
|
+
nextSegmentName,
|
|
43
|
+
emptyIndex,
|
|
44
|
+
} from './jsonl-index.js';
|
|
45
|
+
|
|
46
|
+
const DEFAULT_MAX_BYTES = 1 * 1024 * 1024; // 1 MiB
|
|
47
|
+
const DEFAULT_MAX_LINES = 5000;
|
|
48
|
+
|
|
49
|
+
const defaultParseLine = (line) => {
|
|
50
|
+
const obj = JSON.parse(line);
|
|
51
|
+
return { id: obj.id ?? null, ts: obj.ts ?? null };
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Open (or create) an append-only JSONL log rooted at `dir`.
|
|
56
|
+
* On startup, verifies / rebuilds index.json against files on disk.
|
|
57
|
+
*/
|
|
58
|
+
export function openLog(dir, opts = {}) {
|
|
59
|
+
const maxSegmentBytes = opts.maxSegmentBytes ?? DEFAULT_MAX_BYTES;
|
|
60
|
+
const maxSegmentLines = opts.maxSegmentLines ?? DEFAULT_MAX_LINES;
|
|
61
|
+
const parseLine = opts.parseLine ?? defaultParseLine;
|
|
62
|
+
|
|
63
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
64
|
+
|
|
65
|
+
let index = loadIndex(dir);
|
|
66
|
+
const filesOnDisk = listSegmentFiles(dir);
|
|
67
|
+
|
|
68
|
+
// Rebuild index if missing, corrupt, or out-of-sync with actual segment files.
|
|
69
|
+
if (!index || !indexMatchesDisk(index, filesOnDisk)) {
|
|
70
|
+
index = rebuildIndexFromDisk(dir, filesOnDisk, parseLine);
|
|
71
|
+
saveIndex(dir, index);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Current segment = last one in the index; create 000001.jsonl if empty.
|
|
75
|
+
let current = index.segments[index.segments.length - 1];
|
|
76
|
+
if (!current) {
|
|
77
|
+
current = {
|
|
78
|
+
file: '000001.jsonl',
|
|
79
|
+
firstId: null, lastId: null,
|
|
80
|
+
firstTs: null, lastTs: null,
|
|
81
|
+
count: 0, bytes: 0,
|
|
82
|
+
};
|
|
83
|
+
index.segments.push(current);
|
|
84
|
+
// Touch the file so the fd is openable.
|
|
85
|
+
const path = join(dir, current.file);
|
|
86
|
+
if (!existsSync(path)) closeSync(openSync(path, 'a'));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let fd = openSync(join(dir, current.file), 'a');
|
|
90
|
+
|
|
91
|
+
/** Detect & perform rotation when current segment is full. */
|
|
92
|
+
function maybeRotate() {
|
|
93
|
+
if (current.bytes >= maxSegmentBytes || current.count >= maxSegmentLines) {
|
|
94
|
+
rotate();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function rotate() {
|
|
99
|
+
// Close current fd.
|
|
100
|
+
closeSync(fd);
|
|
101
|
+
// Open new segment.
|
|
102
|
+
const newFile = nextSegmentName(index.segments);
|
|
103
|
+
const newSeg = {
|
|
104
|
+
file: newFile,
|
|
105
|
+
firstId: null, lastId: null,
|
|
106
|
+
firstTs: null, lastTs: null,
|
|
107
|
+
count: 0, bytes: 0,
|
|
108
|
+
};
|
|
109
|
+
index.segments.push(newSeg);
|
|
110
|
+
current = newSeg;
|
|
111
|
+
// Persist the rotation atomically before we start writing to the new file
|
|
112
|
+
// so a crash mid-rotation doesn't lose the boundary.
|
|
113
|
+
saveIndex(dir, index);
|
|
114
|
+
fd = openSync(join(dir, current.file), 'a');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function append(obj) {
|
|
118
|
+
const line = JSON.stringify(obj) + '\n';
|
|
119
|
+
const buf = Buffer.from(line, 'utf8');
|
|
120
|
+
writeSync(fd, buf, 0, buf.length);
|
|
121
|
+
current.count += 1;
|
|
122
|
+
current.bytes += buf.length;
|
|
123
|
+
const id = obj.id ?? null;
|
|
124
|
+
const ts = obj.ts ?? null;
|
|
125
|
+
if (current.firstId === null) current.firstId = id;
|
|
126
|
+
current.lastId = id;
|
|
127
|
+
if (current.firstTs === null) current.firstTs = ts;
|
|
128
|
+
current.lastTs = ts;
|
|
129
|
+
maybeRotate();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function* streamAll() {
|
|
133
|
+
for (const seg of index.segments) {
|
|
134
|
+
const path = join(dir, seg.file);
|
|
135
|
+
if (!existsSync(path)) continue;
|
|
136
|
+
const raw = readFileSync(path, 'utf8');
|
|
137
|
+
for (const line of raw.split('\n')) {
|
|
138
|
+
if (!line) continue;
|
|
139
|
+
try {
|
|
140
|
+
yield JSON.parse(line);
|
|
141
|
+
} catch {
|
|
142
|
+
// Skip malformed line — don't crash the read pipeline.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Read records whose id falls in `[firstId, lastId]` inclusive (string or number). */
|
|
149
|
+
function* readRange(firstId, lastId) {
|
|
150
|
+
for (const seg of index.segments) {
|
|
151
|
+
if (!segmentOverlaps(seg, firstId, lastId)) continue;
|
|
152
|
+
const path = join(dir, seg.file);
|
|
153
|
+
if (!existsSync(path)) continue;
|
|
154
|
+
const raw = readFileSync(path, 'utf8');
|
|
155
|
+
for (const line of raw.split('\n')) {
|
|
156
|
+
if (!line) continue;
|
|
157
|
+
let obj;
|
|
158
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
159
|
+
const id = obj.id ?? null;
|
|
160
|
+
if (id === null) continue;
|
|
161
|
+
if (idBetween(id, firstId, lastId)) yield obj;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function setNextId(n) { index.nextId = n; saveIndex(dir, index); }
|
|
167
|
+
function getNextId() { return index.nextId; }
|
|
168
|
+
function getIndex() { return index; }
|
|
169
|
+
function flushIndex() { saveIndex(dir, index); }
|
|
170
|
+
|
|
171
|
+
function close() {
|
|
172
|
+
try { closeSync(fd); } catch { /* already closed */ }
|
|
173
|
+
saveIndex(dir, index);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
append, readRange, streamAll, rotate, close,
|
|
178
|
+
setNextId, getNextId, getIndex, flushIndex,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True when index's segment list matches exactly the files on disk. */
|
|
183
|
+
function indexMatchesDisk(index, filesOnDisk) {
|
|
184
|
+
const indexFiles = index.segments.map((s) => s.file);
|
|
185
|
+
if (indexFiles.length !== filesOnDisk.length) return false;
|
|
186
|
+
for (let i = 0; i < indexFiles.length; i++) {
|
|
187
|
+
if (indexFiles[i] !== filesOnDisk[i]) return false;
|
|
188
|
+
}
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Rebuild the index by scanning every segment file on disk. Expensive but
|
|
194
|
+
* only happens on first open or after index.json loss.
|
|
195
|
+
*/
|
|
196
|
+
function rebuildIndexFromDisk(dir, files, parseLine) {
|
|
197
|
+
const idx = emptyIndex();
|
|
198
|
+
for (const file of files) {
|
|
199
|
+
idx.segments.push(statSegmentFromDisk(dir, file, parseLine));
|
|
200
|
+
}
|
|
201
|
+
return idx;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Cheap overlap test. If a segment's firstId/lastId are null (empty segment),
|
|
206
|
+
* we treat it as non-overlapping.
|
|
207
|
+
*/
|
|
208
|
+
function segmentOverlaps(seg, first, last) {
|
|
209
|
+
if (seg.firstId == null || seg.lastId == null) return false;
|
|
210
|
+
// Ordering on strings is lexicographic which matches ULID-style ids used
|
|
211
|
+
// by the product (msg_01HW...). For numeric ids, JS > / < works too.
|
|
212
|
+
return !(compareId(seg.lastId, first) < 0 || compareId(seg.firstId, last) > 0);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function idBetween(id, first, last) {
|
|
216
|
+
return compareId(id, first) >= 0 && compareId(id, last) <= 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function compareId(a, b) {
|
|
220
|
+
if (a === b) return 0;
|
|
221
|
+
return a < b ? -1 : 1;
|
|
222
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shard-index.js — Manifest for shard-store.
|
|
3
|
+
*
|
|
4
|
+
* shard-store keeps N shard files (one per schema.shard value) and one
|
|
5
|
+
* `index.json` that maps entry ids → their shard + byte range. This module
|
|
6
|
+
* owns only the manifest; shard-store.js drives writes.
|
|
7
|
+
*
|
|
8
|
+
* Schema (index.json):
|
|
9
|
+
* {
|
|
10
|
+
* version: 1,
|
|
11
|
+
* entries: [{
|
|
12
|
+
* id, shard,
|
|
13
|
+
* byteOffset, byteLen, // byte range inside shard file
|
|
14
|
+
* meta: { kind?, tags?, pinned?, ...caller-chosen }
|
|
15
|
+
* }],
|
|
16
|
+
* shards: {
|
|
17
|
+
* <name>: { entries: <count>, bytes: <size>, softCap: { entries, bytes } }
|
|
18
|
+
* }
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* entries[] is append-style but we rewrite it atomically on every mutation.
|
|
22
|
+
* At ~thousands of entries this is still cheap (<10 KB JSON) and keeps the
|
|
23
|
+
* read path O(1) — the full index loads into memory on open.
|
|
24
|
+
*
|
|
25
|
+
* If index.json is lost or corrupt, shard-store rebuilds it by scanning the
|
|
26
|
+
* shard markdown files for <!--entry:<id>:START/END--> delimiters.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, mkdirSync, readdirSync, statSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { writeAtomic } from './atomic.js';
|
|
32
|
+
|
|
33
|
+
export const SHARD_INDEX_FILE = 'index.json';
|
|
34
|
+
export const SHARD_INDEX_VERSION = 1;
|
|
35
|
+
|
|
36
|
+
export const START_MARK = (id) => `<!--entry:${id}:START-->`;
|
|
37
|
+
export const END_MARK = (id) => `<!--entry:${id}:END-->`;
|
|
38
|
+
|
|
39
|
+
/** Regex that matches any start or end delimiter. */
|
|
40
|
+
const ENTRY_MARK_RE = /<!--entry:([A-Za-z0-9_\-]+):(START|END)-->/g;
|
|
41
|
+
|
|
42
|
+
export function emptyShardIndex() {
|
|
43
|
+
return { version: SHARD_INDEX_VERSION, entries: [], shards: {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function loadShardIndex(dir) {
|
|
47
|
+
const path = join(dir, SHARD_INDEX_FILE);
|
|
48
|
+
if (!existsSync(path)) return null;
|
|
49
|
+
try {
|
|
50
|
+
const raw = readFileSync(path, 'utf8');
|
|
51
|
+
const parsed = JSON.parse(raw);
|
|
52
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
53
|
+
if (!Array.isArray(parsed.entries)) return null;
|
|
54
|
+
if (!parsed.shards || typeof parsed.shards !== 'object') return null;
|
|
55
|
+
return {
|
|
56
|
+
version: parsed.version || SHARD_INDEX_VERSION,
|
|
57
|
+
entries: parsed.entries,
|
|
58
|
+
shards: parsed.shards,
|
|
59
|
+
};
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function saveShardIndex(dir, index) {
|
|
66
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
67
|
+
const payload = JSON.stringify({
|
|
68
|
+
version: SHARD_INDEX_VERSION,
|
|
69
|
+
entries: index.entries,
|
|
70
|
+
shards: index.shards,
|
|
71
|
+
}, null, 2);
|
|
72
|
+
writeAtomic(join(dir, SHARD_INDEX_FILE), payload);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Compose the on-disk filename for a shard (schema may customise). */
|
|
76
|
+
export function shardFileName(shardName) {
|
|
77
|
+
return `memory-${shardName}.md`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Scan shard files in `dir` and rebuild the index entirely from disk.
|
|
82
|
+
* Relies only on the <!--entry:<id>:START/END--> delimiters. Returns a
|
|
83
|
+
* fresh index object. Caller is responsible for populating `meta` again
|
|
84
|
+
* by reading each entry's frontmatter if they need it — this module does
|
|
85
|
+
* not parse the entry body (keeping the store schema-agnostic).
|
|
86
|
+
*/
|
|
87
|
+
export function rebuildShardIndexFromDisk(dir, schema) {
|
|
88
|
+
const index = emptyShardIndex();
|
|
89
|
+
if (!existsSync(dir)) return index;
|
|
90
|
+
|
|
91
|
+
// Preseed shard buckets from the schema so even empty shards show up.
|
|
92
|
+
for (const shardName of schema.shards || []) {
|
|
93
|
+
index.shards[shardName] = {
|
|
94
|
+
entries: 0,
|
|
95
|
+
bytes: 0,
|
|
96
|
+
softCap: schema.softCap?.[shardName] || schema.defaultSoftCap || null,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const name of readdirSync(dir)) {
|
|
101
|
+
if (!name.startsWith('memory-') || !name.endsWith('.md')) continue;
|
|
102
|
+
const shardName = name.slice('memory-'.length, -'.md'.length);
|
|
103
|
+
const path = join(dir, name);
|
|
104
|
+
const body = readFileSync(path, 'utf8');
|
|
105
|
+
const bytes = statSync(path).size;
|
|
106
|
+
|
|
107
|
+
// Ensure bucket exists even if schema didn't preseed this shard.
|
|
108
|
+
if (!index.shards[shardName]) {
|
|
109
|
+
index.shards[shardName] = {
|
|
110
|
+
entries: 0,
|
|
111
|
+
bytes,
|
|
112
|
+
softCap: schema.defaultSoftCap || null,
|
|
113
|
+
};
|
|
114
|
+
} else {
|
|
115
|
+
index.shards[shardName].bytes = bytes;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Walk START/END pairs. Tolerate out-of-order markers by matching by id.
|
|
119
|
+
const starts = new Map();
|
|
120
|
+
ENTRY_MARK_RE.lastIndex = 0;
|
|
121
|
+
let m;
|
|
122
|
+
while ((m = ENTRY_MARK_RE.exec(body))) {
|
|
123
|
+
const id = m[1];
|
|
124
|
+
const kind = m[2];
|
|
125
|
+
if (kind === 'START') {
|
|
126
|
+
starts.set(id, m.index);
|
|
127
|
+
} else if (kind === 'END' && starts.has(id)) {
|
|
128
|
+
const startIdx = starts.get(id);
|
|
129
|
+
const endIdx = m.index + m[0].length;
|
|
130
|
+
index.entries.push({
|
|
131
|
+
id,
|
|
132
|
+
shard: shardName,
|
|
133
|
+
byteOffset: startIdx,
|
|
134
|
+
byteLen: endIdx - startIdx,
|
|
135
|
+
meta: {},
|
|
136
|
+
});
|
|
137
|
+
index.shards[shardName].entries += 1;
|
|
138
|
+
starts.delete(id);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return index;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Upsert (or insert) a single entry record. Mutates `index` in place. */
|
|
146
|
+
export function putEntryRecord(index, record) {
|
|
147
|
+
const i = index.entries.findIndex((e) => e.id === record.id);
|
|
148
|
+
if (i >= 0) index.entries[i] = record;
|
|
149
|
+
else index.entries.push(record);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Remove an entry record by id. Returns the removed record or null. */
|
|
153
|
+
export function removeEntryRecord(index, id) {
|
|
154
|
+
const i = index.entries.findIndex((e) => e.id === id);
|
|
155
|
+
if (i < 0) return null;
|
|
156
|
+
const [removed] = index.entries.splice(i, 1);
|
|
157
|
+
return removed;
|
|
158
|
+
}
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shard-store.js — Schema-aware shard storage on top of shard-index.
|
|
3
|
+
*
|
|
4
|
+
* Stores opaque "entries" across a small number of shard files. Each entry
|
|
5
|
+
* is a chunk of text (typically the serialised body the caller supplies),
|
|
6
|
+
* bracketed by `<!--entry:<id>:START-->` / `<!--entry:<id>:END-->` delimiters.
|
|
7
|
+
*
|
|
8
|
+
* Caller provides a `schema` describing:
|
|
9
|
+
* - shards : allowed shard names (open set if undefined)
|
|
10
|
+
* - softCap : per-shard { entries, bytes } soft limit
|
|
11
|
+
* - defaultSoftCap : fallback for shards not explicitly listed
|
|
12
|
+
*
|
|
13
|
+
* API surface (§10 acceptance):
|
|
14
|
+
* put(entry) → { id, shard, needsRecompression }
|
|
15
|
+
* get(id) → { id, shard, body, meta } | null
|
|
16
|
+
* query(filter) → { results: [...], needsRecompression: [shard names] }
|
|
17
|
+
* remove(id) → boolean
|
|
18
|
+
* compact(shardName?) → rewrites shard(s) to strip tombstone gaps
|
|
19
|
+
*
|
|
20
|
+
* What this module does NOT know:
|
|
21
|
+
* - What an entry body means (kind, sourceRef, superseded chains...).
|
|
22
|
+
* It only reads meta fields the caller surfaces through `entry.meta`
|
|
23
|
+
* for query filtering.
|
|
24
|
+
* - What a VP, task, group, or message is.
|
|
25
|
+
* - When to compact. Compaction is a separate primitive called by 334g
|
|
26
|
+
* (dream). This module only surfaces `needsRecompression` advisory.
|
|
27
|
+
*
|
|
28
|
+
* Soft-cap semantics (acceptance #4):
|
|
29
|
+
* When a shard exceeds its softCap, operations succeed normally but the
|
|
30
|
+
* return value carries `needsRecompression: true` (put) or the shard
|
|
31
|
+
* name is listed in `result.needsRecompression` (query). The store never
|
|
32
|
+
* auto-compacts in response.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
readFileSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
appendFileSync,
|
|
40
|
+
statSync,
|
|
41
|
+
} from 'fs';
|
|
42
|
+
import { join } from 'path';
|
|
43
|
+
import { writeAtomic } from './atomic.js';
|
|
44
|
+
import {
|
|
45
|
+
loadShardIndex,
|
|
46
|
+
saveShardIndex,
|
|
47
|
+
rebuildShardIndexFromDisk,
|
|
48
|
+
putEntryRecord,
|
|
49
|
+
removeEntryRecord,
|
|
50
|
+
shardFileName,
|
|
51
|
+
START_MARK,
|
|
52
|
+
END_MARK,
|
|
53
|
+
emptyShardIndex,
|
|
54
|
+
} from './shard-index.js';
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Open (or create) a shard store rooted at `dir`.
|
|
58
|
+
* `schema` example:
|
|
59
|
+
* {
|
|
60
|
+
* shards: ['skill', 'lessons', 'preferences', 'relations'],
|
|
61
|
+
* softCap: {
|
|
62
|
+
* skill: { entries: 80, bytes: 64 * 1024 },
|
|
63
|
+
* lessons: { entries: 80, bytes: 64 * 1024 },
|
|
64
|
+
* },
|
|
65
|
+
* defaultSoftCap: { entries: 150, bytes: 128 * 1024 },
|
|
66
|
+
* }
|
|
67
|
+
*/
|
|
68
|
+
export function openShardStore(dir, schema = {}) {
|
|
69
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
70
|
+
const shardSchema = normaliseSchema(schema);
|
|
71
|
+
|
|
72
|
+
let index = loadShardIndex(dir);
|
|
73
|
+
if (!index || !indexLooksConsistent(dir, index)) {
|
|
74
|
+
index = rebuildShardIndexFromDisk(dir, shardSchema);
|
|
75
|
+
// Preserve meta from the old index if rebuild lost it and we have a
|
|
76
|
+
// readable on-disk frontmatter strategy — out of scope for 334o; callers
|
|
77
|
+
// re-hydrate meta through `refreshMeta()` below if they care.
|
|
78
|
+
saveShardIndex(dir, index);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Write a fresh entry to a shard file, append-style. */
|
|
82
|
+
function put(entry) {
|
|
83
|
+
validateEntry(entry, shardSchema);
|
|
84
|
+
|
|
85
|
+
// Remove old copy if same id exists (keeps "put" upsert-like).
|
|
86
|
+
const existing = index.entries.find((e) => e.id === entry.id);
|
|
87
|
+
if (existing) {
|
|
88
|
+
compactShard(existing.shard, [entry.id]);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const shard = entry.shard;
|
|
92
|
+
const path = join(dir, shardFileName(shard));
|
|
93
|
+
const payload = formatEntry(entry);
|
|
94
|
+
|
|
95
|
+
// byteOffset is the size of the file BEFORE we append.
|
|
96
|
+
const byteOffset = existsSync(path) ? statSync(path).size : 0;
|
|
97
|
+
appendFileSync(path, payload);
|
|
98
|
+
const byteLen = Buffer.byteLength(payload, 'utf8');
|
|
99
|
+
|
|
100
|
+
putEntryRecord(index, {
|
|
101
|
+
id: entry.id,
|
|
102
|
+
shard,
|
|
103
|
+
byteOffset,
|
|
104
|
+
byteLen,
|
|
105
|
+
meta: sanitiseMeta(entry.meta),
|
|
106
|
+
});
|
|
107
|
+
updateShardStats(index, shard, path);
|
|
108
|
+
saveShardIndex(dir, index);
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: entry.id,
|
|
112
|
+
shard,
|
|
113
|
+
needsRecompression: isOverSoftCap(index, shard, shardSchema),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Read one entry by id. Returns null if absent. */
|
|
118
|
+
function get(id) {
|
|
119
|
+
const rec = index.entries.find((e) => e.id === id);
|
|
120
|
+
if (!rec) return null;
|
|
121
|
+
const path = join(dir, shardFileName(rec.shard));
|
|
122
|
+
if (!existsSync(path)) return null;
|
|
123
|
+
const raw = readFileSync(path, 'utf8');
|
|
124
|
+
// Slice by byte range is approximate for multi-byte UTF-8 — we use the
|
|
125
|
+
// delimiter as the authoritative boundary to stay safe on emoji etc.
|
|
126
|
+
const body = extractBody(raw, id);
|
|
127
|
+
if (body === null) return null;
|
|
128
|
+
return { id, shard: rec.shard, body, meta: rec.meta || {} };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Filter entries in-memory. Filter fields:
|
|
133
|
+
* shard : string | string[] exact shard match
|
|
134
|
+
* kind : string | string[] matches meta.kind
|
|
135
|
+
* tags : string[] entry must contain ALL listed tags
|
|
136
|
+
* pinned: boolean exact match on meta.pinned
|
|
137
|
+
* where : (rec) => boolean escape hatch
|
|
138
|
+
*/
|
|
139
|
+
function query(filter = {}) {
|
|
140
|
+
const { shard, kind, tags, pinned, where } = filter;
|
|
141
|
+
const results = [];
|
|
142
|
+
for (const rec of index.entries) {
|
|
143
|
+
if (shard && !matchesOneOf(rec.shard, shard)) continue;
|
|
144
|
+
if (kind && !matchesOneOf(rec.meta?.kind, kind)) continue;
|
|
145
|
+
if (pinned !== undefined && Boolean(rec.meta?.pinned) !== Boolean(pinned)) continue;
|
|
146
|
+
if (tags && tags.length > 0) {
|
|
147
|
+
const recTags = rec.meta?.tags || [];
|
|
148
|
+
if (!tags.every((t) => recTags.includes(t))) continue;
|
|
149
|
+
}
|
|
150
|
+
if (where && !where(rec)) continue;
|
|
151
|
+
results.push(rec);
|
|
152
|
+
}
|
|
153
|
+
// Surface which shards need re-compression so dream can schedule work.
|
|
154
|
+
const over = [];
|
|
155
|
+
for (const name of Object.keys(index.shards)) {
|
|
156
|
+
if (isOverSoftCap(index, name, shardSchema)) over.push(name);
|
|
157
|
+
}
|
|
158
|
+
return { results, needsRecompression: over };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Delete one entry; compacts the shard to reclaim space immediately. */
|
|
162
|
+
function remove(id) {
|
|
163
|
+
const rec = index.entries.find((e) => e.id === id);
|
|
164
|
+
if (!rec) return false;
|
|
165
|
+
compactShard(rec.shard, [id]);
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Rewrite a shard file, omitting the entries listed in `deleteIds`.
|
|
171
|
+
* Exposed as both the implementation of `remove` and the public compact
|
|
172
|
+
* primitive used by `compact()` (no deletions, just defrag).
|
|
173
|
+
*/
|
|
174
|
+
function compactShard(shardName, deleteIds = []) {
|
|
175
|
+
const path = join(dir, shardFileName(shardName));
|
|
176
|
+
if (!existsSync(path)) return;
|
|
177
|
+
const raw = readFileSync(path, 'utf8');
|
|
178
|
+
const keepIds = index.entries
|
|
179
|
+
.filter((e) => e.shard === shardName && !deleteIds.includes(e.id))
|
|
180
|
+
.map((e) => e.id);
|
|
181
|
+
const parts = [];
|
|
182
|
+
for (const id of keepIds) {
|
|
183
|
+
const body = extractBody(raw, id);
|
|
184
|
+
if (body === null) continue;
|
|
185
|
+
parts.push(formatEntry({ id, shard: shardName, body, meta: null }));
|
|
186
|
+
}
|
|
187
|
+
writeAtomic(path, parts.join(''));
|
|
188
|
+
|
|
189
|
+
// Update in-memory records with their new byte offsets.
|
|
190
|
+
let cursor = 0;
|
|
191
|
+
for (let i = 0; i < keepIds.length; i++) {
|
|
192
|
+
const id = keepIds[i];
|
|
193
|
+
const rec = index.entries.find((e) => e.id === id);
|
|
194
|
+
const part = parts[i];
|
|
195
|
+
const len = Buffer.byteLength(part, 'utf8');
|
|
196
|
+
rec.byteOffset = cursor;
|
|
197
|
+
rec.byteLen = len;
|
|
198
|
+
cursor += len;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Drop removed ids from the index entirely.
|
|
202
|
+
for (const id of deleteIds) removeEntryRecord(index, id);
|
|
203
|
+
|
|
204
|
+
updateShardStats(index, shardName, path);
|
|
205
|
+
saveShardIndex(dir, index);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Public compact: rewrite one shard (or all) with no deletions. */
|
|
209
|
+
function compact(shardName) {
|
|
210
|
+
if (shardName) return compactShard(shardName, []);
|
|
211
|
+
for (const name of Object.keys(index.shards)) compactShard(name, []);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Allow caller (memory-family) to re-hydrate meta after bulk rebuild. */
|
|
215
|
+
function setMeta(id, meta) {
|
|
216
|
+
const rec = index.entries.find((e) => e.id === id);
|
|
217
|
+
if (!rec) return false;
|
|
218
|
+
rec.meta = sanitiseMeta(meta);
|
|
219
|
+
saveShardIndex(dir, index);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function stats() {
|
|
224
|
+
return structuredClone({ shards: index.shards, count: index.entries.length });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function getIndex() { return index; }
|
|
228
|
+
|
|
229
|
+
return { put, get, query, remove, compact, setMeta, stats, getIndex };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Helpers ────────────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function normaliseSchema(schema) {
|
|
235
|
+
return {
|
|
236
|
+
shards: Array.isArray(schema.shards) ? schema.shards.slice() : [],
|
|
237
|
+
softCap: schema.softCap || {},
|
|
238
|
+
defaultSoftCap: schema.defaultSoftCap || { entries: 1000, bytes: 10 * 1024 * 1024 },
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function validateEntry(entry, schema) {
|
|
243
|
+
if (!entry || typeof entry !== 'object') throw new Error('entry must be an object');
|
|
244
|
+
if (!entry.id || typeof entry.id !== 'string') throw new Error('entry.id required (string)');
|
|
245
|
+
if (!/^[A-Za-z0-9_\-]+$/.test(entry.id)) throw new Error('entry.id must be [A-Za-z0-9_-]+');
|
|
246
|
+
if (!entry.shard || typeof entry.shard !== 'string') throw new Error('entry.shard required');
|
|
247
|
+
if (schema.shards.length > 0 && !schema.shards.includes(entry.shard)) {
|
|
248
|
+
// Open shard extension allowed by returning a warning? Spec says shards
|
|
249
|
+
// are fixed — so reject unknown ones. Caller can extend schema.shards[].
|
|
250
|
+
throw new Error(`entry.shard "${entry.shard}" not in schema.shards`);
|
|
251
|
+
}
|
|
252
|
+
if (typeof entry.body !== 'string') throw new Error('entry.body required (string)');
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function formatEntry({ id, body }) {
|
|
256
|
+
// Leading \n so successive appends stay visually separated even if the
|
|
257
|
+
// previous entry's body didn't end in a newline.
|
|
258
|
+
return `\n${START_MARK(id)}\n${body.replace(/\n+$/, '')}\n${END_MARK(id)}\n`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function extractBody(raw, id) {
|
|
262
|
+
const start = raw.indexOf(START_MARK(id));
|
|
263
|
+
const end = raw.indexOf(END_MARK(id));
|
|
264
|
+
if (start < 0 || end < 0 || end < start) return null;
|
|
265
|
+
const bodyStart = start + START_MARK(id).length;
|
|
266
|
+
return raw.slice(bodyStart, end).replace(/^\n+/, '').replace(/\n+$/, '');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function updateShardStats(index, shardName, path) {
|
|
270
|
+
const bucket = index.shards[shardName] || (index.shards[shardName] = {
|
|
271
|
+
entries: 0, bytes: 0, softCap: null,
|
|
272
|
+
});
|
|
273
|
+
bucket.bytes = existsSync(path) ? statSync(path).size : 0;
|
|
274
|
+
bucket.entries = index.entries.filter((e) => e.shard === shardName).length;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function isOverSoftCap(index, shardName, schema) {
|
|
278
|
+
const bucket = index.shards[shardName];
|
|
279
|
+
if (!bucket) return false;
|
|
280
|
+
const cap = schema.softCap?.[shardName] || schema.defaultSoftCap;
|
|
281
|
+
if (!cap) return false;
|
|
282
|
+
if (cap.entries != null && bucket.entries > cap.entries) return true;
|
|
283
|
+
if (cap.bytes != null && bucket.bytes > cap.bytes) return true;
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function matchesOneOf(value, needle) {
|
|
288
|
+
if (Array.isArray(needle)) return needle.includes(value);
|
|
289
|
+
return value === needle;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sanitiseMeta(meta) {
|
|
293
|
+
if (!meta || typeof meta !== 'object') return {};
|
|
294
|
+
// Only allow JSON-safe fields (number/string/boolean/array of those).
|
|
295
|
+
// Anything weird silently dropped so a bad call can't corrupt the index.
|
|
296
|
+
const out = {};
|
|
297
|
+
for (const [k, v] of Object.entries(meta)) {
|
|
298
|
+
if (v === null || ['string', 'number', 'boolean'].includes(typeof v)) {
|
|
299
|
+
out[k] = v;
|
|
300
|
+
} else if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
|
|
301
|
+
out[k] = v.slice();
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function indexLooksConsistent(dir, index) {
|
|
308
|
+
if (!index || !Array.isArray(index.entries)) return false;
|
|
309
|
+
// Cheap sanity: each shard listed in index has a file on disk, OR the shard
|
|
310
|
+
// is empty (no entries yet). Caller recomputes sizes next op.
|
|
311
|
+
for (const name of Object.keys(index.shards)) {
|
|
312
|
+
const path = join(dir, shardFileName(name));
|
|
313
|
+
const bucket = index.shards[name];
|
|
314
|
+
if (bucket.entries > 0 && !existsSync(path)) return false;
|
|
315
|
+
}
|
|
316
|
+
return true;
|
|
317
|
+
}
|