@powerhousedao/reactor 6.2.3-dev.33 → 6.2.3-dev.35

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.
@@ -1,4 +1,4 @@
1
- import { O as DocumentMetaCache, _ as KyselyExecutionScope, a as DEFAULT_DRIVE_CONTAINER_TYPES, b as KyselyWriteCache, d as KyselyOperationStore, f as KyselyKeyframeStore, k as CollectionMembershipCache, m as SimpleJobExecutor, mt as ReactorEventTypes, o as REACTOR_SCHEMA, p as DocumentModelRegistry, x as KyselyOperationIndex, y as EventBus } from "./forwarding-logger-BkwlvD9W.js";
1
+ import { C as KyselyWriteCache, I as DocumentMetaCache, L as CollectionMembershipCache, S as EventBus, _ as KyselyExecutionScope, a as DEFAULT_DRIVE_CONTAINER_TYPES, d as KyselyOperationStore, f as KyselyKeyframeStore, kt as ReactorEventTypes, m as SimpleJobExecutor, o as REACTOR_SCHEMA, p as DocumentModelRegistry, w as KyselyOperationIndex } from "./forwarding-logger-DMqDs9tE.js";
2
2
  //#region src/executor/worker/build-worker-executor.ts
3
3
  async function defaultLoadFactory(spec) {
4
4
  const ref = spec.module;
@@ -90,10 +90,16 @@ async function buildWorkerExecutor(options) {
90
90
  const captured = signatureRefusals;
91
91
  signatureRefusals = [];
92
92
  return captured;
93
+ },
94
+ evictPurged(documentIds) {
95
+ for (const documentId of documentIds) {
96
+ writeCache.invalidate(documentId);
97
+ documentMetaCache.invalidate(documentId);
98
+ }
93
99
  }
94
100
  };
95
101
  }
96
102
  //#endregion
97
103
  export { defaultLoadFactory as n, buildWorkerExecutor as t };
98
104
 
