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.
- package/HUMANS.txt +18 -0
- package/LICENSE +21 -0
- package/README.md +317 -0
- package/README.zh.md +284 -0
- package/cordis.patch.yml +17 -0
- package/lib/artifact-store.js +239 -0
- package/lib/client.bundle.js +637 -0
- package/lib/client.js +752 -0
- package/lib/dynamic-client.js +647 -0
- package/lib/dynamic-host.js +399 -0
- package/lib/host-core.js +379 -0
- package/lib/http.js +186 -0
- package/lib/index.js +75 -0
- package/lib/projection/versions.js +69 -0
- package/lib/types/client.d.ts +14 -0
- package/lib/types/index.d.ts +65 -0
- package/lib/version-index.js +310 -0
- package/lib/versioning.js +193 -0
- package/package.json +100 -0
|
@@ -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
|
+
}
|