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.
Files changed (178) hide show
  1. package/README.md +64 -0
  2. package/dist/api-image-agent.d.ts +32 -0
  3. package/dist/api-image-agent.js +132 -0
  4. package/dist/api-image-output.d.ts +8 -0
  5. package/dist/api-image-output.js +41 -0
  6. package/dist/api.d.ts +35 -0
  7. package/dist/api.js +153 -0
  8. package/dist/auth.d.ts +58 -0
  9. package/dist/auth.js +273 -0
  10. package/dist/backend/adapter.d.ts +35 -0
  11. package/dist/backend/adapter.js +439 -0
  12. package/dist/backend/capabilities.d.ts +23 -0
  13. package/dist/backend/capabilities.js +75 -0
  14. package/dist/backend/diagnostics.d.ts +9 -0
  15. package/dist/backend/diagnostics.js +80 -0
  16. package/dist/backend/index.d.ts +6 -0
  17. package/dist/backend/index.js +7 -0
  18. package/dist/backend/models.d.ts +15 -0
  19. package/dist/backend/models.js +43 -0
  20. package/dist/backend/paths.d.ts +6 -0
  21. package/dist/backend/paths.js +69 -0
  22. package/dist/backend/stdio.d.ts +66 -0
  23. package/dist/backend/stdio.js +172 -0
  24. package/dist/backend/types.d.ts +90 -0
  25. package/dist/backend/types.js +11 -0
  26. package/dist/backup-api.d.ts +10 -0
  27. package/dist/backup-api.js +40 -0
  28. package/dist/blob-store.d.ts +40 -0
  29. package/dist/blob-store.js +218 -0
  30. package/dist/cli-help.d.ts +1 -0
  31. package/dist/cli-help.js +63 -0
  32. package/dist/cli-output.d.ts +4 -0
  33. package/dist/cli-output.js +130 -0
  34. package/dist/cli.d.ts +2 -0
  35. package/dist/cli.js +284 -0
  36. package/dist/client-pairing.d.ts +8 -0
  37. package/dist/client-pairing.js +30 -0
  38. package/dist/directories.d.ts +14 -0
  39. package/dist/directories.js +34 -0
  40. package/dist/errors.d.ts +12 -0
  41. package/dist/errors.js +7 -0
  42. package/dist/events.d.ts +22 -0
  43. package/dist/events.js +155 -0
  44. package/dist/execution-chains.d.ts +24 -0
  45. package/dist/execution-chains.js +231 -0
  46. package/dist/execution-settings.d.ts +10 -0
  47. package/dist/execution-settings.js +38 -0
  48. package/dist/graph-document-history.d.ts +43 -0
  49. package/dist/graph-document-history.js +275 -0
  50. package/dist/graph-files.d.ts +14 -0
  51. package/dist/graph-files.js +59 -0
  52. package/dist/graph-transfer.d.ts +53 -0
  53. package/dist/graph-transfer.js +501 -0
  54. package/dist/graphs.d.ts +68 -0
  55. package/dist/graphs.js +771 -0
  56. package/dist/history.d.ts +20 -0
  57. package/dist/history.js +61 -0
  58. package/dist/http.d.ts +2 -0
  59. package/dist/http.js +42 -0
  60. package/dist/image-credentials.d.ts +11 -0
  61. package/dist/image-credentials.js +130 -0
  62. package/dist/image-providers.d.ts +51 -0
  63. package/dist/image-providers.js +271 -0
  64. package/dist/input-materialization.d.ts +17 -0
  65. package/dist/input-materialization.js +95 -0
  66. package/dist/inputs.d.ts +74 -0
  67. package/dist/inputs.js +301 -0
  68. package/dist/local-control.d.ts +13 -0
  69. package/dist/local-control.js +37 -0
  70. package/dist/network.d.ts +11 -0
  71. package/dist/network.js +48 -0
  72. package/dist/openai-image-driver.d.ts +42 -0
  73. package/dist/openai-image-driver.js +368 -0
  74. package/dist/operations/backups.d.ts +40 -0
  75. package/dist/operations/backups.js +391 -0
  76. package/dist/operations/capacity.d.ts +7 -0
  77. package/dist/operations/capacity.js +35 -0
  78. package/dist/operations/cli-context.d.ts +7 -0
  79. package/dist/operations/cli-context.js +58 -0
  80. package/dist/operations/daemon.d.ts +36 -0
  81. package/dist/operations/daemon.js +159 -0
  82. package/dist/operations/files.d.ts +12 -0
  83. package/dist/operations/files.js +84 -0
  84. package/dist/operations/lifecycle.d.ts +28 -0
  85. package/dist/operations/lifecycle.js +155 -0
  86. package/dist/operations/local-admin.d.ts +6 -0
  87. package/dist/operations/local-admin.js +38 -0
  88. package/dist/operations/logs.d.ts +16 -0
  89. package/dist/operations/logs.js +37 -0
  90. package/dist/operations/plugins.d.ts +10 -0
  91. package/dist/operations/plugins.js +11 -0
  92. package/dist/operations/snapshot-worker.d.ts +1 -0
  93. package/dist/operations/snapshot-worker.js +18 -0
  94. package/dist/operations/snapshot.d.ts +1 -0
  95. package/dist/operations/snapshot.js +14 -0
  96. package/dist/operations/update.d.ts +26 -0
  97. package/dist/operations/update.js +216 -0
  98. package/dist/persistence/asset-business-schema.d.ts +2 -0
  99. package/dist/persistence/asset-business-schema.js +36 -0
  100. package/dist/persistence/database.d.ts +7 -0
  101. package/dist/persistence/database.js +91 -0
  102. package/dist/persistence/plugin-schema.d.ts +9 -0
  103. package/dist/persistence/plugin-schema.js +38 -0
  104. package/dist/persistence/repositories.d.ts +20 -0
  105. package/dist/persistence/repositories.js +106 -0
  106. package/dist/persistence/resource-schema.d.ts +2 -0
  107. package/dist/persistence/resource-schema.js +44 -0
  108. package/dist/persistence/schema.d.ts +6 -0
  109. package/dist/persistence/schema.js +236 -0
  110. package/dist/persistence/workflow-schema.d.ts +2 -0
  111. package/dist/persistence/workflow-schema.js +19 -0
  112. package/dist/plugin-api.d.ts +10 -0
  113. package/dist/plugin-api.js +40 -0
  114. package/dist/plugin-management.d.ts +36 -0
  115. package/dist/plugin-management.js +112 -0
  116. package/dist/plugins.d.ts +55 -0
  117. package/dist/plugins.js +239 -0
  118. package/dist/project-discovery.d.ts +3 -0
  119. package/dist/project-discovery.js +57 -0
  120. package/dist/project-files-api.d.ts +16 -0
  121. package/dist/project-files-api.js +243 -0
  122. package/dist/project-files.d.ts +97 -0
  123. package/dist/project-files.js +557 -0
  124. package/dist/project-lock.d.ts +4 -0
  125. package/dist/project-lock.js +35 -0
  126. package/dist/project-probe-worker.d.ts +1 -0
  127. package/dist/project-probe-worker.js +17 -0
  128. package/dist/project-probe.d.ts +22 -0
  129. package/dist/project-probe.js +67 -0
  130. package/dist/projects.d.ts +29 -0
  131. package/dist/projects.js +219 -0
  132. package/dist/publication.d.ts +31 -0
  133. package/dist/publication.js +642 -0
  134. package/dist/resource-api.d.ts +15 -0
  135. package/dist/resource-api.js +256 -0
  136. package/dist/resources-orphans.d.ts +41 -0
  137. package/dist/resources-orphans.js +235 -0
  138. package/dist/resources.d.ts +246 -0
  139. package/dist/resources.js +714 -0
  140. package/dist/run-api.d.ts +10 -0
  141. package/dist/run-api.js +308 -0
  142. package/dist/run-context.d.ts +54 -0
  143. package/dist/run-context.js +137 -0
  144. package/dist/runs.d.ts +82 -0
  145. package/dist/runs.js +506 -0
  146. package/dist/runtime-backend.d.ts +24 -0
  147. package/dist/runtime-backend.js +214 -0
  148. package/dist/runtime.d.ts +97 -0
  149. package/dist/runtime.js +553 -0
  150. package/dist/scheduler.d.ts +85 -0
  151. package/dist/scheduler.js +344 -0
  152. package/dist/service.d.ts +29 -0
  153. package/dist/service.js +515 -0
  154. package/dist/skills-api.d.ts +18 -0
  155. package/dist/skills-api.js +120 -0
  156. package/dist/storage-api.d.ts +21 -0
  157. package/dist/storage-api.js +92 -0
  158. package/dist/transfer-api.d.ts +10 -0
  159. package/dist/transfer-api.js +60 -0
  160. package/dist/vendor/protocol/contracts.typecheck.d.ts +1 -0
  161. package/dist/vendor/protocol/contracts.typecheck.js +35 -0
  162. package/dist/vendor/protocol/execution-chain.d.ts +6 -0
  163. package/dist/vendor/protocol/execution-chain.js +26 -0
  164. package/dist/vendor/protocol/graph-binary.d.ts +15 -0
  165. package/dist/vendor/protocol/graph-binary.js +121 -0
  166. package/dist/vendor/protocol/graph-title.d.ts +2 -0
  167. package/dist/vendor/protocol/graph-title.js +23 -0
  168. package/dist/vendor/protocol/index.d.ts +617 -0
  169. package/dist/vendor/protocol/index.js +20 -0
  170. package/dist/vendor/protocol/pairing.d.ts +29 -0
  171. package/dist/vendor/protocol/pairing.js +71 -0
  172. package/dist/vendor/protocol/preview.d.ts +2 -0
  173. package/dist/vendor/protocol/preview.js +9 -0
  174. package/dist/vendor/protocol/resource-mime.d.ts +2 -0
  175. package/dist/vendor/protocol/resource-mime.js +35 -0
  176. package/dist/video-thumbnail.d.ts +2 -0
  177. package/dist/video-thumbnail.js +34 -0
  178. package/package.json +9 -0