99
- //# sourceMappingURL=build-worker-executor-BonPsKdx.js.map
105
+ //# sourceMappingURL=build-worker-executor-B9IfcRij.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"build-worker-executor-B9IfcRij.js","names":[],"sources":["../src/executor/worker/build-worker-executor.ts"],"sourcesContent":["import type {\n DocumentModelModule,\n ISigner,\n OperationWithContext,\n} from \"@powerhousedao/shared/document-model\";\nimport type { ILogger } from \"document-model\";\nimport type { Kysely } from \"kysely\";\nimport { CollectionMembershipCache } from \"../../cache/collection-membership-cache.js\";\nimport { DocumentMetaCache } from \"../../cache/document-meta-cache.js\";\nimport { KyselyOperationIndex } from \"../../cache/kysely-operation-index.js\";\nimport { KyselyWriteCache } from \"../../cache/kysely-write-cache.js\";\nimport type { WriteCacheConfig } from \"../../cache/write-cache-types.js\";\nimport { DEFAULT_DRIVE_CONTAINER_TYPES } from \"../../core/drive-container-types.js\";\nimport type { Database } from \"../../core/types.js\";\nimport { EventBus } from \"../../events/event-bus.js\";\nimport {\n ReactorEventTypes,\n type JobWriteReadyEvent,\n type SignatureRefusedEvent,\n} from \"../../events/types.js\";\nimport { DocumentModelRegistry } from \"../../registry/implementation.js\";\nimport type { JobMeta } from \"../../shared/types.js\";\nimport { KyselyKeyframeStore } from \"../../storage/kysely/keyframe-store.js\";\nimport { KyselyOperationStore } from \"../../storage/kysely/store.js\";\nimport type { Database as StorageDatabase } from \"../../storage/kysely/types.js\";\nimport { REACTOR_SCHEMA } from \"../../storage/migrations/migrator.js\";\nimport { KyselyExecutionScope } from \"../execution-scope.js\";\nimport { SimpleJobExecutor } from \"../simple-job-executor.js\";\nimport type { SignatureTrustPolicy } from \"../../signer/types.js\";\nimport type { JobExecutorConfig } from \"../types.js\";\nimport type {\n FactorySpec,\n InitMessage,\n ModelManifestEntry,\n} from \"./protocol.js\";\n\n/**\n * In-worker capture of the JOB_WRITE_READY event emitted by the executor.\n * The worker forwards `operations` and `jobMeta` back to the parent; the\n * parent re-enriches `collectionMemberships` at emit time.\n */\nexport type WorkerWriteReadyCapture = {\n operations: OperationWithContext[];\n jobMeta: JobMeta;\n};\n\nexport type WorkerExecutorStack = {\n executor: SimpleJobExecutor;\n registry: DocumentModelRegistry;\n /**\n * Synchronously pops the most-recent JOB_WRITE_READY captured on this\n * worker's local event bus and clears it. Returns null if the executor\n * did not produce one for this job.\n */\n takeLastWriteReady(): WorkerWriteReadyCapture | null;\n /** Drains the refusals this worker's executor emitted since the last call. */\n takeSignatureRefusals(): SignatureRefusedEvent[];\n /** Drops purged ids from this worker's write and meta caches. */\n evictPurged(documentIds: string[]): void;\n};\n\nexport type BuildWorkerExecutorOptions = {\n init: InitMessage;\n database: Kysely<Database>;\n logger: ILogger;\n executorConfig?: JobExecutorConfig;\n driveContainerTypes?: ReadonlySet<string>;\n /**\n * Override the module loader used to materialize factory specs. Tests\n * can inject a deterministic resolver instead of touching the real\n * Node module loader.\n */\n loadFactory?: (spec: FactorySpec) => Promise<unknown>;\n};\n\nexport async function defaultLoadFactory(spec: FactorySpec): Promise<unknown> {\n const ref = spec.module;\n const specifier =\n \"filePath\" in ref\n ? new URL(`file://${ref.filePath}`).href\n : ref.packageName;\n const mod = (await import(specifier)) as Record<string, unknown>;\n const exported = mod[ref.exportName];\n if (typeof exported === \"function\") {\n return (exported as (args: unknown) => unknown)(spec.initArgs);\n }\n return exported;\n}\n\nasync function loadModelManifest(\n entries: ModelManifestEntry[],\n loadFactory: (spec: FactorySpec) => Promise<unknown>,\n registry: DocumentModelRegistry,\n logger: ILogger,\n): Promise<void> {\n for (const entry of entries) {\n let module: DocumentModelModule;\n try {\n module = (await loadFactory(entry.spec)) as DocumentModelModule;\n } catch (error) {\n logger.error(\n \"worker failed to load document model: @entry @error\",\n entry,\n error,\n );\n throw error;\n }\n const [result] = registry.registerModules(module);\n if (result.status === \"error\") {\n logger.error(\n \"worker failed to register document model: @entry @error\",\n entry,\n result.error,\n );\n throw result.error;\n }\n }\n}\n\n/**\n * Assembles the in-worker storage stack plus a {@link SimpleJobExecutor}\n * bound to a pre-built Kysely instance. The parent owns the wire protocol\n * and routing; the worker owns everything below `SimpleJobExecutor`.\n *\n * The local event bus exists only to satisfy the executor's contract: its\n * JOB_WRITE_READY emissions are captured here and shipped to the parent\n * via {@link WorkerExecutorStack.takeLastWriteReady}.\n */\nexport async function buildWorkerExecutor(\n options: BuildWorkerExecutorOptions,\n): Promise<WorkerExecutorStack> {\n const { init, database: baseDatabase, logger } = options;\n const driveContainerTypes =\n options.driveContainerTypes ?? DEFAULT_DRIVE_CONTAINER_TYPES;\n const loadFactory = options.loadFactory ?? defaultLoadFactory;\n\n const registry = new DocumentModelRegistry();\n await loadModelManifest(init.models, loadFactory, registry, logger);\n\n let signer: ISigner | undefined;\n if (init.signer) {\n try {\n signer = (await loadFactory(init.signer)) as ISigner;\n } catch (error) {\n logger.error(\n \"worker failed to load signer: @spec @error\",\n init.signer.module,\n error,\n );\n throw error;\n }\n }\n\n let trustPolicy: SignatureTrustPolicy | undefined;\n if (init.trustPolicy) {\n try {\n trustPolicy = (await loadFactory(\n init.trustPolicy,\n )) as SignatureTrustPolicy;\n } catch (error) {\n logger.error(\n \"worker failed to load trust policy: @spec @error\",\n init.trustPolicy.module,\n error,\n );\n throw error;\n }\n }\n\n const database = baseDatabase.withSchema(REACTOR_SCHEMA);\n const operationStore = new KyselyOperationStore(\n database as unknown as Kysely<StorageDatabase>,\n );\n const keyframeStore = new KyselyKeyframeStore(\n database as unknown as Kysely<StorageDatabase>,\n );\n\n const cacheConfig: WriteCacheConfig = {\n maxDocuments: 100,\n ringBufferSize: 10,\n keyframeInterval: 10,\n };\n const writeCache = new KyselyWriteCache(\n keyframeStore,\n operationStore,\n registry,\n cacheConfig,\n );\n await writeCache.startup();\n\n const operationIndex = new KyselyOperationIndex(\n database as unknown as Kysely<StorageDatabase>,\n );\n\n const documentMetaCache = new DocumentMetaCache(operationStore, {\n maxDocuments: 1000,\n });\n await documentMetaCache.startup();\n\n const collectionMembershipCache = new CollectionMembershipCache(\n operationIndex,\n );\n\n const executionScope = new KyselyExecutionScope(\n database as unknown as Kysely<StorageDatabase>,\n operationStore,\n operationIndex,\n keyframeStore,\n writeCache,\n documentMetaCache,\n collectionMembershipCache,\n );\n\n const eventBus = new EventBus();\n let lastWriteReady: WorkerWriteReadyCapture | null = null;\n eventBus.subscribe(\n ReactorEventTypes.JOB_WRITE_READY,\n (_t: number, event: JobWriteReadyEvent) => {\n lastWriteReady = {\n operations: event.operations,\n jobMeta: event.jobMeta,\n };\n },\n );\n\n let signatureRefusals: SignatureRefusedEvent[] = [];\n eventBus.subscribe(\n ReactorEventTypes.SIGNATURE_REFUSED,\n (_t: number, event: SignatureRefusedEvent) => {\n signatureRefusals.push(event);\n },\n );\n\n const executorConfig = options.executorConfig ?? {};\n const executor = new SimpleJobExecutor(\n logger,\n registry,\n operationStore,\n eventBus,\n writeCache,\n operationIndex,\n documentMetaCache,\n collectionMembershipCache,\n driveContainerTypes,\n executorConfig,\n executionScope,\n signer,\n trustPolicy,\n );\n\n return {\n executor,\n registry,\n takeLastWriteReady(): WorkerWriteReadyCapture | null {\n const captured = lastWriteReady;\n lastWriteReady = null;\n return captured;\n },\n takeSignatureRefusals(): SignatureRefusedEvent[] {\n const captured = signatureRefusals;\n signatureRefusals = [];\n return captured;\n },\n evictPurged(documentIds: string[]): void {\n for (const documentId of documentIds) {\n writeCache.invalidate(documentId);\n documentMetaCache.invalidate(documentId);\n }\n },\n };\n}\n"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
- import { C as SettledWatermark, at as DocumentNotFoundError, mt as ReactorEventTypes, vt as yieldToMain, w as createKyselyWatermarkProbe } from "./forwarding-logger-BkwlvD9W.js";
2
- import { createAuthState, isDenied } from "@powerhousedao/shared/document-model";
1
+ import { D as acquirePurgeLocks, M as createKyselyWatermarkProbe, Nt as yieldToMain, O as findPurged, gt as DocumentNotFoundError, j as SettledWatermark, kt as ReactorEventTypes } from "./forwarding-logger-DMqDs9tE.js";
2
+ import { createAuthState, isDenied, isPurgeMarker } from "@powerhousedao/shared/document-model";
3
3
  import { v4 } from "uuid";
