@powerhousedao/reactor-attachments 6.2.2-dev.7 → 6.2.2-dev.71

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