dsh-retrace 0.3.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.
@@ -0,0 +1,239 @@
1
+ /**
2
+ * dsh-retrace — content-addressed artifact snapshot store + `retrace` domain.
3
+ *
4
+ * PLAN.md §3.3 / §4.3: snapshots live under
5
+ *
6
+ * $DSH_HOME/dsh-retrace/objects/<sha256[:2]>/<sha256>
7
+ *
8
+ * written with the same durable recipe as the official attachment store
9
+ * (`dsh-attachment-local` saveImageFile): stage in tmp → O_EXCL write →
10
+ * fsync → hardlink publish → directory fsync → integrity-verified reads.
11
+ * The plugin owns this directory (host node:fs, exactly like attachments —
12
+ * it never touches the workspace or any user-approval surface); workspace
13
+ * reads/writes keep going through `ctx.fs`.
14
+ *
15
+ * Reference counting rides the `retrace` storageDomain (`refCounts` table +
16
+ * global config) so one content-addressed object is shared across versions
17
+ * and can be GC'd when nothing references it anymore. References are
18
+ * `<versionId>:<path>` strings, so P1 rollback can look an object up by the
19
+ * exact (version, file) pair.
20
+ */
21
+ import { createHash, randomUUID } from 'node:crypto'
22
+ import { constants } from 'node:fs'
23
+ import { chmod, link, mkdir, open, readdir, readFile, unlink } from 'node:fs/promises'
24
+ import { dirname, join, resolve } from 'node:path'
25
+ import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain'
26
+ import { z } from 'zod'
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // retrace storageDomain (refCounts + global config)
30
+ // ---------------------------------------------------------------------------
31
+
32
+ const refCountRecordSchema = z.object({
33
+ /** `<versionId>:<path>` strings — the versions/files referencing this object. */
34
+ refs: z.array(z.string()),
35
+ sizeBytes: z.number().int().nonnegative(),
36
+ createdAt: z.number().int().nonnegative(),
37
+ })
38
+
39
+ const globalConfigSchema = z.object({
40
+ retentionLimit: z.number().int().min(1),
41
+ gitEnabled: z.boolean(),
42
+ })
43
+
44
+ /**
45
+ * The `retrace` domain spec — open via `ctx.storageDomain.open(retraceDomainSpec)`.
46
+ * The web composition's `storage-json` backend lands it at
47
+ * `$DSH_HOME/storages/retrace.json` beside the session projection cache.
48
+ */
49
+ export const retraceDomainSpec = defineDomain({
50
+ name: 'retrace',
51
+ version: 1,
52
+ tables: {
53
+ // UNIT_NAME_RE is lowercase-only; `refcounts` is the official-safe spelling
54
+ // of the plan's `refCounts` table.
55
+ refcounts: domainTable(refCountRecordSchema),
56
+ },
57
+ global: {
58
+ schema: globalConfigSchema,
59
+ initial: { retentionLimit: 50, gitEnabled: true },
60
+ },
61
+ })
62
+
63
+ // ---------------------------------------------------------------------------
64
+ // Durable directory plumbing (attachment-local pattern)
65
+ // ---------------------------------------------------------------------------
66
+
67
+ /** fsync a directory so its entries survive a crash. */
68
+ async function syncDirectory(path) {
69
+ /* v8 ignore next -- Windows cannot open directory handles; NTFS journaling owns entry durability there. */
70
+ if (process.platform === 'win32') return
71
+ const handle = await open(path, constants.O_RDONLY)
72
+ try {
73
+ await handle.sync()
74
+ } finally {
75
+ await handle.close()
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Create a private directory tree and persist every ancestor entry up to a
81
+ * caller-vouched durable boundary (mirrors `ensureDurableDirectory` in
82
+ * `dsh-attachment-local`). Re-syncing a durable entry is harmless; skipping
83
+ * an unsynced one is not.
84
+ */
85
+ async function ensureDurableDirectory(path, boundary) {
86
+ const target = resolve(path)
87
+ const stop = resolve(boundary)
88
+ await mkdir(target, { recursive: true, mode: 0o700 })
89
+ await chmod(target, 0o700)
90
+ let level = target
91
+ while (level !== stop) {
92
+ const parent = dirname(level)
93
+ await syncDirectory(parent)
94
+ /* v8 ignore next -- callers pass a boundary that is an ancestor of path. */
95
+ if (parent === level) return
96
+ level = parent
97
+ }
98
+ }
99
+
100
+ /** sha256 of bytes (Buffer or Uint8Array). */
101
+ function digest(data) {
102
+ return createHash('sha256').update(data).digest('hex')
103
+ }
104
+
105
+ /** Absolute path of one content-addressed object below the store root. */
106
+ export function objectPath(root, sha256) {
107
+ return join(root, 'objects', sha256.slice(0, 2), sha256)
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // ArtifactStore
112
+ // ---------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Create the content-addressed snapshot store below `root`
116
+ * (`$DSH_HOME/dsh-retrace`). All I/O is host `node:fs` on the plugin's own
117
+ * directory; nothing here reads or writes the workspace.
118
+ */
119
+ export function createArtifactStore(root) {
120
+ const objectsRoot = join(root, 'objects')
121
+ const staging = join(root, 'tmp')
122
+
123
+ return {
124
+ root,
125
+ /** Absolute path of one object (read/remove). */
126
+ pathOf: (sha256) => objectPath(root, sha256),
127
+
128
+ /**
129
+ * Durably persist immutable bytes and return their content address.
130
+ * Deduplicated by sha256: a second save of identical bytes hardlinks to
131
+ * the existing object after verifying its integrity (`existed: true`).
132
+ * @returns {Promise<{sha256: string, sizeBytes: number, existed: boolean}>}
133
+ */
134
+ async save(data) {
135
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
136
+ const sha256 = digest(bytes)
137
+ const bucket = join(objectsRoot, sha256.slice(0, 2))
138
+ const target = objectPath(root, sha256)
139
+ await ensureDurableDirectory(bucket, root)
140
+ await ensureDurableDirectory(staging, root)
141
+ const temporary = join(staging, randomUUID())
142
+ let handle
143
+ try {
144
+ handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600)
145
+ await handle.writeFile(bytes)
146
+ await handle.sync()
147
+ await handle.close()
148
+ handle = undefined
149
+ let existed = false
150
+ try {
151
+ await link(temporary, target)
152
+ } catch (error) {
153
+ // EEXIST is the only recoverable link race: another save published first.
154
+ if (!(error instanceof Error && 'code' in error && error.code === 'EEXIST')) throw error
155
+ existed = true
156
+ const existing = new Uint8Array(await readFile(target))
157
+ if (digest(existing) !== sha256) {
158
+ throw new Error(`retrace artifact integrity mismatch at ${target}`)
159
+ }
160
+ }
161
+ await syncDirectory(bucket)
162
+ await syncDirectory(objectsRoot)
163
+ await unlink(temporary)
164
+ return { sha256, sizeBytes: bytes.byteLength, existed }
165
+ } catch (error) {
166
+ /* v8 ignore next -- descriptor can remain open only when write/sync/close failed. */
167
+ if (handle !== undefined) await handle.close().catch(() => {})
168
+ await unlink(temporary).catch(() => {})
169
+ throw error
170
+ }
171
+ },
172
+
173
+ /** Read an object, verifying its content address (integrity-checked). */
174
+ async read(sha256) {
175
+ const data = new Uint8Array(await readFile(objectPath(root, sha256)))
176
+ if (digest(data) !== sha256) {
177
+ throw new Error(`retrace artifact integrity check failed for ${sha256}`)
178
+ }
179
+ return data
180
+ },
181
+
182
+ /** Remove one object; missing objects are a no-op. */
183
+ async remove(sha256) {
184
+ try {
185
+ await unlink(objectPath(root, sha256))
186
+ } catch (error) {
187
+ /* v8 ignore next -- concurrent GC removing the same object is fine. */
188
+ if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) throw error
189
+ }
190
+ },
191
+
192
+ /** Every stored object sha256 (walks `objects/<xx>/<sha>`). */
193
+ async list() {
194
+ let buckets
195
+ try {
196
+ buckets = await readdir(objectsRoot, { withFileTypes: true })
197
+ } catch (error) {
198
+ if (error instanceof Error && 'code' in error && error.code === 'ENOENT') return []
199
+ throw error
200
+ }
201
+ const hashes = []
202
+ for (const bucket of buckets) {
203
+ if (!bucket.isDirectory() || bucket.name.length !== 2) continue
204
+ for (const entry of await readdir(join(objectsRoot, bucket.name))) {
205
+ if (entry.length === 64) hashes.push(entry)
206
+ }
207
+ }
208
+ return hashes.sort()
209
+ },
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Remove every stored object not in `keep` (a Set of sha256).
215
+ * Callers derive `keep` from the `refCounts` table (refs.length > 0) plus
216
+ * whatever retention policy applies. Returns the number of removed objects.
217
+ */
218
+ export async function gcArtifacts(store, keep) {
219
+ const removed = []
220
+ for (const sha256 of await store.list()) {
221
+ if (!keep.has(sha256)) {
222
+ await store.remove(sha256)
223
+ removed.push(sha256)
224
+ }
225
+ }
226
+ return removed.length
227
+ }
228
+
229
+ /** Parse a `<versionId>:<path>` reference (first colon splits version id). */
230
+ export function parseRef(ref) {
231
+ const colon = ref.indexOf(':')
232
+ if (colon === -1) return { versionId: null, path: ref }
233
+ return { versionId: ref.slice(0, colon), path: ref.slice(colon + 1) }
234
+ }
235
+
236
+ /** Build a `<versionId>:<path>` reference string. */
237
+ export function refFor(versionId, path) {
238
+ return `${versionId}:${path}`
239
+ }