@omgbase/core 0.21.0 → 0.24.0
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/dist/src/core/ids.d.ts +1 -1
- package/dist/src/core/read/document.d.ts +4 -1
- package/dist/src/core/read/document.js +18 -6
- package/dist/src/core/store/archive.d.ts +196 -0
- package/dist/src/core/store/archive.js +606 -0
- package/dist/src/core/store/gc.d.ts +25 -4
- package/dist/src/core/store/gc.js +43 -28
- package/dist/src/core/store/rebuild.d.ts +2 -0
- package/dist/src/core/store/rebuild.js +8 -2
- package/dist/src/core/store/retention.d.ts +138 -0
- package/dist/src/core/store/retention.js +409 -0
- package/dist/src/core/store/schema.d.ts +3 -2
- package/dist/src/core/store/schema.js +30 -4
- package/dist/src/core/store/size.d.ts +21 -2
- package/dist/src/core/store/size.js +24 -3
- package/dist/src/core/store/store.d.ts +1 -0
- package/dist/src/core/store/store.js +53 -1
- package/dist/src/graph/history.d.ts +2 -0
- package/dist/src/graph/history.js +8 -4
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +3 -0
- package/dist/src/mcp/errors.d.ts +1 -1
- package/dist/src/mcp/errors.js +4 -0
- package/dist/src/mcp/reference.d.ts +1 -1
- package/dist/src/mcp/reference.js +49 -11
- package/dist/src/mcp/server.js +2 -2
- package/dist/src/mutate/select.js +5 -1
- package/dist/src/oqx-js/context.js +39 -8
- package/dist/src/oqx-js/dates.d.ts +105 -0
- package/dist/src/oqx-js/dates.js +427 -0
- package/dist/src/oqx-js/names.js +14 -3
- package/dist/src/oqx-js/planner.js +21 -2
- package/dist/src/oqx-js/run.d.ts +1 -0
- package/dist/src/oqx-js/run.js +82 -2
- package/dist/src/oqx-js/sql/translate.js +12 -5
- package/dist/src/oqx-js/store-index.js +1 -1
- package/dist/src/spec-versions.d.ts +4 -4
- package/dist/src/spec-versions.js +3 -3
- package/package.json +2 -2
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, renameSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { basename, join, resolve } from "node:path";
|
|
4
|
+
import { SCHEMA_VERSION } from "./schema.js";
|
|
5
|
+
import { mintId } from "../ids.js";
|
|
6
|
+
import { sweepUnreachable } from "./gc.js";
|
|
7
|
+
import { evaluateRetention, parseSettingsJson, ReachMarker, LAST_SEGMENT_SETTINGS_KEY, RETENTION_SETTINGS_KEY, } from "./retention.js";
|
|
8
|
+
// Archive segments (spec/store §7 "Retention: plan, export, squash", 15.0).
|
|
9
|
+
//
|
|
10
|
+
// A segment is ONE SQLite file per `history export`, never appended: the bodies
|
|
11
|
+
// (blobs, tree nodes) of the revisions the policy archived, self-contained,
|
|
12
|
+
// plus copies of their `revisions`/`commits` rows, the `dispositions` and
|
|
13
|
+
// `block_changes` of the commits that are wholly archived, and one `manifest`
|
|
14
|
+
// row (`ARCHIVE_DDL` = spec/store/archive.sql, embedded verbatim). The main
|
|
15
|
+
// store then SQUASHES in one transaction: an archived revision keeps its row
|
|
16
|
+
// (id, doc, seq, path, rendered hash, commit — so the `revisions` target,
|
|
17
|
+
// `docs_history` and `changes_since` keep listing it) and loses `root_tree` and
|
|
18
|
+
// `frontmatter_blob`, gaining `archive` = the segment id; the wholly archived
|
|
19
|
+
// commits' dispositions go, their block_changes go except each block's EARLIEST
|
|
20
|
+
// row (the mint oracle of spec/store §2.1 reads `block_changes`, so a minted id
|
|
21
|
+
// stays known); the GC sweep reclaims the bodies now reachable from nothing;
|
|
22
|
+
// the segment is recorded in `archives`. Two phases on purpose: the file is
|
|
23
|
+
// complete and durable before a single row of the store moves, and a crash
|
|
24
|
+
// between the phases leaves a complete, unrecorded segment that the next run
|
|
25
|
+
// finds and finishes (the crash rule).
|
|
26
|
+
//
|
|
27
|
+
// Reading an archived revision ATTACHes its segment on demand (one alias per
|
|
28
|
+
// segment, `arc_<id>`, read-only by construction — the file is written with
|
|
29
|
+
// mode 0444 and the engine never writes through an alias) so `docs_read { at }`,
|
|
30
|
+
// `docs_diff` and the fixture runner's `read_at` reconstruct through it. A
|
|
31
|
+
// segment that is not recorded or not on disk is `RevisionArchived`.
|
|
32
|
+
/** spec/store/archive.sql, verbatim — the segment's DDL. */
|
|
33
|
+
export const ARCHIVE_DDL = /* sql */ `-- ---- an archive segment (spec/store §7 "Retention", 15.0) --------------------
|
|
34
|
+
-- One file per \`history export\`, never appended: the bodies of the revisions
|
|
35
|
+
-- the retention policy squashed, self-contained (every blob and tree node
|
|
36
|
+
-- reachable from them), plus copies of their revision, commit, disposition and
|
|
37
|
+
-- block-change rows, and one manifest row. The same DDL subset as schema.sql
|
|
38
|
+
-- minus the foreign keys (a segment holds no repos or docs rows).
|
|
39
|
+
CREATE TABLE IF NOT EXISTS manifest (
|
|
40
|
+
segment_id TEXT PRIMARY KEY,
|
|
41
|
+
repo_id TEXT NOT NULL,
|
|
42
|
+
created_at TEXT NOT NULL,
|
|
43
|
+
policy TEXT NOT NULL,
|
|
44
|
+
seq_lo INTEGER NOT NULL,
|
|
45
|
+
seq_hi INTEGER NOT NULL,
|
|
46
|
+
revision_count INTEGER NOT NULL,
|
|
47
|
+
previous_segment TEXT,
|
|
48
|
+
schema_user_version INTEGER NOT NULL
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS revisions (
|
|
52
|
+
rev_id TEXT PRIMARY KEY,
|
|
53
|
+
doc_id TEXT NOT NULL,
|
|
54
|
+
seq INTEGER NOT NULL,
|
|
55
|
+
root_tree BLOB NOT NULL,
|
|
56
|
+
frontmatter_blob BLOB,
|
|
57
|
+
rendered_hash BLOB NOT NULL,
|
|
58
|
+
path TEXT NOT NULL,
|
|
59
|
+
commit_id TEXT NOT NULL,
|
|
60
|
+
UNIQUE (doc_id, seq)
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS commits (
|
|
64
|
+
commit_id TEXT PRIMARY KEY,
|
|
65
|
+
repo_id TEXT NOT NULL,
|
|
66
|
+
seq INTEGER NOT NULL,
|
|
67
|
+
ts TEXT NOT NULL,
|
|
68
|
+
origin TEXT NOT NULL CHECK (origin IN ('api','observed','import','projection')),
|
|
69
|
+
actor TEXT,
|
|
70
|
+
reason TEXT,
|
|
71
|
+
checkpoint_id TEXT,
|
|
72
|
+
ops TEXT
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
CREATE TABLE IF NOT EXISTS blobs (
|
|
76
|
+
hash BLOB PRIMARY KEY,
|
|
77
|
+
bytes BLOB NOT NULL,
|
|
78
|
+
size INTEGER NOT NULL
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
CREATE TABLE IF NOT EXISTS tree_nodes (
|
|
82
|
+
hash BLOB PRIMARY KEY,
|
|
83
|
+
entries TEXT NOT NULL
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
CREATE TABLE IF NOT EXISTS dispositions (
|
|
87
|
+
commit_id TEXT NOT NULL,
|
|
88
|
+
block_id TEXT NOT NULL,
|
|
89
|
+
kind TEXT NOT NULL CHECK (kind IN
|
|
90
|
+
('same','edited','moved','edited_moved','inserted','deleted',
|
|
91
|
+
'split_from','merged_into','copied_from','resurrected','bulk_rewrite')),
|
|
92
|
+
confidence REAL,
|
|
93
|
+
reason TEXT,
|
|
94
|
+
matcher_v TEXT,
|
|
95
|
+
detail TEXT NOT NULL DEFAULT '{}',
|
|
96
|
+
PRIMARY KEY (commit_id, block_id, kind)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
CREATE TABLE IF NOT EXISTS block_changes (
|
|
100
|
+
block_id TEXT NOT NULL,
|
|
101
|
+
commit_id TEXT NOT NULL,
|
|
102
|
+
kind TEXT NOT NULL,
|
|
103
|
+
PRIMARY KEY (block_id, commit_id, kind)
|
|
104
|
+
);
|
|
105
|
+
`;
|
|
106
|
+
/** The file suffix every segment carries. */
|
|
107
|
+
export const ARCHIVE_SUFFIX = ".omgarchive.db";
|
|
108
|
+
/** The default directory under `.omgbase/`. */
|
|
109
|
+
export const ARCHIVE_DIR = "archives";
|
|
110
|
+
/** The suffix of a segment still being written (never a complete segment). */
|
|
111
|
+
const PARTIAL_SUFFIX = ".partial";
|
|
112
|
+
/** How many segments stay attached to one connection (SQLite's default cap is 10). */
|
|
113
|
+
const MAX_ATTACHED = 8;
|
|
114
|
+
/** `<repo_id>-<seq_lo>-<seq_hi>-<segment_id>.omgarchive.db` */
|
|
115
|
+
export function segmentFileName(repoId, seqLo, seqHi, segmentId) {
|
|
116
|
+
return `${repoId}-${seqLo}-${seqHi}-${segmentId}${ARCHIVE_SUFFIX}`;
|
|
117
|
+
}
|
|
118
|
+
const FILE_RE = /^([a-z]+_[0-9a-z]+)-(\d+)-(\d+)-([a-z]+_[0-9a-z]+)\.omgarchive\.db$/;
|
|
119
|
+
/** The parts a segment file name carries, or null when the name is not one. */
|
|
120
|
+
export function parseSegmentFileName(name) {
|
|
121
|
+
const m = FILE_RE.exec(name);
|
|
122
|
+
return m ? { repoId: m[1], seqLo: Number(m[2]), seqHi: Number(m[3]), segmentId: m[4] } : null;
|
|
123
|
+
}
|
|
124
|
+
/** A read of an archived revision whose segment is not recorded, or recorded but not on disk (spec/surface: `revision_archived`). */
|
|
125
|
+
export class RevisionArchived extends Error {
|
|
126
|
+
rev;
|
|
127
|
+
archive;
|
|
128
|
+
path;
|
|
129
|
+
constructor(rev, archive, path) {
|
|
130
|
+
super(path === null
|
|
131
|
+
? `revision ${JSON.stringify(rev)} is archived in segment ${archive}, which is not attached (omg history attach <file>)`
|
|
132
|
+
: `revision ${JSON.stringify(rev)} is archived in segment ${archive} at ${path}, which is not on disk`);
|
|
133
|
+
this.rev = rev;
|
|
134
|
+
this.archive = archive;
|
|
135
|
+
this.path = path;
|
|
136
|
+
this.name = "RevisionArchived";
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
/** Not an archive segment, or one of another repository. */
|
|
140
|
+
export class NotASegment extends Error {
|
|
141
|
+
path;
|
|
142
|
+
constructor(path, detail) {
|
|
143
|
+
super(`${path}: ${detail}`);
|
|
144
|
+
this.path = path;
|
|
145
|
+
this.name = "NotASegment";
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/** Open a segment read-only and return its manifest; `NotASegment` when the file is not one. */
|
|
149
|
+
export function readManifest(path) {
|
|
150
|
+
if (!existsSync(path))
|
|
151
|
+
throw new NotASegment(path, "no such file");
|
|
152
|
+
let db = null;
|
|
153
|
+
try {
|
|
154
|
+
db = new Database(path, { readonly: true, fileMustExist: true });
|
|
155
|
+
const row = db.prepare("SELECT segment_id, repo_id, created_at, policy, seq_lo, seq_hi, revision_count, previous_segment, schema_user_version FROM manifest").get();
|
|
156
|
+
if (!row)
|
|
157
|
+
throw new NotASegment(path, "no manifest row");
|
|
158
|
+
return { ...row, policy: JSON.parse(row.policy) };
|
|
159
|
+
}
|
|
160
|
+
catch (e) {
|
|
161
|
+
if (e instanceof NotASegment)
|
|
162
|
+
throw e;
|
|
163
|
+
throw new NotASegment(path, "not an archive segment");
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
db?.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** The revision ids a segment holds, in `(doc_id, seq)` order. */
|
|
170
|
+
function segmentRevisionIds(path) {
|
|
171
|
+
const db = new Database(path, { readonly: true, fileMustExist: true });
|
|
172
|
+
try {
|
|
173
|
+
return db.prepare("SELECT rev_id FROM revisions ORDER BY doc_id, seq").all().map((r) => r.rev_id);
|
|
174
|
+
}
|
|
175
|
+
finally {
|
|
176
|
+
db.close();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function archiveRow(store, segmentId) {
|
|
180
|
+
return store.db.prepare("SELECT segment_id, repo_id, path, seq_lo, seq_hi, created_at, revision_count, bytes FROM archives WHERE segment_id = ?").get(segmentId);
|
|
181
|
+
}
|
|
182
|
+
function recordArchive(store, row) {
|
|
183
|
+
store.db
|
|
184
|
+
.prepare("INSERT OR REPLACE INTO archives (segment_id, repo_id, path, seq_lo, seq_hi, created_at, revision_count, bytes) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
|
|
185
|
+
.run(row.segment_id, row.repo_id, row.path, row.seq_lo, row.seq_hi, row.created_at, row.revision_count, row.bytes);
|
|
186
|
+
}
|
|
187
|
+
function fileBytes(path) {
|
|
188
|
+
try {
|
|
189
|
+
return statSync(path).size;
|
|
190
|
+
}
|
|
191
|
+
catch {
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* `omg history attach <file|dir>`: record a segment (idempotent — a known
|
|
197
|
+
* segment's path is re-recorded) or every `.omgarchive.db` of a directory
|
|
198
|
+
* whose manifest `repo_id` is this repository's. A single file of another
|
|
199
|
+
* repository or a non-segment is `NotASegment`; in a directory they are skipped.
|
|
200
|
+
*/
|
|
201
|
+
export function attachArchive(store, repoId, target) {
|
|
202
|
+
const path = resolve(target);
|
|
203
|
+
const attached = [];
|
|
204
|
+
let skipped = 0;
|
|
205
|
+
const one = (file, strict) => {
|
|
206
|
+
let manifest;
|
|
207
|
+
try {
|
|
208
|
+
manifest = readManifest(file);
|
|
209
|
+
}
|
|
210
|
+
catch (e) {
|
|
211
|
+
if (strict)
|
|
212
|
+
throw e;
|
|
213
|
+
skipped++;
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (manifest.repo_id !== repoId) {
|
|
217
|
+
if (strict)
|
|
218
|
+
throw new NotASegment(file, `a segment of repository ${manifest.repo_id}, not ${repoId}`);
|
|
219
|
+
skipped++;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const row = {
|
|
223
|
+
segment_id: manifest.segment_id, repo_id: repoId, path: file, seq_lo: manifest.seq_lo, seq_hi: manifest.seq_hi,
|
|
224
|
+
created_at: manifest.created_at, revision_count: manifest.revision_count, bytes: fileBytes(file),
|
|
225
|
+
};
|
|
226
|
+
// A re-attached segment may have moved: drop a stale alias so the next read re-attaches.
|
|
227
|
+
detachAlias(store, manifest.segment_id);
|
|
228
|
+
recordArchive(store, row);
|
|
229
|
+
attached.push({ segment_id: row.segment_id, path: row.path, seq_lo: row.seq_lo, seq_hi: row.seq_hi, revision_count: row.revision_count, bytes: row.bytes });
|
|
230
|
+
};
|
|
231
|
+
if (existsSync(path) && statSync(path).isDirectory()) {
|
|
232
|
+
for (const name of readdirSync(path).filter((n) => n.endsWith(ARCHIVE_SUFFIX)).sort())
|
|
233
|
+
one(join(path, name), false);
|
|
234
|
+
}
|
|
235
|
+
else
|
|
236
|
+
one(path, true);
|
|
237
|
+
return { attached, skipped };
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* `omg history detach <segment_id|path>`: forget a segment. The revisions it
|
|
241
|
+
* holds keep their `archive` pointer; reading one is `revision_archived` until
|
|
242
|
+
* the segment is attached again. Returns the id, or null when nothing matched.
|
|
243
|
+
*/
|
|
244
|
+
export function detachArchive(store, repoId, ref) {
|
|
245
|
+
const byId = store.db.prepare("SELECT segment_id FROM archives WHERE repo_id = ? AND segment_id = ?").get(repoId, ref);
|
|
246
|
+
const byPath = byId ? undefined : store.db.prepare("SELECT segment_id FROM archives WHERE repo_id = ? AND path = ?").get(repoId, resolve(ref));
|
|
247
|
+
const id = byId?.segment_id ?? byPath?.segment_id;
|
|
248
|
+
if (!id)
|
|
249
|
+
return null;
|
|
250
|
+
detachAlias(store, id);
|
|
251
|
+
store.db.prepare("DELETE FROM archives WHERE segment_id = ?").run(id);
|
|
252
|
+
return id;
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* `omg history list`: the repository's recorded segments by `seq_lo` then id,
|
|
256
|
+
* followed by the unrecorded complete segments found in `dest`, by file name.
|
|
257
|
+
*/
|
|
258
|
+
export function listArchives(store, repoId, opts = {}) {
|
|
259
|
+
const rows = store.db
|
|
260
|
+
.prepare("SELECT segment_id, repo_id, path, seq_lo, seq_hi, created_at, revision_count, bytes FROM archives WHERE repo_id = ? ORDER BY seq_lo, segment_id")
|
|
261
|
+
.all(repoId);
|
|
262
|
+
const out = rows.map((r) => ({
|
|
263
|
+
segment_id: r.segment_id, path: r.path, seq_lo: r.seq_lo, seq_hi: r.seq_hi, revision_count: r.revision_count,
|
|
264
|
+
bytes: r.bytes, created_at: r.created_at, state: existsSync(r.path) ? "attached" : "missing",
|
|
265
|
+
}));
|
|
266
|
+
if (opts.dest) {
|
|
267
|
+
for (const u of unrecordedSegments(store, repoId, opts.dest)) {
|
|
268
|
+
const m = readManifest(u.path);
|
|
269
|
+
out.push({ segment_id: m.segment_id, path: u.path, seq_lo: m.seq_lo, seq_hi: m.seq_hi, revision_count: m.revision_count, bytes: fileBytes(u.path), created_at: m.created_at, state: u.state });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* The complete segments of this repository under `dest` that no `archives`
|
|
276
|
+
* row records, by file name, each classified by its revisions: all live →
|
|
277
|
+
* `pending` (the crash rule's input), all archived → `detached`, else `stale`.
|
|
278
|
+
*/
|
|
279
|
+
export function unrecordedSegments(store, repoId, dest) {
|
|
280
|
+
if (!existsSync(dest))
|
|
281
|
+
return [];
|
|
282
|
+
const known = new Set(store.db.prepare("SELECT segment_id FROM archives").all().map((r) => r.segment_id));
|
|
283
|
+
const live = store.db.prepare("SELECT archive FROM revisions WHERE rev_id = ?");
|
|
284
|
+
return readdirSync(dest)
|
|
285
|
+
.map((name) => ({ name, parts: parseSegmentFileName(name) }))
|
|
286
|
+
.filter((f) => f.parts !== null && f.parts.repoId === repoId && !known.has(f.parts.segmentId))
|
|
287
|
+
.map((f) => f.name)
|
|
288
|
+
.sort()
|
|
289
|
+
.map((name) => {
|
|
290
|
+
const path = join(dest, name);
|
|
291
|
+
let liveCount = 0;
|
|
292
|
+
let archivedCount = 0;
|
|
293
|
+
for (const id of segmentRevisionIds(path)) {
|
|
294
|
+
const r = live.get(id);
|
|
295
|
+
if (r && r.archive === null)
|
|
296
|
+
liveCount++;
|
|
297
|
+
else
|
|
298
|
+
archivedCount++;
|
|
299
|
+
}
|
|
300
|
+
const state = archivedCount === 0 ? "pending" : liveCount === 0 ? "detached" : "stale";
|
|
301
|
+
return { path, state };
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
// ---- reading through a segment -----------------------------------------------------------
|
|
305
|
+
const attachedAliases = new WeakMap();
|
|
306
|
+
function aliasesOf(store) {
|
|
307
|
+
let m = attachedAliases.get(store.db);
|
|
308
|
+
if (!m)
|
|
309
|
+
attachedAliases.set(store.db, (m = new Map()));
|
|
310
|
+
return m;
|
|
311
|
+
}
|
|
312
|
+
export function archiveAlias(segmentId) {
|
|
313
|
+
return `arc_${segmentId.replace(/[^A-Za-z0-9_]/g, "_")}`;
|
|
314
|
+
}
|
|
315
|
+
function detachAlias(store, segmentId) {
|
|
316
|
+
const m = aliasesOf(store);
|
|
317
|
+
const alias = m.get(segmentId);
|
|
318
|
+
if (!alias)
|
|
319
|
+
return;
|
|
320
|
+
try {
|
|
321
|
+
store.db.exec(`DETACH DATABASE ${alias}`);
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
// already gone with the connection
|
|
325
|
+
}
|
|
326
|
+
m.delete(segmentId);
|
|
327
|
+
}
|
|
328
|
+
/**
|
|
329
|
+
* The table prefix a revision's bodies are read under: `""` for a live
|
|
330
|
+
* revision, `"<alias>."` for an archived one whose segment is attached on
|
|
331
|
+
* demand (and verified to be the segment it claims). `RevisionArchived` when
|
|
332
|
+
* the segment is not recorded or not on disk.
|
|
333
|
+
*/
|
|
334
|
+
export function archivePrefix(store, rev) {
|
|
335
|
+
if (rev.archive === null)
|
|
336
|
+
return "";
|
|
337
|
+
const m = aliasesOf(store);
|
|
338
|
+
const have = m.get(rev.archive);
|
|
339
|
+
if (have)
|
|
340
|
+
return `${have}.`;
|
|
341
|
+
const row = archiveRow(store, rev.archive);
|
|
342
|
+
if (!row)
|
|
343
|
+
throw new RevisionArchived(rev.rev_id, rev.archive, null);
|
|
344
|
+
if (!existsSync(row.path))
|
|
345
|
+
throw new RevisionArchived(rev.rev_id, rev.archive, row.path);
|
|
346
|
+
if (m.size >= MAX_ATTACHED) {
|
|
347
|
+
const oldest = m.keys().next().value;
|
|
348
|
+
if (oldest !== undefined)
|
|
349
|
+
detachAlias(store, oldest);
|
|
350
|
+
}
|
|
351
|
+
const alias = archiveAlias(rev.archive);
|
|
352
|
+
store.db.prepare(`ATTACH DATABASE ? AS ${alias}`).run(row.path);
|
|
353
|
+
const claims = store.db.prepare(`SELECT segment_id FROM ${alias}.manifest`).get();
|
|
354
|
+
if (claims?.segment_id !== rev.archive) {
|
|
355
|
+
store.db.exec(`DETACH DATABASE ${alias}`);
|
|
356
|
+
throw new RevisionArchived(rev.rev_id, rev.archive, row.path);
|
|
357
|
+
}
|
|
358
|
+
m.set(rev.archive, alias);
|
|
359
|
+
return `${alias}.`;
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* A revision's row with its bodies resolved: a live revision's from the store,
|
|
363
|
+
* an archived one's from its segment (attached on demand). `undefined` when
|
|
364
|
+
* `revId` is not a revision (of `docId`, when given); `RevisionArchived` when
|
|
365
|
+
* its segment is unavailable.
|
|
366
|
+
*/
|
|
367
|
+
export function revisionBody(store, revId, docId) {
|
|
368
|
+
const row = store.db
|
|
369
|
+
.prepare(`SELECT rev_id, doc_id, path, rendered_hash, root_tree, frontmatter_blob, archive FROM revisions WHERE rev_id = ?${docId === undefined ? "" : " AND doc_id = ?"}`)
|
|
370
|
+
.get(...(docId === undefined ? [revId] : [revId, docId]));
|
|
371
|
+
if (!row)
|
|
372
|
+
return undefined;
|
|
373
|
+
if (row.archive === null) {
|
|
374
|
+
return { rev_id: row.rev_id, doc_id: row.doc_id, path: row.path, rendered_hash: row.rendered_hash, root_tree: row.root_tree, frontmatter_blob: row.frontmatter_blob, prefix: "", archived: false };
|
|
375
|
+
}
|
|
376
|
+
const prefix = archivePrefix(store, row);
|
|
377
|
+
const body = store.db.prepare(`SELECT root_tree, frontmatter_blob FROM ${prefix}revisions WHERE rev_id = ?`).get(revId);
|
|
378
|
+
if (!body)
|
|
379
|
+
throw new RevisionArchived(revId, row.archive, archiveRow(store, row.archive)?.path ?? null);
|
|
380
|
+
return { rev_id: row.rev_id, doc_id: row.doc_id, path: row.path, rendered_hash: row.rendered_hash, root_tree: body.root_tree, frontmatter_blob: body.frontmatter_blob, prefix, archived: true };
|
|
381
|
+
}
|
|
382
|
+
/** An unrecorded segment under `dest` names both live and non-live revisions (it predates another squash, or belongs elsewhere). */
|
|
383
|
+
export class StaleSegment extends Error {
|
|
384
|
+
path;
|
|
385
|
+
rev;
|
|
386
|
+
constructor(path, rev) {
|
|
387
|
+
super(`${path}: an unrecorded segment naming revision ${rev}, which is not a live revision of this store — move the file out of the way`);
|
|
388
|
+
this.path = path;
|
|
389
|
+
this.rev = rev;
|
|
390
|
+
this.name = "StaleSegment";
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
/** The commits of `revIds` every revision of which is in `revIds` or already archived — whose change rows move with the bodies. */
|
|
394
|
+
function whollyArchivedCommits(store, revIds) {
|
|
395
|
+
const commits = new Set();
|
|
396
|
+
const commitOf = store.db.prepare("SELECT commit_id FROM revisions WHERE rev_id = ?");
|
|
397
|
+
for (const id of revIds) {
|
|
398
|
+
const r = commitOf.get(id);
|
|
399
|
+
if (r)
|
|
400
|
+
commits.add(r.commit_id);
|
|
401
|
+
}
|
|
402
|
+
const revsOf = store.db.prepare("SELECT rev_id, archive FROM revisions WHERE commit_id = ?");
|
|
403
|
+
const out = [];
|
|
404
|
+
for (const c of [...commits].sort()) {
|
|
405
|
+
const revs = revsOf.all(c);
|
|
406
|
+
if (revs.every((r) => r.archive !== null || revIds.has(r.rev_id)))
|
|
407
|
+
out.push(c);
|
|
408
|
+
}
|
|
409
|
+
return out;
|
|
410
|
+
}
|
|
411
|
+
// The repo's OWN settings blob (`repos.settings`, the repo layer of
|
|
412
|
+
// `spec/sync`'s two-layer settings) — read and written directly: `core/` does
|
|
413
|
+
// not import `sync/settings.ts`, and the chain pointer belongs to the
|
|
414
|
+
// repository, not the workspace (a workspace holds several).
|
|
415
|
+
function repoSettings(store, repoId) {
|
|
416
|
+
return parseSettingsJson(store.db.prepare("SELECT settings FROM repos WHERE repo_id = ?").get(repoId)?.settings);
|
|
417
|
+
}
|
|
418
|
+
function lastSegment(store, repoId) {
|
|
419
|
+
const s = repoSettings(store, repoId)[RETENTION_SETTINGS_KEY];
|
|
420
|
+
const v = s && typeof s === "object" ? s[LAST_SEGMENT_SETTINGS_KEY] : undefined;
|
|
421
|
+
return typeof v === "string" ? v : null;
|
|
422
|
+
}
|
|
423
|
+
function setLastSegment(store, repoId, segmentId) {
|
|
424
|
+
const settings = repoSettings(store, repoId);
|
|
425
|
+
const history = settings[RETENTION_SETTINGS_KEY];
|
|
426
|
+
const h = history && typeof history === "object" && !Array.isArray(history) ? { ...history } : {};
|
|
427
|
+
h[LAST_SEGMENT_SETTINGS_KEY] = segmentId;
|
|
428
|
+
settings[RETENTION_SETTINGS_KEY] = h;
|
|
429
|
+
store.db.prepare("UPDATE repos SET settings = ? WHERE repo_id = ?").run(JSON.stringify(settings), repoId);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Phase 1: write the segment for `revIds` to `dest`, durably — into a
|
|
433
|
+
* `.partial` file, fsynced (`synchronous = FULL`) and closed, made read-only,
|
|
434
|
+
* then renamed to its final name, so a complete segment is exactly a file with
|
|
435
|
+
* the final name. Returns the segment's info.
|
|
436
|
+
*/
|
|
437
|
+
function writeSegment(store, repoId, revIds, plan, dest) {
|
|
438
|
+
const db = store.db;
|
|
439
|
+
const revRow = db.prepare("SELECT rev_id, doc_id, seq, root_tree, frontmatter_blob, rendered_hash, path, commit_id FROM revisions WHERE rev_id = ?");
|
|
440
|
+
const revisions = revIds.map((id) => revRow.get(id));
|
|
441
|
+
const commitIds = [...new Set(revisions.map((r) => r.commit_id))];
|
|
442
|
+
const commitRow = db.prepare("SELECT commit_id, repo_id, seq, ts, origin, actor, reason, checkpoint_id, ops FROM commits WHERE commit_id = ?");
|
|
443
|
+
const commits = commitIds.map((c) => commitRow.get(c)).sort((a, b) => a.seq - b.seq);
|
|
444
|
+
const seqLo = commits[0].seq;
|
|
445
|
+
const seqHi = commits[commits.length - 1].seq;
|
|
446
|
+
const segmentId = mintId("s");
|
|
447
|
+
const createdAt = plan.now;
|
|
448
|
+
const previous = lastSegment(store, repoId);
|
|
449
|
+
const marker = new ReachMarker(store);
|
|
450
|
+
const trees = new Set();
|
|
451
|
+
const blobs = new Set();
|
|
452
|
+
marker.mark(revisions, trees, blobs);
|
|
453
|
+
const wholly = whollyArchivedCommits(store, new Set(revIds));
|
|
454
|
+
const dispRows = db.prepare("SELECT commit_id, block_id, kind, confidence, reason, matcher_v, detail FROM dispositions WHERE commit_id = ? ORDER BY block_id, kind");
|
|
455
|
+
const bcRows = db.prepare("SELECT block_id, commit_id, kind FROM block_changes WHERE commit_id = ? ORDER BY block_id, kind");
|
|
456
|
+
const treeRow = db.prepare("SELECT hash, entries FROM tree_nodes WHERE hash = ?");
|
|
457
|
+
const blobRow = db.prepare("SELECT hash, bytes, size FROM blobs WHERE hash = ?");
|
|
458
|
+
mkdirSync(dest, { recursive: true });
|
|
459
|
+
for (const stale of readdirSync(dest))
|
|
460
|
+
if (stale.endsWith(ARCHIVE_SUFFIX + PARTIAL_SUFFIX))
|
|
461
|
+
unlinkSync(join(dest, stale));
|
|
462
|
+
const name = segmentFileName(repoId, seqLo, seqHi, segmentId);
|
|
463
|
+
const finalPath = join(dest, name);
|
|
464
|
+
const partialPath = finalPath + PARTIAL_SUFFIX;
|
|
465
|
+
const seg = new Database(partialPath);
|
|
466
|
+
try {
|
|
467
|
+
seg.pragma("journal_mode = DELETE");
|
|
468
|
+
seg.pragma("synchronous = FULL");
|
|
469
|
+
seg.exec(ARCHIVE_DDL);
|
|
470
|
+
seg.transaction(() => {
|
|
471
|
+
seg.prepare("INSERT INTO manifest (segment_id, repo_id, created_at, policy, seq_lo, seq_hi, revision_count, previous_segment, schema_user_version) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)")
|
|
472
|
+
.run(segmentId, repoId, createdAt, JSON.stringify(plan.policy), seqLo, seqHi, revisions.length, previous, SCHEMA_VERSION);
|
|
473
|
+
const insRev = seg.prepare("INSERT INTO revisions (rev_id, doc_id, seq, root_tree, frontmatter_blob, rendered_hash, path, commit_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
474
|
+
for (const r of revisions)
|
|
475
|
+
insRev.run(r.rev_id, r.doc_id, r.seq, r.root_tree, r.frontmatter_blob, r.rendered_hash, r.path, r.commit_id);
|
|
476
|
+
const insCommit = seg.prepare("INSERT INTO commits (commit_id, repo_id, seq, ts, origin, actor, reason, checkpoint_id, ops) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)");
|
|
477
|
+
for (const c of commits)
|
|
478
|
+
insCommit.run(c.commit_id, c.repo_id, c.seq, c.ts, c.origin, c.actor, c.reason, c.checkpoint_id, c.ops);
|
|
479
|
+
const insTree = seg.prepare("INSERT INTO tree_nodes (hash, entries) VALUES (?, ?)");
|
|
480
|
+
for (const t of [...trees].sort()) {
|
|
481
|
+
const row = treeRow.get(Buffer.from(t, "hex"));
|
|
482
|
+
if (row)
|
|
483
|
+
insTree.run(row.hash, row.entries);
|
|
484
|
+
}
|
|
485
|
+
const insBlob = seg.prepare("INSERT INTO blobs (hash, bytes, size) VALUES (?, ?, ?)");
|
|
486
|
+
for (const b of [...blobs].sort()) {
|
|
487
|
+
const row = blobRow.get(Buffer.from(b, "hex"));
|
|
488
|
+
if (row)
|
|
489
|
+
insBlob.run(row.hash, row.bytes, row.size);
|
|
490
|
+
}
|
|
491
|
+
const insDisp = seg.prepare("INSERT INTO dispositions (commit_id, block_id, kind, confidence, reason, matcher_v, detail) VALUES (?, ?, ?, ?, ?, ?, ?)");
|
|
492
|
+
const insBc = seg.prepare("INSERT INTO block_changes (block_id, commit_id, kind) VALUES (?, ?, ?)");
|
|
493
|
+
for (const c of wholly) {
|
|
494
|
+
for (const d of dispRows.all(c))
|
|
495
|
+
insDisp.run(d.commit_id, d.block_id, d.kind, d.confidence, d.reason, d.matcher_v, d.detail);
|
|
496
|
+
for (const b of bcRows.all(c))
|
|
497
|
+
insBc.run(b.block_id, b.commit_id, b.kind);
|
|
498
|
+
}
|
|
499
|
+
})();
|
|
500
|
+
}
|
|
501
|
+
finally {
|
|
502
|
+
seg.close();
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
chmodSync(partialPath, 0o444);
|
|
506
|
+
}
|
|
507
|
+
catch {
|
|
508
|
+
// a filesystem without modes: the engine still never writes through an alias
|
|
509
|
+
}
|
|
510
|
+
renameSync(partialPath, finalPath);
|
|
511
|
+
return { id: segmentId, path: finalPath, seq_lo: seqLo, seq_hi: seqHi, created_at: createdAt, revision_count: revisions.length, bytes: fileBytes(finalPath), previous_segment: previous };
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Phase 2: squash the store for a complete segment, in one transaction — the
|
|
515
|
+
* archived revisions lose their bodies and gain `archive`; the wholly archived
|
|
516
|
+
* commits lose their dispositions and every block_changes row but each block's
|
|
517
|
+
* earliest; the GC sweep reclaims what is reachable from nothing; the segment
|
|
518
|
+
* is recorded in `archives` and as the repo's `history.last_segment`.
|
|
519
|
+
*/
|
|
520
|
+
function squash(store, repoId, segment, revIds) {
|
|
521
|
+
return store.write((db) => {
|
|
522
|
+
const wholly = whollyArchivedCommits(store, new Set(revIds));
|
|
523
|
+
const upd = db.prepare("UPDATE revisions SET root_tree = NULL, frontmatter_blob = NULL, archive = ? WHERE rev_id = ?");
|
|
524
|
+
for (const id of revIds)
|
|
525
|
+
upd.run(segment.id, id);
|
|
526
|
+
let dispositions = 0;
|
|
527
|
+
let blockChanges = 0;
|
|
528
|
+
const delDisp = db.prepare("DELETE FROM dispositions WHERE commit_id = ?");
|
|
529
|
+
// The rows that stay: a block's earliest block_changes row (by commit seq) —
|
|
530
|
+
// the mint oracle (spec/store §2.1) reads this table, so an id ever minted
|
|
531
|
+
// stays in use after its row, its pool entry and its history are gone.
|
|
532
|
+
const delBc = db.prepare(`DELETE FROM block_changes WHERE commit_id = ? AND EXISTS (
|
|
533
|
+
SELECT 1 FROM block_changes b2 JOIN commits c2 ON c2.commit_id = b2.commit_id JOIN commits c ON c.commit_id = block_changes.commit_id
|
|
534
|
+
WHERE b2.block_id = block_changes.block_id AND c2.seq < c.seq)`);
|
|
535
|
+
for (const c of wholly) {
|
|
536
|
+
dispositions += delDisp.run(c).changes;
|
|
537
|
+
blockChanges += delBc.run(c).changes;
|
|
538
|
+
}
|
|
539
|
+
const swept = sweepUnreachable(store);
|
|
540
|
+
recordArchive(store, {
|
|
541
|
+
segment_id: segment.id, repo_id: repoId, path: segment.path, seq_lo: segment.seq_lo, seq_hi: segment.seq_hi,
|
|
542
|
+
created_at: segment.created_at, revision_count: segment.revision_count, bytes: segment.bytes,
|
|
543
|
+
});
|
|
544
|
+
setLastSegment(store, repoId, segment.id);
|
|
545
|
+
return {
|
|
546
|
+
squashed: { revisions: revIds.length, commits: wholly.length, dispositions, block_changes: blockChanges },
|
|
547
|
+
gc: { blobs: swept.blobsSwept, tree_nodes: swept.treeNodesSwept },
|
|
548
|
+
};
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* `omg history export [--prune]`: evaluate the policy, write ONE new segment
|
|
553
|
+
* for the revisions to archive (or finish an unrecorded one — the crash
|
|
554
|
+
* rule), and with `prune` squash the store. Nothing to archive → no file,
|
|
555
|
+
* `segment: null`.
|
|
556
|
+
*/
|
|
557
|
+
export function exportHistory(store, repoId, opts) {
|
|
558
|
+
const plan = evaluateRetention(store, repoId, opts);
|
|
559
|
+
const dest = resolve(opts.dest);
|
|
560
|
+
// The crash rule: a complete segment nobody recorded, whose revisions are
|
|
561
|
+
// still live, is finished first; a detached one is left alone (`attach`
|
|
562
|
+
// brings it back); a stale one is refused rather than guessed about.
|
|
563
|
+
const unrecorded = unrecordedSegments(store, repoId, dest);
|
|
564
|
+
const stale = unrecorded.find((u) => u.state === "stale");
|
|
565
|
+
if (stale) {
|
|
566
|
+
const live = store.db.prepare("SELECT archive FROM revisions WHERE rev_id = ?");
|
|
567
|
+
const offending = segmentRevisionIds(stale.path).find((id) => live.get(id)?.archive !== null) ?? "?";
|
|
568
|
+
throw new StaleSegment(stale.path, offending);
|
|
569
|
+
}
|
|
570
|
+
const pending = unrecorded.find((u) => u.state === "pending");
|
|
571
|
+
if (pending) {
|
|
572
|
+
const file = pending.path;
|
|
573
|
+
const manifest = readManifest(file);
|
|
574
|
+
const revIds = segmentRevisionIds(file);
|
|
575
|
+
const segment = {
|
|
576
|
+
id: manifest.segment_id, path: file, seq_lo: manifest.seq_lo, seq_hi: manifest.seq_hi, created_at: manifest.created_at,
|
|
577
|
+
revision_count: manifest.revision_count, bytes: fileBytes(file), previous_segment: manifest.previous_segment,
|
|
578
|
+
};
|
|
579
|
+
if (!opts.prune)
|
|
580
|
+
return { plan, segment, reused: true, squashed: null, gc: null };
|
|
581
|
+
const done = squash(store, repoId, segment, revIds);
|
|
582
|
+
return { plan, segment, reused: true, ...done };
|
|
583
|
+
}
|
|
584
|
+
const revIds = plan.docs.flatMap((d) => d.archive);
|
|
585
|
+
if (revIds.length === 0)
|
|
586
|
+
return { plan, segment: null, reused: false, squashed: null, gc: null };
|
|
587
|
+
const segment = writeSegment(store, repoId, revIds, plan, dest);
|
|
588
|
+
if (!opts.prune)
|
|
589
|
+
return { plan, segment, reused: false, squashed: null, gc: null };
|
|
590
|
+
const done = squash(store, repoId, segment, revIds);
|
|
591
|
+
return { plan, segment, reused: false, ...done };
|
|
592
|
+
}
|
|
593
|
+
/** The archived-history figures the size report carries (spec/store §7 "Size", 15.0). */
|
|
594
|
+
export function archivedSummary(store) {
|
|
595
|
+
const revisions = store.db.prepare("SELECT count(*) AS c FROM revisions WHERE archive IS NOT NULL").get().c;
|
|
596
|
+
const rows = store.db.prepare("SELECT path FROM archives").all();
|
|
597
|
+
let bytes = 0;
|
|
598
|
+
for (const r of rows)
|
|
599
|
+
bytes += fileBytes(r.path);
|
|
600
|
+
return { revisions, segments: rows.length, bytes };
|
|
601
|
+
}
|
|
602
|
+
/** The file name of a segment path, for messages and fixtures. */
|
|
603
|
+
export function segmentBasename(path) {
|
|
604
|
+
return basename(path);
|
|
605
|
+
}
|
|
606
|
+
//# sourceMappingURL=archive.js.map
|
|
@@ -5,14 +5,35 @@ export interface GcResult {
|
|
|
5
5
|
/** spec/store §7 (14.2): `same` rows a 14.1 store wrote into `dispositions`/`block_changes` — history nothing reads. */
|
|
6
6
|
sameDispositionsSwept: number;
|
|
7
7
|
}
|
|
8
|
+
/**
|
|
9
|
+
* The mark (spec/store §7): every tree node reachable from a live revision's
|
|
10
|
+
* `root_tree` (walking `child_tree_hash_hex`) and every blob those nodes name
|
|
11
|
+
* (raw + trivia) plus every live `frontmatter_blob`. An archived revision
|
|
12
|
+
* (`root_tree IS NULL`, 15.0) contributes nothing — its bodies live in its
|
|
13
|
+
* segment.
|
|
14
|
+
*/
|
|
15
|
+
export declare function markReachable(store: Store): {
|
|
16
|
+
trees: Set<string>;
|
|
17
|
+
blobs: Set<string>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The sweep: delete every `tree_nodes` and `blobs` row the mark did not reach
|
|
21
|
+
* (count only when `dry`). Runs in the caller's transaction — `runGc` and the
|
|
22
|
+
* retention squash both call it.
|
|
23
|
+
*/
|
|
24
|
+
export declare function sweepUnreachable(store: Store, dry?: boolean): {
|
|
25
|
+
blobsSwept: number;
|
|
26
|
+
treeNodesSwept: number;
|
|
27
|
+
};
|
|
8
28
|
/** The `same` rows a store through 14.1 persisted (spec/store §7, 14.2). */
|
|
9
29
|
export declare function countSameDispositions(store: Store): number;
|
|
10
30
|
/**
|
|
11
31
|
* Mark-and-sweep GC (flag-gated; enabled must be true to run — v1.0 ships it
|
|
12
|
-
* off). Removes blobs/tree_nodes unreachable from any revision root.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
32
|
+
* off). Removes blobs/tree_nodes unreachable from any live revision root. In a
|
|
33
|
+
* store that never archived, nothing is unreachable — revisions are append-only
|
|
34
|
+
* — so that half is a no-op; after a retention squash (15.0) the squash has
|
|
35
|
+
* already swept, so it is a no-op again unless a crash interrupted one.
|
|
36
|
+
* Rebuild-equivalence is preserved.
|
|
16
37
|
*
|
|
17
38
|
* spec/store §7 (14.2): the same sweep deletes every `same` row of
|
|
18
39
|
* `dispositions` and `block_changes` — what a 14.1 store wrote for every
|