@powerhousedao/reactor 6.2.3-dev.1 → 6.2.3-dev.10

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,9 +1,9 @@
1
- import { A as selectDecisionModel, B as RevisionMismatchError, C as InvalidModuleError, D as createEmptyConsistencyToken, E as createConsistencyToken, F as AppendConditionFailedError, G as DocumentNotFoundError, H as AuthTimestampNotMonotonicError, I as DocumentAlreadyExistsError, J as UpgradePreconditionFailedError, K as ExcessiveReshuffleError, L as DocumentExistence, M as authDecisionModel, N as buildDecisionModel, O as targetDocumentId, P as APPEND_CONDITION_FAILED_PREFIX, R as DuplicateOperationError, S as DuplicateModuleError, T as GATED_DOCUMENT_ACTIONS, U as AuthorizationDeniedError, V as AuthEnforcementDisabledError, W as DocumentDeletedError, X as parsePagingOptions, Y as matchesScope, Z as throwIfAborted, _ as DocumentMetaCache, a as createForwardingPoolInstrumentation, b as JobExecutorEventTypes, c as KyselyKeyframeStore, d as DriveCollectionId, f as KyselyExecutionScope, g as KyselyOperationIndex, h as KyselyWriteCache, i as runMigrations, j as documentDecisionModel, k as decideAtHead, l as DocumentModelRegistry, m as EventBus, n as REACTOR_SCHEMA, o as instrumentPgPool, p as resolveFeatureFlags, q as InvalidOperationTimestampError, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as SimpleJobExecutor, v as CollectionMembershipCache, w as ModuleNotFoundError, x as DuplicateManifestError, y as DEFAULT_DEFERRED_JOB_TTL_MS, z as OptimisticLockError } from "./drive-container-types-yZrksiJR.js";
1
+ import { $ as parsePagingOptions, A as targetDocumentId, B as DuplicateOperationError, C as InvalidModuleError, D as createEmptyConsistencyToken, E as createConsistencyToken, F as buildDecisionModel, G as AuthorizationDeniedError, H as RevisionMismatchError, I as APPEND_CONDITION_FAILED_PREFIX, J as ExcessiveReshuffleError, K as DocumentDeletedError, L as AppendConditionFailedError, M as selectDecisionModel, N as documentDecisionModel, O as submittedActionIds, P as authDecisionModel, Q as matchesScope, R as DocumentAlreadyExistsError, S as DuplicateModuleError, T as GATED_DOCUMENT_ACTIONS, U as AuthEnforcementDisabledError, V as OptimisticLockError, W as AuthTimestampNotMonotonicError, X as RelationshipNotFoundError, Y as InvalidOperationTimestampError, Z as UpgradePreconditionFailedError, _ as DocumentMetaCache, a as createForwardingPoolInstrumentation, b as JobExecutorEventTypes, c as KyselyKeyframeStore, d as DriveCollectionId, et as throwIfAborted, f as KyselyExecutionScope, g as KyselyOperationIndex, h as KyselyWriteCache, i as runMigrations, j as decideAtHead, k as summarizeSubmittedActions, l as DocumentModelRegistry, m as EventBus, n as REACTOR_SCHEMA, o as instrumentPgPool, p as resolveFeatureFlags, q as DocumentNotFoundError, r as getMigrationStatus, s as KyselyOperationStore, t as DEFAULT_DRIVE_CONTAINER_TYPES, u as SimpleJobExecutor, v as CollectionMembershipCache, w as ModuleNotFoundError, x as DuplicateManifestError, y as DEFAULT_DEFERRED_JOB_TTL_MS, z as DocumentExistence } from "./drive-container-types-CE7dxz0_.js";
2
2
  import { n as ReactorEventTypes, t as EventBusAggregateError } from "./types-DMKLa0Ok.js";
3
3
  import { i as WorkerInitFailedError, r as WorkerExitedError, t as WorkerAbortTimeoutError } from "./errors-D3S6Eysd.js";
4
- import { a as DOCUMENT_INDEXER_READ_MODEL, c as BaseReadModel, i as KyselyDocumentView, n as ConsistencyTracker, o as DOCUMENT_VIEW_READ_MODEL, r as makeConsistencyKey, s as ReadModelCoordinator, t as KyselyDocumentIndexer } from "./document-indexer-C5Gsa1B3.js";
4
+ import { a as DOCUMENT_INDEXER_READ_MODEL, c as BaseReadModel, i as KyselyDocumentView, n as ConsistencyTracker, o as DOCUMENT_VIEW_READ_MODEL, r as makeConsistencyKey, s as ReadModelCoordinator, t as KyselyDocumentIndexer } from "./document-indexer-BRqfWhe1.js";
5
5
  import { n as errorToInfo, r as sanitizeArg, t as createForwardingLogger } from "./forwarding-logger-BBkMSxuJ.js";
6
- import { t as workerEntryPath } from "./worker-HPysBfhx.js";
6
+ import { t as workerEntryPath } from "./worker-B12tBZmr.js";
7
7
  import { DowngradeNotSupportedError, MAX_AUTH_GRANTS, UnsupportedDocumentModelVersionError, actions, actions as documentActions, createPresignedHeader, decide, deserializeSignature, garbageCollect, generateId, groupDocumentType, groupMembershipActionTypes, hashDocumentStateForScope, normalizeDocumentModelVersion, sortOperations, toTransportAction } from "@powerhousedao/shared/document-model";
8
8
  import { addFile, addFolder, copyNode, deleteNode, driveCreateDocument, generateNodesCopy, getDescendants, handleTargetNameCollisions, isFileNode, isFolderNode, moveNode, updateNode } from "@powerhousedao/shared/document-drive";
9
9
  import { v4 } from "uuid";
