@powerhousedao/reactor-attachments 6.0.0-dev.208
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/dist/index.d.ts +442 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +580 -0
- package/dist/index.js.map +1 -0
- package/package.json +39 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { Migrator, sql } from "kysely";
|
|
3
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
4
|
+
import { createReadStream, createWriteStream } from "node:fs";
|
|
5
|
+
import { Readable } from "node:stream";
|
|
6
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
+
//#region \0rolldown/runtime.js
|
|
8
|
+
var __defProp = Object.defineProperty;
|
|
9
|
+
var __exportAll = (all, no_symbols) => {
|
|
10
|
+
let target = {};
|
|
11
|
+
for (var name in all) __defProp(target, name, {
|
|
12
|
+
get: all[name],
|
|
13
|
+
enumerable: true
|
|
14
|
+
});
|
|
15
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
16
|
+
return target;
|
|
17
|
+
};
|
|
18
|
+
//#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
|
+
//#endregion
|
|
48
|
+
//#region src/ref.ts
|
|
49
|
+
const REF_PATTERN = /^attachment:\/\/v(\d+):(.+)$/;
|
|
50
|
+
const DEFAULT_VERSION = 1;
|
|
51
|
+
function parseRef(ref) {
|
|
52
|
+
const match = REF_PATTERN.exec(ref);
|
|
53
|
+
if (!match) throw new InvalidAttachmentRef(ref);
|
|
54
|
+
return {
|
|
55
|
+
version: Number(match[1]),
|
|
56
|
+
hash: match[2]
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function createRef(hash, version = DEFAULT_VERSION) {
|
|
60
|
+
return `attachment://v${version}:${hash}`;
|
|
61
|
+
}
|
|
62
|
+
//#endregion
|
|
63
|
+
//#region src/attachment-service.ts
|
|
64
|
+
var AttachmentService = class {
|
|
65
|
+
constructor(store, reservations, uploadFactory) {
|
|
66
|
+
this.store = store;
|
|
67
|
+
this.reservations = reservations;
|
|
68
|
+
this.uploadFactory = uploadFactory;
|
|
69
|
+
}
|
|
70
|
+
async reserve(options) {
|
|
71
|
+
const reservation = await this.reservations.create(options);
|
|
72
|
+
return this.uploadFactory.createUpload(reservation.reservationId, options);
|
|
73
|
+
}
|
|
74
|
+
async stat(ref) {
|
|
75
|
+
const { hash } = parseRef(ref);
|
|
76
|
+
return this.store.stat(hash);
|
|
77
|
+
}
|
|
78
|
+
async get(ref, signal) {
|
|
79
|
+
const { hash } = parseRef(ref);
|
|
80
|
+
return this.store.get(hash, signal);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/storage/fs/attachment-fs.ts
|
|
85
|
+
/**
|
|
86
|
+
* Compute the relative storage path for an attachment hash.
|
|
87
|
+
* This is what gets stored in the database's storage_path column.
|
|
88
|
+
*/
|
|
89
|
+
function storageRelativePath(hash) {
|
|
90
|
+
return join(hash.slice(0, 2), hash.slice(2, 4), hash);
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Write a ReadableStream to disk. Creates parent directories as needed.
|
|
94
|
+
* Returns the number of bytes written.
|
|
95
|
+
*/
|
|
96
|
+
async function writeAttachmentBytes(path, data) {
|
|
97
|
+
await mkdir(dirname(path), { recursive: true });
|
|
98
|
+
const writer = createWriteStream(path);
|
|
99
|
+
const reader = data.getReader();
|
|
100
|
+
let bytesWritten = 0;
|
|
101
|
+
try {
|
|
102
|
+
for (;;) {
|
|
103
|
+
const { done, value } = await reader.read();
|
|
104
|
+
if (done) break;
|
|
105
|
+
bytesWritten += value.byteLength;
|
|
106
|
+
if (!writer.write(value)) await new Promise((resolve) => writer.once("drain", resolve));
|
|
107
|
+
}
|
|
108
|
+
} finally {
|
|
109
|
+
reader.releaseLock();
|
|
110
|
+
await new Promise((resolve, reject) => {
|
|
111
|
+
writer.end(() => resolve());
|
|
112
|
+
writer.on("error", reject);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
return bytesWritten;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Open a ReadableStream from a file on disk.
|
|
119
|
+
*/
|
|
120
|
+
function readAttachmentStream(path) {
|
|
121
|
+
const nodeStream = createReadStream(path);
|
|
122
|
+
return Readable.toWeb(nodeStream);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Delete a file from disk. No-op if the file does not exist.
|
|
126
|
+
*/
|
|
127
|
+
async function deleteAttachmentBytes(path) {
|
|
128
|
+
await rm(path, { force: true });
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Create a ReadableStream from an in-memory buffer.
|
|
132
|
+
*/
|
|
133
|
+
function streamFromBuffer(data) {
|
|
134
|
+
return new ReadableStream({ start(controller) {
|
|
135
|
+
controller.enqueue(data);
|
|
136
|
+
controller.close();
|
|
137
|
+
} });
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
140
|
+
//#region src/storage/kysely/attachment-store.ts
|
|
141
|
+
function rowToHeader$1(row) {
|
|
142
|
+
return {
|
|
143
|
+
hash: row.hash,
|
|
144
|
+
mimeType: row.mime_type,
|
|
145
|
+
fileName: row.file_name,
|
|
146
|
+
sizeBytes: Number(row.size_bytes),
|
|
147
|
+
extension: row.extension,
|
|
148
|
+
status: row.status,
|
|
149
|
+
source: row.source,
|
|
150
|
+
createdAtUtc: row.created_at_utc,
|
|
151
|
+
lastAccessedAtUtc: row.last_accessed_at_utc
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function wrapStreamWithCleanup(source, cleanup) {
|
|
155
|
+
let cleaned = false;
|
|
156
|
+
const doCleanup = () => {
|
|
157
|
+
if (!cleaned) {
|
|
158
|
+
cleaned = true;
|
|
159
|
+
cleanup();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const reader = source.getReader();
|
|
163
|
+
return new ReadableStream({
|
|
164
|
+
async pull(controller) {
|
|
165
|
+
try {
|
|
166
|
+
const { done, value } = await reader.read();
|
|
167
|
+
if (done) {
|
|
168
|
+
doCleanup();
|
|
169
|
+
controller.close();
|
|
170
|
+
} else controller.enqueue(value);
|
|
171
|
+
} catch (err) {
|
|
172
|
+
doCleanup();
|
|
173
|
+
controller.error(err);
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
cancel() {
|
|
177
|
+
doCleanup();
|
|
178
|
+
reader.cancel().catch(() => {});
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
var KyselyAttachmentStore = class {
|
|
183
|
+
activeReaders = /* @__PURE__ */ new Map();
|
|
184
|
+
constructor(db, transport, basePath) {
|
|
185
|
+
this.db = db;
|
|
186
|
+
this.transport = transport;
|
|
187
|
+
this.basePath = basePath;
|
|
188
|
+
}
|
|
189
|
+
async stat(hash) {
|
|
190
|
+
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
191
|
+
if (!row) throw new AttachmentNotFound(hash);
|
|
192
|
+
return rowToHeader$1(row);
|
|
193
|
+
}
|
|
194
|
+
async has(hash) {
|
|
195
|
+
return (await this.db.selectFrom("attachment").select("status").where("hash", "=", hash).executeTakeFirst())?.status === "available";
|
|
196
|
+
}
|
|
197
|
+
async get(hash, signal) {
|
|
198
|
+
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
|
|
199
|
+
if (!row) throw new AttachmentNotFound(hash);
|
|
200
|
+
if (row.status === "evicted") {
|
|
201
|
+
const remote = await this.transport.fetch(hash, signal);
|
|
202
|
+
if (!remote) throw new AttachmentNotFound(hash);
|
|
203
|
+
await this.put(hash, remote.metadata, remote.body);
|
|
204
|
+
return this.get(hash, signal);
|
|
205
|
+
}
|
|
206
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
207
|
+
await this.db.updateTable("attachment").set({ last_accessed_at_utc: now }).where("hash", "=", hash).execute();
|
|
208
|
+
const header = rowToHeader$1(row);
|
|
209
|
+
header.lastAccessedAtUtc = now;
|
|
210
|
+
this.acquireReader(hash);
|
|
211
|
+
return {
|
|
212
|
+
header,
|
|
213
|
+
body: wrapStreamWithCleanup(readAttachmentStream(join(this.basePath, row.storage_path)), () => this.releaseReader(hash))
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
async put(hash, metadata, data) {
|
|
217
|
+
const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
218
|
+
if (existing?.status === "available") {
|
|
219
|
+
await data.cancel();
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
const relPath = storageRelativePath(hash);
|
|
223
|
+
await writeAttachmentBytes(join(this.basePath, relPath), data);
|
|
224
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
225
|
+
if (!existing) await this.db.insertInto("attachment").values({
|
|
226
|
+
hash,
|
|
227
|
+
mime_type: metadata.mimeType,
|
|
228
|
+
file_name: metadata.fileName,
|
|
229
|
+
size_bytes: metadata.sizeBytes,
|
|
230
|
+
extension: metadata.extension ?? null,
|
|
231
|
+
status: "available",
|
|
232
|
+
storage_path: relPath,
|
|
233
|
+
source: "sync",
|
|
234
|
+
created_at_utc: now,
|
|
235
|
+
last_accessed_at_utc: now
|
|
236
|
+
}).onConflict((oc) => oc.column("hash").doNothing()).execute();
|
|
237
|
+
else await this.db.updateTable("attachment").set({
|
|
238
|
+
status: "available",
|
|
239
|
+
storage_path: relPath,
|
|
240
|
+
last_accessed_at_utc: now
|
|
241
|
+
}).where("hash", "=", hash).where("status", "=", "evicted").execute();
|
|
242
|
+
}
|
|
243
|
+
async evict(hash) {
|
|
244
|
+
if (this.hasActiveReaders(hash)) return;
|
|
245
|
+
const row = await this.db.selectFrom("attachment").select(["storage_path", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
246
|
+
if (!row || row.status === "evicted") return;
|
|
247
|
+
await deleteAttachmentBytes(join(this.basePath, row.storage_path));
|
|
248
|
+
await this.db.updateTable("attachment").set({ status: "evicted" }).where("hash", "=", hash).execute();
|
|
249
|
+
}
|
|
250
|
+
async storageUsed() {
|
|
251
|
+
const result = await this.db.selectFrom("attachment").select(sql`COALESCE(SUM(size_bytes), 0)`.as("total")).where("status", "=", "available").executeTakeFirst();
|
|
252
|
+
return Number(result?.total ?? 0);
|
|
253
|
+
}
|
|
254
|
+
acquireReader(hash) {
|
|
255
|
+
this.activeReaders.set(hash, (this.activeReaders.get(hash) ?? 0) + 1);
|
|
256
|
+
}
|
|
257
|
+
releaseReader(hash) {
|
|
258
|
+
const count = (this.activeReaders.get(hash) ?? 1) - 1;
|
|
259
|
+
if (count <= 0) this.activeReaders.delete(hash);
|
|
260
|
+
else this.activeReaders.set(hash, count);
|
|
261
|
+
}
|
|
262
|
+
hasActiveReaders(hash) {
|
|
263
|
+
return (this.activeReaders.get(hash) ?? 0) > 0;
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
//#endregion
|
|
267
|
+
//#region src/storage/kysely/reservation-store.ts
|
|
268
|
+
function rowToReservation(row) {
|
|
269
|
+
return {
|
|
270
|
+
reservationId: row.reservation_id,
|
|
271
|
+
mimeType: row.mime_type,
|
|
272
|
+
fileName: row.file_name,
|
|
273
|
+
extension: row.extension,
|
|
274
|
+
createdAtUtc: row.created_at_utc
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
var KyselyReservationStore = class {
|
|
278
|
+
constructor(db) {
|
|
279
|
+
this.db = db;
|
|
280
|
+
}
|
|
281
|
+
async create(options) {
|
|
282
|
+
const reservationId = randomUUID();
|
|
283
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
284
|
+
return rowToReservation(await this.db.insertInto("attachment_reservation").values({
|
|
285
|
+
reservation_id: reservationId,
|
|
286
|
+
mime_type: options.mimeType,
|
|
287
|
+
file_name: options.fileName,
|
|
288
|
+
extension: options.extension ?? null,
|
|
289
|
+
created_at_utc: now
|
|
290
|
+
}).returningAll().executeTakeFirstOrThrow());
|
|
291
|
+
}
|
|
292
|
+
async get(reservationId) {
|
|
293
|
+
const row = await this.db.selectFrom("attachment_reservation").selectAll().where("reservation_id", "=", reservationId).executeTakeFirst();
|
|
294
|
+
if (!row) throw new ReservationNotFound(reservationId);
|
|
295
|
+
return rowToReservation(row);
|
|
296
|
+
}
|
|
297
|
+
async delete(reservationId) {
|
|
298
|
+
await this.db.deleteFrom("attachment_reservation").where("reservation_id", "=", reservationId).execute();
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/storage/migrations/001_create_attachment_table.ts
|
|
303
|
+
var _001_create_attachment_table_exports = /* @__PURE__ */ __exportAll({
|
|
304
|
+
down: () => down$1,
|
|
305
|
+
up: () => up$1
|
|
306
|
+
});
|
|
307
|
+
async function up$1(db) {
|
|
308
|
+
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();
|
|
309
|
+
await db.schema.createIndex("idx_attachment_status").on("attachment").column("status").execute();
|
|
310
|
+
await db.schema.createIndex("idx_attachment_lru").on("attachment").columns(["status", "last_accessed_at_utc"]).execute();
|
|
311
|
+
}
|
|
312
|
+
async function down$1(db) {
|
|
313
|
+
await db.schema.dropTable("attachment").ifExists().execute();
|
|
314
|
+
}
|
|
315
|
+
//#endregion
|
|
316
|
+
//#region src/storage/migrations/002_create_reservation_table.ts
|
|
317
|
+
var _002_create_reservation_table_exports = /* @__PURE__ */ __exportAll({
|
|
318
|
+
down: () => down,
|
|
319
|
+
up: () => up
|
|
320
|
+
});
|
|
321
|
+
async function up(db) {
|
|
322
|
+
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();
|
|
323
|
+
}
|
|
324
|
+
async function down(db) {
|
|
325
|
+
await db.schema.dropTable("attachment_reservation").ifExists().execute();
|
|
326
|
+
}
|
|
327
|
+
//#endregion
|
|
328
|
+
//#region src/storage/migrations/migrator.ts
|
|
329
|
+
const ATTACHMENT_SCHEMA = "attachments";
|
|
330
|
+
const migrations = {
|
|
331
|
+
"001_create_attachment_table": _001_create_attachment_table_exports,
|
|
332
|
+
"002_create_reservation_table": _002_create_reservation_table_exports
|
|
333
|
+
};
|
|
334
|
+
var ProgrammaticMigrationProvider = class {
|
|
335
|
+
getMigrations() {
|
|
336
|
+
return Promise.resolve(migrations);
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
async function runAttachmentMigrations(db, schema = ATTACHMENT_SCHEMA) {
|
|
340
|
+
try {
|
|
341
|
+
await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(schema)}`.execute(db);
|
|
342
|
+
} catch (error) {
|
|
343
|
+
return {
|
|
344
|
+
success: false,
|
|
345
|
+
migrationsExecuted: [],
|
|
346
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Failed to create schema")
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
const migrator = new Migrator({
|
|
350
|
+
db: db.withSchema(schema),
|
|
351
|
+
provider: new ProgrammaticMigrationProvider(),
|
|
352
|
+
migrationTableSchema: schema
|
|
353
|
+
});
|
|
354
|
+
let error;
|
|
355
|
+
let results;
|
|
356
|
+
try {
|
|
357
|
+
const result = await migrator.migrateToLatest();
|
|
358
|
+
error = result.error;
|
|
359
|
+
results = result.results;
|
|
360
|
+
} catch (e) {
|
|
361
|
+
error = e;
|
|
362
|
+
results = [];
|
|
363
|
+
}
|
|
364
|
+
const migrationsExecuted = results?.map((result) => result.migrationName) ?? [];
|
|
365
|
+
if (error) return {
|
|
366
|
+
success: false,
|
|
367
|
+
migrationsExecuted,
|
|
368
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Unknown migration error")
|
|
369
|
+
};
|
|
370
|
+
return {
|
|
371
|
+
success: true,
|
|
372
|
+
migrationsExecuted
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
//#endregion
|
|
376
|
+
//#region src/direct/direct-attachment-upload.ts
|
|
377
|
+
function rowToHeader(row) {
|
|
378
|
+
return {
|
|
379
|
+
hash: row.hash,
|
|
380
|
+
mimeType: row.mime_type,
|
|
381
|
+
fileName: row.file_name,
|
|
382
|
+
sizeBytes: Number(row.size_bytes),
|
|
383
|
+
extension: row.extension,
|
|
384
|
+
status: row.status,
|
|
385
|
+
source: row.source,
|
|
386
|
+
createdAtUtc: row.created_at_utc,
|
|
387
|
+
lastAccessedAtUtc: row.last_accessed_at_utc
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
async function collectAndHash(data) {
|
|
391
|
+
const hasher = createHash("sha256");
|
|
392
|
+
const chunks = [];
|
|
393
|
+
const reader = data.getReader();
|
|
394
|
+
for (;;) {
|
|
395
|
+
const { done, value } = await reader.read();
|
|
396
|
+
if (done) break;
|
|
397
|
+
hasher.update(value);
|
|
398
|
+
chunks.push(value);
|
|
399
|
+
}
|
|
400
|
+
const totalLength = chunks.reduce((acc, c) => acc + c.byteLength, 0);
|
|
401
|
+
const bytes = new Uint8Array(totalLength);
|
|
402
|
+
let offset = 0;
|
|
403
|
+
for (const chunk of chunks) {
|
|
404
|
+
bytes.set(chunk, offset);
|
|
405
|
+
offset += chunk.byteLength;
|
|
406
|
+
}
|
|
407
|
+
return {
|
|
408
|
+
bytes,
|
|
409
|
+
hash: hasher.digest("hex")
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
var DirectAttachmentUpload = class {
|
|
413
|
+
reservationId;
|
|
414
|
+
constructor(reservationId, options, db, basePath, reservations) {
|
|
415
|
+
this.options = options;
|
|
416
|
+
this.db = db;
|
|
417
|
+
this.basePath = basePath;
|
|
418
|
+
this.reservations = reservations;
|
|
419
|
+
this.reservationId = reservationId;
|
|
420
|
+
}
|
|
421
|
+
async send(data) {
|
|
422
|
+
const { bytes, hash } = await collectAndHash(data);
|
|
423
|
+
const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
|
|
424
|
+
if (existing?.status !== "available") {
|
|
425
|
+
const relPath = storageRelativePath(hash);
|
|
426
|
+
await writeAttachmentBytes(join(this.basePath, relPath), streamFromBuffer(bytes));
|
|
427
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
428
|
+
if (!existing) await this.db.insertInto("attachment").values({
|
|
429
|
+
hash,
|
|
430
|
+
mime_type: this.options.mimeType,
|
|
431
|
+
file_name: this.options.fileName,
|
|
432
|
+
size_bytes: bytes.byteLength,
|
|
433
|
+
extension: this.options.extension ?? null,
|
|
434
|
+
status: "available",
|
|
435
|
+
storage_path: relPath,
|
|
436
|
+
source: "local",
|
|
437
|
+
created_at_utc: now,
|
|
438
|
+
last_accessed_at_utc: now
|
|
439
|
+
}).onConflict((oc) => oc.column("hash").doNothing()).execute();
|
|
440
|
+
else await this.db.updateTable("attachment").set({
|
|
441
|
+
status: "available",
|
|
442
|
+
storage_path: relPath,
|
|
443
|
+
source: "local",
|
|
444
|
+
last_accessed_at_utc: now
|
|
445
|
+
}).where("hash", "=", hash).where("status", "=", "evicted").execute();
|
|
446
|
+
}
|
|
447
|
+
await this.reservations.delete(this.reservationId);
|
|
448
|
+
const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirstOrThrow();
|
|
449
|
+
return {
|
|
450
|
+
hash,
|
|
451
|
+
ref: createRef(hash),
|
|
452
|
+
header: rowToHeader(row)
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
//#endregion
|
|
457
|
+
//#region src/direct/direct-attachment-upload-factory.ts
|
|
458
|
+
var DirectAttachmentUploadFactory = class {
|
|
459
|
+
constructor(db, basePath, reservations) {
|
|
460
|
+
this.db = db;
|
|
461
|
+
this.basePath = basePath;
|
|
462
|
+
this.reservations = reservations;
|
|
463
|
+
}
|
|
464
|
+
createUpload(reservationId, options) {
|
|
465
|
+
return new DirectAttachmentUpload(reservationId, options, this.db, this.basePath, this.reservations);
|
|
466
|
+
}
|
|
467
|
+
};
|
|
468
|
+
//#endregion
|
|
469
|
+
//#region src/switchboard/switchboard-attachment-transport.ts
|
|
470
|
+
var SwitchboardAttachmentTransport = class {
|
|
471
|
+
remoteUrl;
|
|
472
|
+
jwtHandler;
|
|
473
|
+
fetchFn;
|
|
474
|
+
constructor(config) {
|
|
475
|
+
this.remoteUrl = config.remoteUrl;
|
|
476
|
+
this.jwtHandler = config.jwtHandler;
|
|
477
|
+
this.fetchFn = config.fetchFn ?? globalThis.fetch;
|
|
478
|
+
}
|
|
479
|
+
async fetch(hash, signal) {
|
|
480
|
+
const url = `${this.remoteUrl}/attachments/${hash}`;
|
|
481
|
+
const headers = await this.buildHeaders(url);
|
|
482
|
+
const response = await this.fetchFn(url, {
|
|
483
|
+
signal,
|
|
484
|
+
headers
|
|
485
|
+
});
|
|
486
|
+
if (response.status === 404) return null;
|
|
487
|
+
if (!response.ok) throw new Error(`Attachment fetch failed: ${response.status} ${response.statusText}`);
|
|
488
|
+
const metadata = this.parseMetadataHeaders(response);
|
|
489
|
+
const body = response.body;
|
|
490
|
+
if (!body) throw new Error("Response body is null");
|
|
491
|
+
return {
|
|
492
|
+
hash,
|
|
493
|
+
metadata,
|
|
494
|
+
body
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
async announce(_hash) {}
|
|
498
|
+
async push(hash, remote, data) {
|
|
499
|
+
const url = `${remote}/attachments/${hash}`;
|
|
500
|
+
const headers = await this.buildHeaders(url);
|
|
501
|
+
const response = await this.fetchFn(url, {
|
|
502
|
+
method: "PUT",
|
|
503
|
+
body: data,
|
|
504
|
+
headers,
|
|
505
|
+
duplex: "half"
|
|
506
|
+
});
|
|
507
|
+
if (!response.ok) throw new Error(`Attachment push failed: ${response.status} ${response.statusText}`);
|
|
508
|
+
}
|
|
509
|
+
async buildHeaders(url) {
|
|
510
|
+
const headers = {};
|
|
511
|
+
if (this.jwtHandler) {
|
|
512
|
+
const token = await this.jwtHandler(url);
|
|
513
|
+
if (token) headers["Authorization"] = `Bearer ${token}`;
|
|
514
|
+
}
|
|
515
|
+
return headers;
|
|
516
|
+
}
|
|
517
|
+
parseMetadataHeaders(response) {
|
|
518
|
+
const metaHeader = response.headers.get("X-Attachment-Metadata");
|
|
519
|
+
if (metaHeader) return JSON.parse(metaHeader);
|
|
520
|
+
return {
|
|
521
|
+
mimeType: response.headers.get("Content-Type") ?? "application/octet-stream",
|
|
522
|
+
fileName: "unknown",
|
|
523
|
+
sizeBytes: Number(response.headers.get("Content-Length") ?? 0),
|
|
524
|
+
extension: null
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
//#endregion
|
|
529
|
+
//#region src/null-attachment-transport.ts
|
|
530
|
+
/**
|
|
531
|
+
* No-op transport for deployments without remote sync.
|
|
532
|
+
* fetch() always returns null, announce() and push() are no-ops.
|
|
533
|
+
*/
|
|
534
|
+
var NullAttachmentTransport = class {
|
|
535
|
+
fetch() {
|
|
536
|
+
return Promise.resolve(null);
|
|
537
|
+
}
|
|
538
|
+
announce() {
|
|
539
|
+
return Promise.resolve();
|
|
540
|
+
}
|
|
541
|
+
push() {
|
|
542
|
+
return Promise.resolve();
|
|
543
|
+
}
|
|
544
|
+
};
|
|
545
|
+
//#endregion
|
|
546
|
+
//#region src/attachment-builder.ts
|
|
547
|
+
var AttachmentBuilder = class {
|
|
548
|
+
transport = new NullAttachmentTransport();
|
|
549
|
+
customUploadFactory;
|
|
550
|
+
constructor(db, storagePath) {
|
|
551
|
+
this.db = db;
|
|
552
|
+
this.storagePath = storagePath;
|
|
553
|
+
}
|
|
554
|
+
withTransport(transport) {
|
|
555
|
+
this.transport = transport;
|
|
556
|
+
return this;
|
|
557
|
+
}
|
|
558
|
+
withUploadFactory(factory) {
|
|
559
|
+
this.customUploadFactory = factory;
|
|
560
|
+
return this;
|
|
561
|
+
}
|
|
562
|
+
async build() {
|
|
563
|
+
const result = await runAttachmentMigrations(this.db, ATTACHMENT_SCHEMA);
|
|
564
|
+
if (!result.success && result.error) throw result.error;
|
|
565
|
+
const scopedDb = this.db.withSchema(ATTACHMENT_SCHEMA);
|
|
566
|
+
const store = new KyselyAttachmentStore(scopedDb, this.transport, this.storagePath);
|
|
567
|
+
const reservations = new KyselyReservationStore(scopedDb);
|
|
568
|
+
const uploadFactory = this.customUploadFactory ?? new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations);
|
|
569
|
+
return {
|
|
570
|
+
service: new AttachmentService(store, reservations, uploadFactory),
|
|
571
|
+
store,
|
|
572
|
+
reservations,
|
|
573
|
+
uploadFactory
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
};
|
|
577
|
+
//#endregion
|
|
578
|
+
export { ATTACHMENT_SCHEMA, AttachmentBuilder, AttachmentNotFound, AttachmentService, DirectAttachmentUpload, DirectAttachmentUploadFactory, InvalidAttachmentRef, KyselyAttachmentStore, KyselyReservationStore, NullAttachmentTransport, ReservationNotFound, SwitchboardAttachmentTransport, createRef, parseRef, runAttachmentMigrations };
|
|
579
|
+
|
|
580
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","names":["rowToHeader","up","down","migration001","migration002"],"sources":["../src/errors.ts","../src/ref.ts","../src/attachment-service.ts","../src/storage/fs/attachment-fs.ts","../src/storage/kysely/attachment-store.ts","../src/storage/kysely/reservation-store.ts","../src/storage/migrations/001_create_attachment_table.ts","../src/storage/migrations/002_create_reservation_table.ts","../src/storage/migrations/migrator.ts","../src/direct/direct-attachment-upload.ts","../src/direct/direct-attachment-upload-factory.ts","../src/switchboard/switchboard-attachment-transport.ts","../src/null-attachment-transport.ts","../src/attachment-builder.ts"],"sourcesContent":["/**\n * Thrown when an attachment ref or hash is not known to the store.\n */\nexport class AttachmentNotFound extends Error {\n constructor(identifier: string) {\n super(`Attachment not found: ${identifier}`);\n this.name = \"AttachmentNotFound\";\n }\n}\n\n/**\n * Thrown when a reservation ID is not found in the reservation store.\n */\nexport class ReservationNotFound extends Error {\n constructor(reservationId: string) {\n super(`Reservation not found: ${reservationId}`);\n this.name = \"ReservationNotFound\";\n }\n}\n\n/**\n * Thrown when an attachment ref string does not match the expected format.\n */\nexport class InvalidAttachmentRef extends Error {\n constructor(ref: string) {\n super(`Invalid attachment ref: ${ref}`);\n this.name = \"InvalidAttachmentRef\";\n }\n}\n","import type { AttachmentHash, AttachmentRef } from \"@powerhousedao/reactor\";\nimport { InvalidAttachmentRef } from \"./errors.js\";\n\nconst REF_PATTERN = /^attachment:\\/\\/v(\\d+):(.+)$/;\nconst DEFAULT_VERSION = 1;\n\nexport type ParsedRef = {\n version: number;\n hash: AttachmentHash;\n};\n\nexport function parseRef(ref: AttachmentRef): ParsedRef {\n const match = REF_PATTERN.exec(ref);\n if (!match) {\n throw new InvalidAttachmentRef(ref);\n }\n return {\n version: Number(match[1]),\n hash: match[2],\n };\n}\n\nexport function createRef(\n hash: AttachmentHash,\n version: number = DEFAULT_VERSION,\n): AttachmentRef {\n return `attachment://v${version}:${hash}`;\n}\n","import type { AttachmentRef } from \"@powerhousedao/reactor\";\nimport type {\n IAttachmentService,\n IAttachmentStore,\n IAttachmentUpload,\n IAttachmentUploadFactory,\n IReservationStore,\n} from \"./interfaces.js\";\nimport type {\n AttachmentHeader,\n AttachmentResponse,\n ReserveAttachmentOptions,\n} from \"./types.js\";\nimport { parseRef } from \"./ref.js\";\n\nexport class AttachmentService implements IAttachmentService {\n constructor(\n private readonly store: IAttachmentStore,\n private readonly reservations: IReservationStore,\n private readonly uploadFactory: IAttachmentUploadFactory,\n ) {}\n\n async reserve(options: ReserveAttachmentOptions): Promise<IAttachmentUpload> {\n const reservation = await this.reservations.create(options);\n return this.uploadFactory.createUpload(reservation.reservationId, options);\n }\n\n async stat(ref: AttachmentRef): Promise<AttachmentHeader> {\n const { hash } = parseRef(ref);\n return this.store.stat(hash);\n }\n\n async get(\n ref: AttachmentRef,\n signal?: AbortSignal,\n ): Promise<AttachmentResponse> {\n const { hash } = parseRef(ref);\n return this.store.get(hash, signal);\n }\n}\n","import { mkdir, rm, access } from \"node:fs/promises\";\nimport { createReadStream, createWriteStream } from \"node:fs\";\nimport { join, dirname } from \"node:path\";\nimport { Readable } from \"node:stream\";\n\n/**\n * Compute the absolute storage path for an attachment hash.\n * Uses a 2-level directory fan-out to avoid millions of files\n * in a single directory: ab/cd/abcdef123456...\n */\nexport function storagePath(basePath: string, hash: string): string {\n return join(basePath, storageRelativePath(hash));\n}\n\n/**\n * Compute the relative storage path for an attachment hash.\n * This is what gets stored in the database's storage_path column.\n */\nexport function storageRelativePath(hash: string): string {\n return join(hash.slice(0, 2), hash.slice(2, 4), hash);\n}\n\n/**\n * Write a ReadableStream to disk. Creates parent directories as needed.\n * Returns the number of bytes written.\n */\nexport async function writeAttachmentBytes(\n path: string,\n data: ReadableStream<Uint8Array>,\n): Promise<number> {\n await mkdir(dirname(path), { recursive: true });\n\n const writer = createWriteStream(path);\n const reader = data.getReader();\n let bytesWritten = 0;\n\n try {\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n bytesWritten += value.byteLength;\n const canContinue = writer.write(value);\n if (!canContinue) {\n await new Promise<void>((resolve) => writer.once(\"drain\", resolve));\n }\n }\n } finally {\n reader.releaseLock();\n await new Promise<void>((resolve, reject) => {\n writer.end(() => resolve());\n writer.on(\"error\", reject);\n });\n }\n\n return bytesWritten;\n}\n\n/**\n * Open a ReadableStream from a file on disk.\n */\nexport function readAttachmentStream(path: string): ReadableStream<Uint8Array> {\n const nodeStream = createReadStream(path);\n return Readable.toWeb(nodeStream) as ReadableStream<Uint8Array>;\n}\n\n/**\n * Delete a file from disk. No-op if the file does not exist.\n */\nexport async function deleteAttachmentBytes(path: string): Promise<void> {\n await rm(path, { force: true });\n}\n\n/**\n * Check whether a file exists on disk.\n */\nexport async function attachmentBytesExist(path: string): Promise<boolean> {\n try {\n await access(path);\n return true;\n } catch {\n return false;\n }\n}\n\n/**\n * Create a ReadableStream from an in-memory buffer.\n */\nexport function streamFromBuffer(data: Uint8Array): ReadableStream<Uint8Array> {\n return new ReadableStream({\n start(controller) {\n controller.enqueue(data);\n controller.close();\n },\n });\n}\n","import { join } from \"node:path\";\nimport type { Kysely } from \"kysely\";\nimport { sql } from \"kysely\";\nimport type { AttachmentHash } from \"@powerhousedao/reactor\";\nimport type {\n IAttachmentStore,\n IAttachmentTransport,\n} from \"../../interfaces.js\";\nimport type {\n AttachmentHeader,\n AttachmentMetadata,\n AttachmentResponse,\n AttachmentStatus,\n} from \"../../types.js\";\nimport { AttachmentNotFound } from \"../../errors.js\";\nimport type { AttachmentDatabase, AttachmentRow } from \"./types.js\";\nimport {\n storageRelativePath,\n writeAttachmentBytes,\n readAttachmentStream,\n deleteAttachmentBytes,\n} from \"../fs/attachment-fs.js\";\n\nfunction rowToHeader(row: AttachmentRow): AttachmentHeader {\n return {\n hash: row.hash,\n mimeType: row.mime_type,\n fileName: row.file_name,\n sizeBytes: Number(row.size_bytes),\n extension: row.extension,\n status: row.status as AttachmentStatus,\n source: row.source as \"local\" | \"sync\",\n createdAtUtc: row.created_at_utc,\n lastAccessedAtUtc: row.last_accessed_at_utc,\n };\n}\n\nfunction wrapStreamWithCleanup(\n source: ReadableStream<Uint8Array>,\n cleanup: () => void,\n): ReadableStream<Uint8Array> {\n let cleaned = false;\n const doCleanup = () => {\n if (!cleaned) {\n cleaned = true;\n cleanup();\n }\n };\n\n const reader = source.getReader();\n return new ReadableStream<Uint8Array>({\n async pull(controller) {\n try {\n const { done, value } = await reader.read();\n if (done) {\n doCleanup();\n controller.close();\n } else {\n controller.enqueue(value);\n }\n } catch (err) {\n doCleanup();\n controller.error(err);\n }\n },\n cancel() {\n doCleanup();\n reader.cancel().catch(() => {});\n },\n });\n}\n\nexport class KyselyAttachmentStore implements IAttachmentStore {\n private readonly activeReaders = new Map<string, number>();\n\n constructor(\n private readonly db: Kysely<AttachmentDatabase>,\n private readonly transport: IAttachmentTransport,\n private readonly basePath: string,\n ) {}\n\n async stat(hash: AttachmentHash): Promise<AttachmentHeader> {\n const row = await this.db\n .selectFrom(\"attachment\")\n .selectAll()\n .where(\"hash\", \"=\", hash)\n .executeTakeFirst();\n\n if (!row) {\n throw new AttachmentNotFound(hash);\n }\n\n return rowToHeader(row);\n }\n\n async has(hash: AttachmentHash): Promise<boolean> {\n const row = await this.db\n .selectFrom(\"attachment\")\n .select(\"status\")\n .where(\"hash\", \"=\", hash)\n .executeTakeFirst();\n\n return row?.status === \"available\";\n }\n\n async get(\n hash: AttachmentHash,\n signal?: AbortSignal,\n ): Promise<AttachmentResponse> {\n const row = await this.db\n .selectFrom(\"attachment\")\n .selectAll()\n .where(\"hash\", \"=\", hash)\n .executeTakeFirst();\n\n if (!row) {\n throw new AttachmentNotFound(hash);\n }\n\n if (row.status === \"evicted\") {\n const remote = await this.transport.fetch(hash, signal);\n if (!remote) {\n throw new AttachmentNotFound(hash);\n }\n await this.put(hash, remote.metadata, remote.body);\n return this.get(hash, signal);\n }\n\n const now = new Date().toISOString();\n await this.db\n .updateTable(\"attachment\")\n .set({ last_accessed_at_utc: now })\n .where(\"hash\", \"=\", hash)\n .execute();\n\n const header = rowToHeader(row);\n header.lastAccessedAtUtc = now;\n\n this.acquireReader(hash);\n\n const fullPath = join(this.basePath, row.storage_path);\n const rawStream = readAttachmentStream(fullPath);\n const body = wrapStreamWithCleanup(rawStream, () =>\n this.releaseReader(hash),\n );\n\n return { header, body };\n }\n\n async put(\n hash: AttachmentHash,\n metadata: AttachmentMetadata,\n data: ReadableStream<Uint8Array>,\n ): Promise<void> {\n const existing = await this.db\n .selectFrom(\"attachment\")\n .select([\"hash\", \"status\"])\n .where(\"hash\", \"=\", hash)\n .executeTakeFirst();\n\n if (existing?.status === \"available\") {\n await data.cancel();\n return;\n }\n\n const relPath = storageRelativePath(hash);\n const fullPath = join(this.basePath, relPath);\n await writeAttachmentBytes(fullPath, data);\n\n const now = new Date().toISOString();\n\n if (!existing) {\n await this.db\n .insertInto(\"attachment\")\n .values({\n hash,\n mime_type: metadata.mimeType,\n file_name: metadata.fileName,\n size_bytes: metadata.sizeBytes,\n extension: metadata.extension ?? null,\n status: \"available\",\n storage_path: relPath,\n source: \"sync\",\n created_at_utc: now,\n last_accessed_at_utc: now,\n })\n .onConflict((oc) => oc.column(\"hash\").doNothing())\n .execute();\n } else {\n await this.db\n .updateTable(\"attachment\")\n .set({\n status: \"available\",\n storage_path: relPath,\n last_accessed_at_utc: now,\n })\n .where(\"hash\", \"=\", hash)\n .where(\"status\", \"=\", \"evicted\")\n .execute();\n }\n }\n\n async evict(hash: AttachmentHash): Promise<void> {\n if (this.hasActiveReaders(hash)) {\n return;\n }\n\n const row = await this.db\n .selectFrom(\"attachment\")\n .select([\"storage_path\", \"status\"])\n .where(\"hash\", \"=\", hash)\n .executeTakeFirst();\n\n if (!row || row.status === \"evicted\") {\n return;\n }\n\n const fullPath = join(this.basePath, row.storage_path);\n await deleteAttachmentBytes(fullPath);\n\n await this.db\n .updateTable(\"attachment\")\n .set({ status: \"evicted\" })\n .where(\"hash\", \"=\", hash)\n .execute();\n }\n\n async storageUsed(): Promise<number> {\n const result = await this.db\n .selectFrom(\"attachment\")\n .select(sql<string>`COALESCE(SUM(size_bytes), 0)`.as(\"total\"))\n .where(\"status\", \"=\", \"available\")\n .executeTakeFirst();\n\n return Number(result?.total ?? 0);\n }\n\n // Private: active reader tracking\n\n private acquireReader(hash: string): void {\n this.activeReaders.set(hash, (this.activeReaders.get(hash) ?? 0) + 1);\n }\n\n private releaseReader(hash: string): void {\n const count = (this.activeReaders.get(hash) ?? 1) - 1;\n if (count <= 0) {\n this.activeReaders.delete(hash);\n } else {\n this.activeReaders.set(hash, count);\n }\n }\n\n private hasActiveReaders(hash: string): boolean {\n return (this.activeReaders.get(hash) ?? 0) > 0;\n }\n}\n","import { randomUUID } from \"node:crypto\";\nimport type { Kysely } from \"kysely\";\nimport type { IReservationStore } from \"../../interfaces.js\";\nimport type { Reservation, ReserveAttachmentOptions } from \"../../types.js\";\nimport { ReservationNotFound } from \"../../errors.js\";\nimport type { AttachmentDatabase, ReservationRow } from \"./types.js\";\n\nfunction rowToReservation(row: ReservationRow): Reservation {\n return {\n reservationId: row.reservation_id,\n mimeType: row.mime_type,\n fileName: row.file_name,\n extension: row.extension,\n createdAtUtc: row.created_at_utc,\n };\n}\n\nexport class KyselyReservationStore implements IReservationStore {\n constructor(private readonly db: Kysely<AttachmentDatabase>) {}\n\n async create(options: ReserveAttachmentOptions): Promise<Reservation> {\n const reservationId = randomUUID();\n const now = new Date().toISOString();\n\n const row = await this.db\n .insertInto(\"attachment_reservation\")\n .values({\n reservation_id: reservationId,\n mime_type: options.mimeType,\n file_name: options.fileName,\n extension: options.extension ?? null,\n created_at_utc: now,\n })\n .returningAll()\n .executeTakeFirstOrThrow();\n\n return rowToReservation(row);\n }\n\n async get(reservationId: string): Promise<Reservation> {\n const row = await this.db\n .selectFrom(\"attachment_reservation\")\n .selectAll()\n .where(\"reservation_id\", \"=\", reservationId)\n .executeTakeFirst();\n\n if (!row) {\n throw new ReservationNotFound(reservationId);\n }\n\n return rowToReservation(row);\n }\n\n async delete(reservationId: string): Promise<void> {\n await this.db\n .deleteFrom(\"attachment_reservation\")\n .where(\"reservation_id\", \"=\", reservationId)\n .execute();\n }\n}\n","import type { Kysely } from \"kysely\";\n\nexport async function up(db: Kysely<any>): Promise<void> {\n await db.schema\n .createTable(\"attachment\")\n .addColumn(\"hash\", \"text\", (col) => col.primaryKey())\n .addColumn(\"mime_type\", \"text\", (col) => col.notNull())\n .addColumn(\"file_name\", \"text\", (col) => col.notNull())\n .addColumn(\"size_bytes\", \"bigint\", (col) => col.notNull())\n .addColumn(\"extension\", \"text\")\n .addColumn(\"status\", \"text\", (col) => col.notNull().defaultTo(\"available\"))\n .addColumn(\"storage_path\", \"text\", (col) => col.notNull())\n .addColumn(\"source\", \"text\", (col) => col.notNull().defaultTo(\"local\"))\n .addColumn(\"created_at_utc\", \"text\", (col) => col.notNull())\n .addColumn(\"last_accessed_at_utc\", \"text\", (col) => col.notNull())\n .execute();\n\n await db.schema\n .createIndex(\"idx_attachment_status\")\n .on(\"attachment\")\n .column(\"status\")\n .execute();\n\n // Compound index serves the LRU eviction query:\n // SELECT ... WHERE status = 'available' ORDER BY last_accessed_at_utc ASC\n // A partial index would be ideal but raw SQL doesn't respect withSchema().\n await db.schema\n .createIndex(\"idx_attachment_lru\")\n .on(\"attachment\")\n .columns([\"status\", \"last_accessed_at_utc\"])\n .execute();\n}\n\nexport async function down(db: Kysely<any>): Promise<void> {\n await db.schema.dropTable(\"attachment\").ifExists().execute();\n}\n","import type { Kysely } from \"kysely\";\n\nexport async function up(db: Kysely<any>): Promise<void> {\n await db.schema\n .createTable(\"attachment_reservation\")\n .addColumn(\"reservation_id\", \"text\", (col) => col.primaryKey())\n .addColumn(\"mime_type\", \"text\", (col) => col.notNull())\n .addColumn(\"file_name\", \"text\", (col) => col.notNull())\n .addColumn(\"extension\", \"text\")\n .addColumn(\"created_at_utc\", \"text\", (col) => col.notNull())\n .execute();\n}\n\nexport async function down(db: Kysely<any>): Promise<void> {\n await db.schema.dropTable(\"attachment_reservation\").ifExists().execute();\n}\n","import { Migrator, sql } from \"kysely\";\nimport type { MigrationProvider, Kysely } from \"kysely\";\n\nimport * as migration001 from \"./001_create_attachment_table.js\";\nimport * as migration002 from \"./002_create_reservation_table.js\";\n\nexport const ATTACHMENT_SCHEMA = \"attachments\";\n\nexport interface MigrationResult {\n success: boolean;\n migrationsExecuted: string[];\n error?: Error;\n}\n\nconst migrations = {\n \"001_create_attachment_table\": migration001,\n \"002_create_reservation_table\": migration002,\n};\n\nclass ProgrammaticMigrationProvider implements MigrationProvider {\n getMigrations() {\n return Promise.resolve(migrations);\n }\n}\n\nexport async function runAttachmentMigrations(\n db: Kysely<any>,\n schema: string = ATTACHMENT_SCHEMA,\n): Promise<MigrationResult> {\n try {\n await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(schema)}`.execute(db);\n } catch (error) {\n return {\n success: false,\n migrationsExecuted: [],\n error:\n error instanceof Error ? error : new Error(\"Failed to create schema\"),\n };\n }\n\n const migrator = new Migrator({\n db: db.withSchema(schema),\n provider: new ProgrammaticMigrationProvider(),\n migrationTableSchema: schema,\n });\n\n let error: unknown;\n let results: Awaited<ReturnType<typeof migrator.migrateToLatest>>[\"results\"];\n try {\n const result = await migrator.migrateToLatest();\n error = result.error;\n results = result.results;\n } catch (e) {\n error = e;\n results = [];\n }\n\n const migrationsExecuted =\n results?.map((result) => result.migrationName) ?? [];\n\n if (error) {\n return {\n success: false,\n migrationsExecuted,\n error:\n error instanceof Error ? error : new Error(\"Unknown migration error\"),\n };\n }\n\n return {\n success: true,\n migrationsExecuted,\n };\n}\n","import { createHash } from \"node:crypto\";\nimport { join } from \"node:path\";\nimport type { Kysely } from \"kysely\";\nimport type { IAttachmentUpload, IReservationStore } from \"../interfaces.js\";\nimport type {\n AttachmentHeader,\n AttachmentUploadResult,\n ReserveAttachmentOptions,\n} from \"../types.js\";\nimport type {\n AttachmentDatabase,\n AttachmentRow,\n} from \"../storage/kysely/types.js\";\nimport { createRef } from \"../ref.js\";\nimport {\n storageRelativePath,\n writeAttachmentBytes,\n streamFromBuffer,\n} from \"../storage/fs/attachment-fs.js\";\nimport type { AttachmentStatus } from \"../types.js\";\n\nfunction rowToHeader(row: AttachmentRow): AttachmentHeader {\n return {\n hash: row.hash,\n mimeType: row.mime_type,\n fileName: row.file_name,\n sizeBytes: Number(row.size_bytes),\n extension: row.extension,\n status: row.status as AttachmentStatus,\n source: row.source as \"local\" | \"sync\",\n createdAtUtc: row.created_at_utc,\n lastAccessedAtUtc: row.last_accessed_at_utc,\n };\n}\n\nasync function collectAndHash(\n data: ReadableStream<Uint8Array>,\n): Promise<{ bytes: Uint8Array; hash: string }> {\n const hasher = createHash(\"sha256\");\n const chunks: Uint8Array[] = [];\n const reader = data.getReader();\n\n for (;;) {\n const { done, value } = await reader.read();\n if (done) break;\n hasher.update(value);\n chunks.push(value);\n }\n\n const totalLength = chunks.reduce((acc, c) => acc + c.byteLength, 0);\n const bytes = new Uint8Array(totalLength);\n let offset = 0;\n for (const chunk of chunks) {\n bytes.set(chunk, offset);\n offset += chunk.byteLength;\n }\n\n return { bytes, hash: hasher.digest(\"hex\") };\n}\n\nexport class DirectAttachmentUpload implements IAttachmentUpload {\n readonly reservationId: string;\n\n constructor(\n reservationId: string,\n private readonly options: ReserveAttachmentOptions,\n private readonly db: Kysely<AttachmentDatabase>,\n private readonly basePath: string,\n private readonly reservations: IReservationStore,\n ) {\n this.reservationId = reservationId;\n }\n\n async send(\n data: ReadableStream<Uint8Array>,\n ): Promise<AttachmentUploadResult> {\n const { bytes, hash } = await collectAndHash(data);\n\n const existing = await this.db\n .selectFrom(\"attachment\")\n .select([\"hash\", \"status\"])\n .where(\"hash\", \"=\", hash)\n .executeTakeFirst();\n\n if (existing?.status !== \"available\") {\n const relPath = storageRelativePath(hash);\n const fullPath = join(this.basePath, relPath);\n await writeAttachmentBytes(fullPath, streamFromBuffer(bytes));\n\n const now = new Date().toISOString();\n\n if (!existing) {\n await this.db\n .insertInto(\"attachment\")\n .values({\n hash,\n mime_type: this.options.mimeType,\n file_name: this.options.fileName,\n size_bytes: bytes.byteLength,\n extension: this.options.extension ?? null,\n status: \"available\",\n storage_path: relPath,\n source: \"local\",\n created_at_utc: now,\n last_accessed_at_utc: now,\n })\n .onConflict((oc) => oc.column(\"hash\").doNothing())\n .execute();\n } else {\n // Existing row was evicted — restore it\n await this.db\n .updateTable(\"attachment\")\n .set({\n status: \"available\",\n storage_path: relPath,\n source: \"local\",\n last_accessed_at_utc: now,\n })\n .where(\"hash\", \"=\", hash)\n .where(\"status\", \"=\", \"evicted\")\n .execute();\n }\n }\n\n await this.reservations.delete(this.reservationId);\n\n const row = await this.db\n .selectFrom(\"attachment\")\n .selectAll()\n .where(\"hash\", \"=\", hash)\n .executeTakeFirstOrThrow();\n\n return {\n hash,\n ref: createRef(hash),\n header: rowToHeader(row),\n };\n }\n}\n","import type { Kysely } from \"kysely\";\nimport type {\n IAttachmentUpload,\n IAttachmentUploadFactory,\n IReservationStore,\n} from \"../interfaces.js\";\nimport type { ReserveAttachmentOptions } from \"../types.js\";\nimport type { AttachmentDatabase } from \"../storage/kysely/types.js\";\nimport { DirectAttachmentUpload } from \"./direct-attachment-upload.js\";\n\nexport class DirectAttachmentUploadFactory implements IAttachmentUploadFactory {\n constructor(\n private readonly db: Kysely<AttachmentDatabase>,\n private readonly basePath: string,\n private readonly reservations: IReservationStore,\n ) {}\n\n createUpload(\n reservationId: string,\n options: ReserveAttachmentOptions,\n ): IAttachmentUpload {\n return new DirectAttachmentUpload(\n reservationId,\n options,\n this.db,\n this.basePath,\n this.reservations,\n );\n }\n}\n","import type { AttachmentHash } from \"@powerhousedao/reactor\";\nimport type { JwtHandler } from \"@powerhousedao/reactor\";\nimport type { IAttachmentTransport } from \"../interfaces.js\";\nimport type { AttachmentMetadata, TransportResponse } from \"../types.js\";\n\nexport type SwitchboardTransportConfig = {\n remoteUrl: string;\n jwtHandler?: JwtHandler;\n fetchFn?: typeof fetch;\n};\n\nexport class SwitchboardAttachmentTransport implements IAttachmentTransport {\n private readonly remoteUrl: string;\n private readonly jwtHandler?: JwtHandler;\n private readonly fetchFn: typeof fetch;\n\n constructor(config: SwitchboardTransportConfig) {\n this.remoteUrl = config.remoteUrl;\n this.jwtHandler = config.jwtHandler;\n this.fetchFn = config.fetchFn ?? globalThis.fetch;\n }\n\n async fetch(\n hash: AttachmentHash,\n signal?: AbortSignal,\n ): Promise<TransportResponse | null> {\n const url = `${this.remoteUrl}/attachments/${hash}`;\n const headers = await this.buildHeaders(url);\n\n const response = await this.fetchFn(url, { signal, headers });\n\n if (response.status === 404) {\n return null;\n }\n\n if (!response.ok) {\n throw new Error(\n `Attachment fetch failed: ${response.status} ${response.statusText}`,\n );\n }\n\n const metadata = this.parseMetadataHeaders(response);\n const body = response.body;\n if (!body) {\n throw new Error(\"Response body is null\");\n }\n\n return { hash, metadata, body };\n }\n\n // eslint-disable-next-line @typescript-eslint/no-unused-vars\n async announce(_hash: AttachmentHash): Promise<void> {\n // No-op for switchboard -- data is already on the server after upload.\n }\n\n async push(\n hash: AttachmentHash,\n remote: string,\n data: ReadableStream<Uint8Array>,\n ): Promise<void> {\n const url = `${remote}/attachments/${hash}`;\n const headers = await this.buildHeaders(url);\n\n const response = await this.fetchFn(url, {\n method: \"PUT\",\n body: data,\n headers,\n // @ts-expect-error Node fetch requires duplex for streaming request bodies\n duplex: \"half\",\n });\n\n if (!response.ok) {\n throw new Error(\n `Attachment push failed: ${response.status} ${response.statusText}`,\n );\n }\n }\n\n private async buildHeaders(url: string): Promise<Record<string, string>> {\n const headers: Record<string, string> = {};\n if (this.jwtHandler) {\n const token = await this.jwtHandler(url);\n if (token) {\n headers[\"Authorization\"] = `Bearer ${token}`;\n }\n }\n return headers;\n }\n\n private parseMetadataHeaders(response: Response): AttachmentMetadata {\n const metaHeader = response.headers.get(\"X-Attachment-Metadata\");\n if (metaHeader) {\n return JSON.parse(metaHeader) as AttachmentMetadata;\n }\n return {\n mimeType:\n response.headers.get(\"Content-Type\") ?? \"application/octet-stream\",\n fileName: \"unknown\",\n sizeBytes: Number(response.headers.get(\"Content-Length\") ?? 0),\n extension: null,\n };\n }\n}\n","import type { IAttachmentTransport } from \"./interfaces.js\";\nimport type { TransportResponse } from \"./types.js\";\n\n/**\n * No-op transport for deployments without remote sync.\n * fetch() always returns null, announce() and push() are no-ops.\n */\nexport class NullAttachmentTransport implements IAttachmentTransport {\n fetch(): Promise<TransportResponse | null> {\n return Promise.resolve(null);\n }\n\n announce(): Promise<void> {\n return Promise.resolve();\n }\n\n push(): Promise<void> {\n return Promise.resolve();\n }\n}\n","import type { Kysely } from \"kysely\";\nimport type {\n IAttachmentTransport,\n IAttachmentUploadFactory,\n} from \"./interfaces.js\";\nimport type { AttachmentDatabase } from \"./storage/kysely/types.js\";\nimport { AttachmentService } from \"./attachment-service.js\";\nimport { KyselyAttachmentStore } from \"./storage/kysely/attachment-store.js\";\nimport { KyselyReservationStore } from \"./storage/kysely/reservation-store.js\";\nimport { DirectAttachmentUploadFactory } from \"./direct/direct-attachment-upload-factory.js\";\nimport {\n runAttachmentMigrations,\n ATTACHMENT_SCHEMA,\n} from \"./storage/migrations/migrator.js\";\nimport { NullAttachmentTransport } from \"./null-attachment-transport.js\";\n\nexport type AttachmentBuildResult = {\n service: AttachmentService;\n store: KyselyAttachmentStore;\n reservations: KyselyReservationStore;\n uploadFactory: IAttachmentUploadFactory;\n};\n\nexport class AttachmentBuilder {\n private transport: IAttachmentTransport = new NullAttachmentTransport();\n private customUploadFactory?: IAttachmentUploadFactory;\n\n constructor(\n private readonly db: Kysely<any>,\n private readonly storagePath: string,\n ) {}\n\n withTransport(transport: IAttachmentTransport): this {\n this.transport = transport;\n return this;\n }\n\n withUploadFactory(factory: IAttachmentUploadFactory): this {\n this.customUploadFactory = factory;\n return this;\n }\n\n async build(): Promise<AttachmentBuildResult> {\n const result = await runAttachmentMigrations(this.db, ATTACHMENT_SCHEMA);\n if (!result.success && result.error) {\n throw result.error;\n }\n\n const scopedDb = this.db.withSchema(\n ATTACHMENT_SCHEMA,\n ) as Kysely<AttachmentDatabase>;\n\n const store = new KyselyAttachmentStore(\n scopedDb,\n this.transport,\n this.storagePath,\n );\n const reservations = new KyselyReservationStore(scopedDb);\n\n const uploadFactory =\n this.customUploadFactory ??\n new DirectAttachmentUploadFactory(\n scopedDb,\n this.storagePath,\n reservations,\n );\n\n const service = new AttachmentService(store, reservations, uploadFactory);\n\n return { service, store, reservations, uploadFactory };\n }\n}\n"],"mappings":"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"}
|