@southwind-ai/storage 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,133 @@
1
+ import { readdir, rm, stat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import {
4
+ artifactRef,
5
+ type ArtifactRef,
6
+ type BlobStoreMaintenance,
7
+ } from "./blob-store.js";
8
+
9
+ export class LocalFileBlobMaintenance implements BlobStoreMaintenance {
10
+ constructor(private readonly root: string) {}
11
+
12
+ async cleanupOrphans(input: {
13
+ referencedRefs: ReadonlySet<ArtifactRef>;
14
+ olderThan: Date;
15
+ limit: number;
16
+ }) {
17
+ let cleaned = await cleanupTemporaryFiles(
18
+ join(this.root, ".tmp"),
19
+ input.olderThan,
20
+ input.limit,
21
+ );
22
+ const root = join(this.root, "blobs");
23
+ for (const directory of await safeReadDirectories(root)) {
24
+ if (cleaned >= input.limit) break;
25
+ cleaned += await cleanupBlobDirectory(
26
+ join(root, directory),
27
+ input,
28
+ input.limit - cleaned,
29
+ );
30
+ }
31
+ return cleaned;
32
+ }
33
+ }
34
+
35
+ async function cleanupTemporaryFiles(
36
+ directory: string,
37
+ olderThan: Date,
38
+ limit: number,
39
+ ) {
40
+ let cleaned = 0;
41
+ for (const name of await safeReadFiles(directory)) {
42
+ if (cleaned >= limit) break;
43
+ const path = join(directory, name);
44
+ if (await isOlder(path, olderThan)) {
45
+ await rm(path, { force: true });
46
+ cleaned += 1;
47
+ }
48
+ }
49
+ return cleaned;
50
+ }
51
+
52
+ async function cleanupBlobDirectory(
53
+ directory: string,
54
+ input: {
55
+ referencedRefs: ReadonlySet<ArtifactRef>;
56
+ olderThan: Date;
57
+ },
58
+ limit: number,
59
+ ) {
60
+ const names = new Set(await safeReadFiles(directory));
61
+ const refs = new Set<string>();
62
+ for (const name of names) {
63
+ if (name.endsWith(".blob")) refs.add(name.slice(0, -5));
64
+ if (name.endsWith(".json")) refs.add(name.slice(0, -5));
65
+ }
66
+ let cleaned = 0;
67
+ for (const rawRef of refs) {
68
+ if (cleaned >= limit) break;
69
+ let ref: ArtifactRef;
70
+ try {
71
+ ref = artifactRef(rawRef);
72
+ } catch {
73
+ continue;
74
+ }
75
+ if (input.referencedRefs.has(ref)) continue;
76
+ const blob = join(directory, `${ref}.blob`);
77
+ const metadata = join(directory, `${ref}.json`);
78
+ if (
79
+ (await isOlderOrMissing(blob, input.olderThan)) &&
80
+ (await isOlderOrMissing(metadata, input.olderThan))
81
+ ) {
82
+ await Promise.all([
83
+ rm(blob, { force: true }),
84
+ rm(metadata, { force: true }),
85
+ ]);
86
+ cleaned += 1;
87
+ }
88
+ }
89
+ return cleaned;
90
+ }
91
+
92
+ async function safeReadDirectories(path: string) {
93
+ try {
94
+ return (await readdir(path, { withFileTypes: true }))
95
+ .filter((entry) => entry.isDirectory())
96
+ .map((entry) => entry.name);
97
+ } catch (error) {
98
+ if (isMissing(error)) return [];
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ async function safeReadFiles(path: string) {
104
+ try {
105
+ return (await readdir(path, { withFileTypes: true }))
106
+ .filter((entry) => entry.isFile())
107
+ .map((entry) => entry.name);
108
+ } catch (error) {
109
+ if (isMissing(error)) return [];
110
+ throw error;
111
+ }
112
+ }
113
+
114
+ async function isOlder(path: string, cutoff: Date) {
115
+ return (await stat(path)).mtime <= cutoff;
116
+ }
117
+
118
+ async function isOlderOrMissing(path: string, cutoff: Date) {
119
+ try {
120
+ return await isOlder(path, cutoff);
121
+ } catch (error) {
122
+ if (isMissing(error)) return true;
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ function isMissing(error: unknown) {
128
+ return (
129
+ error instanceof Error &&
130
+ "code" in error &&
131
+ (error as NodeJS.ErrnoException).code === "ENOENT"
132
+ );
133
+ }
@@ -0,0 +1,100 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ artifactRef,
4
+ BlobStoreError,
5
+ type ArtifactRef,
6
+ type BlobStore,
7
+ type PutBlobInput,
8
+ type ReadableBlob,
9
+ type StoredBlob,
10
+ } from "./blob-store.js";
11
+ import { createSha256Accumulator } from "./content-hash.js";
12
+
13
+ interface MemoryBlob extends StoredBlob {
14
+ bytes: Uint8Array;
15
+ }
16
+
17
+ /** Small development/test adapter. Production large files use a streaming adapter. */
18
+ export class MemoryBlobStore implements BlobStore {
19
+ private readonly blobs = new Map<ArtifactRef, MemoryBlob>();
20
+
21
+ async put(input: PutBlobInput): Promise<StoredBlob> {
22
+ const chunks: Uint8Array[] = [];
23
+ const hash = createSha256Accumulator();
24
+ let byteSize = 0;
25
+ const reader = input.body.getReader();
26
+ while (true) {
27
+ const result = await reader.read();
28
+ if (result.done) break;
29
+ const copy = Uint8Array.from(result.value);
30
+ chunks.push(copy);
31
+ hash.update(copy);
32
+ byteSize += copy.byteLength;
33
+ }
34
+ if (
35
+ input.expectedByteSize !== undefined &&
36
+ input.expectedByteSize !== byteSize
37
+ ) {
38
+ throw new BlobStoreError("SIZE_MISMATCH", "Blob 大小与声明不一致");
39
+ }
40
+ const contentHash = hash.digest();
41
+ if (
42
+ input.expectedContentHash &&
43
+ input.expectedContentHash.value !== contentHash.value
44
+ ) {
45
+ throw new BlobStoreError("HASH_MISMATCH", "Blob SHA-256 与声明不一致");
46
+ }
47
+ const ref = artifactRef(`blob_${randomUUID().replaceAll("-", "")}`);
48
+ const blob: MemoryBlob = {
49
+ ref,
50
+ byteSize,
51
+ contentHash,
52
+ createdAt: new Date().toISOString(),
53
+ bytes: join(chunks, byteSize),
54
+ };
55
+ this.blobs.set(ref, blob);
56
+ return metadata(blob);
57
+ }
58
+
59
+ async open(ref: ArtifactRef): Promise<ReadableBlob | undefined> {
60
+ const blob = this.blobs.get(artifactRef(ref));
61
+ if (!blob) return undefined;
62
+ return {
63
+ ...metadata(blob),
64
+ body: new ReadableStream({
65
+ start(controller) {
66
+ controller.enqueue(Uint8Array.from(blob.bytes));
67
+ controller.close();
68
+ },
69
+ }),
70
+ };
71
+ }
72
+
73
+ async inspect(ref: ArtifactRef): Promise<StoredBlob | undefined> {
74
+ const blob = this.blobs.get(artifactRef(ref));
75
+ return blob ? metadata(blob) : undefined;
76
+ }
77
+
78
+ async delete(ref: ArtifactRef) {
79
+ return this.blobs.delete(artifactRef(ref));
80
+ }
81
+
82
+ get size() {
83
+ return this.blobs.size;
84
+ }
85
+ }
86
+
87
+ function metadata(blob: MemoryBlob): StoredBlob {
88
+ const { bytes: _bytes, ...value } = blob;
89
+ return { ...value, contentHash: { ...value.contentHash } };
90
+ }
91
+
92
+ function join(chunks: readonly Uint8Array[], byteSize: number) {
93
+ const output = new Uint8Array(byteSize);
94
+ let offset = 0;
95
+ for (const chunk of chunks) {
96
+ output.set(chunk, offset);
97
+ offset += chunk.byteLength;
98
+ }
99
+ return output;
100
+ }
@@ -0,0 +1,43 @@
1
+ import type { ArtifactRef } from "./blob-store.js";
2
+ import type {
3
+ FileMetadata,
4
+ UploadPart,
5
+ UploadSession,
6
+ } from "./upload-types.js";
7
+
8
+ export type NewUploadSession = Omit<
9
+ UploadSession,
10
+ "receivedByteSize" | "nextPartNumber" | "status" | "updatedAt"
11
+ >;
12
+
13
+ export interface UploadRepository {
14
+ createSession(input: NewUploadSession): Promise<UploadSession>;
15
+ getSession(id: string): Promise<UploadSession | undefined>;
16
+ getPart(
17
+ sessionId: string,
18
+ partNumber: number,
19
+ ): Promise<UploadPart | undefined>;
20
+ listParts(sessionId: string): Promise<UploadPart[]>;
21
+ commitPart(
22
+ part: UploadPart,
23
+ expectedReceivedByteSize: number,
24
+ ): Promise<boolean>;
25
+ completeSession(
26
+ sessionId: string,
27
+ expectedReceivedByteSize: number,
28
+ file: FileMetadata,
29
+ updatedAt: string,
30
+ ): Promise<boolean>;
31
+ abortSession(
32
+ id: string,
33
+ ownerId: string,
34
+ updatedAt: string,
35
+ ): Promise<boolean>;
36
+ getFile(id: string): Promise<FileMetadata | undefined>;
37
+ listExpiredSessions(now: string, limit: number): Promise<UploadSession[]>;
38
+ listExpiredFiles(now: string, limit: number): Promise<FileMetadata[]>;
39
+ listArtifactRefs(): Promise<ArtifactRef[]>;
40
+ deleteParts(sessionId: string): Promise<void>;
41
+ deleteSession(id: string): Promise<void>;
42
+ deleteFile(id: string): Promise<void>;
43
+ }
@@ -0,0 +1,334 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type {
3
+ ArtifactRef,
4
+ BlobStore,
5
+ BlobStoreMaintenance,
6
+ } from "./blob-store.js";
7
+ import { sha256ContentHash } from "./content-hash.js";
8
+ import type { UploadRepository } from "./upload-repository.js";
9
+ import { concatBlobStreams, guardFileSignature } from "./upload-streams.js";
10
+ import {
11
+ UploadError,
12
+ type CreateUploadInput,
13
+ type FileMetadata,
14
+ type UploadPart,
15
+ type UploadPartInput,
16
+ type UploadPolicy,
17
+ type UploadSession,
18
+ } from "./upload-types.js";
19
+
20
+ export interface UploadServiceOptions {
21
+ repository: UploadRepository;
22
+ blobStore: BlobStore;
23
+ policies: readonly UploadPolicy[];
24
+ maintenance?: BlobStoreMaintenance;
25
+ orphanGraceMs?: number;
26
+ now?: () => Date;
27
+ createId?: (prefix: "upload" | "file") => string;
28
+ }
29
+
30
+ export interface CleanupResult {
31
+ sessions: number;
32
+ files: number;
33
+ blobs: number;
34
+ orphans: number;
35
+ }
36
+
37
+ export class UploadService {
38
+ private readonly now: () => Date;
39
+ private readonly createId: (prefix: "upload" | "file") => string;
40
+ private readonly policies: ReadonlyMap<string, UploadPolicy>;
41
+
42
+ constructor(private readonly options: UploadServiceOptions) {
43
+ this.now = options.now ?? (() => new Date());
44
+ this.createId = options.createId ?? randomOpaqueId;
45
+ this.policies = new Map(
46
+ options.policies.map((policy) => [policy.purpose, policy]),
47
+ );
48
+ }
49
+
50
+ async create(input: CreateUploadInput): Promise<UploadSession> {
51
+ const policy = this.requirePolicy(input.purpose);
52
+ const filename = validFilename(input.filename);
53
+ const extension = extensionOf(filename);
54
+ const mimeType = normalizeMime(input.mimeType);
55
+ validateType(policy, mimeType, extension);
56
+ if (
57
+ !Number.isSafeInteger(input.expectedByteSize) ||
58
+ input.expectedByteSize < 0
59
+ ) {
60
+ throw new UploadError("INVALID_UPLOAD", "文件大小无效");
61
+ }
62
+ if (input.expectedByteSize > policy.maxByteSize) {
63
+ throw new UploadError("UPLOAD_TOO_LARGE", "文件超过用途允许的大小");
64
+ }
65
+ const now = this.now();
66
+ if (input.fileExpiresAt && input.fileExpiresAt <= now) {
67
+ throw new UploadError("INVALID_UPLOAD", "文件过期时间必须晚于当前时间");
68
+ }
69
+ return this.options.repository.createSession({
70
+ id: this.createId("upload"),
71
+ ownerId: input.ownerId,
72
+ purpose: input.purpose,
73
+ filename,
74
+ extension,
75
+ mimeType,
76
+ expectedByteSize: input.expectedByteSize,
77
+ expectedContentHash: input.expectedSha256
78
+ ? sha256ContentHash(input.expectedSha256)
79
+ : undefined,
80
+ expiresAt: new Date(now.getTime() + policy.sessionTtlMs).toISOString(),
81
+ fileExpiresAt: input.fileExpiresAt?.toISOString(),
82
+ createdAt: now.toISOString(),
83
+ });
84
+ }
85
+
86
+ async appendPart(input: UploadPartInput): Promise<UploadPart> {
87
+ const session = await this.requireOwnedActive(
88
+ input.sessionId,
89
+ input.ownerId,
90
+ );
91
+ const duplicate = await this.options.repository.getPart(
92
+ session.id,
93
+ input.partNumber,
94
+ );
95
+ const hash = sha256ContentHash(input.sha256);
96
+ if (duplicate && samePart(duplicate, input, hash.value)) return duplicate;
97
+ if (
98
+ input.partNumber !== session.nextPartNumber ||
99
+ input.byteOffset !== session.receivedByteSize
100
+ ) {
101
+ throw new UploadError(
102
+ "UPLOAD_OFFSET_CONFLICT",
103
+ `续传位置冲突,服务端偏移量为 ${session.receivedByteSize}`,
104
+ );
105
+ }
106
+ const policy = this.requirePolicy(session.purpose);
107
+ validatePartSize(input.byteSize, policy, session);
108
+ const body =
109
+ input.partNumber === 0 && policy.signature === "image"
110
+ ? guardFileSignature(input.body, session.mimeType)
111
+ : input.body;
112
+ const stored = await this.options.blobStore.put({
113
+ body,
114
+ expectedByteSize: input.byteSize,
115
+ expectedContentHash: hash,
116
+ });
117
+ const part: UploadPart = {
118
+ sessionId: session.id,
119
+ partNumber: input.partNumber,
120
+ byteOffset: input.byteOffset,
121
+ artifactRef: stored.ref,
122
+ byteSize: stored.byteSize,
123
+ contentHash: stored.contentHash,
124
+ createdAt: this.now().toISOString(),
125
+ };
126
+ const committed = await this.options.repository.commitPart(
127
+ part,
128
+ session.receivedByteSize,
129
+ );
130
+ if (!committed) {
131
+ await this.options.blobStore.delete(stored.ref);
132
+ throw new UploadError(
133
+ "CONCURRENT_UPLOAD",
134
+ "上传会话已被并发更新,请续传",
135
+ );
136
+ }
137
+ return part;
138
+ }
139
+
140
+ getSession(sessionId: string, ownerId: string): Promise<UploadSession> {
141
+ return this.requireOwnedActive(sessionId, ownerId);
142
+ }
143
+
144
+ async complete(sessionId: string, ownerId: string): Promise<FileMetadata> {
145
+ const session = await this.requireOwnedActive(sessionId, ownerId);
146
+ if (session.receivedByteSize !== session.expectedByteSize) {
147
+ throw new UploadError("UPLOAD_INCOMPLETE", "上传内容尚未完整接收");
148
+ }
149
+ const parts = await this.options.repository.listParts(session.id);
150
+ ensureContiguous(parts, session);
151
+ const stored = await this.options.blobStore.put({
152
+ body: concatBlobStreams(
153
+ this.options.blobStore,
154
+ parts.map((part) => part.artifactRef),
155
+ ),
156
+ expectedByteSize: session.expectedByteSize,
157
+ expectedContentHash: session.expectedContentHash,
158
+ });
159
+ const file: FileMetadata = {
160
+ id: this.createId("file"),
161
+ ownerId: session.ownerId,
162
+ purpose: session.purpose,
163
+ filename: session.filename,
164
+ extension: session.extension,
165
+ mimeType: session.mimeType,
166
+ artifactRef: stored.ref,
167
+ byteSize: stored.byteSize,
168
+ contentHash: stored.contentHash,
169
+ expiresAt: session.fileExpiresAt,
170
+ createdAt: this.now().toISOString(),
171
+ };
172
+ const committed = await this.options.repository.completeSession(
173
+ session.id,
174
+ session.receivedByteSize,
175
+ file,
176
+ this.now().toISOString(),
177
+ );
178
+ if (!committed) {
179
+ await this.options.blobStore.delete(stored.ref);
180
+ throw new UploadError("CONCURRENT_UPLOAD", "上传会话已被并发更新");
181
+ }
182
+ await this.deleteParts(session.id, parts);
183
+ return file;
184
+ }
185
+
186
+ async abort(sessionId: string, ownerId: string): Promise<void> {
187
+ const session = await this.requireOwnedActive(sessionId, ownerId);
188
+ const parts = await this.options.repository.listParts(session.id);
189
+ if (
190
+ !(await this.options.repository.abortSession(
191
+ session.id,
192
+ ownerId,
193
+ this.now().toISOString(),
194
+ ))
195
+ ) {
196
+ throw new UploadError("CONCURRENT_UPLOAD", "上传会话已被并发更新");
197
+ }
198
+ await this.deleteParts(session.id, parts);
199
+ }
200
+
201
+ async cleanupExpired(
202
+ limit = 100,
203
+ additionalReferencedRefs: Iterable<ArtifactRef> = [],
204
+ ): Promise<CleanupResult> {
205
+ const now = this.now().toISOString();
206
+ const sessions = await this.options.repository.listExpiredSessions(
207
+ now,
208
+ limit,
209
+ );
210
+ const files = await this.options.repository.listExpiredFiles(now, limit);
211
+ let blobs = 0;
212
+ for (const session of sessions) {
213
+ const parts = await this.options.repository.listParts(session.id);
214
+ await this.options.repository.deleteSession(session.id);
215
+ blobs += await this.deleteBlobRefs(parts.map((part) => part.artifactRef));
216
+ }
217
+ for (const file of files) {
218
+ await this.options.repository.deleteFile(file.id);
219
+ blobs += Number(await this.options.blobStore.delete(file.artifactRef));
220
+ }
221
+ const referencedRefs = new Set([
222
+ ...(await this.options.repository.listArtifactRefs()),
223
+ ...additionalReferencedRefs,
224
+ ]);
225
+ const orphans = this.options.maintenance
226
+ ? await this.options.maintenance.cleanupOrphans({
227
+ referencedRefs,
228
+ olderThan: new Date(
229
+ this.now().getTime() - (this.options.orphanGraceMs ?? 60 * 60_000),
230
+ ),
231
+ limit,
232
+ })
233
+ : 0;
234
+ return { sessions: sessions.length, files: files.length, blobs, orphans };
235
+ }
236
+
237
+ private async requireOwnedActive(id: string, ownerId: string) {
238
+ const session = await this.options.repository.getSession(id);
239
+ if (!session) throw new UploadError("UPLOAD_NOT_FOUND", "上传会话不存在");
240
+ if (session.ownerId !== ownerId)
241
+ throw new UploadError("UPLOAD_FORBIDDEN", "无权访问该上传会话");
242
+ if (session.expiresAt <= this.now().toISOString())
243
+ throw new UploadError("UPLOAD_EXPIRED", "上传会话已过期");
244
+ if (session.status !== "uploading")
245
+ throw new UploadError("UPLOAD_NOT_ACTIVE", "上传会话不可继续操作");
246
+ return session;
247
+ }
248
+
249
+ private requirePolicy(purpose: string) {
250
+ const policy = this.policies.get(purpose);
251
+ if (!policy) throw new UploadError("INVALID_UPLOAD", "上传用途无效");
252
+ return policy;
253
+ }
254
+
255
+ private async deleteParts(sessionId: string, parts: readonly UploadPart[]) {
256
+ await this.deleteBlobRefs(parts.map((part) => part.artifactRef));
257
+ await this.options.repository.deleteParts(sessionId);
258
+ }
259
+
260
+ private async deleteBlobRefs(refs: readonly UploadPart["artifactRef"][]) {
261
+ let deleted = 0;
262
+ for (const ref of refs)
263
+ deleted += Number(await this.options.blobStore.delete(ref));
264
+ return deleted;
265
+ }
266
+ }
267
+
268
+ function randomOpaqueId(prefix: "upload" | "file") {
269
+ return `${prefix}_${randomUUID().replaceAll("-", "")}`;
270
+ }
271
+
272
+ function validFilename(value: string) {
273
+ const filename = value.trim();
274
+ if (
275
+ !filename ||
276
+ filename.length > 240 ||
277
+ filename.includes("/") ||
278
+ filename.includes("\\") ||
279
+ filename.includes(String.fromCharCode(0))
280
+ )
281
+ throw new UploadError("INVALID_UPLOAD", "文件名无效");
282
+ return filename;
283
+ }
284
+
285
+ function extensionOf(filename: string) {
286
+ const dot = filename.lastIndexOf(".");
287
+ if (dot < 1 || dot === filename.length - 1)
288
+ throw new UploadError("UNSUPPORTED_FILE_TYPE", "文件扩展名无效");
289
+ return filename.slice(dot + 1).toLowerCase();
290
+ }
291
+
292
+ function normalizeMime(value: string) {
293
+ return value.split(";", 1)[0]!.trim().toLowerCase();
294
+ }
295
+
296
+ function validateType(policy: UploadPolicy, mime: string, extension: string) {
297
+ if (!policy.allowedTypes[mime]?.includes(extension))
298
+ throw new UploadError(
299
+ "UNSUPPORTED_FILE_TYPE",
300
+ "MIME 类型与文件扩展名不匹配或不受支持",
301
+ );
302
+ }
303
+
304
+ function validatePartSize(
305
+ size: number,
306
+ policy: UploadPolicy,
307
+ session: UploadSession,
308
+ ) {
309
+ if (!Number.isSafeInteger(size) || size <= 0)
310
+ throw new UploadError("INVALID_UPLOAD", "分片大小无效");
311
+ if (size > policy.maxPartByteSize)
312
+ throw new UploadError("PART_TOO_LARGE", "分片超过允许大小");
313
+ if (session.receivedByteSize + size > session.expectedByteSize)
314
+ throw new UploadError("UPLOAD_TOO_LARGE", "分片超过文件声明大小");
315
+ }
316
+
317
+ function samePart(part: UploadPart, input: UploadPartInput, hash: string) {
318
+ return (
319
+ part.byteOffset === input.byteOffset &&
320
+ part.byteSize === input.byteSize &&
321
+ part.contentHash.value === hash
322
+ );
323
+ }
324
+
325
+ function ensureContiguous(parts: UploadPart[], session: UploadSession) {
326
+ let offset = 0;
327
+ for (const [index, part] of parts.entries()) {
328
+ if (part.partNumber !== index || part.byteOffset !== offset)
329
+ throw new UploadError("UPLOAD_INCOMPLETE", "上传分片不连续");
330
+ offset += part.byteSize;
331
+ }
332
+ if (offset !== session.expectedByteSize)
333
+ throw new UploadError("UPLOAD_INCOMPLETE", "上传分片大小不完整");
334
+ }