@@ -1248,6 +1248,79 @@ var ReactorClient = class {
1248
1248
  return this.find({ ids: sourceIds }, view, paging, signal);
1249
1249
  }
1250
1250
  /**
1251
+ * Retrieves the outgoing relationship edges of a source document, carrying the
1252
+ * metadata and timestamps the far-end documents do not.
1253
+ */
1254
+ async getOutgoingRelationshipEdges(sourceIdentifier, relationshipType, view, paging, signal) {
1255
+ this.logger.verbose("getOutgoingRelationshipEdges(@sourceIdentifier, @relationshipType, @view, @paging)", sourceIdentifier, relationshipType, view, paging);
1256
+ const sourceId = await this.documentView.resolveIdOrSlug(sourceIdentifier, view, void 0, signal);
1257
+ const edges = await this.reactor.getOutgoingRelationshipEdges(sourceId, relationshipType, paging, void 0, signal);
1258
+ return this.gateEdges(edges, "targetId", view, signal);
1259
+ }
1260
+ /**
1261
+ * Retrieves the incoming relationship edges of a target document, carrying the
1262
+ * metadata and timestamps the far-end documents do not.
1263
+ */
1264
+ async getIncomingRelationshipEdges(targetIdentifier, relationshipType, view, paging, signal) {
1265
+ this.logger.verbose("getIncomingRelationshipEdges(@targetIdentifier, @relationshipType, @view, @paging)", targetIdentifier, relationshipType, view, paging);
1266
+ const targetId = await this.documentView.resolveIdOrSlug(targetIdentifier, view, void 0, signal);
1267
+ const edges = await this.reactor.getIncomingRelationshipEdges(targetId, relationshipType, paging, void 0, signal);
1268
+ return this.gateEdges(edges, "sourceId", view, signal);
1269
+ }
1270
+ /**
1271
+ * Drops the edges whose far-end document the subject may read no domain scope
1272
+ * of. An edge is withheld whole rather than stripped of its metadata: the
1273
+ * document-shaped relationship reads already answer with the far end stripped
1274
+ * to the scopes the gate allows, so the far end's existence is disclosed
1275
+ * either way, but an edge's metadata is content about the pair that the far
1276
+ * end's own reads would refuse. An edge to a far end stripped to nothing
1277
+ * therefore carries content past a refusal, and there is no useful shell to
1278
+ * hand back in its place.
1279
+ *
1280
+ * `nextCursor` and `options` are left as the underlying stream reported them,
1281
+ * because a caller must feed them back to resume from the right position. A
1282
+ * gated page can therefore be shorter than the limit it asked for.
1283
+ */
1284
+ async gateEdges(page, farEnd, view, signal) {
1285
+ const ids = [...new Set(page.results.map((edge) => edge[farEnd]))];
1286
+ if (ids.length === 0) return page;
1287
+ const farEndView = {
1288
+ subject: view?.subject,
1289
+ branch: view?.branch
1290
+ };
1291
+ const documents = await this.reactor.find({ ids }, farEndView, {
1292
+ cursor: "0",
1293
+ limit: ids.length
1294
+ }, void 0, signal);
1295
+ const readable = new Map(await Promise.all(documents.results.map(async (doc) => {
1296
+ const allows = await this.readableScopes(doc, view, signal);
1297
+ const domainScopes = Object.keys(doc.state).filter((scope) => !ALWAYS_READABLE_SCOPES.has(scope));
1298
+ return [doc.header.id, domainScopes.length === 0 || domainScopes.some(allows)];
1299
+ })));
1300
+ const results = page.results.filter((edge) => readable.get(edge[farEnd]) !== false);
1301
+ if (results.length === page.results.length) return page;
1302
+ const nextPage = page.next;
1303
+ return {
1304
+ ...page,
1305
+ results,
1306
+ next: nextPage ? async () => this.gateEdges(await nextPage(), farEnd, view, signal) : void 0
1307
+ };
1308
+ }
1309
+ /**
1310
+ * One relationship edge, or undefined when it does not exist. A point lookup:
1311
+ * the pair is filtered in SQL rather than scanned out of the source's edge
1312
+ * list, which on a drive with thousands of children is the difference between
1313
+ * one query and dozens.
1314
+ */
1315
+ async readRelationshipEdge(sourceIdentifier, targetIdentifier, relationshipType, view, signal) {
1316
+ const sourceId = await this.documentView.resolveIdOrSlug(sourceIdentifier, view, void 0, signal);
1317
+ const targetId = await this.documentView.resolveIdOrSlug(targetIdentifier, view, void 0, signal);
1318
+ return (await this.documentIndexer.getDirectedRelationships(sourceId, targetId, [relationshipType], {
1319
+ cursor: "0",
1320
+ limit: 1
1321
+ }, void 0, signal)).results[0];
1322
+ }
1323
+ /**
1251
1324
  * Filters documents by criteria and returns a list of them
1252
1325
  */
