@powerhousedao/reactor-attachments 6.2.2-dev.4 → 6.2.2-dev.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/client.d.ts +125 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +177 -3
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +210 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +817 -32
- package/dist/index.js.map +1 -1
- package/dist/{null-attachment-transport-Drx03s02.js → null-attachment-transport-CTBrRCDe.js} +241 -22
- package/dist/null-attachment-transport-CTBrRCDe.js.map +1 -0
- package/dist/{null-attachment-transport-BBhQIk5A.d.ts → null-attachment-transport-D1Bj3hLU.d.ts} +156 -7
- package/dist/null-attachment-transport-D1Bj3hLU.d.ts.map +1 -0
- package/package.json +11 -3
- package/dist/null-attachment-transport-BBhQIk5A.d.ts.map +0 -1
- package/dist/null-attachment-transport-Drx03s02.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
import { _ as
|
|
1
|
+
import { _ as HashMismatch, a as RemoteAttachmentUpload, b as SizeMismatch, c as AttachmentService, d as parseAttachmentDownloadTarget, f as parseAttachmentUploadTarget, g as AttachmentTransferError, h as AttachmentPending, i as RemoteAttachmentUploadFactory, l as createRef, m as AttachmentNotFound, n as createRemoteAttachmentService, o as RemoteReservationStore, p as AttachmentAlreadyExists, r as RemoteAttachmentStore, s as SwitchboardAttachmentTransport, t as NullAttachmentTransport, u as parseRef, v as InvalidAttachmentRef, x as UploadTooLarge, y as ReservationNotFound } from "./null-attachment-transport-CTBrRCDe.js";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { Migrator, sql } from "kysely";
|
|
4
4
|
import { mkdir, rename, rm } from "node:fs/promises";
|
|
5
5
|
import { createReadStream, createWriteStream } from "node:fs";
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
7
|
import { Readable } from "node:stream";
|
|
8
|
+
import { generatorTypeDefs } from "@powerhousedao/document-engineering/graphql";
|
|
9
|
+
import { constantCase, pascalCase } from "change-case";
|
|
10
|
+
import { Kind, buildASTSchema, getNamedType, isInputObjectType, isListType, isNonNullType, parse } from "graphql";
|
|
11
|
+
import { BaseReadModel } from "@powerhousedao/reactor";
|
|
12
|
+
import { Buffer as Buffer$1 } from "node:buffer";
|
|
13
|
+
import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
|
|
14
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
8
15
|
//#region \0rolldown/runtime.js
|
|
9
16
|
var __defProp = Object.defineProperty;
|
|
10
17
|
var __exportAll = (all, no_symbols) => {
|
|
@@ -391,84 +398,84 @@ var KyselyReservationStore = class {
|
|
|
391
398
|
//#endregion
|
|
392
399
|
//#region src/storage/migrations/001_create_attachment_table.ts
|
|
393
400
|
var _001_create_attachment_table_exports = /* @__PURE__ */ __exportAll({
|
|
394
|
-
down: () => down$
|
|
395
|
-
up: () => up$
|
|
401
|
+
down: () => down$6,
|
|
402
|
+
up: () => up$6
|
|
396
403
|
});
|
|
397
|
-
async function up$
|
|
404
|
+
async function up$6(db) {
|
|
398
405
|
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();
|
|
399
406
|
await db.schema.createIndex("idx_attachment_status").on("attachment").column("status").execute();
|
|
400
407
|
await db.schema.createIndex("idx_attachment_lru").on("attachment").columns(["status", "last_accessed_at_utc"]).execute();
|
|
401
408
|
}
|
|
402
|
-
async function down$
|
|
409
|
+
async function down$6(db) {
|
|
403
410
|
await db.schema.dropTable("attachment").ifExists().execute();
|
|
404
411
|
}
|
|
405
412
|
//#endregion
|
|
406
413
|
//#region src/storage/migrations/002_create_reservation_table.ts
|
|
407
414
|
var _002_create_reservation_table_exports = /* @__PURE__ */ __exportAll({
|
|
408
|
-
down: () => down$
|
|
409
|
-
up: () => up$
|
|
415
|
+
down: () => down$5,
|
|
416
|
+
up: () => up$5
|
|
410
417
|
});
|
|
411
|
-
async function up$
|
|
418
|
+
async function up$5(db) {
|
|
412
419
|
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();
|
|
413
420
|
}
|
|
414
|
-
async function down$
|
|
421
|
+
async function down$5(db) {
|
|
415
422
|
await db.schema.dropTable("attachment_reservation").ifExists().execute();
|
|
416
423
|
}
|
|
417
424
|
//#endregion
|
|
418
425
|
//#region src/storage/migrations/003_add_reservation_expires_at.ts
|
|
419
426
|
var _003_add_reservation_expires_at_exports = /* @__PURE__ */ __exportAll({
|
|
420
|
-
down: () => down$
|
|
421
|
-
up: () => up$
|
|
427
|
+
down: () => down$4,
|
|
428
|
+
up: () => up$4
|
|
422
429
|
});
|
|
423
|
-
async function up$
|
|
430
|
+
async function up$4(db) {
|
|
424
431
|
await db.schema.alterTable("attachment_reservation").addColumn("expires_at_utc", "text").execute();
|
|
425
432
|
await db.updateTable("attachment_reservation").set({ expires_at_utc: sql`created_at_utc` }).where("expires_at_utc", "is", null).execute();
|
|
426
433
|
await db.schema.alterTable("attachment_reservation").alterColumn("expires_at_utc", (col) => col.setNotNull()).execute();
|
|
427
434
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
428
435
|
}
|
|
429
|
-
async function down$
|
|
436
|
+
async function down$4(db) {
|
|
430
437
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
431
438
|
await db.schema.alterTable("attachment_reservation").dropColumn("expires_at_utc").execute();
|
|
432
439
|
}
|
|
433
440
|
//#endregion
|
|
434
441
|
//#region src/storage/migrations/004_add_reservation_soft_delete.ts
|
|
435
442
|
var _004_add_reservation_soft_delete_exports = /* @__PURE__ */ __exportAll({
|
|
436
|
-
down: () => down$
|
|
437
|
-
up: () => up$
|
|
443
|
+
down: () => down$3,
|
|
444
|
+
up: () => up$3
|
|
438
445
|
});
|
|
439
|
-
async function up$
|
|
446
|
+
async function up$3(db) {
|
|
440
447
|
await db.schema.alterTable("attachment_reservation").addColumn("deleted_at_utc", "text").execute();
|
|
441
448
|
}
|
|
442
|
-
async function down$
|
|
449
|
+
async function down$3(db) {
|
|
443
450
|
await db.schema.alterTable("attachment_reservation").dropColumn("deleted_at_utc").execute();
|
|
444
451
|
}
|
|
445
452
|
//#endregion
|
|
446
453
|
//#region src/storage/migrations/005_add_reservation_active_index.ts
|
|
447
454
|
var _005_add_reservation_active_index_exports = /* @__PURE__ */ __exportAll({
|
|
448
|
-
down: () => down$
|
|
449
|
-
up: () => up$
|
|
455
|
+
down: () => down$2,
|
|
456
|
+
up: () => up$2
|
|
450
457
|
});
|
|
451
|
-
async function up$
|
|
458
|
+
async function up$2(db) {
|
|
452
459
|
await db.schema.dropIndex("idx_reservation_expires_at").ifExists().execute();
|
|
453
460
|
await db.schema.createIndex("idx_reservation_expires_at_active").on("attachment_reservation").column("expires_at_utc").where(sql`deleted_at_utc IS NULL`).execute();
|
|
454
461
|
}
|
|
455
|
-
async function down$
|
|
462
|
+
async function down$2(db) {
|
|
456
463
|
await db.schema.dropIndex("idx_reservation_expires_at_active").ifExists().execute();
|
|
457
464
|
await db.schema.createIndex("idx_reservation_expires_at").on("attachment_reservation").column("expires_at_utc").execute();
|
|
458
465
|
}
|
|
459
466
|
//#endregion
|
|
460
467
|
//#region src/storage/migrations/006_add_reservation_client_hash.ts
|
|
461
468
|
var _006_add_reservation_client_hash_exports = /* @__PURE__ */ __exportAll({
|
|
462
|
-
down: () => down,
|
|
463
|
-
up: () => up
|
|
469
|
+
down: () => down$1,
|
|
470
|
+
up: () => up$1
|
|
464
471
|
});
|
|
465
|
-
async function up(db) {
|
|
472
|
+
async function up$1(db) {
|
|
466
473
|
await db.schema.alterTable("attachment_reservation").addColumn("client_hash", "text").execute();
|
|
467
474
|
await db.schema.alterTable("attachment_reservation").addColumn("size_bytes", "bigint").execute();
|
|
468
475
|
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
476
|
await db.schema.createIndex("idx_reservation_client_hash").on("attachment_reservation").column("client_hash").execute();
|
|
470
477
|
}
|
|
471
|
-
async function down(db) {
|
|
478
|
+
async function down$1(db) {
|
|
472
479
|
await db.schema.dropIndex("idx_reservation_client_hash").ifExists().execute();
|
|
473
480
|
await db.schema.alterTable("attachment_reservation").dropColumn("size_bytes").execute();
|
|
474
481
|
await db.schema.alterTable("attachment_reservation").dropColumn("client_hash").execute();
|
|
@@ -476,7 +483,7 @@ async function down(db) {
|
|
|
476
483
|
//#endregion
|
|
477
484
|
//#region src/storage/migrations/migrator.ts
|
|
478
485
|
const ATTACHMENT_SCHEMA = "attachments";
|
|
479
|
-
const migrations = {
|
|
486
|
+
const migrations$1 = {
|
|
480
487
|
"001_create_attachment_table": _001_create_attachment_table_exports,
|
|
481
488
|
"002_create_reservation_table": _002_create_reservation_table_exports,
|
|
482
489
|
"003_add_reservation_expires_at": _003_add_reservation_expires_at_exports,
|
|
@@ -484,9 +491,9 @@ const migrations = {
|
|
|
484
491
|
"005_add_reservation_active_index": _005_add_reservation_active_index_exports,
|
|
485
492
|
"006_add_reservation_client_hash": _006_add_reservation_client_hash_exports
|
|
486
493
|
};
|
|
487
|
-
var ProgrammaticMigrationProvider = class {
|
|
494
|
+
var ProgrammaticMigrationProvider$1 = class {
|
|
488
495
|
getMigrations() {
|
|
489
|
-
return Promise.resolve(migrations);
|
|
496
|
+
return Promise.resolve(migrations$1);
|
|
490
497
|
}
|
|
491
498
|
};
|
|
492
499
|
async function runAttachmentMigrations(db, schema = ATTACHMENT_SCHEMA) {
|
|
@@ -501,7 +508,7 @@ async function runAttachmentMigrations(db, schema = ATTACHMENT_SCHEMA) {
|
|
|
501
508
|
}
|
|
502
509
|
const migrator = new Migrator({
|
|
503
510
|
db: db.withSchema(schema),
|
|
504
|
-
provider: new ProgrammaticMigrationProvider(),
|
|
511
|
+
provider: new ProgrammaticMigrationProvider$1(),
|
|
505
512
|
migrationTableSchema: schema
|
|
506
513
|
});
|
|
507
514
|
let error;
|
|
@@ -621,12 +628,69 @@ var DirectAttachmentUploadFactory = class {
|
|
|
621
628
|
}
|
|
622
629
|
};
|
|
623
630
|
//#endregion
|
|
631
|
+
//#region src/direct/filesystem-attachment-backend.ts
|
|
632
|
+
/**
|
|
633
|
+
* Filesystem keeps byte transfer behind Switchboard. URL construction stays
|
|
634
|
+
* at the server edge, while this adapter validates that a filesystem backend
|
|
635
|
+
* can never accidentally return a direct-provider target.
|
|
636
|
+
*/
|
|
637
|
+
var FilesystemAttachmentBackend = class {
|
|
638
|
+
kind = "filesystem";
|
|
639
|
+
constructor(store, config) {
|
|
640
|
+
this.store = store;
|
|
641
|
+
this.config = config;
|
|
642
|
+
}
|
|
643
|
+
async prepareUploadTarget(reservation) {
|
|
644
|
+
const target = parseAttachmentUploadTarget(await this.config.uploadTarget(reservation));
|
|
645
|
+
if (target.kind !== "switchboard") throw new Error("Filesystem upload target must use Switchboard");
|
|
646
|
+
return target;
|
|
647
|
+
}
|
|
648
|
+
async prepareDownloadTarget(hash) {
|
|
649
|
+
const target = parseAttachmentDownloadTarget(await this.config.downloadTarget(hash));
|
|
650
|
+
if (target.kind !== "switchboard") throw new Error("Filesystem download target must use Switchboard");
|
|
651
|
+
return target;
|
|
652
|
+
}
|
|
653
|
+
exists(hash) {
|
|
654
|
+
return this.store.has(hash);
|
|
655
|
+
}
|
|
656
|
+
async health() {
|
|
657
|
+
return {
|
|
658
|
+
kind: this.kind,
|
|
659
|
+
ready: await (this.config.readiness?.() ?? true)
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/storage/s3/upload-factory.ts
|
|
665
|
+
var S3AttachmentUpload = class {
|
|
666
|
+
reservationId;
|
|
667
|
+
ref;
|
|
668
|
+
expiresAtUtc;
|
|
669
|
+
uploadTarget;
|
|
670
|
+
constructor(reservation) {
|
|
671
|
+
this.reservationId = reservation.reservationId;
|
|
672
|
+
this.ref = reservation.clientHash === null ? null : createRef(reservation.clientHash);
|
|
673
|
+
this.expiresAtUtc = reservation.expiresAtUtc;
|
|
674
|
+
this.uploadTarget = reservation.uploadTarget;
|
|
675
|
+
}
|
|
676
|
+
async send(data) {
|
|
677
|
+
await data.cancel().catch(() => {});
|
|
678
|
+
throw new Error("S3 attachment upload must use the presigned target");
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
var S3AttachmentUploadFactory = class {
|
|
682
|
+
createUpload(reservation) {
|
|
683
|
+
return new S3AttachmentUpload(reservation);
|
|
684
|
+
}
|
|
685
|
+
};
|
|
686
|
+
//#endregion
|
|
624
687
|
//#region src/attachment-builder.ts
|
|
625
688
|
var AttachmentBuilder = class {
|
|
626
689
|
transport = new NullAttachmentTransport();
|
|
627
690
|
customUploadFactory;
|
|
628
691
|
maxUploadBytes;
|
|
629
692
|
reservationSweepMs;
|
|
693
|
+
backend;
|
|
630
694
|
constructor(db, storagePath) {
|
|
631
695
|
this.db = db;
|
|
632
696
|
this.storagePath = storagePath;
|
|
@@ -639,6 +703,10 @@ var AttachmentBuilder = class {
|
|
|
639
703
|
this.customUploadFactory = factory;
|
|
640
704
|
return this;
|
|
641
705
|
}
|
|
706
|
+
withBackend(backend) {
|
|
707
|
+
this.backend = backend;
|
|
708
|
+
return this;
|
|
709
|
+
}
|
|
642
710
|
withMaxUploadBytes(maxBytes) {
|
|
643
711
|
this.maxUploadBytes = maxBytes;
|
|
644
712
|
return this;
|
|
@@ -661,8 +729,8 @@ var AttachmentBuilder = class {
|
|
|
661
729
|
const scopedDb = this.db.withSchema(ATTACHMENT_SCHEMA);
|
|
662
730
|
const store = new KyselyAttachmentStore(scopedDb, this.transport, this.storagePath);
|
|
663
731
|
const reservations = new KyselyReservationStore(scopedDb);
|
|
664
|
-
const uploadFactory = this.customUploadFactory ?? new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations, this.maxUploadBytes);
|
|
665
|
-
const service = new AttachmentService(store, reservations, uploadFactory);
|
|
732
|
+
const uploadFactory = this.customUploadFactory ?? (this.backend?.kind === "s3" ? new S3AttachmentUploadFactory() : new DirectAttachmentUploadFactory(scopedDb, this.storagePath, reservations, this.maxUploadBytes));
|
|
733
|
+
const service = new AttachmentService(store, reservations, uploadFactory, this.backend);
|
|
666
734
|
let sweepTimer;
|
|
667
735
|
if (this.reservationSweepMs !== void 0) {
|
|
668
736
|
const intervalMs = this.reservationSweepMs;
|
|
@@ -682,11 +750,728 @@ var AttachmentBuilder = class {
|
|
|
682
750
|
store,
|
|
683
751
|
reservations,
|
|
684
752
|
uploadFactory,
|
|
753
|
+
...this.backend ? { backend: this.backend } : {},
|
|
685
754
|
destroy
|
|
686
755
|
};
|
|
687
756
|
}
|
|
688
757
|
};
|
|
689
758
|
//#endregion
|
|
690
|
-
|
|
759
|
+
//#region src/reference-index/attachment-schema-compiler.ts
|
|
760
|
+
const ATTACHMENT_REF_TYPE = "AttachmentRef";
|
|
761
|
+
const CODEGEN_SCALAR_NAMES = new Set([
|
|
762
|
+
"Unknown",
|
|
763
|
+
"DateTime",
|
|
764
|
+
"Address",
|
|
765
|
+
ATTACHMENT_REF_TYPE,
|
|
766
|
+
...Object.keys(generatorTypeDefs)
|
|
767
|
+
]);
|
|
768
|
+
function describeContext(context) {
|
|
769
|
+
return `document type "${context.documentType}", version ${context.version}, action "${context.actionType}"`;
|
|
770
|
+
}
|
|
771
|
+
function compilationError(context, reason) {
|
|
772
|
+
return /* @__PURE__ */ new Error(`Attachment schema compilation failed for ${describeContext(context)}: ${reason}`);
|
|
773
|
+
}
|
|
774
|
+
function extractionError(context, path, reason) {
|
|
775
|
+
return /* @__PURE__ */ new Error(`Attachment extraction failed for ${describeContext(context)} at ${path}: ${reason}`);
|
|
776
|
+
}
|
|
777
|
+
function applicableSpecification(module, context) {
|
|
778
|
+
const matches = module.documentModel.global.specifications.filter((specification) => specification.version === context.version);
|
|
779
|
+
if (matches.length !== 1) throw compilationError(context, matches.length === 0 ? "the module has no matching specification" : "the module has multiple matching specifications");
|
|
780
|
+
return matches[0];
|
|
781
|
+
}
|
|
782
|
+
function parseOperations(specification, context) {
|
|
783
|
+
return specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.map((operation) => {
|
|
784
|
+
if (operation.schema === null) return {
|
|
785
|
+
document: null,
|
|
786
|
+
operation
|
|
787
|
+
};
|
|
788
|
+
try {
|
|
789
|
+
return {
|
|
790
|
+
document: parse(operation.schema),
|
|
791
|
+
operation
|
|
792
|
+
};
|
|
793
|
+
} catch {
|
|
794
|
+
throw compilationError(context, "an operation has invalid GraphQL SDL");
|
|
795
|
+
}
|
|
796
|
+
}));
|
|
797
|
+
}
|
|
798
|
+
function selectOperation(operations, context) {
|
|
799
|
+
const matches = operations.filter(({ operation }) => operation.name !== null && constantCase(operation.name) === context.actionType);
|
|
800
|
+
if (matches.length > 1) throw compilationError(context, "multiple operations map to the action");
|
|
801
|
+
if (matches.length === 0) return null;
|
|
802
|
+
return matches[0];
|
|
803
|
+
}
|
|
804
|
+
function buildEffectiveSchema(specification, context) {
|
|
805
|
+
const scalarSchemas = Array.from(CODEGEN_SCALAR_NAMES, (name) => `scalar ${name}`);
|
|
806
|
+
const stateSchemas = Object.values(specification.state).map((state) => state.schema);
|
|
807
|
+
const operationSchemas = specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.flatMap((operation) => operation.schema === null ? [] : [operation.schema]));
|
|
808
|
+
try {
|
|
809
|
+
return buildASTSchema(parse([
|
|
810
|
+
...scalarSchemas,
|
|
811
|
+
...stateSchemas,
|
|
812
|
+
...operationSchemas
|
|
813
|
+
].filter(Boolean).join("\n\n")));
|
|
814
|
+
} catch {
|
|
815
|
+
throw compilationError(context, "the effective GraphQL schema is invalid");
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
function attachmentReachableTypes(definitions) {
|
|
819
|
+
const reachable = /* @__PURE__ */ new Set();
|
|
820
|
+
let changed = true;
|
|
821
|
+
while (changed) {
|
|
822
|
+
changed = false;
|
|
823
|
+
for (const [name, definition] of definitions) {
|
|
824
|
+
if (reachable.has(name)) continue;
|
|
825
|
+
if (Object.values(definition.getFields()).some((field) => {
|
|
826
|
+
const typeName = getNamedType(field.type).name;
|
|
827
|
+
return typeName === ATTACHMENT_REF_TYPE || definitions.has(typeName) && reachable.has(typeName);
|
|
828
|
+
})) {
|
|
829
|
+
reachable.add(name);
|
|
830
|
+
changed = true;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
return reachable;
|
|
835
|
+
}
|
|
836
|
+
function compileValuePlan(type, objectPlans) {
|
|
837
|
+
if (isNonNullType(type)) return {
|
|
838
|
+
...compileValuePlan(type.ofType, objectPlans),
|
|
839
|
+
required: true
|
|
840
|
+
};
|
|
841
|
+
if (isListType(type)) return {
|
|
842
|
+
item: compileValuePlan(type.ofType, objectPlans),
|
|
843
|
+
kind: "list",
|
|
844
|
+
required: false
|
|
845
|
+
};
|
|
846
|
+
const typeName = type.name;
|
|
847
|
+
if (typeName === ATTACHMENT_REF_TYPE) return {
|
|
848
|
+
kind: "attachment",
|
|
849
|
+
required: false
|
|
850
|
+
};
|
|
851
|
+
const body = objectPlans.get(typeName);
|
|
852
|
+
if (!body) throw new Error(`Internal attachment schema plan error for ${typeName}`);
|
|
853
|
+
return {
|
|
854
|
+
body,
|
|
855
|
+
kind: "object",
|
|
856
|
+
required: false
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
function compileRootPlan(rootName, definitions) {
|
|
860
|
+
const reachable = attachmentReachableTypes(definitions);
|
|
861
|
+
if (!reachable.has(rootName)) return null;
|
|
862
|
+
const objectPlans = /* @__PURE__ */ new Map();
|
|
863
|
+
for (const name of reachable) objectPlans.set(name, { fields: [] });
|
|
864
|
+
for (const name of reachable) {
|
|
865
|
+
const definition = definitions.get(name);
|
|
866
|
+
const body = objectPlans.get(name);
|
|
867
|
+
if (!definition || !body) continue;
|
|
868
|
+
for (const field of Object.values(definition.getFields())) {
|
|
869
|
+
const typeName = getNamedType(field.type).name;
|
|
870
|
+
if (typeName !== ATTACHMENT_REF_TYPE && !reachable.has(typeName)) continue;
|
|
871
|
+
body.fields.push({
|
|
872
|
+
hasDefault: field.defaultValue !== void 0,
|
|
873
|
+
name: field.name,
|
|
874
|
+
value: compileValuePlan(field.type, objectPlans)
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
return objectPlans.get(rootName) ?? null;
|
|
879
|
+
}
|
|
880
|
+
function readOwnField(value, fieldName, context, path) {
|
|
881
|
+
try {
|
|
882
|
+
if (!Object.prototype.hasOwnProperty.call(value, fieldName)) return {
|
|
883
|
+
present: false,
|
|
884
|
+
value: void 0
|
|
885
|
+
};
|
|
886
|
+
return {
|
|
887
|
+
present: true,
|
|
888
|
+
value: value[fieldName]
|
|
889
|
+
};
|
|
890
|
+
} catch {
|
|
891
|
+
throw extractionError(context, path, "the declared field cannot be read");
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
function extractValue(plan, value, path, context, refs, seenRefs, activeObjects) {
|
|
895
|
+
if (value === null || value === void 0) {
|
|
896
|
+
if (plan.required) throw extractionError(context, path, "a required value is missing or null");
|
|
897
|
+
return;
|
|
898
|
+
}
|
|
899
|
+
if (plan.kind === "attachment") {
|
|
900
|
+
if (typeof value !== "string") throw extractionError(context, path, "expected an AttachmentRef string");
|
|
901
|
+
try {
|
|
902
|
+
parseRef(value);
|
|
903
|
+
} catch {
|
|
904
|
+
throw extractionError(context, path, "the AttachmentRef is malformed");
|
|
905
|
+
}
|
|
906
|
+
if (!seenRefs.has(value)) {
|
|
907
|
+
seenRefs.add(value);
|
|
908
|
+
refs.push(value);
|
|
909
|
+
}
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
if (plan.kind === "list") {
|
|
913
|
+
if (!Array.isArray(value)) throw extractionError(context, path, "expected a list");
|
|
914
|
+
for (let index = 0; index < value.length; index += 1) extractValue(plan.item, value[index], `${path}[${index}]`, context, refs, seenRefs, activeObjects);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
if (typeof value !== "object" || Array.isArray(value)) throw extractionError(context, path, "expected an input object");
|
|
918
|
+
if (activeObjects.has(value)) throw extractionError(context, path, "the input value contains a cycle");
|
|
919
|
+
activeObjects.add(value);
|
|
920
|
+
try {
|
|
921
|
+
const record = value;
|
|
922
|
+
for (const field of plan.body.fields) {
|
|
923
|
+
const fieldPath = `${path}.${field.name}`;
|
|
924
|
+
const result = readOwnField(record, field.name, context, fieldPath);
|
|
925
|
+
if ((!result.present || result.value === void 0) && field.hasDefault) continue;
|
|
926
|
+
extractValue(field.value, result.value, fieldPath, context, refs, seenRefs, activeObjects);
|
|
927
|
+
}
|
|
928
|
+
} finally {
|
|
929
|
+
activeObjects.delete(value);
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
var SchemaCompiledAttachmentExtractor = class {
|
|
933
|
+
constructor(context, rootPlan) {
|
|
934
|
+
this.context = context;
|
|
935
|
+
this.rootPlan = rootPlan;
|
|
936
|
+
}
|
|
937
|
+
extract(action) {
|
|
938
|
+
if (action.type !== this.context.actionType) throw extractionError(this.context, "input", "the action type does not match the compiled schema");
|
|
939
|
+
if (!this.rootPlan) return [];
|
|
940
|
+
if (action.input === null || typeof action.input !== "object" || Array.isArray(action.input)) throw extractionError(this.context, "input", "expected an input object");
|
|
941
|
+
const refs = [];
|
|
942
|
+
const seenRefs = /* @__PURE__ */ new Set();
|
|
943
|
+
const activeObjects = /* @__PURE__ */ new WeakSet();
|
|
944
|
+
activeObjects.add(action.input);
|
|
945
|
+
try {
|
|
946
|
+
const input = action.input;
|
|
947
|
+
for (const field of this.rootPlan.fields) {
|
|
948
|
+
const path = `input.${field.name}`;
|
|
949
|
+
const result = readOwnField(input, field.name, this.context, path);
|
|
950
|
+
if ((!result.present || result.value === void 0) && field.hasDefault) continue;
|
|
951
|
+
extractValue(field.value, result.value, path, this.context, refs, seenRefs, activeObjects);
|
|
952
|
+
}
|
|
953
|
+
} finally {
|
|
954
|
+
activeObjects.delete(action.input);
|
|
955
|
+
}
|
|
956
|
+
return refs;
|
|
957
|
+
}
|
|
958
|
+
};
|
|
959
|
+
function compileExtractor(module, actionType) {
|
|
960
|
+
const context = {
|
|
961
|
+
actionType,
|
|
962
|
+
documentType: module.documentModel.global.id,
|
|
963
|
+
version: module.version ?? 1
|
|
964
|
+
};
|
|
965
|
+
const specification = applicableSpecification(module, context);
|
|
966
|
+
const selected = selectOperation(parseOperations(specification, context), context);
|
|
967
|
+
if (selected === null || selected.operation.schema === null) return new SchemaCompiledAttachmentExtractor(context, null);
|
|
968
|
+
const effectiveSchema = buildEffectiveSchema(specification, context);
|
|
969
|
+
const operationName = selected.operation.name;
|
|
970
|
+
if (operationName === null) throw compilationError(context, "the operation has no name");
|
|
971
|
+
const rootName = `${pascalCase(operationName)}Input`;
|
|
972
|
+
const definitions = /* @__PURE__ */ new Map();
|
|
973
|
+
for (const type of Object.values(effectiveSchema.getTypeMap())) if (isInputObjectType(type) && !type.name.startsWith("__")) definitions.set(type.name, type);
|
|
974
|
+
if (!(selected.document?.definitions.filter((definition) => (definition.kind === Kind.INPUT_OBJECT_TYPE_DEFINITION || definition.kind === Kind.INPUT_OBJECT_TYPE_EXTENSION) && definition.name.value === rootName))?.length || !definitions.has(rootName)) throw compilationError(context, `the operation does not declare its expected root input "${rootName}"`);
|
|
975
|
+
return new SchemaCompiledAttachmentExtractor(context, compileRootPlan(rootName, definitions));
|
|
976
|
+
}
|
|
977
|
+
var AttachmentSchemaCompiler = class {
|
|
978
|
+
cache = /* @__PURE__ */ new WeakMap();
|
|
979
|
+
forModuleAction(module, actionType) {
|
|
980
|
+
let moduleCache = this.cache.get(module);
|
|
981
|
+
if (!moduleCache) {
|
|
982
|
+
moduleCache = /* @__PURE__ */ new Map();
|
|
983
|
+
this.cache.set(module, moduleCache);
|
|
984
|
+
}
|
|
985
|
+
const cached = moduleCache.get(actionType);
|
|
986
|
+
if (cached) return cached;
|
|
987
|
+
const compiled = compileExtractor(module, actionType);
|
|
988
|
+
moduleCache.set(actionType, compiled);
|
|
989
|
+
return compiled;
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
//#endregion
|
|
993
|
+
//#region src/read-models/attachment-reference/kysely-attachment-reference-store.ts
|
|
994
|
+
var KyselyAttachmentReferenceStore = class {
|
|
995
|
+
constructor(db) {
|
|
996
|
+
this.db = db;
|
|
997
|
+
}
|
|
998
|
+
async hasReference(documentId, ref) {
|
|
999
|
+
return await this.db.selectFrom("attachment_reference").select("document_id").where("document_id", "=", documentId).where("attachment_ref", "=", ref).executeTakeFirst() !== void 0;
|
|
1000
|
+
}
|
|
1001
|
+
async addReferences(references) {
|
|
1002
|
+
if (references.length === 0) return;
|
|
1003
|
+
await this.db.insertInto("attachment_reference").values(references.map((reference) => ({
|
|
1004
|
+
document_id: reference.documentId,
|
|
1005
|
+
attachment_ref: reference.ref,
|
|
1006
|
+
attachment_hash: parseRef(reference.ref).hash,
|
|
1007
|
+
first_operation_id: reference.operationId,
|
|
1008
|
+
branch: reference.branch,
|
|
1009
|
+
scope: reference.scope,
|
|
1010
|
+
first_seen_ordinal: reference.ordinal,
|
|
1011
|
+
created_at_utc: (/* @__PURE__ */ new Date()).toISOString()
|
|
1012
|
+
}))).onConflict((oc) => oc.columns(["document_id", "attachment_ref"]).doNothing()).execute();
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
//#endregion
|
|
1016
|
+
//#region src/read-models/attachment-reference/storage/migrations/001_create_attachment_reference_table.ts
|
|
1017
|
+
var _001_create_attachment_reference_table_exports = /* @__PURE__ */ __exportAll({
|
|
1018
|
+
down: () => down,
|
|
1019
|
+
up: () => up
|
|
1020
|
+
});
|
|
1021
|
+
async function up(db) {
|
|
1022
|
+
await db.schema.createTable("attachment_reference").addColumn("document_id", "text", (col) => col.notNull()).addColumn("attachment_ref", "text", (col) => col.notNull()).addColumn("attachment_hash", "text", (col) => col.notNull()).addColumn("first_operation_id", "text", (col) => col.notNull()).addColumn("branch", "text", (col) => col.notNull()).addColumn("scope", "text", (col) => col.notNull()).addColumn("first_seen_ordinal", "integer", (col) => col.notNull()).addColumn("created_at_utc", "text", (col) => col.notNull()).addUniqueConstraint("unique_attachment_reference_document_ref", ["document_id", "attachment_ref"]).execute();
|
|
1023
|
+
await db.schema.createIndex("idx_attachment_reference_ref").on("attachment_reference").column("attachment_ref").execute();
|
|
1024
|
+
await db.schema.createIndex("idx_attachment_reference_hash").on("attachment_reference").column("attachment_hash").execute();
|
|
1025
|
+
}
|
|
1026
|
+
async function down(db) {
|
|
1027
|
+
await db.schema.dropTable("attachment_reference").ifExists().execute();
|
|
1028
|
+
}
|
|
1029
|
+
//#endregion
|
|
1030
|
+
//#region src/read-models/attachment-reference/storage/migrations/migrator.ts
|
|
1031
|
+
const ATTACHMENT_REFERENCE_SCHEMA = "attachment_reference_read_model";
|
|
1032
|
+
const ATTACHMENT_REFERENCE_MIGRATION_TABLE = "kysely_migration_attachment_reference_read_model";
|
|
1033
|
+
const ATTACHMENT_REFERENCE_MIGRATION_LOCK_TABLE = "kysely_migration_attachment_reference_read_model_lock";
|
|
1034
|
+
const migrations = { "001_create_attachment_reference_table": _001_create_attachment_reference_table_exports };
|
|
1035
|
+
var ProgrammaticMigrationProvider = class {
|
|
1036
|
+
getMigrations() {
|
|
1037
|
+
return Promise.resolve(migrations);
|
|
1038
|
+
}
|
|
1039
|
+
};
|
|
1040
|
+
function createMigrator(db, schema) {
|
|
1041
|
+
return new Migrator({
|
|
1042
|
+
db: db.withSchema(schema),
|
|
1043
|
+
provider: new ProgrammaticMigrationProvider(),
|
|
1044
|
+
migrationTableSchema: schema,
|
|
1045
|
+
migrationTableName: ATTACHMENT_REFERENCE_MIGRATION_TABLE,
|
|
1046
|
+
migrationLockTableName: ATTACHMENT_REFERENCE_MIGRATION_LOCK_TABLE
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
function toResult(error, results) {
|
|
1050
|
+
const migrationsExecuted = results?.map((result) => result.migrationName) ?? [];
|
|
1051
|
+
if (error) return {
|
|
1052
|
+
success: false,
|
|
1053
|
+
migrationsExecuted,
|
|
1054
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Unknown migration error")
|
|
1055
|
+
};
|
|
1056
|
+
return {
|
|
1057
|
+
success: true,
|
|
1058
|
+
migrationsExecuted
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
async function runAttachmentReferenceMigrations(db, schema = ATTACHMENT_REFERENCE_SCHEMA) {
|
|
1062
|
+
try {
|
|
1063
|
+
await sql`CREATE SCHEMA IF NOT EXISTS ${sql.id(schema)}`.execute(db);
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
return {
|
|
1066
|
+
success: false,
|
|
1067
|
+
migrationsExecuted: [],
|
|
1068
|
+
error: error instanceof Error ? error : /* @__PURE__ */ new Error("Failed to create schema")
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
try {
|
|
1072
|
+
const { error, results } = await createMigrator(db, schema).migrateToLatest();
|
|
1073
|
+
return toResult(error, results);
|
|
1074
|
+
} catch (error) {
|
|
1075
|
+
return toResult(error, []);
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
async function rollbackAttachmentReferenceMigration(db, schema = ATTACHMENT_REFERENCE_SCHEMA) {
|
|
1079
|
+
try {
|
|
1080
|
+
const { error, results } = await createMigrator(db, schema).migrateDown();
|
|
1081
|
+
return toResult(error, results);
|
|
1082
|
+
} catch (error) {
|
|
1083
|
+
return toResult(error, []);
|
|
1084
|
+
}
|
|
1085
|
+
}
|
|
1086
|
+
async function getAttachmentReferenceMigrationStatus(db, schema = ATTACHMENT_REFERENCE_SCHEMA) {
|
|
1087
|
+
return await createMigrator(db, schema).getMigrations();
|
|
1088
|
+
}
|
|
1089
|
+
//#endregion
|
|
1090
|
+
//#region src/read-models/attachment-reference/index-builder.ts
|
|
1091
|
+
var AttachmentReferenceIndexBuilder = class {
|
|
1092
|
+
constructor(db) {
|
|
1093
|
+
this.db = db;
|
|
1094
|
+
}
|
|
1095
|
+
async build() {
|
|
1096
|
+
const result = await runAttachmentReferenceMigrations(this.db);
|
|
1097
|
+
if (!result.success && result.error) throw result.error;
|
|
1098
|
+
return { store: new KyselyAttachmentReferenceStore(this.db.withSchema(ATTACHMENT_REFERENCE_SCHEMA)) };
|
|
1099
|
+
}
|
|
1100
|
+
};
|
|
1101
|
+
//#endregion
|
|
1102
|
+
//#region src/read-models/attachment-reference/attachment-reference-read-model.ts
|
|
1103
|
+
const ATTACHMENT_REFERENCE_READ_MODEL_ID = "attachment-reference-read-model";
|
|
1104
|
+
var AttachmentReferenceReadModel = class extends BaseReadModel {
|
|
1105
|
+
indexingQueue = Promise.resolve();
|
|
1106
|
+
constructor(db, operationIndex, writeCache, consistencyTracker, documentModelRegistry, schemaCompiler, referenceWriter) {
|
|
1107
|
+
super(db, operationIndex, writeCache, consistencyTracker, {
|
|
1108
|
+
readModelId: ATTACHMENT_REFERENCE_READ_MODEL_ID,
|
|
1109
|
+
rebuildStateOnInit: false
|
|
1110
|
+
});
|
|
1111
|
+
this.documentModelRegistry = documentModelRegistry;
|
|
1112
|
+
this.schemaCompiler = schemaCompiler;
|
|
1113
|
+
this.referenceWriter = referenceWriter;
|
|
1114
|
+
}
|
|
1115
|
+
indexOperations(items) {
|
|
1116
|
+
return this.enqueue(() => this.indexOperationsInOrdinalOrder(items));
|
|
1117
|
+
}
|
|
1118
|
+
init() {
|
|
1119
|
+
return this.enqueue(async () => {
|
|
1120
|
+
const viewState = await this.loadState();
|
|
1121
|
+
if (viewState !== void 0) this.lastOrdinal = viewState;
|
|
1122
|
+
else await this.initializeState();
|
|
1123
|
+
let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
|
|
1124
|
+
while (page.results.length > 0) {
|
|
1125
|
+
await this.indexOperationsInOrdinalOrder(page.results);
|
|
1126
|
+
if (!page.next) break;
|
|
1127
|
+
page = await page.next();
|
|
1128
|
+
}
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
enqueue(work) {
|
|
1132
|
+
const result = this.indexingQueue.then(work);
|
|
1133
|
+
this.indexingQueue = result.catch(() => void 0);
|
|
1134
|
+
return result;
|
|
1135
|
+
}
|
|
1136
|
+
async commitOperations(items) {
|
|
1137
|
+
const references = [];
|
|
1138
|
+
for (const { operation, context } of items) {
|
|
1139
|
+
if (operation.error !== void 0) continue;
|
|
1140
|
+
const module = this.documentModelRegistry.getModule(context.documentType);
|
|
1141
|
+
const refs = this.schemaCompiler.forModuleAction(module, operation.action.type).extract(operation.action);
|
|
1142
|
+
for (const ref of refs) references.push({
|
|
1143
|
+
documentId: context.documentId,
|
|
1144
|
+
ref,
|
|
1145
|
+
operationId: operation.id,
|
|
1146
|
+
branch: context.branch,
|
|
1147
|
+
scope: context.scope,
|
|
1148
|
+
ordinal: context.ordinal
|
|
1149
|
+
});
|
|
1150
|
+
}
|
|
1151
|
+
if (references.length > 0) await this.referenceWriter.addReferences(references);
|
|
1152
|
+
}
|
|
1153
|
+
async indexOperationsInOrdinalOrder(incoming) {
|
|
1154
|
+
const pending = this.sortAndDedupe(incoming.filter(({ context }) => context.ordinal > this.lastOrdinal));
|
|
1155
|
+
if (pending.length === 0) return;
|
|
1156
|
+
const incomingMax = pending[pending.length - 1].context.ordinal;
|
|
1157
|
+
let candidates = pending;
|
|
1158
|
+
if (!this.isContiguousThrough(candidates, incomingMax)) {
|
|
1159
|
+
const replayed = await this.loadThroughOrdinal(incomingMax);
|
|
1160
|
+
candidates = this.sortAndDedupe([...replayed, ...pending]);
|
|
1161
|
+
}
|
|
1162
|
+
const contiguous = [];
|
|
1163
|
+
let expectedOrdinal = this.lastOrdinal + 1;
|
|
1164
|
+
for (const item of candidates) {
|
|
1165
|
+
const ordinal = item.context.ordinal;
|
|
1166
|
+
if (ordinal < expectedOrdinal) continue;
|
|
1167
|
+
if (ordinal > expectedOrdinal || ordinal > incomingMax) break;
|
|
1168
|
+
contiguous.push(item);
|
|
1169
|
+
expectedOrdinal++;
|
|
1170
|
+
}
|
|
1171
|
+
if (expectedOrdinal <= incomingMax) throw new Error(`Attachment reference read model cannot advance past missing ordinal ${expectedOrdinal}`);
|
|
1172
|
+
const previousOrdinal = this.lastOrdinal;
|
|
1173
|
+
try {
|
|
1174
|
+
await super.indexOperations(contiguous);
|
|
1175
|
+
} catch (error) {
|
|
1176
|
+
this.lastOrdinal = previousOrdinal;
|
|
1177
|
+
throw error;
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
1180
|
+
isContiguousThrough(items, maxOrdinal) {
|
|
1181
|
+
let expectedOrdinal = this.lastOrdinal + 1;
|
|
1182
|
+
for (const item of items) {
|
|
1183
|
+
if (item.context.ordinal !== expectedOrdinal) return false;
|
|
1184
|
+
expectedOrdinal++;
|
|
1185
|
+
}
|
|
1186
|
+
return expectedOrdinal > maxOrdinal;
|
|
1187
|
+
}
|
|
1188
|
+
async loadThroughOrdinal(maxOrdinal) {
|
|
1189
|
+
const operations = [];
|
|
1190
|
+
let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
|
|
1191
|
+
for (;;) {
|
|
1192
|
+
for (const item of page.results) if (item.context.ordinal <= maxOrdinal) operations.push(item);
|
|
1193
|
+
if (page.results.some(({ context }) => context.ordinal >= maxOrdinal) || !page.next) break;
|
|
1194
|
+
page = await page.next();
|
|
1195
|
+
}
|
|
1196
|
+
return operations;
|
|
1197
|
+
}
|
|
1198
|
+
sortAndDedupe(items) {
|
|
1199
|
+
const byOrdinal = /* @__PURE__ */ new Map();
|
|
1200
|
+
for (const item of items) byOrdinal.set(item.context.ordinal, item);
|
|
1201
|
+
return [...byOrdinal.values()].sort((left, right) => left.context.ordinal - right.context.ordinal);
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
//#endregion
|
|
1205
|
+
//#region src/storage/s3/config.ts
|
|
1206
|
+
const DEFAULT_S3_ATTACHMENT_PREFIX = "attachments";
|
|
1207
|
+
const DEFAULT_S3_UPLOAD_TTL_SECONDS = 900;
|
|
1208
|
+
const DEFAULT_S3_DOWNLOAD_TTL_SECONDS = 300;
|
|
1209
|
+
const MAX_S3_PRESIGN_TTL_SECONDS = 604800;
|
|
1210
|
+
function required(env, name) {
|
|
1211
|
+
const value = env[name];
|
|
1212
|
+
if (value === void 0 || value.trim().length === 0) throw new Error(`${name} is required and must not be blank`);
|
|
1213
|
+
if (value.trim() !== value) throw new Error(`${name} must not have leading or trailing whitespace`);
|
|
1214
|
+
return value;
|
|
1215
|
+
}
|
|
1216
|
+
const LOOPBACK_HOSTNAMES = new Set([
|
|
1217
|
+
"127.0.0.1",
|
|
1218
|
+
"localhost",
|
|
1219
|
+
"[::1]"
|
|
1220
|
+
]);
|
|
1221
|
+
/** Plain HTTP is allowed only for loopback hosts (local S3 emulators). */
|
|
1222
|
+
function isLoopbackHost(endpoint) {
|
|
1223
|
+
return LOOPBACK_HOSTNAMES.has(endpoint.hostname.toLowerCase());
|
|
1224
|
+
}
|
|
1225
|
+
function parseEndpoint(value) {
|
|
1226
|
+
const message = "PH_ATTACHMENT_S3_ENDPOINT must be a valid HTTPS URL (HTTP is allowed only for loopback hosts)";
|
|
1227
|
+
let endpoint;
|
|
1228
|
+
try {
|
|
1229
|
+
endpoint = new URL(value);
|
|
1230
|
+
} catch {
|
|
1231
|
+
throw new Error(message);
|
|
1232
|
+
}
|
|
1233
|
+
const protocolAllowed = endpoint.protocol === "https:" || endpoint.protocol === "http:" && isLoopbackHost(endpoint);
|
|
1234
|
+
if (value.trim() !== value || !protocolAllowed || endpoint.username !== "" || endpoint.password !== "" || endpoint.search !== "" || endpoint.hash !== "") throw new Error(message);
|
|
1235
|
+
return endpoint.toString().replace(/\/$/, "");
|
|
1236
|
+
}
|
|
1237
|
+
function parseBoolean(env, name, defaultValue) {
|
|
1238
|
+
const value = env[name];
|
|
1239
|
+
if (value === void 0) return defaultValue;
|
|
1240
|
+
if (value === "true") return true;
|
|
1241
|
+
if (value === "false") return false;
|
|
1242
|
+
throw new Error(`${name} must be either true or false`);
|
|
1243
|
+
}
|
|
1244
|
+
function parseTtl(env, name, defaultValue) {
|
|
1245
|
+
const value = env[name];
|
|
1246
|
+
if (value === void 0) return defaultValue;
|
|
1247
|
+
if (!/^[1-9]\d*$/.test(value)) throw new Error(`${name} must be a positive integer`);
|
|
1248
|
+
const seconds = Number(value);
|
|
1249
|
+
if (!Number.isSafeInteger(seconds) || seconds > 604800) throw new Error(`${name} must be between 1 and ${MAX_S3_PRESIGN_TTL_SECONDS}`);
|
|
1250
|
+
return seconds;
|
|
1251
|
+
}
|
|
1252
|
+
function normalizeS3AttachmentPrefix(prefix) {
|
|
1253
|
+
if (prefix.trim() !== prefix || prefix.startsWith("/") || prefix.includes("\\")) throw new Error("S3_ATTACHMENT_PREFIX is unsafe");
|
|
1254
|
+
const normalized = prefix.replace(/\/+$/, "");
|
|
1255
|
+
if (normalized.length === 0) throw new Error("S3_ATTACHMENT_PREFIX must not be blank");
|
|
1256
|
+
const segments = normalized.split("/");
|
|
1257
|
+
if (segments.some((segment) => segment.length === 0 || segment === "." || segment === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment))) throw new Error("S3_ATTACHMENT_PREFIX contains an unsafe segment");
|
|
1258
|
+
return segments.join("/");
|
|
1259
|
+
}
|
|
1260
|
+
function parseAttachmentStorageConfig(env = process.env) {
|
|
1261
|
+
const selector = env.PH_ATTACHMENT_STORAGE;
|
|
1262
|
+
if (selector === void 0 || selector === "filesystem") return { kind: "filesystem" };
|
|
1263
|
+
if (selector !== "s3") throw new Error("PH_ATTACHMENT_STORAGE must be either filesystem or s3");
|
|
1264
|
+
return {
|
|
1265
|
+
kind: "s3",
|
|
1266
|
+
s3: {
|
|
1267
|
+
endpoint: parseEndpoint(required(env, "PH_ATTACHMENT_S3_ENDPOINT")),
|
|
1268
|
+
region: required(env, "PH_ATTACHMENT_S3_REGION"),
|
|
1269
|
+
bucket: required(env, "PH_ATTACHMENT_S3_BUCKET"),
|
|
1270
|
+
accessKeyId: required(env, "PH_ATTACHMENT_S3_ACCESS_KEY_ID"),
|
|
1271
|
+
secretAccessKey: required(env, "PH_ATTACHMENT_S3_SECRET_ACCESS_KEY"),
|
|
1272
|
+
prefix: normalizeS3AttachmentPrefix(env.S3_ATTACHMENT_PREFIX ?? "attachments"),
|
|
1273
|
+
forcePathStyle: parseBoolean(env, "PH_ATTACHMENT_S3_FORCE_PATH_STYLE", false),
|
|
1274
|
+
uploadTtlSeconds: parseTtl(env, "PH_ATTACHMENT_S3_UPLOAD_TTL_SECONDS", 900),
|
|
1275
|
+
downloadTtlSeconds: parseTtl(env, "PH_ATTACHMENT_S3_DOWNLOAD_TTL_SECONDS", 300)
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
}
|
|
1279
|
+
//#endregion
|
|
1280
|
+
//#region src/storage/s3/keying.ts
|
|
1281
|
+
const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
1282
|
+
function validateHash(hash) {
|
|
1283
|
+
if (!SHA256_HEX.test(hash)) throw new Error("Attachment hash must be 64 lowercase hexadecimal characters");
|
|
1284
|
+
}
|
|
1285
|
+
function deriveS3AttachmentKey(hash, prefix) {
|
|
1286
|
+
validateHash(hash);
|
|
1287
|
+
if (prefix.startsWith("/") || prefix.endsWith("/") || prefix.includes("\\") || prefix.split("/").some((segment) => segment.length === 0 || segment === "." || segment === ".." || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(segment))) throw new Error("S3 attachment prefix must be normalized and safe");
|
|
1288
|
+
return `${prefix}/${hash.slice(0, 2)}/${hash.slice(2, 4)}/${hash}`;
|
|
1289
|
+
}
|
|
1290
|
+
function sha256HexToBase64(hash) {
|
|
1291
|
+
validateHash(hash);
|
|
1292
|
+
return Buffer$1.from(hash, "hex").toString("base64");
|
|
1293
|
+
}
|
|
1294
|
+
//#endregion
|
|
1295
|
+
//#region src/storage/s3/primitives.ts
|
|
1296
|
+
const defaultPresigner = (client, command, expiresInSeconds) => getSignedUrl(client, command, {
|
|
1297
|
+
expiresIn: expiresInSeconds,
|
|
1298
|
+
unhoistableHeaders: new Set(["x-amz-checksum-sha256"])
|
|
1299
|
+
});
|
|
1300
|
+
var S3AttachmentPrimitives = class {
|
|
1301
|
+
client;
|
|
1302
|
+
presign;
|
|
1303
|
+
now;
|
|
1304
|
+
constructor(config, dependencies = {}) {
|
|
1305
|
+
this.config = config;
|
|
1306
|
+
this.client = dependencies.client ?? new S3Client({
|
|
1307
|
+
endpoint: config.endpoint,
|
|
1308
|
+
region: config.region,
|
|
1309
|
+
forcePathStyle: config.forcePathStyle,
|
|
1310
|
+
credentials: {
|
|
1311
|
+
accessKeyId: config.accessKeyId,
|
|
1312
|
+
secretAccessKey: config.secretAccessKey
|
|
1313
|
+
}
|
|
1314
|
+
});
|
|
1315
|
+
this.presign = dependencies.presign ?? defaultPresigner;
|
|
1316
|
+
this.now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
1317
|
+
}
|
|
1318
|
+
buildHeadObjectCommand(hash) {
|
|
1319
|
+
return new HeadObjectCommand({
|
|
1320
|
+
Bucket: this.config.bucket,
|
|
1321
|
+
Key: deriveS3AttachmentKey(hash, this.config.prefix)
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
buildPutObjectCommand(hash, mimeType) {
|
|
1325
|
+
if (mimeType.trim().length === 0 || mimeType.includes("\r") || mimeType.includes("\n")) throw new Error("Attachment MIME type must not be blank or contain newlines");
|
|
1326
|
+
return new PutObjectCommand({
|
|
1327
|
+
Bucket: this.config.bucket,
|
|
1328
|
+
Key: deriveS3AttachmentKey(hash, this.config.prefix),
|
|
1329
|
+
ContentType: mimeType,
|
|
1330
|
+
ChecksumSHA256: sha256HexToBase64(hash)
|
|
1331
|
+
});
|
|
1332
|
+
}
|
|
1333
|
+
buildGetObjectCommand(hash) {
|
|
1334
|
+
return new GetObjectCommand({
|
|
1335
|
+
Bucket: this.config.bucket,
|
|
1336
|
+
Key: deriveS3AttachmentKey(hash, this.config.prefix)
|
|
1337
|
+
});
|
|
1338
|
+
}
|
|
1339
|
+
headObject(hash) {
|
|
1340
|
+
return this.client.send(this.buildHeadObjectCommand(hash));
|
|
1341
|
+
}
|
|
1342
|
+
async createUploadTarget(hash, mimeType, ttlSeconds = this.config.uploadTtlSeconds) {
|
|
1343
|
+
const checksum = sha256HexToBase64(hash);
|
|
1344
|
+
return {
|
|
1345
|
+
kind: "presigned-put",
|
|
1346
|
+
method: "PUT",
|
|
1347
|
+
url: await this.presign(this.client, this.buildPutObjectCommand(hash, mimeType), ttlSeconds),
|
|
1348
|
+
headers: {
|
|
1349
|
+
"content-type": mimeType,
|
|
1350
|
+
"x-amz-checksum-sha256": checksum
|
|
1351
|
+
},
|
|
1352
|
+
expiresAtUtc: new Date(this.now().getTime() + ttlSeconds * 1e3).toISOString()
|
|
1353
|
+
};
|
|
1354
|
+
}
|
|
1355
|
+
async createDownloadTarget(hash, ttlSeconds = this.config.downloadTtlSeconds) {
|
|
1356
|
+
return {
|
|
1357
|
+
kind: "presigned-get",
|
|
1358
|
+
method: "GET",
|
|
1359
|
+
url: await this.presign(this.client, this.buildGetObjectCommand(hash), ttlSeconds),
|
|
1360
|
+
headers: {},
|
|
1361
|
+
expiresAtUtc: new Date(this.now().getTime() + ttlSeconds * 1e3).toISOString()
|
|
1362
|
+
};
|
|
1363
|
+
}
|
|
1364
|
+
};
|
|
1365
|
+
function createS3AttachmentPrimitives(config, dependencies = {}) {
|
|
1366
|
+
return new S3AttachmentPrimitives(config, dependencies);
|
|
1367
|
+
}
|
|
1368
|
+
//#endregion
|
|
1369
|
+
//#region src/storage/s3/backend.ts
|
|
1370
|
+
const READINESS_PROBE_HASH = "0".repeat(64);
|
|
1371
|
+
const OBJECT_NOT_FOUND_NAMES = new Set([
|
|
1372
|
+
"NotFound",
|
|
1373
|
+
"NoSuchKey",
|
|
1374
|
+
"NoSuchObject"
|
|
1375
|
+
]);
|
|
1376
|
+
function isObjectNotFound(error) {
|
|
1377
|
+
if (typeof error !== "object" || error === null) return false;
|
|
1378
|
+
const providerError = error;
|
|
1379
|
+
if (providerError.$metadata?.httpStatusCode !== 404) return false;
|
|
1380
|
+
return [providerError.name, providerError.code].some((code) => typeof code === "string" && OBJECT_NOT_FOUND_NAMES.has(code));
|
|
1381
|
+
}
|
|
1382
|
+
function requireHashFirstReservation(reservation) {
|
|
1383
|
+
if (reservation.clientHash === null) throw new Error("S3 attachment reservations require a client hash");
|
|
1384
|
+
deriveS3AttachmentKey(reservation.clientHash, "validation");
|
|
1385
|
+
if (reservation.sizeBytes === null || !Number.isSafeInteger(reservation.sizeBytes) || reservation.sizeBytes <= 0) throw new Error("S3 attachment reservation sizeBytes must be a positive safe integer");
|
|
1386
|
+
}
|
|
1387
|
+
/** Server-only S3 capability. Callers complete authorization before download. */
|
|
1388
|
+
var S3AttachmentBackend = class {
|
|
1389
|
+
kind = "s3";
|
|
1390
|
+
primitives;
|
|
1391
|
+
now;
|
|
1392
|
+
uploadTtlSeconds;
|
|
1393
|
+
downloadTtlSeconds;
|
|
1394
|
+
constructor(db, config, dependencies = {}) {
|
|
1395
|
+
this.db = db;
|
|
1396
|
+
this.config = config;
|
|
1397
|
+
this.primitives = new S3AttachmentPrimitives(config, dependencies);
|
|
1398
|
+
this.now = dependencies.now ?? (() => /* @__PURE__ */ new Date());
|
|
1399
|
+
this.uploadTtlSeconds = dependencies.uploadTtlSeconds ?? config.uploadTtlSeconds;
|
|
1400
|
+
this.downloadTtlSeconds = dependencies.downloadTtlSeconds ?? config.downloadTtlSeconds;
|
|
1401
|
+
}
|
|
1402
|
+
async prepareUploadTarget(reservation) {
|
|
1403
|
+
requireHashFirstReservation(reservation);
|
|
1404
|
+
const hash = reservation.clientHash;
|
|
1405
|
+
const now = this.now().toISOString();
|
|
1406
|
+
const storagePath = deriveS3AttachmentKey(hash, this.config.prefix);
|
|
1407
|
+
try {
|
|
1408
|
+
await this.db.insertInto("attachment").values({
|
|
1409
|
+
hash,
|
|
1410
|
+
mime_type: reservation.mimeType,
|
|
1411
|
+
file_name: reservation.fileName,
|
|
1412
|
+
size_bytes: reservation.sizeBytes,
|
|
1413
|
+
extension: reservation.extension,
|
|
1414
|
+
status: "available",
|
|
1415
|
+
storage_path: storagePath,
|
|
1416
|
+
source: "local",
|
|
1417
|
+
created_at_utc: reservation.createdAtUtc,
|
|
1418
|
+
last_accessed_at_utc: now
|
|
1419
|
+
}).onConflict((conflict) => conflict.column("hash").doUpdateSet({
|
|
1420
|
+
mime_type: reservation.mimeType,
|
|
1421
|
+
file_name: reservation.fileName,
|
|
1422
|
+
size_bytes: reservation.sizeBytes,
|
|
1423
|
+
extension: reservation.extension,
|
|
1424
|
+
status: "available",
|
|
1425
|
+
storage_path: storagePath,
|
|
1426
|
+
last_accessed_at_utc: now
|
|
1427
|
+
})).execute();
|
|
1428
|
+
} catch {
|
|
1429
|
+
throw new Error("S3 attachment metadata registration failed");
|
|
1430
|
+
}
|
|
1431
|
+
let target;
|
|
1432
|
+
try {
|
|
1433
|
+
target = await this.primitives.createUploadTarget(hash, reservation.mimeType, this.uploadTtlSeconds);
|
|
1434
|
+
} catch {
|
|
1435
|
+
throw new Error("S3 attachment upload target preparation failed");
|
|
1436
|
+
}
|
|
1437
|
+
return target;
|
|
1438
|
+
}
|
|
1439
|
+
async prepareDownloadTarget(hash, ttlSeconds) {
|
|
1440
|
+
try {
|
|
1441
|
+
return await this.primitives.createDownloadTarget(hash, ttlSeconds ?? this.downloadTtlSeconds);
|
|
1442
|
+
} catch {
|
|
1443
|
+
throw new Error("S3 attachment download target preparation failed");
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
async exists(hash) {
|
|
1447
|
+
if (!await this.db.selectFrom("attachment").select("hash").where("hash", "=", hash).executeTakeFirst()) return false;
|
|
1448
|
+
try {
|
|
1449
|
+
await this.primitives.headObject(hash);
|
|
1450
|
+
return true;
|
|
1451
|
+
} catch (error) {
|
|
1452
|
+
if (isObjectNotFound(error)) return false;
|
|
1453
|
+
throw new Error("S3 attachment existence check failed");
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
async health() {
|
|
1457
|
+
try {
|
|
1458
|
+
await this.primitives.headObject(READINESS_PROBE_HASH);
|
|
1459
|
+
return {
|
|
1460
|
+
kind: this.kind,
|
|
1461
|
+
ready: true
|
|
1462
|
+
};
|
|
1463
|
+
} catch (error) {
|
|
1464
|
+
return {
|
|
1465
|
+
kind: this.kind,
|
|
1466
|
+
ready: isObjectNotFound(error)
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
function createS3AttachmentBackend(db, config, dependencies = {}) {
|
|
1472
|
+
return new S3AttachmentBackend(db, config, dependencies);
|
|
1473
|
+
}
|
|
1474
|
+
//#endregion
|
|
1475
|
+
export { ATTACHMENT_REFERENCE_MIGRATION_LOCK_TABLE, ATTACHMENT_REFERENCE_MIGRATION_TABLE, ATTACHMENT_REFERENCE_READ_MODEL_ID, ATTACHMENT_REFERENCE_SCHEMA, ATTACHMENT_SCHEMA, AttachmentAlreadyExists, AttachmentBuilder, AttachmentNotFound, AttachmentPending, AttachmentReferenceIndexBuilder, AttachmentReferenceReadModel, AttachmentSchemaCompiler, AttachmentService, AttachmentTransferError, DEFAULT_RESERVATION_TTL_MS, DEFAULT_S3_ATTACHMENT_PREFIX, DEFAULT_S3_DOWNLOAD_TTL_SECONDS, DEFAULT_S3_UPLOAD_TTL_SECONDS, DirectAttachmentUpload, DirectAttachmentUploadFactory, FilesystemAttachmentBackend, HashMismatch, InvalidAttachmentRef, KyselyAttachmentReferenceStore, KyselyAttachmentStore, KyselyReservationStore, MAX_S3_PRESIGN_TTL_SECONDS, NullAttachmentTransport, RemoteAttachmentStore, RemoteAttachmentUpload, RemoteAttachmentUploadFactory, RemoteReservationStore, ReservationNotFound, S3AttachmentBackend, S3AttachmentPrimitives, S3AttachmentUploadFactory, SizeMismatch, SwitchboardAttachmentTransport, UploadTooLarge, createRef, createRemoteAttachmentService, createS3AttachmentBackend, createS3AttachmentPrimitives, deriveS3AttachmentKey, getAttachmentReferenceMigrationStatus, normalizeS3AttachmentPrefix, parseAttachmentDownloadTarget, parseAttachmentStorageConfig, parseAttachmentUploadTarget, parseRef, rollbackAttachmentReferenceMigration, runAttachmentMigrations, runAttachmentReferenceMigrations, sha256HexToBase64 };
|
|
691
1476
|
|
|
692
1477
|
//# sourceMappingURL=index.js.map
|