4
4
  import { childLogger } from "document-model";
5
5
  //#region src/catch-up/scheduler.ts
@@ -336,11 +336,27 @@ function mergeByOrdinal(...lists) {
336
336
  for (const list of lists) for (const item of list) byOrdinal.set(ordinalOf(item), item);
337
337
  return [...byOrdinal.values()].sort((a, b) => ordinalOf(a) - ordinalOf(b));
338
338
  }
339
+ /** The ids whose purge an operation's rows must not outlive. */
340
+ function purgeFenceIds(items) {
341
+ const ids = /* @__PURE__ */ new Set();
342
+ for (const { operation, context } of items) {
343
+ ids.add(context.documentId);
344
+ if (operation.action.type !== "ADD_RELATIONSHIP") continue;
345
+ const input = operation.action.input;
346
+ if (typeof input.sourceId === "string") ids.add(input.sourceId);
347
+ if (typeof input.targetId === "string") ids.add(input.targetId);
348
+ }
349
+ return [...ids].sort();
350
+ }
351
+ function dropPurged(items, purged) {
352
+ if (purged.size === 0) return items;
353
+ return items.filter((item) => isPurgeMarker(item.operation) || !purged.has(item.context.documentId));
354
+ }
339
355
  function errorMessage(error) {
340
356
  return error instanceof Error ? error.message : String(error);
341
357
  }
