@powerhousedao/reactor-attachments 6.2.3-dev.6 → 6.2.3-dev.61

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,4 +1,6 @@
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";
1
+ import { a as parseRef, c as AttachmentPending, d as InvalidAttachmentMetadata, f as InvalidAttachmentRef, h as UploadTooLarge, i as createRef, l as AttachmentTransferError, m as SizeMismatch, o as AttachmentAlreadyExists, p as ReservationNotFound, s as AttachmentNotFound, t as SwitchboardAttachmentTransport, u as HashMismatch } from "./switchboard-attachment-transport-BH3ZAmxE.js";
2
+ import { a as RemoteAttachmentUpload, c as createFetchUploadTransport, d as parseAttachmentDownloadTarget, f as parseAttachmentUploadTarget, i as RemoteAttachmentUploadFactory, l as isBlob, n as createRemoteAttachmentService, o as RemoteReservationStore, r as RemoteAttachmentStore, s as createXhrUploadTransport, t as NullAttachmentTransport, u as AttachmentService } from "./null-attachment-transport-Cm_kb5ww.js";
3
+ import { t as AttachmentSchemaCompiler } from "./attachment-schema-compiler-rIaJpiHO.js";
2
4
  import { dirname, join } from "node:path";
3
5
  import { Migrator, sql } from "kysely";
4
6
  import { mkdir, rename, rm } from "node:fs/promises";
@@ -6,10 +8,8 @@ import { createReadStream, createWriteStream } from "node:fs";
6
8
  import { createHash, randomUUID } from "node:crypto";
7
9
  import { Readable } from "node:stream";
8
10
  import { finished } from "node:stream/promises";
9
- import { generatorTypeDefs } from "@powerhousedao/document-engineering/graphql";
10
- import { constantCase, pascalCase } from "change-case";
11
- import { Kind, buildASTSchema, getNamedType, isInputObjectType, isListType, isNonNullType, parse, print } from "graphql";
12
- import { BaseReadModel } from "@powerhousedao/reactor";
11
+ import { BaseReadModel, defaultReadModelIndexingConfig } from "@powerhousedao/reactor";
12
+ import { isPurgeMarker } from "@powerhousedao/shared/document-model";
13
13
  import { Buffer as Buffer$1 } from "node:buffer";
14
14
  import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
15
15
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -25,6 +25,27 @@ var __exportAll = (all, no_symbols) => {
25
25
  return target;
26
26
  };
27
27
  //#endregion
