openworkgraph 0.2.12
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 +64 -0
- package/dist/api-image-agent.d.ts +32 -0
- package/dist/api-image-agent.js +132 -0
- package/dist/api-image-output.d.ts +8 -0
- package/dist/api-image-output.js +41 -0
- package/dist/api.d.ts +35 -0
- package/dist/api.js +153 -0
- package/dist/auth.d.ts +58 -0
- package/dist/auth.js +273 -0
- package/dist/backend/adapter.d.ts +35 -0
- package/dist/backend/adapter.js +439 -0
- package/dist/backend/capabilities.d.ts +23 -0
- package/dist/backend/capabilities.js +75 -0
- package/dist/backend/diagnostics.d.ts +9 -0
- package/dist/backend/diagnostics.js +80 -0
- package/dist/backend/index.d.ts +6 -0
- package/dist/backend/index.js +7 -0
- package/dist/backend/models.d.ts +15 -0
- package/dist/backend/models.js +43 -0
- package/dist/backend/paths.d.ts +6 -0
- package/dist/backend/paths.js +69 -0
- package/dist/backend/stdio.d.ts +66 -0
- package/dist/backend/stdio.js +172 -0
- package/dist/backend/types.d.ts +90 -0
- package/dist/backend/types.js +11 -0
- package/dist/backup-api.d.ts +10 -0
- package/dist/backup-api.js +40 -0
- package/dist/blob-store.d.ts +40 -0
- package/dist/blob-store.js +218 -0
- package/dist/cli-help.d.ts +1 -0
- package/dist/cli-help.js +63 -0
- package/dist/cli-output.d.ts +4 -0
- package/dist/cli-output.js +130 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +284 -0
- package/dist/client-pairing.d.ts +8 -0
- package/dist/client-pairing.js +30 -0
- package/dist/directories.d.ts +14 -0
- package/dist/directories.js +34 -0
- package/dist/errors.d.ts +12 -0
- package/dist/errors.js +7 -0
- package/dist/events.d.ts +22 -0
- package/dist/events.js +155 -0
- package/dist/execution-chains.d.ts +24 -0
- package/dist/execution-chains.js +231 -0
- package/dist/execution-settings.d.ts +10 -0
- package/dist/execution-settings.js +38 -0
- package/dist/graph-document-history.d.ts +43 -0
- package/dist/graph-document-history.js +275 -0
- package/dist/graph-files.d.ts +14 -0
- package/dist/graph-files.js +59 -0
- package/dist/graph-transfer.d.ts +53 -0
- package/dist/graph-transfer.js +501 -0
- package/dist/graphs.d.ts +68 -0
- package/dist/graphs.js +771 -0
- package/dist/history.d.ts +20 -0
- package/dist/history.js +61 -0
- package/dist/http.d.ts +2 -0
- package/dist/http.js +42 -0
- package/dist/image-credentials.d.ts +11 -0
- package/dist/image-credentials.js +130 -0
- package/dist/image-providers.d.ts +51 -0
- package/dist/image-providers.js +271 -0
- package/dist/input-materialization.d.ts +17 -0
- package/dist/input-materialization.js +95 -0
- package/dist/inputs.d.ts +74 -0
- package/dist/inputs.js +301 -0
- package/dist/local-control.d.ts +13 -0
- package/dist/local-control.js +37 -0
- package/dist/network.d.ts +11 -0
- package/dist/network.js +48 -0
- package/dist/openai-image-driver.d.ts +42 -0
- package/dist/openai-image-driver.js +368 -0
- package/dist/operations/backups.d.ts +40 -0
- package/dist/operations/backups.js +391 -0
- package/dist/operations/capacity.d.ts +7 -0
- package/dist/operations/capacity.js +35 -0
- package/dist/operations/cli-context.d.ts +7 -0
- package/dist/operations/cli-context.js +58 -0
- package/dist/operations/daemon.d.ts +36 -0
- package/dist/operations/daemon.js +159 -0
- package/dist/operations/files.d.ts +12 -0
- package/dist/operations/files.js +84 -0
- package/dist/operations/lifecycle.d.ts +28 -0
- package/dist/operations/lifecycle.js +155 -0
- package/dist/operations/local-admin.d.ts +6 -0
- package/dist/operations/local-admin.js +38 -0
- package/dist/operations/logs.d.ts +16 -0
- package/dist/operations/logs.js +37 -0
- package/dist/operations/plugins.d.ts +10 -0
- package/dist/operations/plugins.js +11 -0
- package/dist/operations/snapshot-worker.d.ts +1 -0
- package/dist/operations/snapshot-worker.js +18 -0
- package/dist/operations/snapshot.d.ts +1 -0
- package/dist/operations/snapshot.js +14 -0
- package/dist/operations/update.d.ts +26 -0
- package/dist/operations/update.js +216 -0
- package/dist/persistence/asset-business-schema.d.ts +2 -0
- package/dist/persistence/asset-business-schema.js +36 -0
- package/dist/persistence/database.d.ts +7 -0
- package/dist/persistence/database.js +91 -0
- package/dist/persistence/plugin-schema.d.ts +9 -0
- package/dist/persistence/plugin-schema.js +38 -0
- package/dist/persistence/repositories.d.ts +20 -0
- package/dist/persistence/repositories.js +106 -0
- package/dist/persistence/resource-schema.d.ts +2 -0
- package/dist/persistence/resource-schema.js +44 -0
- package/dist/persistence/schema.d.ts +6 -0
- package/dist/persistence/schema.js +236 -0
- package/dist/persistence/workflow-schema.d.ts +2 -0
- package/dist/persistence/workflow-schema.js +19 -0
- package/dist/plugin-api.d.ts +10 -0
- package/dist/plugin-api.js +40 -0
- package/dist/plugin-management.d.ts +36 -0
- package/dist/plugin-management.js +112 -0
- package/dist/plugins.d.ts +55 -0
- package/dist/plugins.js +239 -0
- package/dist/project-discovery.d.ts +3 -0
- package/dist/project-discovery.js +57 -0
- package/dist/project-files-api.d.ts +16 -0
- package/dist/project-files-api.js +243 -0
- package/dist/project-files.d.ts +97 -0
- package/dist/project-files.js +557 -0
- package/dist/project-lock.d.ts +4 -0
- package/dist/project-lock.js +35 -0
- package/dist/project-probe-worker.d.ts +1 -0
- package/dist/project-probe-worker.js +17 -0
- package/dist/project-probe.d.ts +22 -0
- package/dist/project-probe.js +67 -0
- package/dist/projects.d.ts +29 -0
- package/dist/projects.js +219 -0
- package/dist/publication.d.ts +31 -0
- package/dist/publication.js +642 -0
- package/dist/resource-api.d.ts +15 -0
- package/dist/resource-api.js +256 -0
- package/dist/resources-orphans.d.ts +41 -0
- package/dist/resources-orphans.js +235 -0
- package/dist/resources.d.ts +246 -0
- package/dist/resources.js +714 -0
- package/dist/run-api.d.ts +10 -0
- package/dist/run-api.js +308 -0
- package/dist/run-context.d.ts +54 -0
- package/dist/run-context.js +137 -0
- package/dist/runs.d.ts +82 -0
- package/dist/runs.js +506 -0
- package/dist/runtime-backend.d.ts +24 -0
- package/dist/runtime-backend.js +214 -0
- package/dist/runtime.d.ts +97 -0
- package/dist/runtime.js +553 -0
- package/dist/scheduler.d.ts +85 -0
- package/dist/scheduler.js +344 -0
- package/dist/service.d.ts +29 -0
- package/dist/service.js +515 -0
- package/dist/skills-api.d.ts +18 -0
- package/dist/skills-api.js +120 -0
- package/dist/storage-api.d.ts +21 -0
- package/dist/storage-api.js +92 -0
- package/dist/transfer-api.d.ts +10 -0
- package/dist/transfer-api.js +60 -0
- package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
- package/dist/vendor/protocol/contracts.typecheck.js +35 -0
- package/dist/vendor/protocol/execution-chain.d.ts +6 -0
- package/dist/vendor/protocol/execution-chain.js +26 -0
- package/dist/vendor/protocol/graph-binary.d.ts +15 -0
- package/dist/vendor/protocol/graph-binary.js +121 -0
- package/dist/vendor/protocol/graph-title.d.ts +2 -0
- package/dist/vendor/protocol/graph-title.js +23 -0
- package/dist/vendor/protocol/index.d.ts +617 -0
- package/dist/vendor/protocol/index.js +20 -0
- package/dist/vendor/protocol/pairing.d.ts +29 -0
- package/dist/vendor/protocol/pairing.js +71 -0
- package/dist/vendor/protocol/preview.d.ts +2 -0
- package/dist/vendor/protocol/preview.js +9 -0
- package/dist/vendor/protocol/resource-mime.d.ts +2 -0
- package/dist/vendor/protocol/resource-mime.js +35 -0
- package/dist/video-thumbnail.d.ts +2 -0
- package/dist/video-thumbnail.js +34 -0
- package/package.json +9 -0
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { open, realpath } from 'node:fs/promises';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
5
|
+
import { BlobStore, hashBytes, supportsText, readBounded } from './blob-store.js';
|
|
6
|
+
import { ServiceError } from './errors.js';
|
|
7
|
+
import { transaction } from './persistence/database.js';
|
|
8
|
+
import sharp from 'sharp';
|
|
9
|
+
import { videoThumbnail } from './video-thumbnail.js';
|
|
10
|
+
const DAY = 24 * 60 * 60 * 1000;
|
|
11
|
+
export const IMAGE_THUMBNAIL_LEVELS = [320, 640, 1280, 2560, 4096];
|
|
12
|
+
const invalid = (message) => { throw new ServiceError('INVALID_REQUEST', message); };
|
|
13
|
+
const missing = () => { throw new ServiceError('NOT_FOUND', 'Resource not found in this scope'); };
|
|
14
|
+
const conflict = (message = 'Resource changed') => { throw new ServiceError('REVISION_CONFLICT', message); };
|
|
15
|
+
function name(value) { if (typeof value !== 'string' || !value.trim() || value.length > 512 || [...value].some(c => c.charCodeAt(0) < 32))
|
|
16
|
+
return invalid('Invalid resource name'); return value.trim(); }
|
|
17
|
+
function integer(value) { if (!Number.isSafeInteger(value) || value < 0)
|
|
18
|
+
invalid('Expected a nonnegative safe integer'); }
|
|
19
|
+
/** All commit methods require the caller's synchronous authenticated transaction. */
|
|
20
|
+
export class Resources {
|
|
21
|
+
db;
|
|
22
|
+
blobs;
|
|
23
|
+
now;
|
|
24
|
+
appends = new WeakSet();
|
|
25
|
+
finishes = new WeakSet();
|
|
26
|
+
videoFrames = new Map();
|
|
27
|
+
constructor(db, blobs, options = {}) { this.db = db; this.blobs = blobs; this.now = options.now ?? Date.now; }
|
|
28
|
+
tx() { if (!this.db.isTransaction)
|
|
29
|
+
throw new Error('Resource commit requires caller transaction'); }
|
|
30
|
+
outside() { if (this.db.isTransaction)
|
|
31
|
+
throw new Error('Filesystem preparation must run outside a transaction'); }
|
|
32
|
+
project(scope) {
|
|
33
|
+
if (scope.serviceId !== undefined && this.db.prepare('SELECT service_id FROM identity WHERE singleton=1').get()?.['service_id'] !== scope.serviceId)
|
|
34
|
+
throw new ServiceError('SERVICE_MISMATCH', 'Wrong service');
|
|
35
|
+
if (!this.db.prepare('SELECT 1 FROM projects WHERE id=?').get(scope.projectId))
|
|
36
|
+
missing();
|
|
37
|
+
}
|
|
38
|
+
graph(scope) { this.project(scope); if (!this.db.prepare('SELECT 1 FROM graphs WHERE id=? AND project_id=?').get(scope.graphId, scope.projectId))
|
|
39
|
+
missing(); }
|
|
40
|
+
writable(scope) { this.project(scope); if (this.db.prepare('SELECT state FROM projects WHERE id=?').get(scope.projectId)['state'] !== 'active')
|
|
41
|
+
throw new ServiceError('PROJECT_INACTIVE', 'Project is inactive'); }
|
|
42
|
+
writableGraph(scope) { this.writable(scope); this.graph(scope); const graph = this.db.prepare('SELECT archived,trashed FROM graphs WHERE id=?').get(scope.graphId); if (graph['archived'] || graph['trashed'])
|
|
43
|
+
throw new ServiceError('CONFLICT', 'Graph is archived or trashed'); }
|
|
44
|
+
event(type, scope, graphId, id, revision, action) {
|
|
45
|
+
this.tx();
|
|
46
|
+
this.db.prepare('INSERT INTO events(id,type,project_id,graph_id,entity_id,revision,occurred_at,payload) VALUES(?,?,?,?,?,?,?,?)').run(randomUUID(), type, scope.projectId, graphId, id, revision, new Date(this.now()).toISOString(), JSON.stringify({ action }));
|
|
47
|
+
}
|
|
48
|
+
startUpload(request) {
|
|
49
|
+
this.tx();
|
|
50
|
+
this.writable(request);
|
|
51
|
+
integer(request.bytes);
|
|
52
|
+
name(request.name);
|
|
53
|
+
if (request.bytes > this.blobs.maxBytes)
|
|
54
|
+
throw new ServiceError('PAYLOAD_TOO_LARGE', 'Resource exceeds configured maximum');
|
|
55
|
+
if (typeof request.mime !== 'string' || !/^[a-zA-Z0-9.+-]+[/][a-zA-Z0-9.+-]+$/.test(request.mime))
|
|
56
|
+
invalid('Invalid MIME');
|
|
57
|
+
if (request.sha256 !== undefined && !/^[a-f0-9]{64}$/.test(request.sha256))
|
|
58
|
+
invalid('Invalid SHA-256');
|
|
59
|
+
const id = randomUUID();
|
|
60
|
+
this.db.prepare('INSERT INTO resource_uploads(id,project_id,name,mime,bytes,expected_sha256,created_at) VALUES(?,?,?,?,?,?,?)').run(id, request.projectId, name(request.name), request.mime, request.bytes, request.sha256 ?? null, this.now());
|
|
61
|
+
return this.uploadStatus(request, id);
|
|
62
|
+
}
|
|
63
|
+
uploadStatus(scope, uploadId) {
|
|
64
|
+
this.project(scope);
|
|
65
|
+
const row = this.db.prepare('SELECT * FROM resource_uploads WHERE id=? AND project_id=?').get(uploadId, scope.projectId);
|
|
66
|
+
if (!row)
|
|
67
|
+
return missing();
|
|
68
|
+
return { uploadId, projectId: scope.projectId, name: String(row['name']), bytes: Number(row['bytes']), received: Number(row['received']), mime: String(row['mime']), state: String(row['state']), sha256: row['sha256'] === null ? null : String(row['sha256']) };
|
|
69
|
+
}
|
|
70
|
+
async prepareAppend(scope, uploadId, offset, bytes) {
|
|
71
|
+
this.outside();
|
|
72
|
+
integer(offset);
|
|
73
|
+
const status = this.uploadStatus(scope, uploadId);
|
|
74
|
+
if (status.state !== 'uploading' || offset !== status.received)
|
|
75
|
+
conflict('Upload offset or state changed');
|
|
76
|
+
if (!bytes.byteLength || bytes.byteLength > 4 * 1024 * 1024 || offset + bytes.byteLength > status.bytes)
|
|
77
|
+
invalid('Invalid chunk size');
|
|
78
|
+
const stable = Buffer.from(bytes);
|
|
79
|
+
const result = Object.freeze({ uploadId, projectId: scope.projectId, offset, bytes: stable.length, sha256: hashBytes(stable), path: await this.blobs.writeChunk(stable) });
|
|
80
|
+
this.appends.add(result);
|
|
81
|
+
return result;
|
|
82
|
+
}
|
|
83
|
+
commitAppend(scope, prepared) {
|
|
84
|
+
this.tx();
|
|
85
|
+
this.writable(scope);
|
|
86
|
+
if (!this.appends.has(prepared) || prepared.projectId !== scope.projectId)
|
|
87
|
+
invalid('Invalid prepared chunk');
|
|
88
|
+
if (this.db.prepare('SELECT 1 FROM resource_file_deletions WHERE path=?').get(prepared.path))
|
|
89
|
+
conflict('Prepared chunk retired');
|
|
90
|
+
const result = this.db.prepare("UPDATE resource_uploads SET received=received+? WHERE id=? AND project_id=? AND state='uploading' AND received=? AND received+?<=bytes").run(prepared.bytes, prepared.uploadId, scope.projectId, prepared.offset, prepared.bytes);
|
|
91
|
+
if (!result.changes)
|
|
92
|
+
conflict('Upload offset changed; query upload status before retry');
|
|
93
|
+
this.db.prepare('INSERT INTO resource_upload_chunks VALUES(?,?,?,?,?)').run(prepared.uploadId, prepared.offset, prepared.bytes, prepared.sha256, prepared.path);
|
|
94
|
+
return this.uploadStatus(scope, prepared.uploadId);
|
|
95
|
+
}
|
|
96
|
+
async prepareFinish(scope, uploadId) {
|
|
97
|
+
this.outside();
|
|
98
|
+
const status = this.uploadStatus(scope, uploadId);
|
|
99
|
+
if (status.state !== 'uploading' || status.bytes !== status.received)
|
|
100
|
+
conflict('Upload is incomplete or already finished');
|
|
101
|
+
const row = this.db.prepare('SELECT expected_sha256 FROM resource_uploads WHERE id=?').get(uploadId);
|
|
102
|
+
const chunks = this.db.prepare('SELECT * FROM resource_upload_chunks WHERE upload_id=? ORDER BY offset').all(uploadId);
|
|
103
|
+
const buffers = [];
|
|
104
|
+
let offset = 0;
|
|
105
|
+
for (const chunk of chunks) {
|
|
106
|
+
const bytes = await this.blobs.read(String(chunk['path']));
|
|
107
|
+
if (Number(chunk['offset']) !== offset || bytes.length !== chunk['bytes'] || hashBytes(bytes) !== chunk['sha256'])
|
|
108
|
+
invalid('Corrupt upload chunk');
|
|
109
|
+
buffers.push(bytes);
|
|
110
|
+
offset += bytes.length;
|
|
111
|
+
}
|
|
112
|
+
if (offset !== status.bytes)
|
|
113
|
+
invalid('Incomplete upload chunks');
|
|
114
|
+
const blob = await this.blobs.prepare(Buffer.concat(buffers, offset), status.mime, row['expected_sha256'] === null ? undefined : String(row['expected_sha256']));
|
|
115
|
+
const result = Object.freeze({ uploadId, projectId: scope.projectId, blob });
|
|
116
|
+
this.finishes.add(result);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
async prepareBytes(bytes, mime = 'application/octet-stream', sha256) { this.outside(); return this.blobs.prepare(bytes, mime, sha256); }
|
|
120
|
+
async prepareProjectFile(scope, path, mime = 'application/octet-stream') {
|
|
121
|
+
this.outside();
|
|
122
|
+
this.project(scope);
|
|
123
|
+
const registeredRoot = String(this.db.prepare('SELECT canonical_path FROM projects WHERE id=?').get(scope.projectId)['canonical_path']);
|
|
124
|
+
const root = await realpath(registeredRoot);
|
|
125
|
+
if (root !== registeredRoot)
|
|
126
|
+
throw new ServiceError('PROJECT_UNAVAILABLE', 'Registered project path was replaced by a symlink');
|
|
127
|
+
const resolved = await realpath(resolve(root, path));
|
|
128
|
+
const rel = relative(root, resolved);
|
|
129
|
+
if (!rel || rel === '..' || rel.startsWith('..' + sep) || isAbsolute(rel))
|
|
130
|
+
invalid('File is outside project');
|
|
131
|
+
const file = await open(resolved, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
132
|
+
try {
|
|
133
|
+
const before = await file.stat();
|
|
134
|
+
if (!before.isFile())
|
|
135
|
+
invalid('Expected a regular file');
|
|
136
|
+
if (before.size > this.blobs.maxBytes)
|
|
137
|
+
throw new ServiceError('PAYLOAD_TOO_LARGE', 'File exceeds maximum');
|
|
138
|
+
const bytes = await readBounded(file, this.blobs.maxBytes);
|
|
139
|
+
const after = await file.stat();
|
|
140
|
+
let current;
|
|
141
|
+
try {
|
|
142
|
+
const currentResolved = await realpath(resolve(root, path));
|
|
143
|
+
if (currentResolved !== resolved)
|
|
144
|
+
throw new ServiceError('CONFLICT', 'File changed during import');
|
|
145
|
+
const currentFile = await open(currentResolved, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
146
|
+
try {
|
|
147
|
+
current = await currentFile.stat();
|
|
148
|
+
if (!current.isFile())
|
|
149
|
+
throw new ServiceError('CONFLICT', 'File changed during import');
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await currentFile.close();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (cause) {
|
|
156
|
+
if (cause instanceof ServiceError)
|
|
157
|
+
throw cause;
|
|
158
|
+
throw new ServiceError('CONFLICT', 'File changed during import', { cause });
|
|
159
|
+
}
|
|
160
|
+
if (bytes.length !== before.size || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.dev !== after.dev || before.ino !== after.ino ||
|
|
161
|
+
before.size !== current.size || before.mtimeMs !== current.mtimeMs || before.dev !== current.dev || before.ino !== current.ino)
|
|
162
|
+
throw new ServiceError('CONFLICT', 'File changed during import');
|
|
163
|
+
return await this.blobs.prepare(bytes, mime);
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
await file.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
register(blob) {
|
|
170
|
+
this.tx();
|
|
171
|
+
this.blobs.assertPrepared(blob);
|
|
172
|
+
if (this.db.prepare('SELECT 1 FROM resource_file_deletions WHERE path=?').get(blob.path))
|
|
173
|
+
conflict('Prepared file was retired; prepare again');
|
|
174
|
+
const prior = this.db.prepare('SELECT b.bytes,f.path FROM blobs b LEFT JOIN resource_blob_files f USING(sha256) WHERE b.sha256=?').get(blob.sha256);
|
|
175
|
+
if (prior && Number(prior['bytes']) !== blob.bytes)
|
|
176
|
+
invalid('Blob hash metadata mismatch');
|
|
177
|
+
this.db.prepare('INSERT OR IGNORE INTO blobs(sha256,bytes) VALUES(?,?)').run(blob.sha256, blob.bytes);
|
|
178
|
+
if (!prior?.['path'])
|
|
179
|
+
this.db.prepare('INSERT INTO resource_blob_files VALUES(?,?)').run(blob.sha256, blob.path);
|
|
180
|
+
else if (prior['path'] !== blob.path)
|
|
181
|
+
this.queueFile(blob.path);
|
|
182
|
+
if (blob.text !== null)
|
|
183
|
+
this.db.prepare("INSERT OR IGNORE INTO resource_representations VALUES(?,'builtin-utf8',1,'ready','text/plain',?,NULL)").run(blob.sha256, blob.text);
|
|
184
|
+
}
|
|
185
|
+
queueFile(path) { this.db.prepare('INSERT OR IGNORE INTO resource_file_deletions(path,created_at) VALUES(?,?)').run(path, this.now()); }
|
|
186
|
+
/** Explicit cleanup of a known failed preparation; never scan unknown files.
|
|
187
|
+
* The permanent tombstone prevents any later commit from reusing this path. */
|
|
188
|
+
discardPrepared(blob) {
|
|
189
|
+
this.tx();
|
|
190
|
+
this.blobs.assertPrepared(blob);
|
|
191
|
+
if (this.db.prepare('SELECT 1 FROM resource_blob_files WHERE path=?').get(blob.path))
|
|
192
|
+
return false;
|
|
193
|
+
this.queueFile(blob.path);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
discardAppend(scope, prepared) {
|
|
197
|
+
this.tx();
|
|
198
|
+
this.project(scope);
|
|
199
|
+
if (!this.appends.has(prepared) || prepared.projectId !== scope.projectId)
|
|
200
|
+
invalid('Invalid prepared chunk');
|
|
201
|
+
if (this.db.prepare('SELECT 1 FROM resource_upload_chunks WHERE path=?').get(prepared.path))
|
|
202
|
+
return false;
|
|
203
|
+
this.queueFile(prepared.path);
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
cancelUpload(scope, uploadId) {
|
|
207
|
+
this.tx();
|
|
208
|
+
this.writable(scope);
|
|
209
|
+
const status = this.uploadStatus(scope, uploadId);
|
|
210
|
+
if (status.state !== 'uploading')
|
|
211
|
+
conflict('Finished upload cannot be cancelled');
|
|
212
|
+
for (const row of this.db.prepare('SELECT path FROM resource_upload_chunks WHERE upload_id=?').all(uploadId))
|
|
213
|
+
this.queueFile(String(row['path']));
|
|
214
|
+
this.db.prepare('DELETE FROM resource_upload_chunks WHERE upload_id=?').run(uploadId);
|
|
215
|
+
this.db.prepare('DELETE FROM resource_uploads WHERE id=?').run(uploadId);
|
|
216
|
+
}
|
|
217
|
+
commitFinish(scope, prepared, target) {
|
|
218
|
+
this.tx();
|
|
219
|
+
this.writable(scope);
|
|
220
|
+
if (!this.finishes.has(prepared) || prepared.projectId !== scope.projectId || target.projectId !== scope.projectId)
|
|
221
|
+
invalid('Invalid prepared upload');
|
|
222
|
+
const status = this.uploadStatus(scope, prepared.uploadId);
|
|
223
|
+
if (status.state !== 'uploading' || status.received !== status.bytes)
|
|
224
|
+
conflict('Upload no longer committable');
|
|
225
|
+
const result = target.mode === 'canvas' ? this.createCanvasFromPrepared(target, prepared.blob, target.name) : this.createLibraryFromPrepared(target, prepared.blob);
|
|
226
|
+
this.db.prepare("UPDATE resource_uploads SET state='finished',sha256=? WHERE id=?").run(prepared.blob.sha256, prepared.uploadId);
|
|
227
|
+
for (const row of this.db.prepare('SELECT path FROM resource_upload_chunks WHERE upload_id=?').all(prepared.uploadId))
|
|
228
|
+
this.queueFile(String(row['path']));
|
|
229
|
+
this.db.prepare('DELETE FROM resource_upload_chunks WHERE upload_id=?').run(prepared.uploadId);
|
|
230
|
+
return result;
|
|
231
|
+
}
|
|
232
|
+
createLibraryFromPrepared(target, blob) { this.tx(); this.writable(target); this.register(blob); return this.libraryFromBlob(target, blob.sha256, blob.mime); }
|
|
233
|
+
libraryFromBlob(target, sha256, mime) {
|
|
234
|
+
if (target.mode !== 'new' && target.mode !== 'update')
|
|
235
|
+
invalid('Choose new or explicit update target');
|
|
236
|
+
if (!this.db.prepare('SELECT 1 FROM resource_blob_files WHERE sha256=?').get(sha256))
|
|
237
|
+
throw new ServiceError('NOT_IMPLEMENTED', 'Legacy blob storage has not been registered');
|
|
238
|
+
this.writable(target);
|
|
239
|
+
const title = name(target.name);
|
|
240
|
+
let id, version;
|
|
241
|
+
if (target.mode === 'new') {
|
|
242
|
+
id = randomUUID();
|
|
243
|
+
version = 1;
|
|
244
|
+
this.db.prepare('INSERT INTO assets(id,project_id,name,current_version) VALUES(?,?,?,1)').run(id, target.projectId, title);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
id = target.assetId;
|
|
248
|
+
const old = this.db.prepare('SELECT * FROM assets WHERE id=? AND project_id=? AND deleted_at IS NULL').get(id, target.projectId);
|
|
249
|
+
if (!old)
|
|
250
|
+
return missing();
|
|
251
|
+
if (old['current_version'] !== target.expectedVersion)
|
|
252
|
+
conflict();
|
|
253
|
+
version = target.expectedVersion + 1;
|
|
254
|
+
this.db.prepare('UPDATE assets SET name=?,current_version=? WHERE id=?').run(title, version, id);
|
|
255
|
+
}
|
|
256
|
+
this.db.prepare("INSERT INTO asset_versions(asset_id,version,sha256,mime,state,representation_version) VALUES(?,?,?,?,'ready',?)").run(id, version, sha256, mime, this.representationVersion(sha256, mime));
|
|
257
|
+
this.event('asset.changed', target, null, id, version, target.mode === 'new' ? 'created' : 'updated');
|
|
258
|
+
return this.getAsset(target, id);
|
|
259
|
+
}
|
|
260
|
+
createCanvasFromPrepared(scope, blob, title) { this.tx(); this.writableGraph(scope); this.register(blob); return this.createCanvasFromReadyBlob(scope, blob.sha256, blob.mime, title); }
|
|
261
|
+
representationVersion(sha256, mime) { return supportsText(mime) && this.db.prepare("SELECT 1 FROM resource_representations WHERE sha256=? AND processor='builtin-utf8' AND version=1 AND state='ready'").get(sha256) ? 1 : null; }
|
|
262
|
+
/** Editing is separate from canvas upload (which always creates an identity).
|
|
263
|
+
* Existing node/history refs stay pinned; caller explicitly attaches the new hold. */
|
|
264
|
+
updateCanvasFromPrepared(scope, resourceId, expectedVersion, blob, title) {
|
|
265
|
+
this.tx();
|
|
266
|
+
this.writableGraph(scope);
|
|
267
|
+
const old = this.getCanvas(scope, resourceId);
|
|
268
|
+
if (old.current.version !== expectedVersion)
|
|
269
|
+
conflict();
|
|
270
|
+
this.register(blob);
|
|
271
|
+
const version = expectedVersion + 1;
|
|
272
|
+
this.db.prepare('INSERT INTO canvas_resource_versions(resource_id,version,sha256,mime,representation_version) VALUES(?,?,?,?,?)').run(resourceId, version, blob.sha256, blob.mime, this.representationVersion(blob.sha256, blob.mime));
|
|
273
|
+
this.db.prepare('UPDATE canvas_resources SET current_version=?,name=? WHERE id=?').run(version, name(title ?? old.name), resourceId);
|
|
274
|
+
this.event('canvas_resource.changed', scope, scope.graphId, resourceId, version, 'updated');
|
|
275
|
+
return { resource: this.getCanvas(scope, resourceId), referenceId: this.holdCanvasResource(scope, resourceId, version) };
|
|
276
|
+
}
|
|
277
|
+
/** Trusted helper: caller authorizes source; never expose as a public hash API. */
|
|
278
|
+
createCanvasFromReadyBlob(scope, sha256, mime, title) {
|
|
279
|
+
this.tx();
|
|
280
|
+
this.writableGraph(scope);
|
|
281
|
+
name(title);
|
|
282
|
+
if (!this.db.prepare('SELECT 1 FROM resource_blob_files WHERE sha256=?').get(sha256))
|
|
283
|
+
missing();
|
|
284
|
+
const id = randomUUID();
|
|
285
|
+
this.db.prepare('INSERT INTO canvas_resources(id,project_id,graph_id,name,current_version) VALUES(?,?,?,?,1)').run(id, scope.projectId, scope.graphId, name(title));
|
|
286
|
+
this.db.prepare('INSERT INTO canvas_resource_versions(resource_id,version,sha256,mime,representation_version) VALUES(?,1,?,?,?)').run(id, sha256, mime, this.representationVersion(sha256, mime));
|
|
287
|
+
this.event('canvas_resource.created', scope, scope.graphId, id, 1, 'created');
|
|
288
|
+
const referenceId = this.holdCanvasResource(scope, id, 1);
|
|
289
|
+
return { resource: this.getCanvas(scope, id), referenceId };
|
|
290
|
+
}
|
|
291
|
+
holdCanvasResource(scope, resourceId, version) {
|
|
292
|
+
this.tx();
|
|
293
|
+
this.writableGraph(scope);
|
|
294
|
+
this.readCanvasVersion(scope, resourceId, version);
|
|
295
|
+
const id = randomUUID();
|
|
296
|
+
this.db.prepare("INSERT INTO canvas_resource_references(id,resource_id,resource_version,graph_id,owner_kind) VALUES(?,?,?,?,'graph')").run(id, resourceId, version, scope.graphId);
|
|
297
|
+
return id;
|
|
298
|
+
}
|
|
299
|
+
attachNodeReference(scope, resourceId, version, nodeId, releaseGraphHold) {
|
|
300
|
+
this.tx();
|
|
301
|
+
this.writableGraph(scope);
|
|
302
|
+
this.readCanvasVersion(scope, resourceId, version);
|
|
303
|
+
if (!this.db.prepare('SELECT 1 FROM nodes WHERE id=? AND graph_id=? AND deleted=0').get(nodeId, scope.graphId))
|
|
304
|
+
missing();
|
|
305
|
+
const id = randomUUID();
|
|
306
|
+
this.db.prepare("INSERT INTO canvas_resource_references(id,resource_id,resource_version,graph_id,owner_kind,node_id) VALUES(?,?,?,?,'node',?)").run(id, resourceId, version, scope.graphId, nodeId);
|
|
307
|
+
if (releaseGraphHold !== undefined)
|
|
308
|
+
this.db.prepare("DELETE FROM canvas_resource_references WHERE id=? AND graph_id=? AND resource_id=? AND owner_kind='graph'").run(releaseGraphHold, scope.graphId, resourceId);
|
|
309
|
+
this.event('canvas_resource.changed', scope, scope.graphId, resourceId, version, 'attached');
|
|
310
|
+
return id;
|
|
311
|
+
}
|
|
312
|
+
releaseCanvasReference(scope, referenceId) {
|
|
313
|
+
this.tx();
|
|
314
|
+
this.writableGraph(scope);
|
|
315
|
+
const row = this.db.prepare("SELECT resource_id,resource_version FROM canvas_resource_references WHERE id=? AND graph_id=? AND owner_kind IN ('graph','node')").get(referenceId, scope.graphId);
|
|
316
|
+
if (row) {
|
|
317
|
+
this.db.prepare('DELETE FROM canvas_resource_references WHERE id=?').run(referenceId);
|
|
318
|
+
this.event('canvas_resource.changed', scope, scope.graphId, String(row['resource_id']), Number(row['resource_version']), 'reference_released');
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
/** Public temp-upload release cannot remove node/snapshot/history ownership. */
|
|
322
|
+
releaseGraphReference(scope, referenceId) {
|
|
323
|
+
this.tx();
|
|
324
|
+
this.writableGraph(scope);
|
|
325
|
+
if (!this.db.prepare("SELECT 1 FROM canvas_resource_references WHERE id=? AND graph_id=? AND owner_kind='graph'").get(referenceId, scope.graphId))
|
|
326
|
+
missing();
|
|
327
|
+
this.releaseCanvasReference(scope, referenceId);
|
|
328
|
+
this.collectGarbage();
|
|
329
|
+
}
|
|
330
|
+
/** Called after Graphs inserts node/history ownership, in the same transaction.
|
|
331
|
+
* Consume only the attached immutable version's temporary graph holds. */
|
|
332
|
+
consumeGraphHolds(scope, resourceId, version) {
|
|
333
|
+
this.tx();
|
|
334
|
+
this.writableGraph(scope);
|
|
335
|
+
this.readCanvasVersion(scope, resourceId, version);
|
|
336
|
+
if (!this.db.prepare("SELECT 1 FROM canvas_resource_references WHERE resource_id=? AND resource_version=? AND owner_kind='node' UNION ALL SELECT 1 FROM node_resource_history WHERE resource_id=? AND resource_version=? LIMIT 1").get(resourceId, version, resourceId, version))
|
|
337
|
+
invalid('Attach node/history ownership before consuming graph holds');
|
|
338
|
+
return Number(this.db.prepare("DELETE FROM canvas_resource_references WHERE graph_id=? AND resource_id=? AND resource_version=? AND owner_kind='graph'").run(scope.graphId, resourceId, version).changes);
|
|
339
|
+
}
|
|
340
|
+
copyAssetToCanvas(scope, assetId, expectedAssetVersion) {
|
|
341
|
+
this.tx();
|
|
342
|
+
this.writableGraph(scope);
|
|
343
|
+
const asset = this.getAsset(scope, assetId);
|
|
344
|
+
if (asset.deleted)
|
|
345
|
+
missing();
|
|
346
|
+
if (asset.current.version !== expectedAssetVersion)
|
|
347
|
+
conflict();
|
|
348
|
+
return this.createCanvasFromReadyBlob(scope, asset.current.sha256, asset.current.mime, asset.name);
|
|
349
|
+
}
|
|
350
|
+
/** Clipboard copies a pinned, authorized canvas version into a new identity.
|
|
351
|
+
* Never expose an arbitrary blob-hash copy API or resurrect a collected source. */
|
|
352
|
+
copyCanvasToCanvas(scope, sourceGraphId, resourceId, version) {
|
|
353
|
+
this.tx();
|
|
354
|
+
this.writableGraph(scope);
|
|
355
|
+
const source = { ...scope, graphId: sourceGraphId };
|
|
356
|
+
const resource = this.getCanvas(source, resourceId);
|
|
357
|
+
const pinned = this.readCanvasVersion(source, resourceId, version);
|
|
358
|
+
return this.createCanvasFromReadyBlob(scope, pinned.sha256, pinned.mime, resource.name);
|
|
359
|
+
}
|
|
360
|
+
saveCanvasToLibrary(scope, resourceId, expectedResourceVersion, title) {
|
|
361
|
+
this.tx();
|
|
362
|
+
this.writableGraph(scope);
|
|
363
|
+
const resource = this.getCanvas(scope, resourceId);
|
|
364
|
+
if (resource.current.version !== expectedResourceVersion)
|
|
365
|
+
conflict();
|
|
366
|
+
return this.libraryFromBlob({ ...scope, mode: 'new', name: title }, resource.current.sha256, resource.current.mime);
|
|
367
|
+
}
|
|
368
|
+
copyAssetToProject(scope, assetId, expectedVersion, title) {
|
|
369
|
+
this.tx();
|
|
370
|
+
this.writable(scope);
|
|
371
|
+
const asset = this.getAsset(scope, assetId);
|
|
372
|
+
if (asset.deleted)
|
|
373
|
+
missing();
|
|
374
|
+
if (asset.current.version !== expectedVersion)
|
|
375
|
+
conflict();
|
|
376
|
+
return this.libraryFromBlob({ ...scope, mode: 'new', name: title ?? asset.name }, asset.current.sha256, asset.current.mime);
|
|
377
|
+
}
|
|
378
|
+
version(row) { return { version: Number(row['version']), sha256: String(row['sha256']), bytes: Number(row['bytes']), mime: String(row['mime']), state: 'ready', representationVersion: row['representation_version'] === null ? null : Number(row['representation_version']) }; }
|
|
379
|
+
getAsset(scope, id) {
|
|
380
|
+
this.project(scope);
|
|
381
|
+
const row = this.db.prepare('SELECT * FROM assets WHERE id=? AND (project_id=? OR (shared=1 AND deleted_at IS NULL))').get(id, scope.projectId);
|
|
382
|
+
if (!row)
|
|
383
|
+
return missing();
|
|
384
|
+
const current = this.readAssetVersion(scope, id, Number(row['current_version']));
|
|
385
|
+
return { id, projectId: String(row['project_id']), name: String(row['name']), shared: row['shared'] === 1, deleted: row['deleted_at'] !== null, current: { ...current, assetId: id } };
|
|
386
|
+
}
|
|
387
|
+
listAssets(scope, options = {}) {
|
|
388
|
+
if (options.deleted !== undefined && typeof options.deleted !== 'boolean')
|
|
389
|
+
invalid('Invalid deleted filter');
|
|
390
|
+
if (options.name !== undefined)
|
|
391
|
+
name(options.name);
|
|
392
|
+
if (options.search !== undefined)
|
|
393
|
+
name(options.search);
|
|
394
|
+
this.project(scope);
|
|
395
|
+
const limit = options.limit ?? 100, offset = options.offset ?? 0;
|
|
396
|
+
integer(limit);
|
|
397
|
+
integer(offset);
|
|
398
|
+
if (limit < 1 || limit > 1000)
|
|
399
|
+
invalid('Invalid page size');
|
|
400
|
+
const selection = options.scope ?? 'available';
|
|
401
|
+
if (!['project', 'shared', 'available'].includes(selection))
|
|
402
|
+
invalid('Invalid library scope');
|
|
403
|
+
const where = selection === 'project' ? 'project_id=?' : selection === 'shared' ? 'shared=1 AND (project_id=? OR deleted_at IS NULL)' : '(project_id=? OR (shared=1 AND deleted_at IS NULL))';
|
|
404
|
+
const sql = 'SELECT id FROM assets WHERE ' + where + ' AND deleted_at IS ' + (options.deleted ? 'NOT ' : '') + 'NULL AND (? IS NULL OR name=?) AND (? IS NULL OR instr(lower(name),lower(?))>0) ORDER BY name,id LIMIT ? OFFSET ?';
|
|
405
|
+
return this.db.prepare(sql).all(scope.projectId, options.name ?? null, options.name ?? null, options.search ?? null, options.search ?? null, limit, offset).map(row => this.getAsset(scope, String(row['id'])));
|
|
406
|
+
}
|
|
407
|
+
listSameNameAssets(scope, title) { return this.listAssets(scope, { name: name(title), scope: 'project' }); }
|
|
408
|
+
setShared(scope, id, shared) { this.tx(); this.writable(scope); if (typeof shared !== 'boolean')
|
|
409
|
+
invalid('Invalid shared flag'); if (!this.db.prepare('UPDATE assets SET shared=? WHERE id=? AND project_id=? AND deleted_at IS NULL').run(shared ? 1 : 0, id, scope.projectId).changes)
|
|
410
|
+
missing(); const asset = this.getAsset(scope, id); this.event('asset.changed', scope, null, id, asset.current.version, 'sharing_changed'); return asset; }
|
|
411
|
+
emptyTrash(scope) {
|
|
412
|
+
this.tx();
|
|
413
|
+
this.writable(scope);
|
|
414
|
+
if (this.db.prepare("SELECT 1 FROM resource_maintenance_leases WHERE kind='backup' LIMIT 1").get())
|
|
415
|
+
throw new ServiceError('CONFLICT', '正在备份,请稍后清空回收站。');
|
|
416
|
+
let removed = 0, retained = 0;
|
|
417
|
+
for (const row of this.db.prepare('SELECT id,current_version FROM assets WHERE project_id=? AND deleted_at IS NOT NULL').all(scope.projectId)) {
|
|
418
|
+
const id = String(row['id']);
|
|
419
|
+
if (this.assetReferenced(id)) {
|
|
420
|
+
retained++;
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
this.db.prepare('DELETE FROM asset_versions WHERE asset_id=?').run(id);
|
|
424
|
+
this.db.prepare('DELETE FROM assets WHERE id=?').run(id);
|
|
425
|
+
this.event('asset.changed', scope, null, id, Number(row['current_version']), 'collected');
|
|
426
|
+
removed++;
|
|
427
|
+
}
|
|
428
|
+
return { removed, retained };
|
|
429
|
+
}
|
|
430
|
+
softDelete(scope, id) {
|
|
431
|
+
this.tx();
|
|
432
|
+
this.writable(scope);
|
|
433
|
+
const asset = this.getAsset(scope, id);
|
|
434
|
+
if (asset.projectId !== scope.projectId)
|
|
435
|
+
missing();
|
|
436
|
+
if (!asset.deleted) {
|
|
437
|
+
this.db.prepare('UPDATE assets SET deleted_at=?,unreferenced_since=? WHERE id=?').run(this.now(), this.assetReferenced(id) ? null : this.now(), id);
|
|
438
|
+
this.event('asset.changed', scope, null, id, asset.current.version, 'deleted');
|
|
439
|
+
}
|
|
440
|
+
return this.getAsset(scope, id);
|
|
441
|
+
}
|
|
442
|
+
restore(scope, id) { this.tx(); this.writable(scope); if (!this.db.prepare('UPDATE assets SET deleted_at=NULL,unreferenced_since=NULL WHERE id=? AND project_id=?').run(id, scope.projectId).changes)
|
|
443
|
+
missing(); const asset = this.getAsset(scope, id); this.event('asset.changed', scope, null, id, asset.current.version, 'restored'); return asset; }
|
|
444
|
+
getCanvas(scope, id) { this.graph(scope); const row = this.db.prepare('SELECT * FROM canvas_resources WHERE id=? AND graph_id=?').get(id, scope.graphId); if (!row)
|
|
445
|
+
return missing(); return { id, projectId: scope.projectId, graphId: scope.graphId, name: String(row['name']), current: { ...this.readCanvasVersion(scope, id, Number(row['current_version'])), resourceId: id } }; }
|
|
446
|
+
listCanvas(scope) { this.graph(scope); return this.db.prepare('SELECT id FROM canvas_resources WHERE graph_id=? ORDER BY id').all(scope.graphId).map(row => this.getCanvas(scope, String(row['id']))); }
|
|
447
|
+
readCanvasVersion(scope, id, version) {
|
|
448
|
+
this.graph(scope);
|
|
449
|
+
integer(version);
|
|
450
|
+
const row = this.db.prepare('SELECT v.*,b.bytes FROM canvas_resource_versions v JOIN canvas_resources r ON r.id=v.resource_id JOIN blobs b USING(sha256) WHERE r.id=? AND r.graph_id=? AND v.version=?').get(id, scope.graphId, version);
|
|
451
|
+
if (!row)
|
|
452
|
+
return missing();
|
|
453
|
+
return this.version(row);
|
|
454
|
+
}
|
|
455
|
+
readAssetVersion(scope, id, version) {
|
|
456
|
+
this.project(scope);
|
|
457
|
+
integer(version);
|
|
458
|
+
const row = this.db.prepare("SELECT v.*,b.bytes FROM asset_versions v JOIN assets a ON a.id=v.asset_id JOIN blobs b USING(sha256) WHERE a.id=? AND v.version=? AND v.state='ready' AND (a.project_id=? OR (a.shared=1 AND a.deleted_at IS NULL) OR EXISTS(SELECT 1 FROM asset_references ar LEFT JOIN runs r ON r.id=ar.run_id LEFT JOIN nodes n ON n.id=ar.node_id LEFT JOIN graphs g ON g.id=n.graph_id WHERE ar.asset_id=a.id AND ar.asset_version=v.version AND (r.project_id=? OR g.project_id=?)) OR EXISTS(SELECT 1 FROM outputs o JOIN runs r ON r.id=o.run_id WHERE o.asset_id=a.id AND o.asset_version=v.version AND r.project_id=?))").get(id, version, scope.projectId, scope.projectId, scope.projectId, scope.projectId);
|
|
459
|
+
if (!row)
|
|
460
|
+
return missing();
|
|
461
|
+
return this.version(row);
|
|
462
|
+
}
|
|
463
|
+
async readContent(scope, kind, id, version, range) {
|
|
464
|
+
this.outside();
|
|
465
|
+
const lease = randomUUID();
|
|
466
|
+
let metadata;
|
|
467
|
+
let path = '';
|
|
468
|
+
transaction(this.db, () => { metadata = kind === 'asset' ? this.readAssetVersion(scope, id, version) : this.readCanvasVersion(scope, id, version); const file = this.db.prepare('SELECT path FROM resource_blob_files WHERE sha256=?').get(metadata.sha256); if (!file)
|
|
469
|
+
throw new ServiceError('NOT_IMPLEMENTED', 'Legacy blob storage has not been registered'); path = String(file['path']); this.db.prepare('INSERT INTO resource_read_leases VALUES(?,?)').run(lease, metadata.sha256); });
|
|
470
|
+
try {
|
|
471
|
+
const bytes = await this.blobs.read(path);
|
|
472
|
+
if (bytes.length !== metadata.bytes || hashBytes(bytes) !== metadata.sha256)
|
|
473
|
+
throw new ServiceError('INTERNAL_ERROR', 'Stored blob integrity check failed');
|
|
474
|
+
const start = range?.start ?? 0, end = range?.end ?? bytes.length - 1;
|
|
475
|
+
integer(start);
|
|
476
|
+
if (range && (start >= bytes.length || !Number.isSafeInteger(end) || end < start || end >= bytes.length))
|
|
477
|
+
invalid('Unsatisfiable byte range');
|
|
478
|
+
return { bytes: bytes.subarray(start, end + 1), mime: metadata.mime, total: bytes.length, start, end };
|
|
479
|
+
}
|
|
480
|
+
finally {
|
|
481
|
+
transaction(this.db, () => this.db.prepare('DELETE FROM resource_read_leases WHERE id=?').run(lease));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
async thumbnail(scope, kind, id, version, size = IMAGE_THUMBNAIL_LEVELS[0]) {
|
|
485
|
+
this.outside();
|
|
486
|
+
const metadata = kind === 'asset' ? this.readAssetVersion(scope, id, version) : this.readCanvasVersion(scope, id, version);
|
|
487
|
+
if (metadata.mime.startsWith('video/')) {
|
|
488
|
+
const target = [80, 160, 320].includes(size) ? size : 320;
|
|
489
|
+
const result = (bytes) => ({ bytes, mime: 'image/webp', total: bytes.length, start: 0, end: bytes.length - 1, cacheable: false });
|
|
490
|
+
const source = this.db.prepare(kind === 'asset' ? 'SELECT project_id,name FROM assets WHERE id=?' : 'SELECT project_id,name FROM canvas_resources WHERE id=?').get(id);
|
|
491
|
+
const title = String(source['name']);
|
|
492
|
+
const dot = title.lastIndexOf('.');
|
|
493
|
+
const stem = dot < 0 ? title : title.slice(0, dot);
|
|
494
|
+
// As in project files, check same-name covers on every request before frames.
|
|
495
|
+
for (const extension of ['jpg', 'jpeg', 'png', 'webp']) {
|
|
496
|
+
const candidates = this.db.prepare('SELECT id,current_version FROM assets WHERE project_id=? AND name=? AND deleted_at IS NULL ORDER BY id').all(String(source['project_id']), stem + '.' + extension);
|
|
497
|
+
for (const cover of candidates) {
|
|
498
|
+
try {
|
|
499
|
+
const coverMetadata = this.readAssetVersion(scope, String(cover['id']), Number(cover['current_version']));
|
|
500
|
+
if (!coverMetadata.mime.startsWith('image/') || coverMetadata.bytes > 8 * 1024 * 1024)
|
|
501
|
+
continue;
|
|
502
|
+
const original = await this.readContent(scope, 'asset', String(cover['id']), Number(cover['current_version']));
|
|
503
|
+
return result(await sharp(original.bytes).rotate().resize({ width: target, height: target, fit: 'inside', withoutEnlargement: true }).webp({ quality: 75 }).toBuffer());
|
|
504
|
+
}
|
|
505
|
+
catch { /* Inaccessible, missing or invalid covers fall back to frames. */ }
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
const lease = randomUUID();
|
|
509
|
+
let path = '';
|
|
510
|
+
transaction(this.db, () => {
|
|
511
|
+
const file = this.db.prepare('SELECT path FROM resource_blob_files WHERE sha256=?').get(metadata.sha256);
|
|
512
|
+
if (!file)
|
|
513
|
+
throw new ServiceError('NOT_IMPLEMENTED', 'Legacy blob storage has not been registered');
|
|
514
|
+
path = String(file['path']);
|
|
515
|
+
this.db.prepare('INSERT INTO resource_read_leases VALUES(?,?)').run(lease, metadata.sha256);
|
|
516
|
+
});
|
|
517
|
+
try {
|
|
518
|
+
const cacheKey = path + ':' + metadata.sha256 + ':' + target;
|
|
519
|
+
const bytes = await this.blobs.withVerifiedFile(path, metadata, async (file) => {
|
|
520
|
+
const cached = this.videoFrames.get(cacheKey);
|
|
521
|
+
if (cached)
|
|
522
|
+
return cached;
|
|
523
|
+
return videoThumbnail(file, target);
|
|
524
|
+
});
|
|
525
|
+
if (this.videoFrames.size >= 32)
|
|
526
|
+
this.videoFrames.delete(this.videoFrames.keys().next().value);
|
|
527
|
+
this.videoFrames.set(cacheKey, bytes);
|
|
528
|
+
return result(bytes);
|
|
529
|
+
}
|
|
530
|
+
finally {
|
|
531
|
+
transaction(this.db, () => this.db.prepare('DELETE FROM resource_read_leases WHERE id=?').run(lease));
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
if (!['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'].includes(metadata.mime))
|
|
535
|
+
throw new ServiceError('INVALID_REQUEST', '仅支持图片资源缩略图。');
|
|
536
|
+
const original = await this.readContent(scope, kind, id, version);
|
|
537
|
+
const image = sharp(original.bytes, { animated: false });
|
|
538
|
+
const info = await image.metadata();
|
|
539
|
+
const maxDimension = Math.max(info.width ?? 0, info.height ?? 0);
|
|
540
|
+
const target = IMAGE_THUMBNAIL_LEVELS.includes(size) ? size : IMAGE_THUMBNAIL_LEVELS[0];
|
|
541
|
+
if (maxDimension > 0 && maxDimension <= target)
|
|
542
|
+
return original;
|
|
543
|
+
const bytes = await image.rotate().resize({ width: target, height: target, fit: 'inside', withoutEnlargement: true }).webp({ quality: 82 }).toBuffer();
|
|
544
|
+
return { bytes, mime: 'image/webp', total: bytes.length, start: 0, end: Math.max(0, bytes.length - 1) };
|
|
545
|
+
}
|
|
546
|
+
async representation(scope, kind, id, version) {
|
|
547
|
+
this.outside();
|
|
548
|
+
const metadata = kind === 'asset' ? this.readAssetVersion(scope, id, version) : this.readCanvasVersion(scope, id, version);
|
|
549
|
+
const supported = supportsText(metadata.mime);
|
|
550
|
+
const result = { state: supported ? 'ready' : 'unsupported', processor: 'builtin-utf8', version: 1, mime: 'text/plain', text: null, reason: supported ? null : 'No extractor installed for ' + metadata.mime };
|
|
551
|
+
if (supported)
|
|
552
|
+
result.text = new TextDecoder('utf-8', { fatal: true }).decode((await this.readContent(scope, kind, id, version)).bytes);
|
|
553
|
+
transaction(this.db, () => { if (this.db.prepare('SELECT 1 FROM blobs WHERE sha256=?').get(metadata.sha256))
|
|
554
|
+
this.db.prepare('INSERT OR REPLACE INTO resource_representations VALUES(?,?,?,?,?,?,?)').run(metadata.sha256, result.processor, result.version, result.state, result.mime, result.text, result.reason); });
|
|
555
|
+
return result;
|
|
556
|
+
}
|
|
557
|
+
/** DB-only resolver for InputPreparation; caller already selected a scoped version.
|
|
558
|
+
* No extraction or file I/O is performed in the snapshot transaction. */
|
|
559
|
+
readPreparedRepresentation(resource) {
|
|
560
|
+
const row = this.db.prepare('SELECT v.*,b.bytes FROM canvas_resource_versions v JOIN blobs b USING(sha256) JOIN resource_blob_files f USING(sha256) WHERE resource_id=? AND version=?').get(resource.resourceId, resource.version);
|
|
561
|
+
if (!row || row['sha256'] !== resource.sha256 || row['mime'] !== resource.mime || row['bytes'] !== resource.bytes || row['representation_version'] !== resource.representationVersion)
|
|
562
|
+
return null;
|
|
563
|
+
const common = { resourceSha256: resource.sha256, representationVersion: resource.representationVersion };
|
|
564
|
+
if (['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(resource.mime))
|
|
565
|
+
return { ...common, state: 'ready', text: null, contentHash: resource.sha256 };
|
|
566
|
+
if (!supportsText(resource.mime))
|
|
567
|
+
return { ...common, state: 'ready', text: null, contentHash: resource.sha256 };
|
|
568
|
+
const representation = this.db.prepare("SELECT text,state FROM resource_representations WHERE sha256=? AND processor='builtin-utf8' AND version=?").get(resource.sha256, resource.representationVersion);
|
|
569
|
+
if (!supportsText(resource.mime) || representation?.['state'] !== 'ready' || typeof representation['text'] !== 'string')
|
|
570
|
+
return { ...common, state: 'failed', text: null, contentHash: null };
|
|
571
|
+
const text = representation['text'];
|
|
572
|
+
return { ...common, state: 'ready', text, contentHash: hashBytes(Buffer.from(text)) };
|
|
573
|
+
}
|
|
574
|
+
async preview(scope, kind, id, version) {
|
|
575
|
+
const metadata = kind === 'asset' ? this.readAssetVersion(scope, id, version) : this.readCanvasVersion(scope, id, version);
|
|
576
|
+
if (['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml'].includes(metadata.mime) || metadata.mime.startsWith('video/'))
|
|
577
|
+
return { state: 'ready', processor: 'original-media', version: 1, content: await this.readContent(scope, kind, id, version) };
|
|
578
|
+
return this.representation(scope, kind, id, version);
|
|
579
|
+
}
|
|
580
|
+
/** Preserve exact ID/hash leaves in legacy snapshot JSON without rewriting it. */
|
|
581
|
+
snapshotMentions(value) { return !!this.db.prepare("SELECT 1 FROM snapshots s,json_tree(s.payload) j WHERE j.type='text' AND j.value=? LIMIT 1").get(value); }
|
|
582
|
+
/** Typed snapshots protect only their named identity; shared hashes protect bytes. */
|
|
583
|
+
snapshotUnboundHash(value) {
|
|
584
|
+
return !!this.db.prepare("SELECT 1 FROM snapshots s,json_tree(s.payload) j WHERE j.type='text' AND j.value=? AND NOT EXISTS(SELECT 1 FROM json_each(s.payload,j.path) member WHERE member.key IN ('resourceId','assetId','resource_id','asset_id','legacyAsset') AND member.type='text' AND length(member.value)>0) LIMIT 1").get(value);
|
|
585
|
+
}
|
|
586
|
+
assetReferenced(id) {
|
|
587
|
+
if (this.db.prepare('SELECT 1 FROM asset_references WHERE asset_id=? UNION ALL SELECT 1 FROM outputs WHERE asset_id=? LIMIT 1').get(id, id) || this.snapshotMentions(id))
|
|
588
|
+
return true;
|
|
589
|
+
return this.db.prepare('SELECT sha256 FROM asset_versions WHERE asset_id=?').all(id).some(row => this.snapshotUnboundHash(String(row['sha256'])));
|
|
590
|
+
}
|
|
591
|
+
canvasReferenced(id, includeUnboundHash = true) {
|
|
592
|
+
if (this.db.prepare('SELECT 1 FROM graph_document_resources WHERE resource_id=? LIMIT 1').get(id))
|
|
593
|
+
return true;
|
|
594
|
+
if (this.db.prepare('SELECT 1 FROM canvas_resource_references WHERE resource_id=? UNION ALL SELECT 1 FROM canvas_outputs WHERE resource_id=? LIMIT 1').get(id, id) || this.snapshotMentions(id))
|
|
595
|
+
return true;
|
|
596
|
+
if (this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='node_resource_history'").get() && this.db.prepare('SELECT 1 FROM node_resource_history WHERE resource_id=? LIMIT 1').get(id))
|
|
597
|
+
return true;
|
|
598
|
+
return includeUnboundHash && this.db.prepare('SELECT sha256 FROM canvas_resource_versions WHERE resource_id=?').all(id).some(row => this.snapshotUnboundHash(String(row['sha256'])));
|
|
599
|
+
}
|
|
600
|
+
collectGarbage(only, canvasOnly = false) {
|
|
601
|
+
this.tx();
|
|
602
|
+
const counts = { canvas: 0, assets: 0, blobs: 0 };
|
|
603
|
+
const now = this.now();
|
|
604
|
+
if (this.db.prepare("SELECT 1 FROM resource_maintenance_leases WHERE kind='backup' LIMIT 1").get())
|
|
605
|
+
return counts;
|
|
606
|
+
this.db.prepare('DELETE FROM graph_document_heads WHERE expires_at<=?').run(now);
|
|
607
|
+
this.db.prepare('DELETE FROM graph_document_versions WHERE graph_id NOT IN (SELECT graph_id FROM graph_document_heads)').run();
|
|
608
|
+
this.db.prepare('DELETE FROM node_resource_history WHERE node_id IN (SELECT id FROM nodes WHERE deleted=1 AND undo_expires_at<=?)').run(now);
|
|
609
|
+
this.db.prepare('UPDATE nodes SET undo_expires_at=NULL WHERE deleted=1 AND undo_expires_at<=?').run(now);
|
|
610
|
+
for (const row of this.db.prepare('SELECT r.id,r.project_id,r.graph_id,r.current_version FROM canvas_resources r JOIN graphs g ON g.id=r.graph_id WHERE (? IS NULL AND g.archived=0 AND g.trashed=0) OR r.graph_id=?').all(only?.graphId ?? null, only?.graphId ?? null)) {
|
|
611
|
+
const id = String(row['id']);
|
|
612
|
+
if (this.canvasReferenced(id, !only))
|
|
613
|
+
continue;
|
|
614
|
+
this.db.prepare('DELETE FROM canvas_resource_versions WHERE resource_id=?').run(id);
|
|
615
|
+
this.db.prepare('DELETE FROM canvas_resources WHERE id=?').run(id);
|
|
616
|
+
this.event('canvas_resource.collected', { projectId: String(row['project_id']) }, String(row['graph_id']), id, Number(row['current_version']), 'collected');
|
|
617
|
+
counts.canvas++;
|
|
618
|
+
}
|
|
619
|
+
for (const row of only || canvasOnly ? [] : this.db.prepare('SELECT id,project_id,current_version,unreferenced_since FROM assets WHERE deleted_at IS NOT NULL').all()) {
|
|
620
|
+
const id = String(row['id']);
|
|
621
|
+
if (this.assetReferenced(id)) {
|
|
622
|
+
this.db.prepare('UPDATE assets SET unreferenced_since=NULL WHERE id=?').run(id);
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (row['unreferenced_since'] === null) {
|
|
626
|
+
this.db.prepare('UPDATE assets SET unreferenced_since=? WHERE id=?').run(now, id);
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
if (Number(row['unreferenced_since']) > now - 30 * DAY)
|
|
630
|
+
continue;
|
|
631
|
+
this.db.prepare('DELETE FROM asset_versions WHERE asset_id=?').run(id);
|
|
632
|
+
this.db.prepare('DELETE FROM assets WHERE id=?').run(id);
|
|
633
|
+
this.event('asset.changed', { projectId: String(row['project_id']) }, null, id, Number(row['current_version']), 'collected');
|
|
634
|
+
counts.assets++;
|
|
635
|
+
}
|
|
636
|
+
const candidates = this.db.prepare('SELECT b.sha256,f.path FROM blobs b JOIN resource_blob_files f USING(sha256) WHERE NOT EXISTS(SELECT 1 FROM asset_versions WHERE sha256=b.sha256) AND NOT EXISTS(SELECT 1 FROM canvas_resource_versions WHERE sha256=b.sha256) AND NOT EXISTS(SELECT 1 FROM resource_read_leases WHERE sha256=b.sha256)').all();
|
|
637
|
+
for (const row of candidates) {
|
|
638
|
+
const hash = String(row['sha256']);
|
|
639
|
+
if ((only && !only.blobHashes.includes(hash)) || this.snapshotMentions(hash))
|
|
640
|
+
continue;
|
|
641
|
+
this.db.exec('SAVEPOINT resource_gc_blob');
|
|
642
|
+
try {
|
|
643
|
+
this.db.prepare("DELETE FROM resource_uploads WHERE sha256=? AND state='finished'").run(hash);
|
|
644
|
+
this.db.prepare('DELETE FROM resource_representations WHERE sha256=?').run(hash);
|
|
645
|
+
this.db.prepare('DELETE FROM resource_blob_files WHERE sha256=?').run(hash);
|
|
646
|
+
this.db.prepare('DELETE FROM blobs WHERE sha256=?').run(hash);
|
|
647
|
+
this.queueFile(String(row['path']));
|
|
648
|
+
this.db.exec('RELEASE resource_gc_blob');
|
|
649
|
+
counts.blobs++;
|
|
650
|
+
}
|
|
651
|
+
catch (error) {
|
|
652
|
+
this.db.exec('ROLLBACK TO resource_gc_blob; RELEASE resource_gc_blob');
|
|
653
|
+
if (!String(error).includes('FOREIGN KEY'))
|
|
654
|
+
throw error;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return counts;
|
|
658
|
+
}
|
|
659
|
+
async drainFileDeletions() {
|
|
660
|
+
this.outside();
|
|
661
|
+
let removed = 0, failed = 0;
|
|
662
|
+
const lease = randomUUID();
|
|
663
|
+
const acquired = transaction(this.db, () => {
|
|
664
|
+
if (this.db.prepare("SELECT 1 FROM resource_maintenance_leases WHERE kind='backup' LIMIT 1").get())
|
|
665
|
+
return false;
|
|
666
|
+
this.db.prepare("INSERT INTO resource_maintenance_leases VALUES(?,'drain',?)").run(lease, this.now());
|
|
667
|
+
return true;
|
|
668
|
+
});
|
|
669
|
+
if (!acquired)
|
|
670
|
+
return { removed, failed };
|
|
671
|
+
try {
|
|
672
|
+
for (const row of this.db.prepare('SELECT path FROM resource_file_deletions WHERE completed=0 ORDER BY created_at').all()) {
|
|
673
|
+
const path = String(row['path']);
|
|
674
|
+
if (this.db.prepare('SELECT 1 FROM resource_blob_files WHERE path=? UNION ALL SELECT 1 FROM resource_upload_chunks WHERE path=? LIMIT 1').get(path, path)) {
|
|
675
|
+
failed++;
|
|
676
|
+
continue;
|
|
677
|
+
}
|
|
678
|
+
try {
|
|
679
|
+
await this.blobs.remove(path);
|
|
680
|
+
transaction(this.db, () => this.db.prepare('UPDATE resource_file_deletions SET completed=1 WHERE path=?').run(path));
|
|
681
|
+
removed++;
|
|
682
|
+
}
|
|
683
|
+
catch {
|
|
684
|
+
failed++;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
return { removed, failed };
|
|
688
|
+
}
|
|
689
|
+
finally {
|
|
690
|
+
transaction(this.db, () => this.db.prepare('DELETE FROM resource_maintenance_leases WHERE id=?').run(lease));
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** Global cross-connection GC barrier for online SQLite + immutable-file copies.
|
|
694
|
+
* Existing drains finish first; new drains and logical GC stop at the barrier.
|
|
695
|
+
* Crashed leases fail closed; only an exclusive offline owner may clear them. */
|
|
696
|
+
async withBlobLease(work) {
|
|
697
|
+
this.outside();
|
|
698
|
+
const lease = randomUUID();
|
|
699
|
+
transaction(this.db, () => this.db.prepare("INSERT INTO resource_maintenance_leases VALUES(?,'backup',?)").run(lease, this.now()));
|
|
700
|
+
try {
|
|
701
|
+
const deadline = Date.now() + 5000;
|
|
702
|
+
while (this.db.prepare("SELECT 1 FROM resource_maintenance_leases WHERE kind='drain' LIMIT 1").get()) {
|
|
703
|
+
if (Date.now() >= deadline)
|
|
704
|
+
throw new ServiceError('MAINTENANCE', 'File drain did not quiesce; retry backup after checking maintenance leases');
|
|
705
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
706
|
+
}
|
|
707
|
+
return await work();
|
|
708
|
+
}
|
|
709
|
+
finally {
|
|
710
|
+
transaction(this.db, () => this.db.prepare('DELETE FROM resource_maintenance_leases WHERE id=?').run(lease));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
//# sourceMappingURL=resources.js.map
|