@powerhousedao/reactor-attachments 6.1.0 → 6.2.0-dev.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/dist/client.d.ts +23 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +61 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +24 -429
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +212 -491
- package/dist/index.js.map +1 -1
- package/dist/null-attachment-transport-BBhQIk5A.d.ts +617 -0
- package/dist/null-attachment-transport-BBhQIk5A.d.ts.map +1 -0
- package/dist/null-attachment-transport-Drx03s02.js +686 -0
- package/dist/null-attachment-transport-Drx03s02.js.map +1 -0
- package/package.json +7 -2
package/dist/index.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { _ as SizeMismatch, a as RemoteAttachmentUpload, c as AttachmentService, d as AttachmentAlreadyExists, f as AttachmentNotFound, g as ReservationNotFound, h as InvalidAttachmentRef, i as RemoteAttachmentUploadFactory, l as createRef, m as HashMismatch, n as createRemoteAttachmentService, o as RemoteReservationStore, p as AttachmentPending, r as RemoteAttachmentStore, s as SwitchboardAttachmentTransport, t as NullAttachmentTransport, u as parseRef, v as UploadTooLarge } from "./null-attachment-transport-Drx03s02.js";
|
|
1
2
|
import { dirname, join } from "node:path";
|
|
2
3
|
import { Migrator, sql } from "kysely";
|
|
3
4
|
import { mkdir, rename, rm } from "node:fs/promises";
|
|
@@ -16,83 +17,6 @@ var __exportAll = (all, no_symbols) => {
|
|
|
16
17
|
return target;
|
|
17
18
|
};
|
|
18
19
|
//#endregion
|
|
19
|
-
//#region src/errors.ts
|
|
20
|
-
/**
|
|
21
|
-
* Thrown when an attachment ref or hash is not known to the store.
|
|
22
|
-
*/
|
|
23
|
-
var AttachmentNotFound = class extends Error {
|
|
24
|
-
constructor(identifier) {
|
|
25
|
-
super(`Attachment not found: ${identifier}`);
|
|
26
|
-
this.name = "AttachmentNotFound";
|
|
27
|
-
}
|
|
28
|
-
};
|
|
29
|
-
/**
|
|
30
|
-
* Thrown when a reservation ID is not found in the reservation store.
|
|
31
|
-
*/
|
|
32
|
-
var ReservationNotFound = class extends Error {
|
|
33
|
-
constructor(reservationId) {
|
|
34
|
-
super(`Reservation not found: ${reservationId}`);
|
|
35
|
-
this.name = "ReservationNotFound";
|
|
36
|
-
}
|
|
37
|
-
};
|
|
38
|
-
/**
|
|
39
|
-
* Thrown when an attachment ref string does not match the expected format.
|
|
40
|
-
*/
|
|
41
|
-
var InvalidAttachmentRef = class extends Error {
|
|
42
|
-
constructor(ref) {
|
|
43
|
-
super(`Invalid attachment ref: ${ref}`);
|
|
44
|
-
this.name = "InvalidAttachmentRef";
|
|
45
|
-
}
|
|
46
|
-
};
|
|
47
|
-
/**
|
|
48
|
-
* Thrown when an upload exceeds the configured maximum byte cap.
|
|
49
|
-
* Route handlers should map this to HTTP 413 Payload Too Large.
|
|
50
|
-
*/
|
|
51
|
-
var UploadTooLarge = class extends Error {
|
|
52
|
-
maxBytes;
|
|
53
|
-
constructor(maxBytes) {
|
|
54
|
-
super(`Upload exceeds maximum size of ${maxBytes} bytes`);
|
|
55
|
-
this.name = "UploadTooLarge";
|
|
56
|
-
this.maxBytes = maxBytes;
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
//#endregion
|
|
60
|
-
//#region src/ref.ts
|
|
61
|
-
const REF_PATTERN = /^attachment:\/\/v(\d+):(.+)$/;
|
|
62
|
-
const DEFAULT_VERSION = 1;
|
|
63
|
-
function parseRef(ref) {
|
|
64
|
-
const match = REF_PATTERN.exec(ref);
|
|
65
|
-
if (!match) throw new InvalidAttachmentRef(ref);
|
|
66
|
-
return {
|
|
67
|
-
version: Number(match[1]),
|
|
68
|
-
hash: match[2]
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
function createRef(hash, version = DEFAULT_VERSION) {
|
|
72
|
-
return `attachment://v${version}:${hash}`;
|
|
73
|
-
}
|
|
74
|
-
//#endregion
|
|
75
|
-
//#region src/attachment-service.ts
|
|
76
|
-
var AttachmentService = class {
|
|
77
|
-
constructor(store, reservations, uploadFactory) {
|
|
78
|
-
this.store = store;
|
|
79
|
-
this.reservations = reservations;
|
|
80
|
-
this.uploadFactory = uploadFactory;
|
|
81
|
-
}
|
|
82
|
-
async reserve(options) {
|
|
83
|
-
const reservation = await this.reservations.create(options);
|
|
84
|
-
return this.uploadFactory.createUpload(reservation.reservationId, options);
|
|
85
|
-
}
|
|
86
|
-
async stat(ref) {
|
|
87
|
-
const { hash } = parseRef(ref);
|
|
88
|
-
return this.store.stat(hash);
|
|
89
|
-
}
|
|
90
|
-
async get(ref, signal) {
|
|
91
|
-
const { hash } = parseRef(ref);
|
|
92
|
-
return this.store.get(hash, signal);
|
|
93
|
-
}
|
|
94
|
-
};
|
|
95
|
-
//#endregion
|
|
96
20
|
//#region src/storage/fs/attachment-fs.ts
|
|
97
21
|
/**
|
|
98
22
|
* Compute the relative storage path for an attachment hash.
|
|
@@ -115,13 +39,24 @@ async function writeAttachmentBytes(path, data) {
|
|
|
115
39
|
const { done, value } = await reader.read();
|
|
116
40
|
if (done) break;
|
|
117
41
|
bytesWritten += value.byteLength;
|
|
118
|
-
if (!writer.write(value)) await new Promise((resolve) =>
|
|
42
|
+
if (!writer.write(value)) await new Promise((resolve, reject) => {
|
|
43
|
+
const onDrain = () => {
|
|
44
|
+
writer.off("error", onError);
|
|
45
|
+
resolve();
|
|
46
|
+
};
|
|
47
|
+
const onError = (err) => {
|
|
48
|
+
writer.off("drain", onDrain);
|
|
49
|
+
reject(err);
|
|
50
|
+
};
|
|
51
|
+
writer.once("drain", onDrain);
|
|
52
|
+
writer.once("error", onError);
|
|
53
|
+
});
|
|
119
54
|
}
|
|
120
55
|
} finally {
|
|
121
56
|
reader.releaseLock();
|
|
122
57
|
await new Promise((resolve, reject) => {
|
|
123
58
|
writer.end(() => resolve());
|
|
124
|
-
writer.
|
|
59
|
+
writer.once("error", reject);
|
|
125
60
|
});
|
|
126
61
|
}
|
|
127
62
|
return bytesWritten;
|
|
@@ -149,9 +84,16 @@ async function deleteAttachmentBytes(path) {
|
|
|
149
84
|
*
|
|
150
85
|
* If `maxBytes` is set and the input exceeds it, the temp file is removed and
|
|
151
86
|
* `UploadTooLarge` is thrown.
|
|
87
|
+
*
|
|
88
|
+
* If `declaredSizeBytes` is set, the byte count is enforced as a contract:
|
|
89
|
+
* mid-stream, the moment the count exceeds the declaration the reader is
|
|
90
|
+
* released and `SizeMismatch` is thrown without consuming the rest of the
|
|
91
|
+
* stream. At stream end, if the count does not equal the declaration,
|
|
92
|
+
* `SizeMismatch` is thrown. Both the `maxBytes` and `declaredSizeBytes`
|
|
93
|
+
* checks apply; `maxBytes` is evaluated first on each chunk.
|
|
152
94
|
*/
|
|
153
95
|
async function streamHashAndWrite(basePath, data, options = {}) {
|
|
154
|
-
const { maxBytes } = options;
|
|
96
|
+
const { maxBytes, declaredSizeBytes } = options;
|
|
155
97
|
const tmpDir = join(basePath, ".tmp");
|
|
156
98
|
await mkdir(tmpDir, { recursive: true });
|
|
157
99
|
const tempPath = join(tmpDir, randomUUID());
|
|
@@ -166,6 +108,7 @@ async function streamHashAndWrite(basePath, data, options = {}) {
|
|
|
166
108
|
if (done) break;
|
|
167
109
|
sizeBytes += value.byteLength;
|
|
168
110
|
if (maxBytes !== void 0 && sizeBytes > maxBytes) throw new UploadTooLarge(maxBytes);
|
|
111
|
+
if (declaredSizeBytes !== void 0 && sizeBytes > declaredSizeBytes) throw new SizeMismatch(declaredSizeBytes, sizeBytes);
|
|
169
112
|
hasher.update(value);
|
|
170
113
|
if (!writer.write(value)) await new Promise((resolve, reject) => {
|
|
171
114
|
const onDrain = () => {
|
|
@@ -185,17 +128,30 @@ async function streamHashAndWrite(basePath, data, options = {}) {
|
|
|
185
128
|
} finally {
|
|
186
129
|
reader.releaseLock();
|
|
187
130
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
131
|
+
let endError;
|
|
132
|
+
try {
|
|
133
|
+
await new Promise((resolve, reject) => {
|
|
134
|
+
writer.end((err) => {
|
|
135
|
+
if (err) reject(err);
|
|
136
|
+
else resolve();
|
|
137
|
+
});
|
|
138
|
+
writer.once("error", reject);
|
|
192
139
|
});
|
|
193
|
-
|
|
194
|
-
|
|
140
|
+
} catch (err) {
|
|
141
|
+
endError = err instanceof Error ? err : new Error(String(err));
|
|
142
|
+
}
|
|
195
143
|
if (caughtError) {
|
|
196
144
|
await rm(tempPath, { force: true });
|
|
197
145
|
throw caughtError;
|
|
198
146
|
}
|
|
147
|
+
if (endError) {
|
|
148
|
+
await rm(tempPath, { force: true });
|
|
149
|
+
throw endError;
|
|
150
|
+
}
|
|
151
|
+
if (declaredSizeBytes !== void 0 && sizeBytes !== declaredSizeBytes) {
|
|
152
|
+
await rm(tempPath, { force: true });
|
|
153
|
+
throw new SizeMismatch(declaredSizeBytes, sizeBytes);
|
|
154
|
+
}
|
|
199
155
|
return {
|
|
200
156
|
tempPath,
|
|
201
157
|
hash: hasher.digest("hex"),
|
|
@@ -214,7 +170,8 @@ function rowToHeader$1(row) {
|
|
|
214
170
|
status: row.status,
|
|
215
171
|
source: row.source,
|
|
216
172
|
createdAtUtc: row.created_at_utc,
|
|
217
|
-
lastAccessedAtUtc: row.last_accessed_at_utc
|
|
173
|
+
lastAccessedAtUtc: row.last_accessed_at_utc,
|
|
174
|
+
expiresAtUtc: null
|
|
218
175
|
};
|
|
219
176
|
}
|
|
220
177
|
function wrapStreamWithCleanup(source, cleanup) {
|
|
@@ -254,30 +211,62 @@ var KyselyAttachmentStore = class {
|
|
|
254
211
|
}
|
|
255
212
|
async stat(hash) {
|
|
256
213
|
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
257
|
-
if (
|
|
258
|
-
|
|
214
|
+
if (row) return rowToHeader$1(row);
|
|
215
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
216
|
+
const pending = await this.findPendingReservation(hash, now);
|
|
217
|
+
if (pending) return {
|
|
218
|
+
hash,
|
|
219
|
+
mimeType: pending.mime_type,
|
|
220
|
+
fileName: pending.file_name,
|
|
221
|
+
sizeBytes: Number(pending.size_bytes),
|
|
222
|
+
extension: pending.extension,
|
|
223
|
+
status: "pending",
|
|
224
|
+
source: "local",
|
|
225
|
+
createdAtUtc: pending.created_at_utc,
|
|
226
|
+
lastAccessedAtUtc: pending.created_at_utc,
|
|
227
|
+
expiresAtUtc: pending.expires_at_utc
|
|
228
|
+
};
|
|
229
|
+
throw new AttachmentNotFound(hash);
|
|
259
230
|
}
|
|
260
231
|
async has(hash) {
|
|
261
232
|
return (await this.db.selectFrom("attachment").select("status").where("hash", "=", hash).executeTakeFirst())?.status === "available";
|
|
262
233
|
}
|
|
263
234
|
async get(hash, signal) {
|
|
264
235
|
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
236
|
+
if (row) {
|
|
237
|
+
if (row.status === "evicted") {
|
|
238
|
+
const remote = await this.transport.fetch(hash, signal);
|
|
239
|
+
if (remote.kind === "data") {
|
|
240
|
+
await this.put(hash, remote.response.metadata, remote.response.body);
|
|
241
|
+
return this.get(hash, signal);
|
|
242
|
+
}
|
|
243
|
+
if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
|
|
244
|
+
throw new AttachmentNotFound(hash);
|
|
245
|
+
}
|
|
246
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
247
|
+
await this.db.updateTable("attachment").set({ last_accessed_at_utc: now }).where("hash", "=", hash).execute();
|
|
248
|
+
const header = rowToHeader$1(row);
|
|
249
|
+
header.lastAccessedAtUtc = now;
|
|
250
|
+
this.acquireReader(hash);
|
|
251
|
+
return {
|
|
252
|
+
header,
|
|
253
|
+
body: wrapStreamWithCleanup(readAttachmentStream(join(this.basePath, row.storage_path)), () => this.releaseReader(hash))
|
|
254
|
+
};
|
|
271
255
|
}
|
|
272
256
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
257
|
+
const pending = await this.findPendingReservation(hash, now);
|
|
258
|
+
if (pending) throw new AttachmentPending(hash, pending.expires_at_utc, {
|
|
259
|
+
mimeType: pending.mime_type,
|
|
260
|
+
fileName: pending.file_name,
|
|
261
|
+
sizeBytes: pending.size_bytes
|
|
262
|
+
});
|
|
263
|
+
const remote = await this.transport.fetch(hash, signal);
|
|
264
|
+
if (remote.kind === "data") {
|
|
265
|
+
await this.put(hash, remote.response.metadata, remote.response.body);
|
|
266
|
+
return this.get(hash, signal);
|
|
267
|
+
}
|
|
268
|
+
if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
|
|
269
|
+
throw new AttachmentNotFound(hash);
|
|
281
270
|
}
|
|
282
271
|
async put(hash, metadata, data) {
|
|
283
272
|
const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
@@ -317,6 +306,25 @@ var KyselyAttachmentStore = class {
|
|
|
317
306
|
const result = await this.db.selectFrom("attachment").select(sql`COALESCE(SUM(size_bytes), 0)`.as("total")).where("status", "=", "available").executeTakeFirst();
|
|
318
307
|
return Number(result?.total ?? 0);
|
|
319
308
|
}
|
|
309
|
+
async findPendingReservation(hash, now) {
|
|
310
|
+
const row = await this.db.selectFrom("attachment_reservation as r").leftJoin("attachment as a", "a.hash", "r.client_hash").select([
|
|
311
|
+
"r.mime_type",
|
|
312
|
+
"r.file_name",
|
|
313
|
+
"r.extension",
|
|
314
|
+
"r.size_bytes",
|
|
315
|
+
"r.created_at_utc",
|
|
316
|
+
"r.expires_at_utc"
|
|
317
|
+
]).where("r.client_hash", "=", hash).where("r.deleted_at_utc", "is", null).where("r.expires_at_utc", ">", now).where("r.size_bytes", "is not", null).where("a.hash", "is", null).orderBy("r.expires_at_utc", "desc").executeTakeFirst();
|
|
318
|
+
if (!row) return null;
|
|
319
|
+
return {
|
|
320
|
+
mime_type: row.mime_type,
|
|
321
|
+
file_name: row.file_name,
|
|
322
|
+
extension: row.extension,
|
|
323
|
+
size_bytes: Number(row.size_bytes),
|
|
324
|
+
created_at_utc: row.created_at_utc,
|
|
325
|
+
expires_at_utc: row.expires_at_utc
|
|
326
|
+
};
|
|
327
|
+
}
|
|
320
328
|
acquireReader(hash) {
|
|
321
329
|
this.activeReaders.set(hash, (this.activeReaders.get(hash) ?? 0) + 1);
|
|
322
330
|
}
|
|
@@ -339,7 +347,9 @@ function rowToReservation(row) {
|
|
|
339
347
|
fileName: row.file_name,
|
|
340
348
|
extension: row.extension,
|
|
341
349
|
createdAtUtc: row.created_at_utc,
|
|
342
|
-
expiresAtUtc: row.expires_at_utc
|
|
350
|
+
expiresAtUtc: row.expires_at_utc,
|
|
351
|
+
clientHash: row.client_hash,
|
|
352
|
+
sizeBytes: row.size_bytes !== null ? Number(row.size_bytes) : null
|
|
343
353
|
};
|
|
344
354
|
}
|
|
345
355
|
var KyselyReservationStore = class {
|
|
@@ -359,7 +369,9 @@ var KyselyReservationStore = class {
|
|
|
359
369
|
file_name: options.fileName,
|
|
360
370
|
extension: options.extension ?? null,
|
|
361
371
|
created_at_utc: now,
|
|
362
|
-
expires_at_utc: expiresAt
|
|
372
|
+
expires_at_utc: expiresAt,
|
|
373
|
+
client_hash: options.clientHash ?? null,
|
|
374
|
+
size_bytes: options.sizeBytes ?? null
|
|
363
375
|
}).returningAll().executeTakeFirstOrThrow());
|
|
364
376
|
}
|
|
365
377
|
async get(reservationId) {
|
|
@@ -379,72 +391,89 @@ var KyselyReservationStore = class {
|
|
|
379
391
|
//#endregion
|
|
380
392
|
//#region src/storage/migrations/001_create_attachment_table.ts
|
|
381
393
|
var _001_create_attachment_table_exports = /* @__PURE__ */ __exportAll({
|
|
382
|
-
down: () => down$
|
|
383
|
-
up: () => up$
|
|
394
|
+
down: () => down$5,
|
|
395
|
+
up: () => up$5
|
|
384
396
|
});
|
|
385
|
-
async function up$
|
|
397
|
+
async function up$5(db) {
|
|
386
398
|
await db.schema.createTable("attachment").addColumn("hash", "text", (col) => col.primaryKey()).addColumn("mime_type", "text", (col) => col.notNull()).addColumn("file_name", "text", (col) => col.notNull()).addColumn("size_bytes", "bigint", (col) => col.notNull()).addColumn("extension", "text").addColumn("status", "text", (col) => col.notNull().defaultTo("available")).addColumn("storage_path", "text", (col) => col.notNull()).addColumn("source", "text", (col) => col.notNull().defaultTo("local")).addColumn("created_at_utc", "text", (col) => col.notNull()).addColumn("last_accessed_at_utc", "text", (col) => col.notNull()).execute();
|
|
387
399
|
await db.schema.createIndex("idx_attachment_status").on("attachment").column("status").execute();
|
|
388
400
|
await db.schema.createIndex("idx_attachment_lru").on("attachment").columns(["status", "last_accessed_at_utc"]).execute();
|
|
389
401
|
}
|
|
390
|
-
async function down$
|
|
402
|
+
async function down$5(db) {
|
|
391
403
|
await db.schema.dropTable("attachment").ifExists().execute();
|
|
392
404
|
}
|
|
393
405
|
//#endregion
|
|
394
406
|
//#region src/storage/migrations/002_create_reservation_table.ts
|
|
395
407
|
var _002_create_reservation_table_exports = /* @__PURE__ */ __exportAll({
|
|
396
|
-
down: () => down$
|
|
397
|
-
up: () => up$
|
|
408
|
+
down: () => down$4,
|
|
409
|
+
up: () => up$4
|
|
398
410
|
});
|
|
399
|
-
async function up$
|
|
411
|
+
async function up$4(db) {
|
|
400
412
|
await db.schema.createTable("attachment_reservation").addColumn("reservation_id", "text", (col) => col.primaryKey()).addColumn("mime_type", "text", (col) => col.notNull()).addColumn("file_name", "text", (col) => col.notNull()).addColumn("extension", "text").addColumn("created_at_utc", "text", (col) => col.notNull()).execute();
|
|
401
413
|
}
|
|
402
|
-
async function down$
|
|
414
|
+
async function down$4(db) {
|
|
403
415
|
await db.schema.dropTable("attachment_reservation").ifExists().execute();
|
|
404
416
|
}
|
|
405
417
|
//#endregion
|
|
406
418
|
//#region src/storage/migrations/003_add_reservation_expires_at.ts
|
|
407
419
|
var _003_add_reservation_expires_at_exports = /* @__PURE__ */ __exportAll({
|
|
408
|
-
down: () => down$
|
|
409
|
-
up: () => up$
|
|
420
|
+
down: () => down$3,
|
|
421
|
+
up: () => up$3
|
|
410
422
|
});
|
|
411
|
-
async function up$
|
|
423
|
+
async function up$3(db) {
|
|
412
424
|
await db.schema.alterTable("attachment_reservation").addColumn("expires_at_utc", "text").execute();
|
|
413
425
|
await db.updateTable("attachment_reservation").set({ expires_at_utc: sql`created_at_utc` }).where("expires_at_utc", "is", null).execute();
|
|
414
426
|
await db.schema.alterTable("attachment_reservation").alterColumn("expires_at_utc", (col) => col.setNotNull()).execute();
|
|
415
427
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
416
428
|
}
|
|
417
|
-
async function down$
|
|
429
|
+
async function down$3(db) {
|
|
418
430
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
419
431
|
await db.schema.alterTable("attachment_reservation").dropColumn("expires_at_utc").execute();
|
|
420
432
|
}
|
|
421
433
|
//#endregion
|
|
422
434
|
//#region src/storage/migrations/004_add_reservation_soft_delete.ts
|
|
423
435
|
var _004_add_reservation_soft_delete_exports = /* @__PURE__ */ __exportAll({
|
|
424
|
-
down: () => down$
|
|
425
|
-
up: () => up$
|
|
436
|
+
down: () => down$2,
|
|
437
|
+
up: () => up$2
|
|
426
438
|
});
|
|
427
|
-
async function up$
|
|
439
|
+
async function up$2(db) {
|
|
428
440
|
await db.schema.alterTable("attachment_reservation").addColumn("deleted_at_utc", "text").execute();
|
|
429
441
|
}
|
|
430
|
-
async function down$
|
|
442
|
+
async function down$2(db) {
|
|
431
443
|
await db.schema.alterTable("attachment_reservation").dropColumn("deleted_at_utc").execute();
|
|
432
444
|
}
|
|
433
445
|
//#endregion
|
|
434
446
|
//#region src/storage/migrations/005_add_reservation_active_index.ts
|
|
435
447
|
var _005_add_reservation_active_index_exports = /* @__PURE__ */ __exportAll({
|
|
436
|
-
down: () => down,
|
|
437
|
-
up: () => up
|
|
448
|
+
down: () => down$1,
|
|
449
|
+
up: () => up$1
|
|
438
450
|
});
|
|
439
|
-
async function up(db) {
|
|
451
|
+
async function up$1(db) {
|
|
440
452
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
441
453
|
await db.schema.createIndex("idx_reservation_expires_at_active").on("attachment_reservation").column("expires_at_utc").where(sql`deleted_at_utc IS NULL`).execute();
|
|
442
454
|
}
|
|
443
|
-
async function down(db) {
|
|
455
|
+
async function down$1(db) {
|
|
444
456
|
await db.schema.dropIndex("idx_reservation_expires_at_active").ifExists().execute();
|
|
445
457
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
446
458
|
}
|
|
447
459
|
//#endregion
|
|
460
|
+
//#region src/storage/migrations/006_add_reservation_client_hash.ts
|
|
461
|
+
var _006_add_reservation_client_hash_exports = /* @__PURE__ */ __exportAll({
|
|
462
|
+
down: () => down,
|
|
463
|
+
up: () => up
|
|
464
|
+
});
|
|
465
|
+
async function up(db) {
|
|
466
|
+
await db.schema.alterTable("attachment_reservation").addColumn("client_hash", "text").execute();
|
|
467
|
+
await db.schema.alterTable("attachment_reservation").addColumn("size_bytes", "bigint").execute();
|
|
468
|
+
await db.schema.alterTable("attachment_reservation").addCheckConstraint("attachment_reservation_hash_size_check", sql`client_hash is null or size_bytes is not null`).execute();
|
|
469
|
+
await db.schema.createIndex("idx_reservation_client_hash").on("attachment_reservation").column("client_hash").execute();
|
|
470
|
+
}
|
|
471
|
+
async function down(db) {
|
|
472
|
+
await db.schema.dropIndex("idx_reservation_client_hash").ifExists().execute();
|
|
473
|
+
await db.schema.alterTable("attachment_reservation").dropColumn("size_bytes").execute();
|
|
474
|
+
await db.schema.alterTable("attachment_reservation").dropColumn("client_hash").execute();
|
|
475
|
+
}
|
|
476
|
+
//#endregion
|
|
448
477
|
//#region src/storage/migrations/migrator.ts
|
|
449
478
|
const ATTACHMENT_SCHEMA = "attachments";
|
|
450
479
|
const migrations = {
|
|
@@ -452,7 +481,8 @@ const migrations = {
|
|
|
452
481
|
"002_create_reservation_table": _002_create_reservation_table_exports,
|
|
453
482
|
"003_add_reservation_expires_at": _003_add_reservation_expires_at_exports,
|
|
454
483
|
"004_add_reservation_soft_delete": _004_add_reservation_soft_delete_exports,
|
|
455
|
-
"005_add_reservation_active_index": _005_add_reservation_active_index_exports
|
|
484
|
+
"005_add_reservation_active_index": _005_add_reservation_active_index_exports,
|
|
485
|
+
"006_add_reservation_client_hash": _006_add_reservation_client_hash_exports
|
|
456
486
|
};
|
|
457
487
|
var ProgrammaticMigrationProvider = class {
|
|
458
488
|
getMigrations() {
|
|
@@ -507,21 +537,35 @@ function rowToHeader(row) {
|
|
|
507
537
|
status: row.status,
|
|
508
538
|
source: row.source,
|
|
509
539
|
createdAtUtc: row.created_at_utc,
|
|
510
|
-
lastAccessedAtUtc: row.last_accessed_at_utc
|
|
540
|
+
lastAccessedAtUtc: row.last_accessed_at_utc,
|
|
541
|
+
expiresAtUtc: null
|
|
511
542
|
};
|
|
512
543
|
}
|
|
513
544
|
var DirectAttachmentUpload = class {
|
|
514
545
|
reservationId;
|
|
515
|
-
|
|
516
|
-
|
|
546
|
+
ref;
|
|
547
|
+
expiresAtUtc;
|
|
548
|
+
constructor(reservation, db, basePath, reservations, maxBytes) {
|
|
549
|
+
this.reservation = reservation;
|
|
517
550
|
this.db = db;
|
|
518
551
|
this.basePath = basePath;
|
|
519
552
|
this.reservations = reservations;
|
|
520
553
|
this.maxBytes = maxBytes;
|
|
521
|
-
this.reservationId = reservationId;
|
|
554
|
+
this.reservationId = reservation.reservationId;
|
|
555
|
+
this.ref = reservation.clientHash != null ? createRef(reservation.clientHash) : null;
|
|
556
|
+
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
522
557
|
}
|
|
523
558
|
async send(data) {
|
|
524
|
-
|
|
559
|
+
if (this.reservation.clientHash != null && this.reservation.sizeBytes == null) throw new Error("hash-first reservation missing sizeBytes");
|
|
560
|
+
const declaredSizeBytes = this.reservation.clientHash != null ? this.reservation.sizeBytes ?? void 0 : void 0;
|
|
561
|
+
const { tempPath, hash, sizeBytes } = await streamHashAndWrite(this.basePath, data, {
|
|
562
|
+
maxBytes: this.maxBytes,
|
|
563
|
+
declaredSizeBytes
|
|
564
|
+
});
|
|
565
|
+
if (this.reservation.clientHash != null && hash !== this.reservation.clientHash) {
|
|
566
|
+
await rm(tempPath, { force: true });
|
|
567
|
+
throw new HashMismatch(this.reservation.clientHash, hash);
|
|
568
|
+
}
|
|
525
569
|
try {
|
|
526
570
|
const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
527
571
|
if (existing?.status === "available") await rm(tempPath, { force: true });
|
|
@@ -533,10 +577,10 @@ var DirectAttachmentUpload = class {
|
|
|
533
577
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
534
578
|
if (!existing) await this.db.insertInto("attachment").values({
|
|
535
579
|
hash,
|
|
536
|
-
mime_type: this.
|
|
537
|
-
file_name: this.
|
|
580
|
+
mime_type: this.reservation.mimeType,
|
|
581
|
+
file_name: this.reservation.fileName,
|
|
538
582
|
size_bytes: sizeBytes,
|
|
539
|
-
extension: this.
|
|
583
|
+
extension: this.reservation.extension ?? null,
|
|
540
584
|
status: "available",
|
|
541
585
|
storage_path: relPath,
|
|
542
586
|
source: "local",
|
|
@@ -572,360 +616,8 @@ var DirectAttachmentUploadFactory = class {
|
|
|
572
616
|
this.reservations = reservations;
|
|
573
617
|
this.maxBytes = maxBytes;
|
|
574
618
|
}
|
|
575
|
-
createUpload(
|
|
576
|
-
return new DirectAttachmentUpload(
|
|
577
|
-
}
|
|
578
|
-
};
|
|
579
|
-
//#endregion
|
|
580
|
-
//#region src/switchboard/build-auth-headers.ts
|
|
581
|
-
async function buildAuthHeaders(url, jwtHandler) {
|
|
582
|
-
const headers = {};
|
|
583
|
-
if (jwtHandler) {
|
|
584
|
-
const token = await jwtHandler(url);
|
|
585
|
-
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
586
|
-
}
|
|
587
|
-
return headers;
|
|
588
|
-
}
|
|
589
|
-
//#endregion
|
|
590
|
-
//#region src/switchboard/switchboard-attachment-transport.ts
|
|
591
|
-
var SwitchboardAttachmentTransport = class {
|
|
592
|
-
remoteUrl;
|
|
593
|
-
jwtHandler;
|
|
594
|
-
fetchFn;
|
|
595
|
-
constructor(config) {
|
|
596
|
-
this.remoteUrl = config.remoteUrl;
|
|
597
|
-
this.jwtHandler = config.jwtHandler;
|
|
598
|
-
this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
|
|
599
|
-
}
|
|
600
|
-
async fetch(hash, signal) {
|
|
601
|
-
const url = `${this.remoteUrl}/attachments/${hash}`;
|
|
602
|
-
const headers = await buildAuthHeaders(url, this.jwtHandler);
|
|
603
|
-
const response = await this.fetchFn(url, {
|
|
604
|
-
signal,
|
|
605
|
-
headers
|
|
606
|
-
});
|
|
607
|
-
if (response.status === 404) return null;
|
|
608
|
-
if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
|
|
609
|
-
const metadata = this.parseMetadataHeaders(response);
|
|
610
|
-
const body = response.body;
|
|
611
|
-
if (!body) throw new Error("Response body is null");
|
|
612
|
-
return {
|
|
613
|
-
hash,
|
|
614
|
-
metadata,
|
|
615
|
-
body
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
async announce(_hash) {}
|
|
619
|
-
async push(hash, remote, data) {
|
|
620
|
-
const url = `${remote}/attachments/${hash}`;
|
|
621
|
-
const headers = await buildAuthHeaders(url, this.jwtHandler);
|
|
622
|
-
const response = await this.fetchFn(url, {
|
|
623
|
-
method: "PUT",
|
|
624
|
-
body: data,
|
|
625
|
-
headers,
|
|
626
|
-
duplex: "half"
|
|
627
|
-
});
|
|
628
|
-
if (!response.ok) throw new Error(`Attachment push failed: ${response.status} ${response.statusText}`);
|
|
629
|
-
}
|
|
630
|
-
parseMetadataHeaders(response) {
|
|
631
|
-
let fallbackCache;
|
|
632
|
-
const fallback = () => {
|
|
633
|
-
if (fallbackCache === void 0) fallbackCache = contentTypeFallback$1(response);
|
|
634
|
-
return fallbackCache;
|
|
635
|
-
};
|
|
636
|
-
const metaHeader = response.headers.get("Attachment-Metadata");
|
|
637
|
-
if (metaHeader) try {
|
|
638
|
-
const parsed = JSON.parse(metaHeader);
|
|
639
|
-
if (isRecord$2(parsed)) {
|
|
640
|
-
if (parsed.extension === void 0) parsed.extension = null;
|
|
641
|
-
if (parsed.createdAtUtc === void 0) parsed.createdAtUtc = fallback().createdAtUtc;
|
|
642
|
-
if (parsed.lastAccessedAtUtc === void 0) parsed.lastAccessedAtUtc = fallback().lastAccessedAtUtc;
|
|
643
|
-
}
|
|
644
|
-
if (isAttachmentMetadata$1(parsed)) return parsed;
|
|
645
|
-
} catch {}
|
|
646
|
-
return fallback();
|
|
647
|
-
}
|
|
648
|
-
};
|
|
649
|
-
function isRecord$2(value) {
|
|
650
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
651
|
-
}
|
|
652
|
-
function isAttachmentMetadata$1(value) {
|
|
653
|
-
if (!isRecord$2(value)) return false;
|
|
654
|
-
if (typeof value.mimeType !== "string") return false;
|
|
655
|
-
if (typeof value.fileName !== "string") return false;
|
|
656
|
-
if (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0) return false;
|
|
657
|
-
if (value.extension !== null && typeof value.extension !== "string") return false;
|
|
658
|
-
if (typeof value.createdAtUtc !== "string") return false;
|
|
659
|
-
if (value.lastAccessedAtUtc !== void 0 && typeof value.lastAccessedAtUtc !== "string") return false;
|
|
660
|
-
return true;
|
|
661
|
-
}
|
|
662
|
-
function contentTypeFallback$1(response) {
|
|
663
|
-
const contentLength = response.headers.get("Content-Length");
|
|
664
|
-
if (contentLength === null) throw new Error("Switchboard response missing both Attachment-Metadata and Content-Length headers");
|
|
665
|
-
const sizeBytes = Number(contentLength);
|
|
666
|
-
if (!Number.isInteger(sizeBytes) || sizeBytes < 0) throw new Error(`Switchboard response has invalid Content-Length header: ${JSON.stringify(contentLength)}`);
|
|
667
|
-
const lastModified = response.headers.get("Last-Modified");
|
|
668
|
-
const dateHeader = response.headers.get("Date");
|
|
669
|
-
const createdAtUtc = lastModified ? new Date(lastModified).toISOString() : dateHeader ? new Date(dateHeader).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
|
|
670
|
-
return {
|
|
671
|
-
mimeType: response.headers.get("Content-Type") ?? "application/octet-stream",
|
|
672
|
-
fileName: "unknown",
|
|
673
|
-
sizeBytes,
|
|
674
|
-
extension: null,
|
|
675
|
-
createdAtUtc,
|
|
676
|
-
lastAccessedAtUtc: createdAtUtc
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
//#endregion
|
|
680
|
-
//#region src/switchboard/remote-reservation-store.ts
|
|
681
|
-
function isRecord$1(value) {
|
|
682
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
683
|
-
}
|
|
684
|
-
function deriveExtension(fileName) {
|
|
685
|
-
const idx = fileName.lastIndexOf(".");
|
|
686
|
-
if (idx <= 0 || idx === fileName.length - 1) return null;
|
|
687
|
-
return fileName.slice(idx + 1).toLowerCase();
|
|
688
|
-
}
|
|
689
|
-
function isReservation(value) {
|
|
690
|
-
if (!isRecord$1(value)) return false;
|
|
691
|
-
if (typeof value.reservationId !== "string") return false;
|
|
692
|
-
if (typeof value.mimeType !== "string") return false;
|
|
693
|
-
if (typeof value.fileName !== "string") return false;
|
|
694
|
-
if (value.extension !== null && typeof value.extension !== "string") return false;
|
|
695
|
-
if (typeof value.createdAtUtc !== "string") return false;
|
|
696
|
-
if (typeof value.expiresAtUtc !== "string") return false;
|
|
697
|
-
return true;
|
|
698
|
-
}
|
|
699
|
-
var RemoteReservationStore = class {
|
|
700
|
-
remoteUrl;
|
|
701
|
-
jwtHandler;
|
|
702
|
-
fetchFn;
|
|
703
|
-
constructor(config) {
|
|
704
|
-
this.remoteUrl = config.remoteUrl;
|
|
705
|
-
this.jwtHandler = config.jwtHandler;
|
|
706
|
-
this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
|
|
707
|
-
}
|
|
708
|
-
async create(options) {
|
|
709
|
-
const url = `${this.remoteUrl}/attachments/reservations`;
|
|
710
|
-
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
711
|
-
const extension = options.extension ?? deriveExtension(options.fileName);
|
|
712
|
-
const response = await this.fetchFn(url, {
|
|
713
|
-
method: "POST",
|
|
714
|
-
headers: {
|
|
715
|
-
...authHeaders,
|
|
716
|
-
"Content-Type": "application/json"
|
|
717
|
-
},
|
|
718
|
-
body: JSON.stringify({
|
|
719
|
-
mimeType: options.mimeType,
|
|
720
|
-
fileName: options.fileName,
|
|
721
|
-
extension
|
|
722
|
-
})
|
|
723
|
-
});
|
|
724
|
-
if (!response.ok) throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
|
|
725
|
-
const json = await response.json();
|
|
726
|
-
const now = /* @__PURE__ */ new Date();
|
|
727
|
-
return {
|
|
728
|
-
reservationId: json.reservationId,
|
|
729
|
-
mimeType: options.mimeType,
|
|
730
|
-
fileName: options.fileName,
|
|
731
|
-
extension,
|
|
732
|
-
createdAtUtc: json.createdAtUtc ?? now.toISOString(),
|
|
733
|
-
expiresAtUtc: json.expiresAtUtc ?? new Date(now.getTime() + 1440 * 60 * 1e3).toISOString()
|
|
734
|
-
};
|
|
735
|
-
}
|
|
736
|
-
async get(reservationId) {
|
|
737
|
-
const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
|
|
738
|
-
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
739
|
-
const response = await this.fetchFn(url, { headers: authHeaders });
|
|
740
|
-
if (response.status === 404) throw new ReservationNotFound(reservationId);
|
|
741
|
-
if (!response.ok) throw new Error(`Reservation get failed: ${response.status} ${response.statusText}`);
|
|
742
|
-
let parsed;
|
|
743
|
-
try {
|
|
744
|
-
parsed = await response.json();
|
|
745
|
-
} catch {
|
|
746
|
-
throw new Error("Reservation get returned non-JSON response");
|
|
747
|
-
}
|
|
748
|
-
if (!isReservation(parsed)) throw new Error("Reservation get returned a payload that does not match the Reservation shape");
|
|
749
|
-
return parsed;
|
|
750
|
-
}
|
|
751
|
-
async delete(reservationId) {
|
|
752
|
-
const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
|
|
753
|
-
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
754
|
-
const response = await this.fetchFn(url, {
|
|
755
|
-
method: "DELETE",
|
|
756
|
-
headers: authHeaders
|
|
757
|
-
});
|
|
758
|
-
if (!response.ok && response.status !== 404 && response.status !== 410) throw new Error(`Reservation delete failed: ${response.status} ${response.statusText}`);
|
|
759
|
-
}
|
|
760
|
-
deleteExpired() {
|
|
761
|
-
return Promise.reject(/* @__PURE__ */ new Error("RemoteReservationStore.deleteExpired is not supported"));
|
|
762
|
-
}
|
|
763
|
-
};
|
|
764
|
-
//#endregion
|
|
765
|
-
//#region src/switchboard/remote-attachment-upload.ts
|
|
766
|
-
var RemoteAttachmentUpload = class {
|
|
767
|
-
reservationId;
|
|
768
|
-
remoteUrl;
|
|
769
|
-
jwtHandler;
|
|
770
|
-
fetchFn;
|
|
771
|
-
options;
|
|
772
|
-
constructor(reservationId, options, config) {
|
|
773
|
-
this.reservationId = reservationId;
|
|
774
|
-
this.options = options;
|
|
775
|
-
this.remoteUrl = config.remoteUrl;
|
|
776
|
-
this.jwtHandler = config.jwtHandler;
|
|
777
|
-
this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
|
|
778
|
-
}
|
|
779
|
-
async send(data) {
|
|
780
|
-
const url = `${this.remoteUrl}/attachments/reservations/${this.reservationId}`;
|
|
781
|
-
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
782
|
-
const body = await new Response(data).blob();
|
|
783
|
-
const response = await this.fetchFn(url, {
|
|
784
|
-
method: "PUT",
|
|
785
|
-
headers: {
|
|
786
|
-
...authHeaders,
|
|
787
|
-
"Content-Type": "application/octet-stream"
|
|
788
|
-
},
|
|
789
|
-
body
|
|
790
|
-
});
|
|
791
|
-
if (!response.ok) throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
|
|
792
|
-
return await response.json();
|
|
793
|
-
}
|
|
794
|
-
};
|
|
795
|
-
//#endregion
|
|
796
|
-
//#region src/switchboard/remote-attachment-upload-factory.ts
|
|
797
|
-
var RemoteAttachmentUploadFactory = class {
|
|
798
|
-
constructor(config) {
|
|
799
|
-
this.config = config;
|
|
800
|
-
}
|
|
801
|
-
createUpload(reservationId, options) {
|
|
802
|
-
return new RemoteAttachmentUpload(reservationId, options, this.config);
|
|
803
|
-
}
|
|
804
|
-
};
|
|
805
|
-
//#endregion
|
|
806
|
-
//#region src/switchboard/remote-attachment-store.ts
|
|
807
|
-
function isRecord(value) {
|
|
808
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
809
|
-
}
|
|
810
|
-
function isAttachmentMetadata(value) {
|
|
811
|
-
if (!isRecord(value)) return false;
|
|
812
|
-
if (typeof value.mimeType !== "string") return false;
|
|
813
|
-
if (typeof value.fileName !== "string") return false;
|
|
814
|
-
if (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0) return false;
|
|
815
|
-
if (value.extension !== null && typeof value.extension !== "string") return false;
|
|
816
|
-
if (typeof value.createdAtUtc !== "string") return false;
|
|
817
|
-
if (value.lastAccessedAtUtc !== void 0 && typeof value.lastAccessedAtUtc !== "string") return false;
|
|
818
|
-
return true;
|
|
819
|
-
}
|
|
820
|
-
function contentTypeFallback(response) {
|
|
821
|
-
const contentLength = response.headers.get("Content-Length");
|
|
822
|
-
if (contentLength === null) throw new Error("Switchboard response missing both Attachment-Metadata and Content-Length headers");
|
|
823
|
-
const sizeBytes = Number(contentLength);
|
|
824
|
-
if (!Number.isInteger(sizeBytes) || sizeBytes < 0) throw new Error(`Switchboard response has invalid Content-Length header: ${JSON.stringify(contentLength)}`);
|
|
825
|
-
const lastModified = response.headers.get("Last-Modified");
|
|
826
|
-
const dateHeader = response.headers.get("Date");
|
|
827
|
-
const createdAtUtc = lastModified ? new Date(lastModified).toISOString() : dateHeader ? new Date(dateHeader).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
|
|
828
|
-
return {
|
|
829
|
-
mimeType: response.headers.get("Content-Type") ?? "application/octet-stream",
|
|
830
|
-
fileName: "unknown",
|
|
831
|
-
sizeBytes,
|
|
832
|
-
extension: null,
|
|
833
|
-
createdAtUtc,
|
|
834
|
-
lastAccessedAtUtc: createdAtUtc
|
|
835
|
-
};
|
|
836
|
-
}
|
|
837
|
-
function parseMetadata(response) {
|
|
838
|
-
let fallbackCache;
|
|
839
|
-
const fallback = () => {
|
|
840
|
-
if (fallbackCache === void 0) fallbackCache = contentTypeFallback(response);
|
|
841
|
-
return fallbackCache;
|
|
842
|
-
};
|
|
843
|
-
const metaHeader = response.headers.get("Attachment-Metadata");
|
|
844
|
-
if (metaHeader) try {
|
|
845
|
-
const parsed = JSON.parse(metaHeader);
|
|
846
|
-
if (isRecord(parsed)) {
|
|
847
|
-
if (parsed.extension === void 0) parsed.extension = null;
|
|
848
|
-
if (parsed.createdAtUtc === void 0) parsed.createdAtUtc = fallback().createdAtUtc;
|
|
849
|
-
if (parsed.lastAccessedAtUtc === void 0) parsed.lastAccessedAtUtc = fallback().lastAccessedAtUtc;
|
|
850
|
-
}
|
|
851
|
-
if (isAttachmentMetadata(parsed)) return parsed;
|
|
852
|
-
} catch {}
|
|
853
|
-
return fallback();
|
|
854
|
-
}
|
|
855
|
-
var RemoteAttachmentStore = class {
|
|
856
|
-
remoteUrl;
|
|
857
|
-
jwtHandler;
|
|
858
|
-
fetchFn;
|
|
859
|
-
constructor(config) {
|
|
860
|
-
this.remoteUrl = config.remoteUrl;
|
|
861
|
-
this.jwtHandler = config.jwtHandler;
|
|
862
|
-
this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
|
|
863
|
-
}
|
|
864
|
-
async stat(hash) {
|
|
865
|
-
const url = `${this.remoteUrl}/attachments/${hash}`;
|
|
866
|
-
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
867
|
-
const response = await this.fetchFn(url, {
|
|
868
|
-
method: "HEAD",
|
|
869
|
-
headers: authHeaders
|
|
870
|
-
});
|
|
871
|
-
if (response.status === 404) throw new AttachmentNotFound(hash);
|
|
872
|
-
if (!response.ok) throw new Error(`Attachment stat failed: ${response.status} ${response.statusText}`);
|
|
873
|
-
return buildHeader(hash, parseMetadata(response));
|
|
874
|
-
}
|
|
875
|
-
async get(hash, signal) {
|
|
876
|
-
return this.fetchAttachment(hash, signal);
|
|
877
|
-
}
|
|
878
|
-
async fetchAttachment(hash, signal) {
|
|
879
|
-
const url = `${this.remoteUrl}/attachments/${hash}`;
|
|
880
|
-
const headers = await buildAuthHeaders(url, this.jwtHandler);
|
|
881
|
-
const response = await this.fetchFn(url, {
|
|
882
|
-
signal,
|
|
883
|
-
headers
|
|
884
|
-
});
|
|
885
|
-
if (response.status === 404) throw new AttachmentNotFound(hash);
|
|
886
|
-
if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
|
|
887
|
-
if (!response.body) throw new Error("Response body is null");
|
|
888
|
-
return {
|
|
889
|
-
header: buildHeader(hash, parseMetadata(response)),
|
|
890
|
-
body: response.body
|
|
891
|
-
};
|
|
892
|
-
}
|
|
893
|
-
};
|
|
894
|
-
function buildHeader(hash, metadata) {
|
|
895
|
-
return {
|
|
896
|
-
hash,
|
|
897
|
-
mimeType: metadata.mimeType,
|
|
898
|
-
fileName: metadata.fileName,
|
|
899
|
-
sizeBytes: metadata.sizeBytes,
|
|
900
|
-
extension: metadata.extension,
|
|
901
|
-
status: "available",
|
|
902
|
-
source: "sync",
|
|
903
|
-
createdAtUtc: metadata.createdAtUtc,
|
|
904
|
-
lastAccessedAtUtc: metadata.lastAccessedAtUtc ?? metadata.createdAtUtc
|
|
905
|
-
};
|
|
906
|
-
}
|
|
907
|
-
//#endregion
|
|
908
|
-
//#region src/switchboard/create-remote-attachment-service.ts
|
|
909
|
-
function createRemoteAttachmentService(config) {
|
|
910
|
-
const reservations = new RemoteReservationStore(config);
|
|
911
|
-
const uploadFactory = new RemoteAttachmentUploadFactory(config);
|
|
912
|
-
return new AttachmentService(new RemoteAttachmentStore(config), reservations, uploadFactory);
|
|
913
|
-
}
|
|
914
|
-
//#endregion
|
|
915
|
-
//#region src/null-attachment-transport.ts
|
|
916
|
-
/**
|
|
917
|
-
* No-op transport for deployments without remote sync.
|
|
918
|
-
* fetch() always returns null, announce() and push() are no-ops.
|
|
919
|
-
*/
|
|
920
|
-
var NullAttachmentTransport = class {
|
|
921
|
-
fetch() {
|
|
922
|
-
return Promise.resolve(null);
|
|
923
|
-
}
|
|
924
|
-
announce() {
|
|
925
|
-
return Promise.resolve();
|
|
926
|
-
}
|
|
927
|
-
push() {
|
|
928
|
-
return Promise.resolve();
|
|
619
|
+
createUpload(reservation) {
|
|
620
|
+
return new DirectAttachmentUpload(reservation, this.db, this.basePath, this.reservations, this.maxBytes);
|
|
929
621
|
}
|
|
930
622
|
};
|
|
931
623
|
//#endregion
|
|
@@ -934,6 +626,7 @@ var AttachmentBuilder = class {
|
|
|
934
626
|
transport = new NullAttachmentTransport();
|
|
935
627
|
customUploadFactory;
|
|
936
628
|
maxUploadBytes;
|
|
629
|
+
reservationSweepMs;
|
|
937
630
|
constructor(db, storagePath) {
|
|
938
631
|
this.db = db;
|
|
939
632
|
this.storagePath = storagePath;
|
|
@@ -950,6 +643,18 @@ var AttachmentBuilder = class {
|
|
|
950
643
|
this.maxUploadBytes = maxBytes;
|
|
951
644
|
return this;
|
|
952
645
|
}
|
|
646
|
+
/**
|
|
647
|
+
* Configure a recurring sweep that deletes expired reservations.
|
|
648
|
+
* The sweep calls reservations.deleteExpired() on the given interval.
|
|
649
|
+
* When set, the built result's destroy() clears the timer.
|
|
650
|
+
* Without this option no sweep runs -- deleteExpired() is never called
|
|
651
|
+
* automatically. Call withReservationSweepMs in production to prevent
|
|
652
|
+
* expired reservation rows from accumulating indefinitely.
|
|
653
|
+
*/
|
|
654
|
+
withReservationSweepMs(intervalMs) {
|
|
655
|
+
this.reservationSweepMs = intervalMs;
|
|
656
|
+
return this;
|
|
657
|
+
}
|
|
953
658
|
async build() {
|
|
954
659
|
const result = await runAttachmentMigrations(this.db, ATTACHMENT_SCHEMA);
|
|
955
660
|
if (!result.success && result.error) throw result.error;
|
|
@@ -957,15 +662,31 @@ var AttachmentBuilder = class {
|
|
|
957
662
|
const store = new KyselyAttachmentStore(scopedDb, this.transport, this.storagePath);
|
|
958
663
|
const reservations = new KyselyReservationStore(scopedDb);
|
|
959
664
|
const uploadFactory = this.customUploadFactory ?? new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations, this.maxUploadBytes);
|
|
665
|
+
const service = new AttachmentService(store, reservations, uploadFactory);
|
|
666
|
+
let sweepTimer;
|
|
667
|
+
if (this.reservationSweepMs !== void 0) {
|
|
668
|
+
const intervalMs = this.reservationSweepMs;
|
|
669
|
+
sweepTimer = setInterval(() => {
|
|
670
|
+
reservations.deleteExpired().catch(() => {});
|
|
671
|
+
}, intervalMs);
|
|
672
|
+
if (typeof sweepTimer.unref === "function") sweepTimer.unref();
|
|
673
|
+
}
|
|
674
|
+
const destroy = () => {
|
|
675
|
+
if (sweepTimer !== void 0) {
|
|
676
|
+
clearInterval(sweepTimer);
|
|
677
|
+
sweepTimer = void 0;
|
|
678
|
+
}
|
|
679
|
+
};
|
|
960
680
|
return {
|
|
961
|
-
service
|
|
681
|
+
service,
|
|
962
682
|
store,
|
|
963
683
|
reservations,
|
|
964
|
-
uploadFactory
|
|
684
|
+
uploadFactory,
|
|
685
|
+
destroy
|
|
965
686
|
};
|
|
966
687
|
}
|
|
967
688
|
};
|
|
968
689
|
//#endregion
|
|
969
|
-
export { ATTACHMENT_SCHEMA, AttachmentBuilder, AttachmentNotFound, AttachmentService, DirectAttachmentUpload, DirectAttachmentUploadFactory, InvalidAttachmentRef, KyselyAttachmentStore, KyselyReservationStore, NullAttachmentTransport, RemoteAttachmentStore, RemoteAttachmentUpload, RemoteAttachmentUploadFactory, RemoteReservationStore, ReservationNotFound, SwitchboardAttachmentTransport, createRef, createRemoteAttachmentService, parseRef, runAttachmentMigrations };
|
|
690
|
+
export { ATTACHMENT_SCHEMA, AttachmentAlreadyExists, AttachmentBuilder, AttachmentNotFound, AttachmentPending, AttachmentService, DEFAULT_RESERVATION_TTL_MS, DirectAttachmentUpload, DirectAttachmentUploadFactory, HashMismatch, InvalidAttachmentRef, KyselyAttachmentStore, KyselyReservationStore, NullAttachmentTransport, RemoteAttachmentStore, RemoteAttachmentUpload, RemoteAttachmentUploadFactory, RemoteReservationStore, ReservationNotFound, SizeMismatch, SwitchboardAttachmentTransport, UploadTooLarge, createRef, createRemoteAttachmentService, parseRef, runAttachmentMigrations };
|
|
970
691
|
|
|
971
692
|
//# sourceMappingURL=index.js.map
|