@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.
package/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ export * from "./src/blob-store.js";
2
+ export * from "./src/content-hash.js";
3
+ export * from "./src/file-service.js";
4
+ export * from "./src/local-file-blob-store.js";
5
+ export * from "./src/local-file-maintenance.js";
6
+ export * from "./src/memory-blob-store.js";
7
+ export * from "./src/in-memory-upload-repository.js";
8
+ export * from "./src/upload-repository.js";
9
+ export * from "./src/upload-service.js";
10
+ export * from "./src/upload-streams.js";
11
+ export * from "./src/upload-types.js";
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@southwind-ai/storage",
3
+ "version": "0.1.0",
4
+ "license": "UNLICENSED",
5
+ "type": "module",
6
+ "engines": {
7
+ "bun": ">=1.3.0"
8
+ },
9
+ "files": [
10
+ "index.ts",
11
+ "src/**/*.ts",
12
+ "!src/**/*.test.ts"
13
+ ],
14
+ "scripts": {
15
+ "typecheck": "tsc --noEmit --project tsconfig.json"
16
+ },
17
+ "exports": {
18
+ ".": "./index.ts"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
@@ -0,0 +1,64 @@
1
+ export type ArtifactRef = string & { readonly __artifactRef: unique symbol };
2
+
3
+ export interface ContentHash {
4
+ algorithm: "sha256";
5
+ value: string;
6
+ }
7
+
8
+ export interface StoredBlob {
9
+ ref: ArtifactRef;
10
+ byteSize: number;
11
+ contentHash: ContentHash;
12
+ createdAt: string;
13
+ }
14
+
15
+ export interface ReadableBlob extends StoredBlob {
16
+ body: ReadableStream<Uint8Array>;
17
+ }
18
+
19
+ export interface PutBlobInput {
20
+ body: ReadableStream<Uint8Array>;
21
+ expectedByteSize?: number;
22
+ expectedContentHash?: ContentHash;
23
+ }
24
+
25
+ export interface BlobStore {
26
+ put(input: PutBlobInput): Promise<StoredBlob>;
27
+ inspect(ref: ArtifactRef): Promise<StoredBlob | undefined>;
28
+ open(ref: ArtifactRef): Promise<ReadableBlob | undefined>;
29
+ delete(ref: ArtifactRef): Promise<boolean>;
30
+ }
31
+
32
+ export interface BlobStoreMaintenance {
33
+ cleanupOrphans(input: {
34
+ referencedRefs: ReadonlySet<ArtifactRef>;
35
+ olderThan: Date;
36
+ limit: number;
37
+ }): Promise<number>;
38
+ }
39
+
40
+ export type BlobStoreErrorCode =
41
+ | "INVALID_ARTIFACT_REF"
42
+ | "INVALID_CONTENT_HASH"
43
+ | "SIZE_MISMATCH"
44
+ | "HASH_MISMATCH"
45
+ | "CORRUPT_BLOB_METADATA";
46
+
47
+ export class BlobStoreError extends Error {
48
+ constructor(
49
+ readonly code: BlobStoreErrorCode,
50
+ message: string,
51
+ ) {
52
+ super(message);
53
+ this.name = "BlobStoreError";
54
+ }
55
+ }
56
+
57
+ const artifactRefPattern = /^blob_[a-f0-9]{32}$/;
58
+
59
+ export function artifactRef(value: string): ArtifactRef {
60
+ if (!artifactRefPattern.test(value)) {
61
+ throw new BlobStoreError("INVALID_ARTIFACT_REF", "Artifact 引用格式无效");
62
+ }
63
+ return value as ArtifactRef;
64
+ }
@@ -0,0 +1,24 @@
1
+ import { createHash } from "node:crypto";
2
+ import { BlobStoreError, type ContentHash } from "./blob-store.js";
3
+
4
+ const sha256Pattern = /^[a-f0-9]{64}$/;
5
+
6
+ export function sha256ContentHash(value: string): ContentHash {
7
+ const normalized = value.toLowerCase();
8
+ if (!sha256Pattern.test(normalized)) {
9
+ throw new BlobStoreError("INVALID_CONTENT_HASH", "SHA-256 摘要格式无效");
10
+ }
11
+ return { algorithm: "sha256", value: normalized };
12
+ }
13
+
14
+ export function createSha256Accumulator() {
15
+ const hash = createHash("sha256");
16
+ return {
17
+ update(chunk: Uint8Array): void {
18
+ hash.update(chunk);
19
+ },
20
+ digest(): ContentHash {
21
+ return sha256ContentHash(hash.digest("hex"));
22
+ },
23
+ };
24
+ }
@@ -0,0 +1,59 @@
1
+ import type { BlobStore } from "./blob-store.js";
2
+ import type { UploadRepository } from "./upload-repository.js";
3
+ import {
4
+ UploadError,
5
+ type FileMetadata,
6
+ type OpenedFile,
7
+ } from "./upload-types.js";
8
+
9
+ export class FileService {
10
+ constructor(
11
+ private readonly repository: UploadRepository,
12
+ private readonly blobStore: BlobStore,
13
+ private readonly now: () => Date = () => new Date(),
14
+ ) {}
15
+
16
+ async open(
17
+ fileId: string,
18
+ ownerId: string,
19
+ allowManage = false,
20
+ ): Promise<OpenedFile> {
21
+ const file = await this.requireAvailable(fileId);
22
+ if (file.ownerId !== ownerId && !allowManage) {
23
+ throw new UploadError("UPLOAD_FORBIDDEN", "无权访问该文件");
24
+ }
25
+ return this.openStored(file);
26
+ }
27
+
28
+ async openPublic(
29
+ fileId: string,
30
+ requiredPurpose: string,
31
+ ): Promise<OpenedFile> {
32
+ const file = await this.requireAvailable(fileId);
33
+ if (file.purpose !== requiredPurpose) {
34
+ throw new UploadError("FILE_NOT_FOUND", "文件不存在");
35
+ }
36
+ return this.openStored(file);
37
+ }
38
+
39
+ private async requireAvailable(id: string) {
40
+ const file = await this.repository.getFile(id);
41
+ if (!file) throw new UploadError("FILE_NOT_FOUND", "文件不存在");
42
+ if (file.expiresAt && file.expiresAt <= this.now().toISOString()) {
43
+ throw new UploadError("FILE_EXPIRED", "文件已过期");
44
+ }
45
+ return file;
46
+ }
47
+
48
+ private async openStored(file: FileMetadata): Promise<OpenedFile> {
49
+ const blob = await this.blobStore.open(file.artifactRef);
50
+ if (
51
+ !blob ||
52
+ blob.byteSize !== file.byteSize ||
53
+ blob.contentHash.value !== file.contentHash.value
54
+ ) {
55
+ throw new UploadError("FILE_CONTENT_MISSING", "文件内容不可用");
56
+ }
57
+ return { ...file, body: blob.body };
58
+ }
59
+ }
@@ -0,0 +1,154 @@
1
+ import type { ArtifactRef } from "./blob-store.js";
2
+ import type {
3
+ NewUploadSession,
4
+ UploadRepository,
5
+ } from "./upload-repository.js";
6
+ import type {
7
+ FileMetadata,
8
+ UploadPart,
9
+ UploadSession,
10
+ } from "./upload-types.js";
11
+
12
+ export class InMemoryUploadRepository implements UploadRepository {
13
+ private readonly sessions = new Map<string, UploadSession>();
14
+ private readonly parts = new Map<string, UploadPart>();
15
+ private readonly files = new Map<string, FileMetadata>();
16
+
17
+ async createSession(input: NewUploadSession) {
18
+ const session: UploadSession = {
19
+ ...input,
20
+ receivedByteSize: 0,
21
+ nextPartNumber: 0,
22
+ status: "uploading",
23
+ updatedAt: input.createdAt,
24
+ };
25
+ this.sessions.set(session.id, session);
26
+ return cloneSession(session);
27
+ }
28
+
29
+ async getSession(id: string) {
30
+ const value = this.sessions.get(id);
31
+ return value ? cloneSession(value) : undefined;
32
+ }
33
+
34
+ async getPart(sessionId: string, partNumber: number) {
35
+ const value = this.parts.get(partKey(sessionId, partNumber));
36
+ return value ? { ...value } : undefined;
37
+ }
38
+
39
+ async listParts(sessionId: string) {
40
+ return [...this.parts.values()]
41
+ .filter((part) => part.sessionId === sessionId)
42
+ .sort((a, b) => a.partNumber - b.partNumber)
43
+ .map((part) => ({ ...part }));
44
+ }
45
+
46
+ async commitPart(part: UploadPart, expectedReceivedByteSize: number) {
47
+ const session = this.sessions.get(part.sessionId);
48
+ if (
49
+ !session ||
50
+ session.status !== "uploading" ||
51
+ session.receivedByteSize !== expectedReceivedByteSize ||
52
+ session.nextPartNumber !== part.partNumber
53
+ ) {
54
+ return false;
55
+ }
56
+ this.parts.set(partKey(part.sessionId, part.partNumber), { ...part });
57
+ session.receivedByteSize += part.byteSize;
58
+ session.nextPartNumber += 1;
59
+ session.updatedAt = part.createdAt;
60
+ return true;
61
+ }
62
+
63
+ async completeSession(
64
+ sessionId: string,
65
+ expectedReceivedByteSize: number,
66
+ file: FileMetadata,
67
+ updatedAt: string,
68
+ ) {
69
+ const session = this.sessions.get(sessionId);
70
+ if (
71
+ !session ||
72
+ session.status !== "uploading" ||
73
+ session.receivedByteSize !== expectedReceivedByteSize
74
+ ) {
75
+ return false;
76
+ }
77
+ this.files.set(file.id, { ...file });
78
+ Object.assign(session, {
79
+ status: "completed",
80
+ fileId: file.id,
81
+ updatedAt,
82
+ });
83
+ return true;
84
+ }
85
+
86
+ async abortSession(id: string, ownerId: string, updatedAt: string) {
87
+ const session = this.sessions.get(id);
88
+ if (
89
+ !session ||
90
+ session.ownerId !== ownerId ||
91
+ session.status !== "uploading"
92
+ )
93
+ return false;
94
+ session.status = "aborted";
95
+ session.updatedAt = updatedAt;
96
+ return true;
97
+ }
98
+
99
+ async getFile(id: string) {
100
+ const value = this.files.get(id);
101
+ return value ? { ...value } : undefined;
102
+ }
103
+
104
+ async listExpiredSessions(now: string, limit: number) {
105
+ return [...this.sessions.values()]
106
+ .filter(
107
+ (session) => session.status !== "completed" && session.expiresAt <= now,
108
+ )
109
+ .slice(0, limit)
110
+ .map(cloneSession);
111
+ }
112
+
113
+ async listExpiredFiles(now: string, limit: number) {
114
+ return [...this.files.values()]
115
+ .filter((file) => file.expiresAt && file.expiresAt <= now)
116
+ .slice(0, limit)
117
+ .map((file) => ({ ...file }));
118
+ }
119
+
120
+ async listArtifactRefs() {
121
+ return [
122
+ ...[...this.parts.values()].map((part) => part.artifactRef),
123
+ ...[...this.files.values()].map((file) => file.artifactRef),
124
+ ] as ArtifactRef[];
125
+ }
126
+
127
+ async deleteSession(id: string) {
128
+ this.sessions.delete(id);
129
+ await this.deleteParts(id);
130
+ }
131
+
132
+ async deleteParts(id: string) {
133
+ for (const [key, part] of this.parts) {
134
+ if (part.sessionId === id) this.parts.delete(key);
135
+ }
136
+ }
137
+
138
+ async deleteFile(id: string) {
139
+ this.files.delete(id);
140
+ }
141
+ }
142
+
143
+ function partKey(sessionId: string, partNumber: number) {
144
+ return `${sessionId}:${partNumber}`;
145
+ }
146
+
147
+ function cloneSession(value: UploadSession): UploadSession {
148
+ return {
149
+ ...value,
150
+ expectedContentHash: value.expectedContentHash
151
+ ? { ...value.expectedContentHash }
152
+ : undefined,
153
+ };
154
+ }
@@ -0,0 +1,344 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import {
3
+ mkdir,
4
+ open,
5
+ readFile,
6
+ rename,
7
+ rm,
8
+ stat,
9
+ type FileHandle,
10
+ } from "node:fs/promises";
11
+ import { join } from "node:path";
12
+ import {
13
+ artifactRef,
14
+ BlobStoreError,
15
+ type ArtifactRef,
16
+ type BlobStore,
17
+ type PutBlobInput,
18
+ type ReadableBlob,
19
+ type StoredBlob,
20
+ } from "./blob-store.js";
21
+ import { createSha256Accumulator, sha256ContentHash } from "./content-hash.js";
22
+
23
+ export interface LocalFileBlobStoreOptions {
24
+ root: string;
25
+ now?: () => Date;
26
+ createRef?: () => ArtifactRef;
27
+ }
28
+
29
+ export class LocalFileBlobStore implements BlobStore {
30
+ private readonly now: () => Date;
31
+ private readonly createRef: () => ArtifactRef;
32
+
33
+ constructor(private readonly options: LocalFileBlobStoreOptions) {
34
+ this.now = options.now ?? (() => new Date());
35
+ this.createRef = options.createRef ?? randomArtifactRef;
36
+ }
37
+
38
+ async put(input: PutBlobInput): Promise<StoredBlob> {
39
+ validateExpectedSize(input.expectedByteSize);
40
+ const ref = this.createRef();
41
+ const paths = this.paths(ref);
42
+ await mkdir(paths.directory, { recursive: true });
43
+ await mkdir(paths.temporaryDirectory, { recursive: true });
44
+ const temporaryBlob = join(paths.temporaryDirectory, `${ref}.partial`);
45
+ const temporaryMetadata = join(
46
+ paths.temporaryDirectory,
47
+ `${ref}.json.partial`,
48
+ );
49
+
50
+ try {
51
+ const stored = await this.writeBody(temporaryBlob, ref, input);
52
+ await rename(temporaryBlob, paths.blob);
53
+ await writeDurably(temporaryMetadata, `${JSON.stringify(stored)}\n`);
54
+ await rename(temporaryMetadata, paths.metadata);
55
+ await syncDirectory(paths.directory);
56
+ return stored;
57
+ } catch (error) {
58
+ await Promise.all([
59
+ rm(temporaryBlob, { force: true }),
60
+ rm(temporaryMetadata, { force: true }),
61
+ ]);
62
+ throw error;
63
+ }
64
+ }
65
+
66
+ async open(ref: ArtifactRef): Promise<ReadableBlob | undefined> {
67
+ const paths = this.paths(ref);
68
+ const metadata = await readMetadata(paths.metadata, ref);
69
+ if (!metadata) return undefined;
70
+ let handle: FileHandle;
71
+ try {
72
+ handle = await open(paths.blob, "r");
73
+ } catch (error) {
74
+ if (isMissing(error)) throw corruptMetadata(ref);
75
+ throw error;
76
+ }
77
+ try {
78
+ await validateHandle(handle, metadata);
79
+ return {
80
+ ...metadata,
81
+ body: streamHandle(handle),
82
+ };
83
+ } catch (error) {
84
+ await handle.close();
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ async inspect(ref: ArtifactRef): Promise<StoredBlob | undefined> {
90
+ const paths = this.paths(ref);
91
+ const metadata = await readMetadata(paths.metadata, ref);
92
+ if (!metadata) return undefined;
93
+ let handle: FileHandle;
94
+ try {
95
+ handle = await open(paths.blob, "r");
96
+ } catch (error) {
97
+ if (isMissing(error)) throw corruptMetadata(ref);
98
+ throw error;
99
+ }
100
+ try {
101
+ await validateHandle(handle, metadata);
102
+ return metadata;
103
+ } finally {
104
+ await handle.close();
105
+ }
106
+ }
107
+
108
+ async delete(ref: ArtifactRef): Promise<boolean> {
109
+ const paths = this.paths(ref);
110
+ const existed = await pathExists(paths.blob);
111
+ await Promise.all([
112
+ rm(paths.blob, { force: true }),
113
+ rm(paths.metadata, { force: true }),
114
+ ]);
115
+ return existed;
116
+ }
117
+
118
+ private async writeBody(
119
+ path: string,
120
+ ref: ArtifactRef,
121
+ input: PutBlobInput,
122
+ ): Promise<StoredBlob> {
123
+ const handle = await open(path, "wx", 0o600);
124
+ const reader = input.body.getReader();
125
+ const hash = createSha256Accumulator();
126
+ let byteSize = 0;
127
+ try {
128
+ while (true) {
129
+ const result = await reader.read();
130
+ if (result.done) break;
131
+ byteSize += result.value.byteLength;
132
+ if (
133
+ input.expectedByteSize !== undefined &&
134
+ byteSize > input.expectedByteSize
135
+ ) {
136
+ throw sizeMismatch(input.expectedByteSize, byteSize);
137
+ }
138
+ hash.update(result.value);
139
+ await writeAll(handle, result.value);
140
+ }
141
+ await handle.sync();
142
+ } finally {
143
+ reader.releaseLock();
144
+ await handle.close();
145
+ }
146
+ if (
147
+ input.expectedByteSize !== undefined &&
148
+ byteSize !== input.expectedByteSize
149
+ ) {
150
+ throw sizeMismatch(input.expectedByteSize, byteSize);
151
+ }
152
+ const contentHash = hash.digest();
153
+ if (
154
+ input.expectedContentHash &&
155
+ input.expectedContentHash.value !== contentHash.value
156
+ ) {
157
+ throw new BlobStoreError("HASH_MISMATCH", "Blob SHA-256 与声明不一致");
158
+ }
159
+ return {
160
+ ref,
161
+ byteSize,
162
+ contentHash,
163
+ createdAt: this.now().toISOString(),
164
+ };
165
+ }
166
+
167
+ private paths(ref: ArtifactRef) {
168
+ const safeRef = artifactRef(ref);
169
+ const directory = join(this.options.root, "blobs", safeRef.slice(5, 7));
170
+ return {
171
+ directory,
172
+ temporaryDirectory: join(this.options.root, ".tmp"),
173
+ blob: join(directory, `${safeRef}.blob`),
174
+ metadata: join(directory, `${safeRef}.json`),
175
+ };
176
+ }
177
+ }
178
+
179
+ async function hashHandle(handle: FileHandle): Promise<string> {
180
+ const hash = createSha256Accumulator();
181
+ const chunk = new Uint8Array(64 * 1024);
182
+ let position = 0;
183
+ while (true) {
184
+ const { bytesRead } = await handle.read(
185
+ chunk,
186
+ 0,
187
+ chunk.byteLength,
188
+ position,
189
+ );
190
+ if (bytesRead === 0) break;
191
+ hash.update(chunk.subarray(0, bytesRead));
192
+ position += bytesRead;
193
+ }
194
+ return hash.digest().value;
195
+ }
196
+
197
+ function streamHandle(handle: FileHandle): ReadableStream<Uint8Array> {
198
+ let position = 0;
199
+ let closed = false;
200
+ const close = async () => {
201
+ if (closed) return;
202
+ closed = true;
203
+ await handle.close();
204
+ };
205
+ return new ReadableStream<Uint8Array>({
206
+ async pull(controller) {
207
+ const chunk = new Uint8Array(64 * 1024);
208
+ try {
209
+ const { bytesRead } = await handle.read(
210
+ chunk,
211
+ 0,
212
+ chunk.byteLength,
213
+ position,
214
+ );
215
+ if (bytesRead === 0) {
216
+ await close();
217
+ controller.close();
218
+ return;
219
+ }
220
+ position += bytesRead;
221
+ controller.enqueue(chunk.subarray(0, bytesRead));
222
+ } catch (error) {
223
+ await close();
224
+ controller.error(error);
225
+ }
226
+ },
227
+ cancel: close,
228
+ });
229
+ }
230
+
231
+ async function readMetadata(path: string, ref: ArtifactRef) {
232
+ try {
233
+ return parseMetadata(await readFile(path, "utf8"), ref);
234
+ } catch (error) {
235
+ if (isMissing(error)) return undefined;
236
+ throw error;
237
+ }
238
+ }
239
+
240
+ async function validateHandle(
241
+ handle: FileHandle,
242
+ metadata: StoredBlob,
243
+ ): Promise<void> {
244
+ if ((await handle.stat()).size !== metadata.byteSize) {
245
+ throw corruptMetadata(metadata.ref);
246
+ }
247
+ if ((await hashHandle(handle)) !== metadata.contentHash.value) {
248
+ throw corruptMetadata(metadata.ref);
249
+ }
250
+ }
251
+
252
+ function randomArtifactRef(): ArtifactRef {
253
+ return artifactRef(`blob_${randomUUID().replaceAll("-", "")}`);
254
+ }
255
+
256
+ async function writeAll(handle: FileHandle, chunk: Uint8Array): Promise<void> {
257
+ let offset = 0;
258
+ while (offset < chunk.byteLength) {
259
+ const result = await handle.write(chunk, offset, chunk.byteLength - offset);
260
+ offset += result.bytesWritten;
261
+ }
262
+ }
263
+
264
+ async function writeDurably(path: string, content: string): Promise<void> {
265
+ const handle = await open(path, "wx", 0o600);
266
+ try {
267
+ await handle.writeFile(content, "utf8");
268
+ await handle.sync();
269
+ } finally {
270
+ await handle.close();
271
+ }
272
+ }
273
+
274
+ async function syncDirectory(path: string): Promise<void> {
275
+ const handle = await open(path, "r");
276
+ try {
277
+ await handle.sync();
278
+ } finally {
279
+ await handle.close();
280
+ }
281
+ }
282
+
283
+ async function pathExists(path: string): Promise<boolean> {
284
+ try {
285
+ await stat(path);
286
+ return true;
287
+ } catch (error) {
288
+ if (isMissing(error)) return false;
289
+ throw error;
290
+ }
291
+ }
292
+
293
+ function parseMetadata(raw: string, expectedRef: ArtifactRef): StoredBlob {
294
+ try {
295
+ const value = JSON.parse(raw) as Record<string, unknown>;
296
+ const ref = artifactRef(String(value.ref));
297
+ const byteSize = Number(value.byteSize);
298
+ const createdAt = String(value.createdAt);
299
+ const hash = value.contentHash as Record<string, unknown>;
300
+ if (
301
+ ref !== expectedRef ||
302
+ !Number.isSafeInteger(byteSize) ||
303
+ byteSize < 0 ||
304
+ Number.isNaN(Date.parse(createdAt)) ||
305
+ hash.algorithm !== "sha256"
306
+ ) {
307
+ throw corruptMetadata(expectedRef);
308
+ }
309
+ return {
310
+ ref,
311
+ byteSize,
312
+ createdAt,
313
+ contentHash: sha256ContentHash(String(hash.value)),
314
+ };
315
+ } catch (error) {
316
+ if (error instanceof BlobStoreError) throw error;
317
+ throw corruptMetadata(expectedRef);
318
+ }
319
+ }
320
+
321
+ function validateExpectedSize(value: number | undefined): void {
322
+ if (value !== undefined && (!Number.isSafeInteger(value) || value < 0)) {
323
+ throw new BlobStoreError("SIZE_MISMATCH", "Blob 声明大小无效");
324
+ }
325
+ }
326
+
327
+ function sizeMismatch(expected: number, actual: number): BlobStoreError {
328
+ return new BlobStoreError(
329
+ "SIZE_MISMATCH",
330
+ `Blob 大小与声明不一致:expected=${expected}, actual=${actual}`,
331
+ );
332
+ }
333
+
334
+ function corruptMetadata(ref: ArtifactRef): BlobStoreError {
335
+ return new BlobStoreError("CORRUPT_BLOB_METADATA", `Blob 元数据损坏:${ref}`);
336
+ }
337
+
338
+ function isMissing(error: unknown): boolean {
339
+ return (
340
+ error instanceof Error &&
341
+ "code" in error &&
342
+ (error as NodeJS.ErrnoException).code === "ENOENT"
343
+ );
344
+ }