@dexto/storage 1.6.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/LICENSE +44 -0
- package/README.md +80 -0
- package/dist/blob/factories/index.cjs +31 -0
- package/dist/blob/factories/index.d.cts +6 -0
- package/dist/blob/factories/index.d.ts +6 -0
- package/dist/blob/factories/index.d.ts.map +1 -0
- package/dist/blob/factories/index.js +6 -0
- package/dist/blob/factories/local.cjs +38 -0
- package/dist/blob/factories/local.d.cts +21 -0
- package/dist/blob/factories/local.d.ts +17 -0
- package/dist/blob/factories/local.d.ts.map +1 -0
- package/dist/blob/factories/local.js +14 -0
- package/dist/blob/factories/memory.cjs +44 -0
- package/dist/blob/factories/memory.d.cts +21 -0
- package/dist/blob/factories/memory.d.ts +17 -0
- package/dist/blob/factories/memory.d.ts.map +1 -0
- package/dist/blob/factories/memory.js +20 -0
- package/dist/blob/factory.cjs +16 -0
- package/dist/blob/factory.d.cts +36 -0
- package/dist/blob/factory.d.ts +35 -0
- package/dist/blob/factory.d.ts.map +1 -0
- package/dist/blob/factory.js +0 -0
- package/dist/blob/index.cjs +45 -0
- package/dist/blob/index.d.cts +8 -0
- package/dist/blob/index.d.ts +26 -0
- package/dist/blob/index.d.ts.map +1 -0
- package/dist/blob/index.js +19 -0
- package/dist/blob/local-blob-store.cjs +532 -0
- package/dist/blob/local-blob-store.d.cts +56 -0
- package/dist/blob/local-blob-store.d.ts +54 -0
- package/dist/blob/local-blob-store.d.ts.map +1 -0
- package/dist/blob/local-blob-store.js +498 -0
- package/dist/blob/memory-blob-store.cjs +327 -0
- package/dist/blob/memory-blob-store.d.cts +69 -0
- package/dist/blob/memory-blob-store.d.ts +67 -0
- package/dist/blob/memory-blob-store.d.ts.map +1 -0
- package/dist/blob/memory-blob-store.js +303 -0
- package/dist/blob/schemas.cjs +52 -0
- package/dist/blob/schemas.d.cts +87 -0
- package/dist/blob/schemas.d.ts +86 -0
- package/dist/blob/schemas.d.ts.map +1 -0
- package/dist/blob/schemas.js +25 -0
- package/dist/blob/types.cjs +16 -0
- package/dist/blob/types.d.cts +1 -0
- package/dist/blob/types.d.ts +2 -0
- package/dist/blob/types.d.ts.map +1 -0
- package/dist/blob/types.js +0 -0
- package/dist/cache/factories/index.cjs +31 -0
- package/dist/cache/factories/index.d.cts +6 -0
- package/dist/cache/factories/index.d.ts +6 -0
- package/dist/cache/factories/index.d.ts.map +1 -0
- package/dist/cache/factories/index.js +6 -0
- package/dist/cache/factories/memory.cjs +39 -0
- package/dist/cache/factories/memory.d.cts +21 -0
- package/dist/cache/factories/memory.d.ts +17 -0
- package/dist/cache/factories/memory.d.ts.map +1 -0
- package/dist/cache/factories/memory.js +15 -0
- package/dist/cache/factories/redis.cjs +65 -0
- package/dist/cache/factories/redis.d.cts +24 -0
- package/dist/cache/factories/redis.d.ts +20 -0
- package/dist/cache/factories/redis.d.ts.map +1 -0
- package/dist/cache/factories/redis.js +31 -0
- package/dist/cache/factory.cjs +16 -0
- package/dist/cache/factory.d.cts +42 -0
- package/dist/cache/factory.d.ts +41 -0
- package/dist/cache/factory.d.ts.map +1 -0
- package/dist/cache/factory.js +0 -0
- package/dist/cache/index.cjs +42 -0
- package/dist/cache/index.d.cts +7 -0
- package/dist/cache/index.d.ts +25 -0
- package/dist/cache/index.d.ts.map +1 -0
- package/dist/cache/index.js +17 -0
- package/dist/cache/memory-cache-store.cjs +106 -0
- package/dist/cache/memory-cache-store.d.cts +27 -0
- package/dist/cache/memory-cache-store.d.ts +25 -0
- package/dist/cache/memory-cache-store.d.ts.map +1 -0
- package/dist/cache/memory-cache-store.js +82 -0
- package/dist/cache/redis-store.cjs +176 -0
- package/dist/cache/redis-store.d.cts +34 -0
- package/dist/cache/redis-store.d.ts +32 -0
- package/dist/cache/redis-store.d.ts.map +1 -0
- package/dist/cache/redis-store.js +152 -0
- package/dist/cache/schemas.cjs +70 -0
- package/dist/cache/schemas.d.cts +93 -0
- package/dist/cache/schemas.d.ts +91 -0
- package/dist/cache/schemas.d.ts.map +1 -0
- package/dist/cache/schemas.js +43 -0
- package/dist/cache/types.cjs +16 -0
- package/dist/cache/types.d.cts +1 -0
- package/dist/cache/types.d.ts +2 -0
- package/dist/cache/types.d.ts.map +1 -0
- package/dist/cache/types.js +0 -0
- package/dist/database/factories/index.cjs +34 -0
- package/dist/database/factories/index.d.cts +7 -0
- package/dist/database/factories/index.d.ts +7 -0
- package/dist/database/factories/index.d.ts.map +1 -0
- package/dist/database/factories/index.js +8 -0
- package/dist/database/factories/memory.cjs +39 -0
- package/dist/database/factories/memory.d.cts +20 -0
- package/dist/database/factories/memory.d.ts +16 -0
- package/dist/database/factories/memory.d.ts.map +1 -0
- package/dist/database/factories/memory.js +15 -0
- package/dist/database/factories/postgres.cjs +61 -0
- package/dist/database/factories/postgres.d.cts +23 -0
- package/dist/database/factories/postgres.d.ts +19 -0
- package/dist/database/factories/postgres.d.ts.map +1 -0
- package/dist/database/factories/postgres.js +27 -0
- package/dist/database/factories/sqlite.cjs +65 -0
- package/dist/database/factories/sqlite.d.cts +24 -0
- package/dist/database/factories/sqlite.d.ts +20 -0
- package/dist/database/factories/sqlite.d.ts.map +1 -0
- package/dist/database/factories/sqlite.js +31 -0
- package/dist/database/factory.cjs +16 -0
- package/dist/database/factory.d.cts +42 -0
- package/dist/database/factory.d.ts +41 -0
- package/dist/database/factory.d.ts.map +1 -0
- package/dist/database/factory.js +0 -0
- package/dist/database/index.cjs +46 -0
- package/dist/database/index.d.cts +8 -0
- package/dist/database/index.d.ts +26 -0
- package/dist/database/index.d.ts.map +1 -0
- package/dist/database/index.js +24 -0
- package/dist/database/memory-database-store.cjs +121 -0
- package/dist/database/memory-database-store.d.cts +30 -0
- package/dist/database/memory-database-store.d.ts +28 -0
- package/dist/database/memory-database-store.d.ts.map +1 -0
- package/dist/database/memory-database-store.js +97 -0
- package/dist/database/postgres-store.cjs +342 -0
- package/dist/database/postgres-store.d.cts +57 -0
- package/dist/database/postgres-store.d.ts +55 -0
- package/dist/database/postgres-store.d.ts.map +1 -0
- package/dist/database/postgres-store.js +318 -0
- package/dist/database/schemas.cjs +82 -0
- package/dist/database/schemas.d.cts +127 -0
- package/dist/database/schemas.d.ts +125 -0
- package/dist/database/schemas.d.ts.map +1 -0
- package/dist/database/schemas.js +54 -0
- package/dist/database/sqlite-store.cjs +270 -0
- package/dist/database/sqlite-store.d.cts +35 -0
- package/dist/database/sqlite-store.d.ts +33 -0
- package/dist/database/sqlite-store.d.ts.map +1 -0
- package/dist/database/sqlite-store.js +236 -0
- package/dist/database/types.cjs +16 -0
- package/dist/database/types.d.cts +1 -0
- package/dist/database/types.d.ts +2 -0
- package/dist/database/types.d.ts.map +1 -0
- package/dist/database/types.js +0 -0
- package/dist/index.cjs +82 -0
- package/dist/index.d.cts +24 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +50 -0
- package/dist/schemas.cjs +67 -0
- package/dist/schemas.d.cts +72 -0
- package/dist/schemas.d.ts +70 -0
- package/dist/schemas.d.ts.map +1 -0
- package/dist/schemas.js +46 -0
- package/package.json +55 -0
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
import { promises as fs, createReadStream } from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { createHash } from "crypto";
|
|
4
|
+
import { pathToFileURL } from "url";
|
|
5
|
+
import { DextoLogComponent, StorageError } from "@dexto/core";
|
|
6
|
+
function isPlainObject(value) {
|
|
7
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function parseStoredBlobMetadata(value) {
|
|
10
|
+
if (!isPlainObject(value)) {
|
|
11
|
+
throw new Error("Invalid blob metadata: expected object");
|
|
12
|
+
}
|
|
13
|
+
const id = value["id"];
|
|
14
|
+
if (typeof id !== "string") {
|
|
15
|
+
throw new Error("Invalid blob metadata: id");
|
|
16
|
+
}
|
|
17
|
+
const mimeType = value["mimeType"];
|
|
18
|
+
if (typeof mimeType !== "string") {
|
|
19
|
+
throw new Error("Invalid blob metadata: mimeType");
|
|
20
|
+
}
|
|
21
|
+
const originalName = value["originalName"];
|
|
22
|
+
if (originalName !== void 0 && typeof originalName !== "string") {
|
|
23
|
+
throw new Error("Invalid blob metadata: originalName");
|
|
24
|
+
}
|
|
25
|
+
const createdAtRaw = value["createdAt"];
|
|
26
|
+
const createdAt = createdAtRaw instanceof Date ? createdAtRaw : typeof createdAtRaw === "string" || typeof createdAtRaw === "number" ? new Date(createdAtRaw) : null;
|
|
27
|
+
if (!createdAt || Number.isNaN(createdAt.getTime())) {
|
|
28
|
+
throw new Error("Invalid blob metadata: createdAt");
|
|
29
|
+
}
|
|
30
|
+
const size = value["size"];
|
|
31
|
+
if (typeof size !== "number") {
|
|
32
|
+
throw new Error("Invalid blob metadata: size");
|
|
33
|
+
}
|
|
34
|
+
const hash = value["hash"];
|
|
35
|
+
if (typeof hash !== "string") {
|
|
36
|
+
throw new Error("Invalid blob metadata: hash");
|
|
37
|
+
}
|
|
38
|
+
const source = value["source"];
|
|
39
|
+
if (source !== void 0 && source !== "tool" && source !== "user" && source !== "system") {
|
|
40
|
+
throw new Error("Invalid blob metadata: source");
|
|
41
|
+
}
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
mimeType,
|
|
45
|
+
...originalName !== void 0 && { originalName },
|
|
46
|
+
createdAt,
|
|
47
|
+
size,
|
|
48
|
+
hash,
|
|
49
|
+
...source !== void 0 && { source }
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
class LocalBlobStore {
|
|
53
|
+
config;
|
|
54
|
+
storePath;
|
|
55
|
+
connected = false;
|
|
56
|
+
statsCache = null;
|
|
57
|
+
statsCachePromise = null;
|
|
58
|
+
lastStatsRefresh = 0;
|
|
59
|
+
logger;
|
|
60
|
+
static STATS_REFRESH_INTERVAL_MS = 6e4;
|
|
61
|
+
// 1 minute
|
|
62
|
+
constructor(config, logger) {
|
|
63
|
+
this.config = config;
|
|
64
|
+
this.storePath = config.storePath;
|
|
65
|
+
this.logger = logger.createChild(DextoLogComponent.STORAGE);
|
|
66
|
+
}
|
|
67
|
+
async connect() {
|
|
68
|
+
if (this.connected) return;
|
|
69
|
+
try {
|
|
70
|
+
await fs.mkdir(this.storePath, { recursive: true });
|
|
71
|
+
await this.refreshStatsCache();
|
|
72
|
+
this.lastStatsRefresh = Date.now();
|
|
73
|
+
this.connected = true;
|
|
74
|
+
this.logger.debug(`LocalBlobStore connected at: ${this.storePath}`);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
throw StorageError.blobOperationFailed("connect", "local", error);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
async disconnect() {
|
|
80
|
+
this.connected = false;
|
|
81
|
+
this.logger.debug("LocalBlobStore disconnected");
|
|
82
|
+
}
|
|
83
|
+
isConnected() {
|
|
84
|
+
return this.connected;
|
|
85
|
+
}
|
|
86
|
+
getStoreType() {
|
|
87
|
+
return "local";
|
|
88
|
+
}
|
|
89
|
+
async store(input, metadata = {}) {
|
|
90
|
+
if (!this.connected) {
|
|
91
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
92
|
+
}
|
|
93
|
+
const buffer = await this.inputToBuffer(input);
|
|
94
|
+
if (buffer.length > this.config.maxBlobSize) {
|
|
95
|
+
throw StorageError.blobSizeExceeded(buffer.length, this.config.maxBlobSize);
|
|
96
|
+
}
|
|
97
|
+
const hash = createHash("sha256").update(buffer).digest("hex").substring(0, 16);
|
|
98
|
+
const id = hash;
|
|
99
|
+
const blobPath = path.join(this.storePath, `${id}.dat`);
|
|
100
|
+
const metaPath = path.join(this.storePath, `${id}.meta.json`);
|
|
101
|
+
try {
|
|
102
|
+
const existingMeta = await fs.readFile(metaPath, "utf-8");
|
|
103
|
+
const parsed = JSON.parse(existingMeta);
|
|
104
|
+
const existingMetadata = parseStoredBlobMetadata(parsed);
|
|
105
|
+
this.logger.debug(
|
|
106
|
+
`Blob ${id} already exists, returning existing reference (deduplication)`
|
|
107
|
+
);
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
uri: `blob:${id}`,
|
|
111
|
+
metadata: existingMetadata
|
|
112
|
+
};
|
|
113
|
+
} catch {
|
|
114
|
+
}
|
|
115
|
+
const maxTotalSize = this.config.maxTotalSize;
|
|
116
|
+
if (maxTotalSize) {
|
|
117
|
+
const stats = await this.ensureStatsCache();
|
|
118
|
+
if (stats.totalSize + buffer.length > maxTotalSize) {
|
|
119
|
+
throw StorageError.blobTotalSizeExceeded(
|
|
120
|
+
stats.totalSize + buffer.length,
|
|
121
|
+
maxTotalSize
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const storedMetadata = {
|
|
126
|
+
id,
|
|
127
|
+
mimeType: metadata.mimeType || this.detectMimeType(buffer, metadata.originalName),
|
|
128
|
+
originalName: metadata.originalName,
|
|
129
|
+
createdAt: metadata.createdAt || /* @__PURE__ */ new Date(),
|
|
130
|
+
source: metadata.source || "system",
|
|
131
|
+
size: buffer.length,
|
|
132
|
+
hash
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
await Promise.all([
|
|
136
|
+
fs.writeFile(blobPath, buffer),
|
|
137
|
+
fs.writeFile(metaPath, JSON.stringify(storedMetadata, null, 2))
|
|
138
|
+
]);
|
|
139
|
+
this.logger.debug(
|
|
140
|
+
`Stored blob ${id} (${buffer.length} bytes, ${storedMetadata.mimeType})`
|
|
141
|
+
);
|
|
142
|
+
this.updateStatsCacheAfterStore(buffer.length);
|
|
143
|
+
return {
|
|
144
|
+
id,
|
|
145
|
+
uri: `blob:${id}`,
|
|
146
|
+
metadata: storedMetadata
|
|
147
|
+
};
|
|
148
|
+
} catch (error) {
|
|
149
|
+
await Promise.allSettled([
|
|
150
|
+
fs.unlink(blobPath).catch(() => {
|
|
151
|
+
}),
|
|
152
|
+
fs.unlink(metaPath).catch(() => {
|
|
153
|
+
})
|
|
154
|
+
]);
|
|
155
|
+
throw StorageError.blobOperationFailed("store", "local", error);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
async retrieve(reference, format = "buffer") {
|
|
159
|
+
if (!this.connected) {
|
|
160
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
161
|
+
}
|
|
162
|
+
const id = this.parseReference(reference);
|
|
163
|
+
const blobPath = path.join(this.storePath, `${id}.dat`);
|
|
164
|
+
const metaPath = path.join(this.storePath, `${id}.meta.json`);
|
|
165
|
+
try {
|
|
166
|
+
const metaContent = await fs.readFile(metaPath, "utf-8");
|
|
167
|
+
const parsed = JSON.parse(metaContent);
|
|
168
|
+
const metadata = parseStoredBlobMetadata(parsed);
|
|
169
|
+
switch (format) {
|
|
170
|
+
case "base64": {
|
|
171
|
+
const buffer = await fs.readFile(blobPath);
|
|
172
|
+
return { format: "base64", data: buffer.toString("base64"), metadata };
|
|
173
|
+
}
|
|
174
|
+
case "buffer": {
|
|
175
|
+
const buffer = await fs.readFile(blobPath);
|
|
176
|
+
return { format: "buffer", data: buffer, metadata };
|
|
177
|
+
}
|
|
178
|
+
case "path": {
|
|
179
|
+
await fs.access(blobPath);
|
|
180
|
+
return { format: "path", data: blobPath, metadata };
|
|
181
|
+
}
|
|
182
|
+
case "stream": {
|
|
183
|
+
const stream = createReadStream(blobPath);
|
|
184
|
+
return { format: "stream", data: stream, metadata };
|
|
185
|
+
}
|
|
186
|
+
case "url": {
|
|
187
|
+
const absolutePath = path.resolve(blobPath);
|
|
188
|
+
return {
|
|
189
|
+
format: "url",
|
|
190
|
+
data: pathToFileURL(absolutePath).toString(),
|
|
191
|
+
metadata
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
default:
|
|
195
|
+
throw StorageError.blobInvalidInput(format, `Unsupported format: ${format}`);
|
|
196
|
+
}
|
|
197
|
+
} catch (error) {
|
|
198
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
199
|
+
throw StorageError.blobNotFound(reference);
|
|
200
|
+
}
|
|
201
|
+
throw StorageError.blobOperationFailed("retrieve", "local", error);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
async exists(reference) {
|
|
205
|
+
if (!this.connected) {
|
|
206
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
207
|
+
}
|
|
208
|
+
const id = this.parseReference(reference);
|
|
209
|
+
const blobPath = path.join(this.storePath, `${id}.dat`);
|
|
210
|
+
const metaPath = path.join(this.storePath, `${id}.meta.json`);
|
|
211
|
+
try {
|
|
212
|
+
await Promise.all([fs.access(blobPath), fs.access(metaPath)]);
|
|
213
|
+
return true;
|
|
214
|
+
} catch {
|
|
215
|
+
return false;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
async delete(reference) {
|
|
219
|
+
if (!this.connected) {
|
|
220
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
221
|
+
}
|
|
222
|
+
const id = this.parseReference(reference);
|
|
223
|
+
const blobPath = path.join(this.storePath, `${id}.dat`);
|
|
224
|
+
const metaPath = path.join(this.storePath, `${id}.meta.json`);
|
|
225
|
+
try {
|
|
226
|
+
const metaContent = await fs.readFile(metaPath, "utf-8");
|
|
227
|
+
const parsed = JSON.parse(metaContent);
|
|
228
|
+
const metadata = parseStoredBlobMetadata(parsed);
|
|
229
|
+
await Promise.all([fs.unlink(blobPath), fs.unlink(metaPath)]);
|
|
230
|
+
this.logger.debug(`Deleted blob: ${id}`);
|
|
231
|
+
this.updateStatsCacheAfterDelete(metadata.size);
|
|
232
|
+
} catch (error) {
|
|
233
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
|
234
|
+
throw StorageError.blobNotFound(reference);
|
|
235
|
+
}
|
|
236
|
+
throw StorageError.blobOperationFailed("delete", "local", error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async cleanup(olderThan) {
|
|
240
|
+
if (!this.connected) {
|
|
241
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
242
|
+
}
|
|
243
|
+
const cleanupDays = this.config.cleanupAfterDays;
|
|
244
|
+
const cutoffDate = olderThan || new Date(Date.now() - cleanupDays * 24 * 60 * 60 * 1e3);
|
|
245
|
+
let deletedCount = 0;
|
|
246
|
+
try {
|
|
247
|
+
const files = await fs.readdir(this.storePath);
|
|
248
|
+
const metaFiles = files.filter((f) => f.endsWith(".meta.json"));
|
|
249
|
+
for (const metaFile of metaFiles) {
|
|
250
|
+
const metaPath = path.join(this.storePath, metaFile);
|
|
251
|
+
const id = metaFile.replace(".meta.json", "");
|
|
252
|
+
const blobPath = path.join(this.storePath, `${id}.dat`);
|
|
253
|
+
try {
|
|
254
|
+
const metaContent = await fs.readFile(metaPath, "utf-8");
|
|
255
|
+
const parsed = JSON.parse(metaContent);
|
|
256
|
+
const metadata = parseStoredBlobMetadata(parsed);
|
|
257
|
+
if (metadata.createdAt < cutoffDate) {
|
|
258
|
+
await Promise.all([
|
|
259
|
+
fs.unlink(blobPath).catch(() => {
|
|
260
|
+
}),
|
|
261
|
+
fs.unlink(metaPath).catch(() => {
|
|
262
|
+
})
|
|
263
|
+
]);
|
|
264
|
+
deletedCount++;
|
|
265
|
+
this.updateStatsCacheAfterDelete(metadata.size);
|
|
266
|
+
this.logger.debug(`Cleaned up old blob: ${id}`);
|
|
267
|
+
}
|
|
268
|
+
} catch (error) {
|
|
269
|
+
this.logger.warn(
|
|
270
|
+
`Failed to process blob metadata ${metaFile}: ${String(error)}`
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (deletedCount > 0) {
|
|
275
|
+
this.logger.info(`Blob cleanup: removed ${deletedCount} old blobs`);
|
|
276
|
+
}
|
|
277
|
+
return deletedCount;
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throw StorageError.blobCleanupFailed("local", error);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
async getStats() {
|
|
283
|
+
if (!this.connected) {
|
|
284
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
285
|
+
}
|
|
286
|
+
const stats = await this.ensureStatsCache();
|
|
287
|
+
return {
|
|
288
|
+
count: stats.count,
|
|
289
|
+
totalSize: stats.totalSize,
|
|
290
|
+
backendType: "local",
|
|
291
|
+
storePath: this.storePath
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
getStoragePath() {
|
|
295
|
+
return this.storePath;
|
|
296
|
+
}
|
|
297
|
+
async listBlobs() {
|
|
298
|
+
if (!this.connected) {
|
|
299
|
+
throw StorageError.blobBackendNotConnected("local");
|
|
300
|
+
}
|
|
301
|
+
try {
|
|
302
|
+
const files = await fs.readdir(this.storePath);
|
|
303
|
+
const metaFiles = files.filter((f) => f.endsWith(".meta.json"));
|
|
304
|
+
const blobs = [];
|
|
305
|
+
for (const metaFile of metaFiles) {
|
|
306
|
+
try {
|
|
307
|
+
const metaPath = path.join(this.storePath, metaFile);
|
|
308
|
+
const metaContent = await fs.readFile(metaPath, "utf-8");
|
|
309
|
+
const parsed = JSON.parse(metaContent);
|
|
310
|
+
const metadata = parseStoredBlobMetadata(parsed);
|
|
311
|
+
const blobId = metaFile.replace(".meta.json", "");
|
|
312
|
+
blobs.push({
|
|
313
|
+
id: blobId,
|
|
314
|
+
uri: `blob:${blobId}`,
|
|
315
|
+
metadata
|
|
316
|
+
});
|
|
317
|
+
} catch (error) {
|
|
318
|
+
this.logger.warn(
|
|
319
|
+
`Failed to process blob metadata ${metaFile}: ${String(error)}`
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return blobs;
|
|
324
|
+
} catch (error) {
|
|
325
|
+
this.logger.warn(`Failed to list blobs: ${String(error)}`);
|
|
326
|
+
return [];
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
async ensureStatsCache() {
|
|
330
|
+
const now = Date.now();
|
|
331
|
+
const cacheAge = now - this.lastStatsRefresh;
|
|
332
|
+
const isStale = cacheAge > LocalBlobStore.STATS_REFRESH_INTERVAL_MS;
|
|
333
|
+
if (!this.statsCache || isStale) {
|
|
334
|
+
if (!this.statsCachePromise) {
|
|
335
|
+
this.statsCachePromise = this.refreshStatsCache();
|
|
336
|
+
}
|
|
337
|
+
try {
|
|
338
|
+
await this.statsCachePromise;
|
|
339
|
+
this.lastStatsRefresh = now;
|
|
340
|
+
} finally {
|
|
341
|
+
this.statsCachePromise = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return this.statsCache ?? { count: 0, totalSize: 0 };
|
|
345
|
+
}
|
|
346
|
+
async refreshStatsCache() {
|
|
347
|
+
const stats = { count: 0, totalSize: 0 };
|
|
348
|
+
try {
|
|
349
|
+
const files = await fs.readdir(this.storePath);
|
|
350
|
+
const datFiles = files.filter((f) => f.endsWith(".dat"));
|
|
351
|
+
stats.count = datFiles.length;
|
|
352
|
+
for (const datFile of datFiles) {
|
|
353
|
+
try {
|
|
354
|
+
const stat = await fs.stat(path.join(this.storePath, datFile));
|
|
355
|
+
stats.totalSize += stat.size;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
this.logger.debug(
|
|
358
|
+
`Skipping size calculation for ${datFile}: ${error instanceof Error ? error.message : String(error)}`
|
|
359
|
+
);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
} catch (error) {
|
|
363
|
+
this.logger.warn(`Failed to refresh blob stats cache: ${String(error)}`);
|
|
364
|
+
}
|
|
365
|
+
this.statsCache = stats;
|
|
366
|
+
}
|
|
367
|
+
updateStatsCacheAfterStore(size) {
|
|
368
|
+
if (!this.statsCache) {
|
|
369
|
+
this.statsCache = { count: 1, totalSize: size };
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
this.statsCache.count += 1;
|
|
373
|
+
this.statsCache.totalSize += size;
|
|
374
|
+
}
|
|
375
|
+
updateStatsCacheAfterDelete(size) {
|
|
376
|
+
if (!this.statsCache) {
|
|
377
|
+
this.statsCache = { count: 0, totalSize: 0 };
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
this.statsCache.count = Math.max(0, this.statsCache.count - 1);
|
|
381
|
+
this.statsCache.totalSize = Math.max(0, this.statsCache.totalSize - size);
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Convert various input types to Buffer
|
|
385
|
+
*/
|
|
386
|
+
async inputToBuffer(input) {
|
|
387
|
+
if (Buffer.isBuffer(input)) {
|
|
388
|
+
return input;
|
|
389
|
+
}
|
|
390
|
+
if (input instanceof Uint8Array) {
|
|
391
|
+
return Buffer.from(input);
|
|
392
|
+
}
|
|
393
|
+
if (input instanceof ArrayBuffer) {
|
|
394
|
+
return Buffer.from(new Uint8Array(input));
|
|
395
|
+
}
|
|
396
|
+
if (typeof input === "string") {
|
|
397
|
+
if (input.startsWith("data:")) {
|
|
398
|
+
const commaIndex = input.indexOf(",");
|
|
399
|
+
if (commaIndex !== -1 && input.includes(";base64,")) {
|
|
400
|
+
const base64Data = input.substring(commaIndex + 1);
|
|
401
|
+
return Buffer.from(base64Data, "base64");
|
|
402
|
+
}
|
|
403
|
+
throw StorageError.blobEncodingError(
|
|
404
|
+
"inputToBuffer",
|
|
405
|
+
"Unsupported data URI format"
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
if ((input.includes("/") || input.includes("\\")) && input.length > 1) {
|
|
409
|
+
try {
|
|
410
|
+
await fs.access(input);
|
|
411
|
+
return await fs.readFile(input);
|
|
412
|
+
} catch {
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
try {
|
|
416
|
+
return Buffer.from(input, "base64");
|
|
417
|
+
} catch {
|
|
418
|
+
throw StorageError.blobEncodingError("inputToBuffer", "Invalid base64 string");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
throw StorageError.blobInvalidInput(input, `Unsupported input type: ${typeof input}`);
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* Parse blob reference to extract ID
|
|
425
|
+
*/
|
|
426
|
+
parseReference(reference) {
|
|
427
|
+
if (!reference) {
|
|
428
|
+
throw StorageError.blobInvalidReference(reference, "Empty reference");
|
|
429
|
+
}
|
|
430
|
+
if (reference.startsWith("blob:")) {
|
|
431
|
+
const id = reference.substring(5);
|
|
432
|
+
if (!id) {
|
|
433
|
+
throw StorageError.blobInvalidReference(reference, "Empty blob ID after prefix");
|
|
434
|
+
}
|
|
435
|
+
return id;
|
|
436
|
+
}
|
|
437
|
+
return reference;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Detect MIME type from buffer content and/or filename
|
|
441
|
+
*/
|
|
442
|
+
detectMimeType(buffer, filename) {
|
|
443
|
+
const header = buffer.subarray(0, 16);
|
|
444
|
+
if (header.length >= 3) {
|
|
445
|
+
const jpegSignature = header.subarray(0, 3);
|
|
446
|
+
if (jpegSignature.equals(Buffer.from([255, 216, 255]))) {
|
|
447
|
+
return "image/jpeg";
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
if (header.length >= 4) {
|
|
451
|
+
const signature = header.subarray(0, 4);
|
|
452
|
+
if (signature.equals(Buffer.from([137, 80, 78, 71]))) return "image/png";
|
|
453
|
+
if (signature.equals(Buffer.from([71, 73, 70, 56]))) return "image/gif";
|
|
454
|
+
if (signature.equals(Buffer.from([37, 80, 68, 70]))) return "application/pdf";
|
|
455
|
+
}
|
|
456
|
+
if (filename) {
|
|
457
|
+
const ext = path.extname(filename).toLowerCase();
|
|
458
|
+
const mimeTypes = {
|
|
459
|
+
".jpg": "image/jpeg",
|
|
460
|
+
".jpeg": "image/jpeg",
|
|
461
|
+
".png": "image/png",
|
|
462
|
+
".gif": "image/gif",
|
|
463
|
+
".pdf": "application/pdf",
|
|
464
|
+
".txt": "text/plain",
|
|
465
|
+
".json": "application/json",
|
|
466
|
+
".xml": "text/xml",
|
|
467
|
+
".html": "text/html",
|
|
468
|
+
".css": "text/css",
|
|
469
|
+
".js": "text/javascript",
|
|
470
|
+
".mp3": "audio/mpeg",
|
|
471
|
+
".mp4": "video/mp4",
|
|
472
|
+
".wav": "audio/wav"
|
|
473
|
+
};
|
|
474
|
+
if (mimeTypes[ext]) return mimeTypes[ext];
|
|
475
|
+
}
|
|
476
|
+
if (this.isTextBuffer(buffer)) {
|
|
477
|
+
return "text/plain";
|
|
478
|
+
}
|
|
479
|
+
return "application/octet-stream";
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Check if buffer contains text content
|
|
483
|
+
*/
|
|
484
|
+
isTextBuffer(buffer) {
|
|
485
|
+
let printableCount = 0;
|
|
486
|
+
const sampleSize = Math.min(512, buffer.length);
|
|
487
|
+
for (let i = 0; i < sampleSize; i++) {
|
|
488
|
+
const byte = buffer[i];
|
|
489
|
+
if (byte !== void 0 && (byte >= 32 && byte <= 126 || byte === 9 || byte === 10 || byte === 13)) {
|
|
490
|
+
printableCount++;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return printableCount / sampleSize > 0.7;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
export {
|
|
497
|
+
LocalBlobStore
|
|
498
|
+
};
|