342
358
  /** A read model whose cursor only sweeps and boot replay advance. */
343
- var BaseReadModel = class {
359
+ var BaseReadModel = class BaseReadModel {
344
360
  db;
345
361
  operationIndex;
346
362
  writeCache;
@@ -372,7 +388,10 @@ var BaseReadModel = class {
372
388
  ]);
373
389
  this.indexing = normalizeIndexingConfig(config.indexing ?? defaultReadModelIndexingConfig);
374
390
  this.cursor = new ContiguousCursor(0, this.maxTrackedAboveCursor);
391
+ if ((config.purgeFence ?? "locked") === "locked" && this.commitOperations !== BaseReadModel.prototype.commitOperations && !this.constructor.commitsInFenceTransaction) throw new Error(`Read model ${config.readModelId}: purgeFence "locked" commits in a transaction its commitOperations must write through. Write through the trx argument and set static commitsInFenceTransaction to true, or set purgeFence "skip" (rows on another handle) or "none" (no rows here, or its own transaction).`);
375
392
  }
393
+ /** Set by a subclass whose commitOperations writes through its trx argument. */
394
+ static commitsInFenceTransaction = false;
376
395
  get consumerId() {
377
396
  return this.config.readModelId;
378
397
  }
@@ -504,7 +523,39 @@ var BaseReadModel = class {
504
523
  }
505
524
  /** Called after the cursor advances. */
506
525
  onCursorAdvanced(appliedThrough) {}
507
- async commitOperations(items) {}
526
+ /** Writes through `trx` when given: the purge fence's transaction. */
527
+ async commitOperations(items, trx) {}
528
+ /** The handle document_purges is read through; `db` may be the fence trx. */
529
+ purgeLookup(db) {
530
+ return db;
531
+ }
532
+ /** False when the batch writes no rows, so the fence opens no transaction. */
533
+ writesRows(items) {
534
+ return true;
535
+ }
536
+ /** Every delivery commits through here. */
537
+ async commitFenced(items) {
538
+ const fence = this.config.purgeFence ?? "locked";
539
+ if (fence === "none") {
540
+ await this.commitOperations(items);
541
+ return;
542
+ }
543
+ if (items.length === 0 || !this.writesRows(items)) {
544
+ if (items.length > 0) await this.commitOperations(items);
545
+ return;
546
+ }
547
+ const ids = purgeFenceIds(items);
548
+ if (fence === "skip") {
549
+ const live = dropPurged(items, await findPurged(this.purgeLookup(this.db), ids));
550
+ if (live.length > 0) await this.commitOperations(live);
551
+ return;
552
+ }
553
+ await this.db.transaction().execute(async (trx) => {
554
+ await acquirePurgeLocks(trx, ids, "shared");
555
+ const live = dropPurged(items, await findPurged(this.purgeLookup(trx), ids));
556
+ if (live.length > 0) await this.commitOperations(live, trx);
557
+ });
558
+ }
508
559
  /** Rebuilds resultingState as the executor writes it: scopes plus header. */
