@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.
@@ -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
+ }