@omgbase/core 0.19.0 → 0.23.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.
Files changed (55) hide show
  1. package/dist/src/core/ids.d.ts +1 -1
  2. package/dist/src/core/ingest.d.ts +2 -0
  3. package/dist/src/core/ingest.js +13 -0
  4. package/dist/src/core/read/document.d.ts +13 -9
  5. package/dist/src/core/read/document.js +18 -8
  6. package/dist/src/core/store/archive.d.ts +196 -0
  7. package/dist/src/core/store/archive.js +606 -0
  8. package/dist/src/core/store/gc.d.ts +37 -4
  9. package/dist/src/core/store/gc.js +62 -28
  10. package/dist/src/core/store/rebuild.d.ts +2 -0
  11. package/dist/src/core/store/rebuild.js +8 -2
  12. package/dist/src/core/store/retention.d.ts +138 -0
  13. package/dist/src/core/store/retention.js +409 -0
  14. package/dist/src/core/store/schema.d.ts +3 -2
  15. package/dist/src/core/store/schema.js +30 -4
  16. package/dist/src/core/store/size.d.ts +24 -2
  17. package/dist/src/core/store/size.js +33 -3
  18. package/dist/src/core/store/store.d.ts +1 -0
  19. package/dist/src/core/store/store.js +53 -1
  20. package/dist/src/graph/history.d.ts +2 -0
  21. package/dist/src/graph/history.js +8 -4
  22. package/dist/src/index.d.ts +3 -1
  23. package/dist/src/index.js +3 -0
  24. package/dist/src/mcp/arguments.d.ts +10 -2
  25. package/dist/src/mcp/arguments.js +37 -3
  26. package/dist/src/mcp/errors.d.ts +1 -1
  27. package/dist/src/mcp/errors.js +4 -0
  28. package/dist/src/mcp/reference.d.ts +1 -1
  29. package/dist/src/mcp/reference.js +58 -8
  30. package/dist/src/mcp/server.js +51 -24
  31. package/dist/src/mutate/effects.d.ts +23 -5
  32. package/dist/src/mutate/effects.js +111 -22
  33. package/dist/src/mutate/known-ids.d.ts +19 -0
  34. package/dist/src/mutate/known-ids.js +47 -7
  35. package/dist/src/mutate/ops.d.ts +1 -0
  36. package/dist/src/mutate/ops.js +20 -1
  37. package/dist/src/mutate/select.d.ts +0 -11
  38. package/dist/src/mutate/select.js +33 -9
  39. package/dist/src/oqx-js/context.d.ts +9 -2
  40. package/dist/src/oqx-js/context.js +92 -30
  41. package/dist/src/oqx-js/dates.d.ts +105 -0
  42. package/dist/src/oqx-js/dates.js +427 -0
  43. package/dist/src/oqx-js/names.d.ts +40 -0
  44. package/dist/src/oqx-js/names.js +358 -0
  45. package/dist/src/oqx-js/planner.d.ts +3 -3
  46. package/dist/src/oqx-js/planner.js +30 -11
  47. package/dist/src/oqx-js/root-row.d.ts +15 -4
  48. package/dist/src/oqx-js/root-row.js +18 -10
  49. package/dist/src/oqx-js/run.d.ts +19 -1
  50. package/dist/src/oqx-js/run.js +97 -13
  51. package/dist/src/oqx-js/sql/translate.js +1 -1
  52. package/dist/src/spec-versions.d.ts +5 -5
  53. package/dist/src/spec-versions.js +4 -4
  54. package/dist/src/sync/observe.js +9 -3
  55. 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
@@ -2,16 +2,49 @@ import type { Store } from "./store.js";
2
2
  export interface GcResult {
3
3
  blobsSwept: number;
4
4
  treeNodesSwept: number;
5
+ /** spec/store §7 (14.2): `same` rows a 14.1 store wrote into `dispositions`/`block_changes` — history nothing reads. */
6
+ sameDispositionsSwept: number;
5
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
+ };
28
+ /** The `same` rows a store through 14.1 persisted (spec/store §7, 14.2). */
29
+ export declare function countSameDispositions(store: Store): number;
6
30
  /**
7
31
  * Mark-and-sweep GC (flag-gated; enabled must be true to run — v1.0 ships it
8
- * off). Removes blobs/tree_nodes unreachable from any revision root. Because
9
- * revisions are append-only and never pruned in v1, nothing is actually
10
- * unreachable unless revision pruning ran first — so this is a no-op in a
11
- * default v1 repo, exactly as specified. Rebuild-equivalence is preserved.
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.
37
+ *
38
+ * spec/store §7 (14.2): the same sweep deletes every `same` row of
39
+ * `dispositions` and `block_changes` — what a 14.1 store wrote for every
40
+ * untouched block of every commit, and nothing reads (`updated_at` and
41
+ * `blocks_history` are the block's own changes now). Not a migration: a
42
+ * data-only change needs no `user_version`, and opening a database must never
43
+ * bulk-delete. `dryRun` counts everything and deletes nothing.
12
44
  */
13
45
  export declare function runGc(store: Store, opts?: {
14
46
  enabled?: boolean;
47
+ dryRun?: boolean;
15
48
  }): GcResult;
16
49
  /** Resurrection-pool expiry sweep (02 §7): drop rows past their expires_ts.
17
50
  * Runs routinely (lazy, at checkpoint time). */