@powerhousedao/reactor-attachments 6.1.0-dev.2 → 6.1.0-dev.20

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/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) => writer.once("drain", 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.on("error", reject);
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
- await new Promise((resolve, reject) => {
189
- writer.end((err) => {
190
- if (err) reject(err);
191
- else resolve();
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
- writer.on("error", reject);
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 (!row) throw new AttachmentNotFound(hash);
258
- return rowToHeader$1(row);
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 (!row) throw new AttachmentNotFound(hash);
266
- if (row.status === "evicted") {
267
- const remote = await this.transport.fetch(hash, signal);
268
- if (!remote) throw new AttachmentNotFound(hash);
269
- await this.put(hash, remote.metadata, remote.body);
270
- return this.get(hash, signal);
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
- await this.db.updateTable("attachment").set({ last_accessed_at_utc: now }).where("hash", "=", hash).execute();
274
- const header = rowToHeader$1(row);
275
- header.lastAccessedAtUtc = now;
276
- this.acquireReader(hash);
277
- return {
278
- header,
279
- body: wrapStreamWithCleanup(readAttachmentStream(join(this.basePath, row.storage_path)), () => this.releaseReader(hash))
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$4,
383
- up: () => up$4
394
+ down: () => down$5,
395
+ up: () => up$5
384
396
  });
385
- async function up$4(db) {
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$4(db) {
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$3,
397
- up: () => up$3
408
+ down: () => down$4,
409
+ up: () => up$4
398
410
  });
399
- async function up$3(db) {
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$3(db) {
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$2,
409
- up: () => up$2
420
+ down: () => down$3,
421
+ up: () => up$3
410
422
  });
411
- async function up$2(db) {
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$2(db) {
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$1,
425
- up: () => up$1
436
+ down: () => down$2,
437
+ up: () => up$2
426
438
  });
427
- async function up$1(db) {
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$1(db) {
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
- constructor(reservationId, options, db, basePath, reservations, maxBytes) {
516
- this.options = options;
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
- const { tempPath, hash, sizeBytes } = await streamHashAndWrite(this.basePath, data, { maxBytes: this.maxBytes });
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.options.mimeType,
537
- file_name: this.options.fileName,
580
+ mime_type: this.reservation.mimeType,
581
+ file_name: this.reservation.fileName,
538
582
  size_bytes: sizeBytes,
539
- extension: this.options.extension ?? null,
583
+ extension: this.reservation.extension ?? null,
540
584
  status: "available",
541
585
  storage_path: relPath,
542
586
  source: "local",
@@ -572,354 +616,8 @@ var DirectAttachmentUploadFactory = class {
572
616
  this.reservations = reservations;
573
617
  this.maxBytes = maxBytes;
574
618
  }
575
- createUpload(reservationId, options) {
576
- return new DirectAttachmentUpload(reservationId, options, this.db, this.basePath, this.reservations, this.maxBytes);
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;
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 isReservation(value) {
685
- if (!isRecord$1(value)) return false;
686
- if (typeof value.reservationId !== "string") return false;
687
- if (typeof value.mimeType !== "string") return false;
688
- if (typeof value.fileName !== "string") return false;
689
- if (value.extension !== null && typeof value.extension !== "string") return false;
690
- if (typeof value.createdAtUtc !== "string") return false;
691
- if (typeof value.expiresAtUtc !== "string") return false;
692
- return true;
693
- }
694
- var RemoteReservationStore = class {
695
- remoteUrl;
696
- jwtHandler;
697
- fetchFn;
698
- constructor(config) {
699
- this.remoteUrl = config.remoteUrl;
700
- this.jwtHandler = config.jwtHandler;
701
- this.fetchFn = config.fetchFn ?? globalThis.fetch;
702
- }
703
- async create(options) {
704
- const url = `${this.remoteUrl}/attachments/reservations`;
705
- const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
706
- const response = await this.fetchFn(url, {
707
- method: "POST",
708
- headers: {
709
- ...authHeaders,
710
- "Content-Type": "application/json"
711
- },
712
- body: JSON.stringify({
713
- mimeType: options.mimeType,
714
- fileName: options.fileName,
715
- extension: options.extension ?? null
716
- })
717
- });
718
- if (!response.ok) throw new Error(`Reservation create failed: ${response.status} ${response.statusText}`);
719
- const json = await response.json();
720
- const now = /* @__PURE__ */ new Date();
721
- return {
722
- reservationId: json.reservationId,
723
- mimeType: options.mimeType,
724
- fileName: options.fileName,
725
- extension: options.extension ?? null,
726
- createdAtUtc: json.createdAtUtc ?? now.toISOString(),
727
- expiresAtUtc: json.expiresAtUtc ?? new Date(now.getTime() + 1440 * 60 * 1e3).toISOString()
728
- };
729
- }
730
- async get(reservationId) {
731
- const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
732
- const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
733
- const response = await this.fetchFn(url, { headers: authHeaders });
734
- if (response.status === 404) throw new ReservationNotFound(reservationId);
735
- if (!response.ok) throw new Error(`Reservation get failed: ${response.status} ${response.statusText}`);
736
- let parsed;
737
- try {
738
- parsed = await response.json();
739
- } catch {
740
- throw new Error("Reservation get returned non-JSON response");
741
- }
742
- if (!isReservation(parsed)) throw new Error("Reservation get returned a payload that does not match the Reservation shape");
743
- return parsed;
744
- }
745
- async delete(reservationId) {
746
- const url = `${this.remoteUrl}/attachments/reservations/${encodeURIComponent(reservationId)}`;
747
- const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
748
- const response = await this.fetchFn(url, {
749
- method: "DELETE",
750
- headers: authHeaders
751
- });
752
- if (!response.ok && response.status !== 404 && response.status !== 410) throw new Error(`Reservation delete failed: ${response.status} ${response.statusText}`);
753
- }
754
- deleteExpired() {
755
- return Promise.reject(/* @__PURE__ */ new Error("RemoteReservationStore.deleteExpired is not supported"));
756
- }
757
- };
758
- //#endregion
759
- //#region src/switchboard/remote-attachment-upload.ts
760
- var RemoteAttachmentUpload = class {
761
- reservationId;
762
- remoteUrl;
763
- jwtHandler;
764
- fetchFn;
765
- options;
766
- constructor(reservationId, options, config) {
767
- this.reservationId = reservationId;
768
- this.options = options;
769
- this.remoteUrl = config.remoteUrl;
770
- this.jwtHandler = config.jwtHandler;
771
- this.fetchFn = config.fetchFn ?? globalThis.fetch;
772
- }
773
- async send(data) {
774
- const url = `${this.remoteUrl}/attachments/reservations/${this.reservationId}`;
775
- const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
776
- const response = await this.fetchFn(url, {
777
- method: "PUT",
778
- headers: {
779
- ...authHeaders,
780
- "Content-Type": "application/octet-stream"
781
- },
782
- body: data,
783
- duplex: "half"
784
- });
785
- if (!response.ok) throw new Error(`Attachment upload failed: ${response.status} ${response.statusText}`);
786
- return await response.json();
787
- }
788
- };
789
- //#endregion
790
- //#region src/switchboard/remote-attachment-upload-factory.ts
791
- var RemoteAttachmentUploadFactory = class {
792
- constructor(config) {
793
- this.config = config;
794
- }
795
- createUpload(reservationId, options) {
796
- return new RemoteAttachmentUpload(reservationId, options, this.config);
797
- }
798
- };
799
- //#endregion
800
- //#region src/switchboard/remote-attachment-store.ts
801
- function isRecord(value) {
802
- return typeof value === "object" && value !== null && !Array.isArray(value);
803
- }
804
- function isAttachmentMetadata(value) {
805
- if (!isRecord(value)) return false;
806
- if (typeof value.mimeType !== "string") return false;
807
- if (typeof value.fileName !== "string") return false;
808
- if (typeof value.sizeBytes !== "number" || !Number.isFinite(value.sizeBytes) || value.sizeBytes < 0) return false;
809
- if (value.extension !== null && typeof value.extension !== "string") return false;
810
- if (typeof value.createdAtUtc !== "string") return false;
811
- if (value.lastAccessedAtUtc !== void 0 && typeof value.lastAccessedAtUtc !== "string") return false;
812
- return true;
813
- }
814
- function contentTypeFallback(response) {
815
- const contentLength = response.headers.get("Content-Length");
816
- if (contentLength === null) throw new Error("Switchboard response missing both Attachment-Metadata and Content-Length headers");
817
- const sizeBytes = Number(contentLength);
818
- if (!Number.isInteger(sizeBytes) || sizeBytes < 0) throw new Error(`Switchboard response has invalid Content-Length header: ${JSON.stringify(contentLength)}`);
819
- const lastModified = response.headers.get("Last-Modified");
820
- const dateHeader = response.headers.get("Date");
821
- const createdAtUtc = lastModified ? new Date(lastModified).toISOString() : dateHeader ? new Date(dateHeader).toISOString() : (/* @__PURE__ */ new Date()).toISOString();
822
- return {
823
- mimeType: response.headers.get("Content-Type") ?? "application/octet-stream",
824
- fileName: "unknown",
825
- sizeBytes,
826
- extension: null,
827
- createdAtUtc,
828
- lastAccessedAtUtc: createdAtUtc
829
- };
830
- }
831
- function parseMetadata(response) {
832
- let fallbackCache;
833
- const fallback = () => {
834
- if (fallbackCache === void 0) fallbackCache = contentTypeFallback(response);
835
- return fallbackCache;
836
- };
837
- const metaHeader = response.headers.get("Attachment-Metadata");
838
- if (metaHeader) try {
839
- const parsed = JSON.parse(metaHeader);
840
- if (isRecord(parsed)) {
841
- if (parsed.extension === void 0) parsed.extension = null;
842
- if (parsed.createdAtUtc === void 0) parsed.createdAtUtc = fallback().createdAtUtc;
843
- if (parsed.lastAccessedAtUtc === void 0) parsed.lastAccessedAtUtc = fallback().lastAccessedAtUtc;
844
- }
845
- if (isAttachmentMetadata(parsed)) return parsed;
846
- } catch {}
847
- return fallback();
848
- }
849
- var RemoteAttachmentStore = class {
850
- remoteUrl;
851
- jwtHandler;
852
- fetchFn;
853
- constructor(config) {
854
- this.remoteUrl = config.remoteUrl;
855
- this.jwtHandler = config.jwtHandler;
856
- this.fetchFn = config.fetchFn ?? globalThis.fetch;
857
- }
858
- async stat(hash) {
859
- const url = `${this.remoteUrl}/attachments/${hash}`;
860
- const authHeaders = await buildAuthHeaders(url, this.jwtHandler);
861
- const response = await this.fetchFn(url, {
862
- method: "HEAD",
863
- headers: authHeaders
864
- });
865
- if (response.status === 404) throw new AttachmentNotFound(hash);
866
- if (!response.ok) throw new Error(`Attachment stat failed: ${response.status} ${response.statusText}`);
867
- return buildHeader(hash, parseMetadata(response));
868
- }
869
- async get(hash, signal) {
870
- return this.fetchAttachment(hash, signal);
871
- }
872
- async fetchAttachment(hash, signal) {
873
- const url = `${this.remoteUrl}/attachments/${hash}`;
874
- const headers = await buildAuthHeaders(url, this.jwtHandler);
875
- const response = await this.fetchFn(url, {
876
- signal,
877
- headers
878
- });
879
- if (response.status === 404) throw new AttachmentNotFound(hash);
880
- if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
881
- if (!response.body) throw new Error("Response body is null");
882
- return {
883
- header: buildHeader(hash, parseMetadata(response)),
884
- body: response.body
885
- };
886
- }
887
- };
888
- function buildHeader(hash, metadata) {
889
- return {
890
- hash,
891
- mimeType: metadata.mimeType,
892
- fileName: metadata.fileName,
893
- sizeBytes: metadata.sizeBytes,
894
- extension: metadata.extension,
895
- status: "available",
896
- source: "sync",
897
- createdAtUtc: metadata.createdAtUtc,
898
- lastAccessedAtUtc: metadata.lastAccessedAtUtc ?? metadata.createdAtUtc
899
- };
900
- }
901
- //#endregion
902
- //#region src/switchboard/create-remote-attachment-service.ts
903
- function createRemoteAttachmentService(config) {
904
- const reservations = new RemoteReservationStore(config);
905
- const uploadFactory = new RemoteAttachmentUploadFactory(config);
906
- return new AttachmentService(new RemoteAttachmentStore(config), reservations, uploadFactory);
907
- }
908
- //#endregion
909
- //#region src/null-attachment-transport.ts
910
- /**
911
- * No-op transport for deployments without remote sync.
912
- * fetch() always returns null, announce() and push() are no-ops.
913
- */
914
- var NullAttachmentTransport = class {
915
- fetch() {
916
- return Promise.resolve(null);
917
- }
918
- announce() {
919
- return Promise.resolve();
920
- }
921
- push() {
922
- return Promise.resolve();
619
+ createUpload(reservation) {
620
+ return new DirectAttachmentUpload(reservation, this.db, this.basePath, this.reservations, this.maxBytes);
923
621
  }
924
622
  };
925
623
  //#endregion
@@ -928,6 +626,7 @@ var AttachmentBuilder = class {
928
626
  transport = new NullAttachmentTransport();
929
627
  customUploadFactory;
930
628
  maxUploadBytes;
629
+ reservationSweepMs;
931
630
  constructor(db, storagePath) {
932
631
  this.db = db;
933
632
  this.storagePath = storagePath;
@@ -944,6 +643,18 @@ var AttachmentBuilder = class {
944
643
  this.maxUploadBytes = maxBytes;
945
644
  return this;
946
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
+ }
947
658
  async build() {
948
659
  const result = await runAttachmentMigrations(this.db, ATTACHMENT_SCHEMA);
949
660
  if (!result.success && result.error) throw result.error;
@@ -951,15 +662,31 @@ var AttachmentBuilder = class {
951
662
  const store = new KyselyAttachmentStore(scopedDb, this.transport, this.storagePath);
952
663
  const reservations = new KyselyReservationStore(scopedDb);
953
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
+ };
954
680
  return {
955
- service: new AttachmentService(store, reservations, uploadFactory),
681
+ service,
956
682
  store,
957
683
  reservations,
958
- uploadFactory
684
+ uploadFactory,
685
+ destroy
959
686
  };
960
687
  }
961
688
  };
962
689
  //#endregion
963
- 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 };
964
691
 
965
692
  //# sourceMappingURL=index.js.map