509
560
  async rebuildStateForOperations(operations) {
510
561
  const result = [];
@@ -615,7 +666,7 @@ var BaseReadModel = class {
615
666
  }
616
667
  const chunk = items.slice(start, start + commitChunkSize);
617
668
  try {
618
- await this.commitOperations(chunk);
669
+ await this.commitFenced(chunk);
619
670
  } catch (error) {
620
671
  this.failedItem = chunk[0];
621
672
  const prefix = items.slice(0, committed);
@@ -722,7 +773,7 @@ var BaseReadModel = class {
722
773
  };
723
774
  }
724
775
  try {
725
- await this.commitOperations(rebuilt.items);
776
+ await this.commitFenced(rebuilt.items);
726
777
  } catch (error) {
727
778
  this.cursor.settle(owned, false);
728
779
  return {
@@ -741,7 +792,7 @@ var BaseReadModel = class {
741
792
  reapplied: rebuilt.items.length - replayed
742
793
  };
743
794
  }
744
- /** A document gone from the write cache is absent, not a failure. */
795
+ /** A document gone from the write cache, or purged, is absent. */
745
796
  async rebuildIfConfigured(items) {
746
797
  if (!this.config.rebuildStateOnInit) return {
747
798
  items,
@@ -750,11 +801,15 @@ var BaseReadModel = class {
750
801
  const rebuilt = [];
751
802
  const absent = [];
752
803
  for (const item of items) {
804
+ if (isPurgeMarker(item.operation)) {
805
+ rebuilt.push(item);
806
+ continue;
807
+ }
753
808
  let result;
754
809
  try {
755
810
  result = await this.rebuildStateForOperations([item]);
756
811
  } catch (error) {
757
- if (!(error instanceof DocumentNotFoundError)) throw error;
812
+ if (!DocumentNotFoundError.isError(error) && !await this.isPurged(item.context.documentId)) throw error;
758
813
  this.catchUpLogger.warn("@consumer dropped ordinal @ordinal: document @documentId is gone", this.consumerId, ordinalOf(item), item.context.documentId);
759
814
  absent.push(ordinalOf(item));
760
815
  continue;
@@ -766,6 +821,14 @@ var BaseReadModel = class {
766
821
  absent
767
822
  };
768
823
  }
824
+ /** A failed lookup is no answer: the caller's own error stands. */
825
+ async isPurged(documentId) {
826
+ try {
827
+ return (await findPurged(this.purgeLookup(this.db), [documentId])).has(documentId);
828
+ } catch {
829
+ return false;
830
+ }
831
+ }
769
832
  block(item, error) {
770
833
  const blockedAt = {
771
834
  ordinal: ordinalOf(item),
@@ -1044,13 +1107,15 @@ let DeletedDocumentRead = /* @__PURE__ */ function(DeletedDocumentRead) {
1044
1107
  var KyselyDocumentView = class extends BaseReadModel {
1045
1108
  operationStore;
1046
1109
  deletedDocumentRead;
1110
+ static commitsInFenceTransaction = true;
1047
1111
  _db;
1048
1112
  constructor(db, operationStore, operationIndex, writeCache, consistencyTracker, deletedDocumentRead, indexing = defaultReadModelIndexingConfig) {
1049
1113
  super(db, operationIndex, writeCache, consistencyTracker, {
1050
1114
  readModelId: DOCUMENT_VIEW_READ_MODEL,
1051
1115
  rebuildStateOnInit: true,
1052
1116
  indexing,
1053
- replayStreamSuffix: false
1117
+ replayStreamSuffix: false,
1118
+ purgeFence: "locked"
1054
1119
  });
1055
1120
  this.operationStore = operationStore;
1056
1121
  this.deletedDocumentRead = deletedDocumentRead;
@@ -1070,10 +1135,11 @@ var KyselyDocumentView = class extends BaseReadModel {
1070
1135
  * at least the one the row already carries, so an older duplicate from a
1071
1136
  * sweep, boot replay or chunk interleave never rolls a scope back.
1072
1137
  */
1073
- async commitOperations(items) {
1074
- await this._db.transaction().execute(async (trx) => {
1138
+ async commitOperations(items, fenced) {
1139
+ const write = async (trx) => {
1075
1140
  for (const item of items) {
1076
1141
  const { operation, context } = item;
1142
+ if (isPurgeMarker(operation)) continue;
1077
1143
  const { documentId, scope, branch, documentType, resultingState, ordinal } = context;
1078
1144
  const { index, hash } = operation;
1079
1145
  if (!resultingState) throw new Error(`Missing resultingState in context for operation ${operation.id || "unknown"}. IDocumentView requires resultingState from upstream - it does not rebuild documents.`);
@@ -1178,7 +1244,12 @@ var KyselyDocumentView = class extends BaseReadModel {
1178
1244
  }
1179
1245
  }
1180
1246
  }
1181
- });
1247
+ };
1248
+ if (fenced) {
1249
+ await write(fenced);
1250
+ return;
1251
+ }
1252
+ await this._db.transaction().execute(write);
1182
1253
  }
1183
1254
  async exists(documentIds, existence, consistencyToken, signal) {
1184
1255
  if (consistencyToken) await this.waitForConsistency(consistencyToken, void 0, signal);
@@ -1202,7 +1273,7 @@ var KyselyDocumentView = class extends BaseReadModel {
1202
1273
  if (this.deletedDocumentRead === "NotFound") query = query.where("isDeleted", "=", false);
1203
1274
  if (scopesToQuery.length > 0) query = query.where("scope", "in", scopesToQuery);
1204
1275
  const snapshots = await query.execute();
1205
- if (snapshots.length === 0) throw new Error(`Document not found: ${documentId}`);
1276
+ if (snapshots.length === 0) throw new DocumentNotFoundError(documentId, `Document not found: ${documentId}`);
1206
1277
  if (signal?.aborted) throw new Error("Operation aborted");
1207
1278
  const headerSnapshot = snapshots.find((s) => s.scope === "header");
1208
1279
  if (!headerSnapshot) throw new Error(`Document header not found: ${documentId}`);
@@ -1345,7 +1416,7 @@ var KyselyDocumentView = class extends BaseReadModel {
1345
1416
  const slugMatchDocId = slugMatch?.documentId;
1346
1417
  if (idMatchDocId && slugMatchDocId && idMatchDocId !== slugMatchDocId) throw new Error(`Ambiguous identifier "${identifier}": matches both document ID "${idMatchDocId}" and slug for document ID "${slugMatchDocId}". Please use get() for ID or resolveSlug() + get() for slug to be explicit.`);
1347
1418
  const resolvedDocumentId = idMatchDocId || slugMatchDocId;
1348
- if (!resolvedDocumentId) throw new Error(`Document not found: ${identifier}`);
1419
+ if (!resolvedDocumentId) throw new DocumentNotFoundError(identifier, `Document not found: ${identifier}`);
1349
1420
  return resolvedDocumentId;
1350
1421
  }
1351
1422
  /**
@@ -1488,29 +1559,56 @@ async function collectAllPages(firstPage, signal) {
1488
1559
  function isRelationshipAction(actionType) {
1489
1560
  return actionType === "ADD_RELATIONSHIP" || actionType === "REMOVE_RELATIONSHIP" || actionType === "UPDATE_RELATIONSHIP";
1490
1561
  }
1562
+ function addRelationshipIds(items) {
1563
+ const ids = [];
1564
+ for (const { operation } of items) {
1565
+ if (operation.action.type !== "ADD_RELATIONSHIP") continue;
1566
+ const { sourceId, targetId } = operation.action.input;
1567
+ if (typeof sourceId === "string") ids.push(sourceId);
1568
+ if (typeof targetId === "string") ids.push(targetId);
1569
+ }
1570
+ return ids;
1571
+ }
1572
+ function touchesPurged(operation, purged) {
1573
+ const input = operation.action.input;
1574
+ return purged.has(input.sourceId) || purged.has(input.targetId);
1575
+ }
1491
1576
  var KyselyDocumentIndexer = class extends BaseReadModel {
1577
+ static commitsInFenceTransaction = true;
1492
1578
  _db;
1493
1579
  constructor(db, operationIndex, writeCache, consistencyTracker, indexing = defaultReadModelIndexingConfig) {
1494
1580
  super(db, operationIndex, writeCache, consistencyTracker, {
1495
1581
  readModelId: DOCUMENT_INDEXER_READ_MODEL,
1496
1582
  rebuildStateOnInit: false,
1497
- indexing
1583
+ indexing,
1584
+ purgeFence: "locked"
1498
1585
  });
1499
1586
  this._db = db;
1500
1587
  }
1588
+ writesRows(items) {
1589
+ return items.some((item) => isRelationshipAction(item.operation.action.type));
1590
+ }
1501
1591
  /** Opens no transaction for a batch carrying no relationship operation. */
1502
- async commitOperations(items) {
1592
+ async commitOperations(items, fenced) {
1503
1593
  const relationshipOps = items.filter((item) => isRelationshipAction(item.operation.action.type));
1504
1594
  if (relationshipOps.length === 0) return;
1505
- await this._db.transaction().execute(async (trx) => {
1506
- for (const item of relationshipOps) {
1507
- const { operation } = item;
1508
- const actionType = operation.action.type;
1509
- if (actionType === "ADD_RELATIONSHIP") await this.handleAddRelationship(trx, operation);
1510
- else if (actionType === "REMOVE_RELATIONSHIP") await this.handleRemoveRelationship(trx, operation);
1511
- else if (actionType === "UPDATE_RELATIONSHIP") await this.handleUpdateRelationship(trx, operation);
1512
- }
1513
- });
1595
+ if (fenced) {
1596
+ await this.writeRelationships(relationshipOps, fenced);
1597
+ return;
1598
+ }
1599
+ await this._db.transaction().execute((trx) => this.writeRelationships(relationshipOps, trx));
1600
+ }
1601
+ async writeRelationships(relationshipOps, trx) {
1602
+ const purged = await findPurged(trx, addRelationshipIds(relationshipOps));
1603
+ for (const item of relationshipOps) {
1604
+ const { operation } = item;
1605
+ const actionType = operation.action.type;
1606
+ if (actionType === "ADD_RELATIONSHIP") {
1607
+ if (touchesPurged(operation, purged)) continue;
1608
+ await this.handleAddRelationship(trx, operation);
1609
+ } else if (actionType === "REMOVE_RELATIONSHIP") await this.handleRemoveRelationship(trx, operation);
1610
+ else if (actionType === "UPDATE_RELATIONSHIP") await this.handleUpdateRelationship(trx, operation);
1611
+ }
1514
1612
  }
1515
1613
  async getOutgoing(documentId, types, paging, consistencyToken, signal) {
1516
1614
  if (consistencyToken) await this.waitForConsistency(consistencyToken, void 0, signal);
@@ -1734,4 +1832,4 @@ var KyselyDocumentIndexer = class extends BaseReadModel {
1734
1832
  //#endregion
1735
1833
  export { ContiguousCursor as _, KyselyDocumentView as a, isCatchUpConsumer as b, ReadModelCoordinator as c, reserveBatch as d, BaseReadModel as f, unchunkedReadModelIndexingConfig as g, defaultReadModelIndexingConfig as h, DeletedDocumentRead as i, indexReserved as l, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS as m, ConsistencyTracker as n, DOCUMENT_INDEXER_READ_MODEL as o, DEFAULT_COMMIT_CHUNK_SIZE as p, makeConsistencyKey as r, DOCUMENT_VIEW_READ_MODEL as s, KyselyDocumentIndexer as t, releaseBatch as u, defaultCatchUpConfig as v, CatchUpScheduler as y };
1736
1834
 
1737
- //# sourceMappingURL=document-indexer-I6i_YzsR.js.map
1835
+ //# sourceMappingURL=document-indexer-CZqIpdz2.js.map