@powerhousedao/reactor-attachments 6.1.0-dev.17 → 6.1.0-dev.18
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 +635 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +743 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +237 -30
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +495 -92
- package/dist/index.js.map +1 -1
- package/package.json +7 -2
package/dist/index.js
CHANGED
|
@@ -56,6 +56,80 @@ var UploadTooLarge = class extends Error {
|
|
|
56
56
|
this.maxBytes = maxBytes;
|
|
57
57
|
}
|
|
58
58
|
};
|
|
59
|
+
/**
|
|
60
|
+
* Thrown by reserve() when the claimed hash is already available in the store.
|
|
61
|
+
* The caller should use err.ref directly and upload nothing -- this is the
|
|
62
|
+
* dedup fast path: duplicate content never leaves the client.
|
|
63
|
+
*/
|
|
64
|
+
var AttachmentAlreadyExists = class extends Error {
|
|
65
|
+
hash;
|
|
66
|
+
ref;
|
|
67
|
+
constructor(hash, ref) {
|
|
68
|
+
super(`Attachment already exists for hash: ${hash}`);
|
|
69
|
+
this.name = "AttachmentAlreadyExists";
|
|
70
|
+
this.hash = hash;
|
|
71
|
+
this.ref = ref;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Thrown by send() when the server-computed hash of the uploaded bytes
|
|
76
|
+
* does not match the hash claimed at reservation time. Nothing is committed;
|
|
77
|
+
* the reservation is retained so the client can retry with correct bytes.
|
|
78
|
+
*/
|
|
79
|
+
var HashMismatch = class extends Error {
|
|
80
|
+
claimed;
|
|
81
|
+
actual;
|
|
82
|
+
constructor(claimed, actual) {
|
|
83
|
+
super(`Hash mismatch: claimed ${claimed} but computed ${actual}`);
|
|
84
|
+
this.name = "HashMismatch";
|
|
85
|
+
this.claimed = claimed;
|
|
86
|
+
this.actual = actual;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Thrown by send() when the uploaded byte count does not equal the
|
|
91
|
+
* sizeBytes declared at reservation time. The handle may reject
|
|
92
|
+
* mid-stream as soon as the count exceeds the declaration.
|
|
93
|
+
* Nothing is committed; the reservation is retained for retry.
|
|
94
|
+
*
|
|
95
|
+
* "actual" is the byte count received from the stream before aborting --
|
|
96
|
+
* it includes the chunk that crossed the declaration and can exceed bytes
|
|
97
|
+
* persisted. On mid-stream aborts the true total is unknown; at least
|
|
98
|
+
* "actual" bytes were sent.
|
|
99
|
+
*/
|
|
100
|
+
var SizeMismatch = class extends Error {
|
|
101
|
+
declared;
|
|
102
|
+
actual;
|
|
103
|
+
constructor(declared, actual) {
|
|
104
|
+
super(`Size mismatch: declared ${declared} bytes but received ${actual}`);
|
|
105
|
+
this.name = "SizeMismatch";
|
|
106
|
+
this.declared = declared;
|
|
107
|
+
this.actual = actual;
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Thrown by get() when the hash is reserved by an in-flight upload and
|
|
112
|
+
* bytes are not yet available anywhere. Deliberately NOT a subclass of
|
|
113
|
+
* AttachmentNotFound -- callers must distinguish "retry later" from "unknown".
|
|
114
|
+
* After expiresAtUtc has passed the hash reads as not found.
|
|
115
|
+
*
|
|
116
|
+
* metadata is populated when the reservation is local and its fields are
|
|
117
|
+
* known (mimeType, fileName, sizeBytes). It is undefined when the pending
|
|
118
|
+
* state is learned from a remote transport that did not supply the full
|
|
119
|
+
* Attachment-Pending header (transport-pending / degraded wire case).
|
|
120
|
+
*/
|
|
121
|
+
var AttachmentPending = class extends Error {
|
|
122
|
+
hash;
|
|
123
|
+
expiresAtUtc;
|
|
124
|
+
metadata;
|
|
125
|
+
constructor(hash, expiresAtUtc, meta) {
|
|
126
|
+
super(`Attachment pending upload for hash: ${hash}, expires: ${expiresAtUtc}`);
|
|
127
|
+
this.name = "AttachmentPending";
|
|
128
|
+
this.hash = hash;
|
|
129
|
+
this.expiresAtUtc = expiresAtUtc;
|
|
130
|
+
this.metadata = meta;
|
|
131
|
+
}
|
|
132
|
+
};
|
|
59
133
|
//#endregion
|
|
60
134
|
//#region src/ref.ts
|
|
61
135
|
const REF_PATTERN = /^attachment:\/\/v(\d+):(.+)$/;
|
|
@@ -73,6 +147,7 @@ function createRef(hash, version = DEFAULT_VERSION) {
|
|
|
73
147
|
}
|
|
74
148
|
//#endregion
|
|
75
149
|
//#region src/attachment-service.ts
|
|
150
|
+
const CLIENT_HASH_PATTERN = /^[a-f0-9]{64}$/;
|
|
76
151
|
var AttachmentService = class {
|
|
77
152
|
constructor(store, reservations, uploadFactory) {
|
|
78
153
|
this.store = store;
|
|
@@ -80,8 +155,9 @@ var AttachmentService = class {
|
|
|
80
155
|
this.uploadFactory = uploadFactory;
|
|
81
156
|
}
|
|
82
157
|
async reserve(options) {
|
|
158
|
+
if (options.clientHash !== void 0) return this.reserveHashFirst(options);
|
|
83
159
|
const reservation = await this.reservations.create(options);
|
|
84
|
-
return this.uploadFactory.createUpload(reservation
|
|
160
|
+
return this.uploadFactory.createUpload(reservation);
|
|
85
161
|
}
|
|
86
162
|
async stat(ref) {
|
|
87
163
|
const { hash } = parseRef(ref);
|
|
@@ -91,6 +167,24 @@ var AttachmentService = class {
|
|
|
91
167
|
const { hash } = parseRef(ref);
|
|
92
168
|
return this.store.get(hash, signal);
|
|
93
169
|
}
|
|
170
|
+
async reserveHashFirst(options) {
|
|
171
|
+
const normalized = options.clientHash.toLowerCase();
|
|
172
|
+
if (!CLIENT_HASH_PATTERN.test(normalized)) throw new Error(`clientHash must be a 64-character lowercase hex string, got: ${options.clientHash}`);
|
|
173
|
+
if (options.sizeBytes === void 0 || !Number.isInteger(options.sizeBytes) || options.sizeBytes <= 0 || !Number.isSafeInteger(options.sizeBytes)) throw new Error("sizeBytes must be a positive safe integer when clientHash is provided");
|
|
174
|
+
const normalizedOptions = {
|
|
175
|
+
...options,
|
|
176
|
+
clientHash: normalized
|
|
177
|
+
};
|
|
178
|
+
let existingHeader = null;
|
|
179
|
+
try {
|
|
180
|
+
existingHeader = await this.store.stat(normalized);
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (!(err instanceof AttachmentNotFound) && !(err instanceof AttachmentPending)) throw err;
|
|
183
|
+
}
|
|
184
|
+
if (existingHeader !== null && existingHeader.status === "available") throw new AttachmentAlreadyExists(normalized, createRef(normalized));
|
|
185
|
+
const reservation = await this.reservations.create(normalizedOptions);
|
|
186
|
+
return this.uploadFactory.createUpload(reservation);
|
|
187
|
+
}
|
|
94
188
|
};
|
|
95
189
|
//#endregion
|
|
96
190
|
//#region src/storage/fs/attachment-fs.ts
|
|
@@ -115,13 +209,24 @@ async function writeAttachmentBytes(path, data) {
|
|
|
115
209
|
const { done, value } = await reader.read();
|
|
116
210
|
if (done) break;
|
|
117
211
|
bytesWritten += value.byteLength;
|
|
118
|
-
if (!writer.write(value)) await new Promise((resolve) =>
|
|
212
|
+
if (!writer.write(value)) await new Promise((resolve, reject) => {
|
|
213
|
+
const onDrain = () => {
|
|
214
|
+
writer.off("error", onError);
|
|
215
|
+
resolve();
|
|
216
|
+
};
|
|
217
|
+
const onError = (err) => {
|
|
218
|
+
writer.off("drain", onDrain);
|
|
219
|
+
reject(err);
|
|
220
|
+
};
|
|
221
|
+
writer.once("drain", onDrain);
|
|
222
|
+
writer.once("error", onError);
|
|
223
|
+
});
|
|
119
224
|
}
|
|
120
225
|
} finally {
|
|
121
226
|
reader.releaseLock();
|
|
122
227
|
await new Promise((resolve, reject) => {
|
|
123
228
|
writer.end(() => resolve());
|
|
124
|
-
writer.
|
|
229
|
+
writer.once("error", reject);
|
|
125
230
|
});
|
|
126
231
|
}
|
|
127
232
|
return bytesWritten;
|
|
@@ -149,9 +254,16 @@ async function deleteAttachmentBytes(path) {
|
|
|
149
254
|
*
|
|
150
255
|
* If `maxBytes` is set and the input exceeds it, the temp file is removed and
|
|
151
256
|
* `UploadTooLarge` is thrown.
|
|
257
|
+
*
|
|
258
|
+
* If `declaredSizeBytes` is set, the byte count is enforced as a contract:
|
|
259
|
+
* mid-stream, the moment the count exceeds the declaration the reader is
|
|
260
|
+
* released and `SizeMismatch` is thrown without consuming the rest of the
|
|
261
|
+
* stream. At stream end, if the count does not equal the declaration,
|
|
262
|
+
* `SizeMismatch` is thrown. Both the `maxBytes` and `declaredSizeBytes`
|
|
263
|
+
* checks apply; `maxBytes` is evaluated first on each chunk.
|
|
152
264
|
*/
|
|
153
265
|
async function streamHashAndWrite(basePath, data, options = {}) {
|
|
154
|
-
const { maxBytes } = options;
|
|
266
|
+
const { maxBytes, declaredSizeBytes } = options;
|
|
155
267
|
const tmpDir = join(basePath, ".tmp");
|
|
156
268
|
await mkdir(tmpDir, { recursive: true });
|
|
157
269
|
const tempPath = join(tmpDir, randomUUID());
|
|
@@ -166,6 +278,7 @@ async function streamHashAndWrite(basePath, data, options = {}) {
|
|
|
166
278
|
if (done) break;
|
|
167
279
|
sizeBytes += value.byteLength;
|
|
168
280
|
if (maxBytes !== void 0 && sizeBytes > maxBytes) throw new UploadTooLarge(maxBytes);
|
|
281
|
+
if (declaredSizeBytes !== void 0 && sizeBytes > declaredSizeBytes) throw new SizeMismatch(declaredSizeBytes, sizeBytes);
|
|
169
282
|
hasher.update(value);
|
|
170
283
|
if (!writer.write(value)) await new Promise((resolve, reject) => {
|
|
171
284
|
const onDrain = () => {
|
|
@@ -185,17 +298,30 @@ async function streamHashAndWrite(basePath, data, options = {}) {
|
|
|
185
298
|
} finally {
|
|
186
299
|
reader.releaseLock();
|
|
187
300
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
301
|
+
let endError;
|
|
302
|
+
try {
|
|
303
|
+
await new Promise((resolve, reject) => {
|
|
304
|
+
writer.end((err) => {
|
|
305
|
+
if (err) reject(err);
|
|
306
|
+
else resolve();
|
|
307
|
+
});
|
|
308
|
+
writer.once("error", reject);
|
|
192
309
|
});
|
|
193
|
-
|
|
194
|
-
|
|
310
|
+
} catch (err) {
|
|
311
|
+
endError = err instanceof Error ? err : new Error(String(err));
|
|
312
|
+
}
|
|
195
313
|
if (caughtError) {
|
|
196
314
|
await rm(tempPath, { force: true });
|
|
197
315
|
throw caughtError;
|
|
198
316
|
}
|
|
317
|
+
if (endError) {
|
|
318
|
+
await rm(tempPath, { force: true });
|
|
319
|
+
throw endError;
|
|
320
|
+
}
|
|
321
|
+
if (declaredSizeBytes !== void 0 && sizeBytes !== declaredSizeBytes) {
|
|
322
|
+
await rm(tempPath, { force: true });
|
|
323
|
+
throw new SizeMismatch(declaredSizeBytes, sizeBytes);
|
|
324
|
+
}
|
|
199
325
|
return {
|
|
200
326
|
tempPath,
|
|
201
327
|
hash: hasher.digest("hex"),
|
|
@@ -214,7 +340,8 @@ function rowToHeader$1(row) {
|
|
|
214
340
|
status: row.status,
|
|
215
341
|
source: row.source,
|
|
216
342
|
createdAtUtc: row.created_at_utc,
|
|
217
|
-
lastAccessedAtUtc: row.last_accessed_at_utc
|
|
343
|
+
lastAccessedAtUtc: row.last_accessed_at_utc,
|
|
344
|
+
expiresAtUtc: null
|
|
218
345
|
};
|
|
219
346
|
}
|
|
220
347
|
function wrapStreamWithCleanup(source, cleanup) {
|
|
@@ -254,30 +381,62 @@ var KyselyAttachmentStore = class {
|
|
|
254
381
|
}
|
|
255
382
|
async stat(hash) {
|
|
256
383
|
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
257
|
-
if (
|
|
258
|
-
|
|
384
|
+
if (row) return rowToHeader$1(row);
|
|
385
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
386
|
+
const pending = await this.findPendingReservation(hash, now);
|
|
387
|
+
if (pending) return {
|
|
388
|
+
hash,
|
|
389
|
+
mimeType: pending.mime_type,
|
|
390
|
+
fileName: pending.file_name,
|
|
391
|
+
sizeBytes: Number(pending.size_bytes),
|
|
392
|
+
extension: pending.extension,
|
|
393
|
+
status: "pending",
|
|
394
|
+
source: "local",
|
|
395
|
+
createdAtUtc: pending.created_at_utc,
|
|
396
|
+
lastAccessedAtUtc: pending.created_at_utc,
|
|
397
|
+
expiresAtUtc: pending.expires_at_utc
|
|
398
|
+
};
|
|
399
|
+
throw new AttachmentNotFound(hash);
|
|
259
400
|
}
|
|
260
401
|
async has(hash) {
|
|
261
402
|
return (await this.db.selectFrom("attachment").select("status").where("hash", "=", hash).executeTakeFirst())?.status === "available";
|
|
262
403
|
}
|
|
263
404
|
async get(hash, signal) {
|
|
264
405
|
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
406
|
+
if (row) {
|
|
407
|
+
if (row.status === "evicted") {
|
|
408
|
+
const remote = await this.transport.fetch(hash, signal);
|
|
409
|
+
if (remote.kind === "data") {
|
|
410
|
+
await this.put(hash, remote.response.metadata, remote.response.body);
|
|
411
|
+
return this.get(hash, signal);
|
|
412
|
+
}
|
|
413
|
+
if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
|
|
414
|
+
throw new AttachmentNotFound(hash);
|
|
415
|
+
}
|
|
416
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
417
|
+
await this.db.updateTable("attachment").set({ last_accessed_at_utc: now }).where("hash", "=", hash).execute();
|
|
418
|
+
const header = rowToHeader$1(row);
|
|
419
|
+
header.lastAccessedAtUtc = now;
|
|
420
|
+
this.acquireReader(hash);
|
|
421
|
+
return {
|
|
422
|
+
header,
|
|
423
|
+
body: wrapStreamWithCleanup(readAttachmentStream(join(this.basePath, row.storage_path)), () => this.releaseReader(hash))
|
|
424
|
+
};
|
|
271
425
|
}
|
|
272
426
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
427
|
+
const pending = await this.findPendingReservation(hash, now);
|
|
428
|
+
if (pending) throw new AttachmentPending(hash, pending.expires_at_utc, {
|
|
429
|
+
mimeType: pending.mime_type,
|
|
430
|
+
fileName: pending.file_name,
|
|
431
|
+
sizeBytes: pending.size_bytes
|
|
432
|
+
});
|
|
433
|
+
const remote = await this.transport.fetch(hash, signal);
|
|
434
|
+
if (remote.kind === "data") {
|
|
435
|
+
await this.put(hash, remote.response.metadata, remote.response.body);
|
|
436
|
+
return this.get(hash, signal);
|
|
437
|
+
}
|
|
438
|
+
if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
|
|
439
|
+
throw new AttachmentNotFound(hash);
|
|
281
440
|
}
|
|
282
441
|
async put(hash, metadata, data) {
|
|
283
442
|
const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
@@ -317,6 +476,25 @@ var KyselyAttachmentStore = class {
|
|
|
317
476
|
const result = await this.db.selectFrom("attachment").select(sql`COALESCE(SUM(size_bytes), 0)`.as("total")).where("status", "=", "available").executeTakeFirst();
|
|
318
477
|
return Number(result?.total ?? 0);
|
|
319
478
|
}
|
|
479
|
+
async findPendingReservation(hash, now) {
|
|
480
|
+
const row = await this.db.selectFrom("attachment_reservation as r").leftJoin("attachment as a", "a.hash", "r.client_hash").select([
|
|
481
|
+
"r.mime_type",
|
|
482
|
+
"r.file_name",
|
|
483
|
+
"r.extension",
|
|
484
|
+
"r.size_bytes",
|
|
485
|
+
"r.created_at_utc",
|
|
486
|
+
"r.expires_at_utc"
|
|
487
|
+
]).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();
|
|
488
|
+
if (!row) return null;
|
|
489
|
+
return {
|
|
490
|
+
mime_type: row.mime_type,
|
|
491
|
+
file_name: row.file_name,
|
|
492
|
+
extension: row.extension,
|
|
493
|
+
size_bytes: Number(row.size_bytes),
|
|
494
|
+
created_at_utc: row.created_at_utc,
|
|
495
|
+
expires_at_utc: row.expires_at_utc
|
|
496
|
+
};
|
|
497
|
+
}
|
|
320
498
|
acquireReader(hash) {
|
|
321
499
|
this.activeReaders.set(hash, (this.activeReaders.get(hash) ?? 0) + 1);
|
|
322
500
|
}
|
|
@@ -339,7 +517,9 @@ function rowToReservation(row) {
|
|
|
339
517
|
fileName: row.file_name,
|
|
340
518
|
extension: row.extension,
|
|
341
519
|
createdAtUtc: row.created_at_utc,
|
|
342
|
-
expiresAtUtc: row.expires_at_utc
|
|
520
|
+
expiresAtUtc: row.expires_at_utc,
|
|
521
|
+
clientHash: row.client_hash,
|
|
522
|
+
sizeBytes: row.size_bytes !== null ? Number(row.size_bytes) : null
|
|
343
523
|
};
|
|
344
524
|
}
|
|
345
525
|
var KyselyReservationStore = class {
|
|
@@ -359,7 +539,9 @@ var KyselyReservationStore = class {
|
|
|
359
539
|
file_name: options.fileName,
|
|
360
540
|
extension: options.extension ?? null,
|
|
361
541
|
created_at_utc: now,
|
|
362
|
-
expires_at_utc: expiresAt
|
|
542
|
+
expires_at_utc: expiresAt,
|
|
543
|
+
client_hash: options.clientHash ?? null,
|
|
544
|
+
size_bytes: options.sizeBytes ?? null
|
|
363
545
|
}).returningAll().executeTakeFirstOrThrow());
|
|
364
546
|
}
|
|
365
547
|
async get(reservationId) {
|
|
@@ -379,72 +561,89 @@ var KyselyReservationStore = class {
|
|
|
379
561
|
//#endregion
|
|
380
562
|
//#region src/storage/migrations/001_create_attachment_table.ts
|
|
381
563
|
var _001_create_attachment_table_exports = /* @__PURE__ */ __exportAll({
|
|
382
|
-
down: () => down$
|
|
383
|
-
up: () => up$
|
|
564
|
+
down: () => down$5,
|
|
565
|
+
up: () => up$5
|
|
384
566
|
});
|
|
385
|
-
async function up$
|
|
567
|
+
async function up$5(db) {
|
|
386
568
|
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
569
|
await db.schema.createIndex("idx_attachment_status").on("attachment").column("status").execute();
|
|
388
570
|
await db.schema.createIndex("idx_attachment_lru").on("attachment").columns(["status", "last_accessed_at_utc"]).execute();
|
|
389
571
|
}
|
|
390
|
-
async function down$
|
|
572
|
+
async function down$5(db) {
|
|
391
573
|
await db.schema.dropTable("attachment").ifExists().execute();
|
|
392
574
|
}
|
|
393
575
|
//#endregion
|
|
394
576
|
//#region src/storage/migrations/002_create_reservation_table.ts
|
|
395
577
|
var _002_create_reservation_table_exports = /* @__PURE__ */ __exportAll({
|
|
396
|
-
down: () => down$
|
|
397
|
-
up: () => up$
|
|
578
|
+
down: () => down$4,
|
|
579
|
+
up: () => up$4
|
|
398
580
|
});
|
|
399
|
-
async function up$
|
|
581
|
+
async function up$4(db) {
|
|
400
582
|
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
583
|
}
|
|
402
|
-
async function down$
|
|
584
|
+
async function down$4(db) {
|
|
403
585
|
await db.schema.dropTable("attachment_reservation").ifExists().execute();
|
|
404
586
|
}
|
|
405
587
|
//#endregion
|
|
406
588
|
//#region src/storage/migrations/003_add_reservation_expires_at.ts
|
|
407
589
|
var _003_add_reservation_expires_at_exports = /* @__PURE__ */ __exportAll({
|
|
408
|
-
down: () => down$
|
|
409
|
-
up: () => up$
|
|
590
|
+
down: () => down$3,
|
|
591
|
+
up: () => up$3
|
|
410
592
|
});
|
|
411
|
-
async function up$
|
|
593
|
+
async function up$3(db) {
|
|
412
594
|
await db.schema.alterTable("attachment_reservation").addColumn("expires_at_utc", "text").execute();
|
|
413
595
|
await db.updateTable("attachment_reservation").set({ expires_at_utc: sql`created_at_utc` }).where("expires_at_utc", "is", null).execute();
|
|
414
596
|
await db.schema.alterTable("attachment_reservation").alterColumn("expires_at_utc", (col) => col.setNotNull()).execute();
|
|
415
597
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
416
598
|
}
|
|
417
|
-
async function down$
|
|
599
|
+
async function down$3(db) {
|
|
418
600
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
419
601
|
await db.schema.alterTable("attachment_reservation").dropColumn("expires_at_utc").execute();
|
|
420
602
|
}
|
|
421
603
|
//#endregion
|
|
422
604
|
//#region src/storage/migrations/004_add_reservation_soft_delete.ts
|
|
423
605
|
var _004_add_reservation_soft_delete_exports = /* @__PURE__ */ __exportAll({
|
|
424
|
-
down: () => down$
|
|
425
|
-
up: () => up$
|
|
606
|
+
down: () => down$2,
|
|
607
|
+
up: () => up$2
|
|
426
608
|
});
|
|
427
|
-
async function up$
|
|
609
|
+
async function up$2(db) {
|
|
428
610
|
await db.schema.alterTable("attachment_reservation").addColumn("deleted_at_utc", "text").execute();
|
|
429
611
|
}
|
|
430
|
-
async function down$
|
|
612
|
+
async function down$2(db) {
|
|
431
613
|
await db.schema.alterTable("attachment_reservation").dropColumn("deleted_at_utc").execute();
|
|
432
614
|
}
|
|
433
615
|
//#endregion
|
|
434
616
|
//#region src/storage/migrations/005_add_reservation_active_index.ts
|
|
435
617
|
var _005_add_reservation_active_index_exports = /* @__PURE__ */ __exportAll({
|
|
436
|
-
down: () => down,
|
|
437
|
-
up: () => up
|
|
618
|
+
down: () => down$1,
|
|
619
|
+
up: () => up$1
|
|
438
620
|
});
|
|
439
|
-
async function up(db) {
|
|
621
|
+
async function up$1(db) {
|
|
440
622
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
441
623
|
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
624
|
}
|
|
443
|
-
async function down(db) {
|
|
625
|
+
async function down$1(db) {
|
|
444
626
|
await db.schema.dropIndex("idx_reservation_expires_at_active").ifExists().execute();
|
|
445
627
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
446
628
|
}
|
|
447
629
|
//#endregion
|
|
630
|
+
//#region src/storage/migrations/006_add_reservation_client_hash.ts
|
|
631
|
+
var _006_add_reservation_client_hash_exports = /* @__PURE__ */ __exportAll({
|
|
632
|
+
down: () => down,
|
|
633
|
+
up: () => up
|
|
634
|
+
});
|
|
635
|
+
async function up(db) {
|
|
636
|
+
await db.schema.alterTable("attachment_reservation").addColumn("client_hash", "text").execute();
|
|
637
|
+
await db.schema.alterTable("attachment_reservation").addColumn("size_bytes", "bigint").execute();
|
|
638
|
+
await db.schema.alterTable("attachment_reservation").addCheckConstraint("attachment_reservation_hash_size_check", sql`client_hash is null or size_bytes is not null`).execute();
|
|
639
|
+
await db.schema.createIndex("idx_reservation_client_hash").on("attachment_reservation").column("client_hash").execute();
|
|
640
|
+
}
|
|
641
|
+
async function down(db) {
|
|
642
|
+
await db.schema.dropIndex("idx_reservation_client_hash").ifExists().execute();
|
|
643
|
+
await db.schema.alterTable("attachment_reservation").dropColumn("size_bytes").execute();
|
|
644
|
+
await db.schema.alterTable("attachment_reservation").dropColumn("client_hash").execute();
|
|
645
|
+
}
|
|
646
|
+
//#endregion
|
|
448
647
|
//#region src/storage/migrations/migrator.ts
|
|
449
648
|
const ATTACHMENT_SCHEMA = "attachments";
|
|
450
649
|
const migrations = {
|
|
@@ -452,7 +651,8 @@ const migrations = {
|
|
|
452
651
|
"002_create_reservation_table": _002_create_reservation_table_exports,
|
|
453
652
|
"003_add_reservation_expires_at": _003_add_reservation_expires_at_exports,
|
|
454
653
|
"004_add_reservation_soft_delete": _004_add_reservation_soft_delete_exports,
|
|
455
|
-
"005_add_reservation_active_index": _005_add_reservation_active_index_exports
|
|
654
|
+
"005_add_reservation_active_index": _005_add_reservation_active_index_exports,
|
|
655
|
+
"006_add_reservation_client_hash": _006_add_reservation_client_hash_exports
|
|
456
656
|
};
|
|
457
657
|
var ProgrammaticMigrationProvider = class {
|
|
458
658
|
getMigrations() {
|
|
@@ -507,21 +707,35 @@ function rowToHeader(row) {
|
|
|
507
707
|
status: row.status,
|
|
508
708
|
source: row.source,
|
|
509
709
|
createdAtUtc: row.created_at_utc,
|
|
510
|
-
lastAccessedAtUtc: row.last_accessed_at_utc
|
|
710
|
+
lastAccessedAtUtc: row.last_accessed_at_utc,
|
|
711
|
+
expiresAtUtc: null
|
|
511
712
|
};
|
|
512
713
|
}
|
|
513
714
|
var DirectAttachmentUpload = class {
|
|
514
715
|
reservationId;
|
|
515
|
-
|
|
516
|
-
|
|
716
|
+
ref;
|
|
717
|
+
expiresAtUtc;
|
|
718
|
+
constructor(reservation, db, basePath, reservations, maxBytes) {
|
|
719
|
+
this.reservation = reservation;
|
|
517
720
|
this.db = db;
|
|
518
721
|
this.basePath = basePath;
|
|
519
722
|
this.reservations = reservations;
|
|
520
723
|
this.maxBytes = maxBytes;
|
|
521
|
-
this.reservationId = reservationId;
|
|
724
|
+
this.reservationId = reservation.reservationId;
|
|
725
|
+
this.ref = reservation.clientHash != null ? createRef(reservation.clientHash) : null;
|
|
726
|
+
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
522
727
|
}
|
|
523
728
|
async send(data) {
|
|
524
|
-
|
|
729
|
+
if (this.reservation.clientHash != null && this.reservation.sizeBytes == null) throw new Error("hash-first reservation missing sizeBytes");
|
|
730
|
+
const declaredSizeBytes = this.reservation.clientHash != null ? this.reservation.sizeBytes ?? void 0 : void 0;
|
|
731
|
+
const { tempPath, hash, sizeBytes } = await streamHashAndWrite(this.basePath, data, {
|
|
732
|
+
maxBytes: this.maxBytes,
|
|
733
|
+
declaredSizeBytes
|
|
734
|
+
});
|
|
735
|
+
if (this.reservation.clientHash != null && hash !== this.reservation.clientHash) {
|
|
736
|
+
await rm(tempPath, { force: true });
|
|
737
|
+
throw new HashMismatch(this.reservation.clientHash, hash);
|
|
738
|
+
}
|
|
525
739
|
try {
|
|
526
740
|
const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
527
741
|
if (existing?.status === "available") await rm(tempPath, { force: true });
|
|
@@ -533,10 +747,10 @@ var DirectAttachmentUpload = class {
|
|
|
533
747
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
534
748
|
if (!existing) await this.db.insertInto("attachment").values({
|
|
535
749
|
hash,
|
|
536
|
-
mime_type: this.
|
|
537
|
-
file_name: this.
|
|
750
|
+
mime_type: this.reservation.mimeType,
|
|
751
|
+
file_name: this.reservation.fileName,
|
|
538
752
|
size_bytes: sizeBytes,
|
|
539
|
-
extension: this.
|
|
753
|
+
extension: this.reservation.extension ?? null,
|
|
540
754
|
status: "available",
|
|
541
755
|
storage_path: relPath,
|
|
542
756
|
source: "local",
|
|
@@ -572,8 +786,8 @@ var DirectAttachmentUploadFactory = class {
|
|
|
572
786
|
this.reservations = reservations;
|
|
573
787
|
this.maxBytes = maxBytes;
|
|
574
788
|
}
|
|
575
|
-
createUpload(
|
|
576
|
-
return new DirectAttachmentUpload(
|
|
789
|
+
createUpload(reservation) {
|
|
790
|
+
return new DirectAttachmentUpload(reservation, this.db, this.basePath, this.reservations, this.maxBytes);
|
|
577
791
|
}
|
|
578
792
|
};
|
|
579
793
|
//#endregion
|
|
@@ -604,15 +818,28 @@ var SwitchboardAttachmentTransport = class {
|
|
|
604
818
|
signal,
|
|
605
819
|
headers
|
|
606
820
|
});
|
|
607
|
-
if (response.status ===
|
|
821
|
+
if (response.status === 202) {
|
|
822
|
+
const expiresAtUtc = this.parsePendingExpiry(response);
|
|
823
|
+
if (!expiresAtUtc) throw new Error("Attachment fetch returned 202 with missing or malformed Attachment-Pending header");
|
|
824
|
+
return {
|
|
825
|
+
kind: "pending",
|
|
826
|
+
hash,
|
|
827
|
+
expiresAtUtc,
|
|
828
|
+
retryAfterMs: parseRetryAfterMs(response)
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
if (response.status === 404) return { kind: "not-found" };
|
|
608
832
|
if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
|
|
609
833
|
const metadata = this.parseMetadataHeaders(response);
|
|
610
834
|
const body = response.body;
|
|
611
835
|
if (!body) throw new Error("Response body is null");
|
|
612
836
|
return {
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
837
|
+
kind: "data",
|
|
838
|
+
response: {
|
|
839
|
+
hash,
|
|
840
|
+
metadata,
|
|
841
|
+
body
|
|
842
|
+
}
|
|
616
843
|
};
|
|
617
844
|
}
|
|
618
845
|
async announce(_hash) {}
|
|
@@ -627,6 +854,18 @@ var SwitchboardAttachmentTransport = class {
|
|
|
627
854
|
});
|
|
628
855
|
if (!response.ok) throw new Error(`Attachment push failed: ${response.status} ${response.statusText}`);
|
|
629
856
|
}
|
|
857
|
+
parsePendingExpiry(response) {
|
|
858
|
+
const header = response.headers.get("Attachment-Pending");
|
|
859
|
+
if (!header) return null;
|
|
860
|
+
try {
|
|
861
|
+
const parsed = JSON.parse(header);
|
|
862
|
+
if (!isRecord$3(parsed)) return null;
|
|
863
|
+
if (typeof parsed.expiresAtUtc !== "string") return null;
|
|
864
|
+
return parsed.expiresAtUtc;
|
|
865
|
+
} catch {
|
|
866
|
+
return null;
|
|
867
|
+
}
|
|
868
|
+
}
|
|
630
869
|
parseMetadataHeaders(response) {
|
|
631
870
|
let fallbackCache;
|
|
632
871
|
const fallback = () => {
|
|
@@ -636,7 +875,7 @@ var SwitchboardAttachmentTransport = class {
|
|
|
636
875
|
const metaHeader = response.headers.get("Attachment-Metadata");
|
|
637
876
|
if (metaHeader) try {
|
|
638
877
|
const parsed = JSON.parse(metaHeader);
|
|
639
|
-
if (isRecord$
|
|
878
|
+
if (isRecord$3(parsed)) {
|
|
640
879
|
if (parsed.extension === void 0) parsed.extension = null;
|
|
641
880
|
if (parsed.createdAtUtc === void 0) parsed.createdAtUtc = fallback().createdAtUtc;
|
|
642
881
|
if (parsed.lastAccessedAtUtc === void 0) parsed.lastAccessedAtUtc = fallback().lastAccessedAtUtc;
|
|
@@ -646,11 +885,19 @@ var SwitchboardAttachmentTransport = class {
|
|
|
646
885
|
return fallback();
|
|
647
886
|
}
|
|
648
887
|
};
|
|
649
|
-
|
|
888
|
+
const DEFAULT_RETRY_AFTER_MS = 5e3;
|
|
889
|
+
function parseRetryAfterMs(response) {
|
|
890
|
+
const retryAfter = response.headers.get("Retry-After");
|
|
891
|
+
if (!retryAfter) return DEFAULT_RETRY_AFTER_MS;
|
|
892
|
+
const seconds = Number(retryAfter);
|
|
893
|
+
if (!Number.isFinite(seconds) || seconds < 0) return DEFAULT_RETRY_AFTER_MS;
|
|
894
|
+
return Math.round(seconds * 1e3);
|
|
895
|
+
}
|
|
896
|
+
function isRecord$3(value) {
|
|
650
897
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
651
898
|
}
|
|
652
899
|
function isAttachmentMetadata$1(value) {
|
|
653
|
-
if (!isRecord$
|
|
900
|
+
if (!isRecord$3(value)) return false;
|
|
654
901
|
if (typeof value.mimeType !== "string") return false;
|
|
655
902
|
if (typeof value.fileName !== "string") return false;
|
|
656
903
|
if (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0) return false;
|
|
@@ -678,7 +925,7 @@ function contentTypeFallback$1(response) {
|
|
|
678
925
|
}
|
|
679
926
|
//#endregion
|
|
680
927
|
//#region src/switchboard/remote-reservation-store.ts
|
|
681
|
-
function isRecord$
|
|
928
|
+
function isRecord$2(value) {
|
|
682
929
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
683
930
|
}
|
|
684
931
|
function deriveExtension(fileName) {
|
|
@@ -686,8 +933,8 @@ function deriveExtension(fileName) {
|
|
|
686
933
|
if (idx <= 0 || idx === fileName.length - 1) return null;
|
|
687
934
|
return fileName.slice(idx + 1).toLowerCase();
|
|
688
935
|
}
|
|
689
|
-
function
|
|
690
|
-
if (!isRecord$
|
|
936
|
+
function isReservationBase(value) {
|
|
937
|
+
if (!isRecord$2(value)) return false;
|
|
691
938
|
if (typeof value.reservationId !== "string") return false;
|
|
692
939
|
if (typeof value.mimeType !== "string") return false;
|
|
693
940
|
if (typeof value.fileName !== "string") return false;
|
|
@@ -696,6 +943,12 @@ function isReservation(value) {
|
|
|
696
943
|
if (typeof value.expiresAtUtc !== "string") return false;
|
|
697
944
|
return true;
|
|
698
945
|
}
|
|
946
|
+
function isReservation(value) {
|
|
947
|
+
if (!isReservationBase(value)) return false;
|
|
948
|
+
if (value.clientHash !== void 0 && value.clientHash !== null && typeof value.clientHash !== "string") return false;
|
|
949
|
+
if (value.sizeBytes !== void 0 && value.sizeBytes !== null && typeof value.sizeBytes !== "number") return false;
|
|
950
|
+
return true;
|
|
951
|
+
}
|
|
699
952
|
var RemoteReservationStore = class {
|
|
700
953
|
remoteUrl;
|
|
701
954
|
jwtHandler;
|
|
@@ -709,28 +962,60 @@ var RemoteReservationStore = class {
|
|
|
709
962
|
const url = `${this.remoteUrl}/attachments/reservations`;
|
|
710
963
|
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
711
964
|
const extension = options.extension ?? deriveExtension(options.fileName);
|
|
965
|
+
const bodyObj = {
|
|
966
|
+
mimeType: options.mimeType,
|
|
967
|
+
fileName: options.fileName,
|
|
968
|
+
extension
|
|
969
|
+
};
|
|
970
|
+
if (options.clientHash !== void 0) bodyObj.clientHash = options.clientHash;
|
|
971
|
+
if (options.sizeBytes !== void 0) bodyObj.sizeBytes = options.sizeBytes;
|
|
712
972
|
const response = await this.fetchFn(url, {
|
|
713
973
|
method: "POST",
|
|
714
974
|
headers: {
|
|
715
975
|
...authHeaders,
|
|
716
976
|
"Content-Type": "application/json"
|
|
717
977
|
},
|
|
718
|
-
body: JSON.stringify(
|
|
719
|
-
mimeType: options.mimeType,
|
|
720
|
-
fileName: options.fileName,
|
|
721
|
-
extension
|
|
722
|
-
})
|
|
978
|
+
body: JSON.stringify(bodyObj)
|
|
723
979
|
});
|
|
980
|
+
if (response.status === 409) {
|
|
981
|
+
let body;
|
|
982
|
+
try {
|
|
983
|
+
body = await response.json();
|
|
984
|
+
} catch {
|
|
985
|
+
throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
|
|
986
|
+
}
|
|
987
|
+
if (isRecord$2(body) && body.error === "already_exists" && options.clientHash !== void 0) {
|
|
988
|
+
let ref;
|
|
989
|
+
if (typeof body.ref === "string") try {
|
|
990
|
+
parseRef(body.ref);
|
|
991
|
+
ref = body.ref;
|
|
992
|
+
} catch {
|
|
993
|
+
ref = createRef(options.clientHash);
|
|
994
|
+
}
|
|
995
|
+
else ref = createRef(options.clientHash);
|
|
996
|
+
throw new AttachmentAlreadyExists(options.clientHash, ref);
|
|
997
|
+
}
|
|
998
|
+
throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
|
|
999
|
+
}
|
|
724
1000
|
if (!response.ok) throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
|
|
725
|
-
|
|
1001
|
+
let json;
|
|
1002
|
+
try {
|
|
1003
|
+
json = await response.json();
|
|
1004
|
+
} catch {
|
|
1005
|
+
throw new Error("Reservation create returned non-JSON response");
|
|
1006
|
+
}
|
|
1007
|
+
if (typeof json !== "object" || json === null || typeof json.reservationId !== "string" || json.reservationId.length === 0) throw new Error("Reservation create returned a payload missing a non-empty reservationId string");
|
|
1008
|
+
const body = json;
|
|
726
1009
|
const now = /* @__PURE__ */ new Date();
|
|
727
1010
|
return {
|
|
728
|
-
reservationId:
|
|
1011
|
+
reservationId: body.reservationId,
|
|
729
1012
|
mimeType: options.mimeType,
|
|
730
1013
|
fileName: options.fileName,
|
|
731
1014
|
extension,
|
|
732
|
-
createdAtUtc:
|
|
733
|
-
expiresAtUtc:
|
|
1015
|
+
createdAtUtc: body.createdAtUtc ?? now.toISOString(),
|
|
1016
|
+
expiresAtUtc: body.expiresAtUtc ?? new Date(now.getTime() + 1440 * 60 * 1e3).toISOString(),
|
|
1017
|
+
clientHash: options.clientHash ?? null,
|
|
1018
|
+
sizeBytes: options.sizeBytes ?? null
|
|
734
1019
|
};
|
|
735
1020
|
}
|
|
736
1021
|
async get(reservationId) {
|
|
@@ -746,7 +1031,16 @@ var RemoteReservationStore = class {
|
|
|
746
1031
|
throw new Error("Reservation get returned non-JSON response");
|
|
747
1032
|
}
|
|
748
1033
|
if (!isReservation(parsed)) throw new Error("Reservation get returned a payload that does not match the Reservation shape");
|
|
749
|
-
return
|
|
1034
|
+
return {
|
|
1035
|
+
reservationId: parsed.reservationId,
|
|
1036
|
+
mimeType: parsed.mimeType,
|
|
1037
|
+
fileName: parsed.fileName,
|
|
1038
|
+
extension: parsed.extension,
|
|
1039
|
+
createdAtUtc: parsed.createdAtUtc,
|
|
1040
|
+
expiresAtUtc: parsed.expiresAtUtc,
|
|
1041
|
+
clientHash: parsed.clientHash ?? null,
|
|
1042
|
+
sizeBytes: parsed.sizeBytes ?? null
|
|
1043
|
+
};
|
|
750
1044
|
}
|
|
751
1045
|
async delete(reservationId) {
|
|
752
1046
|
const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
|
|
@@ -763,15 +1057,20 @@ var RemoteReservationStore = class {
|
|
|
763
1057
|
};
|
|
764
1058
|
//#endregion
|
|
765
1059
|
//#region src/switchboard/remote-attachment-upload.ts
|
|
1060
|
+
function isRecord$1(value) {
|
|
1061
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1062
|
+
}
|
|
766
1063
|
var RemoteAttachmentUpload = class {
|
|
767
1064
|
reservationId;
|
|
1065
|
+
ref;
|
|
1066
|
+
expiresAtUtc;
|
|
768
1067
|
remoteUrl;
|
|
769
1068
|
jwtHandler;
|
|
770
1069
|
fetchFn;
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
this.
|
|
774
|
-
this.
|
|
1070
|
+
constructor(reservation, config) {
|
|
1071
|
+
this.reservationId = reservation.reservationId;
|
|
1072
|
+
this.ref = reservation.clientHash !== null ? createRef(reservation.clientHash) : null;
|
|
1073
|
+
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
775
1074
|
this.remoteUrl = config.remoteUrl;
|
|
776
1075
|
this.jwtHandler = config.jwtHandler;
|
|
777
1076
|
this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
|
|
@@ -788,6 +1087,19 @@ var RemoteAttachmentUpload = class {
|
|
|
788
1087
|
},
|
|
789
1088
|
body
|
|
790
1089
|
});
|
|
1090
|
+
if (response.status === 422) {
|
|
1091
|
+
let errorBody;
|
|
1092
|
+
try {
|
|
1093
|
+
errorBody = await response.json();
|
|
1094
|
+
} catch {
|
|
1095
|
+
throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
|
|
1096
|
+
}
|
|
1097
|
+
if (isRecord$1(errorBody)) {
|
|
1098
|
+
if (errorBody.error === "hash_mismatch" && typeof errorBody.claimed === "string" && typeof errorBody.actual === "string") throw new HashMismatch(errorBody.claimed, errorBody.actual);
|
|
1099
|
+
if (errorBody.error === "size_mismatch" && typeof errorBody.declared === "number" && typeof errorBody.actual === "number") throw new SizeMismatch(errorBody.declared, errorBody.actual);
|
|
1100
|
+
}
|
|
1101
|
+
throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
|
|
1102
|
+
}
|
|
791
1103
|
if (!response.ok) throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
|
|
792
1104
|
return await response.json();
|
|
793
1105
|
}
|
|
@@ -798,8 +1110,8 @@ var RemoteAttachmentUploadFactory = class {
|
|
|
798
1110
|
constructor(config) {
|
|
799
1111
|
this.config = config;
|
|
800
1112
|
}
|
|
801
|
-
createUpload(
|
|
802
|
-
return new RemoteAttachmentUpload(
|
|
1113
|
+
createUpload(reservation) {
|
|
1114
|
+
return new RemoteAttachmentUpload(reservation, this.config);
|
|
803
1115
|
}
|
|
804
1116
|
};
|
|
805
1117
|
//#endregion
|
|
@@ -852,6 +1164,33 @@ function parseMetadata(response) {
|
|
|
852
1164
|
} catch {}
|
|
853
1165
|
return fallback();
|
|
854
1166
|
}
|
|
1167
|
+
function parsePendingExpiry(response) {
|
|
1168
|
+
const header = response.headers.get("Attachment-Pending");
|
|
1169
|
+
if (!header) return null;
|
|
1170
|
+
try {
|
|
1171
|
+
const parsed = JSON.parse(header);
|
|
1172
|
+
if (!isRecord(parsed)) return null;
|
|
1173
|
+
if (typeof parsed.expiresAtUtc !== "string") return null;
|
|
1174
|
+
const result = { expiresAtUtc: parsed.expiresAtUtc };
|
|
1175
|
+
if (typeof parsed.mimeType === "string") result.mimeType = parsed.mimeType;
|
|
1176
|
+
if (typeof parsed.fileName === "string") result.fileName = parsed.fileName;
|
|
1177
|
+
if (typeof parsed.sizeBytes === "number" && Number.isFinite(parsed.sizeBytes) && parsed.sizeBytes >= 0) result.sizeBytes = parsed.sizeBytes;
|
|
1178
|
+
return result;
|
|
1179
|
+
} catch {
|
|
1180
|
+
return null;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function parsePendingHeader(response) {
|
|
1184
|
+
const partial = parsePendingExpiry(response);
|
|
1185
|
+
if (!partial) return null;
|
|
1186
|
+
if (typeof partial.mimeType !== "string" || typeof partial.fileName !== "string" || partial.sizeBytes === void 0) return null;
|
|
1187
|
+
return {
|
|
1188
|
+
expiresAtUtc: partial.expiresAtUtc,
|
|
1189
|
+
mimeType: partial.mimeType,
|
|
1190
|
+
fileName: partial.fileName,
|
|
1191
|
+
sizeBytes: partial.sizeBytes
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
855
1194
|
var RemoteAttachmentStore = class {
|
|
856
1195
|
remoteUrl;
|
|
857
1196
|
jwtHandler;
|
|
@@ -861,6 +1200,13 @@ var RemoteAttachmentStore = class {
|
|
|
861
1200
|
this.jwtHandler = config.jwtHandler;
|
|
862
1201
|
this.fetchFn = (config.fetchFn ?? globalThis.fetch).bind(globalThis);
|
|
863
1202
|
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Get attachment metadata. Normally returns a pending AttachmentHeader
|
|
1205
|
+
* (status: 'pending') when the server responds 202 with a full
|
|
1206
|
+
* Attachment-Pending header. When only expiresAtUtc is present in the
|
|
1207
|
+
* header (degraded wire), throws AttachmentPending instead -- the
|
|
1208
|
+
* AttachmentPending throw is the degraded-wire case.
|
|
1209
|
+
*/
|
|
864
1210
|
async stat(hash) {
|
|
865
1211
|
const url = `${this.remoteUrl}/attachments/${hash}`;
|
|
866
1212
|
const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
|
|
@@ -868,6 +1214,13 @@ var RemoteAttachmentStore = class {
|
|
|
868
1214
|
method: "HEAD",
|
|
869
1215
|
headers: authHeaders
|
|
870
1216
|
});
|
|
1217
|
+
if (response.status === 202) {
|
|
1218
|
+
const fullPending = parsePendingHeader(response);
|
|
1219
|
+
if (fullPending) return buildPendingHeader(hash, fullPending);
|
|
1220
|
+
const partial = parsePendingExpiry(response);
|
|
1221
|
+
if (partial) throw new AttachmentPending(hash, partial.expiresAtUtc);
|
|
1222
|
+
throw new Error("Attachment stat returned 202 with missing or malformed Attachment-Pending header");
|
|
1223
|
+
}
|
|
871
1224
|
if (response.status === 404) throw new AttachmentNotFound(hash);
|
|
872
1225
|
if (!response.ok) throw new Error(`Attachment stat failed: ${response.status} ${response.statusText}`);
|
|
873
1226
|
return buildHeader(hash, parseMetadata(response));
|
|
@@ -882,6 +1235,11 @@ var RemoteAttachmentStore = class {
|
|
|
882
1235
|
signal,
|
|
883
1236
|
headers
|
|
884
1237
|
});
|
|
1238
|
+
if (response.status === 202) {
|
|
1239
|
+
const pending = parsePendingExpiry(response);
|
|
1240
|
+
if (!pending) throw new Error("Attachment fetch returned 202 with missing or malformed Attachment-Pending header");
|
|
1241
|
+
throw new AttachmentPending(hash, pending.expiresAtUtc);
|
|
1242
|
+
}
|
|
885
1243
|
if (response.status === 404) throw new AttachmentNotFound(hash);
|
|
886
1244
|
if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
|
|
887
1245
|
if (!response.body) throw new Error("Response body is null");
|
|
@@ -901,7 +1259,23 @@ function buildHeader(hash, metadata) {
|
|
|
901
1259
|
status: "available",
|
|
902
1260
|
source: "sync",
|
|
903
1261
|
createdAtUtc: metadata.createdAtUtc,
|
|
904
|
-
lastAccessedAtUtc: metadata.lastAccessedAtUtc ?? metadata.createdAtUtc
|
|
1262
|
+
lastAccessedAtUtc: metadata.lastAccessedAtUtc ?? metadata.createdAtUtc,
|
|
1263
|
+
expiresAtUtc: null
|
|
1264
|
+
};
|
|
1265
|
+
}
|
|
1266
|
+
function buildPendingHeader(hash, pending) {
|
|
1267
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1268
|
+
return {
|
|
1269
|
+
hash,
|
|
1270
|
+
mimeType: pending.mimeType,
|
|
1271
|
+
fileName: pending.fileName,
|
|
1272
|
+
sizeBytes: pending.sizeBytes,
|
|
1273
|
+
extension: null,
|
|
1274
|
+
status: "pending",
|
|
1275
|
+
source: "sync",
|
|
1276
|
+
createdAtUtc: now,
|
|
1277
|
+
lastAccessedAtUtc: now,
|
|
1278
|
+
expiresAtUtc: pending.expiresAtUtc
|
|
905
1279
|
};
|
|
906
1280
|
}
|
|
907
1281
|
//#endregion
|
|
@@ -915,11 +1289,11 @@ function createRemoteAttachmentService(config) {
|
|
|
915
1289
|
//#region src/null-attachment-transport.ts
|
|
916
1290
|
/**
|
|
917
1291
|
* No-op transport for deployments without remote sync.
|
|
918
|
-
* fetch() always returns
|
|
1292
|
+
* fetch() always returns not-found, announce() and push() are no-ops.
|
|
919
1293
|
*/
|
|
920
1294
|
var NullAttachmentTransport = class {
|
|
921
1295
|
fetch() {
|
|
922
|
-
return Promise.resolve(
|
|
1296
|
+
return Promise.resolve({ kind: "not-found" });
|
|
923
1297
|
}
|
|
924
1298
|
announce() {
|
|
925
1299
|
return Promise.resolve();
|
|
@@ -934,6 +1308,7 @@ var AttachmentBuilder = class {
|
|
|
934
1308
|
transport = new NullAttachmentTransport();
|
|
935
1309
|
customUploadFactory;
|
|
936
1310
|
maxUploadBytes;
|
|
1311
|
+
reservationSweepMs;
|
|
937
1312
|
constructor(db, storagePath) {
|
|
938
1313
|
this.db = db;
|
|
939
1314
|
this.storagePath = storagePath;
|
|
@@ -950,6 +1325,18 @@ var AttachmentBuilder = class {
|
|
|
950
1325
|
this.maxUploadBytes = maxBytes;
|
|
951
1326
|
return this;
|
|
952
1327
|
}
|
|
1328
|
+
/**
|
|
1329
|
+
* Configure a recurring sweep that deletes expired reservations.
|
|
1330
|
+
* The sweep calls reservations.deleteExpired() on the given interval.
|
|
1331
|
+
* When set, the built result's destroy() clears the timer.
|
|
1332
|
+
* Without this option no sweep runs -- deleteExpired() is never called
|
|
1333
|
+
* automatically. Call withReservationSweepMs in production to prevent
|
|
1334
|
+
* expired reservation rows from accumulating indefinitely.
|
|
1335
|
+
*/
|
|
1336
|
+
withReservationSweepMs(intervalMs) {
|
|
1337
|
+
this.reservationSweepMs = intervalMs;
|
|
1338
|
+
return this;
|
|
1339
|
+
}
|
|
953
1340
|
async build() {
|
|
954
1341
|
const result = await runAttachmentMigrations(this.db, ATTACHMENT_SCHEMA);
|
|
955
1342
|
if (!result.success && result.error) throw result.error;
|
|
@@ -957,15 +1344,31 @@ var AttachmentBuilder = class {
|
|
|
957
1344
|
const store = new KyselyAttachmentStore(scopedDb, this.transport, this.storagePath);
|
|
958
1345
|
const reservations = new KyselyReservationStore(scopedDb);
|
|
959
1346
|
const uploadFactory = this.customUploadFactory ?? new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations, this.maxUploadBytes);
|
|
1347
|
+
const service = new AttachmentService(store, reservations, uploadFactory);
|
|
1348
|
+
let sweepTimer;
|
|
1349
|
+
if (this.reservationSweepMs !== void 0) {
|
|
1350
|
+
const intervalMs = this.reservationSweepMs;
|
|
1351
|
+
sweepTimer = setInterval(() => {
|
|
1352
|
+
reservations.deleteExpired().catch(() => {});
|
|
1353
|
+
}, intervalMs);
|
|
1354
|
+
if (typeof sweepTimer.unref === "function") sweepTimer.unref();
|
|
1355
|
+
}
|
|
1356
|
+
const destroy = () => {
|
|
1357
|
+
if (sweepTimer !== void 0) {
|
|
1358
|
+
clearInterval(sweepTimer);
|
|
1359
|
+
sweepTimer = void 0;
|
|
1360
|
+
}
|
|
1361
|
+
};
|
|
960
1362
|
return {
|
|
961
|
-
service
|
|
1363
|
+
service,
|
|
962
1364
|
store,
|
|
963
1365
|
reservations,
|
|
964
|
-
uploadFactory
|
|
1366
|
+
uploadFactory,
|
|
1367
|
+
destroy
|
|
965
1368
|
};
|
|
966
1369
|
}
|
|
967
1370
|
};
|
|
968
1371
|
//#endregion
|
|
969
|
-
export { ATTACHMENT_SCHEMA, AttachmentBuilder, AttachmentNotFound, AttachmentService, DirectAttachmentUpload, DirectAttachmentUploadFactory, InvalidAttachmentRef, KyselyAttachmentStore, KyselyReservationStore, NullAttachmentTransport, RemoteAttachmentStore, RemoteAttachmentUpload, RemoteAttachmentUploadFactory, RemoteReservationStore, ReservationNotFound, SwitchboardAttachmentTransport, createRef, createRemoteAttachmentService, parseRef, runAttachmentMigrations };
|
|
1372
|
+
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
1373
|
|
|
971
1374
|
//# sourceMappingURL=index.js.map
|