@deepblend/dsh-blender-host 0.3.3 → 0.3.5

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,64 @@
1
+ /** Durable creation identity lives in the project record, under a store-scoped writer lease. */
2
+ import { join } from 'node:path'
3
+ import { BlenderError, BlenderErrorCode, CREATION_REQUEST_VERSION, sha256, sha256Canonical } from '@deepblend/dsh-blender-contracts'
4
+ import { resolveInside } from './paths.js'
5
+ import { withProjectWriter } from './project-writer.js'
6
+
7
+ function jsonValue(value, ancestors = new Set()) {
8
+ if (value === null || ['string', 'boolean'].includes(typeof value)) return
9
+ if (typeof value === 'number' && Number.isFinite(value)) return
10
+ if (typeof value !== 'object' || ancestors.has(value)) throw new Error('Not a JSON value')
11
+ const prototype = Object.getPrototypeOf(value)
12
+ if (!Array.isArray(value) && prototype !== null && Object.getPrototypeOf(prototype) !== null) throw new Error('Not a JSON object')
13
+ ancestors.add(value)
14
+ for (const entry of Object.values(value)) jsonValue(entry, ancestors)
15
+ ancestors.delete(value)
16
+ }
17
+
18
+ export function creationIdentity(request) {
19
+ if (request.creationKey === undefined) return null
20
+ const key = request.creationKey
21
+ if (typeof key !== 'string' || !key.trim() || key.length > 128 || /[\x00-\x1f\x7f]/.test(key)) {
22
+ throw new BlenderError(BlenderErrorCode.CREATION_REQUEST_INVALID, 'creationKey must be non-empty text of at most 128 characters, without control characters.')
23
+ }
24
+ if (['staging', '.revision-writers', '.creation-requests'].includes(request.projectId)) {
25
+ throw new BlenderError(BlenderErrorCode.PROJECT_ID_INVALID, 'Choose a project id other than a reserved store directory.')
26
+ }
27
+ let requestHash
28
+ try {
29
+ const inputs = { title: request.title?.trim(), goal: request.goal ?? null,
30
+ recipe: request.recipe ?? null, sceneSpec: request.sceneSpec ?? null, projectId: request.projectId ?? null,
31
+ saveCheckpoint: request.saveCheckpoint !== false, renderPreview: request.renderPreview === true, actor: request.actor ?? null }
32
+ jsonValue(inputs)
33
+ requestHash = sha256Canonical(inputs)
34
+ } catch (cause) {
35
+ throw new BlenderError(BlenderErrorCode.CREATION_REQUEST_INVALID, 'Creation inputs must be JSON values.', { cause })
36
+ }
37
+ return { schemaVersion: CREATION_REQUEST_VERSION, keyHash: sha256(key.trim()), requestHash }
38
+ }
39
+
40
+ export async function withCreationRequest(store, request, create, recover) {
41
+ const identity = creationIdentity(request)
42
+ if (!identity) return create(null)
43
+ store.ensureRoot()
44
+ const scope = resolveInside(store.projectsRoot, join(store.projectsRoot, '.creation-requests', identity.keyHash), 'creation request lease')
45
+ // Hosts may use different workspace roots around the same store. The store
46
+ // itself determines this lease's location, so those Hosts still coordinate.
47
+ return withProjectWriter(scope, store.projectsRoot, async () => {
48
+ const matches = store.listProjectIds().map(projectId => ({ projectId, record: store.readRecord(projectId) }))
49
+ .filter(({ record }) => record.creationRequest?.keyHash === identity.keyHash)
50
+ if (matches.length > 1) throw new BlenderError(BlenderErrorCode.CREATION_REQUEST_CONFLICT, 'More than one project claims this creation request. Inspect the records before retrying.')
51
+ if (matches.length) {
52
+ const { projectId, record } = matches[0]
53
+ if (record.projectId !== projectId || record.creationRequest.schemaVersion !== identity.schemaVersion
54
+ || record.creationRequest.requestHash !== identity.requestHash) {
55
+ throw new BlenderError(BlenderErrorCode.CREATION_REQUEST_CONFLICT, 'This creationKey belongs to different creation inputs. Reuse the original inputs, or choose a new key for a new project.')
56
+ }
57
+ if (!(record.revisionCount > 0) || record.currentRevision === 'r0000') {
58
+ throw new BlenderError(BlenderErrorCode.CREATION_REQUEST_CONFLICT, 'The previous creation has no committed revision. Inspect that incomplete project before retrying; a second project was not created.', { detail: { projectId } })
59
+ }
60
+ return recover(projectId)
61
+ }
62
+ return create(identity)
63
+ })
64
+ }
package/lib/index.js CHANGED
@@ -34,6 +34,7 @@ import {
34
34
  LOG_SCOPE,
35
35
  BlenderError,
36
36
  BlenderErrorCode,
37
+ CREATION_REQUEST_VERSION,
37
38
  BlenderWarningCode,
38
39
  SCENE_PATCH_VERSION,
39
40
  buildViewPlan,
@@ -84,6 +85,7 @@ import {
84
85
  } from '@deepblend/dsh-blender-contracts'
85
86
 
86
87
  import { RecipeCatalog } from './recipe-catalog.js'
88
+ import { withCreationRequest } from './creation-request.js'
87
89
  import { ProjectStore, GENESIS_REVISION, parseRevisionId } from './project-store.js'
88
90
  import { RenderJobStore, UNFINISHED_STATUSES } from './render-job-store.js'
89
91
  import { RevisionTransaction } from './revision-transaction.js'
@@ -599,6 +601,7 @@ export default class BlenderStudio extends Service {
599
601
  * @param {object} [request.sceneSpec] - a full SceneSpec to seed the project with.
600
602
  * @param {{ id: string, version: string, digest: string, parameters?: object }} [request.recipe]
601
603
  * @param {string} [request.projectId]
604
+ * @param {string} [request.creationKey] - a stable key reused only for the same creation intent.
602
605
  * @param {boolean} [request.saveCheckpoint]
603
606
  * @param {boolean} [request.renderPreview]
604
607
  * @param {AbortSignal} [request.signal]
@@ -612,8 +615,21 @@ export default class BlenderStudio extends Service {
612
615
  )
613
616
  }
614
617
  if (request.recipe && request.sceneSpec) throw new BlenderError('RECIPE_REQUEST_INVALID', 'Choose either a recipe or an initial scene')
618
+ return withCreationRequest(this.store, request, identity => this._createProject(request, identity),
619
+ projectId => this.store.withProjectWrite(projectId, async () => {
620
+ const project = await this.getProject(projectId)
621
+ const manifest = this.store.readRevisionManifest(projectId, project.currentRevision)
622
+ const job = manifest.jobId ? this.store.readJob(projectId, manifest.jobId) : null
623
+ return { ...project, revision: toCanonicalRevisionSummary({ manifest, checkpointPath: manifest.checkpoint }),
624
+ job: toCanonicalJobRecord(job), warnings: job?.warnings ?? [], creationReplayed: true }
625
+ }))
626
+ }
627
+
628
+ get creationRequestProtocol() { return CREATION_REQUEST_VERSION }
629
+
630
+ async _createProject(request, creationRequest) {
615
631
  const source = request.recipe ? this.recipes.instantiate(request.recipe) : null
616
- const outcome = await this.transactions.createProject({ ...request,
632
+ const outcome = await this.transactions.createProject({ ...request, creationRequest,
617
633
  ...(source ? { sceneSpec: source.sceneSpec } : {}), recipeLock: source?.lock ?? null })
618
634
  return {
619
635
  ...toCanonicalProjectSummary({
@@ -3374,7 +3390,8 @@ export default class BlenderStudio extends Service {
3374
3390
  revisionCount: record.revisionCount ?? this.store.listRevisions(projectId).length,
3375
3391
  createdAt: record.createdAt ?? null,
3376
3392
  updatedAt: record.updatedAt ?? null,
3377
- unreadable: specs === null && record.currentRevision !== null,
3393
+ creationPending: record.currentRevision === GENESIS_REVISION && record.revisionCount === 0,
3394
+ unreadable: specs === null && record.currentRevision !== null && !(record.currentRevision === GENESIS_REVISION && record.revisionCount === 0),
3378
3395
  scene: specs,
3379
3396
  jobs: this.renderJobs.list(projectId).map(job => ({
3380
3397
  jobId: job.jobId,
@@ -214,7 +214,9 @@ export class ProjectStore {
214
214
  const base = slugifyProjectId(title)
215
215
  for (let suffix = 0; suffix < 1000; suffix += 1) {
216
216
  const candidate = suffix === 0 ? base : `${base}-${suffix + 1}`
217
- if (!this.exists(candidate)) return candidate
217
+ // The root staging directory is excluded from project listings. A title
218
+ // such as "Staging" must therefore allocate a suffixed, visible project.
219
+ if (candidate !== 'staging' && !this.exists(candidate)) return candidate
218
220
  }
219
221
  throw new BlenderError(
220
222
  BlenderErrorCode.PROJECT_EXISTS,
@@ -249,6 +249,7 @@ export class RevisionTransaction {
249
249
  revisionCount: 0,
250
250
  runtimeProtocolVersion: 'deepblend.blender/v1',
251
251
  projectsRoot: this.store.projectsRoot,
252
+ ...(input.creationRequest ? { creationRequest: input.creationRequest } : {}),
252
253
  })
253
254
 
254
255
  const outcome = await this._commit({
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepblend/dsh-blender-host",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "DeepBlend Host service: the blenderStudio business facade over the BlenderRuntime seam.",
6
6
  "main": "lib/index.js",
@@ -20,7 +20,7 @@
20
20
  "./package.json": "./package.json"
21
21
  },
22
22
  "dependencies": {
23
- "@deepblend/dsh-blender-contracts": "0.3.3",
23
+ "@deepblend/dsh-blender-contracts": "0.3.5",
24
24
  "sharp": "0.35.5"
25
25
  },
26
26
  "license": "MIT",