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

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,7 +1,7 @@
1
- import { $ as DocumentNotFoundError, A as ModuleNotFoundError, B as authDecisionModel, C as DocumentMetaCache, D as DuplicateManifestError, E as JobExecutorEventTypes, F as summarizeSubmittedActions, G as DocumentExistence, H as APPEND_CONDITION_FAILED_PREFIX, I as targetDocumentId, J as RevisionMismatchError, K as DuplicateOperationError, L as decideAtHead, M as createConsistencyToken, N as createEmptyConsistencyToken, O as DuplicateModuleError, P as submittedActionIds, Q as DocumentDeletedError, R as selectDecisionModel, S as KyselyOperationIndex, T as DEFAULT_DEFERRED_JOB_TTL_MS, U as AppendConditionFailedError, V as buildDecisionModel, W as DocumentAlreadyExistsError, X as AuthTimestampNotMonotonicError, Y as AuthEnforcementDisabledError, Z as AuthorizationDeniedError, _ as DriveCollectionId, a as DEFAULT_DRIVE_CONTAINER_TYPES, at as EventBusAggregateError, b as EventBus, c as runMigrations, ct as parsePagingOptions, d as KyselyOperationStore, dt as __exportAll, et as ExcessiveReshuffleError, f as KyselyKeyframeStore, g as PassthroughSigner, h as verifyActionSignature, it as UpgradePreconditionFailedError, j as GATED_DOCUMENT_ACTIONS, k as InvalidModuleError, l as createForwardingPoolInstrumentation, lt as throwIfAborted, m as SimpleJobExecutor, n as errorToInfo, nt as InvalidSignatureError, o as REACTOR_SCHEMA, ot as ReactorEventTypes, p as DocumentModelRegistry, q as OptimisticLockError, r as sanitizeArg, rt as RelationshipNotFoundError, s as getMigrationStatus, st as matchesScope, t as createForwardingLogger, tt as InvalidOperationTimestampError, u as instrumentPgPool, v as KyselyExecutionScope, w as CollectionMembershipCache, x as KyselyWriteCache, y as resolveFeatureFlags, z as documentDecisionModel } from "./forwarding-logger-CnuVDVze.js";
1
+ import { $ as OptimisticLockError, A as DEFAULT_DEFERRED_JOB_TTL_MS, B as summarizeSubmittedActions, C as SettledWatermark, D as snapshotFunctionsFor, E as parseSnapshot, F as ModuleNotFoundError, G as authDecisionModel, H as decideAtHead, I as GATED_DOCUMENT_ACTIONS, J as APPEND_CONDITION_FAILED_PREFIX, K as buildDecisionModel, L as createConsistencyToken, M as DuplicateManifestError, N as DuplicateModuleError, O as DocumentMetaCache, P as InvalidModuleError, Q as DuplicateOperationError, R as createEmptyConsistencyToken, S as ProbeSettler, T as describeWaitingSessions, U as selectDecisionModel, V as targetDocumentId, W as documentDecisionModel, X as DocumentAlreadyExistsError, Y as AppendConditionFailedError, Z as DocumentExistence, _ as KyselyExecutionScope, _t as throwIfAborted, a as DEFAULT_DRIVE_CONTAINER_TYPES, at as DocumentNotFoundError, b as KyselyWriteCache, c as runMigrations, ct as InvalidSignatureError, d as KyselyOperationStore, dt as UnsupportedStoredProtocolError, et as RevisionMismatchError, f as KyselyKeyframeStore, ft as UpgradePreconditionFailedError, g as PassthroughSigner, gt as parsePagingOptions, h as verifyActionSignature, ht as matchesScope, it as DocumentDeletedError, j as JobExecutorEventTypes, k as CollectionMembershipCache, l as createForwardingPoolInstrumentation, lt as RelationshipNotFoundError, m as SimpleJobExecutor, mt as ReactorEventTypes, n as errorToInfo, nt as AuthTimestampNotMonotonicError, o as REACTOR_SCHEMA, ot as ExcessiveReshuffleError, p as DocumentModelRegistry, pt as EventBusAggregateError, q as DriveCollectionId, r as sanitizeArg, rt as AuthorizationDeniedError, s as getMigrationStatus, st as InvalidOperationTimestampError, t as createForwardingLogger, tt as AuthEnforcementDisabledError, u as instrumentPgPool, ut as UnsupportedProtocolVersionError, v as resolveFeatureFlags, w as createKyselyWatermarkProbe, x as KyselyOperationIndex, y as EventBus, yt as __exportAll, z as submittedActionIds } from "./forwarding-logger-BkwlvD9W.js";
2
2
  import { i as WorkerInitFailedError, r as WorkerExitedError, t as WorkerAbortTimeoutError } from "./errors-BM_ULVWd.js";
3
- import { a as KyselyDocumentView, c as ReadModelCoordinator, d as DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, f as defaultReadModelIndexingConfig, i as DeletedDocumentRead, l as BaseReadModel, n as ConsistencyTracker, o as DOCUMENT_INDEXER_READ_MODEL, p as unchunkedReadModelIndexingConfig, r as makeConsistencyKey, s as DOCUMENT_VIEW_READ_MODEL, t as KyselyDocumentIndexer, u as DEFAULT_COMMIT_CHUNK_SIZE } from "./document-indexer-BVTGbEB0.js";
4
- import { DEFAULT_SIGNATURE_POLICY, DowngradeNotSupportedError, MAX_AUTH_GRANTS, UnsupportedDocumentModelVersionError, actionSignerIdentity, actionSigningTarget, actions, actions as documentActions, createCopyHeader, decide, deserializeSignature, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, requestedSignaturePolicy, sortOperations, toTransportAction, withSignaturePolicy } from "@powerhousedao/shared/document-model";
3
+ import { _ as ContiguousCursor, a as KyselyDocumentView, b as isCatchUpConsumer, c as ReadModelCoordinator, d as reserveBatch, f as BaseReadModel, g as unchunkedReadModelIndexingConfig, h as defaultReadModelIndexingConfig, i as DeletedDocumentRead, l as indexReserved, m as DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, n as ConsistencyTracker, o as DOCUMENT_INDEXER_READ_MODEL, p as DEFAULT_COMMIT_CHUNK_SIZE, r as makeConsistencyKey, s as DOCUMENT_VIEW_READ_MODEL, t as KyselyDocumentIndexer, u as releaseBatch, v as defaultCatchUpConfig, y as CatchUpScheduler } from "./document-indexer-I6i_YzsR.js";
4
+ import { DEFAULT_SIGNATURE_POLICY, DowngradeNotSupportedError, MAX_AUTH_GRANTS, PEER_CAPABILITIES, UnsupportedDocumentModelVersionError, actionSignerIdentity, actionSigningTarget, actions, actions as documentActions, canonicalJson, coversLocal, createCopyHeader, decide, deserializeSignature, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, holdReason, isOlderManifest, legacySupports, localPeerManifest, localSupports, mergePeerCapabilities, normalizeDocumentModelVersion, peerSupports, readPeerManifest, requestedSignaturePolicy, selectProtocolVersions, sortOperations, toTransportAction, withSignaturePolicy } from "@powerhousedao/shared/document-model";
5
5
  import { addFile, addFolder, copyNode, deleteNode, driveCreateDocument, generateNodesCopy, getDescendants, handleTargetNameCollisions, isFileNode, isFolderNode, moveNode, updateNode } from "@powerhousedao/shared/document-drive";
6
6
  import { v4 } from "uuid";
7
7
  import { ConsoleLogger, childLogger } from "document-model";
