zyrk 0.2.3 → 0.3.7

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.
@@ -0,0 +1,689 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ import { ZyrkError } from "../errors.ts";
4
+ import { lockedGraph } from "../install/lock.ts";
5
+ import type { NpmLockDoc } from "../lockfile/types.ts";
6
+ import { openStoreDb, type StoreDb } from "../store/db.ts";
7
+ import {
8
+ contextIdFor,
9
+ createGraphDoc,
10
+ graphHash,
11
+ validateGraph,
12
+ type GraphDoc,
13
+ } from "../store/graph.ts";
14
+ import { Store } from "../store/layout.ts";
15
+ import { readSourceManifest } from "../store/manifests.ts";
16
+ import { objectPathFor, verifyObjectPayload } from "../store/objects.ts";
17
+ import { realizationDirFor } from "../store/realize.ts";
18
+ import { verifyRealization } from "../store/verify.ts";
19
+ import {
20
+ isErrno,
21
+ pathExists,
22
+ rmrfNoFollow,
23
+ writeFileAtomic,
24
+ } from "../util/fsx.ts";
25
+ import { objectIdOf } from "../util/hash.ts";
26
+ import { randomId } from "../util/ids.ts";
27
+ import { acquireLock } from "../util/locks.ts";
28
+
29
+ type Root = {
30
+ kind: "generation" | "pin";
31
+ projectId?: string;
32
+ project?: string;
33
+ graph: string;
34
+ instances: string[];
35
+ sources: string[];
36
+ };
37
+ type RootFile = { id: string; root: Root };
38
+ type Item = {
39
+ relative: string;
40
+ kind: "source" | "object" | "realization" | "graph";
41
+ id: string;
42
+ };
43
+ const GRACE_MS = 30 * 86400_000;
44
+
45
+ function corrupt(subject: string): never {
46
+ throw new ZyrkError(
47
+ "ZYRK_STORE_CORRUPT",
48
+ `Cannot inspect store ${subject}.`,
49
+ {
50
+ remedy:
51
+ "Restore the damaged metadata or re-fetch the affected packages; collection stops until liveness can be established.",
52
+ },
53
+ );
54
+ }
55
+ function usage(): never {
56
+ throw new ZyrkError(
57
+ "ZYRK_USAGE",
58
+ "Invalid store or projects command arguments.",
59
+ {
60
+ remedy:
61
+ "Use store path, status, verify --full, gc [--dry-run], pin <project-or-lockfile>, unpin <pin-id>, or projects list / forget <project-id>.",
62
+ },
63
+ );
64
+ }
65
+ function component(value: string): string {
66
+ if (!/^[A-Za-z0-9_-]+(?:\.json)?$/.test(value)) corrupt("identifier");
67
+ return value;
68
+ }
69
+ async function json(file: string): Promise<unknown> {
70
+ if (!(await fs.lstat(file)).isFile()) corrupt("metadata file type");
71
+ try {
72
+ return JSON.parse(await fs.readFile(file, "utf8"));
73
+ } catch {
74
+ return corrupt("JSON metadata");
75
+ }
76
+ }
77
+ async function names(dir: string): Promise<string[]> {
78
+ try {
79
+ if (!(await fs.lstat(dir)).isDirectory()) corrupt("directory type");
80
+ return (await fs.readdir(dir)).sort();
81
+ } catch (error) {
82
+ if (isErrno(error, "ENOENT")) return [];
83
+ throw error;
84
+ }
85
+ }
86
+ async function roots(store: Store): Promise<RootFile[]> {
87
+ const result: RootFile[] = [];
88
+ for (const id of await names(store.paths.pinsDir)) {
89
+ component(id);
90
+ const value = (await json(
91
+ path.join(store.paths.pinsDir, id),
92
+ )) as Partial<Root>;
93
+ if (
94
+ !value ||
95
+ !["generation", "pin"].includes(value.kind ?? "") ||
96
+ typeof value.graph !== "string" ||
97
+ !Array.isArray(value.instances) ||
98
+ !Array.isArray(value.sources)
99
+ )
100
+ corrupt("retention root");
101
+ component(value.graph);
102
+ for (const source of value.sources) {
103
+ if (typeof source !== "string") corrupt("source root");
104
+ component(source);
105
+ }
106
+ for (const instance of value.instances) {
107
+ if (
108
+ typeof instance !== "string" ||
109
+ path.dirname(instance) !== store.paths.realizationsDir
110
+ )
111
+ corrupt("instance root");
112
+ component(path.basename(instance));
113
+ }
114
+ if (
115
+ value.kind === "generation" &&
116
+ (typeof value.projectId !== "string" || typeof value.project !== "string")
117
+ )
118
+ corrupt("project root");
119
+ result.push({ id, root: value as Root });
120
+ }
121
+ return result;
122
+ }
123
+ async function graphAt(store: Store, id: string): Promise<GraphDoc> {
124
+ component(id);
125
+ const graph = (await json(
126
+ path.join(store.paths.graphsDir, `${id}.json`),
127
+ )) as GraphDoc;
128
+ try {
129
+ validateGraph(graph);
130
+ if (graphHash(graph) !== id) corrupt("graph digest");
131
+ } catch {
132
+ corrupt("graph descriptor");
133
+ }
134
+ for (const node of graph.nodes) component(node.sourceId);
135
+ return graph;
136
+ }
137
+
138
+ /** All publication and root/lease mutation uses this same store barrier. */
139
+ export async function runStoreCommand(options: {
140
+ command: "store" | "projects";
141
+ args: readonly string[];
142
+ store: Store;
143
+ cwd: string;
144
+ json?: boolean;
145
+ }): Promise<unknown> {
146
+ const { store, args } = options;
147
+ const [action, argument, extra] = args;
148
+ if (options.command === "store" && action === "path" && args.length === 1)
149
+ return { path: store.root };
150
+ if (!store.initialized) {
151
+ if (action === "status") return { initialized: false };
152
+ if (options.command === "projects" && action === "list")
153
+ return { projects: [] };
154
+ store.requireInitialized();
155
+ }
156
+ const barrier = await acquireLock(
157
+ path.join(store.paths.locksDir, "liveness.lock"),
158
+ { name: "store liveness", timeoutMs: 30_000 },
159
+ );
160
+ try {
161
+ if (options.command === "projects") {
162
+ if (action === "list" && args.length === 1)
163
+ return await listProjects(store);
164
+ if (action === "forget" && argument && !extra)
165
+ return await forgetProject(store, argument);
166
+ usage();
167
+ }
168
+ if (action === "status" && args.length === 1) return await status(store);
169
+ if (action === "verify" && argument === "--full" && !extra)
170
+ return await verify(store);
171
+ if (action === "gc" && (!argument || argument === "--dry-run") && !extra)
172
+ return await collect(store, argument === "--dry-run");
173
+ if (action === "pin" && argument && !extra)
174
+ return await pin(store, path.resolve(options.cwd, argument));
175
+ if (action === "unpin" && argument && !extra) {
176
+ component(argument);
177
+ const found = (await roots(store)).find(
178
+ (r) => r.id === `${argument}.json` && r.root.kind === "pin",
179
+ );
180
+ if (!found) usage();
181
+ await fs.unlink(path.join(store.paths.pinsDir, found.id));
182
+ return {
183
+ unpinned: argument,
184
+ impact:
185
+ "The closure can become eligible for collection after its unreferenced grace period; installed project roots remain protected.",
186
+ };
187
+ }
188
+ usage();
189
+ } finally {
190
+ await barrier.release();
191
+ }
192
+ }
193
+
194
+ async function listProjects(store: Store) {
195
+ const projects = new Map<
196
+ string,
197
+ { projectId: string; path: string; graphs: string[]; reachability: string }
198
+ >();
199
+ for (const { root } of await roots(store)) {
200
+ if (root.kind !== "generation" || !root.projectId || !root.project)
201
+ continue;
202
+ let project = projects.get(root.projectId);
203
+ if (!project) {
204
+ let reachability = "uncertain; retained";
205
+ try {
206
+ const state = (await json(
207
+ path.join(root.project, ".zyrk", "install-state.json"),
208
+ )) as { projectId?: string };
209
+ reachability =
210
+ state.projectId === root.projectId
211
+ ? "present"
212
+ : "identity mismatch; retained";
213
+ } catch (error) {
214
+ if (isErrno(error, "ENOENT"))
215
+ reachability = "missing; retained until explicitly forgotten";
216
+ }
217
+ project = {
218
+ projectId: root.projectId,
219
+ path: root.project,
220
+ graphs: [],
221
+ reachability,
222
+ };
223
+ projects.set(root.projectId, project);
224
+ }
225
+ project.graphs.push(root.graph);
226
+ }
227
+ return { projects: [...projects.values()] };
228
+ }
229
+ async function forgetProject(store: Store, id: string) {
230
+ component(id);
231
+ const found = (await roots(store)).filter(
232
+ ({ root }) => root.kind === "generation" && root.projectId === id,
233
+ );
234
+ if (!found.length) usage();
235
+ // Root removal is explicit, never inferred from ENOENT or inaccessible media.
236
+ for (const root of found)
237
+ await fs.unlink(path.join(store.paths.pinsDir, root.id));
238
+ if (await pathExists(store.paths.dbFile)) {
239
+ const db = openStoreDb(store.paths.dbFile);
240
+ try {
241
+ db.prepare("UPDATE roots SET state = 'removed' WHERE project_id = ?").run(
242
+ id,
243
+ );
244
+ db.prepare(
245
+ "UPDATE generations SET state = 'abandoned' WHERE project_id = ?",
246
+ ).run(id);
247
+ } finally {
248
+ db.close();
249
+ }
250
+ }
251
+ return {
252
+ forgotten: id,
253
+ removedRoots: found.length,
254
+ impact:
255
+ "Surviving links in this project or moved/copied locations may break after collection. Reinstall or pin their closure before collecting.",
256
+ };
257
+ }
258
+
259
+ async function pin(store: Store, target: string) {
260
+ const stat = await fs.stat(target);
261
+ const lockPath = stat.isDirectory()
262
+ ? path.join(target, "package-lock.json")
263
+ : target;
264
+ const resolved = lockedGraph((await json(lockPath)) as NpmLockDoc);
265
+ const graph = createGraphDoc(
266
+ resolved.nodes.map((node) => ({ ...node, deps: node.edges })),
267
+ );
268
+ const sources = [...new Set(graph.nodes.map((node) => node.sourceId))];
269
+ for (const id of sources) {
270
+ component(id);
271
+ const doc = await safeManifest(store, id);
272
+ for (const entry of doc.manifest.entries)
273
+ if (entry.type === "file") {
274
+ await requireObject(store, entry);
275
+ }
276
+ }
277
+ const instances: string[] = [];
278
+ for (const node of graph.nodes) {
279
+ const context = contextIdFor(graph, node.key);
280
+ if (await pathExists(realizationDirFor(store, context))) {
281
+ await verifyRealization({
282
+ store,
283
+ graph,
284
+ rootKey: node.key,
285
+ contextId: context,
286
+ });
287
+ instances.push(realizationDirFor(store, context));
288
+ }
289
+ }
290
+ const id = randomId("pin1_");
291
+ const digest = graphHash(graph);
292
+ await writeFileAtomic(
293
+ path.join(store.paths.graphsDir, `${digest}.json`),
294
+ JSON.stringify(graph),
295
+ { mode: 0o600 },
296
+ );
297
+ const root: Root = { kind: "pin", graph: digest, instances, sources };
298
+ await writeFileAtomic(
299
+ path.join(store.paths.pinsDir, `${id}.json`),
300
+ JSON.stringify(root),
301
+ { mode: 0o600 },
302
+ );
303
+ return {
304
+ pinId: id,
305
+ sources: sources.length,
306
+ scope:
307
+ "Verified sources and existing realizations for the current platform. Native compilation still requires the project toolchain; registry metadata and tool downloads are not pinned.",
308
+ };
309
+ }
310
+
311
+ async function safeManifest(store: Store, id: string) {
312
+ component(id);
313
+ const directory = path.join(store.paths.sourcesDir, id);
314
+ await requireType(directory, "directory");
315
+ await requireType(path.join(directory, "manifest.json"), "file");
316
+ return await readSourceManifest(store, id);
317
+ }
318
+
319
+ async function requireType(target: string, type: "directory" | "file") {
320
+ try {
321
+ const stat = await fs.lstat(target);
322
+ if (type === "directory" ? !stat.isDirectory() : !stat.isFile())
323
+ corrupt("managed entry type");
324
+ } catch {
325
+ corrupt("missing or unreadable managed entry");
326
+ }
327
+ }
328
+
329
+ async function requireObject(
330
+ store: Store,
331
+ entry: {
332
+ objectId: string;
333
+ innerDigest: string;
334
+ mode: "plain" | "exec";
335
+ size: number;
336
+ },
337
+ ) {
338
+ component(entry.objectId);
339
+ await requireType(path.join(store.paths.objectsDir, "sha512"), "directory");
340
+ if (objectIdOf(entry.mode, entry.innerDigest) !== entry.objectId)
341
+ corrupt("object identity");
342
+ const target = objectPathFor(store, entry.objectId);
343
+ await requireType(path.dirname(target), "directory");
344
+ await requireType(target, "file");
345
+ const result = await verifyObjectPayload(store, {
346
+ objectId: entry.objectId,
347
+ innerDigestHex: entry.innerDigest,
348
+ modeClass: entry.mode,
349
+ bytes: entry.size,
350
+ });
351
+ if (!result.ok) corrupt(`object payload (${result.reason})`);
352
+ }
353
+
354
+ async function inventory(store: Store): Promise<Item[]> {
355
+ const result: Item[] = [];
356
+ for (const [directory, kind] of [
357
+ ["sources", "source"],
358
+ ["realizations", "realization"],
359
+ ["graphs", "graph"],
360
+ ] as const) {
361
+ for (const id of await names(path.join(store.root, directory))) {
362
+ component(id);
363
+ const relative = path.join(directory, id);
364
+ const stat = await fs.lstat(path.join(store.root, relative));
365
+ if (kind === "graph" ? !stat.isFile() : !stat.isDirectory())
366
+ corrupt("inventory entry type");
367
+ result.push({
368
+ relative,
369
+ kind,
370
+ id: kind === "graph" ? id.replace(/\.json$/, "") : id,
371
+ });
372
+ }
373
+ }
374
+ for (const prefix of await names(
375
+ path.join(store.paths.objectsDir, "sha512"),
376
+ )) {
377
+ if (!/^[0-9a-f]{2}$/.test(prefix)) corrupt("object prefix");
378
+ for (const id of await names(
379
+ path.join(store.paths.objectsDir, "sha512", prefix),
380
+ )) {
381
+ if (!/^[0-9a-f]{128}$/.test(id) || !id.startsWith(prefix))
382
+ corrupt("object key");
383
+ const relative = path.join("objects", "sha512", prefix, id);
384
+ if (!(await fs.lstat(path.join(store.root, relative))).isFile())
385
+ corrupt("object type");
386
+ result.push({ relative, kind: "object", id });
387
+ }
388
+ }
389
+ return result;
390
+ }
391
+
392
+ async function verify(store: Store) {
393
+ let sources = 0,
394
+ objects = 0,
395
+ realizations = 0;
396
+ const checked = new Set<string>();
397
+ const items = await inventory(store);
398
+ for (const { root } of await roots(store)) {
399
+ await graphAt(store, root.graph);
400
+ for (const source of root.sources) await safeManifest(store, source);
401
+ for (const instance of root.instances)
402
+ await requireType(instance, "directory");
403
+ }
404
+ for (const item of items.filter((item) => item.kind === "source")) {
405
+ const doc = await safeManifest(store, item.id);
406
+ for (const entry of doc.manifest.entries)
407
+ if (entry.type === "file" && !checked.has(entry.objectId)) {
408
+ await requireObject(store, entry);
409
+ checked.add(entry.objectId);
410
+ objects++;
411
+ }
412
+ sources++;
413
+ }
414
+ const contexts = new Set<string>();
415
+ for (const item of items.filter((item) => item.kind === "graph")) {
416
+ const graph = await graphAt(store, item.id);
417
+ for (const node of graph.nodes) {
418
+ const contextId = contextIdFor(graph, node.key);
419
+ if (
420
+ contexts.has(contextId) ||
421
+ !(await pathExists(realizationDirFor(store, contextId)))
422
+ )
423
+ continue;
424
+ await verifyRealization({ store, graph, rootKey: node.key, contextId });
425
+ contexts.add(contextId);
426
+ realizations++;
427
+ }
428
+ }
429
+ // Unindexed payload cannot be called verified merely because it exists.
430
+ if (items.some((item) => item.kind === "object" && !checked.has(item.id)))
431
+ corrupt("object without a source manifest");
432
+ if (
433
+ items.some((item) => item.kind === "realization" && !contexts.has(item.id))
434
+ )
435
+ corrupt("realization without a graph descriptor");
436
+ return { verified: true, sources, objects, realizations };
437
+ }
438
+
439
+ async function allocation(
440
+ target: string,
441
+ seen = new Set<string>(),
442
+ ): Promise<{ logicalBytes: number; allocatedBytesEstimate: number }> {
443
+ const stat = await fs.lstat(target);
444
+ let logicalBytes = 0,
445
+ allocatedBytesEstimate = 0;
446
+ const inode = `${stat.dev}:${stat.ino}`;
447
+ if (!seen.has(inode)) {
448
+ seen.add(inode);
449
+ allocatedBytesEstimate += stat.blocks * 512;
450
+ if (stat.isFile()) logicalBytes += stat.size;
451
+ }
452
+ if (stat.isDirectory())
453
+ for (const name of await fs.readdir(target)) {
454
+ const child = await allocation(path.join(target, name), seen);
455
+ logicalBytes += child.logicalBytes;
456
+ allocatedBytesEstimate += child.allocatedBytesEstimate;
457
+ }
458
+ return { logicalBytes, allocatedBytesEstimate };
459
+ }
460
+ async function status(store: Store) {
461
+ const categories: Record<string, unknown> = {};
462
+ for (const name of [
463
+ "objects",
464
+ "sources",
465
+ "realizations",
466
+ "graphs",
467
+ "staging",
468
+ "quarantine",
469
+ "trash",
470
+ ] as const) {
471
+ categories[name] = await allocation(path.join(store.root, name));
472
+ }
473
+ const total = await allocation(store.root);
474
+ const retained = await roots(store);
475
+ let logicalSourceBytesReferencedByProjects = 0;
476
+ for (const { root } of retained.filter(
477
+ ({ root }) => root.kind === "generation",
478
+ )) {
479
+ for (const id of new Set(root.sources))
480
+ logicalSourceBytesReferencedByProjects += (await safeManifest(store, id))
481
+ .manifest.totals.logicalBytes;
482
+ }
483
+ return {
484
+ initialized: true,
485
+ categories,
486
+ logicalSourceBytesReferencedByProjects,
487
+ ...total,
488
+ allocationMethod:
489
+ "Estimate from st_blocks deduplicated by device/inode; reflink extent sharing and exclusive reclaimability are unknown.",
490
+ privateProjectBytes: "unavailable",
491
+ archivesAndFetchCaches: "unavailable",
492
+ buildOutputsAndToolCaches: "unavailable",
493
+ retentionRoots: retained.length,
494
+ graceDays: 30,
495
+ };
496
+ }
497
+
498
+ async function collect(store: Store, dryRun: boolean) {
499
+ const items = await inventory(store);
500
+ const marked = new Set<string>();
501
+ const reasons: string[] = [];
502
+ const retainedRoots = await roots(store);
503
+ const markSource = async (id: string) => {
504
+ component(id);
505
+ marked.add(path.join("sources", id));
506
+ const doc = await safeManifest(store, id);
507
+ for (const entry of doc.manifest.entries)
508
+ if (entry.type === "file") {
509
+ component(entry.objectId);
510
+ marked.add(
511
+ path.relative(store.root, objectPathFor(store, entry.objectId)),
512
+ );
513
+ }
514
+ };
515
+ for (const { root } of retainedRoots) {
516
+ marked.add(path.join("graphs", `${root.graph}.json`));
517
+ const graph = await graphAt(store, root.graph);
518
+ for (const node of graph.nodes) {
519
+ await markSource(node.sourceId);
520
+ marked.add(
521
+ path.relative(
522
+ store.root,
523
+ realizationDirFor(store, contextIdFor(graph, node.key)),
524
+ ),
525
+ );
526
+ }
527
+ for (const id of root.sources) await markSource(id);
528
+ for (const instance of root.instances)
529
+ marked.add(path.relative(store.root, instance));
530
+ }
531
+ if ((await names(store.paths.stagingDir)).length)
532
+ reasons.push(
533
+ "Staging work exists; retain all content until transactions are reconciled.",
534
+ );
535
+ if (await pathExists(store.paths.dbFile)) {
536
+ const db = openStoreDb(store.paths.dbFile);
537
+ try {
538
+ if (
539
+ db
540
+ .prepare("SELECT lease_id FROM leases WHERE state != 'released'")
541
+ .all().length
542
+ )
543
+ reasons.push("Active or uncertain process leases protect the store.");
544
+ const activeRoots = db
545
+ .prepare("SELECT project_id FROM roots WHERE state != 'removed'")
546
+ .all() as { project_id: string | null }[];
547
+ if (
548
+ activeRoots.some(
549
+ (root) =>
550
+ !root.project_id ||
551
+ !retainedRoots.some(
552
+ ({ root: durable }) => durable.projectId === root.project_id,
553
+ ),
554
+ )
555
+ )
556
+ reasons.push(
557
+ "Indexed roots without durable descriptors require conservative retention.",
558
+ );
559
+ // Any indexed generation reference is retained, including rollback/pending roots.
560
+ const indexed = db
561
+ .prepare(
562
+ "SELECT DISTINCT path FROM realization_instances JOIN generation_instances USING (instance_id) JOIN generations USING (generation_id) WHERE generations.state != 'abandoned'",
563
+ )
564
+ .all() as { path: string }[];
565
+ if (
566
+ indexed.some((row) => !marked.has(path.relative(store.root, row.path)))
567
+ )
568
+ reasons.push(
569
+ "Additional indexed generation instances require conservative retention.",
570
+ );
571
+ } finally {
572
+ db.close();
573
+ }
574
+ }
575
+ const marksFile = path.join(store.paths.metadataDir, "gc-unreferenced.json");
576
+ const prior = (await pathExists(marksFile))
577
+ ? ((await json(marksFile)) as Record<string, number>)
578
+ : {};
579
+ if (!prior || typeof prior !== "object" || Array.isArray(prior))
580
+ corrupt("GC grace metadata");
581
+ const next: Record<string, number> = {};
582
+ const now = Date.now();
583
+ let candidates: Item[] = [];
584
+ let graceRetained = 0;
585
+ for (const item of items) {
586
+ if (marked.has(item.relative) || reasons.length) continue;
587
+ const first = prior[item.relative];
588
+ if (
589
+ first !== undefined &&
590
+ (!Number.isFinite(first) || first > now || first < 0)
591
+ )
592
+ corrupt("GC grace timestamp");
593
+ next[item.relative] = first ?? now;
594
+ if (first !== undefined && now - first >= GRACE_MS) candidates.push(item);
595
+ else graceRetained++;
596
+ }
597
+ // Grace-retained descriptors/instances also retain their source closure.
598
+ // A recent graph may reuse an old object whose own timer has elapsed.
599
+ const candidatePaths = new Set(candidates.map((item) => item.relative));
600
+ const retainedContexts = new Set(
601
+ items
602
+ .filter(
603
+ (item) =>
604
+ item.kind === "realization" && !candidatePaths.has(item.relative),
605
+ )
606
+ .map((item) => item.id),
607
+ );
608
+ for (const item of items.filter((item) => item.kind === "graph")) {
609
+ const graph = await graphAt(store, item.id);
610
+ for (const node of graph.nodes) {
611
+ if (
612
+ !candidatePaths.has(item.relative) ||
613
+ retainedContexts.has(contextIdFor(graph, node.key))
614
+ ) {
615
+ await markSource(node.sourceId);
616
+ marked.add(
617
+ path.relative(
618
+ store.root,
619
+ realizationDirFor(store, contextIdFor(graph, node.key)),
620
+ ),
621
+ );
622
+ marked.add(item.relative);
623
+ }
624
+ }
625
+ }
626
+ for (const item of items.filter(
627
+ (item) =>
628
+ item.kind === "source" &&
629
+ (!candidatePaths.has(item.relative) || marked.has(item.relative)),
630
+ ))
631
+ await markSource(item.id);
632
+ candidates = candidates.filter((item) => !marked.has(item.relative));
633
+ const report = {
634
+ dryRun,
635
+ candidates: candidates.map(({ relative, kind }) => ({
636
+ path: relative,
637
+ kind,
638
+ reason: "Unreferenced for at least 30 days",
639
+ })),
640
+ retained: items.length - candidates.length,
641
+ graceRetained,
642
+ retentionReasons: reasons,
643
+ removed: 0,
644
+ logicalBytesSelected: 0,
645
+ physicalBytesReclaimed: "unavailable",
646
+ };
647
+ for (const item of candidates)
648
+ report.logicalBytesSelected += (
649
+ await allocation(path.join(store.root, item.relative))
650
+ ).logicalBytes;
651
+ if (dryRun) return report;
652
+ await writeFileAtomic(marksFile, JSON.stringify(next), { mode: 0o600 });
653
+ const runId = randomId("gc1_");
654
+ const trash = path.join(store.paths.trashDir, runId);
655
+ await fs.mkdir(trash, { mode: 0o700 });
656
+ // The persisted plan makes interrupted detachment inspectable. Trash is never reused.
657
+ await writeFileAtomic(path.join(trash, "plan.json"), JSON.stringify(report), {
658
+ mode: 0o600,
659
+ });
660
+ for (const item of candidates) {
661
+ const dest = path.join(trash, item.relative);
662
+ await fs.mkdir(path.dirname(dest), { recursive: true, mode: 0o700 });
663
+ await fs.rename(path.join(store.root, item.relative), dest);
664
+ if (await pathExists(store.paths.dbFile)) {
665
+ const db = openStoreDb(store.paths.dbFile);
666
+ try {
667
+ retire(db, item);
668
+ } finally {
669
+ db.close();
670
+ }
671
+ }
672
+ report.removed++;
673
+ }
674
+ await rmrfNoFollow(trash);
675
+ return report;
676
+ }
677
+ function retire(db: StoreDb, item: Item) {
678
+ if (item.kind === "realization") {
679
+ db.prepare(
680
+ "UPDATE realization_instances SET verification_state = 'retired' WHERE context_id = ?",
681
+ ).run(item.id);
682
+ db.prepare(
683
+ "UPDATE realizations SET readiness = 'retired' WHERE context_id = ?",
684
+ ).run(item.id);
685
+ }
686
+ if (item.kind === "object")
687
+ db.prepare("DELETE FROM objects WHERE object_id = ?").run(item.id);
688
+ // Source index rows remain diagnostic/rebuildable while retired instances refer to them.
689
+ }