@deepblend/dsh-blender-host 0.3.4 → 0.3.6
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/creation-request.js +64 -0
- package/lib/index.js +19 -2
- package/lib/project-store.js +3 -1
- package/lib/revision-transaction.js +1 -0
- package/package.json +2 -2
|
@@ -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
|
-
|
|
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,
|
package/lib/project-store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
"version": "0.3.6",
|
|
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.
|
|
23
|
+
"@deepblend/dsh-blender-contracts": "0.3.6",
|
|
24
24
|
"sharp": "0.35.5"
|
|
25
25
|
},
|
|
26
26
|
"license": "MIT",
|