@@ -311,6 +311,16 @@ let JobStatus = /* @__PURE__ */ function(JobStatus) {
311
311
  * for batch execution. The back-reference is captured but never invoked
312
312
  * during construction, so the partial-`this` hazard does not apply.
313
313
  */
314
+ /** A copy keeps its source's versions; selection fills only the keys it lacks. */
315
+ function withSelectedVersions(header, selected) {
316
+ return {
317
+ documentType: header.documentType,
318
+ protocolVersions: header.protocolVersions ? {
319
+ ...selected,
320
+ ...header.protocolVersions
321
+ } : void 0
322
+ };
323
+ }
314
324
  var DriveClient = class {
315
325
  client;
316
326
  logger;
@@ -328,7 +338,10 @@ var DriveClient = class {
328
338
  name: input.global.name || "",
329
339
  icon: input.global.icon ?? null,
330
340
  nodes: []
331
- } }), requestedSignaturePolicy(input, await this.client.getCreateSignaturePolicy()), { protocolVersions: input.protocolVersions });
341
+ } }), requestedSignaturePolicy(input, await this.client.getCreateSignaturePolicy()), { protocolVersions: {
342
+ ...await this.client.getCreateProtocolVersions(void 0, signal),
343
+ ...input.protocolVersions
344
+ } });
332
345
  if (input.preferredEditor) driveDoc.header.meta = {
333
346
  ...driveDoc.header.meta,
334
347
  preferredEditor: input.preferredEditor
@@ -468,13 +481,14 @@ var DriveClient = class {
468
481
  resolvedNamesByTargetId.set(entry.targetId, resolved);
469
482
  }
470
483
  const policy = await this.client.getCreateSignaturePolicy();
484
+ const base = await this.client.getCreateProtocolVersions(drive.header.id, signal);
471
485
  for (const entry of copyPlan) {
472
486
  const node = drive.state.global.nodes.find((n) => n.id === entry.srcId);
473
487
  if (!node || !isFileNode(node)) continue;
474
488
  const srcDoc = await this.client.get(entry.srcId, void 0, signal);
475
489
  const duplicated = {
476
490
  ...srcDoc,
477
- header: createCopyHeader(srcDoc.header, entry.targetId, policy),
491
+ header: createCopyHeader(withSelectedVersions(srcDoc.header, base), entry.targetId, policy),
478
492
  initialState: srcDoc.state,
479
493
  operations: {}
480
494
  };
@@ -1242,8 +1256,14 @@ var ReactorClient = class {
1242
1256
  eventReads;
1243
1257
  actionEvaluation;
1244
1258
  createSignaturePolicy;
1259
+ protocolSelection;
1245
1260
  drives;
1246
- constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate(), actionEvaluation, createSignaturePolicy = DEFAULT_SIGNATURE_POLICY) {
1261
+ constructor(logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, readGate = new BareReadGate(), actionEvaluation, createSignaturePolicy = DEFAULT_SIGNATURE_POLICY, protocolSelection = {
1262
+ capabilities: PEER_CAPABILITIES,
1263
+ flags: {},
1264
+ collectionsOf: () => Promise.resolve({})
1265
+ }) {
1266
+ this.protocolSelection = protocolSelection;
1247
1267
  this.logger = logger;
1248
1268
  this.reactor = reactor;
1249
1269
  this.signer = signer;
@@ -1477,6 +1497,27 @@ var ReactorClient = class {
1477
1497
  getCreateSignaturePolicy() {
1478
1498
  return Promise.resolve(this.createSignaturePolicy);
1479
1499
  }
1500
+ async getCreateProtocolVersions(parentIdentifier, signal) {
1501
+ const { capabilities, flags, agreement, collectionsOf } = this.protocolSelection;
1502
+ let members = [];
1503
+ if (parentIdentifier !== void 0 && agreement) {
1504
+ let parentId;
1505
+ try {
1506
+ parentId = await this.resolveIdOrSlug(parentIdentifier, void 0, signal);
1507
+ } catch {
1508
+ parentId = void 0;
1509
+ }
1510
+ if (parentId !== void 0) {
1511
+ const collectionIds = [...(await collectionsOf([parentId]))[parentId] ?? [], DriveCollectionId.forDrive(parentId).key];
1512
+ members = [...agreement().members(collectionIds).values()];
1513
+ }
1514
+ }
1515
+ return selectProtocolVersions({
1516
+ capabilities,
1517
+ flags,
1518
+ members
1519
+ });
1520
+ }
1480
1521
  /**
1481
1522
  * Creates a document and waits for completion
1482
1523
  */
@@ -1557,7 +1598,11 @@ var ReactorClient = class {
1557
1598
  }, void 0);
1558
1599
  if (!module) throw new Error(`Document model not found for type: ${documentModelType}`);
1559
1600
  }
1560
- const document = withSignaturePolicy(module.utils.createDocument(), requestedSignaturePolicy(options, this.createSignaturePolicy), { protocolVersions: options?.protocolVersions });
1601
+ const base = await this.getCreateProtocolVersions(options?.parentIdentifier, signal);
1602
+ const document = withSignaturePolicy(module.utils.createDocument(), requestedSignaturePolicy(options, this.createSignaturePolicy), { protocolVersions: {
1603
+ ...base,
1604
+ ...options?.protocolVersions
1605
+ } });
1561
1606
  document.state.document.version = normalizeDocumentModelVersion(module.version);
1562
1607
  return this.create(document, options?.parentIdentifier, signal);
1563
1608
  }
@@ -2189,7 +2234,7 @@ var JobResultHandler = class {
2189
2234
  callbacks.deferJob(result.error.documentId, handle.job);
2190
2235
  return;
2191
2236
  }
2192
- if (result.error && (DocumentDeletedError.isError(result.error) || DocumentAlreadyExistsError.isError(result.error) || DocumentNotFoundError.isError(result.error) || AuthorizationDeniedError.isError(result.error) || AuthTimestampNotMonotonicError.isError(result.error) || InvalidOperationTimestampError.isError(result.error) || ExcessiveReshuffleError.isError(result.error) || InvalidSignatureError.isError(result.error) || UpgradePreconditionFailedError.isError(result.error))) {
2237
+ if (result.error && (DocumentDeletedError.isError(result.error) || DocumentAlreadyExistsError.isError(result.error) || DocumentNotFoundError.isError(result.error) || AuthorizationDeniedError.isError(result.error) || AuthTimestampNotMonotonicError.isError(result.error) || InvalidOperationTimestampError.isError(result.error) || ExcessiveReshuffleError.isError(result.error) || InvalidSignatureError.isError(result.error) || UpgradePreconditionFailedError.isError(result.error) || UnsupportedProtocolVersionError.isError(result.error))) {
2193
2238
  const errorInfo = toErrorInfo(result.error);
2194
2239
  this.jobTracker.markFailed(handle.job.id, errorInfo, handle.job);
2195
2240
  this.eventBus.emit(ReactorEventTypes.JOB_FAILED, {
@@ -2710,6 +2755,65 @@ function fillMissingMemberships(documentIds, found) {
2710
2755
  return result;
2711
2756
  }
2712
2757
  //#endregion
2758
+ //#region src/storage/kysely/stored-protocol-versions.ts
2759
+ const created = sql.raw(`scope = 'document' and "index" = 0 and (action->'input'->'protocolVersions') is not null`);
2760
+ const key = sql.raw(`md5((action->'input'->'protocolVersions')::text)`);
2761
+ /** Distinct creation protocolVersions, one index probe per value. */
2762
+ async function storedProtocolVersions(db, schema) {
2763
+ const operation = sql.id(schema, "Operation");
2764
+ return (await sql`
2765
+ with recursive hashes(hash) as (
2766
+ select (select ${key} from ${operation} where ${created} order by 1 limit 1)
2767
+ union all
2768
+ select (
2769
+ select ${key} from ${operation}
2770
+ where ${created} and ${key} > hashes.hash
2771
+ order by 1 limit 1
2772
+ )
2773
+ from hashes where hashes.hash is not null
2774
+ )
2775
+ select hash, (
2776
+ select action->'input'->'protocolVersions' from ${operation}
2777
+ where ${created} and ${key} = hashes.hash limit 1
2778
+ ) as versions
2779
+ from hashes where hash is not null
2780
+ `.execute(db)).rows;
2781
+ }
2782
+ /** Documents created with any of the protocolVersions `hashes` name. */
2783
+ async function countDocumentsCreatedWith(db, schema, hashes) {
2784
+ if (hashes.length === 0) return 0;
2785
+ const operation = sql.id(schema, "Operation");
2786
+ const result = await sql`
2787
+ select count(distinct "documentId") as documents from ${operation}
2788
+ where ${created} and ${key} in (${sql.join(hashes)})
2789
+ `.execute(db);
2790
+ return Number(result.rows[0]?.documents ?? 0);
2791
+ }
2792
+ //#endregion
2793
+ //#region src/core/stored-protocol-check.ts
2794
+ /** Refuses to run below a version the store holds, unless told read-only. */
2795
+ async function checkStoredProtocols(db, schema, support, mode, logger) {
2796
+ const unsupported = /* @__PURE__ */ new Map();
2797
+ const hashes = [];
2798
+ for (const { hash, versions } of await storedProtocolVersions(db, schema)) {
2799
+ let refused = false;
2800
+ for (const [protocol, version] of Object.entries(versions)) {
2801
+ const supported = support[protocol];
2802
+ if (supported === void 0 || supported.includes(version)) continue;
2803
+ unsupported.set(`${protocol}:${version}`, {
2804
+ protocol,
2805
+ version
2806
+ });
2807
+ refused = true;
2808
+ }
2809
+ if (refused) hashes.push(hash);
2810
+ }
2811
+ if (hashes.length === 0) return;
2812
+ const error = new UnsupportedStoredProtocolError([...unsupported.values()], await countDocumentsCreatedWith(db, schema, hashes));
2813
+ if (mode === "refuse") throw error;
2814
+ logger.warn("Starting with documents read-only: @message", error.message);
2815
+ }
2816
+ //#endregion
2713
2817
  //#region src/executor/simple-job-executor-manager.ts
2714
2818
  /**
2715
2819
  * Manages multiple job executors and coordinates job distribution.
@@ -3293,9 +3397,9 @@ var ProcessorQueue = class {
3293
3397
  }
3294
3398
  async raiseCursor(through) {
3295
3399
  const { cursor } = this.options;
3296
- if (cursor.status !== "active") return;
3297
- if (through <= cursor.lastOrdinal) return;
3298
- cursor.lastOrdinal = through;
3400
+ const capped = Math.min(through, this.options.confirmedThrough());
3401
+ if (cursor.status !== "active" || capped <= cursor.lastOrdinal) return;
3402
+ cursor.lastOrdinal = capped;
3299
3403
  await this.persist();
3300
3404
  }
3301
3405
  async parkBelow(missed) {
@@ -3327,6 +3431,8 @@ var ProcessorManager = class extends BaseReadModel {
3327
3431
  knownDrives = /* @__PURE__ */ new Map();
3328
3432
  highestRoutedOrdinal = 0;
3329
3433
  cursorCache = /* @__PURE__ */ new Map();
3434
+ persistedOrdinals = /* @__PURE__ */ new Map();
3435
+ advancing = Promise.resolve();
3330
3436
  cursorWrites = /* @__PURE__ */ new Map();
3331
3437
  draining = /* @__PURE__ */ new Map();
3332
3438
  logger;
@@ -3566,6 +3672,7 @@ var ProcessorManager = class extends BaseReadModel {
3566
3672
  floor,
3567
3673
  readSince: (ordinal) => this.operationIndex.getSinceOrdinal(ordinal),
3568
3674
  routedThrough: () => this.highWater(),
3675
+ confirmedThrough: () => this.lastOrdinal,
3569
3676
  persist: (state) => this.writeCursor(tracked, state),
3570
3677
  logger: this.logger
3571
3678
  });
@@ -3574,6 +3681,16 @@ var ProcessorManager = class extends BaseReadModel {
3574
3681
  queue
3575
3682
  };
3576
3683
  }
3684
+ /** Resolves once every queue has taken the latest cursor advance. */
3685
+ whenCursorsAdvanced() {
3686
+ return this.advancing;
3687
+ }
3688
+ /** Every bound processor may now move up to the manager's cursor. */
3689
+ onCursorAdvanced(appliedThrough) {
3690
+ const advances = [];
3691
+ for (const { queue } of this.allBound()) advances.push(queue.advance(appliedThrough));
3692
+ this.advancing = Promise.all(advances).then(() => void 0);
3693
+ }
3577
3694
  async discard(slot, disconnect) {
3578
3695
  try {
3579
3696
  await disconnect();
@@ -3599,7 +3716,10 @@ var ProcessorManager = class extends BaseReadModel {
3599
3716
  }
3600
3717
  async loadAllCursors() {
3601
3718
  const rows = await this.db.selectFrom("ProcessorCursor").selectAll().execute();
3602
- for (const row of rows) this.cursorCache.set(row.processorId, row);
3719
+ for (const row of rows) {
3720
+ this.cursorCache.set(row.processorId, row);
3721
+ this.persistedOrdinals.set(row.processorId, row.lastOrdinal);
3722
+ }
3603
3723
  }
3604
3724
  lane(processorId, write) {
3605
3725
  const next = (this.cursorWrites.get(processorId) ?? Promise.resolve()).then(write).then(() => void 0, (error) => {
@@ -3626,7 +3746,28 @@ var ProcessorManager = class extends BaseReadModel {
3626
3746
  updatedAt: now
3627
3747
  };
3628
3748
  this.cursorCache.set(row.processorId, row);
3629
- return this.lane(row.processorId, () => this.db.insertInto("ProcessorCursor").values({
3749
+ return this.lane(row.processorId, async () => {
3750
+ const expected = this.persistedOrdinals.get(row.processorId);
3751
+ if (expected === void 0) {
3752
+ await this.insertCursor(row, now);
3753
+ return;
3754
+ }
3755
+ const result = await this.db.updateTable("ProcessorCursor").set({
3756
+ lastOrdinal: row.lastOrdinal,
3757
+ status: row.status,
3758
+ lastError: row.lastError,
3759
+ lastErrorTimestamp: row.lastErrorTimestamp,
3760
+ updatedAt: now
3761
+ }).where("processorId", "=", row.processorId).where("lastOrdinal", "=", expected).executeTakeFirst();
3762
+ if (Number(result.numUpdatedRows) > 0) {
3763
+ this.persistedOrdinals.set(row.processorId, row.lastOrdinal);
3764
+ return;
3765
+ }
3766
+ await this.reconcileCursor(tracked, row, now);
3767
+ });
3768
+ }
3769
+ async insertCursor(row, now) {
3770
+ await this.db.insertInto("ProcessorCursor").values({
3630
3771
  processorId: row.processorId,
3631
3772
  factoryId: row.factoryId,
3632
3773
  driveId: row.driveId,
@@ -3642,13 +3783,29 @@ var ProcessorManager = class extends BaseReadModel {
3642
3783
  lastError: row.lastError,
3643
3784
  lastErrorTimestamp: row.lastErrorTimestamp,
3644
3785
  updatedAt: now
3645
- })).execute());
3786
+ })).execute();
3787
+ this.persistedOrdinals.set(row.processorId, row.lastOrdinal);
3788
+ }
3789
+ /** A failed compare-and-set: a lowered row resets the cursor and backfills. */
3790
+ async reconcileCursor(tracked, row, now) {
3791
+ const stored = await this.db.selectFrom("ProcessorCursor").select("lastOrdinal").where("processorId", "=", row.processorId).executeTakeFirst();
3792
+ if (stored === void 0) {
3793
+ await this.insertCursor(row, now);
3794
+ return;
3795
+ }
3796
+ this.persistedOrdinals.set(row.processorId, stored.lastOrdinal);
3797
+ if (stored.lastOrdinal >= row.lastOrdinal) return;
3798
+ this.logger.info("Processor '@ProcessorId' cursor lowered externally from @Old to @New; replaying", row.processorId, row.lastOrdinal, stored.lastOrdinal);
3799
+ tracked.lastOrdinal = stored.lastOrdinal;
3800
+ row.lastOrdinal = stored.lastOrdinal;
3801
+ for (const bound of this.allBound()) if (bound.tracked.processorId === row.processorId) bound.queue.backfill();
3646
3802
  }
3647
3803
  deleteCursors(matches) {
3648
3804
  const deletes = [];
3649
3805
  for (const [processorId, row] of this.cursorCache) {
3650
3806
  if (!matches(row)) continue;
3651
3807
  this.cursorCache.delete(processorId);
3808
+ this.persistedOrdinals.delete(processorId);
3652
3809
  deletes.push(this.lane(processorId, () => this.db.deleteFrom("ProcessorCursor").where("processorId", "=", processorId).execute()));
3653
3810
  }
3654
3811
  return deletes;
@@ -4567,6 +4724,10 @@ var SubscriptionNotificationReadModel = class {
4567
4724
  };
4568
4725
  //#endregion
4569
4726
  //#region src/core/group-reevaluation-trigger.ts
4727
+ const GROUP_REEVALUATION_TRIGGER = "group-reevaluation-trigger";
4728
+ function isMembershipChange({ operation, context }) {
4729
+ return context.documentType === groupDocumentType && context.scope === "global" && groupMembershipActionTypes.includes(operation.action.type);
4730
+ }
4570
4731
  /**
4571
4732
  * Watches committed writes for group membership changes and enqueues a
4572
4733
  * re-evaluation job for every document whose auth history references the
@@ -4578,33 +4739,49 @@ var SubscriptionNotificationReadModel = class {
4578
4739
  * skips the pass when everything the document holds sorts before it, which
4579
4740
  * keeps the common case (a membership write later than all history) free.
4580
4741
  */
4581
- var GroupReevaluationTrigger = class {
4742
+ var GroupReevaluationTrigger = class extends BaseReadModel {
4582
4743
  logger;
4583
4744
  eventBus;
4584
4745
  queue;
4585
- operationIndex;
4586
4746
  unsubscribe;
4587
- constructor(logger, eventBus, queue, operationIndex) {
4747
+ constructor(logger, eventBus, queue, operationIndex, db) {
4748
+ super(db, operationIndex, {}, new ConsistencyTracker(), {
4749
+ readModelId: GROUP_REEVALUATION_TRIGGER,
4750
+ rebuildStateOnInit: false,
4751
+ indexing: unchunkedReadModelIndexingConfig,
4752
+ startFrom: "head",
4753
+ replayStreamSuffix: false
4754
+ });
4588
4755
  this.logger = logger;
4589
4756
  this.eventBus = eventBus;
4590
4757
  this.queue = queue;
4591
- this.operationIndex = operationIndex;
4592
4758
  }
4593
- startup() {
4759
+ /** A first start begins at the watermark: it has no history to trust. */
4760
+ async startup() {
4761
+ await this.init();
4594
4762
  this.unsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_WRITE_READY, async (_type, event) => this.onWriteReady(event));
4595
4763
  }
4596
4764
  shutdown() {
4597
4765
  this.unsubscribe?.();
4598
4766
  this.unsubscribe = void 0;
4599
4767
  }
4600
- async onWriteReady(event) {
4768
+ /** Sweeps membership changes only; other operations never hold the cursor. */
4769
+ async sweep(settledThrough, _present, signal) {
4770
+ signal?.throwIfAborted();
4771
+ const rows = await this.db.selectFrom("operation_index_operations").select("ordinal").where("ordinal", ">", this.appliedThrough).where("ordinal", "<=", settledThrough).where("documentType", "=", groupDocumentType).where("scope", "=", "global").where(sql`action->>'type'`, "in", [...groupMembershipActionTypes]).orderBy("ordinal", "asc").execute();
4772
+ return super.sweep(settledThrough, rows.map((row) => Number(row.ordinal)), signal);
4773
+ }
4774
+ /** Throws when any lookup or enqueue failed, so the batch is retried. */
4775
+ async commitOperations(items) {
4601
4776
  const changed = /* @__PURE__ */ new Map();
4602
- for (const { operation, context } of event.operations) {
4603
- if (context.documentType !== groupDocumentType || context.scope !== "global" || !groupMembershipActionTypes.includes(operation.action.type)) continue;
4777
+ for (const item of items) {
4778
+ if (!isMembershipChange(item)) continue;
4779
+ const { operation, context } = item;
4604
4780
  const existing = changed.get(context.documentId);
4605
4781
  if (existing === void 0 || Date.parse(operation.timestampUtcMs) < Date.parse(existing)) changed.set(context.documentId, operation.timestampUtcMs);
4606
4782
  }
4607
4783
  if (changed.size === 0) return;
4784
+ const failures = [];
4608
4785
  const affected = /* @__PURE__ */ new Map();
4609
4786
  for (const [groupId, timestamp] of changed) {
4610
4787
  let referencers;
@@ -4612,6 +4789,7 @@ var GroupReevaluationTrigger = class {
4612
4789
  referencers = await this.operationIndex.getGroupReferencers(groupId);
4613
4790
  } catch (error) {
4614
4791
  this.logger.error("Failed to resolve referencers of group @groupId: @error", groupId, error);
4792
+ failures.push(error);
4615
4793
  continue;
4616
4794
  }
4617
4795
  for (const documentId of referencers) {
@@ -4642,8 +4820,17 @@ var GroupReevaluationTrigger = class {
4642
4820
  await this.queue.enqueue(job);
4643
4821
  } catch (error) {
4644
4822
  this.logger.error("Failed to enqueue re-evaluation of @documentId: @error", documentId, error);
4823
+ failures.push(error);
4645
4824
  }
4646
4825
  }
4826
+ if (failures.length > 0) throw new AggregateError(failures, "group re-evaluation was not enqueued for every affected document");
4827
+ }
4828
+ async onWriteReady(event) {
4829
+ const changes = event.operations.filter(isMembershipChange);
4830
+ if (changes.length === 0) return;
4831
+ try {
4832
+ await this.indexOperations(changes);
4833
+ } catch {}
4647
4834
  }
4648
4835
  };
4649
4836
  //#endregion
@@ -4689,7 +4876,9 @@ const SyncEventTypes = {
4689
4876
  SYNC_SUCCEEDED: 20002,
4690
4877
  SYNC_FAILED: 20003,
4691
4878
  DEAD_LETTER_ADDED: 20004,
4692
- CONNECTION_STATE_CHANGED: 20005
4879
+ CONNECTION_STATE_CHANGED: 20005,
4880
+ SYNC_HELD: 20006,
4881
+ SYNC_RELEASED: 20007
4693
4882
  };
4694
4883
  //#endregion
4695
4884
  //#region src/sync/mailbox.ts
@@ -5431,6 +5620,7 @@ function classifyJobFailure(errorName) {
5431
5620
  case "ExcessiveReshuffleError": return "EXCESSIVE_SHUFFLE";
5432
5621
  case "InvalidSignatureError": return "SIGNATURE_INVALID";
5433
5622
  case "HashMismatchError": return "HASH_MISMATCH";
5623
+ case "UnsupportedProtocolVersionError": return "UNSUPPORTED_PROTOCOL";
5434
5624
  default: return "UNCLASSIFIED";
5435
5625
  }
5436
5626
  }
@@ -5439,10 +5629,15 @@ function syncOperationErrorType(error) {
5439
5629
  return error?.errorType ?? classifyJobFailure(error?.error?.name ?? "Error");
5440
5630
  }
5441
5631
  /** Dead-letter types that must not stop the document syncing. */
5442
- const NON_QUARANTINING_ERROR_TYPES = /* @__PURE__ */ new Set(["AUTH_TIMESTAMP_NOT_MONOTONIC"]);
5632
+ const NON_QUARANTINING_ERROR_TYPES = /* @__PURE__ */ new Set([
5633
+ "AUTH_TIMESTAMP_NOT_MONOTONIC",
5634
+ "UNSUPPORTED_PROTOCOL",
5635
+ "PEER_PROTOCOL_UNSUPPORTED"
5636
+ ]);
5443
5637
  /**
5444
5638
  * A held auth operation must not quarantine: reconciling the two policies needs
5445
- * the traffic a quarantine would stop.
5639
+ * the traffic a quarantine would stop. A protocol refusal concerns one peer,
5640
+ * and quarantine is global to the document.
5446
5641
  */
5447
5642
  function quarantinesDocument(errorType) {
5448
5643
  return !NON_QUARANTINING_ERROR_TYPES.has(errorType);
@@ -5676,6 +5871,27 @@ const getLatestAppliedOrdinal = (syncOps) => {
5676
5871
  * the remote is known to serve them.
5677
5872
  */
5678
5873
  const DECISION_FIELDS = ["deniedReason", "errorType"];
5874
+ /** Fields peer agreement added; a remote without them is a silent peer. */
5875
+ const AGREEMENT_FIELDS = [
5876
+ "manifest",
5877
+ "manifestRevision",
5878
+ "peerManifestRevision",
5879
+ "refusals",
5880
+ "SyncRefusalInput"
5881
+ ];
5882
+ /** How often a channel whose remote went silent asks again whether it serves agreement. */
5883
+ const AGREEMENT_PROBE_INTERVAL_MS = 3e5;
5884
+ function pushMutation(withGatedUnder) {
5885
+ return withGatedUnder ? `
5886
+ mutation PushSyncEnvelopes($envelopes: [SyncEnvelopeInput!]!, $peerManifestRevision: String) {
5887
+ pushSyncEnvelopes(envelopes: $envelopes, peerManifestRevision: $peerManifestRevision)
5888
+ }
5889
+ ` : `
5890
+ mutation PushSyncEnvelopes($envelopes: [SyncEnvelopeInput!]!) {
5891
+ pushSyncEnvelopes(envelopes: $envelopes)
5892
+ }
5893
+ `;
5894
+ }
5679
5895
  /**
5680
5896
  * GraphQL-based synchronization channel for network communication between reactors.
5681
5897
  */
@@ -5706,6 +5922,16 @@ var GqlRequestChannel = class {
5706
5922
  receivingPages = false;
5707
5923
  /** Cleared for good the first time the remote rejects {@link DECISION_FIELDS}. */
5708
5924
  peerServesDecisionFields = true;
5925
+ /** Cleared when the remote rejects {@link AGREEMENT_FIELDS}; set again by a touch that carries them. */
5926
+ peerServesAgreement = true;
5927
+ agreementStoppedUtcMs = 0;
5928
+ localManifestProvider;
5929
+ /** Undefined until the first handshake; null for a silent peer. */
5930
+ peerManifest = void 0;
5931
+ peerManifestCallbacks = /* @__PURE__ */ new Set();
5932
+ manifestRefresh;
5933
+ /** Polled rows this reactor could not run, reported on the next poll. */
5934
+ pendingRefusals = /* @__PURE__ */ new Map();
5709
5935
  isRecovering = false;
5710
5936
  connectionState = "connecting";
5711
5937
  /** Latest unrecoverable error was an auth rejection; cleared on connect. */
@@ -5734,7 +5960,13 @@ var GqlRequestChannel = class {
5734
5960
  this.outbox = this.bufferedOutbox;
5735
5961
  this.deadLetter = new Mailbox();
5736
5962
  this.deadLetter.onAdded((syncOps) => {
5737
- for (const syncOp of syncOps) this.logger.warn("Dead letter added for document @DocumentId on channel @ChannelId", syncOp.documentId, this.channelId);
5963
+ for (const syncOp of syncOps) {
5964
+ this.logger.warn("Dead letter added for document @DocumentId on channel @ChannelId", syncOp.documentId, this.channelId);
5965
+ if (syncOp.error?.source === "inbox" && syncOperationErrorType(syncOp.error) === "UNSUPPORTED_PROTOCOL") this.pendingRefusals.set(`${syncOp.documentId}:${syncOp.branch}`, {
5966
+ documentId: syncOp.documentId,
5967
+ branch: syncOp.branch
5968
+ });
5969
+ }
5738
5970
  });
5739
5971
  this.outbox.onAdded((syncOps) => {
5740
5972
  if (this.isShutdown) return;
@@ -5750,13 +5982,13 @@ var GqlRequestChannel = class {
5750
5982
  this.attemptPush(syncOps);
5751
5983
  });
5752
5984
  this.outbox.onRemoved((syncOps) => {
5753
- const maxOrdinal = getLatestAppliedOrdinal(syncOps);
5754
- if (maxOrdinal > this.lastPersistedOutboxOrdinal) {
5755
- this.lastPersistedOutboxOrdinal = maxOrdinal;
5985
+ const ordinal = Math.min(getLatestAppliedOrdinal(syncOps), this.unappliedFloor() - 1);
5986
+ if (ordinal > this.lastPersistedOutboxOrdinal) {
5987
+ this.lastPersistedOutboxOrdinal = ordinal;
5756
5988
  this.cursorStorage.upsert({
5757
5989
  remoteName: this.remoteName,
5758
5990
  cursorType: "outbox",
5759
- cursorOrdinal: maxOrdinal,
5991
+ cursorOrdinal: ordinal,
5760
5992
  lastSyncedAtUtcMs: Date.now()
5761
5993
  }).catch((error) => {
5762
5994
  this.logger.error("Failed to update outbox cursor for @ChannelId! This means that future application runs may resend duplicate operations. This is recoverable (with deduplication protection), but not-optimal: @Error", this.channelId, error);
@@ -5819,6 +6051,58 @@ var GqlRequestChannel = class {
5819
6051
  notePoll() {}
5820
6052
  /** No holder, so nothing this channel reports may strand one. */
5821
6053
  lastHolderPollUtcMs() {}
6054
+ setLocalManifest(provider) {
6055
+ this.localManifestProvider = provider;
6056
+ }
6057
+ onPeerManifest(callback) {
6058
+ this.peerManifestCallbacks.add(callback);
6059
+ return () => {
6060
+ this.peerManifestCallbacks.delete(callback);
6061
+ };
6062
+ }
6063
+ async hearPeer(manifest, undelivered) {
6064
+ if (this.peerManifest !== void 0 && (this.peerManifest?.revision ?? null) === (manifest?.revision ?? null) || isOlderManifest(manifest, this.peerManifest)) return;
6065
+ this.peerManifest = manifest;
6066
+ await Promise.all([...this.peerManifestCallbacks].map(async (callback) => {
6067
+ try {
6068
+ await callback(manifest, undelivered);
6069
+ } catch (error) {
6070
+ this.logger.error("Peer manifest callback error: @Error", error);
6071
+ }
6072
+ }));
6073
+ }
6074
+ /**
6075
+ * Re-touches once when either side's manifest moved; touching is idempotent.
6076
+ * False when the refresh failed, so polled rows must not be judged yet.
6077
+ */
6078
+ async refreshManifestsIfStale(manifestRevision, peerManifestRevision) {
6079
+ if (!this.peerServesAgreement || typeof manifestRevision !== "string") return true;
6080
+ const local = this.localManifestProvider?.().revision ?? null;
6081
+ if (manifestRevision === (this.peerManifest?.revision ?? null) && (peerManifestRevision ?? null) === local) return true;
6082
+ if (this.isShutdown) return false;
6083
+ this.manifestRefresh ??= this.touchRemoteChannel().then(({ ackOrdinal }) => {
6084
+ if (ackOrdinal > 0) trimMailboxFromAckOrdinal(this.outbox, ackOrdinal);
6085
+ }).finally(() => {
6086
+ this.manifestRefresh = void 0;
6087
+ });
6088
+ try {
6089
+ await this.manifestRefresh;
6090
+ return true;
6091
+ } catch (error) {
6092
+ this.logger.error("GqlChannel @ChannelId manifest refresh failed: @Error", this.channelId, error);
6093
+ return false;
6094
+ }
6095
+ }
6096
+ /** Re-touches a silent remote in case it was upgraded. */
6097
+ async probeAgreement() {
6098
+ this.agreementStoppedUtcMs = Date.now();
6099
+ try {
6100
+ const { ackOrdinal } = await this.touchRemoteChannel();
6101
+ if (ackOrdinal > 0) trimMailboxFromAckOrdinal(this.outbox, ackOrdinal);
6102
+ } catch (error) {
6103
+ this.logger.error("GqlChannel @ChannelId agreement probe failed: @Error", this.channelId, error);
6104
+ }
6105
+ }
5822
6106
  /**
5823
6107
  * Initializes the channel by registering it on the remote server and starting polling.
5824
6108
  */
@@ -5852,6 +6136,7 @@ var GqlRequestChannel = class {
5852
6136
  */
5853
6137
  async poll() {
5854
6138
  if (this.isShutdown) return;
6139
+ if (!this.peerServesAgreement && Date.now() - this.agreementStoppedUtcMs >= AGREEMENT_PROBE_INTERVAL_MS) await this.probeAgreement();
5855
6140
  let response;
5856
6141
  try {
5857
6142
  response = await this.pollSyncEnvelopes(this.inbox.ackOrdinal, this.inbox.latestOrdinal);
@@ -5859,8 +6144,9 @@ var GqlRequestChannel = class {
5859
6144
  if (!this.handlePollError(error)) throw error;
5860
6145
  return;
5861
6146
  }
5862
- const { envelopes, ackOrdinal, deadLetters, hasMore } = response;
6147
+ const { envelopes, ackOrdinal, deadLetters, hasMore, manifestRevision, peerManifestRevision } = response;
5863
6148
  if (ackOrdinal > 0) trimMailboxFromAckOrdinal(this.outbox, ackOrdinal);
6149
+ if (!await this.refreshManifestsIfStale(manifestRevision, peerManifestRevision)) return;
5864
6150
  const allSyncOps = [];
5865
6151
  for (const envelope of envelopes) if (envelope.type.toLowerCase() === "operations" && envelope.operations) {
5866
6152
  const syncOps = envelopesToSyncOperations(envelope, this.remoteName);
@@ -5986,21 +6272,51 @@ var GqlRequestChannel = class {
5986
6272
  outboxLatest: latestOrdinal
5987
6273
  };
5988
6274
  let response;
5989
- try {
5990
- response = await this.executeGraphQL(this.pollQuery(this.peerServesDecisionFields), variables);
5991
- } catch (error) {
5992
- if (!this.rejectsDecisionFields(error)) throw error;
5993
- this.logger.warn("Remote @channelId does not serve deniedReason/errorType; polling without them. The remote is on an older schema, so it has neither to report.", this.channelId);
5994
- this.peerServesDecisionFields = false;
5995
- response = await this.executeGraphQL(this.pollQuery(false), variables);
6275
+ let refusals = [];
6276
+ for (;;) {
6277
+ const revision = this.peerServesAgreement ? this.localManifestProvider?.().revision : void 0;
6278
+ refusals = this.peerServesAgreement ? [...this.pendingRefusals.values()] : [];
6279
+ try {
6280
+ response = await this.executeGraphQL(this.pollQuery(this.peerServesDecisionFields, this.peerServesAgreement), this.peerServesAgreement ? {
6281
+ ...variables,
6282
+ manifestRevision: revision,
6283
+ refusals
6284
+ } : variables);
6285
+ break;
6286
+ } catch (error) {
6287
+ if (this.rejectsAgreementFields(error)) {
6288
+ await this.stopAgreement();
6289
+ continue;
6290
+ }
6291
+ if (!this.rejectsDecisionFields(error)) throw error;
6292
+ this.logger.warn("Remote @channelId does not serve deniedReason/errorType; polling without them. The remote is on an older schema, so it has neither to report.", this.channelId);
6293
+ this.peerServesDecisionFields = false;
6294
+ }
5996
6295
  }
6296
+ for (const refusal of refusals) this.pendingRefusals.delete(`${refusal.documentId}:${refusal.branch}`);
5997
6297
  return {
5998
6298
  envelopes: response.pollSyncEnvelopes.envelopes,
5999
6299
  ackOrdinal: response.pollSyncEnvelopes.ackOrdinal,
6000
6300
  deadLetters: response.pollSyncEnvelopes.deadLetters ?? [],
6001
- hasMore: response.pollSyncEnvelopes.hasMore
6301
+ hasMore: response.pollSyncEnvelopes.hasMore,
6302
+ manifestRevision: response.pollSyncEnvelopes.manifestRevision,
6303
+ peerManifestRevision: response.pollSyncEnvelopes.peerManifestRevision
6002
6304
  };
6003
6305
  }
6306
+ rejectsAgreementFields(error) {
6307
+ return this.peerServesAgreement && this.isAgreementRejection(error);
6308
+ }
6309
+ isAgreementRejection(error) {
6310
+ if (!(error instanceof GraphQLRequestError) || error.category !== "graphql") return false;
6311
+ return AGREEMENT_FIELDS.some((field) => error.message.includes(field));
6312
+ }
6313
+ /** Resolves once what the silent peer cannot run is held. */
6314
+ async stopAgreement(undelivered) {
6315
+ this.logger.warn("Remote @channelId does not serve peer manifests; treating it as a silent peer.", this.channelId);
6316
+ this.peerServesAgreement = false;
6317
+ this.agreementStoppedUtcMs = Date.now();
6318
+ await this.hearPeer(null, undelivered);
6319
+ }
6004
6320
  /**
6005
6321
  * True when the remote rejected the query for naming a field it does not
6006
6322
  * have. Selecting an unknown field fails validation for the whole query, so
@@ -6016,10 +6332,10 @@ var GqlRequestChannel = class {
6016
6332
  * The poll query. `withDecisionFields` selects the two fields added with the
6017
6333
  * auth projection; a remote on the previous schema is polled without them.
6018
6334
  */
6019
- pollQuery(withDecisionFields) {
6335
+ pollQuery(withDecisionFields, withAgreementFields) {
6020
6336
  return `
6021
- query PollSyncEnvelopes($channelId: String!, $outboxAck: Int!, $outboxLatest: Int!) {
6022
- pollSyncEnvelopes(channelId: $channelId, outboxAck: $outboxAck, outboxLatest: $outboxLatest) {
6337
+ query PollSyncEnvelopes($channelId: String!, $outboxAck: Int!, $outboxLatest: Int!${withAgreementFields ? ", $manifestRevision: String, $refusals: [SyncRefusalInput!]" : ""}) {
6338
+ pollSyncEnvelopes(channelId: $channelId, outboxAck: $outboxAck, outboxLatest: $outboxLatest${withAgreementFields ? ", manifestRevision: $manifestRevision, refusals: $refusals" : ""}) {
6023
6339
  envelopes {
6024
6340
  type
6025
6341
  channelMeta {
@@ -6083,6 +6399,7 @@ var GqlRequestChannel = class {
6083
6399
  operationCount
6084
6400
  }
6085
6401
  hasMore
6402
+ ${withAgreementFields ? "manifestRevision\n peerManifestRevision" : ""}
6086
6403
  }
6087
6404
  }
6088
6405
  `;
@@ -6097,27 +6414,40 @@ var GqlRequestChannel = class {
6097
6414
  const result = await this.operationIndex.getLatestTimestampForCollection(this.config.collectionId.key);
6098
6415
  if (result) sinceTimestampUtcMs = result;
6099
6416
  } catch {}
6100
- const mutation = `
6417
+ const touch = (withAgreement) => {
6418
+ const manifest = withAgreement ? this.localManifestProvider?.() : void 0;
6419
+ return this.executeGraphQL(`
6101
6420
  mutation TouchChannel($input: TouchChannelInput!) {
6102
6421
  touchChannel(input: $input) {
6103
6422
  success
6104
6423
  ackOrdinal
6424
+ ${withAgreement ? "manifest" : ""}
6105
6425
  }
6106
6426
  }
6107
- `;
6108
- const variables = { input: {
6109
- id: this.channelId,
6110
- name: this.channelId,
6111
- collectionId: this.config.collectionId.key,
6112
- filter: {
6113
- documentId: this.config.filter.documentId,
6114
- scope: this.config.filter.scope,
6115
- branch: this.config.filter.branch
6116
- },
6117
- sinceTimestampUtcMs
6118
- } };
6119
- const data = await this.executeGraphQL(mutation, variables);
6427
+ `, { input: {
6428
+ id: this.channelId,
6429
+ name: this.channelId,
6430
+ collectionId: this.config.collectionId.key,
6431
+ filter: {
6432
+ documentId: this.config.filter.documentId,
6433
+ scope: this.config.filter.scope,
6434
+ branch: this.config.filter.branch
6435
+ },
6436
+ sinceTimestampUtcMs,
6437
+ ...manifest ? { manifest } : {}
6438
+ } });
6439
+ };
6440
+ let data;
6441
+ try {
6442
+ data = await touch(true);
6443
+ this.peerServesAgreement = true;
6444
+ } catch (error) {
6445
+ if (!this.isAgreementRejection(error)) throw error;
6446
+ await this.stopAgreement();
6447
+ data = await touch(false);
6448
+ }
6120
6449
  if (!data.touchChannel.success) throw new GraphQLRequestError("touchChannel returned success=false", "graphql");
6450
+ await this.hearPeer(this.peerServesAgreement ? readPeerManifest(data.touchChannel.manifest) : null);
6121
6451
  return { ackOrdinal: data.touchChannel.ackOrdinal };
6122
6452
  }
6123
6453
  /**
@@ -6214,6 +6544,24 @@ var GqlRequestChannel = class {
6214
6544
  */
6215
6545
  async pushSyncOperations(syncOps) {
6216
6546
  for (const syncOp of syncOps) syncOp.started();
6547
+ const gatedUnder = this.peerServesAgreement ? this.peerManifest?.revision : void 0;
6548
+ try {
6549
+ await this.executeGraphQL(pushMutation(gatedUnder !== void 0), {
6550
+ envelopes: this.envelopesFor(syncOps),
6551
+ ...gatedUnder !== void 0 ? { peerManifestRevision: gatedUnder } : {}
6552
+ });
6553
+ return;
6554
+ } catch (error) {
6555
+ if (gatedUnder === void 0 || !this.rejectsAgreementFields(error)) throw error;
6556
+ }
6557
+ await this.stopAgreement(syncOps);
6558
+ const unsent = new Set(this.outbox.items);
6559
+ const remaining = syncOps.filter((syncOp) => unsent.has(syncOp));
6560
+ if (remaining.length === 0) return;
6561
+ await this.executeGraphQL(pushMutation(false), { envelopes: this.envelopesFor(remaining) });
6562
+ }
6563
+ /** One envelope per SyncOperation, with key/dependsOn for batch ordering. */
6564
+ envelopesFor(syncOps) {
6217
6565
  const jobIdToKeys = /* @__PURE__ */ new Map();
6218
6566
  const envelopes = [];
6219
6567
  for (let i = 0; i < syncOps.length; i++) {
@@ -6237,13 +6585,7 @@ var GqlRequestChannel = class {
6237
6585
  dependsOn
6238
6586
  });
6239
6587
  }
6240
- const mutation = `
6241
- mutation PushSyncEnvelopes($envelopes: [SyncEnvelopeInput!]!) {
6242
- pushSyncEnvelopes(envelopes: $envelopes)
6243
- }
6244
- `;
6245
- const variables = { envelopes: envelopes.map((e) => serializeEnvelope(e)) };
6246
- await this.executeGraphQL(mutation, variables);
6588
+ return envelopes.map((e) => serializeEnvelope(e));
6247
6589
  }
6248
6590
  /**
6249
6591
  * Gets the authorization header value using jwtHandler.
@@ -6296,6 +6638,18 @@ var GqlRequestChannel = class {
6296
6638
  get poller() {
6297
6639
  return this.pollTimer;
6298
6640
  }
6641
+ /** The lowest ordinal of an outbox item the remote has not applied. */
6642
+ unappliedFloor() {
6643
+ let floor = Number.POSITIVE_INFINITY;
6644
+ for (const syncOp of this.outbox.items) {
6645
+ if (syncOp.status === 2) continue;
6646
+ for (const op of syncOp.operations) {
6647
+ const ordinal = op.context.ordinal;
6648
+ if (ordinal > 0 && ordinal < floor) floor = ordinal;
6649
+ }
6650
+ }
6651
+ return floor;
6652
+ }
6299
6653
  };
6300
6654
  //#endregion
6301
6655
  //#region src/sync/channels/gql-request-channel-factory.ts
@@ -6469,6 +6823,11 @@ var GqlResponseChannel = class {
6469
6823
  lastHolderPollUtcMs() {
6470
6824
  return this.lastPollUtcMs;
6471
6825
  }
6826
+ /** The client announces through touchChannel, which the sync manager serves. */
6827
+ setLocalManifest(_provider) {}
6828
+ onPeerManifest(_callback) {
6829
+ return () => {};
6830
+ }
6472
6831
  async init() {
6473
6832
  const cursors = await this.cursorStorage.list(this.remoteName);
6474
6833
  const inboxOrdinal = cursors.find((c) => c.cursorType === "inbox")?.cursorOrdinal ?? 0;
@@ -6722,9 +7081,67 @@ var KyselySyncDeadLetterStorage = class {
6722
7081
  }
6723
7082
  };
6724
7083
  //#endregion
7084
+ //#region src/storage/kysely/sync-hold-storage.ts
7085
+ var KyselySyncHoldStorage = class {
7086
+ db;
7087
+ constructor(db) {
7088
+ this.db = db;
7089
+ }
7090
+ async list(filter = {}) {
7091
+ let query = this.db.selectFrom("sync_holds").selectAll();
7092
+ if (filter.remoteName !== void 0) query = query.where("remote_name", "=", filter.remoteName);
7093
+ if (filter.documentId !== void 0) query = query.where("document_id", "=", filter.documentId);
7094
+ return (await query.orderBy("held_at_utc_ms").execute()).map((row) => ({
7095
+ remoteName: row.remote_name,
7096
+ documentId: row.document_id,
7097
+ branch: row.branch,
7098
+ protocol: row.protocol,
7099
+ version: row.version,
7100
+ heldAtUtcMs: Number(row.held_at_utc_ms)
7101
+ }));
7102
+ }
7103
+ async upsert(hold) {
7104
+ const row = {
7105
+ remote_name: hold.remoteName,
7106
+ document_id: hold.documentId,
7107
+ branch: hold.branch,
7108
+ protocol: hold.protocol,
7109
+ version: hold.version,
7110
+ held_at_utc_ms: hold.heldAtUtcMs
7111
+ };
7112
+ await this.db.insertInto("sync_holds").values(row).onConflict((oc) => oc.columns([
7113
+ "remote_name",
7114
+ "document_id",
7115
+ "branch"
7116
+ ]).doUpdateSet({
7117
+ protocol: row.protocol,
7118
+ version: row.version
7119
+ })).execute();
7120
+ }
7121
+ async remove(remoteName, documentId, branch) {
7122
+ await this.db.deleteFrom("sync_holds").where("remote_name", "=", remoteName).where("document_id", "=", documentId).where("branch", "=", branch).execute();
7123
+ }
7124
+ async removeRemote(remoteName) {
7125
+ await this.db.deleteFrom("sync_holds").where("remote_name", "=", remoteName).execute();
7126
+ }
7127
+ };
7128
+ //#endregion
6725
7129
  //#region src/storage/kysely/sync-remote-storage.ts
7130
+ function rowToPeer(row) {
7131
+ if (row.peer_manifest_at_utc_ms === null) return;
7132
+ let manifest = null;
7133
+ if (row.peer_manifest !== null) try {
7134
+ manifest = readPeerManifest(JSON.parse(row.peer_manifest));
7135
+ } catch {}
7136
+ return {
7137
+ manifest,
7138
+ receivedAtUtcMs: Number(row.peer_manifest_at_utc_ms)
7139
+ };
7140
+ }
6726
7141
  function rowToRemoteRecord(row) {
7142
+ const peer = rowToPeer(row);
6727
7143
  return {
7144
+ ...peer ? { peer } : {},
6728
7145
  id: row.channel_id,
6729
7146
  name: row.name,
6730
7147
  collectionId: DriveCollectionId.fromKey(row.collection_id),
@@ -6781,7 +7198,9 @@ function remoteRecordToRow(remote) {
6781
7198
  pull_last_success_utc_ms: remote.status.pull.lastSuccessUtcMs ? new Date(remote.status.pull.lastSuccessUtcMs).toISOString() : null,
6782
7199
  pull_last_failure_utc_ms: remote.status.pull.lastFailureUtcMs ? new Date(remote.status.pull.lastFailureUtcMs).toISOString() : null,
6783
7200
  pull_failure_count: remote.status.pull.failureCount,
6784
- bound_address: remote.options.boundAddress ?? null
7201
+ bound_address: remote.options.boundAddress ?? null,
7202
+ peer_manifest: remote.peer?.manifest ? canonicalJson(remote.peer.manifest, "peer manifest") : null,
7203
+ peer_manifest_at_utc_ms: remote.peer?.receivedAtUtcMs ?? null
6785
7204
  };
6786
7205
  }
6787
7206
  var KyselySyncRemoteStorage = class {
@@ -6823,22 +7242,31 @@ var KyselySyncRemoteStorage = class {
6823
7242
  };
6824
7243
  //#endregion
6825
7244
  //#region src/sync/batch-aggregator.ts
7245
+ /** Queued between write-ready events when the settled watermark advances. */
7246
+ const SETTLED = Symbol("settled");
6826
7247
  var BatchAggregator = class {
6827
7248
  logger;
6828
7249
  driveContainerTypes;
6829
7250
  onBatchReady;
7251
+ onSettled;
6830
7252
  queue = [];
6831
7253
  processing = false;
6832
7254
  pendingBatches = /* @__PURE__ */ new Map();
6833
- constructor(logger, driveContainerTypes, onBatchReady) {
7255
+ constructor(logger, driveContainerTypes, onBatchReady, onSettled) {
6834
7256
  this.logger = logger;
6835
7257
  this.driveContainerTypes = driveContainerTypes;
6836
7258
  this.onBatchReady = onBatchReady;
7259
+ this.onSettled = onSettled;
6837
7260
  }
6838
7261
  async enqueueWriteReady(event) {
6839
7262
  this.queue.push(event);
6840
7263
  await this.processQueue();
6841
7264
  }
7265
+ /** Runs onSettled on the serial queue; one queued run covers many. */
7266
+ async enqueueSettled() {
7267
+ if (!this.queue.includes(SETTLED)) this.queue.push(SETTLED);
7268
+ await this.processQueue();
7269
+ }
6842
7270
  async handleJobFailed(event) {
6843
7271
  const batchId = event.job?.meta.batchId;
6844
7272
  if (!batchId) return;
@@ -6856,12 +7284,20 @@ var BatchAggregator = class {
6856
7284
  this.processing = true;
6857
7285
  try {
6858
7286
  while (this.queue.length > 0) {
6859
- const event = this.queue.shift();
7287
+ const item = this.queue.shift();
7288
+ if (item === SETTLED) {
7289
+ try {
7290
+ await this.onSettled();
7291
+ } catch (error) {
7292
+ this.logger.error("Failed to derive settled outboxes (@error)", error instanceof Error ? error.message : String(error));
7293
+ }
7294
+ continue;
7295
+ }
6860
7296
  try {
6861
- await this.handleWriteReady(event);
7297
+ await this.handleWriteReady(item);
6862
7298
  } catch (error) {
6863
7299
  const err = error instanceof Error ? error : new Error(String(error));
6864
- this.logger.error("Failed to process write-ready event (@jobId, @error)", event.jobId, err.message);
7300
+ this.logger.error("Failed to process write-ready event (@jobId, @error)", item.jobId, err.message);
6865
7301
  }
6866
7302
  }
6867
7303
  } finally {
@@ -6929,6 +7365,59 @@ var BatchAggregator = class {
6929
7365
  }
6930
7366
  };
6931
7367
  //#endregion
7368
+ //#region src/sync/memory-hold-storage.ts
7369
+ const keyOf = (remoteName, documentId, branch) => `${remoteName}\u0000${documentId}\u0000${branch}`;
7370
+ /** For a sync manager built without a database. */
7371
+ var InMemorySyncHoldStorage = class {
7372
+ holds = /* @__PURE__ */ new Map();
7373
+ list(filter = {}) {
7374
+ return Promise.resolve([...this.holds.values()].filter((hold) => (filter.remoteName === void 0 || hold.remoteName === filter.remoteName) && (filter.documentId === void 0 || hold.documentId === filter.documentId)));
7375
+ }
7376
+ upsert(hold) {
7377
+ const key = keyOf(hold.remoteName, hold.documentId, hold.branch);
7378
+ const known = this.holds.get(key);
7379
+ this.holds.set(key, {
7380
+ ...hold,
7381
+ heldAtUtcMs: known?.heldAtUtcMs ?? hold.heldAtUtcMs
7382
+ });
7383
+ return Promise.resolve();
7384
+ }
7385
+ remove(remoteName, documentId, branch) {
7386
+ this.holds.delete(keyOf(remoteName, documentId, branch));
7387
+ return Promise.resolve();
7388
+ }
7389
+ removeRemote(remoteName) {
7390
+ for (const [key, hold] of this.holds) if (hold.remoteName === remoteName) this.holds.delete(key);
7391
+ return Promise.resolve();
7392
+ }
7393
+ };
7394
+ //#endregion
7395
+ //#region src/sync/peer-agreement.ts
7396
+ /** Pure over the remote records it is given, live or not. */
7397
+ function createPeerAgreement(basis, remotes) {
7398
+ const supportsOf = (remote) => remote.peer?.manifest ?? basis.legacy;
7399
+ const members = (collectionIds) => {
7400
+ const wanted = new Set(collectionIds);
7401
+ const found = /* @__PURE__ */ new Map();
7402
+ for (const remote of remotes) if (wanted.has(remote.collectionId.key)) found.set(remote.name, supportsOf(remote));
7403
+ return found;
7404
+ };
7405
+ return {
7406
+ local: () => basis.local,
7407
+ basis: () => basis,
7408
+ peer: (remoteName) => {
7409
+ const remote = remotes.find((candidate) => candidate.name === remoteName);
7410
+ return remote ? supportsOf(remote) : basis.legacy;
7411
+ },
7412
+ members,
7413
+ limitedBy: (collectionId, protocol) => {
7414
+ const version = basis.wanted[protocol];
7415
+ if (version === void 0) return [];
7416
+ return [...members([collectionId])].filter(([, supports]) => !supports.protocols[protocol]?.includes(version)).map(([name]) => name);
7417
+ }
7418
+ };
7419
+ }
7420
+ //#endregion
6932
7421
  //#region src/sync/sync-awaiter.ts
6933
7422
  /**
6934
7423
  * Provides a promise-based interface for waiting on sync completion.
@@ -7160,6 +7649,12 @@ function isCredentialOrNetworkError(error) {
7160
7649
  if (isDriveAuthError(error)) return true;
7161
7650
  return error instanceof GraphQLRequestError && error.category === "network";
7162
7651
  }
7652
+ const holdKey = (documentId, branch) => `${documentId}\u0000${branch}`;
7653
+ const VERSION_CACHE_CAP = 1e4;
7654
+ /** The versions a CREATE_DOCUMENT among `operations` fixes for the document. */
7655
+ function createdVersions(operations, documentId) {
7656
+ for (const { operation, context } of operations) if (context.documentId === documentId && operation.action.type === "CREATE_DOCUMENT") return operation.action.input.protocolVersions ?? {};
7657
+ }
7163
7658
  /** Where a sync operation's run of ordinals begins. */
7164
7659
  function firstOrdinalOf(syncOp) {
7165
7660
  return syncOp.operations.length > 0 ? syncOp.operations[0].context.ordinal : 0;
@@ -7194,8 +7689,39 @@ var SyncManager = class {
7194
7689
  pruneDrainDeferred = false;
7195
7690
  removing = /* @__PURE__ */ new Set();
7196
7691
  lastEnqueuedJobIdByKey = /* @__PURE__ */ new Map();
7692
+ watermark;
7693
+ owed = /* @__PURE__ */ new Map();
7694
+ settledUnsubscribe;
7197
7695
  inboxChunkChain = Promise.resolve();
7198
- constructor(logger, remoteStorage, cursorStorage, deadLetterStorage, channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, config = {}) {
7696
+ capabilities;
7697
+ manifest;
7698
+ localSupport;
7699
+ legacy;
7700
+ wanted = {};
7701
+ holds;
7702
+ heldKeys = /* @__PURE__ */ new Map();
7703
+ records = /* @__PURE__ */ new Map();
7704
+ versionCache = /* @__PURE__ */ new Map();
7705
+ protocolVersionsOf;
7706
+ peerUpdates = /* @__PURE__ */ new Map();
7707
+ peerUnsubscribes = /* @__PURE__ */ new Map();
7708
+ constructor(logger, remoteStorage, cursorStorage, deadLetterStorage, channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, watermark, config = {}, localPeer = {
7709
+ capabilities: PEER_CAPABILITIES,
7710
+ flags: {}
7711
+ }, holds = new InMemorySyncHoldStorage()) {
7712
+ this.watermark = watermark;
7713
+ this.capabilities = localPeer.capabilities;
7714
+ this.manifest = localPeerManifest(localPeer.capabilities, localPeer.flags, localPeer.appKey, localPeer.sequence ?? Date.now());
7715
+ this.localSupport = localSupports(localPeer.capabilities, localPeer.flags);
7716
+ this.legacy = legacySupports(localPeer.capabilities);
7717
+ for (const capability of localPeer.capabilities) {
7718
+ if (capability.kind !== "protocol") continue;
7719
+ const supported = capability.supported(localPeer.flags);
7720
+ const wanted = capability.preferred?.(localPeer.flags) ?? (supported.length > 0 ? Math.max(...supported) : void 0);
7721
+ if (wanted !== void 0) this.wanted[capability.name] = wanted;
7722
+ }
7723
+ this.holds = holds;
7724
+ this.protocolVersionsOf = localPeer.protocolVersionsOf ?? ((documentId, branch) => this.indexedVersions(documentId, branch));
7199
7725
  this.logger = logger;
7200
7726
  this.remoteStorage = remoteStorage;
7201
7727
  this.cursorStorage = cursorStorage;
@@ -7212,7 +7738,7 @@ var SyncManager = class {
7212
7738
  this.awaiter = new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
7213
7739
  this.syncAwaiter = new SyncAwaiter(eventBus);
7214
7740
  this.isShutdown = false;
7215
- this.batchAggregator = new BatchAggregator(logger, driveContainerTypes, (batch) => this.processCompleteBatch(batch));
7741
+ this.batchAggregator = new BatchAggregator(logger, driveContainerTypes, (batch) => this.processCompleteBatch(batch), () => this.deriveSettled());
7216
7742
  this.syncStatusTracker = new SyncStatusTracker();
7217
7743
  }
7218
7744
  async startup() {
@@ -7223,7 +7749,13 @@ var SyncManager = class {
7223
7749
  } catch (error) {
7224
7750
  this.logger.error("Failed to load quarantined document IDs (@error)", error instanceof Error ? error.message : String(error));
7225
7751
  }
7752
+ try {
7753
+ for (const hold of await this.holds.list()) this.heldFor(hold.remoteName).set(holdKey(hold.documentId, hold.branch), hold);
7754
+ } catch (error) {
7755
+ this.logger.error("Failed to load sync holds (@error)", error instanceof Error ? error.message : String(error));
7756
+ }
7226
7757
  const remoteRecords = await this.remoteStorage.list();
7758
+ const head = await this.watermarkHead();
7227
7759
  for (const record of remoteRecords) {
7228
7760
  const channel = this.channelFactory.instance(record.id, record.name, record.channelConfig, this.cursorStorage, record.collectionId, record.filter, this.operationIndex, record.options);
7229
7761
  const remote = {
@@ -7233,11 +7765,14 @@ var SyncManager = class {
7233
7765
  collectionId: record.collectionId,
7234
7766
  channelConfig: record.channelConfig,
7235
7767
  filter: record.filter,
7236
- options: record.options
7768
+ options: record.options,
7769
+ peer: record.peer
7237
7770
  },
7238
7771
  channel
7239
7772
  };
7240
7773
  this.remotes.set(record.name, remote);
7774
+ this.owe(record.name, head);
7775
+ this.records.set(record.name, remote.meta);
7241
7776
  await this.loadDeadLetters(remote);
7242
7777
  this.wireChannelCallbacks(remote);
7243
7778
  try {
@@ -7247,6 +7782,7 @@ var SyncManager = class {
7247
7782
  await this.dropRemoteAfterFailedInit(remote, false);
7248
7783
  continue;
7249
7784
  }
7785
+ await this.peerUpdates.get(record.name);
7250
7786
  const outboxAckOrdinal = remote.channel.outbox.ackOrdinal;
7251
7787
  if (outboxAckOrdinal > 0) {
7252
7788
  const backfillController = new AbortController();
@@ -7260,6 +7796,7 @@ var SyncManager = class {
7260
7796
  });
7261
7797
  }
7262
7798
  }
7799
+ this.settledUnsubscribe = this.watermark.onAdvance(() => void this.batchAggregator.enqueueSettled());
7263
7800
  this.eventUnsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_WRITE_READY, async (_type, event) => this.batchAggregator.enqueueWriteReady(event));
7264
7801
  this.failedEventUnsubscribe = this.eventBus.subscribe(ReactorEventTypes.JOB_FAILED, async (_type, event) => this.batchAggregator.handleJobFailed(event));
7265
7802
  }
@@ -7273,6 +7810,9 @@ var SyncManager = class {
7273
7810
  this.prunePending.clear();
7274
7811
  this.pruneDrainDeferred = false;
7275
7812
  this.batchAggregator.clear();
7813
+ this.owed.clear();
7814
+ this.settledUnsubscribe?.();
7815
+ this.settledUnsubscribe = void 0;
7276
7816
  if (this.eventUnsubscribe) {
7277
7817
  this.eventUnsubscribe();
7278
7818
  this.eventUnsubscribe = void 0;
@@ -7286,6 +7826,9 @@ var SyncManager = class {
7286
7826
  this.syncStatusTracker.clear();
7287
7827
  for (const unsub of this.connectionStateUnsubscribes.values()) unsub();
7288
7828
  this.connectionStateUnsubscribes.clear();
7829
+ for (const unsub of this.peerUnsubscribes.values()) unsub();
7830
+ this.peerUnsubscribes.clear();
7831
+ this.peerUpdates.clear();
7289
7832
  const promises = [];
7290
7833
  for (const remote of this.remotes.values()) promises.push(remote.channel.shutdown());
7291
7834
  this.remotes.clear();
@@ -7312,54 +7855,316 @@ var SyncManager = class {
7312
7855
  ...remote.meta.options,
7313
7856
  boundAddress
7314
7857
  };
7315
- await this.remoteStorage.upsert({
7316
- id: remote.meta.id,
7317
- name: remote.meta.name,
7318
- collectionId: remote.meta.collectionId,
7319
- channelConfig: remote.meta.channelConfig,
7320
- filter: remote.meta.filter,
7321
- options: remote.meta.options,
7858
+ await this.remoteStorage.upsert(this.recordOf(remote.meta));
7859
+ }
7860
+ localManifest() {
7861
+ return this.manifest;
7862
+ }
7863
+ async setPeerManifest(id, manifest) {
7864
+ await this.queuePeerManifest(this.getById(id), manifest);
7865
+ }
7866
+ /** Applied in arrival order per remote; the handshake awaits the tail. */
7867
+ queuePeerManifest(remote, manifest, undelivered) {
7868
+ const name = remote.meta.name;
7869
+ const next = (this.peerUpdates.get(name) ?? Promise.resolve()).then(() => this.applyPeerManifest(remote, manifest, undelivered));
7870
+ const settled = next.catch((error) => {
7871
+ this.logger.error("Failed to record the peer manifest of @name: @error", name, error instanceof Error ? error.message : String(error));
7872
+ });
7873
+ this.peerUpdates.set(name, settled);
7874
+ return next;
7875
+ }
7876
+ async applyPeerManifest(remote, manifest, undelivered) {
7877
+ const name = remote.meta.name;
7878
+ if (this.remotes.get(name) !== remote || this.removing.has(name)) return;
7879
+ const known = remote.meta.peer;
7880
+ if (isOlderManifest(manifest, known?.manifest)) return;
7881
+ if (known !== void 0 && (known.manifest?.revision ?? null) === (manifest?.revision ?? null)) {
7882
+ if (this.heldKeys.get(name)?.size) await this.releaseSupported(remote);
7883
+ return;
7884
+ }
7885
+ remote.meta.peer = {
7886
+ manifest,
7887
+ receivedAtUtcMs: Date.now()
7888
+ };
7889
+ await this.remoteStorage.upsert(this.recordOf(remote.meta));
7890
+ await this.holdUnsupported(remote, undelivered);
7891
+ await this.releaseSupported(remote);
7892
+ }
7893
+ async listHolds(filter = {}) {
7894
+ return (await this.holds.list(filter)).map((record) => ({
7895
+ remoteName: record.remoteName,
7896
+ documentId: record.documentId,
7897
+ branch: record.branch,
7898
+ reason: {
7899
+ protocol: record.protocol,
7900
+ version: record.version,
7901
+ peerSupports: this.agreement().peer(record.remoteName).protocols[record.protocol] ?? []
7902
+ },
7903
+ heldAtUtcMs: record.heldAtUtcMs
7904
+ }));
7905
+ }
7906
+ agreement() {
7907
+ return createPeerAgreement({
7908
+ local: this.manifest,
7909
+ legacy: this.legacy,
7910
+ wanted: this.wanted
7911
+ }, [...this.records.values()]);
7912
+ }
7913
+ heldFor(remoteName) {
7914
+ let held = this.heldKeys.get(remoteName);
7915
+ if (!held) {
7916
+ held = /* @__PURE__ */ new Map();
7917
+ this.heldKeys.set(remoteName, held);
7918
+ }
7919
+ return held;
7920
+ }
7921
+ async forgetRemote(name) {
7922
+ this.records.delete(name);
7923
+ this.heldKeys.delete(name);
7924
+ await this.holds.removeRemote(name);
7925
+ }
7926
+ peerSupportsOf(remote) {
7927
+ return peerSupports(remote.meta.peer?.manifest ?? null, this.capabilities);
7928
+ }
7929
+ /** Cached once found: a document's versions never change. */
7930
+ async versionsOf(documentId, branch) {
7931
+ const key = holdKey(documentId, branch);
7932
+ const cached = this.versionCache.get(key);
7933
+ if (cached) return cached;
7934
+ let versions;
7935
+ try {
7936
+ versions = await this.protocolVersionsOf(documentId, branch);
7937
+ } catch {
7938
+ versions = void 0;
7939
+ }
7940
+ if (versions) {
7941
+ if (this.versionCache.size >= VERSION_CACHE_CAP) {
7942
+ const oldest = this.versionCache.keys().next().value;
7943
+ if (oldest !== void 0) this.versionCache.delete(oldest);
7944
+ }
7945
+ this.versionCache.set(key, versions);
7946
+ }
7947
+ return versions;
7948
+ }
7949
+ /** The fallback without a meta cache: the document's indexed creation. */
7950
+ async indexedVersions(documentId, branch) {
7951
+ return createdVersions((await this.operationIndex.get(documentId, {
7952
+ branch,
7953
+ scopes: ["document"]
7954
+ }, {
7955
+ cursor: "0",
7956
+ limit: 10
7957
+ })).results.map((entry) => toOperationWithContext(entry)), documentId);
7958
+ }
7959
+ async hold(remote, documentId, branch, reason) {
7960
+ const held = this.heldFor(remote.meta.name);
7961
+ const key = holdKey(documentId, branch);
7962
+ if (held.has(key)) return;
7963
+ const record = {
7964
+ remoteName: remote.meta.name,
7965
+ documentId,
7966
+ branch,
7967
+ protocol: reason.protocol,
7968
+ version: reason.version,
7969
+ heldAtUtcMs: Date.now()
7970
+ };
7971
+ held.set(key, record);
7972
+ try {
7973
+ await this.holds.upsert(record);
7974
+ } catch (error) {
7975
+ this.logger.error("Failed to persist a sync hold (@remote, @documentId): @error", remote.meta.name, documentId, error instanceof Error ? error.message : String(error));
7976
+ }
7977
+ this.eventBus.emit(SyncEventTypes.SYNC_HELD, {
7978
+ remoteName: remote.meta.name,
7979
+ documentId,
7980
+ branch,
7981
+ reason
7982
+ }).catch(() => {});
7983
+ }
7984
+ /** Narrowed: unsent outbox items for documents the peer cannot run. */
7985
+ async holdUnsupported(remote, undelivered = []) {
7986
+ const support = this.peerSupportsOf(remote);
7987
+ if (coversLocal(support, this.localSupport, this.capabilities)) return;
7988
+ const verdicts = /* @__PURE__ */ new Map();
7989
+ const held = [];
7990
+ const unsent = new Set(undelivered);
7991
+ for (const item of remote.channel.outbox.items) {
7992
+ if ((item.status !== -1 || item.emittedCount > 0) && !unsent.has(item)) continue;
7993
+ const key = holdKey(item.documentId, item.branch);
7994
+ let reason = verdicts.get(key);
7995
+ if (reason === void 0) {
7996
+ const versions = createdVersions(item.operations, item.documentId) ?? await this.versionsOf(item.documentId, item.branch);
7997
+ reason = (versions && holdReason(support, versions, this.capabilities)) ?? null;
7998
+ verdicts.set(key, reason);
7999
+ }
8000
+ if (reason) {
8001
+ held.push(item);
8002
+ await this.hold(remote, item.documentId, item.branch, reason);
8003
+ }
8004
+ }
8005
+ if (held.length > 0) remote.channel.outbox.remove(...held);
8006
+ }
8007
+ /** Widened: held documents the peer can now run go out whole. */
8008
+ async releaseSupported(remote) {
8009
+ const held = this.heldKeys.get(remote.meta.name);
8010
+ if (!held?.size) return;
8011
+ const support = this.peerSupportsOf(remote);
8012
+ for (const [key, record] of [...held]) {
8013
+ const versions = await this.versionsOf(record.documentId, record.branch);
8014
+ if (versions && holdReason(support, versions, this.capabilities)) continue;
8015
+ held.delete(key);
8016
+ await this.backfillDocument(remote, record.documentId, record.branch);
8017
+ await this.holds.remove(record.remoteName, record.documentId, record.branch);
8018
+ this.eventBus.emit(SyncEventTypes.SYNC_RELEASED, {
8019
+ remoteName: record.remoteName,
8020
+ documentId: record.documentId,
8021
+ branch: record.branch
8022
+ }).catch(() => {});
8023
+ }
8024
+ }
8025
+ /**
8026
+ * Queues a released document's whole history. The peer never received it
8027
+ * from this remote, so sinceTimestampUtcMs does not apply; the receiver
8028
+ * dedups by action id.
8029
+ */
8030
+ async backfillDocument(remote, documentId, branch) {
8031
+ const entries = [];
8032
+ let page = await this.operationIndex.get(documentId, { branch }, void 0, this.abortController.signal);
8033
+ for (;;) {
8034
+ entries.push(...page.results);
8035
+ if (!page.next) break;
8036
+ page = await page.next();
8037
+ }
8038
+ let operations = entries.filter((entry) => entry.sourceRemote !== remote.meta.name).map((entry) => toOperationWithContext(entry));
8039
+ operations = filterOperations(operations, remote.meta.filter);
8040
+ if (operations.length === 0) return;
8041
+ operations.sort((a, b) => {
8042
+ if (a.context.scope !== b.context.scope) {
8043
+ if (a.context.scope === "document") return -1;
8044
+ if (b.context.scope === "document") return 1;
8045
+ return a.context.scope < b.context.scope ? -1 : 1;
8046
+ }
8047
+ return a.context.ordinal - b.context.ordinal;
8048
+ });
8049
+ this.emitBatches(remote, operations, "backfill", { lastJobByDoc: /* @__PURE__ */ new Map() });
8050
+ }
8051
+ /** A peer that announced support refused the document: hold it from that peer. */
8052
+ async holdRefused(remote, syncOp) {
8053
+ const versions = await this.versionsOf(syncOp.documentId, syncOp.branch) ?? {};
8054
+ const support = this.peerSupportsOf(remote);
8055
+ const protocols = this.capabilities.filter((capability) => capability.kind === "protocol" && versions[capability.name] !== void 0);
8056
+ const protocol = (protocols.find((candidate) => !candidate.baseline.includes(versions[candidate.name])) ?? protocols.at(0))?.name ?? "unknown";
8057
+ await this.hold(remote, syncOp.documentId, syncOp.branch, {
8058
+ protocol,
8059
+ version: versions[protocol] ?? 0,
8060
+ peerSupports: support.protocols[protocol] ?? []
8061
+ });
8062
+ }
8063
+ /**
8064
+ * Refuses, before load, what this reactor cannot run and what the sending
8065
+ * peer does not announce. Neither quarantines the document.
8066
+ */
8067
+ async refuseOnReceipt(remote, syncOps) {
8068
+ await this.peerUpdates.get(remote.meta.name);
8069
+ const peer = this.peerSupportsOf(remote);
8070
+ const refused = /* @__PURE__ */ new Set();
8071
+ for (const syncOp of syncOps) {
8072
+ const versions = createdVersions(syncOp.operations, syncOp.documentId) ?? await this.versionsOf(syncOp.documentId, syncOp.branch);
8073
+ if (versions && this.refuse(remote, syncOp, versions, peer)) refused.add(syncOp);
8074
+ }
8075
+ return refused;
8076
+ }
8077
+ /** Dead-letters a refused sync operation. */
8078
+ refuse(remote, syncOp, versions, peer) {
8079
+ let errorType;
8080
+ let reason = holdReason(this.localSupport, versions, this.capabilities);
8081
+ if (reason) errorType = "UNSUPPORTED_PROTOCOL";
8082
+ else {
8083
+ reason = holdReason(peer, versions, this.capabilities);
8084
+ if (!reason) return false;
8085
+ errorType = "PEER_PROTOCOL_UNSUPPORTED";
8086
+ }
8087
+ const message = errorType === "UNSUPPORTED_PROTOCOL" ? `Document ${syncOp.documentId} requires ${reason.protocol} ${reason.version}, which this reactor does not support` : `Remote ${remote.meta.name} wrote into document ${syncOp.documentId} at ${reason.protocol} ${reason.version}, which its peer does not announce`;
8088
+ syncOp.failed(new ChannelError("inbox", new Error(message), errorType));
8089
+ remote.channel.deadLetter.add(syncOp);
8090
+ remote.channel.inbox.remove(syncOp);
8091
+ return true;
8092
+ }
8093
+ /** Whether anything could be held from this remote; synchronous when not. */
8094
+ gates(remote) {
8095
+ return (this.heldKeys.get(remote.meta.name)?.size ?? 0) > 0 || !coversLocal(this.peerSupportsOf(remote), this.localSupport, this.capabilities);
8096
+ }
8097
+ /** Drops rows of documents the remote's peer cannot run, recording holds. */
8098
+ async gateOutbound(remote, operations) {
8099
+ if (operations.length === 0) return operations;
8100
+ const held = this.heldKeys.get(remote.meta.name);
8101
+ const support = this.peerSupportsOf(remote);
8102
+ const verdicts = /* @__PURE__ */ new Map();
8103
+ const kept = [];
8104
+ for (const entry of operations) {
8105
+ const { documentId, branch } = entry.context;
8106
+ const key = holdKey(documentId, branch);
8107
+ let isHeld = verdicts.get(key);
8108
+ if (isHeld === void 0) {
8109
+ isHeld = held?.has(key) ?? false;
8110
+ if (!isHeld) {
8111
+ const versions = createdVersions(operations, documentId) ?? await this.versionsOf(documentId, branch);
8112
+ const reason = versions && holdReason(support, versions, this.capabilities);
8113
+ if (reason) {
8114
+ await this.hold(remote, documentId, branch, reason);
8115
+ isHeld = true;
8116
+ }
8117
+ }
8118
+ verdicts.set(key, isHeld);
8119
+ }
8120
+ if (!isHeld) kept.push(entry);
8121
+ }
8122
+ return kept;
8123
+ }
8124
+ recordOf(meta) {
8125
+ return {
8126
+ id: meta.id,
8127
+ name: meta.name,
8128
+ collectionId: meta.collectionId,
8129
+ channelConfig: meta.channelConfig,
8130
+ filter: meta.filter,
8131
+ options: meta.options,
7322
8132
  status: {
7323
8133
  push: createIdleHealth(),
7324
8134
  pull: createIdleHealth()
7325
- }
7326
- });
8135
+ },
8136
+ peer: meta.peer
8137
+ };
7327
8138
  }
7328
8139
  async add(name, collectionId, channelConfig, filter = {
7329
8140
  documentId: [],
7330
8141
  scope: [],
7331
8142
  branch: ""
7332
- }, options = { sinceTimestampUtcMs: "0" }, id) {
8143
+ }, options = { sinceTimestampUtcMs: "0" }, id, peer) {
7333
8144
  if (this.isShutdown) throw new Error("SyncManager is shutdown and cannot add remotes");
7334
8145
  if (this.remotes.has(name)) throw new Error(`Remote with name '${name}' already exists`);
7335
8146
  this.logger.debug("Adding remote (@name, @collectionId, @channelConfig, @filter, @options, @id)", name, collectionId.key, channelConfig, filter, options, id);
7336
8147
  const remoteId = id ?? crypto.randomUUID();
7337
- const remoteRecord = {
8148
+ const meta = {
7338
8149
  id: remoteId,
7339
8150
  name,
7340
8151
  collectionId,
7341
8152
  channelConfig,
7342
8153
  filter,
7343
8154
  options,
7344
- status: {
7345
- push: createIdleHealth(),
7346
- pull: createIdleHealth()
8155
+ peer: peer === void 0 ? void 0 : {
8156
+ manifest: peer,
8157
+ receivedAtUtcMs: Date.now()
7347
8158
  }
7348
8159
  };
7349
- await this.remoteStorage.upsert(remoteRecord);
8160
+ await this.remoteStorage.upsert(this.recordOf(meta));
7350
8161
  const channel = this.channelFactory.instance(remoteId, name, channelConfig, this.cursorStorage, collectionId, filter, this.operationIndex, options);
7351
8162
  const remote = {
7352
- meta: {
7353
- id: remoteId,
7354
- name,
7355
- collectionId,
7356
- channelConfig,
7357
- filter,
7358
- options
7359
- },
8163
+ meta,
7360
8164
  channel
7361
8165
  };
7362
8166
  this.remotes.set(name, remote);
8167
+ this.records.set(name, meta);
7363
8168
  await this.loadDeadLetters(remote);
7364
8169
  this.wireChannelCallbacks(remote);
7365
8170
  try {
@@ -7368,6 +8173,8 @@ var SyncManager = class {
7368
8173
  await this.dropRemoteAfterFailedInit(remote, !isCredentialOrNetworkError(error));
7369
8174
  throw error;
7370
8175
  }
8176
+ await this.peerUpdates.get(name);
8177
+ this.owe(name, await this.watermarkHead());
7371
8178
  const backfillController = new AbortController();
7372
8179
  this.backfillAbortControllers.set(name, backfillController);
7373
8180
  this.updateOutbox(remote, 0, "backfill", backfillController.signal).catch((error) => {
@@ -7388,10 +8195,12 @@ var SyncManager = class {
7388
8195
  const remote = this.remotes.get(name);
7389
8196
  if (!remote) throw new Error(`Remote with name '${name}' does not exist`);
7390
8197
  this.removing.add(name);
8198
+ this.owed.delete(name);
7391
8199
  try {
7392
8200
  await this.teardownRemoteResources(remote);
7393
8201
  await this.remoteStorage.remove(name);
7394
8202
  await this.cursorStorage.remove(name);
8203
+ await this.forgetRemote(name);
7395
8204
  } finally {
7396
8205
  this.remotes.delete(name);
7397
8206
  this.removing.delete(name);
@@ -7409,7 +8218,10 @@ var SyncManager = class {
7409
8218
  this.removing.add(name);
7410
8219
  try {
7411
8220
  await this.teardownRemoteResources(remote);
7412
- if (removeStorageRecord) await this.remoteStorage.remove(name);
8221
+ if (removeStorageRecord) {
8222
+ await this.remoteStorage.remove(name);
8223
+ await this.forgetRemote(name);
8224
+ }
7413
8225
  } catch (error) {
7414
8226
  this.logger.error("Error tearing down remote after failed init (@name, @error)", name, error instanceof Error ? error.message : String(error));
7415
8227
  } finally {
@@ -7442,6 +8254,9 @@ var SyncManager = class {
7442
8254
  }
7443
8255
  this.evictedOutboxFloors.delete(name);
7444
8256
  this.prunePending.delete(name);
8257
+ this.peerUnsubscribes.get(name)?.();
8258
+ this.peerUnsubscribes.delete(name);
8259
+ this.peerUpdates.delete(name);
7445
8260
  }
7446
8261
  }
7447
8262
  list() {
@@ -7465,6 +8280,9 @@ var SyncManager = class {
7465
8280
  }
7466
8281
  wireChannelCallbacks(remote) {
7467
8282
  remote.channel.inbox.onAdded((syncOps) => this.handleInboxAdded(remote, syncOps));
8283
+ remote.channel.setLocalManifest?.(() => this.manifest);
8284
+ const unsubscribePeer = remote.channel.onPeerManifest?.((manifest, undelivered) => this.queuePeerManifest(remote, manifest, undelivered).catch(() => {}));
8285
+ if (unsubscribePeer) this.peerUnsubscribes.set(remote.meta.name, unsubscribePeer);
7468
8286
  this.syncStatusTracker.trackRemote(remote.meta.name, remote.channel);
7469
8287
  const unsubscribe = remote.channel.onConnectionStateChange((snapshot) => {
7470
8288
  this.eventBus.emit(SyncEventTypes.CONNECTION_STATE_CHANGED, {
@@ -7476,7 +8294,13 @@ var SyncManager = class {
7476
8294
  }).catch(() => {});
7477
8295
  });
7478
8296
  this.connectionStateUnsubscribes.set(remote.meta.name, unsubscribe);
7479
- remote.channel.deadLetter.onAdded((syncOps) => {
8297
+ remote.channel.deadLetter.onAdded((added) => {
8298
+ const refusals = added.filter((syncOp) => syncOp.error?.source === "outbox" && syncOperationErrorType(syncOp.error) === "UNSUPPORTED_PROTOCOL");
8299
+ if (refusals.length > 0) {
8300
+ remote.channel.deadLetter.remove(...refusals);
8301
+ for (const syncOp of refusals) this.holdRefused(remote, syncOp).catch(() => {});
8302
+ }
8303
+ const syncOps = added.filter((syncOp) => !refusals.includes(syncOp));
7480
8304
  for (const syncOp of syncOps) {
7481
8305
  this.logger.error("Dead letter (@remote, @documentId, @jobId, @error, @dependencies)", remote.meta.name, syncOp.documentId, syncOp.jobId, syncOp.error?.message ?? "unknown", syncOp.jobDependencies);
7482
8306
  const errorType = syncOperationErrorType(syncOp.error);
@@ -7548,12 +8372,43 @@ var SyncManager = class {
7548
8372
  for (const remote of remotes) if (!affectedRemotes.includes(remote)) affectedRemotes.push(remote);
7549
8373
  }
7550
8374
  for (const remote of affectedRemotes) trimMailboxFromBatch(remote.channel.inbox, batch);
8375
+ const through = await this.watermark.refresh();
8376
+ let batchMax = 0;
8377
+ for (const { event } of batch.entries) for (const op of event.operations) batchMax = Math.max(batchMax, op.context.ordinal);
7551
8378
  for (const remote of affectedRemotes) {
7552
8379
  if (!this.remotes.has(remote.meta.name) || this.removing.has(remote.meta.name)) continue;
7553
8380
  await this.updateOutbox(remote, remote.channel.outbox.latestOrdinal, "batch-triggered");
8381
+ if (batchMax > through) this.owe(remote.meta.name, batchMax);
7554
8382
  }
7555
8383
  await this.drainPrunes();
7556
8384
  }
8385
+ /** Derives every owed remote up to the watermark. */
8386
+ async deriveSettled() {
8387
+ if (this.isShutdown) return;
8388
+ const through = this.watermark.settledThrough;
8389
+ for (const [name, upTo] of [...this.owed]) {
8390
+ const remote = this.remotes.get(name);
8391
+ if (!remote || this.removing.has(name)) {
8392
+ this.owed.delete(name);
8393
+ continue;
8394
+ }
8395
+ await this.updateOutbox(remote, remote.channel.outbox.latestOrdinal, "batch-triggered");
8396
+ if (through >= upTo) this.owed.delete(name);
8397
+ }
8398
+ await this.drainPrunes();
8399
+ }
8400
+ owe(name, upTo) {
8401
+ this.owed.set(name, Math.max(this.owed.get(name) ?? 0, upTo));
8402
+ }
8403
+ /** The sequence head after a fresh probe; a remote is owed through it. */
8404
+ async watermarkHead() {
8405
+ try {
8406
+ await this.watermark.refresh();
8407
+ } catch (error) {
8408
+ this.logger.warn("Settled watermark probe failed; owed remotes wait for the next one: @error", error);
8409
+ }
8410
+ return this.watermark.status().head;
8411
+ }
7557
8412
  handleInboxAdded(remote, syncOps) {
7558
8413
  if (this.isShutdown) return;
7559
8414
  const eligible = syncOps.filter((op) => !this.quarantinedDocumentIds.has(op.documentId));
@@ -7584,6 +8439,7 @@ var SyncManager = class {
7584
8439
  return next;
7585
8440
  }
7586
8441
  async applyInboxJob(remote, syncOp) {
8442
+ if ((await this.refuseOnReceipt(remote, [syncOp])).size > 0) return;
7587
8443
  const operations = syncOp.operations.map((op) => op.operation);
7588
8444
  let jobInfo;
7589
8445
  try {
@@ -7620,7 +8476,14 @@ var SyncManager = class {
7620
8476
  } else syncOp.executed();
7621
8477
  remote.channel.inbox.remove(syncOp);
7622
8478
  }
7623
- async applyInboxBatch(items) {
8479
+ async applyInboxBatch(received) {
8480
+ const refused = /* @__PURE__ */ new Set();
8481
+ for (const { remote, syncOp } of received) {
8482
+ const refusals = await this.refuseOnReceipt(remote, [syncOp]);
8483
+ for (const refusal of refusals) refused.add(refusal);
8484
+ }
8485
+ const items = received.filter(({ syncOp }) => !refused.has(syncOp));
8486
+ if (items.length === 0) return;
7624
8487
  const sourceRemote = items[0].remote.meta.name;
7625
8488
  const chunkKeys = new Set(items.map(({ syncOp }) => syncOp.jobId));
7626
8489
  const jobs = items.map(({ syncOp }) => {
@@ -7841,32 +8704,35 @@ var SyncManager = class {
7841
8704
  }
7842
8705
  }
7843
8706
  }
8707
+ emitBatches(remote, operations, mode, chain) {
8708
+ if (operations.length === 0) return;
8709
+ const batches = batchOperationsByDocument(operations);
8710
+ const syncOps = [];
8711
+ for (const batch of batches) {
8712
+ const jobId = crypto.randomUUID();
8713
+ const prevJobId = chain.lastJobByDoc.get(batch.documentId);
8714
+ const deps = [];
8715
+ if (prevJobId) deps.push(prevJobId);
8716
+ if (mode === "batch-triggered" && chain.prevChainJobId && chain.prevChainJobId !== prevJobId) deps.push(chain.prevChainJobId);
8717
+ const syncOp = new SyncOperation(crypto.randomUUID(), jobId, deps, remote.meta.name, batch.documentId, [batch.scope], batch.branch, batch.operations);
8718
+ syncOps.push(syncOp);
8719
+ chain.lastJobByDoc.set(batch.documentId, jobId);
8720
+ if (mode === "batch-triggered") chain.prevChainJobId = jobId;
8721
+ }
8722
+ remote.channel.outbox.add(...syncOps);
8723
+ this.evictPastOutboxBound(remote);
8724
+ }
7844
8725
  async deriveOutbox(remote, ackOrdinal, mode, signal) {
7845
8726
  const composedSignal = signal ? AbortSignal.any([signal, this.abortController.signal]) : this.abortController.signal;
7846
8727
  const startOrdinal = this.refillOrdinal(remote, ackOrdinal);
7847
8728
  let maxOrdinal = startOrdinal;
7848
- const lastJobByDoc = /* @__PURE__ */ new Map();
7849
- let prevChainJobId;
8729
+ const chain = { lastJobByDoc: /* @__PURE__ */ new Map() };
7850
8730
  const sinceTimestamp = remote.meta.options.sinceTimestampUtcMs;
7851
- const emitBatches = (operations) => {
7852
- if (operations.length === 0) return;
7853
- const batches = batchOperationsByDocument(operations);
7854
- const syncOps = [];
7855
- for (const batch of batches) {
7856
- const jobId = crypto.randomUUID();
7857
- const prevJobId = lastJobByDoc.get(batch.documentId);
7858
- const deps = [];
7859
- if (prevJobId) deps.push(prevJobId);
7860
- if (mode === "batch-triggered" && prevChainJobId && prevChainJobId !== prevJobId) deps.push(prevChainJobId);
7861
- const syncOp = new SyncOperation(crypto.randomUUID(), jobId, deps, remote.meta.name, batch.documentId, [batch.scope], batch.branch, batch.operations);
7862
- syncOps.push(syncOp);
7863
- lastJobByDoc.set(batch.documentId, jobId);
7864
- if (mode === "batch-triggered") prevChainJobId = jobId;
7865
- }
7866
- remote.channel.outbox.add(...syncOps);
7867
- this.evictPastOutboxBound(remote);
7868
- };
7869
- let page = await this.operationIndex.find(remote.meta.collectionId.key, startOrdinal, { excludeSourceRemote: remote.meta.name }, void 0, composedSignal);
8731
+ const emitBatches = (operations) => this.emitBatches(remote, operations, mode, chain);
8732
+ let page = await this.operationIndex.find(remote.meta.collectionId.key, startOrdinal, {
8733
+ excludeSourceRemote: remote.meta.name,
8734
+ throughOrdinal: this.watermark.settledThrough
8735
+ }, void 0, composedSignal);
7870
8736
  let carry = [];
7871
8737
  let hasMore;
7872
8738
  do {
@@ -7880,6 +8746,7 @@ var SyncManager = class {
7880
8746
  if (sinceTimestamp && sinceTimestamp !== "0") operations = operations.filter((op) => op.operation.timestampUtcMs >= sinceTimestamp);
7881
8747
  operations = filterOperations(operations, remote.meta.filter);
7882
8748
  operations = operations.filter((op) => !this.quarantinedDocumentIds.has(op.context.documentId));
8749
+ if (this.gates(remote)) operations = await this.gateOutbound(remote, operations);
7883
8750
  hasMore = !!page.next;
7884
8751
  if (operations.length > 0) {
7885
8752
  const created = /* @__PURE__ */ new Set();
@@ -7914,6 +8781,7 @@ var SyncBuilder = class {
7914
8781
  remoteStorage;
7915
8782
  cursorStorage;
7916
8783
  deadLetterStorage;
8784
+ holdStorage;
7917
8785
  config = {};
7918
8786
  withChannelFactory(factory) {
7919
8787
  this.channelFactory = factory;
@@ -7931,6 +8799,10 @@ var SyncBuilder = class {
7931
8799
  this.deadLetterStorage = storage;
7932
8800
  return this;
7933
8801
  }
8802
+ withHoldStorage(storage) {
8803
+ this.holdStorage = storage;
8804
+ return this;
8805
+ }
7934
8806
  withMaxDeadLettersPerRemote(limit) {
7935
8807
  this.config.maxDeadLettersPerRemote = limit;
7936
8808
  return this;
@@ -7947,19 +8819,21 @@ var SyncBuilder = class {
7947
8819
  this.config.staleRemotePollWindowMs = windowMs;
7948
8820
  return this;
7949
8821
  }
7950
- build(reactor, logger, operationIndex, eventBus, db, driveContainerTypes) {
7951
- return this.buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes).syncManager;
8822
+ build(reactor, logger, operationIndex, eventBus, db, driveContainerTypes, watermark, localPeer) {
8823
+ return this.buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes, watermark, localPeer).syncManager;
7952
8824
  }
7953
- buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes) {
8825
+ buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes, watermark, localPeer) {
7954
8826
  if (!this.channelFactory) throw new Error("Channel factory is required");
7955
8827
  const remoteStorage = this.remoteStorage ?? new KyselySyncRemoteStorage(db);
7956
8828
  const cursorStorage = this.cursorStorage ?? new KyselySyncCursorStorage(db);
7957
8829
  const deadLetterStorage = this.deadLetterStorage ?? new KyselySyncDeadLetterStorage(db);
7958
- const syncManager = new SyncManager(logger, remoteStorage, cursorStorage, deadLetterStorage, this.channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, this.config);
8830
+ const holdStorage = this.holdStorage ?? new KyselySyncHoldStorage(db);
8831
+ const syncManager = new SyncManager(logger, remoteStorage, cursorStorage, deadLetterStorage, this.channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, watermark, this.config, localPeer, holdStorage);
7959
8832
  return {
7960
8833
  remoteStorage,
7961
8834
  cursorStorage,
7962
8835
  deadLetterStorage,
8836
+ holdStorage,
7963
8837
  channelFactory: this.channelFactory,
7964
8838
  syncManager
7965
8839
  };
@@ -8033,7 +8907,9 @@ var Reactor = class {
8033
8907
  operationStore;
8034
8908
  eventBus;
8035
8909
  executorManager;
8036
- constructor(logger, documentModelRegistry, queue, jobTracker, readModelCoordinator, features, documentView, documentIndexer, operationStore, eventBus, executorManager) {
8910
+ catchUp;
8911
+ constructor(logger, documentModelRegistry, queue, jobTracker, readModelCoordinator, features, documentView, documentIndexer, operationStore, eventBus, executorManager, catchUp) {
8912
+ this.catchUp = catchUp;
8037
8913
  this.logger = logger;
8038
8914
  this.documentModelRegistry = documentModelRegistry;
8039
8915
  this.queue = queue;
@@ -8060,6 +8936,7 @@ var Reactor = class {
8060
8936
  this.setShutdown(true);
8061
8937
  const shutdownAsync = async () => {
8062
8938
  await this.executorManager.stop(true);
8939
+ await this.catchUp?.stop();
8063
8940
  this.readModelCoordinator.stop();
8064
8941
  this.jobTracker.shutdown();
8065
8942
  };
@@ -8589,6 +9466,14 @@ var Reactor = class {
8589
9466
  };
8590
9467
  //#endregion
8591
9468
  //#region src/core/reactor-builder.ts
9469
+ /** One contiguous cursor per read model cannot serve shards that each see part of the stream. */
9470
+ function validateShardCount(shardCount) {
9471
+ if (shardCount !== 1) throw new Error(`shardCount ${shardCount} is not supported: read-side catch-up keeps one cursor per read model, so projection runs in exactly one worker (shardCount: 1)`);
9472
+ }
9473
+ /** A coordinator whose models sweep in a worker reports that status. */
9474
+ function hasCatchUpStatuses(coordinator) {
9475
+ return "catchUpStatuses" in coordinator && typeof coordinator.catchUpStatuses === "function";
9476
+ }
8592
9477
  function sameDatabaseTarget(a, b) {
8593
9478
  return a.host === b.host && a.port === b.port && a.database === b.database;
8594
9479
  }
@@ -8620,6 +9505,7 @@ var ReactorBuilder = class {
8620
9505
  executorConfig = {};
8621
9506
  writeCacheConfig;
8622
9507
  migrationStrategy = "auto";
9508
+ unsupportedStoredDocuments = "refuse";
8623
9509
  syncBuilder;
8624
9510
  eventBus;
8625
9511
  readModelCoordinator;
@@ -8642,6 +9528,8 @@ var ReactorBuilder = class {
8642
9528
  projectionShardConfig;
8643
9529
  projectionWorkerFactory;
8644
9530
  instrumentedPools = [];
9531
+ catchUpConfig = defaultCatchUpConfig;
9532
+ extraPeerCapabilities = [];
8645
9533
  withLogger(logger) {
8646
9534
  this.logger = logger;
8647
9535
  return this;
@@ -8657,6 +9545,15 @@ var ReactorBuilder = class {
8657
9545
  this.documentModelSources.push(...sources);
8658
9546
  return this;
8659
9547
  }
9548
+ /** Capabilities beyond the registry, for tests and hosts that ship their own. */
9549
+ withPeerCapabilities(extra) {
9550
+ this.extraPeerCapabilities = mergePeerCapabilities(this.extraPeerCapabilities, extra);
9551
+ return this;
9552
+ }
9553
+ /** The registry plus any capabilities added with withPeerCapabilities. */
9554
+ getPeerCapabilities() {
9555
+ return mergePeerCapabilities(PEER_CAPABILITIES, this.extraPeerCapabilities);
9556
+ }
8660
9557
  withUpgradeManifests(manifests) {
8661
9558
  this.upgradeManifests = manifests;
8662
9559
  return this;
@@ -8719,10 +9616,27 @@ var ReactorBuilder = class {
8719
9616
  this.migrationStrategy = strategy;
8720
9617
  return this;
8721
9618
  }
9619
+ /**
9620
+ * Stored documents at protocol versions this build does not run: "refuse"
9621
+ * (the default) fails buildModule; "read-only" starts with a warning, and
9622
+ * every job and received row into them is refused.
9623
+ */
9624
+ withUnsupportedStoredDocuments(mode) {
9625
+ this.unsupportedStoredDocuments = mode;
9626
+ return this;
9627
+ }
8722
9628
  withSync(syncBuilder) {
8723
9629
  this.syncBuilder = syncBuilder;
8724
9630
  return this;
8725
9631
  }
9632
+ /** Tunes the read-side catch-up sweep. */
9633
+ withCatchUp(config) {
9634
+ this.catchUpConfig = {
9635
+ ...this.catchUpConfig,
9636
+ ...config
9637
+ };
9638
+ return this;
9639
+ }
8726
9640
  withEventBus(eventBus) {
8727
9641
  this.eventBus = eventBus;
8728
9642
  return this;
@@ -8848,11 +9762,18 @@ var ReactorBuilder = class {
8848
9762
  async buildModule() {
8849
9763
  if (!this.logger) this.logger = new ConsoleLogger(["reactor"]);
8850
9764
  const featureFlags = resolveFeatureFlags(this.executorConfig.featureFlags);
9765
+ if (this.executorConfig.protocolSupport === void 0) this.executorConfig = {
9766
+ ...this.executorConfig,
9767
+ protocolSupport: localSupports(this.getPeerCapabilities(), featureFlags).protocols
9768
+ };
8851
9769
  if (this.readModelCoordinator !== void 0 && this.readModelCoordinatorFactory !== void 0) throw new Error("withReadModelCoordinator and withReadModelCoordinatorFactory are mutually exclusive; register one coordinator source");
8852
9770
  if (this.projectionShardConfig !== void 0 && this.readModelCoordinatorFactory !== void 0) throw new Error("withProjectionShards and withReadModelCoordinatorFactory are mutually exclusive; the factory owns the projection worker through its createProjectionShardManager dependency");
8853
9771
  if (this.projectionShardConfig !== void 0 && this.readModelFactories.length > 0) throw new Error("withProjectionShards does not support read models registered through withReadModelFactory; projection workers cannot receive host-only factory dependencies");
8854
9772
  if (this.projectionShardConfig !== void 0 && this.readModels.length > 0) throw new Error("withProjectionShards does not support read models registered through withReadModel; projection workers build their own read models from the shard config and would silently omit these");
8855
- if (this.projectionShardConfig !== void 0) validateBuiltInKindCoverage(this.projectionShardConfig.preReadyKinds, this.projectionShardConfig.postReadyKinds);
9773
+ if (this.projectionShardConfig !== void 0) {
9774
+ validateShardCount(this.projectionShardConfig.shardCount);
9775
+ validateBuiltInKindCoverage(this.projectionShardConfig.preReadyKinds, this.projectionShardConfig.postReadyKinds);
9776
+ }
8856
9777
  const resolvedSources = await resolveModelSources(this.documentModelSources);
8857
9778
  if (this.workerPool) {
8858
9779
  if (resolvedSources.manifest.length === 0) throw new Error("withWorkerPool requires at least one worker-importable document-model source ({ filePath } or { packageName }).");
@@ -8875,6 +9796,7 @@ var ReactorBuilder = class {
8875
9796
  const result = await runMigrations(baseDatabase, REACTOR_SCHEMA);
8876
9797
  if (!result.success && result.error) throw new Error(`Database migration failed: ${result.error.message}`);
8877
9798
  }
9799
+ await checkStoredProtocols(baseDatabase, REACTOR_SCHEMA, this.executorConfig.protocolSupport ?? {}, this.unsupportedStoredDocuments, this.logger);
8878
9800
  const database = baseDatabase.withSchema(REACTOR_SCHEMA);
8879
9801
  const operationStore = new KyselyOperationStore(database);
8880
9802
  const keyframeStore = new KyselyKeyframeStore(database);
@@ -8891,6 +9813,14 @@ var ReactorBuilder = class {
8891
9813
  const writeCache = new KyselyWriteCache(keyframeStore, operationStore, documentModelRegistry, cacheConfig);
8892
9814
  await writeCache.startup();
8893
9815
  const operationIndex = new KyselyOperationIndex(database);
9816
+ const settledWatermark = new SettledWatermark(createKyselyWatermarkProbe(database), this.logger);
9817
+ const catchUp = new CatchUpScheduler(settledWatermark, operationIndex, this.catchUpConfig, this.logger, {
9818
+ onSwept: (result, thread) => void eventBus.emit(ReactorEventTypes.CATCHUP_SWEPT, {
9819
+ ...result,
9820
+ thread
9821
+ }).catch(() => {}),
9822
+ describeSessions: (xids) => describeWaitingSessions(database, xids)
9823
+ });
8894
9824
  const documentMetaCache = new DocumentMetaCache(operationStore, { maxDocuments: 1e3 });
8895
9825
  await documentMetaCache.startup();
8896
9826
  const collectionMembershipCache = new CollectionMembershipCache(operationIndex);
@@ -8929,6 +9859,7 @@ var ReactorBuilder = class {
8929
9859
  };
8930
9860
  const documentViewConsistencyTracker = new ConsistencyTracker();
8931
9861
  const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions ? "StateAtDeletion" : "NotFound", readModelIndexing);
9862
+ documentView.attachCatchUp(settledWatermark, this.catchUpConfig.maxTrackedAboveCursor);
8932
9863
  try {
8933
9864
  await documentView.init();
8934
9865
  } catch (error) {
@@ -8936,6 +9867,7 @@ var ReactorBuilder = class {
8936
9867
  }
8937
9868
  const documentIndexerConsistencyTracker = new ConsistencyTracker();
8938
9869
  const documentIndexer = new KyselyDocumentIndexer(database, operationIndex, writeCache, documentIndexerConsistencyTracker, readModelIndexing);
9870
+ documentIndexer.attachCatchUp(settledWatermark, this.catchUpConfig.maxTrackedAboveCursor);
8939
9871
  try {
8940
9872
  await documentIndexer.init();
8941
9873
  } catch (error) {
@@ -8945,6 +9877,7 @@ var ReactorBuilder = class {
8945
9877
  const subscriptionNotificationReadModel = new SubscriptionNotificationReadModel(subscriptionManager, documentView);
8946
9878
  const processorManagerConsistencyTracker = new ConsistencyTracker();
8947
9879
  const processorManager = new ProcessorManager(database, operationIndex, writeCache, processorManagerConsistencyTracker, this.logger, this.driveContainerTypes, { legacyProcessorIds: this.features.legacyProcessorIds !== false });
9880
+ processorManager.attachCatchUp(settledWatermark, this.catchUpConfig.maxTrackedAboveCursor);
8948
9881
  try {
8949
9882
  await processorManager.init();
8950
9883
  } catch (error) {
@@ -8981,20 +9914,38 @@ var ReactorBuilder = class {
8981
9914
  createProjectionShardManager: (config) => this.createProjectionShardManager(config, eventBus, hostTrackers, readModelIndexing, false),
8982
9915
  registerShutdownHook: (hook) => this.shutdownHooks.push(hook)
8983
9916
  }) : this.projectionShardConfig ? await this.createProjectionShardManager(this.projectionShardConfig, eventBus, hostTrackers, readModelIndexing, true) : new ReadModelCoordinator(eventBus, readModelInstances, [subscriptionNotificationReadModel, processorManager]);
8984
- const reactor = new Reactor(this.logger, documentModelRegistry, queue, jobTracker, readModelCoordinator, this.features, documentView, documentIndexer, operationStore, eventBus, executorManager);
9917
+ if (hasCatchUpStatuses(readModelCoordinator)) catchUp.addStatusSource(() => readModelCoordinator.catchUpStatuses().flatMap((status) => status.consumers));
9918
+ const indexedReadModels = readModelCoordinator.indexedReadModels?.bind(readModelCoordinator);
9919
+ if (indexedReadModels) catchUp.addSource(() => indexedReadModels().filter((model) => isCatchUpConsumer(model)), "host");
9920
+ else this.logger.warn("The read model coordinator does not report indexedReadModels; the host runs no read-side catch-up sweep");
9921
+ const reactor = new Reactor(this.logger, documentModelRegistry, queue, jobTracker, readModelCoordinator, this.features, documentView, documentIndexer, operationStore, eventBus, executorManager, catchUp);
9922
+ const localPeer = {
9923
+ capabilities: this.getPeerCapabilities(),
9924
+ flags: featureFlags,
9925
+ appKey: this.signer?.app?.key,
9926
+ protocolVersionsOf: async (documentId, branch) => {
9927
+ try {
9928
+ return (await documentMetaCache.getDocumentMeta(documentId, branch)).protocolVersions;
9929
+ } catch {
9930
+ return;
9931
+ }
9932
+ }
9933
+ };
8985
9934
  let syncModule = void 0;
8986
9935
  if (this.channelScheme) {
8987
9936
  const factory = this.channelScheme === "connect" ? new GqlRequestChannelFactory(this.logger, this.jwtHandler, queue) : new GqlResponseChannelFactory(this.logger);
8988
- syncModule = new SyncBuilder().withChannelFactory(factory).buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes);
9937
+ syncModule = new SyncBuilder().withChannelFactory(factory).buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes, settledWatermark, localPeer);
8989
9938
  await syncModule.syncManager.startup();
8990
9939
  } else if (this.syncBuilder) {
8991
- syncModule = this.syncBuilder.buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes);
9940
+ syncModule = this.syncBuilder.buildModule(reactor, this.logger, operationIndex, eventBus, database, this.driveContainerTypes, settledWatermark, localPeer);
8992
9941
  await syncModule.syncManager.startup();
8993
9942
  }
8994
9943
  let groupReevaluationTrigger;
8995
9944
  if (featureFlags.authGroups) {
8996
- groupReevaluationTrigger = new GroupReevaluationTrigger(this.logger, eventBus, queue, operationIndex);
8997
- groupReevaluationTrigger.startup();
9945
+ groupReevaluationTrigger = new GroupReevaluationTrigger(this.logger, eventBus, queue, operationIndex, database);
9946
+ groupReevaluationTrigger.attachCatchUp(settledWatermark, this.catchUpConfig.maxTrackedAboveCursor);
9947
+ await groupReevaluationTrigger.startup();
9948
+ catchUp.addConsumer(groupReevaluationTrigger, "host");
8998
9949
  }
8999
9950
  const module = {
9000
9951
  eventBus,
@@ -9020,8 +9971,11 @@ var ReactorBuilder = class {
9020
9971
  reactor,
9021
9972
  groupReevaluationTrigger,
9022
9973
  pools: this.instrumentedPools,
9023
- degradedComponents
9974
+ degradedComponents,
9975
+ catchUp,
9976
+ settledWatermark
9024
9977
  };
9978
+ catchUp.start();
9025
9979
  if (degradedComponents.length > 0) this.logger.warn("Reactor started with @count degraded component(s): @components", degradedComponents.length, degradedComponents.map(({ component }) => component).join(", "));
9026
9980
  if (this.signalHandlersEnabled) this.attachSignalHandlers(module);
9027
9981
  return module;
@@ -9070,6 +10024,7 @@ var ReactorBuilder = class {
9070
10024
  if (!parentDb) throw new Error(`The projection worker db passed to ${caller} must also be the parent reactor's database, but nothing configures the parent: it would fall back to the default embedded database while the worker projects into Postgres. Pass the same { db } to withWorkerPool, or give the parent its connection with withKysely.`);
9071
10025
  if (!sameDatabaseTarget(config.db, parentDb)) throw new Error(`The projection worker db passed to ${caller} and the parent reactor database must address the same Postgres database (same host, port, and database); the parent writes operations there and the projection shards read them.`);
9072
10026
  }
10027
+ validateShardCount(config.shardCount);
9073
10028
  validateBuiltInKindCoverage(config.preReadyKinds, config.postReadyKinds);
9074
10029
  if (this.moduleOnlyModelKeys.length > 0) throw new Error(`projection workers require worker-importable sources, but these models were registered only as live modules: ${this.moduleOnlyModelKeys.join(", ")}. Provide a { filePath } or { packageName } source for each.`);
9075
10030
  const models = this.resolvedModelManifest ?? [];
@@ -9085,7 +10040,7 @@ var ReactorBuilder = class {
9085
10040
  poolInstrumentations.push(forwarder);
9086
10041
  this.instrumentedPools.push(forwarder);
9087
10042
  }
9088
- const { ProjectionShardManager } = await import("./projection-shard-manager-BpcqDtnQ.js");
10043
+ const { ProjectionShardManager } = await import("./projection-shard-manager-CJIdjsd8.js");
9089
10044
  const manager = new ProjectionShardManager({
9090
10045
  shardCount: config.shardCount,
9091
10046
  db,
@@ -9093,6 +10048,7 @@ var ReactorBuilder = class {
9093
10048
  preReadyKinds: config.preReadyKinds,
9094
10049
  postReadyKinds: config.postReadyKinds,
9095
10050
  indexing,
10051
+ catchUp: this.catchUpConfig,
9096
10052
  factory,
9097
10053
  logger: this.logger,
9098
10054
  hostBus: eventBus,
@@ -9129,7 +10085,7 @@ var ReactorBuilder = class {
9129
10085
  */
9130
10086
  async createDefaultWorkerFactory(numWorkers, db) {
9131
10087
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
9132
- import("./worker-handle-C2rLUHwv.js"),
10088
+ import("./worker-handle-DjbEQkq-.js"),
9133
10089
  import("./transport-CZDHzfvy.js"),
9134
10090
  Promise.resolve().then(() => worker_exports)
9135
10091
  ]);
@@ -9272,7 +10228,9 @@ var HybridProjectionCoordinator = class {
9272
10228
  }
9273
10229
  const enqueuedAt = performance.now();
9274
10230
  const key = this.queueKeyFor(event);
9275
- const current = (this.chains.get(key) ?? Promise.resolve()).then(() => this.runHostChain(event, enqueuedAt));
10231
+ const reservations = reserveBatch(this.indexedReadModels(), event.operations, this.logger);
10232
+ const run = () => this.runHostChain(event, enqueuedAt, reservations);
10233
+ const current = (this.chains.get(key) ?? Promise.resolve()).then(run, run);
9276
10234
  this.chains.set(key, current);
9277
10235
  current.finally(() => {
9278
10236
  if (this.chains.get(key) === current) this.chains.delete(key);
@@ -9287,6 +10245,12 @@ var HybridProjectionCoordinator = class {
9287
10245
  getChainDepth() {
9288
10246
  return this.manager.getChainDepth() + this.chains.size;
9289
10247
  }
10248
+ indexedReadModels() {
10249
+ return [...this.preReady, ...this.postReady];
10250
+ }
10251
+ catchUpStatuses() {
10252
+ return this.manager.catchUpStatuses();
10253
+ }
9290
10254
  /** Worker chains flush first, so every relayed read-ready is in `chains`. */
9291
10255
  async drain() {
9292
10256
  await this.manager.drain();
@@ -9304,11 +10268,18 @@ var HybridProjectionCoordinator = class {
9304
10268
  }
9305
10269
  await this.manager.shutdown();
9306
10270
  }
9307
- async runHostChain(event, enqueuedAt) {
10271
+ async runHostChain(event, enqueuedAt, reservations) {
10272
+ try {
10273
+ await this.runHostBatch(event, enqueuedAt, reservations);
10274
+ } finally {
10275
+ releaseBatch(reservations);
10276
+ }
10277
+ }
10278
+ async runHostBatch(event, enqueuedAt, reservations) {
9308
10279
  const chainWaitDurationMs = performance.now() - enqueuedAt;
9309
10280
  const preReadyStart = performance.now();
9310
10281
  try {
9311
- await Promise.all(this.preReady.map((readModel) => this.indexWithTiming(readModel, "pre_ready", event)));
10282
+ await Promise.all(this.preReady.map((readModel) => this.indexWithTiming(readModel, "pre_ready", event, reservations)));
9312
10283
  } catch (error) {
9313
10284
  this.logger.error("Host pre-ready read model indexing failed for job @jobId: @Error", { jobId: event.jobId }, error);
9314
10285
  }
@@ -9322,7 +10293,7 @@ var HybridProjectionCoordinator = class {
9322
10293
  const emitDurationMs = performance.now() - emitStart;
9323
10294
  const postReadyStart = performance.now();
9324
10295
  try {
9325
- await Promise.all(this.postReady.map((readModel) => this.indexWithTiming(readModel, "post_ready", event)));
10296
+ await Promise.all(this.postReady.map((readModel) => this.indexWithTiming(readModel, "post_ready", event, reservations)));
9326
10297
  } catch (error) {
9327
10298
  this.logger.error("Host post-ready read model indexing failed for job @jobId: @Error", { jobId: event.jobId }, error);
9328
10299
  }
@@ -9336,11 +10307,11 @@ var HybridProjectionCoordinator = class {
9336
10307
  postReadyDurationMs
9337
10308
  });
9338
10309
  }
9339
- async indexWithTiming(readModel, stage, event) {
10310
+ async indexWithTiming(readModel, stage, event, reservations) {
9340
10311
  const start = performance.now();
9341
10312
  let success = false;
9342
10313
  try {
9343
- await readModel.indexOperations(event.operations);
10314
+ await indexReserved(readModel, event.operations, reservations);
9344
10315
  success = true;
9345
10316
  } finally {
9346
10317
  this.emitReadModelIndexed({
@@ -9521,6 +10492,17 @@ var ReactorClientBuilder = class {
9521
10492
  flags: reactorModule.featureFlags
9522
10493
  } : void 0;
9523
10494
  }
10495
+ /** Selection reads the sync manager's agreement; without a module, none. */
10496
+ resolveProtocolSelection(reactorModule) {
10497
+ if (!reactorModule || !this.reactorBuilder) return;
10498
+ const syncManager = reactorModule.syncModule?.syncManager;
10499
+ return {
10500
+ capabilities: this.reactorBuilder.getPeerCapabilities(),
10501
+ flags: reactorModule.featureFlags,
10502
+ agreement: syncManager ? () => syncManager.agreement() : void 0,
10503
+ collectionsOf: (documentIds) => reactorModule.operationIndex.getCollectionsForDocuments(documentIds)
10504
+ };
10505
+ }
9524
10506
  async build() {
9525
10507
  return (await this.buildModule()).client;
9526
10508
  }
@@ -9552,7 +10534,7 @@ var ReactorClientBuilder = class {
9552
10534
  const jobAwaiter = this.jobAwaiter ?? new JobAwaiter(eventBus, (jobId, signal) => reactor.getJobStatus(jobId, signal));
9553
10535
  const decisionModel = reactorModule ? readDecisionModel(reactorModule.featureFlags, reactorModule.documentModelRegistry) : void 0;
9554
10536
  return {
9555
- client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView, decisionModel), this.resolveActionEvaluation(reactorModule, decisionModel), this.createSignaturePolicy),
10537
+ client: new ReactorClient(this.logger, reactor, signer, subscriptionManager, jobAwaiter, documentIndexer, documentView, this.readGate ?? this.resolveReadGate(reactorModule, documentView, decisionModel), this.resolveActionEvaluation(reactorModule, decisionModel), this.createSignaturePolicy, this.resolveProtocolSelection(reactorModule)),
9556
10538
  reactor,
9557
10539
  eventBus,
9558
10540
  documentIndexer,
@@ -9852,6 +10834,6 @@ var DocumentIntegrityService = class {
9852
10834
  }
9853
10835
  };
9854
10836
  //#endregion
9855
- export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, BareReadGate, BaseReadModel, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, DEFAULT_COMMIT_CHUNK_SIZE, DEFAULT_DRIVE_CONTAINER_TYPES, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, DOCUMENT_INDEXER_READ_MODEL, DOCUMENT_VIEW_READ_MODEL, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, DeletedDocumentRead, DocumentAlreadyExistsError, DocumentChangeType, DocumentExistence, DocumentIntegrityService, DocumentModelRegistry, DocumentModelResolver, DriveClient, DriveCollectionId, DriveRequestError, DuplicateManifestError, DuplicateModuleError, DuplicateOperationError, EventBus, EventBusAggregateError, GqlRequestChannel, GqlRequestChannelFactory, GqlResponseChannel, GqlResponseChannelFactory, HybridProjectionCoordinator, SimpleJobExecutor as InMemoryJobExecutor, SimpleJobExecutor, InMemoryJobTracker, InMemoryQueue, IntervalPollTimer, InvalidModuleError, InvalidSignatureError, JobAwaiter, JobExecutorEventTypes, JobStatus, KyselyDocumentIndexer, KyselyDocumentView, KyselyKeyframeStore, KyselyOperationStore, KyselySyncCursorStorage, KyselySyncRemoteStorage, KyselyWriteCache, Mailbox, ModelReadGate, ModuleNotFoundError, NullDocumentModelResolver, OptimisticLockError, PollBehavior, PollingChannelError, ProcessorManager, PropagationMode, QueueEventTypes, REACTOR_SCHEMA, RECOVERABLE_GRAPHQL_ERROR_CODES, Reactor, ReactorBuilder, ReactorClient, ReactorClientBuilder, ReactorEventTypes, ReactorSubscriptionManager, ReadModelCoordinator, RelationalDbProcessor, RelationshipChangeType, RelationshipNotFoundError, RetryAccounting, RevisionMismatchError, SIGNATURE_REFUSAL_CODES, SeededStateReader, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncScopeGate, SyncStatus, SyncStatusTracker, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createHybridProjectionCoordinatorFactory, createMutableShutdownStatus, createReactorHostModuleBase, createRelationalDb, decideAtHead, defaultReadModelIndexingConfig, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isDriveAuthError, isRecoverableGraphQLError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, unchunkedReadModelIndexingConfig, updateRelationshipAction, upgradeDocumentAction, verifyActionSignature, workerEntryPath };
10837
+ export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, BareReadGate, BaseReadModel, CatchUpScheduler, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, ContiguousCursor, DEFAULT_COMMIT_CHUNK_SIZE, DEFAULT_DRIVE_CONTAINER_TYPES, DEFAULT_READ_MODEL_YIELD_DEADLINE_MS, DOCUMENT_INDEXER_READ_MODEL, DOCUMENT_VIEW_READ_MODEL, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, DeletedDocumentRead, DocumentAlreadyExistsError, DocumentChangeType, DocumentExistence, DocumentIntegrityService, DocumentModelRegistry, DocumentModelResolver, DriveClient, DriveCollectionId, DriveRequestError, DuplicateManifestError, DuplicateModuleError, DuplicateOperationError, EventBus, EventBusAggregateError, GqlRequestChannel, GqlRequestChannelFactory, GqlResponseChannel, GqlResponseChannelFactory, HybridProjectionCoordinator, SimpleJobExecutor as InMemoryJobExecutor, SimpleJobExecutor, InMemoryJobTracker, InMemoryQueue, InMemorySyncHoldStorage, IntervalPollTimer, InvalidModuleError, InvalidSignatureError, JobAwaiter, JobExecutorEventTypes, JobStatus, KyselyDocumentIndexer, KyselyDocumentView, KyselyKeyframeStore, KyselyOperationStore, KyselySyncCursorStorage, KyselySyncHoldStorage, KyselySyncRemoteStorage, KyselyWriteCache, Mailbox, ModelReadGate, ModuleNotFoundError, NullDocumentModelResolver, OptimisticLockError, PollBehavior, PollingChannelError, ProbeSettler, ProcessorManager, PropagationMode, QueueEventTypes, REACTOR_SCHEMA, RECOVERABLE_GRAPHQL_ERROR_CODES, Reactor, ReactorBuilder, ReactorClient, ReactorClientBuilder, ReactorEventTypes, ReactorSubscriptionManager, ReadModelCoordinator, RelationalDbProcessor, RelationshipChangeType, RelationshipNotFoundError, RetryAccounting, RevisionMismatchError, SIGNATURE_REFUSAL_CODES, SeededStateReader, SettledWatermark, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncScopeGate, SyncStatus, SyncStatusTracker, UnsupportedStoredProtocolError, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createHybridProjectionCoordinatorFactory, createKyselyWatermarkProbe, createMutableShutdownStatus, createPeerAgreement, createReactorHostModuleBase, createRelationalDb, decideAtHead, defaultCatchUpConfig, defaultReadModelIndexingConfig, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isCatchUpConsumer, isDriveAuthError, isRecoverableGraphQLError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, parseSnapshot, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, snapshotFunctionsFor, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, unchunkedReadModelIndexingConfig, updateRelationshipAction, upgradeDocumentAction, verifyActionSignature, workerEntryPath };
9856
10838
 
9857
10839
  //# sourceMappingURL=index.js.map