@@ -0,0 +1,15 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import { Auth } from './auth.js';
3
+ import { Resources } from './resources.js';
4
+ import { Graphs } from './graphs.js';
5
+ import type { ApiResult } from './api.js';
6
+ export declare class ResourceApi {
7
+ readonly resources: Resources;
8
+ readonly graphs: Graphs;
9
+ readonly auth: Auth;
10
+ constructor(resources: Resources, graphs: Graphs, auth: Auth);
11
+ private wire;
12
+ handle(request: IncomingMessage, path: string, token: string, origin: string): Promise<ApiResult>;
13
+ private content;
14
+ private binary;
15
+ }
@@ -0,0 +1,256 @@
1
+ import { Auth } from './auth.js';
2
+ import { Resources } from './resources.js';
3
+ import { Graphs, assertProjectWritable } from './graphs.js';
4
+ import { readJson } from './http.js';
5
+ import { ServiceError } from './errors.js';
6
+ import { Repositories } from './persistence/repositories.js';
7
+ import { transaction } from './persistence/database.js';
8
+ const invalid = (message) => { throw new ServiceError('INVALID_REQUEST', message); };
9
+ const notFound = () => { throw new ServiceError('NOT_FOUND', '资源接口不存在。'); };
10
+ function string(value, max = 512) { if (typeof value !== 'string' || !value.trim() || value.length > max || [...value].some(c => c.charCodeAt(0) < 32))
11
+ return invalid('需要有效且长度受限的字符串。'); return value; }
12
+ function id(value) { const result = string(value, 128); if (!/^[a-zA-Z0-9_-]+$/.test(result))
13
+ invalid('无效的标识符。'); return result; }
14
+ function integer(value, min = 0, max = Number.MAX_SAFE_INTEGER) { if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < min || value > max)
15
+ return invalid('整数超出允许范围。'); return value; }
16
+ function version(value) { if (!/^[1-9][0-9]*$/.test(value))
17
+ invalid('版本须为规范正整数。'); return integer(Number(value), 1); }
18
+ function fields(body, required, optional = []) {
19
+ if (!body || required.some(key => !Object.hasOwn(body, key)) || Object.keys(body).some(key => !required.includes(key) && !optional.includes(key)))
20
+ return invalid('请求字段缺失或包含未知字段。');
21
+ return body;
22
+ }
23
+ export class ResourceApi {
24
+ resources;
25
+ graphs;
26
+ auth;
27
+ constructor(resources, graphs, auth) {
28
+ this.resources = resources;
29
+ this.graphs = graphs;
30
+ this.auth = auth;
31
+ }
32
+ wire(value) {
33
+ if (Array.isArray(value))
34
+ return value.map(item => this.wire(item));
35
+ if (value && typeof value === 'object') {
36
+ const result = {};
37
+ for (const [key, item] of Object.entries(value)) {
38
+ if (item !== undefined)
39
+ result[key] = this.wire(item);
40
+ }
41
+ if (typeof result['projectId'] === 'string')
42
+ result['serviceId'] = this.graphs.serviceId;
43
+ return result;
44
+ }
45
+ return value;
46
+ }
47
+ async handle(request, path, token, origin) {
48
+ const parts = path.split('/');
49
+ if (parts[0] !== '' || parts[1] !== 'v1' || parts[2] !== 'projects' || !['uploads', 'assets', 'graphs'].includes(parts[4] ?? ''))
50
+ return { handled: false };
51
+ const section = parts[4];
52
+ if (section === 'graphs' && parts[6] !== 'resources')
53
+ return { handled: false };
54
+ // Do not collapse empty segments, decode aliases, or ignore trailing segments.
55
+ const projectId = id(parts[3]);
56
+ const tail = parts.slice(5);
57
+ for (const segment of tail)
58
+ id(segment);
59
+ const scope = { serviceId: this.graphs.serviceId, projectId };
60
+ const session = this.auth.withSession(token, origin, s => s);
61
+ const method = request.method;
62
+ if (method !== 'GET' && method !== 'POST')
63
+ return notFound();
64
+ const body = method === 'POST' ? await readJson(request, 6 * 1024 * 1024) : undefined;
65
+ const read = (work) => this.auth.withSession(token, origin, work);
66
+ const reply = (value) => ({ handled: true, body: this.wire(value) });
67
+ const repo = new Repositories(this.resources.db);
68
+ const cleanups = [];
69
+ const prepare = async (promise) => { const blob = await promise; cleanups.push(() => this.resources.discardPrepared(blob)); return blob; };
70
+ const mutation = async (required, optional, work) => {
71
+ const input = fields(body, [...required, 'idempotencyKey'], optional), key = string(input['idempotencyKey'], 256), namespace = session.id + ':' + path;
72
+ const replay = () => read(() => { assertProjectWritable(this.resources.db, projectId); return repo.replay(namespace, key, input); });
73
+ const prior = replay();
74
+ if (prior !== undefined) {
75
+ await this.resources.drainFileDeletions();
76
+ this.auth.assertActive(token, origin);
77
+ return reply(prior);
78
+ }
79
+ try {
80
+ const commit = await work(input);
81
+ const result = read(() => repo.idempotent(namespace, key, input, () => { assertProjectWritable(this.resources.db, projectId); const value = commit(); this.resources.collectGarbage(); return this.wire(value); }));
82
+ return reply(result);
83
+ }
84
+ catch (error) {
85
+ const duplicate = replay();
86
+ if (duplicate !== undefined)
87
+ return reply(duplicate);
88
+ throw error;
89
+ }
90
+ finally {
91
+ // Capability-only cleanup still works after revocation or deactivation.
92
+ if (cleanups.length)
93
+ transaction(this.resources.db, () => { for (const cleanup of cleanups)
94
+ cleanup(); });
95
+ await this.resources.drainFileDeletions();
96
+ this.auth.assertActive(token, origin);
97
+ }
98
+ };
99
+ if (section === 'uploads') {
100
+ const [uploadId, action] = tail;
101
+ if (method === 'GET' && tail.length === 1)
102
+ return reply(read(() => this.resources.uploadStatus(scope, uploadId)));
103
+ if (method === 'POST' && tail.length === 0)
104
+ return mutation(['name', 'mime', 'bytes'], ['sha256'], b => {
105
+ const name = string(b['name']), mime = string(b['mime'], 128), bytes = integer(b['bytes']);
106
+ const sha256 = b['sha256'] === undefined ? undefined : string(b['sha256'], 64);
107
+ return () => this.resources.startUpload({ ...scope, name, mime, bytes, ...(sha256 === undefined ? {} : { sha256 }) });
108
+ });
109
+ if (method === 'POST' && tail.length === 2 && action === 'chunks')
110
+ return mutation(['data', 'offset'], [], async (b) => {
111
+ const encoded = string(b['data'], Math.ceil(4 * 1024 * 1024 / 3) * 4), offset = integer(b['offset']);
112
+ const bytes = Buffer.from(encoded, 'base64');
113
+ if (!bytes.length || bytes.toString('base64') !== encoded)
114
+ invalid('分块须为规范 Base64。');
115
+ const prepared = await this.resources.prepareAppend(scope, uploadId, offset, bytes);
116
+ cleanups.push(() => this.resources.discardAppend(scope, prepared));
117
+ return () => this.resources.commitAppend(scope, prepared);
118
+ });
119
+ if (method === 'POST' && tail.length === 2 && action === 'finish') {
120
+ const mode = body?.['mode'];
121
+ if (mode !== 'new' && mode !== 'update' && mode !== 'canvas')
122
+ invalid('无效的上传入口。');
123
+ const required = mode === 'canvas' ? ['mode', 'name', 'graphId'] : mode === 'update' ? ['mode', 'name', 'assetId', 'expectedVersion'] : ['mode', 'name'];
124
+ return mutation(required, [], async (b) => {
125
+ const name = string(b['name']);
126
+ const target = mode === 'canvas' ? { ...scope, mode: 'canvas', graphId: id(b['graphId']), name } : mode === 'update' ? { ...scope, mode: 'update', name, assetId: id(b['assetId']), expectedVersion: integer(b['expectedVersion'], 1) } : { ...scope, mode: 'new', name };
127
+ if (target.mode === 'canvas')
128
+ read(() => this.graphs.writable({ ...target, serviceId: this.graphs.serviceId }));
129
+ const prepared = await this.resources.prepareFinish(scope, uploadId);
130
+ cleanups.push(() => this.resources.discardPrepared(prepared.blob));
131
+ return () => this.resources.commitFinish(scope, prepared, target);
132
+ });
133
+ }
134
+ if (method === 'POST' && tail.length === 2 && action === 'cancel')
135
+ return mutation([], [], () => () => { this.resources.cancelUpload(scope, uploadId); return { cancelled: true }; });
136
+ }
137
+ if (section === 'assets') {
138
+ const [assetId, action] = tail;
139
+ if (method === 'POST' && tail.length === 1 && assetId === 'empty-trash')
140
+ return mutation([], [], () => () => this.resources.emptyTrash(scope));
141
+ if (method === 'GET' && tail.length === 0)
142
+ return reply(read(() => this.resources.listAssets(scope)));
143
+ if (method === 'POST' && tail.length === 1 && assetId === 'query') {
144
+ const b = fields(body, [], ['name', 'search', 'deleted', 'scope', 'limit', 'offset']);
145
+ if (b['name'] !== undefined)
146
+ string(b['name']);
147
+ if (b['search'] !== undefined)
148
+ string(b['search']);
149
+ if (b['deleted'] !== undefined && typeof b['deleted'] !== 'boolean')
150
+ invalid('deleted 须为布尔值。');
151
+ if (b['scope'] !== undefined && (typeof b['scope'] !== 'string' || !['project', 'shared', 'available'].includes(b['scope'])))
152
+ invalid('无效的查询范围。');
153
+ if (b['limit'] !== undefined)
154
+ integer(b['limit'], 1, 1000);
155
+ if (b['offset'] !== undefined)
156
+ integer(b['offset']);
157
+ return reply(read(() => this.resources.listAssets(scope, b)));
158
+ }
159
+ if (method === 'POST' && tail.length === 1 && assetId === 'same-name') {
160
+ const b = fields(body, ['name']);
161
+ const name = string(b['name']);
162
+ return reply(read(() => this.resources.listSameNameAssets(scope, name)));
163
+ }
164
+ if (method === 'GET' && tail.length === 1)
165
+ return reply(read(() => this.resources.getAsset(scope, assetId)));
166
+ if (method === 'POST' && tail.length === 2 && action === 'delete')
167
+ return mutation([], [], () => () => this.resources.softDelete(scope, assetId));
168
+ if (method === 'POST' && tail.length === 2 && action === 'restore')
169
+ return mutation([], [], () => () => this.resources.restore(scope, assetId));
170
+ if (method === 'POST' && tail.length === 2 && action === 'share')
171
+ return mutation(['shared'], [], b => { const shared = b['shared']; if (typeof shared !== 'boolean')
172
+ return invalid('shared 须为布尔值。'); return () => this.resources.setShared(scope, assetId, shared); });
173
+ if (method === 'POST' && tail.length === 2 && action === 'copy-to-project')
174
+ return mutation(['targetProjectId', 'expectedVersion'], ['name'], b => {
175
+ const targetProjectId = id(b['targetProjectId']), expectedVersion = integer(b['expectedVersion'], 1), name = b['name'] === undefined ? undefined : string(b['name']);
176
+ return () => { this.resources.getAsset(scope, assetId); return this.resources.copyAssetToProject({ ...scope, projectId: targetProjectId }, assetId, expectedVersion, name); };
177
+ });
178
+ if (method === 'POST' && tail.length === 1 && assetId === 'import-file')
179
+ return mutation(['path', 'mime', 'name'], [], async (b) => {
180
+ const path = string(b['path'], 4096), mime = string(b['mime'], 128), name = string(b['name']);
181
+ const blob = await prepare(this.resources.prepareProjectFile(scope, path, mime));
182
+ return () => this.resources.createLibraryFromPrepared({ ...scope, mode: 'new', name }, blob);
183
+ });
184
+ if (method === 'GET' && (tail.length === 3 || tail.length === 4) && action === 'versions')
185
+ return this.content(request, scope, 'asset', assetId, version(tail[2]), tail[3], read);
186
+ }
187
+ if (section === 'graphs') {
188
+ const graphId = tail[0], action = tail[2], graphScope = { ...scope, graphId };
189
+ if (method === 'GET' && tail.length === 2)
190
+ return reply(read(() => this.resources.listCanvas(graphScope)));
191
+ if (method === 'POST' && tail.length === 3 && action === 'import-file')
192
+ return mutation(['path', 'mime', 'name'], ['sourceProjectId'], async (b) => {
193
+ const path = string(b['path'], 4096), mime = string(b['mime'], 128), name = string(b['name']);
194
+ const sourceProjectId = b['sourceProjectId'] === undefined ? scope.projectId : string(b['sourceProjectId'], 128);
195
+ read(() => this.graphs.writable(graphScope));
196
+ const blob = await prepare(this.resources.prepareProjectFile({ ...scope, projectId: sourceProjectId }, path, mime));
197
+ return () => this.resources.createCanvasFromPrepared(graphScope, blob, name);
198
+ });
199
+ if (method === 'POST' && tail.length === 3 && action === 'copy-asset')
200
+ return mutation(['assetId', 'expectedVersion'], [], b => { const assetId = id(b['assetId']), expectedVersion = integer(b['expectedVersion'], 1); return () => this.resources.copyAssetToCanvas(graphScope, assetId, expectedVersion); });
201
+ if (method === 'POST' && tail.length === 3 && action === 'copy-resource')
202
+ return mutation(['sourceGraphId', 'resourceId', 'version'], [], b => { const sourceGraphId = id(b['sourceGraphId']), resourceId = id(b['resourceId']), sourceVersion = integer(b['version'], 1); return () => this.resources.copyCanvasToCanvas(graphScope, sourceGraphId, resourceId, sourceVersion); });
203
+ if (method === 'POST' && tail.length === 3 && action === 'save-to-library')
204
+ return mutation(['resourceId', 'expectedVersion', 'name'], [], b => { const resourceId = id(b['resourceId']), expectedVersion = integer(b['expectedVersion'], 1), name = string(b['name']); return () => this.resources.saveCanvasToLibrary(graphScope, resourceId, expectedVersion, name); });
205
+ if (method === 'POST' && tail.length === 3 && action === 'release')
206
+ return mutation(['referenceId'], [], b => { const referenceId = id(b['referenceId']); return () => { this.resources.releaseGraphReference(graphScope, referenceId); return { released: true }; }; });
207
+ if (method === 'GET' && (tail.length === 5 || tail.length === 6) && tail[3] === 'versions')
208
+ return this.content(request, graphScope, 'canvas', action, version(tail[4]), tail[5], read);
209
+ }
210
+ return notFound();
211
+ }
212
+ async content(request, scope, kind, id, version, action, read) {
213
+ const identity = { ...scope, serviceId: this.graphs.serviceId, ...(kind === 'asset' ? { assetId: id } : { resourceId: id }) };
214
+ if (action === undefined)
215
+ return { handled: true, body: read(() => ({ ...identity, ...(kind === 'asset' ? this.resources.readAssetVersion(scope, id, version) : this.resources.readCanvasVersion(scope, id, version)) })) };
216
+ if (action === 'representation') {
217
+ const result = await this.resources.representation(scope, kind, id, version);
218
+ return { handled: true, body: read(() => ({ ...identity, ...result })) };
219
+ }
220
+ if (action === 'thumbnail') {
221
+ if (request.headers.range)
222
+ invalid('缩略图不接受 Range。');
223
+ const requestedSize = request.url ? new URL(request.url, 'http://localhost').searchParams.get('size') : null;
224
+ const size = requestedSize === null ? 320 : integer(Number(requestedSize));
225
+ if (![80, 160, 320, 640, 1280, 2560, 4096].includes(size))
226
+ invalid('无效的缩略图尺寸。');
227
+ const result = await this.resources.thumbnail(scope, kind, id, version, size);
228
+ return read(() => { const response = this.binary(result, false, result.cacheable !== false); if (result.cacheable === false)
229
+ response.headers = { ...response.headers, 'Cache-Control': 'no-cache' }; return response; });
230
+ }
231
+ if (action === 'preview') {
232
+ if (request.headers.range)
233
+ invalid('预览表示不接受 Range;媒体范围读取请使用 content。');
234
+ const result = await this.resources.preview(scope, kind, id, version);
235
+ if (!('content' in result))
236
+ return { handled: true, body: read(() => ({ ...identity, ...result })) };
237
+ return read(() => this.binary(result.content, false));
238
+ }
239
+ if (action !== 'content')
240
+ return notFound();
241
+ let range;
242
+ if (request.headers.range) {
243
+ const match = /^bytes=([0-9]+)-([0-9]*)$/.exec(request.headers.range);
244
+ if (!match)
245
+ return invalid('仅支持单个明确起点的字节范围。');
246
+ range = { start: integer(Number(match[1])), ...(match[2] ? { end: integer(Number(match[2])) } : {}) };
247
+ }
248
+ const result = await this.resources.readContent(scope, kind, id, version, range);
249
+ return read(() => this.binary(result, range !== undefined, !range && (result.mime.startsWith('image/') || result.mime.startsWith('video/'))));
250
+ }
251
+ binary(content, ranged, cacheable = false) {
252
+ const svg = content.mime === 'image/svg+xml';
253
+ return { handled: true, binary: content.bytes, status: ranged ? 206 : 200, headers: { 'Content-Type': content.mime, 'Content-Length': String(content.bytes.length), 'Accept-Ranges': 'bytes', 'X-Content-Type-Options': 'nosniff', ...(cacheable ? { 'Cache-Control': 'private, max-age=31536000, immutable' } : {}), 'Content-Disposition': !svg && (content.mime.startsWith('image/') || content.mime.startsWith('video/')) ? 'inline' : 'attachment', ...(svg ? { 'Content-Security-Policy': "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data: blob:;" } : {}), ...(ranged ? { 'Content-Range': 'bytes ' + content.start + '-' + content.end + '/' + content.total } : {}) } };
254
+ }
255
+ }
256
+ //# sourceMappingURL=resource-api.js.map
@@ -0,0 +1,41 @@
1
+ /** Deliberately offline, opt-in only. A crash before SQL registration is NOT
2
+ * covered by ordinary discardPrepared. This policy scans only current immutable
3
+ * generation files (blobs/{objects,chunks}/UUID), never legacy staging, backups,
4
+ * runs, unknown directories or unknown names. Default grace is 24 hours.
5
+ * Any recorded path/hash, hardlink or unfinished backup is retained/fails closed.
6
+ * Preview tokens are local to one uninterrupted exclusive maintenance session.
7
+ * No schema migration, reference deletion or automatic startup sweep occurs. */
8
+ export interface OrphanOptions {
9
+ minAgeMs?: number;
10
+ maxEntries?: number;
11
+ maxFileBytes?: number;
12
+ now?: () => number;
13
+ }
14
+ export interface OrphanCandidate {
15
+ readonly path: string;
16
+ readonly bytes: number;
17
+ readonly sha256: string;
18
+ }
19
+ export interface OrphanPreview {
20
+ readonly id: string;
21
+ readonly root: string;
22
+ readonly candidates: readonly OrphanCandidate[];
23
+ readonly retained: readonly {
24
+ path: string;
25
+ reason: string;
26
+ }[];
27
+ }
28
+ export interface OrphanCleanup {
29
+ removed: string[];
30
+ retained: string[];
31
+ failed: string[];
32
+ }
33
+ export interface OrphanMaintenance {
34
+ preview(): Promise<OrphanPreview>;
35
+ cleanup(preview: OrphanPreview): Promise<OrphanCleanup>;
36
+ }
37
+ /** Existing data only. Acquires the same kernel lock as serve BEFORE opening DB;
38
+ * never accepts caller-forged held flags, never migrates, never publishes a server.
39
+ * Escaped controller handles reject after this callback and in-flight calls drain
40
+ * before the DB/lock are closed. Cleanup must be explicitly requested in callback. */
41
+ export declare function withOfflineResourceOrphans<T>(dataDir: string, work: (maintenance: OrphanMaintenance) => Promise<T>, options?: OrphanOptions): Promise<T>;
@@ -0,0 +1,235 @@
1
+ import { DatabaseSync } from 'node:sqlite';
2
+ import { constants } from 'node:fs';
3
+ import { lstat, open, readdir } from 'node:fs/promises';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { join, resolve, sep } from 'node:path';
6
+ import { BlobStore } from './blob-store.js';
7
+ import { ServiceError } from './errors.js';
8
+ import { transaction } from './persistence/database.js';
9
+ import { SCHEMA_VERSION } from './persistence/schema.js';
10
+ import { acquireOperationsLock, rootPath } from './operations/lifecycle.js';
11
+ import { safePath } from './operations/files.js';
12
+ const fail = (message) => { throw new ServiceError('MAINTENANCE', message); };
13
+ const generation = /^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/;
14
+ function stamp(stat) { return [stat.dev, stat.ino, stat.size, stat.mtimeMs, stat.ctimeMs, stat.nlink].join(':'); }
15
+ function option(value, min) { if (!Number.isSafeInteger(value) || value < min)
16
+ throw new ServiceError('INVALID_REQUEST', 'Invalid orphan scan bound'); return value; }
17
+ class Maintenance {
18
+ db;
19
+ lock;
20
+ tokens = new WeakMap();
21
+ chain = Promise.resolve();
22
+ closing = false;
23
+ minAge;
24
+ maxEntries;
25
+ maxBytes;
26
+ now;
27
+ store;
28
+ constructor(db, lock, options) {
29
+ this.db = db;
30
+ this.lock = lock;
31
+ this.minAge = option(options.minAgeMs ?? 86400000, 0);
32
+ this.maxEntries = option(options.maxEntries ?? 10000, 1);
33
+ this.maxBytes = option(options.maxFileBytes ?? 512 * 1024 * 1024, 1);
34
+ this.now = options.now ?? Date.now;
35
+ this.store = new BlobStore(join(lock.root, 'blobs'));
36
+ }
37
+ assertOwned() {
38
+ if (!this.lock.held || this.lock.record.serviceId || this.db.prepare('PRAGMA database_list').all().find(row => row['name'] === 'main')?.['file'] !== join(this.lock.root, 'service.sqlite'))
39
+ fail('Matching unpublished exclusive maintenance ownership required');
40
+ if (this.db.isTransaction)
41
+ fail('Orphan filesystem operations cannot run in a transaction');
42
+ }
43
+ run(work) {
44
+ if (this.closing)
45
+ return Promise.reject(new ServiceError('MAINTENANCE', 'Maintenance session is closed'));
46
+ const pending = this.chain.then(() => { this.assertOwned(); return work(); });
47
+ this.chain = pending.catch(() => { });
48
+ return pending;
49
+ }
50
+ async close() { this.closing = true; await this.chain; }
51
+ references() {
52
+ if (this.db.prepare('SELECT 1 FROM resource_maintenance_leases LIMIT 1').get() || this.db.prepare("SELECT 1 FROM backups WHERE state='creating' LIMIT 1").get())
53
+ fail('Unresolved maintenance/backup record; preserve it and recover separately before orphan cleanup');
54
+ const result = { paths: new Set(), hashes: new Set() };
55
+ for (const row of this.db.prepare('SELECT path FROM resource_blob_files UNION SELECT path FROM resource_upload_chunks UNION SELECT path FROM resource_file_deletions UNION SELECT path FROM backups WHERE path IS NOT NULL').all())
56
+ result.paths.add(resolve(this.lock.root, String(row['path'])));
57
+ for (const row of this.db.prepare("SELECT sha256 FROM blobs UNION SELECT sha256 FROM resource_upload_chunks UNION SELECT sha256 FROM resource_uploads WHERE sha256 IS NOT NULL UNION SELECT expected_sha256 AS sha256 FROM resource_uploads WHERE expected_sha256 IS NOT NULL UNION SELECT sha256 FROM resource_read_leases UNION SELECT sha256 FROM backups WHERE sha256 IS NOT NULL UNION SELECT j.value AS sha256 FROM snapshots s,json_tree(s.payload) j WHERE j.type='text' AND length(j.value)=64").all())
58
+ result.hashes.add(String(row['sha256']));
59
+ return result;
60
+ }
61
+ protected(path, hash, refs) { return [...refs.paths].some(owned => path === owned || path.startsWith(owned + sep)) || (hash !== undefined && refs.hashes.has(hash)); }
62
+ async inspect(path) {
63
+ await safePath(path);
64
+ const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
65
+ try {
66
+ const before = await file.stat();
67
+ if (!before.isFile() || before.nlink !== 1 || before.size > this.maxBytes)
68
+ fail('Generation file changed or is not a bounded single-link file');
69
+ const hash = createHash('sha256');
70
+ let bytes = 0;
71
+ const buffer = Buffer.alloc(64 * 1024);
72
+ while (true) {
73
+ const read = await file.read(buffer, 0, buffer.length, null);
74
+ if (!read.bytesRead)
75
+ break;
76
+ bytes += read.bytesRead;
77
+ if (bytes > this.maxBytes)
78
+ fail('Generation file grew beyond scan bound');
79
+ hash.update(buffer.subarray(0, read.bytesRead));
80
+ }
81
+ const after = await file.stat();
82
+ if (stamp(before) !== stamp(after) || bytes !== before.size)
83
+ fail('Generation file changed during preview');
84
+ return { path, bytes, sha256: hash.digest('hex'), stamp: stamp(after) };
85
+ }
86
+ finally {
87
+ await file.close();
88
+ }
89
+ }
90
+ async scan() {
91
+ this.assertOwned();
92
+ const refs = this.references();
93
+ const result = { candidates: [], retained: [] };
94
+ let entries = 0;
95
+ const cutoff = this.now() - this.minAge;
96
+ for (const kind of ['objects', 'chunks']) {
97
+ const directory = join(this.lock.root, 'blobs', kind);
98
+ try {
99
+ await safePath(directory);
100
+ }
101
+ catch (error) {
102
+ if (error.code === 'ENOENT')
103
+ continue;
104
+ throw error;
105
+ }
106
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
107
+ if (++entries > this.maxEntries)
108
+ throw new ServiceError('PAYLOAD_TOO_LARGE', 'Orphan scan entry bound exceeded; no cleanup performed');
109
+ const path = join(directory, entry.name);
110
+ await safePath(path);
111
+ const stat = await lstat(path);
112
+ let reason;
113
+ if (!stat.isFile() || !generation.test(entry.name))
114
+ reason = 'unknown-layout';
115
+ else if (stat.nlink !== 1)
116
+ reason = 'hardlink';
117
+ else if (this.protected(path, undefined, refs))
118
+ reason = 'recorded-path';
119
+ else if (Math.max(stat.mtimeMs, stat.ctimeMs) > cutoff)
120
+ reason = 'grace-period';
121
+ else if (stat.size > this.maxBytes)
122
+ reason = 'oversize';
123
+ if (reason) {
124
+ result.retained.push({ path, reason });
125
+ continue;
126
+ }
127
+ const candidate = await this.inspect(path);
128
+ if (this.protected(path, candidate.sha256, refs))
129
+ result.retained.push({ path, reason: 'recorded-hash' });
130
+ else
131
+ result.candidates.push(candidate);
132
+ }
133
+ }
134
+ this.assertOwned();
135
+ return result;
136
+ }
137
+ preview() {
138
+ return this.run(async () => {
139
+ const result = await this.scan();
140
+ const preview = Object.freeze({ id: randomUUID(), root: this.lock.root, candidates: Object.freeze(result.candidates.map(({ path, bytes, sha256 }) => Object.freeze({ path, bytes, sha256 }))), retained: Object.freeze(result.retained.map(row => Object.freeze(row))) });
141
+ this.tokens.set(preview, result.candidates);
142
+ return preview;
143
+ });
144
+ }
145
+ cleanup(preview) {
146
+ return this.run(async () => {
147
+ const approved = this.tokens.get(preview);
148
+ if (!approved)
149
+ throw new ServiceError('INVALID_REQUEST', 'Use an unchanged preview from this exclusive maintenance session');
150
+ // Full rescan rejects symlinks or backup blockers before queuing any deletion.
151
+ const current = new Map((await this.scan()).candidates.map(row => [row.path, row]));
152
+ const eligible = [];
153
+ const result = { removed: [], retained: [], failed: [] };
154
+ for (const old of approved) {
155
+ const latest = current.get(old.path);
156
+ if (!latest || latest.stamp !== old.stamp || latest.sha256 !== old.sha256)
157
+ result.retained.push(old.path);
158
+ else
159
+ eligible.push(latest);
160
+ }
161
+ this.assertOwned();
162
+ const claimed = transaction(this.db, () => {
163
+ const refs = this.references();
164
+ const rows = [];
165
+ for (const candidate of eligible) {
166
+ if (this.protected(candidate.path, candidate.sha256, refs)) {
167
+ result.retained.push(candidate.path);
168
+ continue;
169
+ }
170
+ this.db.prepare('INSERT INTO resource_file_deletions(path,created_at,completed) VALUES(?,?,0)').run(candidate.path, this.now());
171
+ rows.push(candidate);
172
+ }
173
+ return rows;
174
+ });
175
+ this.tokens.delete(preview);
176
+ for (const candidate of claimed) {
177
+ try {
178
+ this.assertOwned();
179
+ await safePath(candidate.path);
180
+ const current = await lstat(candidate.path);
181
+ if (stamp(current) !== candidate.stamp) {
182
+ // Do not let a later online drain delete a replacement file either.
183
+ transaction(this.db, () => this.db.prepare('UPDATE resource_file_deletions SET completed=1 WHERE path=?').run(candidate.path));
184
+ result.retained.push(candidate.path);
185
+ continue;
186
+ }
187
+ await this.store.remove(candidate.path);
188
+ transaction(this.db, () => this.db.prepare('UPDATE resource_file_deletions SET completed=1 WHERE path=?').run(candidate.path));
189
+ result.removed.push(candidate.path);
190
+ }
191
+ catch {
192
+ result.failed.push(candidate.path);
193
+ }
194
+ }
195
+ return result;
196
+ });
197
+ }
198
+ }
199
+ /** Existing data only. Acquires the same kernel lock as serve BEFORE opening DB;
200
+ * never accepts caller-forged held flags, never migrates, never publishes a server.
201
+ * Escaped controller handles reject after this callback and in-flight calls drain
202
+ * before the DB/lock are closed. Cleanup must be explicitly requested in callback. */
203
+ export async function withOfflineResourceOrphans(dataDir, work, options = {}) {
204
+ const root = await safePath(rootPath(dataDir));
205
+ if (!(await lstat(root)).isDirectory())
206
+ fail('Existing data directory required');
207
+ const lock = await acquireOperationsLock(root);
208
+ let db;
209
+ let maintenance;
210
+ try {
211
+ const path = await safePath(join(lock.root, 'service.sqlite'));
212
+ if (!(await lstat(path)).isFile())
213
+ fail('Existing regular database required');
214
+ db = new DatabaseSync(path, { allowExtension: false, timeout: 5000, enableForeignKeyConstraints: true });
215
+ if (db.prepare('PRAGMA user_version').get()?.['user_version'] !== SCHEMA_VERSION)
216
+ fail('Matching resource schema required; orphan maintenance never migrates');
217
+ if (db.prepare('PRAGMA quick_check').get()?.['quick_check'] !== 'ok' || db.prepare('PRAGMA foreign_key_check').get())
218
+ fail('Database integrity check failed');
219
+ for (const table of ['blobs', 'resource_blob_files', 'resource_uploads', 'resource_upload_chunks', 'resource_file_deletions', 'resource_read_leases', 'resource_maintenance_leases', 'backups', 'snapshots'])
220
+ if (!db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(table))
221
+ fail('Incomplete resource schema');
222
+ maintenance = new Maintenance(db, lock, options);
223
+ return await work(maintenance);
224
+ }
225
+ finally {
226
+ try {
227
+ await maintenance?.close();
228
+ db?.close();
229
+ }
230
+ finally {
231
+ await lock.release();
232
+ }
233
+ }
234
+ }
235
+ //# sourceMappingURL=resources-orphans.js.map