@lumenkey/cli 0.3.0 → 0.4.0-rc.1
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/README.md +51 -2
- package/dist/cli/copied.js +86 -85
- package/dist/cli/index.js +56 -44
- package/dist/cli/render.js +5 -1
- package/dist/core/asset-stream.d.ts +16 -0
- package/dist/core/asset-stream.js +64 -0
- package/dist/core/atomic-config-file.js +8 -7
- package/dist/core/chinese-messages.d.ts +4 -0
- package/dist/core/chinese-messages.js +907 -0
- package/dist/core/chinese-script.d.ts +7 -0
- package/dist/core/chinese-script.js +28 -0
- package/dist/core/client.js +2 -1
- package/dist/core/config-dir.js +2 -1
- package/dist/core/config.js +7 -6
- package/dist/core/download-root.d.ts +5 -0
- package/dist/core/download-root.js +64 -0
- package/dist/core/download.js +15 -12
- package/dist/core/generation-input.js +3 -2
- package/dist/core/generation.d.ts +8 -1
- package/dist/core/generation.js +13 -10
- package/dist/core/job-display.js +7 -6
- package/dist/core/locale.d.ts +2 -0
- package/dist/core/locale.js +172 -142
- package/dist/core/outcome.d.ts +6 -0
- package/dist/core/outcome.js +17 -4
- package/dist/core/output-path.d.ts +3 -1
- package/dist/core/output-path.js +5 -2
- package/dist/core/params.js +26 -25
- package/dist/core/profiles.js +5 -4
- package/dist/core/set-values.d.ts +0 -1
- package/dist/core/set-values.js +7 -7
- package/dist/core/submission-journal.js +43 -17
- package/dist/core/version.js +2 -3
- package/dist/mcp/server.js +30 -29
- package/dist/studio/asset-integrity-hash.d.ts +28 -0
- package/dist/studio/asset-integrity-hash.js +93 -0
- package/dist/studio/asset-integrity-queue.d.ts +24 -0
- package/dist/studio/asset-integrity-queue.js +94 -0
- package/dist/studio/asset-store.d.ts +52 -0
- package/dist/studio/asset-store.js +352 -0
- package/dist/studio/canvas/LICENSES.txt +1590 -0
- package/dist/studio/canvas/dependencies.json +862 -0
- package/dist/studio/canvas/director-source-manifest.json +47 -0
- package/dist/studio/canvas/editor.css +239 -0
- package/dist/studio/canvas/editor.js +4613 -0
- package/dist/studio/canvas/integration-source-manifest.json +384 -0
- package/dist/studio/canvas/source-manifest.json +240 -0
- package/dist/studio/canvas-store.d.ts +1254 -0
- package/dist/studio/canvas-store.js +205 -0
- package/dist/studio/capture-placement.d.ts +11 -0
- package/dist/studio/capture-placement.js +85 -0
- package/dist/studio/cloud-contract.d.ts +128 -0
- package/dist/studio/cloud-contract.js +229 -0
- package/dist/studio/cloud-service.d.ts +178 -0
- package/dist/studio/cloud-service.js +202 -0
- package/dist/studio/director-captures.d.ts +59 -0
- package/dist/studio/director-captures.js +141 -0
- package/dist/studio/director-contract.d.ts +1489 -0
- package/dist/studio/director-contract.js +288 -0
- package/dist/studio/director-png.d.ts +6 -0
- package/dist/studio/director-png.js +41 -0
- package/dist/studio/director-relations.d.ts +40 -0
- package/dist/studio/director-relations.js +103 -0
- package/dist/studio/document-authority.d.ts +7 -0
- package/dist/studio/document-authority.js +50 -0
- package/dist/studio/draft-store.d.ts +95 -0
- package/dist/studio/draft-store.js +100 -0
- package/dist/studio/errors.d.ts +85 -0
- package/dist/studio/errors.js +94 -0
- package/dist/studio/launch.d.ts +5 -0
- package/dist/studio/launch.js +31 -0
- package/dist/studio/media.d.ts +9 -0
- package/dist/studio/media.js +50 -0
- package/dist/studio/output-save-contract.d.ts +1321 -0
- package/dist/studio/output-save-contract.js +26 -0
- package/dist/studio/output-save-journal.d.ts +971 -0
- package/dist/studio/output-save-journal.js +287 -0
- package/dist/studio/output-save-recovery.d.ts +18 -0
- package/dist/studio/output-save-recovery.js +161 -0
- package/dist/studio/output-save-service.d.ts +436 -0
- package/dist/studio/output-save-service.js +251 -0
- package/dist/studio/output-save-worker.d.ts +40 -0
- package/dist/studio/output-save-worker.js +360 -0
- package/dist/studio/paths.d.ts +6 -0
- package/dist/studio/paths.js +68 -0
- package/dist/studio/run-journal.d.ts +993 -0
- package/dist/studio/run-journal.js +125 -0
- package/dist/studio/server.d.ts +17 -0
- package/dist/studio/server.js +440 -0
- package/dist/studio/session.d.ts +6 -0
- package/dist/studio/session.js +26 -0
- package/dist/studio/shell.d.ts +3 -0
- package/dist/studio/shell.js +23 -0
- package/dist/studio/style.d.ts +2 -0
- package/dist/studio/style.js +29 -0
- package/dist/studio/types.d.ts +26 -0
- package/dist/studio/types.js +2 -0
- package/dist/studio/web/app.d.ts +2 -0
- package/dist/studio/web/app.js +662 -0
- package/dist/studio/web/asset-controller.d.ts +37 -0
- package/dist/studio/web/asset-controller.js +191 -0
- package/dist/studio/web/asset-model.d.ts +29 -0
- package/dist/studio/web/asset-model.js +51 -0
- package/dist/studio/web/controller.d.ts +113 -0
- package/dist/studio/web/controller.js +401 -0
- package/dist/studio/web/messages.d.ts +2 -0
- package/dist/studio/web/messages.js +74 -0
- package/dist/studio/web/model.d.ts +75 -0
- package/dist/studio/web/model.js +182 -0
- package/dist/studio/web/save-controller.d.ts +85 -0
- package/dist/studio/web/save-controller.js +495 -0
- package/dist/studio/web/save-model.d.ts +63 -0
- package/dist/studio/web/save-model.js +64 -0
- package/dist/studio/web/save-queue.d.ts +7 -0
- package/dist/studio/web/save-queue.js +62 -0
- package/dist/studio/web/save-view.d.ts +9 -0
- package/dist/studio/web/save-view.js +19 -0
- package/dist/studio/writer-lock.d.ts +5 -0
- package/dist/studio/writer-lock.js +88 -0
- package/package.json +11 -5
- package/dist/cli/copied.d.ts.map +0 -1
- package/dist/cli/copied.js.map +0 -1
- package/dist/cli/index.d.ts.map +0 -1
- package/dist/cli/index.js.map +0 -1
- package/dist/cli/render.d.ts.map +0 -1
- package/dist/cli/render.js.map +0 -1
- package/dist/core/atomic-config-file.d.ts.map +0 -1
- package/dist/core/atomic-config-file.js.map +0 -1
- package/dist/core/chat.d.ts.map +0 -1
- package/dist/core/chat.js.map +0 -1
- package/dist/core/client.d.ts.map +0 -1
- package/dist/core/client.js.map +0 -1
- package/dist/core/config-dir.d.ts.map +0 -1
- package/dist/core/config-dir.js.map +0 -1
- package/dist/core/config-error.d.ts.map +0 -1
- package/dist/core/config-error.js.map +0 -1
- package/dist/core/config.d.ts.map +0 -1
- package/dist/core/config.js.map +0 -1
- package/dist/core/credit-cap.d.ts.map +0 -1
- package/dist/core/credit-cap.js.map +0 -1
- package/dist/core/download.d.ts.map +0 -1
- package/dist/core/download.js.map +0 -1
- package/dist/core/error-result.d.ts.map +0 -1
- package/dist/core/error-result.js.map +0 -1
- package/dist/core/explain.d.ts.map +0 -1
- package/dist/core/explain.js.map +0 -1
- package/dist/core/generation-input.d.ts.map +0 -1
- package/dist/core/generation-input.js.map +0 -1
- package/dist/core/generation-result.d.ts.map +0 -1
- package/dist/core/generation-result.js.map +0 -1
- package/dist/core/generation.d.ts.map +0 -1
- package/dist/core/generation.js.map +0 -1
- package/dist/core/job-display.d.ts.map +0 -1
- package/dist/core/job-display.js.map +0 -1
- package/dist/core/locale.d.ts.map +0 -1
- package/dist/core/locale.js.map +0 -1
- package/dist/core/origin.d.ts.map +0 -1
- package/dist/core/origin.js.map +0 -1
- package/dist/core/outcome.d.ts.map +0 -1
- package/dist/core/outcome.js.map +0 -1
- package/dist/core/output-path.d.ts.map +0 -1
- package/dist/core/output-path.js.map +0 -1
- package/dist/core/params.d.ts.map +0 -1
- package/dist/core/params.js.map +0 -1
- package/dist/core/poller.d.ts.map +0 -1
- package/dist/core/poller.js.map +0 -1
- package/dist/core/profiles.d.ts.map +0 -1
- package/dist/core/profiles.js.map +0 -1
- package/dist/core/set-values.d.ts.map +0 -1
- package/dist/core/set-values.js.map +0 -1
- package/dist/core/submission-journal.d.ts.map +0 -1
- package/dist/core/submission-journal.js.map +0 -1
- package/dist/core/types.d.ts.map +0 -1
- package/dist/core/types.js.map +0 -1
- package/dist/core/version.d.ts.map +0 -1
- package/dist/core/version.js.map +0 -1
- package/dist/core/wait.d.ts.map +0 -1
- package/dist/core/wait.js.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/mcp/render.d.ts.map +0 -1
- package/dist/mcp/render.js.map +0 -1
- package/dist/mcp/server.d.ts.map +0 -1
- package/dist/mcp/server.js.map +0 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type AssetIntegrity } from './asset-integrity-hash.js';
|
|
2
|
+
import type { StudioSaveAuthority } from './output-save-journal.js';
|
|
3
|
+
import type { StudioAsset, StudioRoot } from './types.js';
|
|
4
|
+
export interface AssetIntegrityQueue {
|
|
5
|
+
cached(assetIds: readonly string[]): Promise<Map<string, AssetIntegrity>>;
|
|
6
|
+
observe(asset: StudioAsset): Promise<AssetIntegrity>;
|
|
7
|
+
idle(): Promise<void>;
|
|
8
|
+
close(): Promise<void>;
|
|
9
|
+
}
|
|
10
|
+
/** Root-owned local observer. No network, repair, HTTP hooks or file mutation.
|
|
11
|
+
* Hashing runs outside the authority's short commit queue. A failed completion
|
|
12
|
+
* publication makes later reads fail closed, rather than advertising stale success. */
|
|
13
|
+
export declare function createAssetIntegrityQueue(root: StudioRoot, authority: StudioSaveAuthority, options: {
|
|
14
|
+
maxBytes: number;
|
|
15
|
+
now?: () => number;
|
|
16
|
+
beforeHash?(asset: StudioAsset): void | Promise<void>;
|
|
17
|
+
afterHash?(asset: StudioAsset): void | Promise<void>;
|
|
18
|
+
onInspect?(): void;
|
|
19
|
+
hashOptions?: {
|
|
20
|
+
beforeRead?(): void | Promise<void>;
|
|
21
|
+
afterChunk?(): void | Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
}): AssetIntegrityQueue;
|
|
24
|
+
//# sourceMappingURL=asset-integrity-queue.d.ts.map
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { hashStudioAsset, inspectStudioAsset } from './asset-integrity-hash.js';
|
|
2
|
+
import { StudioError } from './errors.js';
|
|
3
|
+
const TTL = 300000;
|
|
4
|
+
const cold = () => ({ file: 'NOT_CHECKED', checkedAt: null, integrityCheck: 'IDLE' });
|
|
5
|
+
const projection = (value) => ({ file: value.file, checkedAt: value.checkedAt, integrityCheck: value.integrityCheck });
|
|
6
|
+
/** Root-owned local observer. No network, repair, HTTP hooks or file mutation.
|
|
7
|
+
* Hashing runs outside the authority's short commit queue. A failed completion
|
|
8
|
+
* publication makes later reads fail closed, rather than advertising stale success. */
|
|
9
|
+
export function createAssetIntegrityQueue(root, authority, options) {
|
|
10
|
+
const now = options.now ?? Date.now;
|
|
11
|
+
let tail = Promise.resolve(), closing = false, fatal;
|
|
12
|
+
const tasks = new Map();
|
|
13
|
+
const preparing = new Map();
|
|
14
|
+
const cache = new Map();
|
|
15
|
+
function healthy() { if (fatal)
|
|
16
|
+
throw fatal; }
|
|
17
|
+
async function observations() { healthy(); return (await authority.readIntegrity())?.integrityObservations ?? []; }
|
|
18
|
+
async function existing(assetId) { return (await observations()).find(o => o.assetId === assetId); }
|
|
19
|
+
async function run(asset, task, signature) {
|
|
20
|
+
try {
|
|
21
|
+
let result;
|
|
22
|
+
if (closing)
|
|
23
|
+
result = { file: 'UNKNOWN', checkedAt: null, errorCode: 'SAVE_FILE_READ_FAILED' };
|
|
24
|
+
else {
|
|
25
|
+
try {
|
|
26
|
+
await options.beforeHash?.(asset);
|
|
27
|
+
result = await hashStudioAsset(root, asset, { maxBytes: options.maxBytes, expectedSignature: signature, now, signal: task.controller.signal, ...options.hashOptions });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
result = { file: 'UNKNOWN', checkedAt: null, errorCode: 'SAVE_FILE_READ_FAILED' };
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
await options.afterHash?.(asset);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const value = { file: result.file, checkedAt: result.file === 'VERIFIED' ? result.checkedAt : task.checkedAt, integrityCheck: 'IDLE', ...(result.signature ? { signature: result.signature } : {}), ...(result.errorCode ? { errorCode: result.errorCode } : {}) };
|
|
37
|
+
await authority.observeIntegrity(asset.assetId, task.revision, value, result);
|
|
38
|
+
// Negative completed observations must remain visible to follow-up GETs.
|
|
39
|
+
// A changed stat still invalidates this bounded cache immediately.
|
|
40
|
+
cache.set(asset.assetId, { signature: result.signature ?? signature, completedAt: now() });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
fatal = new StudioError('SAVE_AUTHORITY_UNAVAILABLE', 503);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
tasks.delete(asset.assetId);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function prepare(asset) {
|
|
50
|
+
healthy();
|
|
51
|
+
if (closing)
|
|
52
|
+
throw new StudioError('STUDIO_STORAGE_CLOSED', 503);
|
|
53
|
+
const previous = await existing(asset.assetId);
|
|
54
|
+
if (tasks.has(asset.assetId))
|
|
55
|
+
return projection(previous ?? cold());
|
|
56
|
+
options.onInspect?.();
|
|
57
|
+
const stat = await inspectStudioAsset(root, asset);
|
|
58
|
+
if (!stat.signature) {
|
|
59
|
+
cache.delete(asset.assetId);
|
|
60
|
+
const value = { file: stat.file, checkedAt: previous?.checkedAt ?? null, integrityCheck: 'IDLE', ...(stat.errorCode ? { errorCode: stat.errorCode } : {}) };
|
|
61
|
+
const committed = await authority.observeIntegrity(asset.assetId, previous?.revision ?? 0, value);
|
|
62
|
+
return projection(committed);
|
|
63
|
+
}
|
|
64
|
+
const recent = cache.get(asset.assetId), age = recent ? now() - recent.completedAt : -1;
|
|
65
|
+
if (previous && previous.file !== 'NOT_CHECKED' && previous.integrityCheck === 'IDLE' && recent?.signature === stat.signature && age >= 0 && age < TTL)
|
|
66
|
+
return projection(previous);
|
|
67
|
+
const pending = await authority.observeIntegrity(asset.assetId, previous?.revision ?? 0, { file: 'NOT_CHECKED', checkedAt: previous?.checkedAt ?? null, integrityCheck: 'PENDING' });
|
|
68
|
+
const task = { revision: pending.revision, checkedAt: pending.checkedAt, controller: new AbortController() };
|
|
69
|
+
tasks.set(asset.assetId, task);
|
|
70
|
+
// This is the single-hash-per-root guard, independent of download/commit locks.
|
|
71
|
+
tail = tail.then(() => run(asset, task, stat.signature));
|
|
72
|
+
return projection(pending);
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
cached: async (ids) => {
|
|
76
|
+
const all = await observations();
|
|
77
|
+
const byId = new Map(all.map(o => [o.assetId, o]));
|
|
78
|
+
return new Map(ids.map(id => [id, projection(byId.get(id) ?? cold())]));
|
|
79
|
+
},
|
|
80
|
+
observe: asset => {
|
|
81
|
+
const pending = preparing.get(asset.assetId);
|
|
82
|
+
if (pending)
|
|
83
|
+
return pending;
|
|
84
|
+
const next = prepare(asset);
|
|
85
|
+
preparing.set(asset.assetId, next);
|
|
86
|
+
void next.finally(() => preparing.delete(asset.assetId)).catch(() => { });
|
|
87
|
+
return next;
|
|
88
|
+
},
|
|
89
|
+
idle: async () => { await Promise.allSettled(preparing.values()); await tail; healthy(); },
|
|
90
|
+
close: async () => { closing = true; for (const task of tasks.values())
|
|
91
|
+
task.controller.abort(); await Promise.allSettled(preparing.values()); await tail; healthy(); },
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
//# sourceMappingURL=asset-integrity-queue.js.map
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { ReadStream } from 'node:fs';
|
|
2
|
+
import type { StudioAsset, StudioRecoveryIssue } from './types.js';
|
|
3
|
+
import { type StudioRunJournal } from './run-journal.js';
|
|
4
|
+
import { type StudioSaveAuthority } from './output-save-journal.js';
|
|
5
|
+
import { type StudioDraftStore } from './draft-store.js';
|
|
6
|
+
import { type StudioCanvasStore } from './canvas-store.js';
|
|
7
|
+
import { type DirectorCaptureStore } from './director-captures.js';
|
|
8
|
+
import { createAssetIntegrityQueue, type AssetIntegrityQueue } from './asset-integrity-queue.js';
|
|
9
|
+
import type { AssetIntegrity } from './asset-integrity-hash.js';
|
|
10
|
+
export declare const DEFAULT_STUDIO_MAX_BYTES: number;
|
|
11
|
+
export interface StudioSaveInput {
|
|
12
|
+
displayName: string;
|
|
13
|
+
contentType: string;
|
|
14
|
+
expectedByteLength?: number;
|
|
15
|
+
stream: AsyncIterable<Uint8Array>;
|
|
16
|
+
}
|
|
17
|
+
export type StudioAssetWithIntegrity = StudioAsset & {
|
|
18
|
+
integrity: AssetIntegrity;
|
|
19
|
+
};
|
|
20
|
+
export interface StudioAssetStore {
|
|
21
|
+
readonly maxBytes: number;
|
|
22
|
+
directorCaptures: DirectorCaptureStore;
|
|
23
|
+
canvases: StudioCanvasStore;
|
|
24
|
+
drafts: StudioDraftStore;
|
|
25
|
+
runs: StudioRunJournal;
|
|
26
|
+
saveAuthority: StudioSaveAuthority;
|
|
27
|
+
readonly integrity: AssetIntegrityQueue;
|
|
28
|
+
metadata(assetId: string): Promise<StudioAssetWithIntegrity>;
|
|
29
|
+
list(): Promise<{
|
|
30
|
+
revision: number;
|
|
31
|
+
assets: StudioAssetWithIntegrity[];
|
|
32
|
+
recoveryIssues: StudioRecoveryIssue[];
|
|
33
|
+
}>;
|
|
34
|
+
save(input: StudioSaveInput): Promise<StudioAsset>;
|
|
35
|
+
registerExistingManagedFile(asset: StudioAsset): Promise<StudioAsset>;
|
|
36
|
+
read(assetId: string): Promise<{
|
|
37
|
+
asset: StudioAsset;
|
|
38
|
+
stream: ReadStream;
|
|
39
|
+
}>;
|
|
40
|
+
close(): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
export declare function openStudioAssetStore(options: {
|
|
43
|
+
outputDir: string;
|
|
44
|
+
maxBytes?: number;
|
|
45
|
+
integrity?: Omit<Parameters<typeof createAssetIntegrityQueue>[2], 'maxBytes'>;
|
|
46
|
+
faults?: {
|
|
47
|
+
beforeCatalogCommit?(): void | Promise<void>;
|
|
48
|
+
beforeCaptureCanvas?(): void | Promise<void>;
|
|
49
|
+
afterCaptureCanvas?(): void | Promise<void>;
|
|
50
|
+
};
|
|
51
|
+
}): Promise<StudioAssetStore>;
|
|
52
|
+
//# sourceMappingURL=asset-store.d.ts.map
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { link, open, readdir, rename, unlink, lstat } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { assertStudioMediaType, displayNameSchema, mediaExtensions, validateStudioAssetId, validateStudioCatalog } from './media.js';
|
|
5
|
+
import { assertRegularFile, assertStudioRoot, prepareStudioRoot, resolveStudioAssetPath } from './paths.js';
|
|
6
|
+
import { acquireStudioWriter } from './writer-lock.js';
|
|
7
|
+
import { hasFsCode, storageError, StudioError, isSaveAuthorityError } from './errors.js';
|
|
8
|
+
import { createRunJournal } from './run-journal.js';
|
|
9
|
+
import { createSaveAuthority } from './output-save-journal.js';
|
|
10
|
+
import { createDraftStore } from './draft-store.js';
|
|
11
|
+
import { createCanvasAuthority, serializeCanvasAuthority } from './canvas-store.js';
|
|
12
|
+
import { assertDirectorCanvas } from './director-relations.js';
|
|
13
|
+
import { createDirectorCaptureStore } from './director-captures.js';
|
|
14
|
+
import { createAssetIntegrityQueue } from './asset-integrity-queue.js';
|
|
15
|
+
export const DEFAULT_STUDIO_MAX_BYTES = 512 * 1024 * 1024;
|
|
16
|
+
const MAX_CATALOG_BYTES = 32 * 1024 * 1024;
|
|
17
|
+
export async function openStudioAssetStore(options) {
|
|
18
|
+
const maxBytes = options.maxBytes ?? DEFAULT_STUDIO_MAX_BYTES;
|
|
19
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > DEFAULT_STUDIO_MAX_BYTES)
|
|
20
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
21
|
+
const root = await prepareStudioRoot(options.outputDir);
|
|
22
|
+
const writer = await acquireStudioWriter(root);
|
|
23
|
+
const catalogPath = path.join(root.managedDir, 'catalog.json');
|
|
24
|
+
let catalog;
|
|
25
|
+
async function readCatalog() {
|
|
26
|
+
await assertStudioRoot(root);
|
|
27
|
+
let handle;
|
|
28
|
+
try {
|
|
29
|
+
await assertRegularFile(catalogPath);
|
|
30
|
+
handle = await open(catalogPath, 'r');
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
if (hasFsCode(error, 'ENOENT'))
|
|
34
|
+
return undefined;
|
|
35
|
+
throw storageError(error);
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
if ((await handle.stat()).size > MAX_CATALOG_BYTES)
|
|
39
|
+
throw new Error();
|
|
40
|
+
return validateStudioCatalog(JSON.parse(await handle.readFile('utf8')));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
throw new StudioError('STUDIO_CATALOG_INVALID', 500);
|
|
44
|
+
}
|
|
45
|
+
finally {
|
|
46
|
+
await handle.close();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
async function commit(next, initial = false) {
|
|
50
|
+
await assertStudioRoot(root);
|
|
51
|
+
validateStudioCatalog(next);
|
|
52
|
+
const text = JSON.stringify(next);
|
|
53
|
+
if (Buffer.byteLength(text) > MAX_CATALOG_BYTES)
|
|
54
|
+
throw new StudioError('STUDIO_CATALOG_INVALID', 500);
|
|
55
|
+
const temp = path.join(root.managedDir, `catalog.${randomUUID()}.tmp`);
|
|
56
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
57
|
+
try {
|
|
58
|
+
await handle.writeFile(text);
|
|
59
|
+
await handle.sync();
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
await options.faults?.beforeCatalogCommit?.();
|
|
65
|
+
await assertStudioRoot(root);
|
|
66
|
+
if (initial) {
|
|
67
|
+
await link(temp, catalogPath);
|
|
68
|
+
await unlink(temp);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
await assertRegularFile(catalogPath);
|
|
72
|
+
await rename(temp, catalogPath);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const loaded = await readCatalog();
|
|
77
|
+
catalog = loaded ?? { schemaVersion: 1, revision: 0, assets: [] };
|
|
78
|
+
if (!loaded) {
|
|
79
|
+
// Do not turn a disappeared authority into an empty library.
|
|
80
|
+
const files = await readdir(root.assetsDir);
|
|
81
|
+
const managed = await readdir(root.managedDir);
|
|
82
|
+
if (files.length || managed.some(name => name.startsWith('catalog.')))
|
|
83
|
+
throw new StudioError('STUDIO_CATALOG_INVALID', 500);
|
|
84
|
+
await commit(catalog, true);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (error) {
|
|
88
|
+
await writer.release();
|
|
89
|
+
throw storageError(error);
|
|
90
|
+
}
|
|
91
|
+
let queue = Promise.resolve();
|
|
92
|
+
let closing = false;
|
|
93
|
+
let closed;
|
|
94
|
+
function commitSerial(action) {
|
|
95
|
+
const next = queue.then(action).catch(error => { throw storageError(error); });
|
|
96
|
+
queue = next.catch(() => { });
|
|
97
|
+
return next;
|
|
98
|
+
}
|
|
99
|
+
function ensureOpen() { if (closing)
|
|
100
|
+
throw new StudioError('STUDIO_STORAGE_CLOSED', 503); }
|
|
101
|
+
function serial(action) {
|
|
102
|
+
if (closing)
|
|
103
|
+
return Promise.reject(new StudioError('STUDIO_STORAGE_CLOSED', 503));
|
|
104
|
+
return commitSerial(action);
|
|
105
|
+
}
|
|
106
|
+
const saveAuthority = createSaveAuthority(root, commitSerial);
|
|
107
|
+
// Reconcile a surviving PENDING before exposing any cache or receipt. Never
|
|
108
|
+
// initialize a save journal just because an empty local library was opened.
|
|
109
|
+
try {
|
|
110
|
+
await saveAuthority.readIntegrity();
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (!isSaveAuthorityError(error)) {
|
|
114
|
+
await writer.release();
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const integrity = createAssetIntegrityQueue(root, saveAuthority, { maxBytes, ...options.integrity });
|
|
119
|
+
const notChecked = () => ({ file: 'NOT_CHECKED', checkedAt: null, integrityCheck: 'IDLE' });
|
|
120
|
+
async function authority() {
|
|
121
|
+
const disk = await readCatalog();
|
|
122
|
+
if (!disk || JSON.stringify(disk) !== JSON.stringify(catalog))
|
|
123
|
+
throw new StudioError('STUDIO_CATALOG_INVALID', 500);
|
|
124
|
+
}
|
|
125
|
+
async function checkedAsset(asset) {
|
|
126
|
+
let handle;
|
|
127
|
+
try {
|
|
128
|
+
const file = await resolveStudioAssetPath(root, asset);
|
|
129
|
+
handle = await open(file, 'r');
|
|
130
|
+
if ((await handle.stat()).size !== asset.byteLength)
|
|
131
|
+
throw new Error();
|
|
132
|
+
const hash = createHash('sha256');
|
|
133
|
+
const buffer = Buffer.alloc(64 * 1024);
|
|
134
|
+
let position = 0;
|
|
135
|
+
while (position < asset.byteLength) {
|
|
136
|
+
const { bytesRead } = await handle.read(buffer, 0, Math.min(buffer.length, asset.byteLength - position), position);
|
|
137
|
+
if (!bytesRead)
|
|
138
|
+
throw new Error();
|
|
139
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
140
|
+
position += bytesRead;
|
|
141
|
+
}
|
|
142
|
+
if (hash.digest('hex') !== asset.sha256)
|
|
143
|
+
throw new Error();
|
|
144
|
+
return handle;
|
|
145
|
+
}
|
|
146
|
+
catch (error) {
|
|
147
|
+
await handle?.close();
|
|
148
|
+
throw new StudioError('STUDIO_ASSET_CORRUPT', 409);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// Internal primitives are not queued: the public operation takes this root's
|
|
152
|
+
// one serial lease exactly once, including catalog -> canvas capture writes.
|
|
153
|
+
async function saveAsset(input, captureAsset) {
|
|
154
|
+
const name = displayNameSchema.safeParse(input.displayName);
|
|
155
|
+
if (!name.success || !Object.hasOwn(mediaExtensions, input.contentType))
|
|
156
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
157
|
+
const expected = input.expectedByteLength;
|
|
158
|
+
if (expected !== undefined && (!Number.isSafeInteger(expected) || expected <= 0))
|
|
159
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
160
|
+
if (expected !== undefined && expected > maxBytes)
|
|
161
|
+
throw new StudioError('STUDIO_MEDIA_TOO_LARGE', 413);
|
|
162
|
+
await authority();
|
|
163
|
+
const assetId = captureAsset?.assetId ?? randomUUID();
|
|
164
|
+
const temp = path.join(root.assetsDir, `${assetId}.tmp`);
|
|
165
|
+
const handle = await open(temp, 'wx', 0o600);
|
|
166
|
+
let byteLength = 0;
|
|
167
|
+
let prefix = Buffer.alloc(0);
|
|
168
|
+
const hash = createHash('sha256');
|
|
169
|
+
try {
|
|
170
|
+
for await (const chunk of input.stream) {
|
|
171
|
+
if (!(chunk instanceof Uint8Array))
|
|
172
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
173
|
+
byteLength += chunk.byteLength;
|
|
174
|
+
if (byteLength > maxBytes)
|
|
175
|
+
throw new StudioError('STUDIO_MEDIA_TOO_LARGE', 413);
|
|
176
|
+
if (prefix.length < 4096)
|
|
177
|
+
prefix = Buffer.concat([prefix, chunk.subarray(0, 4096 - prefix.length)]);
|
|
178
|
+
hash.update(chunk);
|
|
179
|
+
await handle.writeFile(chunk);
|
|
180
|
+
}
|
|
181
|
+
if (expected !== undefined && expected !== byteLength)
|
|
182
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
183
|
+
const mediaType = assertStudioMediaType(prefix, input.contentType);
|
|
184
|
+
await handle.sync();
|
|
185
|
+
const asset = { assetId, displayName: name.data, mediaType, relativePath: `assets/${assetId}.${mediaExtensions[mediaType]}`, byteLength, sha256: hash.digest('hex'), savedAt: captureAsset?.savedAt ?? new Date().toISOString(), ...(captureAsset?.directorCapture ? { directorCapture: captureAsset.directorCapture } : {}) };
|
|
186
|
+
if (captureAsset && (asset.sha256 !== captureAsset.sha256 || asset.byteLength !== captureAsset.byteLength))
|
|
187
|
+
throw new StudioError('STUDIO_CAPTURE_PNG_INVALID', 422);
|
|
188
|
+
await handle.close();
|
|
189
|
+
await assertStudioRoot(root);
|
|
190
|
+
// link is exclusive: unlike rename it cannot overwrite an existing result.
|
|
191
|
+
await link(temp, path.join(root.managedDir, asset.relativePath));
|
|
192
|
+
await unlink(temp);
|
|
193
|
+
await authority();
|
|
194
|
+
// Cache the same schema-canonical representation that readCatalog returns;
|
|
195
|
+
// adding optional provenance must not turn property order into corruption.
|
|
196
|
+
const next = validateStudioCatalog({ schemaVersion: 1, revision: catalog.revision + 1, assets: [...catalog.assets, asset] });
|
|
197
|
+
await commit(next);
|
|
198
|
+
catalog = next;
|
|
199
|
+
return structuredClone(asset);
|
|
200
|
+
}
|
|
201
|
+
finally {
|
|
202
|
+
await handle.close();
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
const canvasAuthority = createCanvasAuthority(root, async (ids, pendingAssets) => {
|
|
206
|
+
await authority();
|
|
207
|
+
if (ids.some(id => ![...catalog.assets, ...pendingAssets].some(asset => asset.assetId === id)))
|
|
208
|
+
throw new StudioError('STUDIO_ASSET_NOT_FOUND', 404);
|
|
209
|
+
}, async (next, previous, all, pendingAssets) => { await authority(); assertDirectorCanvas(next, previous, all, [...catalog.assets, ...pendingAssets]); });
|
|
210
|
+
return {
|
|
211
|
+
maxBytes,
|
|
212
|
+
canvases: serializeCanvasAuthority(canvasAuthority, serial),
|
|
213
|
+
directorCaptures: createDirectorCaptureStore({ canvas: canvasAuthority, maxBytes,
|
|
214
|
+
assets: async () => { await authority(); return structuredClone(catalog.assets); },
|
|
215
|
+
save: (asset, bytes) => saveAsset({ displayName: asset.displayName, contentType: asset.mediaType, expectedByteLength: asset.byteLength, stream: (async function* () { yield bytes; })() }, asset),
|
|
216
|
+
verify: async (asset) => { try {
|
|
217
|
+
const handle = await checkedAsset(asset);
|
|
218
|
+
await handle.close();
|
|
219
|
+
return 'READY';
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
try {
|
|
223
|
+
await assertRegularFile(path.join(root.managedDir, asset.relativePath));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (hasFsCode(error, 'ENOENT'))
|
|
227
|
+
return 'MISSING';
|
|
228
|
+
}
|
|
229
|
+
return 'CORRUPT';
|
|
230
|
+
} },
|
|
231
|
+
faults: options.faults,
|
|
232
|
+
}, serial),
|
|
233
|
+
drafts: createDraftStore(root, serial),
|
|
234
|
+
runs: createRunJournal(root, serial),
|
|
235
|
+
saveAuthority: {
|
|
236
|
+
read: () => { ensureOpen(); return saveAuthority.read(); },
|
|
237
|
+
readIntegrity: () => { ensureOpen(); return saveAuthority.readIntegrity(); },
|
|
238
|
+
bind: digest => { ensureOpen(); return saveAuthority.bind(digest); },
|
|
239
|
+
accept: input => { ensureOpen(); return saveAuthority.accept(input); },
|
|
240
|
+
update: (...args) => { ensureOpen(); return saveAuthority.update(...args); },
|
|
241
|
+
advanceWorker: (...args) => { ensureOpen(); return saveAuthority.advanceWorker(...args); },
|
|
242
|
+
observeIntegrity: (...args) => { ensureOpen(); return saveAuthority.observeIntegrity(...args); },
|
|
243
|
+
},
|
|
244
|
+
integrity,
|
|
245
|
+
metadata: async (assetId) => {
|
|
246
|
+
const asset = await serial(async () => { validateStudioAssetId(assetId); await authority(); const found = catalog.assets.find(a => a.assetId === assetId); if (!found)
|
|
247
|
+
throw new StudioError('STUDIO_ASSET_NOT_FOUND', 404); return structuredClone(found); });
|
|
248
|
+
let fact;
|
|
249
|
+
try {
|
|
250
|
+
fact = await integrity.observe(asset);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
if (!isSaveAuthorityError(error))
|
|
254
|
+
throw error;
|
|
255
|
+
fact = notChecked();
|
|
256
|
+
}
|
|
257
|
+
ensureOpen();
|
|
258
|
+
return { ...asset, integrity: fact };
|
|
259
|
+
},
|
|
260
|
+
list: async () => {
|
|
261
|
+
const snapshot = await serial(async () => {
|
|
262
|
+
await authority();
|
|
263
|
+
const recoveryIssues = [];
|
|
264
|
+
const registered = new Set(catalog.assets.map(a => path.basename(a.relativePath)));
|
|
265
|
+
for (const name of await readdir(root.assetsDir)) {
|
|
266
|
+
if (!registered.has(name))
|
|
267
|
+
recoveryIssues.push({ code: name.endsWith('.tmp') ? 'INCOMPLETE_FILE' : 'UNREGISTERED_ASSET', file: `assets/${name}` });
|
|
268
|
+
}
|
|
269
|
+
for (const name of await readdir(root.managedDir)) {
|
|
270
|
+
if (/^catalog\..+\.tmp$/.test(name))
|
|
271
|
+
recoveryIssues.push({ code: 'INCOMPLETE_FILE', file: name });
|
|
272
|
+
}
|
|
273
|
+
return { revision: catalog.revision, assets: structuredClone(catalog.assets), recoveryIssues };
|
|
274
|
+
});
|
|
275
|
+
let facts;
|
|
276
|
+
try {
|
|
277
|
+
facts = await integrity.cached(snapshot.assets.map(a => a.assetId));
|
|
278
|
+
}
|
|
279
|
+
catch (error) {
|
|
280
|
+
if (!isSaveAuthorityError(error))
|
|
281
|
+
throw error;
|
|
282
|
+
facts = new Map(snapshot.assets.map(a => [a.assetId, notChecked()]));
|
|
283
|
+
}
|
|
284
|
+
ensureOpen();
|
|
285
|
+
const assets = snapshot.assets.map(asset => ({ ...asset, integrity: facts.get(asset.assetId) }));
|
|
286
|
+
for (const asset of assets) {
|
|
287
|
+
if (['MISSING', 'MODIFIED', 'UNSAFE', 'UNKNOWN'].includes(asset.integrity.file))
|
|
288
|
+
snapshot.recoveryIssues.push({ code: asset.integrity.file === 'MISSING' ? 'ASSET_MISSING' : 'ASSET_CORRUPT', file: asset.relativePath });
|
|
289
|
+
}
|
|
290
|
+
return { ...snapshot, assets };
|
|
291
|
+
},
|
|
292
|
+
save: input => serial(() => saveAsset(input)),
|
|
293
|
+
// Internal only: the worker must prove ownership and persist its transport
|
|
294
|
+
// metadata before calling. Never accepts an arbitrary browser file path.
|
|
295
|
+
registerExistingManagedFile: input => serial(async () => {
|
|
296
|
+
const asset = validateStudioCatalog({ schemaVersion: 1, revision: 0, assets: [input] }).assets[0];
|
|
297
|
+
if (asset.directorCapture || asset.byteLength > Math.min(maxBytes, 128 * 1024 * 1024))
|
|
298
|
+
throw new StudioError('STUDIO_INPUT_INVALID');
|
|
299
|
+
await authority();
|
|
300
|
+
const file = await resolveStudioAssetPath(root, asset);
|
|
301
|
+
const handle = await open(file, 'r');
|
|
302
|
+
try {
|
|
303
|
+
const before = await handle.stat();
|
|
304
|
+
if (!before.isFile() || before.nlink !== 1 || before.size !== asset.byteLength)
|
|
305
|
+
throw new StudioError('STUDIO_ASSET_CORRUPT', 409);
|
|
306
|
+
const prefix = Buffer.alloc(Math.min(4096, asset.byteLength));
|
|
307
|
+
await handle.read(prefix, 0, prefix.length, 0);
|
|
308
|
+
assertStudioMediaType(prefix, asset.mediaType);
|
|
309
|
+
await assertStudioRoot(root);
|
|
310
|
+
await assertRegularFile(file);
|
|
311
|
+
const after = await handle.stat();
|
|
312
|
+
const named = await lstat(file);
|
|
313
|
+
if (after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ino !== named.ino || after.dev !== named.dev || after.nlink !== 1)
|
|
314
|
+
throw new StudioError('STUDIO_ASSET_CORRUPT', 409);
|
|
315
|
+
const existing = catalog.assets.find(a => a.assetId === asset.assetId);
|
|
316
|
+
if (existing) {
|
|
317
|
+
if (JSON.stringify(existing) !== JSON.stringify(asset))
|
|
318
|
+
throw new StudioError('STUDIO_CATALOG_INVALID', 409);
|
|
319
|
+
return structuredClone(existing);
|
|
320
|
+
}
|
|
321
|
+
await authority();
|
|
322
|
+
const next = validateStudioCatalog({ ...catalog, revision: catalog.revision + 1, assets: [...catalog.assets, asset] });
|
|
323
|
+
await commit(next);
|
|
324
|
+
catalog = next;
|
|
325
|
+
return structuredClone(asset);
|
|
326
|
+
}
|
|
327
|
+
finally {
|
|
328
|
+
await handle.close();
|
|
329
|
+
}
|
|
330
|
+
}),
|
|
331
|
+
read: assetId => serial(async () => {
|
|
332
|
+
validateStudioAssetId(assetId);
|
|
333
|
+
await authority();
|
|
334
|
+
const asset = catalog.assets.find(a => a.assetId === assetId);
|
|
335
|
+
if (!asset)
|
|
336
|
+
throw new StudioError('STUDIO_ASSET_NOT_FOUND', 404);
|
|
337
|
+
const handle = await checkedAsset(asset);
|
|
338
|
+
return { asset: structuredClone(asset), stream: handle.createReadStream({ start: 0, autoClose: true }) };
|
|
339
|
+
}),
|
|
340
|
+
close() {
|
|
341
|
+
closing = true;
|
|
342
|
+
return closed ??= (async () => { try {
|
|
343
|
+
await integrity.close();
|
|
344
|
+
}
|
|
345
|
+
finally {
|
|
346
|
+
await queue;
|
|
347
|
+
await writer.release();
|
|
348
|
+
} })();
|
|
349
|
+
},
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
//# sourceMappingURL=asset-store.js.map
|