28
+ //#region src/reserve-metadata.ts
29
+ const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
30
+ const NON_HEADER_CHARS = /[^\t\x20-\x7e\x80-\xff]/;
31
+ const MIME_TYPE_PATTERN = /^[!#$%&'*+\-.^_`|~\w]+\/[!#$%&'*+\-.^_`|~\w]+(?:[ \t]*;[ \t]*[!#$%&'*+\-.^_`|~\w]+=(?:[!#$%&'*+\-.^_`|~\w]+|"(?:[^"\\\r\n]|\\[^\r\n])*"))*$/;
32
+ const MAX_FILENAME_LEN = 255;
33
+ const MAX_MIMETYPE_LEN = 255;
34
+ /**
35
+ * Validates reserve metadata with the rules the switchboard reserve route
36
+ * applies.
37
+ *
38
+ * @throws InvalidAttachmentMetadata naming the first field that fails.
39
+ */
40
+ function validateReserveMetadata(meta) {
41
+ const { mimeType, fileName, extension } = meta;
42
+ if (typeof mimeType !== "string" || mimeType.length === 0 || mimeType.length > MAX_MIMETYPE_LEN || NON_HEADER_CHARS.test(mimeType) || !MIME_TYPE_PATTERN.test(mimeType)) throw new InvalidAttachmentMetadata("mimeType");
43
+ if (typeof fileName !== "string" || fileName.length === 0 || fileName.length > MAX_FILENAME_LEN || CONTROL_CHARS.test(fileName)) throw new InvalidAttachmentMetadata("fileName");
44
+ if (typeof extension === "string") {
45
+ if (extension.length === 0 || /[\\/]/.test(extension)) throw new InvalidAttachmentMetadata("extension");
46
+ } else if (extension !== void 0 && extension !== null) throw new InvalidAttachmentMetadata("extension");
47
+ }
48
+ //#endregion
28
49
  //#region src/storage/fs/attachment-fs.ts
29
50
  /**
30
51
  * Compute the relative storage path for an attachment hash.
@@ -262,6 +283,9 @@ function wrapStreamWithCleanup(source, cleanup) {
262
283
  });
263
284
  }
264
285
  var KyselyAttachmentStore = class {
286
+ db;
287
+ transport;
288
+ basePath;
265
289
  activeReaders = /* @__PURE__ */ new Map();
266
290
  constructor(db, transport, basePath) {
267
291
  this.db = db;
@@ -290,18 +314,10 @@ var KyselyAttachmentStore = class {
290
314
  async has(hash) {
291
315
  return (await this.db.selectFrom("attachment").select("status").where("hash", "=", hash).executeTakeFirst())?.status === "available";
292
316
  }
293
- async get(hash, signal) {
317
+ async get(hash, signal, documentId) {
294
318
  const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
295
319
  if (row) {
296
- if (row.status === "evicted") {
297
- const remote = await this.transport.fetch(hash, signal);
298
- if (remote.kind === "data") {
299
- await this.put(hash, remote.response.metadata, remote.response.body);
300
- return this.get(hash, signal);
301
- }
302
- if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
303
- throw new AttachmentNotFound(hash);
304
- }
320
+ if (row.status === "evicted") return this.fetchRemote(hash, signal, documentId);
305
321
  const now = (/* @__PURE__ */ new Date()).toISOString();
306
322
  await this.db.updateTable("attachment").set({ last_accessed_at_utc: now }).where("hash", "=", hash).execute();
307
323
  const header = rowToHeader$1(row);
@@ -319,13 +335,7 @@ var KyselyAttachmentStore = class {
319
335
  fileName: pending.file_name,
320
336
  sizeBytes: pending.size_bytes
321
337
  });
322
- const remote = await this.transport.fetch(hash, signal);
323
- if (remote.kind === "data") {
324
- await this.put(hash, remote.response.metadata, remote.response.body);
325
- return this.get(hash, signal);
326
- }
327
- if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
328
- throw new AttachmentNotFound(hash);
338
+ return this.fetchRemote(hash, signal, documentId);
329
339
  }
330
340
  async put(hash, metadata, data) {
331
341
  const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
@@ -365,6 +375,16 @@ var KyselyAttachmentStore = class {
365
375
  const result = await this.db.selectFrom("attachment").select(sql`COALESCE(SUM(size_bytes), 0)`.as("total")).where("status", "=", "available").executeTakeFirst();
366
376
  return Number(result?.total ?? 0);
367
377
  }
378
+ async fetchRemote(hash, signal, documentId) {
379
+ if (documentId === void 0) throw new AttachmentNotFound(hash);
380
+ const remote = await this.transport.fetch(hash, documentId, signal);
381
+ if (remote.kind === "data") {
382
+ await this.put(hash, remote.response.metadata, remote.response.body);
383
+ return this.get(hash, signal);
384
+ }
385
+ if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
386
+ throw new AttachmentNotFound(hash);
387
+ }
368
388
  async findPendingReservation(hash, now) {
369
389
  const row = await this.db.selectFrom("attachment_reservation as r").leftJoin("attachment as a", "a.hash", "r.client_hash").select([
370
390
  "r.mime_type",
@@ -398,7 +418,7 @@ var KyselyAttachmentStore = class {
398
418
  };
399
419
  //#endregion
400
420
  //#region src/storage/kysely/reservation-store.ts
401
- const DEFAULT_RESERVATION_TTL_MS = 1440 * 60 * 1e3;
421
+ const DEFAULT_RESERVATION_TTL_MS = 864e5;
402
422
  function rowToReservation(row) {
403
423
  return {
404
424
  reservationId: row.reservation_id,
@@ -412,6 +432,7 @@ function rowToReservation(row) {
412
432
  };
413
433
  }
414
434
  var KyselyReservationStore = class {
435
+ db;
415
436
  ttlMs;
416
437
  constructor(db, ttlMs = DEFAULT_RESERVATION_TTL_MS) {
417
438
  this.db = db;
@@ -601,6 +622,11 @@ function rowToHeader(row) {
601
622
  };
602
623
  }
603
624
  var DirectAttachmentUpload = class {
625
+ reservation;
626
+ db;
627
+ basePath;
628
+ reservations;
629
+ maxBytes;
604
630
  reservationId;
605
631
  ref;
606
632
  expiresAtUtc;
@@ -617,7 +643,7 @@ var DirectAttachmentUpload = class {
617
643
  async send(data, options) {
618
644
  if (this.reservation.clientHash != null && this.reservation.sizeBytes == null) throw new Error("hash-first reservation missing sizeBytes");
619
645
  const declaredSizeBytes = this.reservation.clientHash != null ? this.reservation.sizeBytes ?? void 0 : void 0;
620
- const { tempPath, hash, sizeBytes } = await streamHashAndWrite(this.basePath, data, {
646
+ const { tempPath, hash, sizeBytes } = await streamHashAndWrite(this.basePath, isBlob(data) ? data.stream() : data, {
621
647
  maxBytes: this.maxBytes,
622
648
  declaredSizeBytes,
623
649
  ...options?.signal ? { signal: options.signal } : {}
@@ -670,6 +696,10 @@ var DirectAttachmentUpload = class {
670
696
  //#endregion
671
697
  //#region src/direct/direct-attachment-upload-factory.ts
672
698
  var DirectAttachmentUploadFactory = class {
699
+ db;
700
+ basePath;
701
+ reservations;
702
+ maxBytes;
673
703
  constructor(db, basePath, reservations, maxBytes) {
674
704
  this.db = db;
675
705
  this.basePath = basePath;
@@ -688,6 +718,8 @@ var DirectAttachmentUploadFactory = class {
688
718
  * can never accidentally return a direct-provider target.
689
719
  */
690
720
  var FilesystemAttachmentBackend = class {
721
+ store;
722
+ config;
691
723
  kind = "filesystem";
692
724
  constructor(store, config) {
693
725
  this.store = store;
@@ -727,7 +759,7 @@ var S3AttachmentUpload = class {
727
759
  this.uploadTarget = reservation.uploadTarget;
728
760
  }
729
761
  async send(data) {
730
- await data.cancel().catch(() => {});
762
+ if (!isBlob(data)) await data.cancel().catch(() => {});
731
763
  throw new Error("S3 attachment upload must use the presigned target");
732
764
  }
733
765
  };
@@ -739,6 +771,8 @@ var S3AttachmentUploadFactory = class {
739
771
  //#endregion
740
772
  //#region src/attachment-builder.ts
741
773
  var AttachmentBuilder = class {
774
+ db;
775
+ storagePath;
742
776
  transport = new NullAttachmentTransport();
743
777
  customUploadFactory;
744
778
  maxUploadBytes;
@@ -809,266 +843,59 @@ var AttachmentBuilder = class {
809
843
  }
810
844
  };
811
845
  //#endregion
812
- //#region src/reference-index/attachment-schema-compiler.ts
813
- const ATTACHMENT_REF_TYPE = "AttachmentRef";
814
- const CODEGEN_SCALAR_NAMES = new Set([
815
- "Unknown",
816
- "DateTime",
817
- "Address",
818
- ATTACHMENT_REF_TYPE,
819
- ...Object.keys(generatorTypeDefs)
820
- ]);
821
- function describeContext(context) {
822
- return `document type "${context.documentType}", version ${context.version}, action "${context.actionType}"`;
823
- }
824
- function compilationError(context, reason) {
825
- return /* @__PURE__ */ new Error(`Attachment schema compilation failed for ${describeContext(context)}: ${reason}`);
826
- }
827
- function extractionError(context, path, reason) {
828
- return /* @__PURE__ */ new Error(`Attachment extraction failed for ${describeContext(context)} at ${path}: ${reason}`);
829
- }
830
- function applicableSpecification(module, context) {
831
- const matches = module.documentModel.global.specifications.filter((specification) => specification.version === context.version);
832
- if (matches.length !== 1) throw compilationError(context, matches.length === 0 ? "the module has no matching specification" : "the module has multiple matching specifications");
833
- return matches[0];
834
- }
835
- function parseOperations(specification, context) {
836
- return specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.map((operation) => {
837
- if (operation.schema === null) return {
838
- document: null,
839
- operation
840
- };
841
- try {
842
- return {
843
- document: parse(operation.schema),
844
- operation
845
- };
846
- } catch {
847
- throw compilationError(context, "an operation has invalid GraphQL SDL");
848
- }
849
- }));
850
- }
851
- function selectOperation(operations, context) {
852
- const matches = operations.filter(({ operation }) => operation.name !== null && constantCase(operation.name) === context.actionType);
853
- if (matches.length > 1) throw compilationError(context, "multiple operations map to the action");
854
- if (matches.length === 0) return null;
855
- return matches[0];
846
+ //#region src/read-models/attachment-reference/kysely-attachment-reference-store.ts
847
+ function encodeCursor(cursor) {
848
+ return JSON.stringify([cursor.documentId, cursor.ref]);
856
849
  }
857
- function buildEffectiveSchema(specification, context) {
858
- const scalarSchemas = Array.from(CODEGEN_SCALAR_NAMES, (name) => `scalar ${name}`);
859
- const stateSchemas = Object.values(specification.state).map((state) => state.schema);
860
- const operationSchemas = specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.flatMap((operation) => operation.schema === null ? [] : [operation.schema]));
850
+ function decodeCursor(cursor) {
851
+ let parsed;
861
852
  try {
862
- return buildASTSchema(dedupeTypeDefinitions(parse([
863
- ...scalarSchemas,
864
- ...stateSchemas,
865
- ...operationSchemas
866
- ].filter(Boolean).join("\n\n"))));
853
+ parsed = JSON.parse(cursor);
867
854
  } catch {
868
- throw compilationError(context, "the effective GraphQL schema is invalid");
855
+ throw new Error(`Invalid attachment reference scan cursor: ${JSON.stringify(cursor)}`);
869
856
  }
870
- }
871
- function dedupeTypeDefinitions(document) {
872
- const seen = /* @__PURE__ */ new Map();
873
- const definitions = document.definitions.filter((definition) => {
874
- if (!("name" in definition) || definition.name?.value === void 0 || definition.kind === Kind.SCALAR_TYPE_DEFINITION) return true;
875
- const name = definition.name.value;
876
- const printed = print(definition);
877
- const existing = seen.get(name);
878
- if (existing === void 0) {
879
- seen.set(name, printed);
880
- return true;
881
- }
882
- return existing !== printed;
883
- });
884
- return {
885
- ...document,
886
- definitions
887
- };
888
- }
889
- function attachmentReachableTypes(definitions) {
890
- const reachable = /* @__PURE__ */ new Set();
891
- let changed = true;
892
- while (changed) {
893
- changed = false;
894
- for (const [name, definition] of definitions) {
895
- if (reachable.has(name)) continue;
896
- if (Object.values(definition.getFields()).some((field) => {
897
- const typeName = getNamedType(field.type).name;
898
- return typeName === ATTACHMENT_REF_TYPE || definitions.has(typeName) && reachable.has(typeName);
899
- })) {
900
- reachable.add(name);
901
- changed = true;
902
- }
903
- }
904
- }
905
- return reachable;
906
- }
907
- function compileValuePlan(type, objectPlans) {
908
- if (isNonNullType(type)) return {
909
- ...compileValuePlan(type.ofType, objectPlans),
910
- required: true
911
- };
912
- if (isListType(type)) return {
913
- item: compileValuePlan(type.ofType, objectPlans),
914
- kind: "list",
915
- required: false
916
- };
917
- const typeName = type.name;
918
- if (typeName === ATTACHMENT_REF_TYPE) return {
919
- kind: "attachment",
920
- required: false
921
- };
922
- const body = objectPlans.get(typeName);
923
- if (!body) throw new Error(`Internal attachment schema plan error for ${typeName}`);
857
+ if (!Array.isArray(parsed) || parsed.length !== 2 || typeof parsed[0] !== "string" || parsed[0] === "" || typeof parsed[1] !== "string" || parsed[1] === "") throw new Error(`Invalid attachment reference scan cursor: ${JSON.stringify(cursor)}`);
924
858
  return {
925
- body,
926
- kind: "object",
927
- required: false
928
- };
929
- }
930
- function compileRootPlan(rootName, definitions) {
931
- const reachable = attachmentReachableTypes(definitions);
932
- if (!reachable.has(rootName)) return null;
933
- const objectPlans = /* @__PURE__ */ new Map();
934
- for (const name of reachable) objectPlans.set(name, { fields: [] });
935
- for (const name of reachable) {
936
- const definition = definitions.get(name);
937
- const body = objectPlans.get(name);
938
- if (!definition || !body) continue;
939
- for (const field of Object.values(definition.getFields())) {
940
- const typeName = getNamedType(field.type).name;
941
- if (typeName !== ATTACHMENT_REF_TYPE && !reachable.has(typeName)) continue;
942
- body.fields.push({
943
- hasDefault: field.defaultValue !== void 0,
944
- name: field.name,
945
- value: compileValuePlan(field.type, objectPlans)
946
- });
947
- }
948
- }
949
- return objectPlans.get(rootName) ?? null;
950
- }
951
- function readOwnField(value, fieldName, context, path) {
952
- try {
953
- if (!Object.prototype.hasOwnProperty.call(value, fieldName)) return {
954
- present: false,
955
- value: void 0
956
- };
957
- return {
958
- present: true,
959
- value: value[fieldName]
960
- };
961
- } catch {
962
- throw extractionError(context, path, "the declared field cannot be read");
963
- }
964
- }
965
- function extractValue(plan, value, path, context, refs, seenRefs, activeObjects) {
966
- if (value === null || value === void 0) {
967
- if (plan.required) throw extractionError(context, path, "a required value is missing or null");
968
- return;
969
- }
970
- if (plan.kind === "attachment") {
971
- if (typeof value !== "string") throw extractionError(context, path, "expected an AttachmentRef string");
972
- try {
973
- parseRef(value);
974
- } catch {
975
- throw extractionError(context, path, "the AttachmentRef is malformed");
976
- }
977
- if (!seenRefs.has(value)) {
978
- seenRefs.add(value);
979
- refs.push(value);
980
- }
981
- return;
982
- }
983
- if (plan.kind === "list") {
984
- if (!Array.isArray(value)) throw extractionError(context, path, "expected a list");
985
- for (let index = 0; index < value.length; index += 1) extractValue(plan.item, value[index], `${path}[${index}]`, context, refs, seenRefs, activeObjects);
986
- return;
987
- }
988
- if (typeof value !== "object" || Array.isArray(value)) throw extractionError(context, path, "expected an input object");
989
- if (activeObjects.has(value)) throw extractionError(context, path, "the input value contains a cycle");
990
- activeObjects.add(value);
991
- try {
992
- const record = value;
993
- for (const field of plan.body.fields) {
994
- const fieldPath = `${path}.${field.name}`;
995
- const result = readOwnField(record, field.name, context, fieldPath);
996
- if ((!result.present || result.value === void 0) && field.hasDefault) continue;
997
- extractValue(field.value, result.value, fieldPath, context, refs, seenRefs, activeObjects);
998
- }
999
- } finally {
1000
- activeObjects.delete(value);
1001
- }
1002
- }
1003
- var SchemaCompiledAttachmentExtractor = class {
1004
- constructor(context, rootPlan) {
1005
- this.context = context;
1006
- this.rootPlan = rootPlan;
1007
- }
1008
- extract(action) {
1009
- if (action.type !== this.context.actionType) throw extractionError(this.context, "input", "the action type does not match the compiled schema");
1010
- if (!this.rootPlan) return [];
1011
- if (action.input === null || typeof action.input !== "object" || Array.isArray(action.input)) throw extractionError(this.context, "input", "expected an input object");
1012
- const refs = [];
1013
- const seenRefs = /* @__PURE__ */ new Set();
1014
- const activeObjects = /* @__PURE__ */ new WeakSet();
1015
- activeObjects.add(action.input);
1016
- try {
1017
- const input = action.input;
1018
- for (const field of this.rootPlan.fields) {
1019
- const path = `input.${field.name}`;
1020
- const result = readOwnField(input, field.name, this.context, path);
1021
- if ((!result.present || result.value === void 0) && field.hasDefault) continue;
1022
- extractValue(field.value, result.value, path, this.context, refs, seenRefs, activeObjects);
1023
- }
1024
- } finally {
1025
- activeObjects.delete(action.input);
1026
- }
1027
- return refs;
1028
- }
1029
- };
1030
- function compileExtractor(module, actionType) {
1031
- const context = {
1032
- actionType,
1033
- documentType: module.documentModel.global.id,
1034
- version: module.version ?? 1
859
+ documentId: parsed[0],
860
+ ref: parsed[1]
1035
861
  };
1036
- const specification = applicableSpecification(module, context);
1037
- const selected = selectOperation(parseOperations(specification, context), context);
1038
- if (selected === null || selected.operation.schema === null) return new SchemaCompiledAttachmentExtractor(context, null);
1039
- const effectiveSchema = buildEffectiveSchema(specification, context);
1040
- const operationName = selected.operation.name;
1041
- if (operationName === null) throw compilationError(context, "the operation has no name");
1042
- const rootName = `${pascalCase(operationName)}Input`;
1043
- const definitions = /* @__PURE__ */ new Map();
1044
- for (const type of Object.values(effectiveSchema.getTypeMap())) if (isInputObjectType(type) && !type.name.startsWith("__")) definitions.set(type.name, type);
1045
- 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}"`);
1046
- return new SchemaCompiledAttachmentExtractor(context, compileRootPlan(rootName, definitions));
1047
862
  }
1048
- var AttachmentSchemaCompiler = class {
1049
- cache = /* @__PURE__ */ new WeakMap();
1050
- forModuleAction(module, actionType) {
1051
- let moduleCache = this.cache.get(module);
1052
- if (!moduleCache) {
1053
- moduleCache = /* @__PURE__ */ new Map();
1054
- this.cache.set(module, moduleCache);
1055
- }
1056
- const cached = moduleCache.get(actionType);
1057
- if (cached) return cached;
1058
- const compiled = compileExtractor(module, actionType);
1059
- moduleCache.set(actionType, compiled);
1060
- return compiled;
1061
- }
1062
- };
1063
- //#endregion
1064
- //#region src/read-models/attachment-reference/kysely-attachment-reference-store.ts
1065
863
  var KyselyAttachmentReferenceStore = class {
864
+ db;
1066
865
  constructor(db) {
1067
866
  this.db = db;
1068
867
  }
1069
868
  async hasReference(documentId, ref) {
1070
869
  return await this.db.selectFrom("attachment_reference").select("document_id").where("document_id", "=", documentId).where("attachment_ref", "=", ref).executeTakeFirst() !== void 0;
1071
870
  }
871
+ async referencingScopes(documentId, ref) {
872
+ return (await this.db.selectFrom("attachment_reference").select("scope").distinct().where("document_id", "=", documentId).where("attachment_ref", "=", ref).execute()).map((row) => row.scope);
873
+ }
874
+ /**
875
+ * Keyset paging over the unique `(document_id, attachment_ref)` constraint,
876
+ * rather than OFFSET: a scan that runs while the read model is still
877
+ * indexing must not skip or repeat a row because rows appeared behind the
878
+ * cursor. That is exactly the boot case the replicator's re-scan runs in.
879
+ */
880
+ async listReferences(cursor, limit) {
881
+ let query = this.db.selectFrom("attachment_reference").select(["document_id", "attachment_ref"]).orderBy("document_id", "asc").orderBy("attachment_ref", "asc").limit(limit);
882
+ if (cursor !== void 0) {
883
+ const after = decodeCursor(cursor);
884
+ query = query.where((eb) => eb.or([eb("document_id", ">", after.documentId), eb.and([eb("document_id", "=", after.documentId), eb("attachment_ref", ">", after.ref)])]));
885
+ }
886
+ const rows = await query.execute();
887
+ const last = rows.at(-1);
888
+ return {
889
+ references: rows.map((row) => ({
890
+ documentId: row.document_id,
891
+ ref: row.attachment_ref
892
+ })),
893
+ nextCursor: rows.length === limit && last ? encodeCursor({
894
+ documentId: last.document_id,
895
+ ref: last.attachment_ref
896
+ }) : void 0
897
+ };
898
+ }
1072
899
  async addReferences(references) {
1073
900
  if (references.length === 0) return;
1074
901
  await this.db.insertInto("attachment_reference").values(references.map((reference) => ({
@@ -1082,6 +909,10 @@ var KyselyAttachmentReferenceStore = class {
1082
909
  created_at_utc: (/* @__PURE__ */ new Date()).toISOString()
1083
910
  }))).onConflict((oc) => oc.columns(["document_id", "attachment_ref"]).doNothing()).execute();
1084
911
  }
912
+ async removeDocuments(documentIds) {
913
+ if (documentIds.length === 0) return;
914
+ await this.db.deleteFrom("attachment_reference").where("document_id", "in", [...documentIds]).execute();
915
+ }
1085
916
  };
1086
917
  //#endregion
1087
918
  //#region src/read-models/attachment-reference/storage/migrations/001_create_attachment_reference_table.ts
@@ -1160,6 +991,7 @@ async function getAttachmentReferenceMigrationStatus(db, schema = ATTACHMENT_REF
1160
991
  //#endregion
1161
992
  //#region src/read-models/attachment-reference/index-builder.ts
1162
993
  var AttachmentReferenceIndexBuilder = class {
994
+ db;
1163
995
  constructor(db) {
1164
996
  this.db = db;
1165
997
  }
@@ -1172,52 +1004,40 @@ var AttachmentReferenceIndexBuilder = class {
1172
1004
  //#endregion
1173
1005
  //#region src/read-models/attachment-reference/attachment-reference-read-model.ts
1174
1006
  const ATTACHMENT_REFERENCE_READ_MODEL_ID = "attachment-reference-read-model";
1007
+ /**
1008
+ * Indexes attachment references in chunks.
1009
+ *
1010
+ * Each reference row stands on its own and is written insert-or-do-nothing, so
1011
+ * a batch that commits in pieces exposes no partial structure, and re-indexing
1012
+ * the same range writes nothing new: the catch-up sweep needs no stream suffix.
1013
+ *
1014
+ * The rows live on another handle, so the purge fence is "skip": a tombstoned
1015
+ * id's operations are dropped at commit and its marker deletes its rows.
1016
+ */
1175
1017
  var AttachmentReferenceReadModel = class extends BaseReadModel {
1176
- indexingQueue = Promise.resolve();
1177
- checkpointTarget;
1178
- /**
1179
- * How far a replay may skip ahead of the cursor: the highest ordinal already
1180
- * pulled from the index while the cursor was parked.
1181
- *
1182
- * NOT a claim that every ordinal below it committed -- the parked ordinal is
1183
- * precisely the one that did not -- so it is a hint, and {@link replayFrom}
1184
- * re-probes the gap before honouring it.
1185
- */
1186
- replayedThrough;
1187
- warnedCheckpoint;
1018
+ documentModelRegistry;
1019
+ schemaCompiler;
1020
+ referenceWriter;
1188
1021
  constructor(db, operationIndex, writeCache, consistencyTracker, documentModelRegistry, schemaCompiler, referenceWriter) {
1189
1022
  super(db, operationIndex, writeCache, consistencyTracker, {
1190
1023
  readModelId: ATTACHMENT_REFERENCE_READ_MODEL_ID,
1191
- rebuildStateOnInit: false
1024
+ rebuildStateOnInit: false,
1025
+ indexing: defaultReadModelIndexingConfig,
1026
+ replayStreamSuffix: false,
1027
+ purgeFence: "skip"
1192
1028
  });
1193
1029
  this.documentModelRegistry = documentModelRegistry;
1194
1030
  this.schemaCompiler = schemaCompiler;
1195
1031
  this.referenceWriter = referenceWriter;
1196
1032
  }
1197
- indexOperations(items) {
1198
- return this.enqueue(() => this.indexOperationsInOrdinalOrder(items));
1199
- }
1200
- init() {
1201
- return this.enqueue(async () => {
1202
- const viewState = await this.loadState();
1203
- if (viewState !== void 0) this.lastOrdinal = viewState;
1204
- else await this.initializeState();
1205
- let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
1206
- while (page.results.length > 0) {
1207
- await this.indexOperationsInOrdinalOrder(page.results);
1208
- if (!page.next) break;
1209
- page = await page.next();
1210
- }
1211
- });
1212
- }
1213
- enqueue(work) {
1214
- const result = this.indexingQueue.then(work);
1215
- this.indexingQueue = result.catch(() => void 0);
1216
- return result;
1217
- }
1218
1033
  async commitOperations(items) {
1219
1034
  const references = [];
1035
+ const purged = /* @__PURE__ */ new Set();
1220
1036
  for (const { operation, context } of items) {
1037
+ if (isPurgeMarker(operation)) {
1038
+ purged.add(context.documentId);
1039
+ continue;
1040
+ }
1221
1041
  if (operation.error !== void 0) continue;
1222
1042
  const module = this.documentModelRegistry.getModule(context.documentType);
1223
1043
  const refs = this.schemaCompiler.forModuleAction(module, operation.action.type).extract(operation.action);
@@ -1231,102 +1051,7 @@ var AttachmentReferenceReadModel = class extends BaseReadModel {
1231
1051
  });
1232
1052
  }
1233
1053
  if (references.length > 0) await this.referenceWriter.addReferences(references);
1234
- }
1235
- async indexOperationsInOrdinalOrder(incoming) {
1236
- let candidates = this.sortAndDedupe(incoming);
1237
- if (candidates.length === 0) return;
1238
- const incomingMax = candidates[candidates.length - 1].context.ordinal;
1239
- if (this.contiguousEnd(candidates) < incomingMax) {
1240
- const replayed = await this.loadThroughOrdinal(incomingMax);
1241
- candidates = this.sortAndDedupe([...replayed, ...candidates]);
1242
- }
1243
- const checkpoint = this.contiguousEnd(candidates);
1244
- if (checkpoint < incomingMax && checkpoint !== this.warnedCheckpoint) {
1245
- this.warnedCheckpoint = checkpoint;
1246
- console.warn(`[${this.config.readModelId}] indexed through ordinal ${incomingMax} but parked the cursor at ${checkpoint}: ordinal ${checkpoint + 1} is missing`);
1247
- }
1248
- const previousOrdinal = this.lastOrdinal;
1249
- this.checkpointTarget = checkpoint;
1250
- try {
1251
- await super.indexOperations(candidates);
1252
- } catch (error) {
1253
- this.lastOrdinal = previousOrdinal;
1254
- this.replayedThrough = void 0;
1255
- throw error;
1256
- } finally {
1257
- this.checkpointTarget = void 0;
1258
- }
1259
- if (checkpoint > previousOrdinal) this.replayedThrough = void 0;
1260
- else if (checkpoint < incomingMax) this.replayedThrough = Math.max(this.replayedThrough ?? checkpoint, incomingMax);
1261
- }
1262
- /**
1263
- * Writes the cursor parked by indexOperationsInOrdinalOrder instead of the
1264
- * batch maximum, so indexing an operation above a gap never advances past it.
1265
- */
1266
- async saveState(trx, items) {
1267
- const target = this.checkpointTarget;
1268
- if (target === void 0) {
1269
- await super.saveState(trx, items);
1270
- return;
1271
- }
1272
- this.lastOrdinal = target;
1273
- await trx.updateTable("ViewState").set({
1274
- lastOrdinal: target,
1275
- lastOperationTimestamp: /* @__PURE__ */ new Date()
1276
- }).where("readModelId", "=", this.config.readModelId).execute();
1277
- }
1278
- /** Last ordinal of the contiguous run starting at lastOrdinal + 1. */
1279
- contiguousEnd(items) {
1280
- let expectedOrdinal = this.lastOrdinal + 1;
1281
- for (const item of items) {
1282
- const ordinal = item.context.ordinal;
1283
- if (ordinal < expectedOrdinal) continue;
1284
- if (ordinal > expectedOrdinal) break;
1285
- expectedOrdinal++;
1286
- }
1287
- return expectedOrdinal - 1;
1288
- }
1289
- /**
1290
- * Opens a replay at the lowest ordinal still worth reading.
1291
- *
1292
- * {@link replayedThrough} is what keeps a permanently held hole -- a
1293
- * rolled-back insert, which never fills -- from re-reading the whole tail on
1294
- * every batch. It cannot be trusted on its own: a hole that fills without
1295
- * being delivered here is visible only in the index, and a mark that is
1296
- * never questioned would hide that operation for the life of the process.
1297
- * That happens whenever another writer commits the gap (a second reactor on
1298
- * the same database), and it leaves the cursor parked below an operation
1299
- * whose references were never written -- `hasReference` then answers false
1300
- * for an attachment that is genuinely referenced.
1301
- *
1302
- * So a parked batch spends one page probing the gap: the first row above the
1303
- * cursor is the missing ordinal itself once it commits. Finding it drops the
1304
- * mark and replays from the cursor; not finding it leaves the mark standing,
1305
- * which is the cheap path and the common one.
1306
- */
1307
- async replayFrom() {
1308
- const fromCursor = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
1309
- if (this.replayedThrough === void 0) return fromCursor;
1310
- if (fromCursor.results[0]?.context.ordinal === this.lastOrdinal + 1) {
1311
- this.replayedThrough = void 0;
1312
- return fromCursor;
1313
- }
1314
- return this.operationIndex.getSinceOrdinal(this.replayedThrough);
1315
- }
1316
- async loadThroughOrdinal(maxOrdinal) {
1317
- const operations = [];
1318
- let page = await this.replayFrom();
1319
- for (;;) {
1320
- for (const item of page.results) if (item.context.ordinal <= maxOrdinal) operations.push(item);
1321
- if (page.results.some(({ context }) => context.ordinal >= maxOrdinal) || !page.next) break;
1322
- page = await page.next();
1323
- }
1324
- return operations;
1325
- }
1326
- sortAndDedupe(items) {
1327
- const byOrdinal = /* @__PURE__ */ new Map();
1328
- for (const item of items) byOrdinal.set(item.context.ordinal, item);
1329
- return [...byOrdinal.values()].sort((left, right) => left.context.ordinal - right.context.ordinal);
1054
+ if (purged.size > 0) await this.referenceWriter.removeDocuments([...purged]);
1330
1055
  }
1331
1056
  };
1332
1057
  //#endregion
@@ -1341,7 +1066,7 @@ function required(env, name) {
1341
1066
  if (value.trim() !== value) throw new Error(`${name} must not have leading or trailing whitespace`);
1342
1067
  return value;
1343
1068
  }
1344
- const LOOPBACK_HOSTNAMES = new Set([
1069
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
1345
1070
  "127.0.0.1",
1346
1071
  "localhost",
1347
1072
  "[::1]"
@@ -1423,9 +1148,10 @@ function sha256HexToBase64(hash) {
1423
1148
  //#region src/storage/s3/primitives.ts
1424
1149
  const defaultPresigner = (client, command, expiresInSeconds) => getSignedUrl(client, command, {
1425
1150
  expiresIn: expiresInSeconds,
1426
- unhoistableHeaders: new Set(["x-amz-checksum-sha256"])
1151
+ unhoistableHeaders: /* @__PURE__ */ new Set(["x-amz-checksum-sha256"])
1427
1152
  });
1428
1153
  var S3AttachmentPrimitives = class {
1154
+ config;
1429
1155
  client;
1430
1156
  presign;
1431
1157
  now;
@@ -1496,7 +1222,7 @@ function createS3AttachmentPrimitives(config, dependencies = {}) {
1496
1222
  //#endregion
1497
1223
  //#region src/storage/s3/backend.ts
1498
1224
  const READINESS_PROBE_HASH = "0".repeat(64);
1499
- const OBJECT_NOT_FOUND_NAMES = new Set([
1225
+ const OBJECT_NOT_FOUND_NAMES = /* @__PURE__ */ new Set([
1500
1226
  "NotFound",
1501
1227
  "NoSuchKey",
1502
1228
  "NoSuchObject"
@@ -1514,6 +1240,8 @@ function requireHashFirstReservation(reservation) {
1514
1240
  }
1515
1241
  /** Server-only S3 capability. Callers complete authorization before download. */
1516
1242
  var S3AttachmentBackend = class {
1243
+ db;
1244
+ config;
1517
1245
  kind = "s3";
1518
1246
  primitives;
1519
1247
  now;
@@ -1600,6 +1328,6 @@ function createS3AttachmentBackend(db, config, dependencies = {}) {
1600
1328
  return new S3AttachmentBackend(db, config, dependencies);
1601
1329
  }
1602
1330
  //#endregion
1603
- 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 };
1331
+ 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, InvalidAttachmentMetadata, 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, validateReserveMetadata };
1604
1332
 
1605
1333
  //# sourceMappingURL=index.js.map