1253
1326
  async find(search, view, paging, signal) {
@@ -1515,9 +1588,21 @@ var ReactorClient = class {
1515
1588
  /**
1516
1589
  * Adds multiple documents as children to another and waits for completion
1517
1590
  */
1518
- async addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch = "main", signal) {
1519
- this.logger.verbose("addRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @branch)", sourceIdentifier, targetIdentifier, relationshipType, branch);
1520
- const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1591
+ async addRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch = "main", signal) {
1592
+ this.logger.verbose("addRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @metadata, @branch)", sourceIdentifier, targetIdentifier, relationshipType, metadata, branch);
1593
+ const jobInfo = await this.reactor.addRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch, this.signer, signal);
1594
+ const completedJob = await this.waitForJob(jobInfo, signal);
1595
+ if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1596
+ const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
1597
+ return this.gateDocument(result, { branch }, signal);
1598
+ }
1599
+ /**
1600
+ * Replaces the metadata of an existing relationship and waits for completion.
1601
+ */
1602
+ async updateRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch = "main", signal) {
1603
+ this.logger.verbose("updateRelationship(@sourceIdentifier, @targetIdentifier, @relationshipType, @metadata, @branch)", sourceIdentifier, targetIdentifier, relationshipType, metadata, branch);
1604
+ if (!await this.readRelationshipEdge(sourceIdentifier, targetIdentifier, relationshipType, { branch }, signal)) throw new RelationshipNotFoundError(sourceIdentifier, targetIdentifier, relationshipType);
1605
+ const jobInfo = await this.reactor.updateRelationship(sourceIdentifier, targetIdentifier, relationshipType, metadata, branch, this.signer, signal);
1521
1606
  const completedJob = await this.waitForJob(jobInfo, signal);
1522
1607
  if (completedJob.status === JobStatus.FAILED) throw new Error(completedJob.error?.message);
1523
1608
  const result = await this.reactor.getByIdOrSlug(sourceIdentifier, { branch }, completedJob.consistencyToken, signal);
@@ -1539,10 +1624,11 @@ var ReactorClient = class {
1539
1624
  */
1540
1625
  async moveRelationship(sourceParentIdentifier, targetParentIdentifier, targetIdentifier, relationshipType, branch = "main", signal) {
1541
1626
  this.logger.verbose("moveRelationship(@sourceParentIdentifier, @targetParentIdentifier, @targetIdentifier, @relationshipType, @branch)", sourceParentIdentifier, targetParentIdentifier, targetIdentifier, relationshipType, branch);
1627
+ const metadata = (await this.readRelationshipEdge(sourceParentIdentifier, targetIdentifier, relationshipType, { branch }, signal))?.metadata;
1542
1628
  const removeJobInfo = await this.reactor.removeRelationship(sourceParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1543
1629
  const removeCompletedJob = await this.waitForJob(removeJobInfo, signal);
1544
1630
  if (removeCompletedJob.status === JobStatus.FAILED) throw new Error(removeCompletedJob.error?.message);
1545
- const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, branch, this.signer, signal);
1631
+ const addJobInfo = await this.reactor.addRelationship(targetParentIdentifier, targetIdentifier, relationshipType, metadata, branch, this.signer, signal);
1546
1632
  const addCompletedJob = await this.waitForJob(addJobInfo, signal);
1547
1633
  if (addCompletedJob.status === JobStatus.FAILED) throw new Error(addCompletedJob.error?.message);
1548
1634
  const sourceResult = await this.reactor.getByIdOrSlug(sourceParentIdentifier, { branch }, removeCompletedJob.consistencyToken, signal);
@@ -2212,6 +2298,7 @@ var WorkerPoolJobExecutorManager = class {
2212
2298
  jobId: job.id,
2213
2299
  operations: payload.operations,
2214
2300
  jobMeta: payload.jobMeta,
2301
+ submittedActionIds: submittedActionIds(job),
2215
2302
  collectionMemberships
2216
2303
  };
2217
2304
  try {
@@ -2495,7 +2582,8 @@ var InMemoryJobTracker = class {
2495
2582
  this.jobs.set(jobId, {
2496
2583
  ...job,
2497
2584
  status: JobStatus.WRITE_READY,
2498
- consistencyToken
2585
+ consistencyToken,
2586
+ result: summarizeSubmittedActions(event.operations, event.submittedActionIds)
2499
2587
  });
2500
2588
  }
2501
2589
  }
@@ -4229,9 +4317,35 @@ const DRIVE_AUTH_ERROR_MESSAGES = {
4229
4317
  forbidden: "Forbidden: insufficient permissions",
4230
4318
  authenticationRequired: "Forbidden: authentication required"
4231
4319
  };
4320
+ /**
4321
+ * A non-GraphQL HTTP failure against a drive endpoint.
4322
+ *
4323
+ * Drive discovery (`GET <base>/d/:drive`) is REST, not GraphQL, so its
4324
+ * failures cannot be a `GraphQLRequestError` without the name lying about
4325
+ * what was called. It carries the status for the same reason that one does:
4326
+ * `isDriveAuthError` is what decides whether a failure prompts a login, and a
4327
+ * bare `Error` tells it nothing.
4328
+ */
4329
+ var DriveRequestError = class extends Error {
4330
+ statusCode;
4331
+ constructor(message, statusCode) {
4332
+ super(message);
4333
+ this.name = "DriveRequestError";
4334
+ this.statusCode = statusCode;
4335
+ }
4336
+ };
4232
4337
  /** True when the remote rejected the caller as unauthenticated/unauthorized:
4233
- * an HTTP 401/403, or a Forbidden/Unauthorized GraphQL error. */
4338
+ * an HTTP 401/403, or a Forbidden/Unauthorized GraphQL error.
4339
+ *
4340
+ * 403 and 401 only — NOT 404. The drive info endpoint answers a drive the
4341
+ * caller may not read with the same 404 it gives a drive that does not exist,
4342
+ * so that an unauthorized caller cannot enumerate drives by probing slugs.
4343
+ * That is deliberate, and it costs exactly this: a protected drive is
4344
+ * indistinguishable from a typo, and prompting for a login on every 404 would
4345
+ * fire on every mistyped URL. See the `WWW-Authenticate` note on the endpoint
4346
+ * for the signal that would let a client tell the two apart. */
4234
4347
  function isDriveAuthError(error) {
4348
+ if (error instanceof DriveRequestError) return error.statusCode === 401 || error.statusCode === 403;
4235
4349
  if (!(error instanceof GraphQLRequestError)) return false;
4236
4350
  if (error.category === "http") return error.statusCode === 401 || error.statusCode === 403;
4237
4351
  if (error.category === "graphql") return Object.values(DRIVE_AUTH_ERROR_MESSAGES).some((m) => error.message.includes(m));
@@ -5028,6 +5142,10 @@ var GqlRequestChannel = class {
5028
5142
  if (this.isShutdown) return;
5029
5143
  this.pollTimer.triggerNow();
5030
5144
  }
5145
+ /** This channel polls a remote itself; it has no holder to hear from. */
5146
+ notePoll() {}
5147
+ /** No holder, so nothing this channel reports may strand one. */
5148
+ lastHolderPollUtcMs() {}
5031
5149
  /**
5032
5150
  * Initializes the channel by registering it on the remote server and starting polling.
5033
5151
  */
@@ -5611,6 +5729,7 @@ var GqlResponseChannel = class {
5611
5729
  lastPersistedOutboxOrdinal = 0;
5612
5730
  evictedOutboxFloor = Number.POSITIVE_INFINITY;
5613
5731
  appliedOutboxOrdinal = 0;
5732
+ lastPollUtcMs = Date.now();
5614
5733
  connectionState = "connecting";
5615
5734
  connectionStateCallbacks = /* @__PURE__ */ new Set();
5616
5735
  constructor(logger, channelId, remoteName, cursorStorage) {
@@ -5653,7 +5772,7 @@ var GqlResponseChannel = class {
5653
5772
  return {
5654
5773
  state: this.connectionState,
5655
5774
  failureCount: 0,
5656
- lastSuccessUtcMs: 0,
5775
+ lastSuccessUtcMs: this.lastPollUtcMs,
5657
5776
  lastFailureUtcMs: 0,
5658
5777
  pushBlocked: false,
5659
5778
  pushFailureCount: 0,
@@ -5669,6 +5788,13 @@ var GqlResponseChannel = class {
5669
5788
  }
5670
5789
  /** Response channels are push-driven; resolvers populate mailboxes directly. */
5671
5790
  triggerPull() {}
5791
+ notePoll() {
5792
+ this.lastPollUtcMs = Date.now();
5793
+ }
5794
+ /** This channel is served: its holder's polls are the liveness it reports. */
5795
+ lastHolderPollUtcMs() {
5796
+ return this.lastPollUtcMs;
5797
+ }
5672
5798
  async init() {
5673
5799
  const cursors = await this.cursorStorage.list(this.remoteName);
5674
5800
  const inboxOrdinal = cursors.find((c) => c.cursorType === "inbox")?.cursorOrdinal ?? 0;
@@ -6348,9 +6474,19 @@ var OutboxMode = /* @__PURE__ */ function(OutboxMode) {
6348
6474
  const defaultSyncManagerConfig = {
6349
6475
  maxDeadLettersPerRemote: 100,
6350
6476
  maxInboxBatchSize: 32,
6351
- maxHeldOperationsPerRemote: 1e4
6477
+ maxHeldOperationsPerRemote: 1e4,
6478
+ staleRemotePollWindowMs: 5 * 6e4
6352
6479
  };
6353
6480
  const PLAN_KEY_TO_JOB_UUID_CAP = 1e4;
6481
+ /**
6482
+ * Whether a channel failure says the caller could not authenticate or could not
6483
+ * reach the remote, rather than that the remote itself is misconfigured. The
6484
+ * remote record stays on disk for these so a retry after sign-in can re-add it.
6485
+ */
6486
+ function isCredentialOrNetworkError(error) {
6487
+ if (isDriveAuthError(error)) return true;
6488
+ return error instanceof GraphQLRequestError && error.category === "network";
6489
+ }
6354
6490
  /** Where a sync operation's run of ordinals begins. */
6355
6491
  function firstOrdinalOf(syncOp) {
6356
6492
  return syncOp.operations.length > 0 ? syncOp.operations[0].context.ordinal : 0;
@@ -6379,6 +6515,11 @@ var SyncManager = class {
6379
6515
  backfillAbortControllers = /* @__PURE__ */ new Map();
6380
6516
  planKeyToJobUuid = /* @__PURE__ */ new Map();
6381
6517
  evictedOutboxFloors = /* @__PURE__ */ new Map();
6518
+ prunePending = /* @__PURE__ */ new Set();
6519
+ pruneChain = Promise.resolve();
6520
+ derivingOutboxes = 0;
6521
+ pruneDrainDeferred = false;
6522
+ removing = /* @__PURE__ */ new Set();
6382
6523
  lastEnqueuedJobIdByKey = /* @__PURE__ */ new Map();
6383
6524
  inboxChunkChain = Promise.resolve();
6384
6525
  constructor(logger, remoteStorage, cursorStorage, deadLetterStorage, channelFactory, operationIndex, reactor, eventBus, driveContainerTypes, config = {}) {
@@ -6430,7 +6571,7 @@ var SyncManager = class {
6430
6571
  await channel.init();
6431
6572
  } catch (error) {
6432
6573
  this.logger.error("Error initializing channel for remote (@name, @error)", record.name, error instanceof Error ? error.message : String(error));
6433
- this.remotes.delete(record.name);
6574
+ await this.dropRemoteAfterFailedInit(remote, false);
6434
6575
  continue;
6435
6576
  }
6436
6577
  const outboxAckOrdinal = remote.channel.outbox.ackOrdinal;
@@ -6442,6 +6583,7 @@ var SyncManager = class {
6442
6583
  this.logger.error("Backfill failed for remote @RemoteName: @Error", remote.meta.name, error instanceof Error ? error : new Error(String(error)));
6443
6584
  }).finally(() => {
6444
6585
  this.backfillAbortControllers.delete(record.name);
6586
+ this.drainPrunes();
6445
6587
  });
6446
6588
  }
6447
6589
  }
@@ -6455,6 +6597,8 @@ var SyncManager = class {
6455
6597
  this.backfillAbortControllers.clear();
6456
6598
  this.planKeyToJobUuid.clear();
6457
6599
  this.lastEnqueuedJobIdByKey.clear();
6600
+ this.prunePending.clear();
6601
+ this.pruneDrainDeferred = false;
6458
6602
  this.batchAggregator.clear();
6459
6603
  if (this.eventUnsubscribe) {
6460
6604
  this.eventUnsubscribe();
@@ -6548,8 +6692,7 @@ var SyncManager = class {
6548
6692
  try {
6549
6693
  await channel.init();
6550
6694
  } catch (error) {
6551
- this.remotes.delete(name);
6552
- await this.remoteStorage.remove(name);
6695
+ await this.dropRemoteAfterFailedInit(remote, !isCredentialOrNetworkError(error));
6553
6696
  throw error;
6554
6697
  }
6555
6698
  const backfillController = new AbortController();
@@ -6559,6 +6702,7 @@ var SyncManager = class {
6559
6702
  this.logger.error("Backfill failed for remote @RemoteName: @Error", remote.meta.name, error instanceof Error ? error : new Error(String(error)));
6560
6703
  }).finally(() => {
6561
6704
  this.backfillAbortControllers.delete(name);
6705
+ this.drainPrunes();
6562
6706
  });
6563
6707
  return remote;
6564
6708
  }
@@ -6570,21 +6714,62 @@ var SyncManager = class {
6570
6714
  async remove(name) {
6571
6715
  const remote = this.remotes.get(name);
6572
6716
  if (!remote) throw new Error(`Remote with name '${name}' does not exist`);
6717
+ this.removing.add(name);
6718
+ try {
6719
+ await this.teardownRemoteResources(remote);
6720
+ await this.remoteStorage.remove(name);
6721
+ await this.cursorStorage.remove(name);
6722
+ } finally {
6723
+ this.remotes.delete(name);
6724
+ this.removing.delete(name);
6725
+ }
6726
+ }
6727
+ /**
6728
+ * Drops a remote whose channel.init() rejected, optionally removing its
6729
+ * stored record. Ordered like remove(): the registry slot is released last,
6730
+ * so a concurrent add of the same name cannot slip in and have its record
6731
+ * deleted by the removal below. A failure to tear down must not replace the
6732
+ * init error the caller has to classify.
6733
+ */
6734
+ async dropRemoteAfterFailedInit(remote, removeStorageRecord) {
6735
+ const name = remote.meta.name;
6736
+ this.removing.add(name);
6737
+ try {
6738
+ await this.teardownRemoteResources(remote);
6739
+ if (removeStorageRecord) await this.remoteStorage.remove(name);
6740
+ } catch (error) {
6741
+ this.logger.error("Error tearing down remote after failed init (@name, @error)", name, error instanceof Error ? error.message : String(error));
6742
+ } finally {
6743
+ this.remotes.delete(name);
6744
+ this.removing.delete(name);
6745
+ }
6746
+ }
6747
+ /**
6748
+ * Releases everything wiring a remote up holds: the in-flight backfill, the
6749
+ * channel, the status tracker entry and the connection-state subscription.
6750
+ * The registry slot is deliberately NOT released here -- the caller holds it
6751
+ * until any storage removal has finished, so a concurrent add of the same
6752
+ * name is refused for the whole teardown.
6753
+ */
6754
+ async teardownRemoteResources(remote) {
6755
+ const name = remote.meta.name;
6573
6756
  const backfillController = this.backfillAbortControllers.get(name);
6574
6757
  if (backfillController) {
6575
6758
  backfillController.abort();
6576
6759
  this.backfillAbortControllers.delete(name);
6577
6760
  }
6578
- await remote.channel.shutdown();
6579
- await this.remoteStorage.remove(name);
6580
- await this.cursorStorage.remove(name);
6581
- this.syncStatusTracker.untrackRemote(name);
6582
- const unsub = this.connectionStateUnsubscribes.get(name);
6583
- if (unsub) {
6584
- unsub();
6585
- this.connectionStateUnsubscribes.delete(name);
6761
+ try {
6762
+ await remote.channel.shutdown();
6763
+ } finally {
6764
+ this.syncStatusTracker.untrackRemote(name);
6765
+ const unsub = this.connectionStateUnsubscribes.get(name);
6766
+ if (unsub) {
6767
+ unsub();
6768
+ this.connectionStateUnsubscribes.delete(name);
6769
+ }
6770
+ this.evictedOutboxFloors.delete(name);
6771
+ this.prunePending.delete(name);
6586
6772
  }
6587
- this.remotes.delete(name);
6588
6773
  }
6589
6774
  list() {
6590
6775
  return Array.from(this.remotes.values());
@@ -6679,7 +6864,7 @@ var SyncManager = class {
6679
6864
  this.logger.debug("Loaded @count persisted dead letters for remote @name", records.length, remote.meta.name);
6680
6865
  }
6681
6866
  getRemotesForCollection(collectionId) {
6682
- return Array.from(this.remotes.values()).filter((remote) => remote.meta.collectionId.key === collectionId);
6867
+ return Array.from(this.remotes.values()).filter((remote) => remote.meta.collectionId.key === collectionId && !this.removing.has(remote.meta.name));
6683
6868
  }
6684
6869
  async processCompleteBatch(batch) {
6685
6870
  if (this.isShutdown) return;
@@ -6690,7 +6875,11 @@ var SyncManager = class {
6690
6875
  for (const remote of remotes) if (!affectedRemotes.includes(remote)) affectedRemotes.push(remote);
6691
6876
  }
6692
6877
  for (const remote of affectedRemotes) trimMailboxFromBatch(remote.channel.inbox, batch);
6693
- for (const remote of affectedRemotes) await this.updateOutbox(remote, remote.channel.outbox.latestOrdinal, OutboxMode.BatchTriggered);
6878
+ for (const remote of affectedRemotes) {
6879
+ if (!this.remotes.has(remote.meta.name) || this.removing.has(remote.meta.name)) continue;
6880
+ await this.updateOutbox(remote, remote.channel.outbox.latestOrdinal, OutboxMode.BatchTriggered);
6881
+ }
6882
+ await this.drainPrunes();
6694
6883
  }
6695
6884
  handleInboxAdded(remote, syncOps) {
6696
6885
  if (this.isShutdown) return;
@@ -6891,15 +7080,95 @@ var SyncManager = class {
6891
7080
  const floor = firstOrdinalOf(evicted[0]);
6892
7081
  const known = this.evictedOutboxFloors.get(remote.meta.name);
6893
7082
  this.evictedOutboxFloors.set(remote.meta.name, known === void 0 ? floor : Math.min(known, floor));
6894
- this.logger.warn("Outbox for @RemoteName is past its bound of @Cap operations; evicting @Count entries from ordinal @Floor, to be derived again once it drains", remote.meta.name, cap, evicted.length, floor);
7083
+ const staleMs = this.stalePollAgeMs(remote);
7084
+ if (staleMs !== void 0) {
7085
+ let held = kept;
7086
+ for (const syncOp of evicted) held += syncOp.operations.length;
7087
+ if (!this.prunePending.has(remote.meta.name)) {
7088
+ this.prunePending.add(remote.meta.name);
7089
+ this.logger.warn("Outbox for @RemoteName (@Collection) is past its bound of @Cap operations holding @Held, and it has not been polled for @StaleMs ms; marking the channel for removal once this derivation ends", remote.meta.name, remote.meta.collectionId.key, cap, held, staleMs);
7090
+ }
7091
+ } else this.logger.warn("Outbox for @RemoteName is past its bound of @Cap operations; evicting @Count entries from ordinal @Floor, to be derived again once it drains", remote.meta.name, cap, evicted.length, floor);
6895
7092
  remote.channel.outbox.remove(...evicted);
6896
7093
  }
7094
+ /**
7095
+ * How long a served remote's holder has been silent, if past the window.
7096
+ *
7097
+ * The channel is asked, rather than its config inspected: SyncManager serves
7098
+ * and subscribes with the same interface, both kinds report lastSuccessUtcMs,
7099
+ * and the caller-supplied channelConfig.type is a free-form string the
7100
+ * factories do not read -- so neither could tell a dead served channel from a
7101
+ * client whose switchboard is merely unreachable. Only a channel that claims
7102
+ * a holder by reporting when it last heard from one can be removed for that
7103
+ * holder's silence; a channel that reports nothing (or 0) is never pruned.
7104
+ */
7105
+ stalePollAgeMs(remote) {
7106
+ const last = remote.channel.lastHolderPollUtcMs();
7107
+ if (last === void 0 || last <= 0) return;
7108
+ const age = Date.now() - last;
7109
+ return age >= this.config.staleRemotePollWindowMs ? age : void 0;
7110
+ }
7111
+ /**
7112
+ * Removes the remotes marked stale during eviction.
7113
+ *
7114
+ * Removing a remote that a derivation is still iterating would pull its
7115
+ * mailboxes out from under it, so a drain that arrives during one is deferred
7116
+ * rather than run; the last derivation to finish re-arms it.
7117
+ */
7118
+ drainPrunes() {
7119
+ if (this.derivingOutboxes > 0) {
7120
+ this.pruneDrainDeferred = true;
7121
+ return Promise.resolve();
7122
+ }
7123
+ const next = this.pruneChain.then(async () => {
7124
+ if (this.isShutdown) return;
7125
+ for (const name of [...this.prunePending]) {
7126
+ if (this.derivingOutboxes > 0) {
7127
+ this.pruneDrainDeferred = true;
7128
+ return;
7129
+ }
7130
+ this.prunePending.delete(name);
7131
+ const remote = this.remotes.get(name);
7132
+ if (!remote) continue;
7133
+ if (this.stalePollAgeMs(remote) === void 0) {
7134
+ this.logger.info("Stale removal of @name revoked: its holder polled while the outbox was being derived", name);
7135
+ continue;
7136
+ }
7137
+ try {
7138
+ await this.remove(name);
7139
+ } catch (error) {
7140
+ this.logger.error("Failed to remove stale remote (@name, @error)", name, error instanceof Error ? error.message : String(error));
7141
+ }
7142
+ }
7143
+ });
7144
+ this.pruneChain = next.catch(() => {});
7145
+ return next;
7146
+ }
6897
7147
  outboxOperationCount(remote) {
6898
7148
  let count = 0;
6899
7149
  for (const syncOp of remote.channel.outbox.items) count += syncOp.operations.length;
6900
7150
  return count;
6901
7151
  }
7152
+ /**
7153
+ * Derives this remote's outbox, holding off prunes for the duration.
7154
+ *
7155
+ * A backfill elsewhere can finish at any await in here and drain the prunes
7156
+ * it marked; the count is what keeps that drain from removing the remote this
7157
+ * call is still adding to.
7158
+ */
6902
7159
  async updateOutbox(remote, ackOrdinal, mode = OutboxMode.Backfill, signal) {
7160
+ this.derivingOutboxes++;
7161
+ try {
7162
+ await this.deriveOutbox(remote, ackOrdinal, mode, signal);
7163
+ } finally {
7164
+ this.derivingOutboxes--;
7165
+ if (this.derivingOutboxes === 0 && this.pruneDrainDeferred) {
7166
+ this.pruneDrainDeferred = false;
7167
+ this.drainPrunes();
7168
+ }
7169
+ }
7170
+ }
7171
+ async deriveOutbox(remote, ackOrdinal, mode, signal) {
6903
7172
  const composedSignal = signal ? AbortSignal.any([signal, this.abortController.signal]) : this.abortController.signal;
6904
7173
  const startOrdinal = this.refillOrdinal(remote, ackOrdinal);
6905
7174
  let maxOrdinal = startOrdinal;
@@ -7001,6 +7270,10 @@ var SyncBuilder = class {
7001
7270
  this.config.maxHeldOperationsPerRemote = limit;
7002
7271
  return this;
7003
7272
  }
7273
+ withStaleRemotePollWindowMs(windowMs) {
7274
+ this.config.staleRemotePollWindowMs = windowMs;
7275
+ return this;
7276
+ }
7004
7277
  build(reactor, logger, operationIndex, eventBus, db, driveContainerTypes) {
7005
7278
  return this.buildModule(reactor, logger, operationIndex, eventBus, db, driveContainerTypes).syncManager;
7006
7279
  }
@@ -7166,6 +7439,16 @@ var Reactor = class {
7166
7439
  throwIfAborted(signal, () => new AbortError());
7167
7440
  return relationships.results.map((rel) => rel.sourceId);
7168
7441
  }
7442
+ async getOutgoingRelationshipEdges(sourceId, relationshipType, paging, consistencyToken, signal) {
7443
+ const relationships = await this.documentIndexer.getOutgoing(sourceId, relationshipType ? [relationshipType] : void 0, paging, consistencyToken, signal);
7444
+ throwIfAborted(signal, () => new AbortError());
7445
+ return relationships;
7446
+ }
7447
+ async getIncomingRelationshipEdges(targetId, relationshipType, paging, consistencyToken, signal) {
7448
+ const relationships = await this.documentIndexer.getIncoming(targetId, relationshipType ? [relationshipType] : void 0, paging, consistencyToken, signal);
7449
+ throwIfAborted(signal, () => new AbortError());
7450
+ return relationships;
7451
+ }
7169
7452
  async getOperations(documentId, view, filter, paging, consistencyToken, signal) {
7170
7453
  this.logger.verbose("getOperations(@documentId, @view, @filter, @paging)", documentId, view, filter, paging);
7171
7454
  const branch = view?.branch || "main";
@@ -7528,10 +7811,17 @@ var Reactor = class {
7528
7811
  }
7529
7812
  return { jobs: Object.fromEntries(jobInfos) };
7530
7813
  }
7531
- async addRelationship(sourceId, targetId, relationshipType, branch = "main", signer, signal) {
7532
- this.logger.verbose("addRelationship(@sourceId, @targetId, @relationshipType, @branch)", sourceId, targetId, relationshipType, branch);
7814
+ async addRelationship(sourceId, targetId, relationshipType, metadata, branch = "main", signer, signal) {
7815
+ this.logger.verbose("addRelationship(@sourceId, @targetId, @relationshipType, @metadata, @branch)", sourceId, targetId, relationshipType, metadata, branch);
7533
7816
  throwIfAborted(signal, () => new AbortError());
7534
- let actions = [addRelationshipAction(sourceId, targetId, relationshipType)];
7817
+ let actions = [addRelationshipAction(sourceId, targetId, relationshipType, metadata)];
7818
+ if (signer) actions = await signActions(actions, signer, signal);
7819
+ return await this.execute(sourceId, branch, actions, signal);
7820
+ }
7821
+ async updateRelationship(sourceId, targetId, relationshipType, metadata, branch = "main", signer, signal) {
7822
+ this.logger.verbose("updateRelationship(@sourceId, @targetId, @relationshipType, @metadata, @branch)", sourceId, targetId, relationshipType, metadata, branch);
7823
+ throwIfAborted(signal, () => new AbortError());
7824
+ let actions = [updateRelationshipAction(sourceId, targetId, relationshipType, metadata)];
7535
7825
  if (signer) actions = await signActions(actions, signer, signal);
7536
7826
  return await this.execute(sourceId, branch, actions, signal);
7537
7827
  }
@@ -7903,19 +8193,27 @@ var ReactorBuilder = class {
7903
8193
  } else executorManager = new SimpleJobExecutorManager(() => new SimpleJobExecutor(this.logger, documentModelRegistry, operationStore, eventBus, writeCache, operationIndex, documentMetaCache, collectionMembershipCache, this.driveContainerTypes, this.executorConfig, this.signatureVerifier, executionScope), eventBus, queue, jobTracker, this.logger, resolver, this.executorConfig.jobTimeoutMs, this.executorConfig.deferredJobTtlMs);
7904
8194
  await executorManager.start(executorStartCount);
7905
8195
  const callerReadModels = Array.from(new Set([...this.readModels]));
8196
+ const degradedComponents = [];
8197
+ const startDegraded = (component, error) => {
8198
+ degradedComponents.push({
8199
+ component,
8200
+ error: error instanceof Error ? error : new Error(String(error))
8201
+ });
8202
+ this.logger?.error("Reactor component started degraded: @component", component, error);
8203
+ };
7906
8204
  const documentViewConsistencyTracker = new ConsistencyTracker();
7907
8205
  const documentView = new KyselyDocumentView(database, operationStore, operationIndex, writeCache, documentViewConsistencyTracker, featureFlags.documentDecisions);
7908
8206
  try {
7909
8207
  await documentView.init();
7910
8208
  } catch (error) {
7911
- console.error("Error initializing document view", error);
8209
+ startDegraded("document view", error);
7912
8210
  }
7913
8211
  const documentIndexerConsistencyTracker = new ConsistencyTracker();
7914
8212
  const documentIndexer = new KyselyDocumentIndexer(database, operationIndex, writeCache, documentIndexerConsistencyTracker);
7915
8213
  try {
7916
8214
  await documentIndexer.init();
7917
8215
  } catch (error) {
7918
- console.error("Error initializing document indexer", error);
8216
+ startDegraded("document indexer", error);
7919
8217
  }
7920
8218
  const subscriptionManager = new ReactorSubscriptionManager(new DefaultSubscriptionErrorHandler());
7921
8219
  const subscriptionNotificationReadModel = new SubscriptionNotificationReadModel(subscriptionManager, documentView);
@@ -7924,9 +8222,9 @@ var ReactorBuilder = class {
7924
8222
  try {
7925
8223
  await processorManager.init();
7926
8224
  } catch (error) {
7927
- console.error("Error initializing processor manager", error);
8225
+ startDegraded("processor manager", error);
7928
8226
  }
7929
- for (const factory of this.readModelFactories) {
8227
+ for (const [index, factory] of this.readModelFactories.entries()) try {
7930
8228
  const readModel = await factory({
7931
8229
  documentModelRegistry,
7932
8230
  operationIndex,
@@ -7934,6 +8232,8 @@ var ReactorBuilder = class {
7934
8232
  processorManagerConsistencyTracker
7935
8233
  });
7936
8234
  callerReadModels.push(readModel);
8235
+ } catch (error) {
8236
+ startDegraded(`read model ${index}${factory.name ? ` (${factory.name})` : ""}`, error);
7937
8237
  }
7938
8238
  const readModelInstances = [
7939
8239
  ...callerReadModels,
@@ -7993,8 +8293,10 @@ var ReactorBuilder = class {
7993
8293
  syncModule,
7994
8294
  reactor,
7995
8295
  groupReevaluationTrigger,
7996
- pools: this.instrumentedPools
8296
+ pools: this.instrumentedPools,
8297
+ degradedComponents
7997
8298
  };
8299
+ if (degradedComponents.length > 0) this.logger.warn("Reactor started with @count degraded component(s): @components", degradedComponents.length, degradedComponents.map(({ component }) => component).join(", "));
7998
8300
  if (this.signalHandlersEnabled) this.attachSignalHandlers(module);
7999
8301
  return module;
8000
8302
  }
@@ -8098,7 +8400,7 @@ var ReactorBuilder = class {
8098
8400
  const [{ WorkerHandle }, { createThreadTransport }, { workerEntryPath }] = await Promise.all([
8099
8401
  import("./worker-handle-CrERzl8s.js"),
8100
8402
  import("./transport-ByGviWdZ.js"),
8101
- import("./worker-HPysBfhx.js").then((n) => n.n)
8403
+ import("./worker-B12tBZmr.js").then((n) => n.n)
8102
8404
  ]);
8103
8405
  const poolConfig = {
8104
8406
  enabled: true,
@@ -8792,6 +9094,6 @@ var DocumentIntegrityService = class {
8792
9094
  }
8793
9095
  };
8794
9096
  //#endregion
8795
- export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, BareReadGate, BaseReadModel, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, DEFAULT_DRIVE_CONTAINER_TYPES, DOCUMENT_INDEXER_READ_MODEL, DOCUMENT_VIEW_READ_MODEL, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, DocumentAlreadyExistsError, DocumentChangeType, DocumentExistence, DocumentIntegrityService, DocumentModelRegistry, DocumentModelResolver, DriveClient, DriveCollectionId, DuplicateManifestError, DuplicateModuleError, DuplicateOperationError, EventBus, EventBusAggregateError, GqlRequestChannel, GqlRequestChannelFactory, GqlResponseChannel, GqlResponseChannelFactory, HybridProjectionCoordinator, SimpleJobExecutor as InMemoryJobExecutor, SimpleJobExecutor, InMemoryJobTracker, InMemoryQueue, IntervalPollTimer, InvalidModuleError, 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, RetryAccounting, RevisionMismatchError, SeededStateReader, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncScopeGate, SyncStatus, SyncStatusTracker, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createHybridProjectionCoordinatorFactory, createMutableShutdownStatus, createReactorHostModuleBase, createRelationalDb, decideAtHead, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isDriveAuthError, isRecoverableGraphQLError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, updateRelationshipAction, upgradeDocumentAction, workerEntryPath };
9097
+ export { ALWAYS_READABLE_SCOPES, APPEND_CONDITION_FAILED_PREFIX, AppendConditionFailedError, AuthEnforcementDisabledError, BareReadGate, BaseReadModel, ChannelError, ChannelErrorSource, ChannelScheme, ConsistencyTracker, DEFAULT_DRIVE_CONTAINER_TYPES, DOCUMENT_INDEXER_READ_MODEL, DOCUMENT_VIEW_READ_MODEL, DRIVE_AUTH_ERROR_MESSAGES, DefaultSubscriptionErrorHandler, 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, 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, SeededStateReader, SimpleJobExecutorManager, SyncBuilder, SyncEventTypes, SyncOperation, SyncOperationAggregateError, SyncOperationStatus, SyncScopeGate, SyncStatus, SyncStatusTracker, addRelationshipAction, authDecisionModel, batchOperationsByDocument, buildDecisionModel, classifyJobFailure, consolidateSyncOperations, createDocumentAction, createForwardingLogger, createHybridProjectionCoordinatorFactory, createMutableShutdownStatus, createReactorHostModuleBase, createRelationalDb, decideAtHead, deleteDocumentAction, documentActions, documentDecisionModel, driveIdFromUrl, envelopesToSyncOperations, errorToInfo, firstOutOfOrderPair, getMigrationStatus, instrumentPgPool, isDriveAuthError, isRecoverableGraphQLError, makeConsistencyKey, parseDriveUrl, parsePagingOptions, quarantinesDocument, readDecisionModel, removeRelationshipAction, runMigrations, sanitizeArg, selectDecisionModel, supportsLiveReadModelRegistration, syncOperationErrorType, trimMailboxFromAckOrdinal, updateRelationshipAction, upgradeDocumentAction, workerEntryPath };
8796
9098
 
8797
9099
  //# sourceMappingURL=index.js.map