@powerhousedao/reactor-attachments 6.2.3-dev.3 → 6.2.3-dev.30

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,14 +1,15 @@
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 { 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-D53Rz9lE.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 { finished } from "node:stream/promises";
8
9
  import { generatorTypeDefs } from "@powerhousedao/document-engineering/graphql";
9
10
  import { constantCase, pascalCase } from "change-case";
10
11
  import { Kind, buildASTSchema, getNamedType, isInputObjectType, isListType, isNonNullType, parse, print } from "graphql";
11
- import { BaseReadModel } from "@powerhousedao/reactor";
12
+ import { BaseReadModel, defaultReadModelIndexingConfig } from "@powerhousedao/reactor";
12
13
  import { Buffer as Buffer$1 } from "node:buffer";
13
14
  import { GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
14
15
  import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
@@ -46,6 +47,13 @@ function storageRelativePath(hash) {
46
47
  *
47
48
  * Returns the number of bytes written.
48
49
  */
50
+ async function discardTempFile(writer, tempPath) {
51
+ writer.destroy();
52
+ try {
53
+ await finished(writer, { error: false });
54
+ } catch {}
55
+ await rm(tempPath, { force: true });
56
+ }
49
57
  async function writeAttachmentBytes(path, data) {
50
58
  await mkdir(dirname(path), { recursive: true });
51
59
  const tempPath = join(dirname(path), `${randomUUID()}.tmp`);
@@ -82,8 +90,7 @@ async function writeAttachmentBytes(path, data) {
82
90
  reader.releaseLock();
83
91
  }
84
92
  if (caughtError) {
85
- writer.destroy();
86
- await rm(tempPath, { force: true });
93
+ await discardTempFile(writer, tempPath);
87
94
  throw caughtError;
88
95
  }
89
96
  try {
@@ -96,7 +103,7 @@ async function writeAttachmentBytes(path, data) {
96
103
  });
97
104
  await rename(tempPath, path);
98
105
  } catch (err) {
99
- await rm(tempPath, { force: true });
106
+ await discardTempFile(writer, tempPath);
100
107
  throw err instanceof Error ? err : new Error(String(err));
101
108
  }
102
109
  return bytesWritten;
@@ -255,6 +262,9 @@ function wrapStreamWithCleanup(source, cleanup) {
255
262
  });
256
263
  }
257
264
  var KyselyAttachmentStore = class {
265
+ db;
266
+ transport;
267
+ basePath;
258
268
  activeReaders = /* @__PURE__ */ new Map();
259
269
  constructor(db, transport, basePath) {
260
270
  this.db = db;
@@ -283,18 +293,10 @@ var KyselyAttachmentStore = class {
283
293
  async has(hash) {
284
294
  return (await this.db.selectFrom("attachment").select("status").where("hash", "=", hash).executeTakeFirst())?.status === "available";
285
295
  }
286
- async get(hash, signal) {
296
+ async get(hash, signal, documentId) {
287
297
  const row = await this.db.selectFrom("attachment").selectAll().where("hash", "=", hash).executeTakeFirst();
288
298
  if (row) {
289
- if (row.status === "evicted") {
290
- const remote = await this.transport.fetch(hash, signal);
291
- if (remote.kind === "data") {
292
- await this.put(hash, remote.response.metadata, remote.response.body);
293
- return this.get(hash, signal);
294
- }
295
- if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
296
- throw new AttachmentNotFound(hash);
297
- }
299
+ if (row.status === "evicted") return this.fetchRemote(hash, signal, documentId);
298
300
  const now = (/* @__PURE__ */ new Date()).toISOString();
299
301
  await this.db.updateTable("attachment").set({ last_accessed_at_utc: now }).where("hash", "=", hash).execute();
300
302
  const header = rowToHeader$1(row);
@@ -312,13 +314,7 @@ var KyselyAttachmentStore = class {
312
314
  fileName: pending.file_name,
313
315
  sizeBytes: pending.size_bytes
314
316
  });
315
- const remote = await this.transport.fetch(hash, signal);
316
- if (remote.kind === "data") {
317
- await this.put(hash, remote.response.metadata, remote.response.body);
318
- return this.get(hash, signal);
319
- }
320
- if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
321
- throw new AttachmentNotFound(hash);
317
+ return this.fetchRemote(hash, signal, documentId);
322
318
  }
323
319
  async put(hash, metadata, data) {
324
320
  const existing = await this.db.selectFrom("attachment").select(["hash", "status"]).where("hash", "=", hash).executeTakeFirst();
@@ -358,6 +354,16 @@ var KyselyAttachmentStore = class {
358
354
  const result = await this.db.selectFrom("attachment").select(sql`COALESCE(SUM(size_bytes), 0)`.as("total")).where("status", "=", "available").executeTakeFirst();
359
355
  return Number(result?.total ?? 0);
360
356
  }
357
+ async fetchRemote(hash, signal, documentId) {
358
+ if (documentId === void 0) throw new AttachmentNotFound(hash);
359
+ const remote = await this.transport.fetch(hash, documentId, signal);
360
+ if (remote.kind === "data") {
361
+ await this.put(hash, remote.response.metadata, remote.response.body);
362
+ return this.get(hash, signal);
363
+ }
364
+ if (remote.kind === "pending") throw new AttachmentPending(hash, remote.expiresAtUtc);
365
+ throw new AttachmentNotFound(hash);
366
+ }
361
367
  async findPendingReservation(hash, now) {
362
368
  const row = await this.db.selectFrom("attachment_reservation as r").leftJoin("attachment as a", "a.hash", "r.client_hash").select([
363
369
  "r.mime_type",
@@ -391,7 +397,7 @@ var KyselyAttachmentStore = class {
391
397
  };
392
398
  //#endregion
393
399
  //#region src/storage/kysely/reservation-store.ts
394
- const DEFAULT_RESERVATION_TTL_MS = 1440 * 60 * 1e3;
400
+ const DEFAULT_RESERVATION_TTL_MS = 864e5;
395
401
  function rowToReservation(row) {
396
402
  return {
397
403
  reservationId: row.reservation_id,
@@ -405,6 +411,7 @@ function rowToReservation(row) {
405
411
  };
406
412
  }
407
413
  var KyselyReservationStore = class {
414
+ db;
408
415
  ttlMs;
409
416
  constructor(db, ttlMs = DEFAULT_RESERVATION_TTL_MS) {
410
417
  this.db = db;
@@ -594,6 +601,11 @@ function rowToHeader(row) {
594
601
  };
595
602
  }
596
603
  var DirectAttachmentUpload = class {
604
+ reservation;
605
+ db;
606
+ basePath;
607
+ reservations;
608
+ maxBytes;
597
609
  reservationId;
598
610
  ref;
599
611
  expiresAtUtc;
@@ -663,6 +675,10 @@ var DirectAttachmentUpload = class {
663
675
  //#endregion
664
676
  //#region src/direct/direct-attachment-upload-factory.ts
665
677
  var DirectAttachmentUploadFactory = class {
678
+ db;
679
+ basePath;
680
+ reservations;
681
+ maxBytes;
666
682
  constructor(db, basePath, reservations, maxBytes) {
667
683
  this.db = db;
668
684
  this.basePath = basePath;
@@ -681,6 +697,8 @@ var DirectAttachmentUploadFactory = class {
681
697
  * can never accidentally return a direct-provider target.
682
698
  */
683
699
  var FilesystemAttachmentBackend = class {
700
+ store;
701
+ config;
684
702
  kind = "filesystem";
685
703
  constructor(store, config) {
686
704
  this.store = store;
@@ -732,6 +750,8 @@ var S3AttachmentUploadFactory = class {
732
750
  //#endregion
733
751
  //#region src/attachment-builder.ts
734
752
  var AttachmentBuilder = class {
753
+ db;
754
+ storagePath;
735
755
  transport = new NullAttachmentTransport();
736
756
  customUploadFactory;
737
757
  maxUploadBytes;
@@ -804,7 +824,7 @@ var AttachmentBuilder = class {
804
824
  //#endregion
805
825
  //#region src/reference-index/attachment-schema-compiler.ts
806
826
  const ATTACHMENT_REF_TYPE = "AttachmentRef";
807
- const CODEGEN_SCALAR_NAMES = new Set([
827
+ const CODEGEN_SCALAR_NAMES = /* @__PURE__ */ new Set([
808
828
  "Unknown",
809
829
  "DateTime",
810
830
  "Address",
@@ -852,11 +872,12 @@ function buildEffectiveSchema(specification, context) {
852
872
  const stateSchemas = Object.values(specification.state).map((state) => state.schema);
853
873
  const operationSchemas = specification.modules.flatMap((moduleSpecification) => moduleSpecification.operations.flatMap((operation) => operation.schema === null ? [] : [operation.schema]));
854
874
  try {
855
- return buildASTSchema(dedupeTypeDefinitions(parse([
875
+ const document = parse([
856
876
  ...scalarSchemas,
857
877
  ...stateSchemas,
858
878
  ...operationSchemas
859
- ].filter(Boolean).join("\n\n"))));
879
+ ].filter(Boolean).join("\n\n"));
880
+ return buildASTSchema(dedupeTypeDefinitions(document));
860
881
  } catch {
861
882
  throw compilationError(context, "the effective GraphQL schema is invalid");
862
883
  }
@@ -994,6 +1015,8 @@ function extractValue(plan, value, path, context, refs, seenRefs, activeObjects)
994
1015
  }
995
1016
  }
996
1017
  var SchemaCompiledAttachmentExtractor = class {
1018
+ context;
1019
+ rootPlan;
997
1020
  constructor(context, rootPlan) {
998
1021
  this.context = context;
999
1022
  this.rootPlan = rootPlan;
@@ -1056,12 +1079,16 @@ var AttachmentSchemaCompiler = class {
1056
1079
  //#endregion
1057
1080
  //#region src/read-models/attachment-reference/kysely-attachment-reference-store.ts
1058
1081
  var KyselyAttachmentReferenceStore = class {
1082
+ db;
1059
1083
  constructor(db) {
1060
1084
  this.db = db;
1061
1085
  }
1062
1086
  async hasReference(documentId, ref) {
1063
1087
  return await this.db.selectFrom("attachment_reference").select("document_id").where("document_id", "=", documentId).where("attachment_ref", "=", ref).executeTakeFirst() !== void 0;
1064
1088
  }
1089
+ async referencingScopes(documentId, ref) {
1090
+ return (await this.db.selectFrom("attachment_reference").select("scope").distinct().where("document_id", "=", documentId).where("attachment_ref", "=", ref).execute()).map((row) => row.scope);
1091
+ }
1065
1092
  async addReferences(references) {
1066
1093
  if (references.length === 0) return;
1067
1094
  await this.db.insertInto("attachment_reference").values(references.map((reference) => ({
@@ -1153,6 +1180,7 @@ async function getAttachmentReferenceMigrationStatus(db, schema = ATTACHMENT_REF
1153
1180
  //#endregion
1154
1181
  //#region src/read-models/attachment-reference/index-builder.ts
1155
1182
  var AttachmentReferenceIndexBuilder = class {
1183
+ db;
1156
1184
  constructor(db) {
1157
1185
  this.db = db;
1158
1186
  }
@@ -1165,12 +1193,36 @@ var AttachmentReferenceIndexBuilder = class {
1165
1193
  //#endregion
1166
1194
  //#region src/read-models/attachment-reference/attachment-reference-read-model.ts
1167
1195
  const ATTACHMENT_REFERENCE_READ_MODEL_ID = "attachment-reference-read-model";
1196
+ /**
1197
+ * Indexes attachment references in chunks.
1198
+ *
1199
+ * Each reference row stands on its own and is written insert-or-do-nothing, so
1200
+ * a batch that commits in pieces exposes no partial structure: a reader either
1201
+ * sees a reference or does not, and re-indexing the same range writes nothing
1202
+ * new. Batches here are the largest in the system, which is what chunking is
1203
+ * for.
1204
+ */
1168
1205
  var AttachmentReferenceReadModel = class extends BaseReadModel {
1206
+ documentModelRegistry;
1207
+ schemaCompiler;
1208
+ referenceWriter;
1169
1209
  indexingQueue = Promise.resolve();
1210
+ checkpointTarget;
1211
+ /**
1212
+ * How far a replay may skip ahead of the cursor: the highest ordinal already
1213
+ * pulled from the index while the cursor was parked.
1214
+ *
1215
+ * NOT a claim that every ordinal below it committed -- the parked ordinal is
1216
+ * precisely the one that did not -- so it is a hint, and {@link replayFrom}
1217
+ * re-probes the gap before honouring it.
1218
+ */
1219
+ replayedThrough;
1220
+ warnedCheckpoint;
1170
1221
  constructor(db, operationIndex, writeCache, consistencyTracker, documentModelRegistry, schemaCompiler, referenceWriter) {
1171
1222
  super(db, operationIndex, writeCache, consistencyTracker, {
1172
1223
  readModelId: ATTACHMENT_REFERENCE_READ_MODEL_ID,
1173
- rebuildStateOnInit: false
1224
+ rebuildStateOnInit: false,
1225
+ indexing: defaultReadModelIndexingConfig
1174
1226
  });
1175
1227
  this.documentModelRegistry = documentModelRegistry;
1176
1228
  this.schemaCompiler = schemaCompiler;
@@ -1215,43 +1267,89 @@ var AttachmentReferenceReadModel = class extends BaseReadModel {
1215
1267
  if (references.length > 0) await this.referenceWriter.addReferences(references);
1216
1268
  }
1217
1269
  async indexOperationsInOrdinalOrder(incoming) {
1218
- const pending = this.sortAndDedupe(incoming.filter(({ context }) => context.ordinal > this.lastOrdinal));
1219
- if (pending.length === 0) return;
1220
- const incomingMax = pending[pending.length - 1].context.ordinal;
1221
- let candidates = pending;
1222
- if (!this.isContiguousThrough(candidates, incomingMax)) {
1270
+ let candidates = this.sortAndDedupe(incoming);
1271
+ if (candidates.length === 0) return;
1272
+ const incomingMax = candidates[candidates.length - 1].context.ordinal;
1273
+ if (this.contiguousEnd(candidates) < incomingMax) {
1223
1274
  const replayed = await this.loadThroughOrdinal(incomingMax);
1224
- candidates = this.sortAndDedupe([...replayed, ...pending]);
1275
+ candidates = this.sortAndDedupe([...replayed, ...candidates]);
1225
1276
  }
1226
- const contiguous = [];
1227
- let expectedOrdinal = this.lastOrdinal + 1;
1228
- for (const item of candidates) {
1229
- const ordinal = item.context.ordinal;
1230
- if (ordinal < expectedOrdinal) continue;
1231
- if (ordinal > expectedOrdinal || ordinal > incomingMax) break;
1232
- contiguous.push(item);
1233
- expectedOrdinal++;
1277
+ const checkpoint = this.contiguousEnd(candidates);
1278
+ if (checkpoint < incomingMax && checkpoint !== this.warnedCheckpoint) {
1279
+ this.warnedCheckpoint = checkpoint;
1280
+ console.warn(`[${this.config.readModelId}] indexed through ordinal ${incomingMax} but parked the cursor at ${checkpoint}: ordinal ${checkpoint + 1} is missing`);
1234
1281
  }
1235
- if (expectedOrdinal <= incomingMax) throw new Error(`Attachment reference read model cannot advance past missing ordinal ${expectedOrdinal}`);
1236
1282
  const previousOrdinal = this.lastOrdinal;
1283
+ this.checkpointTarget = checkpoint;
1237
1284
  try {
1238
- await super.indexOperations(contiguous);
1285
+ await super.indexOperations(candidates);
1239
1286
  } catch (error) {
1240
1287
  this.lastOrdinal = previousOrdinal;
1288
+ this.replayedThrough = void 0;
1241
1289
  throw error;
1290
+ } finally {
1291
+ this.checkpointTarget = void 0;
1242
1292
  }
1293
+ if (checkpoint > previousOrdinal) this.replayedThrough = void 0;
1294
+ else if (checkpoint < incomingMax) this.replayedThrough = Math.max(this.replayedThrough ?? checkpoint, incomingMax);
1243
1295
  }
1244
- isContiguousThrough(items, maxOrdinal) {
1296
+ /**
1297
+ * Writes the cursor parked by indexOperationsInOrdinalOrder instead of the
1298
+ * batch maximum, so indexing an operation above a gap never advances past it.
1299
+ */
1300
+ async saveState(trx, items) {
1301
+ const target = this.checkpointTarget;
1302
+ if (target === void 0) {
1303
+ await super.saveState(trx, items);
1304
+ return;
1305
+ }
1306
+ this.lastOrdinal = target;
1307
+ await trx.updateTable("ViewState").set({
1308
+ lastOrdinal: target,
1309
+ lastOperationTimestamp: /* @__PURE__ */ new Date()
1310
+ }).where("readModelId", "=", this.config.readModelId).execute();
1311
+ }
1312
+ /** Last ordinal of the contiguous run starting at lastOrdinal + 1. */
1313
+ contiguousEnd(items) {
1245
1314
  let expectedOrdinal = this.lastOrdinal + 1;
1246
1315
  for (const item of items) {
1247
- if (item.context.ordinal !== expectedOrdinal) return false;
1316
+ const ordinal = item.context.ordinal;
1317
+ if (ordinal < expectedOrdinal) continue;
1318
+ if (ordinal > expectedOrdinal) break;
1248
1319
  expectedOrdinal++;
1249
1320
  }
1250
- return expectedOrdinal > maxOrdinal;
1321
+ return expectedOrdinal - 1;
1322
+ }
1323
+ /**
1324
+ * Opens a replay at the lowest ordinal still worth reading.
1325
+ *
1326
+ * {@link replayedThrough} is what keeps a permanently held hole -- a
1327
+ * rolled-back insert, which never fills -- from re-reading the whole tail on
1328
+ * every batch. It cannot be trusted on its own: a hole that fills without
1329
+ * being delivered here is visible only in the index, and a mark that is
1330
+ * never questioned would hide that operation for the life of the process.
1331
+ * That happens whenever another writer commits the gap (a second reactor on
1332
+ * the same database), and it leaves the cursor parked below an operation
1333
+ * whose references were never written -- `hasReference` then answers false
1334
+ * for an attachment that is genuinely referenced.
1335
+ *
1336
+ * So a parked batch spends one page probing the gap: the first row above the
1337
+ * cursor is the missing ordinal itself once it commits. Finding it drops the
1338
+ * mark and replays from the cursor; not finding it leaves the mark standing,
1339
+ * which is the cheap path and the common one.
1340
+ */
1341
+ async replayFrom() {
1342
+ const fromCursor = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
1343
+ if (this.replayedThrough === void 0) return fromCursor;
1344
+ if (fromCursor.results[0]?.context.ordinal === this.lastOrdinal + 1) {
1345
+ this.replayedThrough = void 0;
1346
+ return fromCursor;
1347
+ }
1348
+ return this.operationIndex.getSinceOrdinal(this.replayedThrough);
1251
1349
  }
1252
1350
  async loadThroughOrdinal(maxOrdinal) {
1253
1351
  const operations = [];
1254
- let page = await this.operationIndex.getSinceOrdinal(this.lastOrdinal);
1352
+ let page = await this.replayFrom();
1255
1353
  for (;;) {
1256
1354
  for (const item of page.results) if (item.context.ordinal <= maxOrdinal) operations.push(item);
1257
1355
  if (page.results.some(({ context }) => context.ordinal >= maxOrdinal) || !page.next) break;
@@ -1277,7 +1375,7 @@ function required(env, name) {
1277
1375
  if (value.trim() !== value) throw new Error(`${name} must not have leading or trailing whitespace`);
1278
1376
  return value;
1279
1377
  }
1280
- const LOOPBACK_HOSTNAMES = new Set([
1378
+ const LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set([
1281
1379
  "127.0.0.1",
1282
1380
  "localhost",
1283
1381
  "[::1]"
@@ -1359,9 +1457,10 @@ function sha256HexToBase64(hash) {
1359
1457
  //#region src/storage/s3/primitives.ts
1360
1458
  const defaultPresigner = (client, command, expiresInSeconds) => getSignedUrl(client, command, {
1361
1459
  expiresIn: expiresInSeconds,
1362
- unhoistableHeaders: new Set(["x-amz-checksum-sha256"])
1460
+ unhoistableHeaders: /* @__PURE__ */ new Set(["x-amz-checksum-sha256"])
1363
1461
  });
1364
1462
  var S3AttachmentPrimitives = class {
1463
+ config;
1365
1464
  client;
1366
1465
  presign;
1367
1466
  now;
@@ -1432,7 +1531,7 @@ function createS3AttachmentPrimitives(config, dependencies = {}) {
1432
1531
  //#endregion
1433
1532
  //#region src/storage/s3/backend.ts
1434
1533
  const READINESS_PROBE_HASH = "0".repeat(64);
1435
- const OBJECT_NOT_FOUND_NAMES = new Set([
1534
+ const OBJECT_NOT_FOUND_NAMES = /* @__PURE__ */ new Set([
1436
1535
  "NotFound",
1437
1536
  "NoSuchKey",
1438
1537
  "NoSuchObject"
@@ -1450,6 +1549,8 @@ function requireHashFirstReservation(reservation) {
1450
1549
  }
1451
1550
  /** Server-only S3 capability. Callers complete authorization before download. */
1452
1551
  var S3AttachmentBackend = class {
1552
+ db;
1553
+ config;
1453
1554
  kind = "s3";
1454
1555
  primitives;
1455
1556
  now;