@deepblend/dsh-blender-host 0.1.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/lib/delivery-manifest.js +177 -0
- package/lib/frame-ledger.js +189 -0
- package/lib/index.js +4778 -0
- package/lib/paths.js +314 -0
- package/lib/project-store.js +528 -0
- package/lib/render-job-store.js +290 -0
- package/lib/render-journal.js +236 -0
- package/lib/render-reconciler.js +340 -0
- package/lib/revision-transaction.js +1064 -0
- package/lib/video-encoder.js +345 -0
- package/package.json +34 -0
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project Store — the durable, cross-session home of a DeepBlend project.
|
|
3
|
+
*
|
|
4
|
+
* Plane: HOST composition (SPEC §4.1 "DeepBlend Project Store", §4.3 "项目与
|
|
5
|
+
* Revision 存储 → Host / 跨会话持久化"). It is in-process shared state on disk,
|
|
6
|
+
* not a per-session service.
|
|
7
|
+
*
|
|
8
|
+
* LAYOUT (one directory per project, everything below it project-relative):
|
|
9
|
+
*
|
|
10
|
+
* ```
|
|
11
|
+
* <projectsRoot>/
|
|
12
|
+
* projects.json index: one line per project, for listing
|
|
13
|
+
* <projectId>/
|
|
14
|
+
* project.json PROJECT RECORD — the authority for "what is current"
|
|
15
|
+
* assets/ ingested user assets (M1: declared, not yet ingested)
|
|
16
|
+
* revisions/
|
|
17
|
+
* r0001/ immutable once published
|
|
18
|
+
* scene-spec.json the revision's scene (the real source of truth)
|
|
19
|
+
* revision-manifest.json identity, digests, counts, validation
|
|
20
|
+
* operation-manifest.json what changed and why
|
|
21
|
+
* request.json the exact patch that was accepted
|
|
22
|
+
* validation.json the QA report for this revision
|
|
23
|
+
* scene.blend checkpoint, when saveCheckpoint was set
|
|
24
|
+
* previews/ rendered previews for this revision
|
|
25
|
+
* jobs/
|
|
26
|
+
* <jobId>.json one durable record per Blender batch attempt
|
|
27
|
+
* operations/
|
|
28
|
+
* <key-hash>.json idempotency ledger (SPEC §8.5)
|
|
29
|
+
* staging/
|
|
30
|
+
* <revision>/ a revision under construction; never read
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* Two rules the layout encodes:
|
|
34
|
+
*
|
|
35
|
+
* - **`currentRevision` lives in `project.json`, never in a revision.** A
|
|
36
|
+
* revision manifest describes that revision and nothing else, so publishing a
|
|
37
|
+
* revision and moving the pointer stay two separate steps and a half-finished
|
|
38
|
+
* publish cannot claim to be current.
|
|
39
|
+
* - **`staging/` is the only mutable revision state.** Everything under
|
|
40
|
+
* `revisions/` was published by an atomic rename and is never written again.
|
|
41
|
+
*
|
|
42
|
+
* Owner: DeepBlend Studio — M1
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { existsSync, readdirSync } from 'node:fs'
|
|
46
|
+
import { join } from 'node:path'
|
|
47
|
+
|
|
48
|
+
import {
|
|
49
|
+
BlenderError,
|
|
50
|
+
BlenderErrorCode,
|
|
51
|
+
PROJECT_RECORD_VERSION,
|
|
52
|
+
canonicalPretty,
|
|
53
|
+
sha256,
|
|
54
|
+
} from '@deepblend/dsh-blender-contracts'
|
|
55
|
+
import {
|
|
56
|
+
ensureDirectory,
|
|
57
|
+
isFile,
|
|
58
|
+
listDirectories,
|
|
59
|
+
readJson,
|
|
60
|
+
requireSafeSegment,
|
|
61
|
+
resolveInside,
|
|
62
|
+
safeRealpath,
|
|
63
|
+
slugifyProjectId,
|
|
64
|
+
writeFileAtomic,
|
|
65
|
+
writeJsonAtomic,
|
|
66
|
+
} from './paths.js'
|
|
67
|
+
|
|
68
|
+
/** Revision id grammar. Zero-padded so directory listings sort chronologically. */
|
|
69
|
+
const REVISION_PATTERN = /^r(\d+)$/
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Format a revision number as an id. Padding widens past 9999 rather than
|
|
73
|
+
* truncating, so ordering by name never silently breaks on a long-running project.
|
|
74
|
+
* @param {number} value
|
|
75
|
+
* @returns {string}
|
|
76
|
+
*/
|
|
77
|
+
export function formatRevisionId(value) {
|
|
78
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
79
|
+
throw new BlenderError(BlenderErrorCode.REVISION_ALLOCATION_FAILED, `Invalid revision number ${value}.`)
|
|
80
|
+
}
|
|
81
|
+
return `r${String(value).padStart(4, '0')}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse a revision id into its ordinal, or `null` when it is not one.
|
|
86
|
+
* @param {unknown} value
|
|
87
|
+
* @returns {number|null}
|
|
88
|
+
*/
|
|
89
|
+
export function parseRevisionId(value) {
|
|
90
|
+
if (typeof value !== 'string') return null
|
|
91
|
+
const match = REVISION_PATTERN.exec(value)
|
|
92
|
+
if (match === null) return null
|
|
93
|
+
const ordinal = Number.parseInt(match[1], 10)
|
|
94
|
+
return Number.isInteger(ordinal) && ordinal > 0 ? ordinal : null
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The revision id used before any revision exists. Never a real revision. */
|
|
98
|
+
export const GENESIS_REVISION = 'r0000'
|
|
99
|
+
|
|
100
|
+
/** Compute the sha256 that keys an idempotency record. */
|
|
101
|
+
function idempotencyRecordFor(projectId, key) {
|
|
102
|
+
return {
|
|
103
|
+
schemaVersion: 'deepblend.idempotency/v1',
|
|
104
|
+
projectId,
|
|
105
|
+
idempotencyKey: key,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export class ProjectStore {
|
|
110
|
+
/**
|
|
111
|
+
* @param {{ projectsRoot: string, workspaceRoot: string }} config
|
|
112
|
+
*/
|
|
113
|
+
constructor(config) {
|
|
114
|
+
this.projectsRoot = safeRealpath(config.projectsRoot)
|
|
115
|
+
this.workspaceRoot = safeRealpath(config.workspaceRoot)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The projects root, created on first use. */
|
|
119
|
+
ensureRoot() {
|
|
120
|
+
return ensureDirectory(this.workspaceRoot, join(this.projectsRoot), 'projectsRoot')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Absolute path of a project directory. Validates the id before joining. */
|
|
124
|
+
projectDirectory(projectId) {
|
|
125
|
+
requireSafeSegment(projectId, 'project id')
|
|
126
|
+
return resolveInside(this.projectsRoot, join(this.projectsRoot, projectId), `project "${projectId}"`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Absolute path of one revision directory inside a project. */
|
|
130
|
+
revisionDirectory(projectId, revision) {
|
|
131
|
+
if (parseRevisionId(revision) === null) {
|
|
132
|
+
throw new BlenderError(
|
|
133
|
+
BlenderErrorCode.REVISION_ID_INVALID,
|
|
134
|
+
`"${revision}" is not a revision id; expected the form r0001.`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
return join(this.projectDirectory(projectId), 'revisions', revision)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** True when a project directory and record both exist. */
|
|
141
|
+
exists(projectId) {
|
|
142
|
+
try {
|
|
143
|
+
return isFile(join(this.projectDirectory(projectId), 'project.json'))
|
|
144
|
+
} catch {
|
|
145
|
+
return false
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Read a project record, or throw a stable error explaining which of the two
|
|
151
|
+
* states (absent vs. corrupt) the caller hit.
|
|
152
|
+
* @param {string} projectId
|
|
153
|
+
* @returns {object}
|
|
154
|
+
*/
|
|
155
|
+
readRecord(projectId) {
|
|
156
|
+
const directory = this.projectDirectory(projectId)
|
|
157
|
+
if (!existsSync(directory)) {
|
|
158
|
+
throw new BlenderError(
|
|
159
|
+
BlenderErrorCode.PROJECT_NOT_FOUND,
|
|
160
|
+
`No DeepBlend project named "${projectId}" exists under ${this.projectsRoot}.`,
|
|
161
|
+
{ detail: { projectId, projectsRoot: this.projectsRoot } },
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
const record = readJson(join(directory, 'project.json'))
|
|
165
|
+
if (record === null) {
|
|
166
|
+
throw new BlenderError(
|
|
167
|
+
BlenderErrorCode.PROJECT_CORRUPT,
|
|
168
|
+
`Project directory ${directory} exists but has no project.json; it was never completed.`,
|
|
169
|
+
{ detail: { projectId, directory } },
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
if (record.schemaVersion !== PROJECT_RECORD_VERSION) {
|
|
173
|
+
throw new BlenderError(
|
|
174
|
+
BlenderErrorCode.PROJECT_CORRUPT,
|
|
175
|
+
`Project "${projectId}" records schemaVersion "${record.schemaVersion}", expected "${PROJECT_RECORD_VERSION}".`,
|
|
176
|
+
)
|
|
177
|
+
}
|
|
178
|
+
return record
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Persist a project record atomically. */
|
|
182
|
+
writeRecord(projectId, record) {
|
|
183
|
+
const directory = ensureDirectory(this.projectsRoot, this.projectDirectory(projectId), `project "${projectId}"`)
|
|
184
|
+
writeJsonAtomic(join(directory, 'project.json'), record)
|
|
185
|
+
this.#refreshIndex(projectId, record)
|
|
186
|
+
return record
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Allocate a project id from a title, avoiding collisions.
|
|
191
|
+
*
|
|
192
|
+
* Suffixing rather than failing is deliberate: two projects called "watch
|
|
193
|
+
* commercial" are a normal thing for an operator to do, and making the second
|
|
194
|
+
* one fail would push the caller into inventing ids by hand.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} title
|
|
197
|
+
* @returns {string}
|
|
198
|
+
*/
|
|
199
|
+
allocateProjectId(title) {
|
|
200
|
+
this.ensureRoot()
|
|
201
|
+
const base = slugifyProjectId(title)
|
|
202
|
+
for (let suffix = 0; suffix < 1000; suffix += 1) {
|
|
203
|
+
const candidate = suffix === 0 ? base : `${base}-${suffix + 1}`
|
|
204
|
+
if (!this.exists(candidate)) return candidate
|
|
205
|
+
}
|
|
206
|
+
throw new BlenderError(
|
|
207
|
+
BlenderErrorCode.PROJECT_EXISTS,
|
|
208
|
+
`Could not allocate an unused project id derived from "${title}" after 1000 attempts.`,
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Every project id in the store, sorted. */
|
|
213
|
+
listProjectIds() {
|
|
214
|
+
const root = this.ensureRoot()
|
|
215
|
+
return listDirectories(root).filter(name => name !== 'staging' && this.exists(name))
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Create a project's directory skeleton. Does NOT allocate a revision. */
|
|
219
|
+
createSkeleton(projectId) {
|
|
220
|
+
const directory = ensureDirectory(this.projectsRoot, this.projectDirectory(projectId), `project "${projectId}"`)
|
|
221
|
+
for (const child of ['assets', 'revisions', 'jobs', 'operations', 'staging']) {
|
|
222
|
+
ensureDirectory(this.projectsRoot, join(directory, child), `${projectId}/${child}`)
|
|
223
|
+
}
|
|
224
|
+
return directory
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// -------------------------------------------------------------------------
|
|
228
|
+
// Revisions
|
|
229
|
+
// -------------------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
/** The revision id a project currently points at. */
|
|
232
|
+
currentRevision(projectId) {
|
|
233
|
+
return this.readRecord(projectId).currentRevision
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** All published revision ids for a project, in ascending order. */
|
|
237
|
+
listRevisions(projectId) {
|
|
238
|
+
const directory = join(this.projectDirectory(projectId), 'revisions')
|
|
239
|
+
return listDirectories(directory)
|
|
240
|
+
.filter(name => parseRevisionId(name) !== null)
|
|
241
|
+
.sort((left, right) => /** @type {number} */ (parseRevisionId(left)) - /** @type {number} */ (parseRevisionId(right)))
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The next revision id after the highest published one. */
|
|
245
|
+
nextRevisionId(projectId) {
|
|
246
|
+
const revisions = this.listRevisions(projectId)
|
|
247
|
+
if (revisions.length === 0) return formatRevisionId(1)
|
|
248
|
+
const highest = /** @type {number} */ (parseRevisionId(revisions[revisions.length - 1]))
|
|
249
|
+
return formatRevisionId(highest + 1)
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Read the SceneSpec stored in a revision.
|
|
254
|
+
* @param {string} projectId
|
|
255
|
+
* @param {string} revision
|
|
256
|
+
* @returns {object}
|
|
257
|
+
*/
|
|
258
|
+
readRevisionSpec(projectId, revision) {
|
|
259
|
+
const directory = this.revisionDirectory(projectId, revision)
|
|
260
|
+
if (!existsSync(directory)) {
|
|
261
|
+
throw new BlenderError(
|
|
262
|
+
BlenderErrorCode.REVISION_NOT_FOUND,
|
|
263
|
+
`Project "${projectId}" has no revision ${revision}.`,
|
|
264
|
+
{ detail: { projectId, revision, available: this.listRevisions(projectId) } },
|
|
265
|
+
)
|
|
266
|
+
}
|
|
267
|
+
const spec = readJson(join(directory, 'scene-spec.json'))
|
|
268
|
+
if (spec === null) {
|
|
269
|
+
throw new BlenderError(
|
|
270
|
+
BlenderErrorCode.REVISION_CORRUPT,
|
|
271
|
+
`Revision ${revision} of "${projectId}" has no scene-spec.json.`,
|
|
272
|
+
{ detail: { projectId, revision } },
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
return spec
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Read a revision manifest.
|
|
280
|
+
* @param {string} projectId
|
|
281
|
+
* @param {string} revision
|
|
282
|
+
* @returns {object|null}
|
|
283
|
+
*/
|
|
284
|
+
readRevisionManifest(projectId, revision) {
|
|
285
|
+
return readJson(join(this.revisionDirectory(projectId, revision), 'revision-manifest.json'))
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** Absolute path of a revision's `.blend` checkpoint, or `null` when absent. */
|
|
289
|
+
checkpointPath(projectId, revision) {
|
|
290
|
+
const path = join(this.revisionDirectory(projectId, revision), 'scene.blend')
|
|
291
|
+
return isFile(path) ? path : null
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Append one preview artifact to a revision's manifest, returning the
|
|
296
|
+
* revision's complete preview list.
|
|
297
|
+
*
|
|
298
|
+
* Thin alias for {@link recordRevisionArtifact} under the `previews` index. Kept
|
|
299
|
+
* as its own name because every M1 caller and test reads it as "the preview
|
|
300
|
+
* index", and renaming a working call site to make a generalization visible is
|
|
301
|
+
* churn that hides the change it was meant to advertise.
|
|
302
|
+
*
|
|
303
|
+
* @param {string} projectId
|
|
304
|
+
* @param {string} revision
|
|
305
|
+
* @param {object} artifact
|
|
306
|
+
* @returns {object[]} the revision's previews, in render order.
|
|
307
|
+
*/
|
|
308
|
+
recordRevisionPreview(projectId, revision, artifact) {
|
|
309
|
+
return this.recordRevisionArtifact(projectId, revision, 'previews', artifact)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Append one artifact to a revision's **artifact index** (decision D28).
|
|
314
|
+
*
|
|
315
|
+
* A revision is immutable in everything it DECIDED: its SceneSpec, its
|
|
316
|
+
* checkpoint, its validation report, its digests. But it also *emits* things
|
|
317
|
+
* afterwards — previews, contact sheets, a visual review — and those are produced
|
|
318
|
+
* on demand, because rendering and reviewing observe a scene rather than change
|
|
319
|
+
* it (which is why neither creates a revision).
|
|
320
|
+
*
|
|
321
|
+
* This is the append-only amendment that keeps the index honest, and it is
|
|
322
|
+
* deliberately generic over the index key: M1 learned that a manifest claiming
|
|
323
|
+
* one preview while the directory held three is worse than no manifest at all,
|
|
324
|
+
* because the manifest is the copy that gets persisted, delivered and READ. A
|
|
325
|
+
* second kind of emitted artifact would have re-created that bug immediately, in
|
|
326
|
+
* a place where the M1 regression test could not see it.
|
|
327
|
+
*
|
|
328
|
+
* It touches only the named index key; every other field is carried over
|
|
329
|
+
* byte-for-byte, and the single {@link writeJsonAtomic} means a reader sees the
|
|
330
|
+
* old list or the new one, never a half-written manifest.
|
|
331
|
+
*
|
|
332
|
+
* @param {string} projectId
|
|
333
|
+
* @param {string} revision
|
|
334
|
+
* @param {'previews'|'contactSheets'|'reviews'} indexKey
|
|
335
|
+
* @param {object} artifact
|
|
336
|
+
* @returns {object[]} the revision's entries under that key, in emission order.
|
|
337
|
+
*/
|
|
338
|
+
recordRevisionArtifact(projectId, revision, indexKey, artifact) {
|
|
339
|
+
const path = join(this.revisionDirectory(projectId, revision), 'revision-manifest.json')
|
|
340
|
+
const manifest = readJson(path)
|
|
341
|
+
if (manifest === null) {
|
|
342
|
+
// A revision directory with no manifest did not finish publishing, so there
|
|
343
|
+
// is nothing to amend and nothing safe to write into it.
|
|
344
|
+
return [artifact]
|
|
345
|
+
}
|
|
346
|
+
const existing = Array.isArray(manifest[indexKey]) ? manifest[indexKey] : []
|
|
347
|
+
// Re-emitting the same path replaces its entry rather than duplicating it:
|
|
348
|
+
// re-rendering a view after a fix must not make the sheet look like two.
|
|
349
|
+
const next = [...existing.filter(entry => entry.path !== artifact.path), artifact]
|
|
350
|
+
writeJsonAtomic(path, { ...manifest, [indexKey]: next })
|
|
351
|
+
return next
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* The newest revision at or before `revision` that has a `.blend` checkpoint.
|
|
356
|
+
*
|
|
357
|
+
* A preview must render the scene the caller asked for. Compiling a revision
|
|
358
|
+
* from spec is always available (the spec is the source of truth), but a
|
|
359
|
+
* checkpoint is cheaper and — importantly — is the artifact the SPEC requires
|
|
360
|
+
* the renderer to consume when one exists (SPEC §9.1 "已有 Checkpoint").
|
|
361
|
+
*
|
|
362
|
+
* @param {string} projectId
|
|
363
|
+
* @param {string} [revision]
|
|
364
|
+
* @returns {{ revision: string, path: string }|null}
|
|
365
|
+
*/
|
|
366
|
+
findCheckpointAtOrBefore(projectId, revision) {
|
|
367
|
+
const ceiling = revision === undefined ? null : parseRevisionId(revision)
|
|
368
|
+
const candidates = this.listRevisions(projectId)
|
|
369
|
+
.filter(candidate => ceiling === null || /** @type {number} */ (parseRevisionId(candidate)) <= ceiling)
|
|
370
|
+
.reverse()
|
|
371
|
+
for (const candidate of candidates) {
|
|
372
|
+
const path = this.checkpointPath(projectId, candidate)
|
|
373
|
+
if (path !== null) return { revision: candidate, path }
|
|
374
|
+
}
|
|
375
|
+
return null
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// -------------------------------------------------------------------------
|
|
379
|
+
// Jobs
|
|
380
|
+
// -------------------------------------------------------------------------
|
|
381
|
+
|
|
382
|
+
/** Absolute path of a job record. */
|
|
383
|
+
jobPath(projectId, jobId) {
|
|
384
|
+
requireSafeSegment(jobId, 'job id')
|
|
385
|
+
return join(this.projectDirectory(projectId), 'jobs', `${jobId}.json`)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Persist a job record. */
|
|
389
|
+
writeJob(projectId, record) {
|
|
390
|
+
const path = this.jobPath(projectId, record.jobId)
|
|
391
|
+
writeJsonAtomic(path, record)
|
|
392
|
+
return record
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Read a job record, or throw when it is unknown. */
|
|
396
|
+
readJob(projectId, jobId) {
|
|
397
|
+
const record = readJson(this.jobPath(projectId, jobId))
|
|
398
|
+
if (record === null) {
|
|
399
|
+
throw new BlenderError(
|
|
400
|
+
BlenderErrorCode.PROJECT_NOT_FOUND,
|
|
401
|
+
`Project "${projectId}" has no job "${jobId}".`,
|
|
402
|
+
{ detail: { projectId, jobId } },
|
|
403
|
+
)
|
|
404
|
+
}
|
|
405
|
+
return record
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Read a job record without throwing, for a lookup that has a fallback.
|
|
410
|
+
* @param {string} projectId
|
|
411
|
+
* @param {string} jobId
|
|
412
|
+
* @returns {object|null}
|
|
413
|
+
*/
|
|
414
|
+
readJobSafe(projectId, jobId) {
|
|
415
|
+
return readJson(this.jobPath(projectId, jobId))
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Allocate a job id. Monotonic within the project and readable in a log,
|
|
420
|
+
* because a job id ends up in a tool result the model reasons about.
|
|
421
|
+
* @param {string} projectId
|
|
422
|
+
* @param {string} action
|
|
423
|
+
* @returns {string}
|
|
424
|
+
*/
|
|
425
|
+
allocateJobId(projectId, action) {
|
|
426
|
+
const directory = join(this.projectDirectory(projectId), 'jobs')
|
|
427
|
+
const existing = existsSync(directory)
|
|
428
|
+
? readdirSync(directory).filter(name => name.endsWith('.json')).length
|
|
429
|
+
: 0
|
|
430
|
+
const stamp = new Date().toISOString().replace(/[-:.TZ]/g, '').slice(0, 14)
|
|
431
|
+
return `${action}-${stamp}-${String(existing + 1).padStart(3, '0')}`
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// -------------------------------------------------------------------------
|
|
435
|
+
// Idempotency ledger (SPEC §8.5)
|
|
436
|
+
// -------------------------------------------------------------------------
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* The ledger path for an idempotency key.
|
|
440
|
+
*
|
|
441
|
+
* The key is hashed rather than used as a file name: a key is caller-supplied
|
|
442
|
+
* free text, and hashing removes every question about separators, length and
|
|
443
|
+
* case-folding on a case-insensitive filesystem. The raw key is stored inside
|
|
444
|
+
* the record so the ledger stays readable.
|
|
445
|
+
*
|
|
446
|
+
* @param {string} projectId
|
|
447
|
+
* @param {string} idempotencyKey
|
|
448
|
+
* @returns {string}
|
|
449
|
+
*/
|
|
450
|
+
idempotencyPath(projectId, idempotencyKey) {
|
|
451
|
+
return join(this.projectDirectory(projectId), 'operations', `${sha256(idempotencyKey).slice(0, 32)}.json`)
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Look up a previously recorded outcome for an idempotency key.
|
|
456
|
+
* @param {string} projectId
|
|
457
|
+
* @param {string} idempotencyKey
|
|
458
|
+
* @returns {object|null}
|
|
459
|
+
*/
|
|
460
|
+
readIdempotencyRecord(projectId, idempotencyKey) {
|
|
461
|
+
const path = this.idempotencyPath(projectId, idempotencyKey)
|
|
462
|
+
const record = readJson(path)
|
|
463
|
+
if (record === null) return null
|
|
464
|
+
if (record.idempotencyKey !== idempotencyKey) {
|
|
465
|
+
// A hash collision, or a file someone copied. Either way the safe answer is
|
|
466
|
+
// "no record", which degrades to a normal apply rather than to a wrong reuse.
|
|
467
|
+
return null
|
|
468
|
+
}
|
|
469
|
+
return record
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** Record the outcome of an idempotency key so a retry can reuse it. */
|
|
473
|
+
writeIdempotencyRecord(projectId, idempotencyKey, outcome) {
|
|
474
|
+
const path = this.idempotencyPath(projectId, idempotencyKey)
|
|
475
|
+
const record = {
|
|
476
|
+
...idempotencyRecordFor(projectId, idempotencyKey),
|
|
477
|
+
recordedAt: new Date().toISOString(),
|
|
478
|
+
...outcome,
|
|
479
|
+
}
|
|
480
|
+
writeJsonAtomic(path, record)
|
|
481
|
+
return record
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// -------------------------------------------------------------------------
|
|
485
|
+
// Assets
|
|
486
|
+
// -------------------------------------------------------------------------
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Absolute path of a declared asset, contained inside the project.
|
|
490
|
+
* @param {string} projectId
|
|
491
|
+
* @param {string} relativePath
|
|
492
|
+
* @returns {string}
|
|
493
|
+
*/
|
|
494
|
+
assetPath(projectId, relativePath) {
|
|
495
|
+
const directory = this.projectDirectory(projectId)
|
|
496
|
+
return resolveInside(directory, join(directory, relativePath), `asset "${relativePath}"`)
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// -------------------------------------------------------------------------
|
|
500
|
+
// Index
|
|
501
|
+
// -------------------------------------------------------------------------
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* Maintain a flat index of projects for fast listing.
|
|
505
|
+
*
|
|
506
|
+
* The index is a convenience, never an authority: `listProjectIds()` and
|
|
507
|
+
* `readRecord()` read the directory tree, and a missing or stale index only
|
|
508
|
+
* costs a rebuild. Keeping that direction of dependency matters — an index that
|
|
509
|
+
* could disagree with the records would make "which revision is current?"
|
|
510
|
+
* ambiguous, which is precisely the question the store exists to answer.
|
|
511
|
+
*/
|
|
512
|
+
#refreshIndex(projectId, record) {
|
|
513
|
+
const path = join(this.projectsRoot, 'projects.json')
|
|
514
|
+
const index = readJson(path) ?? { schemaVersion: 'deepblend.project-index/v1', projects: {} }
|
|
515
|
+
index.projects = index.projects ?? {}
|
|
516
|
+
index.projects[projectId] = {
|
|
517
|
+
title: record.title,
|
|
518
|
+
currentRevision: record.currentRevision,
|
|
519
|
+
updatedAt: record.updatedAt,
|
|
520
|
+
}
|
|
521
|
+
try {
|
|
522
|
+
writeFileAtomic(path, canonicalPretty(index))
|
|
523
|
+
} catch {
|
|
524
|
+
// The index is derived data. Failing a successful write because a cache
|
|
525
|
+
// could not be updated would make the cache more important than the truth.
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|