@agentshouse/mdruntime 0.1.0-cc294e382554d → 0.1.0-cc56db96f86ae

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.
Files changed (125) hide show
  1. package/AGENTS.md +28 -23
  2. package/README.md +9 -8
  3. package/dist/adapter.d.ts +11 -10
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/bootstrap.d.ts.map +1 -1
  6. package/dist/bootstrap.js +60 -29
  7. package/dist/change-set.d.ts.map +1 -1
  8. package/dist/change-set.js +2 -6
  9. package/dist/change.d.ts +31 -1
  10. package/dist/change.d.ts.map +1 -1
  11. package/dist/change.js +10 -1
  12. package/dist/chat/house.d.ts +25 -0
  13. package/dist/chat/house.d.ts.map +1 -0
  14. package/dist/chat/house.js +38 -0
  15. package/dist/chat/input.d.ts +3 -1
  16. package/dist/chat/input.d.ts.map +1 -1
  17. package/dist/chat/input.js +11 -18
  18. package/dist/chat/message.d.ts.map +1 -1
  19. package/dist/chat/message.js +45 -10
  20. package/dist/chat/messages.d.ts +6 -1
  21. package/dist/chat/messages.d.ts.map +1 -1
  22. package/dist/chat/messages.js +14 -11
  23. package/dist/chat/name.d.ts +1 -0
  24. package/dist/chat/name.d.ts.map +1 -1
  25. package/dist/chat/name.js +1 -1
  26. package/dist/chat/opened.d.ts +1 -0
  27. package/dist/chat/opened.d.ts.map +1 -1
  28. package/dist/chat/opened.js +2 -0
  29. package/dist/chat/overlay.d.ts.map +1 -1
  30. package/dist/chat/overlay.js +1 -5
  31. package/dist/chat/parts.d.ts +1 -0
  32. package/dist/chat/parts.d.ts.map +1 -1
  33. package/dist/chat/parts.js +3 -2
  34. package/dist/chat/provider.d.ts +5 -1
  35. package/dist/chat/provider.d.ts.map +1 -1
  36. package/dist/chat/provider.js +58 -77
  37. package/dist/chat/ready.d.ts +4 -1
  38. package/dist/chat/ready.d.ts.map +1 -1
  39. package/dist/chat/ready.js +7 -5
  40. package/dist/chat/room-file.d.ts +8 -0
  41. package/dist/chat/room-file.d.ts.map +1 -0
  42. package/dist/chat/room-file.js +67 -0
  43. package/dist/chat/style.d.ts.map +1 -1
  44. package/dist/chat/style.js +4 -0
  45. package/dist/chat.d.ts +1 -0
  46. package/dist/chat.d.ts.map +1 -1
  47. package/dist/client.d.ts +4 -1
  48. package/dist/client.d.ts.map +1 -1
  49. package/dist/client.js +2 -0
  50. package/dist/collection.d.ts +2 -2
  51. package/dist/collection.d.ts.map +1 -1
  52. package/dist/collection.js +56 -40
  53. package/dist/connection.d.ts +5 -1
  54. package/dist/connection.d.ts.map +1 -1
  55. package/dist/connection.js +723 -158
  56. package/dist/conversation.d.ts +2 -2
  57. package/dist/conversation.d.ts.map +1 -1
  58. package/dist/conversation.js +18 -29
  59. package/dist/digest.d.ts +2 -0
  60. package/dist/digest.d.ts.map +1 -0
  61. package/dist/digest.js +100 -0
  62. package/dist/durability.d.ts +1 -1
  63. package/dist/file.d.ts +2 -10
  64. package/dist/file.d.ts.map +1 -1
  65. package/dist/file.js +3 -120
  66. package/dist/host.d.ts +1 -1
  67. package/dist/host.d.ts.map +1 -1
  68. package/dist/host.js +7 -3
  69. package/dist/index.d.ts +6 -4
  70. package/dist/index.d.ts.map +1 -1
  71. package/dist/index.js +2 -0
  72. package/dist/memory.d.ts.map +1 -1
  73. package/dist/memory.js +9 -5
  74. package/dist/outcome.d.ts +20 -1
  75. package/dist/outcome.d.ts.map +1 -1
  76. package/dist/outcome.js +22 -0
  77. package/dist/overlay.d.ts +1 -1
  78. package/dist/overlay.d.ts.map +1 -1
  79. package/dist/overlay.js +27 -3
  80. package/dist/query.d.ts +2 -1
  81. package/dist/query.d.ts.map +1 -1
  82. package/dist/query.js +1 -0
  83. package/dist/reach.d.ts +5 -1
  84. package/dist/reach.d.ts.map +1 -1
  85. package/dist/reach.js +144 -39
  86. package/dist/read-side.d.ts +4 -4
  87. package/dist/read-side.d.ts.map +1 -1
  88. package/dist/read-side.js +585 -232
  89. package/dist/readiness.d.ts +1 -1
  90. package/dist/readiness.d.ts.map +1 -1
  91. package/dist/recovery.d.ts +2 -2
  92. package/dist/recovery.d.ts.map +1 -1
  93. package/dist/recovery.js +8 -7
  94. package/dist/refresh.js +8 -8
  95. package/dist/replica.d.ts +1 -2
  96. package/dist/replica.d.ts.map +1 -1
  97. package/dist/replica.js +69 -48
  98. package/dist/room-draft.d.ts +3 -1
  99. package/dist/room-draft.d.ts.map +1 -1
  100. package/dist/room-draft.js +21 -13
  101. package/dist/save.d.ts +43 -7
  102. package/dist/save.d.ts.map +1 -1
  103. package/dist/save.js +329 -77
  104. package/dist/search.d.ts +1 -1
  105. package/dist/search.d.ts.map +1 -1
  106. package/dist/search.js +61 -10
  107. package/dist/shared.d.ts +5 -1
  108. package/dist/shared.d.ts.map +1 -1
  109. package/dist/shared.js +72 -9
  110. package/dist/store.d.ts +8 -13
  111. package/dist/store.d.ts.map +1 -1
  112. package/dist/store.js +189 -230
  113. package/dist/tables.d.ts +181 -0
  114. package/dist/tables.d.ts.map +1 -0
  115. package/dist/tables.js +309 -0
  116. package/dist/turn.d.ts +1 -1
  117. package/dist/turn.d.ts.map +1 -1
  118. package/dist/turn.js +9 -6
  119. package/dist/windows.d.ts +0 -5
  120. package/dist/windows.d.ts.map +1 -1
  121. package/dist/windows.js +0 -9
  122. package/dist/wire.d.ts +8 -0
  123. package/dist/wire.d.ts.map +1 -1
  124. package/dist/wire.js +2 -0
  125. package/package.json +6 -2
@@ -1,33 +1,34 @@
1
+ import { roomTarget, target } from '@agentshouse/mdmodel';
1
2
  import { attachedModel } from './attached-model.js';
2
3
  import { cached, modelTypes, NO_MODEL, sameModel, } from './model.js';
3
4
  import { observing, whole } from './observation.js';
4
5
  import { filterOf, identityOf, planOf, plain, queryKey, } from './query.js';
5
6
  import { createReadSide } from './read-side.js';
6
7
  import { seededResult } from './seed.js';
7
- import { markdownPath, roomOfKey } from './rows.js';
8
+ import { documentKey, markdownPath, roomOfKey } from './rows.js';
8
9
  import { createSearchIndex, searchPlanOf, } from './search.js';
9
10
  import { capped, MAXIMUM_WINDOW } from './windows.js';
10
11
  import { bootstrapRoom } from './bootstrap.js';
11
12
  import { changeSet, changing, entering, mergeChangeSets } from './change-set.js';
12
13
  import { createConversationStore, } from './conversation.js';
13
- import { queuedFile } from './change.js';
14
+ import { leaving as leavingIn, partnerOf, queuedFile, } from './change.js';
14
15
  import { indexReferrers, referencesIndexed, validRoot, } from './collection.js';
15
16
  import { runtimeError } from './failure.js';
16
17
  import { createLog } from './log.js';
17
18
  import { binaryRecord } from './binary.js';
18
- import { absentBinary, binaryAnswer, binaryLinkAnswer, calledResult, failure, historyAnswer, undeclared, UNCERTAIN, } from './outcome.js';
19
+ import { ABSENT_TARGET, absentBinary, binaryAnswer, binaryLinkAnswer, calledResult, failure, historyAnswer, LOCKED_UNREADABLE, lockedRoom, undeclared, UNCERTAIN, UNREADABLE, } from './outcome.js';
19
20
  import { createRecovery } from './recovery.js';
20
21
  import { doorReads } from './reads.js';
21
22
  import { refreshRoom } from './refresh.js';
22
- import { buildBatch, joined, limitedIn, queuedDocuments, requeued, } from './save.js';
23
- import { applyReturned, compositionOf, confirmedOf, landed, readReclassified, replicaOf, replicating, sameMembership, standing, } from './replica.js';
23
+ import { buildBatch, buildMove, joined, limiting, queuedDocuments, requeuing, } from './save.js';
24
+ import { applyReturned, compositionOf, confirmedOf, landed, readReclassified, replicating, sameMembership, standing, } from './replica.js';
24
25
  import { binaryBase, changedParts, changesOf, held, heldFile, joinedDraft, moveTarget, readParts, returnedBy, returnedInto, staleWrite, underneath, withdrawnDrafts, } from './room-draft.js';
25
26
  import { same, saving, STARTING, transferring, } from './status.js';
26
27
  import { detectStorageLoss, nextGeneration } from './durability.js';
27
28
  import { readinessOf, SETTLED_ROOMS, } from './readiness.js';
28
29
  import { FencedMap } from './fence.js';
29
- import { tally } from './shared.js';
30
- import { inChunks, ROW_WEIGHT, stepped, yielded } from './turn.js';
30
+ import { keysIn, tallying } from './shared.js';
31
+ import { finished, inChunks, ROW_WEIGHT, stepped, yielded } from './turn.js';
31
32
  import { identityTime, roomDraftRevision } from './identity.js';
32
33
  import { beneath, normalize } from './paths.js';
33
34
  import { openRuntimeStore, } from './store.js';
@@ -59,6 +60,8 @@ const OPENING_LANE = 'opening';
59
60
  const CONVERSATION_LANE = 'conversations';
60
61
  const ROOM_READS = 256;
61
62
  const ROOM_ATTEMPTS = 16;
63
+ // A Room that locks offline stays readable this long after the connection drops, so a laptop waking or changing network keeps working while a disconnected device locks soon after.
64
+ const LOCK_GRACE_MS = 120_000;
62
65
  function operationOf(input) {
63
66
  const operation = input?.operation_id;
64
67
  return typeof operation === 'string' ? operation : null;
@@ -88,6 +91,9 @@ function abandonedOn(signal) {
88
91
  function roomLane(locator) {
89
92
  return `room ${locator}`;
90
93
  }
94
+ function pendingKey(locator, id) {
95
+ return `${locator}\u0000${id}`;
96
+ }
91
97
  export function composeRuntime(options) {
92
98
  const localSaves = options.adapter.localSaves === true;
93
99
  let authorization = options.authorization;
@@ -180,6 +186,9 @@ export function composeRuntime(options) {
180
186
  let withdrawConversations;
181
187
  let currencyQueued = null;
182
188
  let socketOpen = false;
189
+ const confirmed = new Set();
190
+ let lockGrace = null;
191
+ const lockListeners = new Set();
183
192
  let serviceMode = 'open';
184
193
  let haltedRefusal = false;
185
194
  let openSignals = 0;
@@ -331,20 +340,74 @@ export function composeRuntime(options) {
331
340
  }
332
341
  function documentsIn(locator) {
333
342
  const replica = replicas.get(locator);
334
- if (replica === undefined)
335
- return 0;
336
- const roots = replica.confirmed.record.typedSelection?.roots ?? [];
337
- const weighed = outsideOf(roots);
338
- return (tally(replica.visible.documents, weighed.documents) +
339
- tally(replica.visible.binaries, weighed.binaries));
343
+ return replica === undefined ? 0 : finished(counting(replica));
344
+ }
345
+ function* counting(replica) {
346
+ const weighed = outsideOf(replica.confirmed.record.typedSelection?.roots ?? []);
347
+ return ((yield* tallying(replica.visible.documents, weighed.documents)) +
348
+ (yield* tallying(replica.visible.binaries, weighed.binaries)));
340
349
  }
341
350
  function listed() {
342
351
  return structuredClone([...rooms.values()].map((room) => {
343
352
  const documents = documentsIn(room.locator);
344
353
  counted.set(room.locator, documents);
345
- return { ...room, documents };
354
+ return locked(room.locator)
355
+ ? {
356
+ ...room,
357
+ model: modelTypes(NO_MODEL),
358
+ typedSelection: null,
359
+ documents: 0,
360
+ locked: true,
361
+ }
362
+ : { ...room, documents, locked: false };
346
363
  }));
347
364
  }
365
+ function locked(locator) {
366
+ return rooms.get(locator)?.replica.offline === 'locked' && !confirmed.has(locator);
367
+ }
368
+ function lockedRooms() {
369
+ return [...rooms.keys()].filter(locked).sort();
370
+ }
371
+ function relocked(before) {
372
+ const after = lockedRooms();
373
+ if (JSON.stringify(after) === JSON.stringify(before))
374
+ return;
375
+ journal.record(`locked ${after.length}`);
376
+ emitRooms();
377
+ for (const notify of [...lockListeners])
378
+ notify();
379
+ }
380
+ function confirmLocks(listed) {
381
+ if (!socketOpen)
382
+ return;
383
+ for (const room of listed) {
384
+ if (room.replica.admission === 'admitted')
385
+ confirmed.add(room.locator);
386
+ }
387
+ }
388
+ function connectionLost() {
389
+ if (lockGrace !== null || closed)
390
+ return;
391
+ lockGrace = setTimeout(() => {
392
+ lockGrace = null;
393
+ if (socketOpen || closed)
394
+ return;
395
+ const before = lockedRooms();
396
+ confirmed.clear();
397
+ relocked(before);
398
+ }, LOCK_GRACE_MS);
399
+ }
400
+ function connectionLive() {
401
+ if (lockGrace !== null)
402
+ clearTimeout(lockGrace);
403
+ lockGrace = null;
404
+ }
405
+ function watchLocks(notify) {
406
+ lockListeners.add(notify);
407
+ return () => {
408
+ lockListeners.delete(notify);
409
+ };
410
+ }
348
411
  function roomFacts(reach = null) {
349
412
  const held = [...rooms.values()]
350
413
  .filter((room) => holds(reach, room.locator))
@@ -402,17 +465,17 @@ export function composeRuntime(options) {
402
465
  for (const listener of statusListeners)
403
466
  listener(shown);
404
467
  }
405
- function entryOf(batch) {
468
+ function entryOf(locator, batch) {
406
469
  if (localSaves)
407
470
  return Object.freeze({ batch, state: 'local' });
408
- const state = pendings.get(batch.id);
471
+ const state = pendings.get(pendingKey(locator, batch.id));
409
472
  if (state?.accepted !== undefined) {
410
473
  return Object.freeze({ batch, state: 'accepted', roomVersion: state.accepted });
411
474
  }
412
475
  return Object.freeze({ batch, ...(state?.submission ?? { state: 'waiting' }) });
413
476
  }
414
477
  function entriesOf(locator) {
415
- return (replicas.get(locator)?.queue ?? queuesWithoutReplica.get(locator) ?? []).map(entryOf);
478
+ return (replicas.get(locator)?.queue ?? queuesWithoutReplica.get(locator) ?? []).map((batch) => entryOf(locator, batch));
416
479
  }
417
480
  function emitQueue(locator) {
418
481
  const entries = entriesOf(locator);
@@ -451,13 +514,10 @@ export function composeRuntime(options) {
451
514
  if (replica === undefined || closed || prepared.has(locator))
452
515
  return;
453
516
  const visible = replica.visible;
454
- const paths = [
455
- ...new Set([...Object.keys(visible.documents), ...Object.keys(visible.binaries)]),
456
- ];
457
517
  const entered = [];
458
518
  projecting.add(locator);
459
519
  try {
460
- await inChunks(paths, (path) => ROW_WEIGHT + (visible.documents[path]?.length ?? 0), (chunk) => {
520
+ await inChunks(await stepped(keysIn(visible.documents, visible.binaries)), (path) => ROW_WEIGHT + (visible.documents[path]?.length ?? 0), (chunk) => {
461
521
  const current = replicas.get(locator);
462
522
  if (current === undefined || closed)
463
523
  return;
@@ -514,7 +574,9 @@ export function composeRuntime(options) {
514
574
  (reached === null
515
575
  ? [...new Set([...rooms.keys(), ...replicas.keys(), ...queuesWithoutReplica.keys()])]
516
576
  : [...reached]);
517
- return [...new Set(named)].filter((locator) => reached === null || reached.has(locator)).sort();
577
+ return [...new Set(named)]
578
+ .filter((locator) => (reached === null || reached.has(locator)) && !plan.locked.includes(locator))
579
+ .sort();
518
580
  }
519
581
  function awaited(locator) {
520
582
  return (!roomsListed ||
@@ -717,7 +779,7 @@ export function composeRuntime(options) {
717
779
  }
718
780
  async function estimated() {
719
781
  const estimate = options.shell.estimate === undefined ? null : await options.shell.estimate();
720
- return estimate === null ? null : Math.max(0, estimate.quota - estimate.usage);
782
+ return estimate === null ? null : Math.max(0, estimate.total - estimate.usage);
721
783
  }
722
784
  function pause(locator, cause) {
723
785
  paused.add(locator);
@@ -738,7 +800,8 @@ export function composeRuntime(options) {
738
800
  const available = await estimated().catch(() => null);
739
801
  if (available === null || available < (held.needed ?? 0))
740
802
  return;
741
- if (held.cause === 'quota-exceeded' && (held.available === null || available <= held.available))
803
+ if (held.cause === 'storage-exceeded' &&
804
+ (held.available === null || available <= held.available))
742
805
  return;
743
806
  resume();
744
807
  }
@@ -780,7 +843,7 @@ export function composeRuntime(options) {
780
843
  }
781
844
  async function exceededOn(locator, bytes) {
782
845
  const available = await estimated().catch(() => null);
783
- pause(locator, { cause: 'quota-exceeded', needed: bytes, available });
846
+ pause(locator, { cause: 'storage-exceeded', needed: bytes, available });
784
847
  }
785
848
  function seedRoomHeld(room, version) {
786
849
  return prepared.has(room) && reachedBy(room, version);
@@ -917,17 +980,22 @@ export function composeRuntime(options) {
917
980
  models.set(key, revoked);
918
981
  liveModels.delete(key);
919
982
  }
920
- const changed = [];
921
- for (const [locator, room] of rooms) {
983
+ const revocations = [];
984
+ for (const locator of [...rooms.keys()]) {
922
985
  const before = replicas.get(locator) ?? null;
923
- if (before !== null) {
924
- const record = {
925
- ...before.confirmed.record,
926
- model: revoked,
927
- selectionModel: revoked,
928
- };
929
- replicas.set(locator, replicaOf(confirmedOf(record, before.confirmed), before.queue, before));
930
- }
986
+ const after = before === null
987
+ ? null
988
+ : await stepped(replicating(await confirmedOf({ ...before.confirmed.record, model: revoked, selectionModel: revoked }, before.confirmed), before.queue, before));
989
+ const set = await stepped(changing(locator, before, after, { room: true }));
990
+ revocations.push([locator, before, after, set]);
991
+ }
992
+ const changed = [];
993
+ for (const [locator, before, after, set] of revocations) {
994
+ const room = rooms.get(locator);
995
+ if (room === undefined || (replicas.get(locator) ?? null) !== before)
996
+ continue;
997
+ if (after !== null)
998
+ replicas.set(locator, after);
931
999
  rooms.set(locator, {
932
1000
  ...room,
933
1001
  model: modelTypes(revoked),
@@ -935,7 +1003,7 @@ export function composeRuntime(options) {
935
1003
  ? null
936
1004
  : { ...room.typedSelection, model: modelTypes(revoked) },
937
1005
  });
938
- changed.push(changeSet(locator, before, replicas.get(locator) ?? null, { room: true }));
1006
+ changed.push(set);
939
1007
  }
940
1008
  publishChangeSet(mergeChangeSets(changed));
941
1009
  });
@@ -974,6 +1042,7 @@ export function composeRuntime(options) {
974
1042
  });
975
1043
  }
976
1044
  async function applyDiscovery(found, started, covered) {
1045
+ const lockedBefore = lockedRooms();
977
1046
  for (const room of found) {
978
1047
  const fence = fences.get(room.locator);
979
1048
  if (fence !== undefined && fence.erased && fence.at < started)
@@ -1006,8 +1075,10 @@ export function composeRuntime(options) {
1006
1075
  }
1007
1076
  list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
1008
1077
  }
1078
+ confirmLocks(listed);
1009
1079
  roomsListed = true;
1010
1080
  emitRooms();
1081
+ relocked(lockedBefore);
1011
1082
  return listed;
1012
1083
  }
1013
1084
  function heldLocally(locator) {
@@ -1037,16 +1108,17 @@ export function composeRuntime(options) {
1037
1108
  }
1038
1109
  function fence(locators) {
1039
1110
  const fresh = [...new Set(locators)].filter((locator) => !fences.has(locator));
1040
- const removed = fresh.map((locator) => changeSet(locator, replicas.get(locator) ?? null, null, {
1041
- room: true,
1042
- queue: true,
1111
+ const removed = fresh.map((locator) => ({
1112
+ locator,
1113
+ shown: prepared.has(locator) || projecting.has(locator) ? (replicas.get(locator) ?? null) : null,
1043
1114
  drafts: [...(roomDrafts.get(locator)?.keys() ?? [])],
1044
1115
  }));
1045
1116
  for (const locator of fresh) {
1046
1117
  fences.set(locator, { at: discoveries, erased: false });
1047
1118
  lastFenced.set(locator, ++fencings);
1119
+ confirmed.delete(locator);
1048
1120
  for (const batch of replicas.get(locator)?.queue ?? queuesWithoutReplica.get(locator) ?? [])
1049
- pendings.delete(batch.id);
1121
+ pendings.delete(pendingKey(locator, batch.id));
1050
1122
  replicas.delete(locator);
1051
1123
  queuesWithoutReplica.delete(locator);
1052
1124
  rooms.delete(locator);
@@ -1060,6 +1132,7 @@ export function composeRuntime(options) {
1060
1132
  repairable.delete(locator);
1061
1133
  unindexable.delete(locator);
1062
1134
  paused.delete(locator);
1135
+ prepared.delete(locator);
1063
1136
  scopedRefusals.delete(locator);
1064
1137
  sourceRefusals.delete(locator);
1065
1138
  declarationRefusals.delete(locator);
@@ -1073,14 +1146,21 @@ export function composeRuntime(options) {
1073
1146
  }
1074
1147
  if (fresh.length === 0)
1075
1148
  return fresh;
1076
- publishChangeSet(mergeChangeSets(removed));
1077
- for (const locator of fresh)
1078
- prepared.delete(locator);
1079
1149
  searchIndex.forget(fresh);
1150
+ const withdrawn = readSide.apply(stepped(withdrawing(removed)), (set) => conversations.changed(set));
1151
+ if (withdrawn instanceof Promise)
1152
+ void withdrawn.catch(unpublished);
1153
+ publishChangeSet(mergeChangeSets(removed.map(({ locator, drafts }) => changeSet(locator, null, null, { room: true, queue: true, drafts }))));
1080
1154
  readSide.recover();
1081
1155
  moveTo({ ...status, ...attention() });
1082
1156
  return fresh;
1083
1157
  }
1158
+ function* withdrawing(removed) {
1159
+ const sets = [];
1160
+ for (const { locator, shown } of removed)
1161
+ sets.push(yield* changing(locator, shown, null));
1162
+ return mergeChangeSets(sets);
1163
+ }
1084
1164
  async function erased(local, locators) {
1085
1165
  await local.erase(locators);
1086
1166
  for (const locator of locators) {
@@ -1091,8 +1171,12 @@ export function composeRuntime(options) {
1091
1171
  }
1092
1172
  async function loseRooms(locators) {
1093
1173
  const fresh = fence(locators);
1094
- if (fresh.length > 0)
1095
- await durably((local) => erased(local, fresh));
1174
+ if (fresh.length === 0)
1175
+ return;
1176
+ await durably(async (local) => {
1177
+ await erased(local, fresh);
1178
+ await unpair(local);
1179
+ });
1096
1180
  }
1097
1181
  function restored(key, persisted) {
1098
1182
  const prior = models.get(key);
@@ -1127,6 +1211,22 @@ export function composeRuntime(options) {
1127
1211
  opened.transfers(),
1128
1212
  opened.targets(),
1129
1213
  ]);
1214
+ const queuedBatches = new Map(queues.map((queue) => [queue.locator, queue.pending.map(({ batch }) => batch)]));
1215
+ const previousReplicas = new Map(replicas);
1216
+ const read = new Map();
1217
+ for (const record of records) {
1218
+ const model = restored(modelKey(record.locator, 'collection'), record.model);
1219
+ const selectionModel = restored(modelKey(record.locator, 'typed-selection'), record.selectionModel);
1220
+ const prior = previousReplicas.get(record.locator);
1221
+ const confirmed = await confirmedOf({ ...record, model, selectionModel }, prior?.confirmed);
1222
+ const replica = await stepped(replicating(confirmed, queuedBatches.get(record.locator) ?? [], prior));
1223
+ await stepped(counting(replica));
1224
+ read.set(record.locator, replica);
1225
+ }
1226
+ const sets = new Map();
1227
+ for (const locator of new Set([...previousReplicas.keys(), ...read.keys()])) {
1228
+ sets.set(locator, await stepped(changing(locator, previousReplicas.get(locator) ?? null, read.get(locator) ?? null)));
1229
+ }
1130
1230
  targets = heldTargets;
1131
1231
  transfers.clear();
1132
1232
  for (const record of staged)
@@ -1141,26 +1241,24 @@ export function composeRuntime(options) {
1141
1241
  for (const record of presented)
1142
1242
  presentations.set(record.app, record.value);
1143
1243
  conversations.hydrate(held, chatDrafts);
1144
- const queuedBatches = new Map(queues.map((queue) => [queue.locator, queue.pending.map(({ batch }) => batch)]));
1145
1244
  pendings.clear();
1146
1245
  for (const queue of queues) {
1147
- for (const { batch, state } of queue.pending)
1148
- pendings.set(batch.id, state);
1246
+ for (const { batch, state } of queue.pending) {
1247
+ pendings.set(pendingKey(queue.locator, batch.id), state);
1248
+ }
1149
1249
  }
1150
- const previousReplicas = new Map(replicas);
1151
1250
  replicas.clear();
1152
1251
  queuesWithoutReplica.clear();
1153
- const replicated = new Set(records.map((record) => record.locator));
1154
1252
  for (const [locator, queue] of queuedBatches) {
1155
- if (!replicated.has(locator))
1253
+ if (!read.has(locator))
1156
1254
  queuesWithoutReplica.set(locator, queue);
1157
1255
  }
1158
1256
  for (const record of records) {
1159
- const model = restored(modelKey(record.locator, 'collection'), record.model);
1160
- const selectionModel = restored(modelKey(record.locator, 'typed-selection'), record.selectionModel);
1161
- const prior = previousReplicas.get(record.locator);
1162
- const confirmed = confirmedOf({ ...record, model, selectionModel }, prior?.confirmed);
1163
- replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? [], prior));
1257
+ if (fences.has(record.locator))
1258
+ continue;
1259
+ const replica = read.get(record.locator);
1260
+ const { model, selectionModel } = replica.confirmed.record;
1261
+ replicas.set(record.locator, replica);
1164
1262
  const existing = rooms.get(record.locator);
1165
1263
  if (existing === undefined)
1166
1264
  list(record, false);
@@ -1183,7 +1281,7 @@ export function composeRuntime(options) {
1183
1281
  return {
1184
1282
  lastCompleted,
1185
1283
  locators: new Set([...queuedBatches.keys(), ...replicas.keys()]),
1186
- read: mergeChangeSets([...new Set([...previousReplicas.keys(), ...replicas.keys()])].map((locator) => changeSet(locator, previousReplicas.get(locator) ?? null, replicas.get(locator) ?? null))),
1284
+ read: mergeChangeSets([...sets].flatMap(([locator, set]) => (fences.has(locator) ? [] : [set]))),
1187
1285
  };
1188
1286
  }
1189
1287
  async function readState(opened) {
@@ -1202,7 +1300,7 @@ export function composeRuntime(options) {
1202
1300
  async function open() {
1203
1301
  if (closed)
1204
1302
  throw new Error('runtime closed');
1205
- const opened = await openRuntimeStore(isFenced);
1303
+ const opened = await openRuntimeStore(isFenced, options.database);
1206
1304
  store = opened;
1207
1305
  const interrupted = await opened.fences();
1208
1306
  for (const locator of interrupted) {
@@ -1225,6 +1323,9 @@ export function composeRuntime(options) {
1225
1323
  if (interrupted.length > 0)
1226
1324
  await erased(opened, interrupted);
1227
1325
  moveTo({ ...status, lastCompleted, ...attention() });
1326
+ void exclusive(() => unpair(opened)).catch(() => {
1327
+ journal.record('unpair failed');
1328
+ });
1228
1329
  return opened;
1229
1330
  }
1230
1331
  function durably(work) {
@@ -1245,28 +1346,92 @@ export function composeRuntime(options) {
1245
1346
  mutations = next.then(() => undefined, () => undefined);
1246
1347
  return next;
1247
1348
  }
1248
- function withdrawable(batch) {
1249
- const state = pendings.get(batch.id);
1250
- return state !== undefined && state.submission.state !== 'submitted' && !state.lost;
1349
+ function withdrawable(locator) {
1350
+ return (batch) => {
1351
+ const state = pendings.get(pendingKey(locator, batch.id));
1352
+ return state !== undefined && state.submission.state !== 'submitted' && !state.lost;
1353
+ };
1354
+ }
1355
+ function landedWhole(locator, batch, confirmed, current) {
1356
+ if (!landed(batch, confirmed, current))
1357
+ return false;
1358
+ const partner = partnerOf(batch, locator);
1359
+ if (partner === null)
1360
+ return true;
1361
+ const other = replicas.get(partner);
1362
+ const half = other?.queue.find((one) => one.id === batch.id);
1363
+ return other !== undefined && half !== undefined && landed(half, other.confirmed);
1364
+ }
1365
+ function carriedBack(locator, unlanded, written) {
1366
+ const back = new Map();
1367
+ const taken = [];
1368
+ for (const batch of unlanded) {
1369
+ const between = batch.between;
1370
+ if (between === undefined)
1371
+ continue;
1372
+ const source = between.source.room;
1373
+ const destination = between.destination.room;
1374
+ for (const { path: to } of pendings.get(pendingKey(destination, batch.id))?.drafts ?? []) {
1375
+ const record = roomDrafts.get(destination)?.get(to);
1376
+ const path = between.moves.find((move) => move.to === to)?.path;
1377
+ if (record === undefined || path === undefined)
1378
+ continue;
1379
+ const [into, key] = source === locator ? [written, path] : [back, documentKey(source, path)];
1380
+ const existing = into.get(key) ?? roomDrafts.get(source)?.get(path);
1381
+ taken.push({ locator: destination, path: to });
1382
+ into.set(key, {
1383
+ locator: source,
1384
+ path,
1385
+ revision: roomDraftRevision(),
1386
+ parts: returnedInto(existing?.parts, record.parts),
1387
+ returned: existing?.returned ?? null,
1388
+ });
1389
+ }
1390
+ }
1391
+ const records = [...back.values()];
1392
+ const elsewhere = taken.filter((one) => one.locator !== locator);
1393
+ const rooms = new Set([...records, ...elsewhere].map((one) => one.locator));
1394
+ return {
1395
+ taken: taken.filter((one) => one.locator === locator).map(({ path }) => path),
1396
+ writes: [...rooms].map((room) => ({
1397
+ locator: room,
1398
+ roomDrafts: records.filter((record) => record.locator === room),
1399
+ consumedRoomDrafts: elsewhere.filter((one) => one.locator === room).map(({ path }) => path),
1400
+ })),
1401
+ changes: [...rooms].map((room) => changeSet(room, null, null, {
1402
+ drafts: [...records, ...elsewhere]
1403
+ .filter((one) => one.locator === room)
1404
+ .map(({ path }) => path),
1405
+ })),
1406
+ remember() {
1407
+ for (const one of taken)
1408
+ forgetRoomDraft(one.locator, one.path);
1409
+ for (const record of records)
1410
+ rememberRoomDraft(record);
1411
+ },
1412
+ };
1251
1413
  }
1252
1414
  async function settle(local, locator, before, confirmed, queue, settling = {}) {
1253
1415
  const previousQueue = before?.queue ?? queuesWithoutReplica.get(locator) ?? [];
1254
- const { withdrawn, kept } = await stepped(standing(confirmed, queue, withdrawable, localSaves));
1416
+ const { withdrawn, kept } = await stepped(standing(confirmed, queue, withdrawable(locator), localSaves));
1255
1417
  const founded = kept.filter((batch) => !queue.includes(batch));
1256
1418
  const queueChanged = kept.length !== previousQueue.length || founded.length > 0;
1257
1419
  const unqueued = withdrawn.map((batch) => ({
1258
1420
  batch,
1259
- returned: localSaves || landed(batch, confirmed) ? null : returnedBy('withdrawn'),
1421
+ landed: landedWhole(locator, batch, confirmed),
1260
1422
  }));
1261
1423
  const returning = [
1262
- ...[...unqueued].reverse(),
1424
+ ...[...unqueued].reverse().map(({ batch, landed }) => ({
1425
+ batch,
1426
+ returned: localSaves || landed ? null : returnedBy('withdrawn'),
1427
+ })),
1263
1428
  ...(settling.leaving === undefined ? [] : [settling.leaving]),
1264
1429
  ];
1265
1430
  const written = new Map();
1266
1431
  for (const { batch, returned } of returning) {
1267
- if (returned === null)
1432
+ if (returned === null || batch.between?.destination.room === locator)
1268
1433
  continue;
1269
- for (const saved of pendings.get(batch.id)?.drafts ?? []) {
1434
+ for (const saved of pendings.get(pendingKey(locator, batch.id))?.drafts ?? []) {
1270
1435
  const existing = written.get(saved.path) ?? roomDrafts.get(locator)?.get(saved.path);
1271
1436
  written.set(saved.path, {
1272
1437
  locator,
@@ -1277,14 +1442,28 @@ export function composeRuntime(options) {
1277
1442
  });
1278
1443
  }
1279
1444
  }
1280
- await local.keepAuthored({ locator, queue: kept, founded, roomDrafts: [...written.values()] });
1445
+ const carried = carriedBack(locator, [
1446
+ ...[...unqueued].reverse().flatMap(({ batch, landed }) => (landed ? [] : [batch])),
1447
+ ...(settling.leaving === undefined || settling.leaving.returned === null
1448
+ ? []
1449
+ : [settling.leaving.batch]),
1450
+ ], written);
1451
+ await local.keepAuthored({
1452
+ locator,
1453
+ queue: kept,
1454
+ founded,
1455
+ roomDrafts: [...written.values()],
1456
+ consumedRoomDrafts: carried.taken,
1457
+ }, ...carried.writes);
1281
1458
  const staying = new Set(kept.map((batch) => batch.id));
1282
1459
  for (const batch of previousQueue) {
1283
1460
  if (!staying.has(batch.id))
1284
- pendings.delete(batch.id);
1461
+ pendings.delete(pendingKey(locator, batch.id));
1285
1462
  }
1286
1463
  for (const record of written.values())
1287
1464
  rememberRoomDraft(record);
1465
+ carried.remember();
1466
+ const drafts = [...written.keys(), ...carried.taken];
1288
1467
  try {
1289
1468
  await local.write({
1290
1469
  next: confirmed.record,
@@ -1294,31 +1473,32 @@ export function composeRuntime(options) {
1294
1473
  }
1295
1474
  catch (error) {
1296
1475
  const held = before === undefined ? null : await stepped(replicating(before.confirmed, kept, before));
1297
- const set = await stepped(changing(locator, before ?? null, held, {
1298
- queue: queueChanged,
1299
- drafts: [...written.keys()],
1300
- }));
1476
+ const set = await stepped(changing(locator, before ?? null, held, { queue: queueChanged, drafts }));
1301
1477
  if (held !== null)
1302
1478
  replicas.set(locator, held);
1303
1479
  else if (kept.length > 0)
1304
1480
  queuesWithoutReplica.set(locator, kept);
1305
1481
  else
1306
1482
  queuesWithoutReplica.delete(locator);
1307
- publishChangeSet(set);
1483
+ publishChangeSet(mergeChangeSets([set, ...carried.changes]));
1308
1484
  moveTo({ ...status, ...attention() });
1309
1485
  throw error;
1310
1486
  }
1311
- const changed = { room: settling.room, queue: queueChanged, drafts: [...written.keys()] };
1487
+ const changed = { room: settling.room, queue: queueChanged, drafts };
1312
1488
  const next = await stepped(replicating(confirmed, kept, before));
1489
+ await stepped(counting(next));
1313
1490
  const set = await stepped(changing(locator, before ?? null, next, changed));
1314
1491
  return () => {
1315
1492
  queuesWithoutReplica.delete(locator);
1316
- for (const { batch, returned } of unqueued) {
1317
- const outcome = localSaves ? 'dropped' : returned === null ? 'landed' : 'withdrawn';
1493
+ for (const { batch, landed } of unqueued) {
1494
+ const outcome = localSaves ? 'dropped' : landed ? 'landed' : 'withdrawn';
1318
1495
  journal.record(`outcome ${locator} ${batch.id} ${outcome}`);
1319
1496
  }
1320
1497
  replicas.set(locator, next);
1321
- return heldChange(locator, before ?? null, next, set, changed);
1498
+ return mergeChangeSets([
1499
+ heldChange(locator, before ?? null, next, set, changed),
1500
+ ...carried.changes,
1501
+ ]);
1322
1502
  };
1323
1503
  }
1324
1504
  function heldChange(locator, before, next, set, changed) {
@@ -1376,7 +1556,7 @@ export function composeRuntime(options) {
1376
1556
  const queue = prior?.queue ?? queuesWithoutReplica.get(room.locator) ?? [];
1377
1557
  const roomVersion = confirmed.record.roomVersion;
1378
1558
  const accepted = queue.filter((batch) => {
1379
- const accepted = pendings.get(batch.id)?.accepted;
1559
+ const accepted = pendings.get(pendingKey(room.locator, batch.id))?.accepted;
1380
1560
  return (accepted !== undefined && options.adapter.reached(room.locator, roomVersion, accepted));
1381
1561
  });
1382
1562
  let settled;
@@ -1396,7 +1576,7 @@ export function composeRuntime(options) {
1396
1576
  catching.delete(room.locator);
1397
1577
  }
1398
1578
  for (const batch of accepted)
1399
- pendings.delete(batch.id);
1579
+ pendings.delete(pendingKey(room.locator, batch.id));
1400
1580
  stale.delete(room.locator);
1401
1581
  unindexable.delete(room.locator);
1402
1582
  if (unreadable.delete(room.locator))
@@ -1407,8 +1587,12 @@ export function composeRuntime(options) {
1407
1587
  if (prior === undefined)
1408
1588
  prepared.add(room.locator);
1409
1589
  publishChangeSet(set);
1590
+ await unpair(local);
1410
1591
  moveTo({ ...status, ...attention() });
1411
1592
  options.adapter.roomStream?.published(room.locator, roomVersion);
1593
+ if (accepted.length > 0 && (replicas.get(room.locator)?.queue.length ?? 0) > 0) {
1594
+ submitLater(room.locator);
1595
+ }
1412
1596
  return true;
1413
1597
  });
1414
1598
  }
@@ -1444,13 +1628,17 @@ export function composeRuntime(options) {
1444
1628
  }
1445
1629
  const target = await savedRoom(locator);
1446
1630
  const saved = [];
1631
+ const files = [];
1447
1632
  for (const { path, parts } of read) {
1448
1633
  const file = parts.file === undefined
1449
1634
  ? undefined
1450
1635
  : await heldFile(parts.file.blob, binaryBase(target.replica.visible, path));
1636
+ if (parts.file !== undefined && file !== undefined) {
1637
+ files.push({ key: file.localId, blob: parts.file.blob });
1638
+ }
1451
1639
  saved.push({ path, parts: held(undefined, parts, file) });
1452
1640
  }
1453
- return committed(locator, target, saved, new Set(), writtenBy);
1641
+ return committed(locator, target, saved, new Set(), writtenBy, files);
1454
1642
  }
1455
1643
  function carriedDrafts(locator, saved, consumed) {
1456
1644
  const holding = roomDrafts.get(locator);
@@ -1464,13 +1652,145 @@ export function composeRuntime(options) {
1464
1652
  return [[path, { ...record, path: to, revision: roomDraftRevision() }]];
1465
1653
  }));
1466
1654
  }
1467
- async function committed(locator, { local, replica, room }, saved, consumed, writtenBy) {
1655
+ function sideOf(locator, replica, room) {
1656
+ const record = replica.confirmed.record;
1657
+ return {
1658
+ room: locator,
1659
+ handle: room.handle,
1660
+ baseRoomVersion: record.roomVersion,
1661
+ composition: compositionFor(record),
1662
+ operations: room.operations,
1663
+ providerOwnedPaths: room.providerOwnedPaths,
1664
+ visible: replica.visible,
1665
+ queued: queuedDocuments(replica.queue),
1666
+ clean: {
1667
+ text: record.documents,
1668
+ binaries: record.binaries,
1669
+ composition: compositionOf(record),
1670
+ },
1671
+ };
1672
+ }
1673
+ function movedInto(locator, saved) {
1674
+ const into = saved.find(({ parts }) => parts.move?.room !== undefined && parts.move.room !== locator);
1675
+ return into?.parts.move?.room ?? null;
1676
+ }
1677
+ async function committedMove(locator, destination, { local, replica, room }, saved, consumed, writtenBy, files) {
1678
+ const mixed = saved.find(({ parts }) => Object.keys(parts).length !== 1 || parts.move?.room !== destination);
1679
+ if (mixed !== undefined) {
1680
+ return { outcome: 'refused', code: 'mixed-room-move', retryable: false, file: mixed.path };
1681
+ }
1682
+ const changed = saved.flatMap(({ path, parts }) => changedParts(path, underneath(parts, path, replica.visible)));
1683
+ if (changed.length > 0)
1684
+ return { outcome: 'refused', code: 'changed-underneath', retryable: false, parts: changed };
1685
+ await referenced(destination);
1686
+ const target = replicas.get(destination);
1687
+ const listedRoom = rooms.get(destination);
1688
+ if (target === undefined || listedRoom === undefined) {
1689
+ throw runtimeError('room-not-held', `No Local Replica for ${destination}`);
1690
+ }
1691
+ const built = await buildMove({
1692
+ source: sideOf(locator, replica, room),
1693
+ destination: sideOf(destination, target, listedRoom),
1694
+ moves: saved.map(({ path, parts }) => ({ path, to: moveTarget(parts.move) })),
1695
+ providerLimits: limits,
1696
+ local: localSaves,
1697
+ ...(writtenBy === undefined ? {} : { writtenBy }),
1698
+ });
1699
+ if (built.kind === 'refused')
1700
+ return { outcome: 'refused', ...built.refusal };
1701
+ const id = built.leaving.id;
1702
+ const carrying = saved.flatMap(({ path, parts }) => {
1703
+ const record = roomDrafts.get(locator)?.get(path);
1704
+ const to = moveTarget(parts.move);
1705
+ if (record === undefined || consumed.has(path) || roomDrafts.get(destination)?.has(to)) {
1706
+ return [];
1707
+ }
1708
+ const moved = {
1709
+ ...record,
1710
+ locator: destination,
1711
+ path: to,
1712
+ revision: roomDraftRevision(),
1713
+ };
1714
+ return [{ from: path, record: moved }];
1715
+ });
1716
+ const carried = carrying.map(({ record }) => record);
1717
+ const marked = carried.map(({ path }) => ({ path, parts: {} }));
1718
+ const left = [...consumed, ...carrying.map(({ from }) => from)];
1719
+ const leavingQueue = [...replica.queue, built.leaving];
1720
+ const arrivingQueue = [...target.queue, built.arriving];
1721
+ let fencedRoom;
1722
+ try {
1723
+ fencedRoom = await local.saveAuthored(files, {
1724
+ locator,
1725
+ queue: leavingQueue,
1726
+ saved: new Map([[id, saved]]),
1727
+ consumedRoomDrafts: left,
1728
+ }, {
1729
+ locator: destination,
1730
+ queue: arrivingQueue,
1731
+ saved: new Map([[id, marked]]),
1732
+ roomDrafts: carried,
1733
+ });
1734
+ }
1735
+ catch {
1736
+ return { outcome: 'refused', code: 'durable-write', retryable: true };
1737
+ }
1738
+ if (fencedRoom !== null) {
1739
+ throw runtimeError('room-not-held', `No Local Replica for ${fencedRoom}`);
1740
+ }
1741
+ const nextSource = await stepped(replicating(replica.confirmed, leavingQueue, replica));
1742
+ const nextDestination = await stepped(replicating(target.confirmed, arrivingQueue, target));
1743
+ const sets = [
1744
+ await stepped(changing(locator, replica, nextSource, { queue: true, drafts: left })),
1745
+ await stepped(changing(destination, target, nextDestination, {
1746
+ queue: true,
1747
+ drafts: carried.map(({ path }) => path),
1748
+ })),
1749
+ ];
1750
+ if (fences.has(locator) || fences.has(destination)) {
1751
+ throw runtimeError('room-not-held', `No Local Replica for ${locator}`);
1752
+ }
1753
+ pendings.set(pendingKey(locator, id), {
1754
+ drafts: saved,
1755
+ submission: { state: 'waiting' },
1756
+ lost: false,
1757
+ });
1758
+ pendings.set(pendingKey(destination, id), {
1759
+ drafts: marked,
1760
+ submission: { state: 'waiting' },
1761
+ lost: false,
1762
+ });
1763
+ for (const path of left)
1764
+ forgetRoomDraft(locator, path);
1765
+ for (const record of carried)
1766
+ rememberRoomDraft(record);
1767
+ replicas.set(locator, nextSource);
1768
+ replicas.set(destination, nextDestination);
1769
+ publishChangeSet(mergeChangeSets(sets));
1770
+ queuedSave(locator);
1771
+ return { outcome: 'saved', identifier: id };
1772
+ }
1773
+ function queuedSave(locator) {
1774
+ if (!localSaves)
1775
+ recorded = false;
1776
+ moveTo({ ...(localSaves ? status : saving(status)), ...attention() });
1777
+ submitLater(locator);
1778
+ }
1779
+ async function committed(locator, target, saved, consumed, writtenBy, files = []) {
1780
+ const into = movedInto(locator, saved);
1781
+ if (into !== null) {
1782
+ return committedMove(locator, into, target, saved, consumed, writtenBy, files);
1783
+ }
1784
+ const { local, replica, room } = target;
1468
1785
  const changed = saved.flatMap(({ path, parts }) => changedParts(path, underneath(parts, path, replica.visible)));
1469
1786
  if (changed.length > 0)
1470
1787
  return { outcome: 'refused', code: 'changed-underneath', retryable: false, parts: changed };
1471
1788
  const parts = saved.map(({ path, parts }) => changesOf(path, parts));
1472
1789
  const changes = parts.flatMap(({ changes }) => changes);
1473
- const requeue = requeued(replica.queue, changes, replica.visible, withdrawable, (queue) => replicaOf(replica.confirmed, queue, replica).visible);
1790
+ const visibleOver = function* (queue) {
1791
+ return (yield* replicating(replica.confirmed, queue, replica)).visible;
1792
+ };
1793
+ const requeue = await stepped(requeuing(replica.queue, changes, replica.visible, withdrawable(locator), visibleOver));
1474
1794
  if (requeue.kind === 'refused')
1475
1795
  return { outcome: 'refused', ...requeue.refusal };
1476
1796
  const foreign = [...requeue.withdrawn.keys()].some((origin) => {
@@ -1548,7 +1868,7 @@ export function composeRuntime(options) {
1548
1868
  for (const [origin, replaced] of withdrawn) {
1549
1869
  if (replaced === null)
1550
1870
  continue;
1551
- const carried = withdrawnDrafts(pendings.get(origin)?.drafts ?? [], changes);
1871
+ const carried = withdrawnDrafts(pendings.get(pendingKey(locator, origin))?.drafts ?? [], changes);
1552
1872
  drafted.set(replaced.id, replaced === join?.batch
1553
1873
  ? [
1554
1874
  ...carried.map((held) => joinedDraft(held, saved)),
@@ -1562,15 +1882,16 @@ export function composeRuntime(options) {
1562
1882
  for (const replaced of withdrawn.values()) {
1563
1883
  if (replaced === null || !batches.includes(replaced))
1564
1884
  continue;
1565
- const limited = limitedIn(batches, replaced, limits, (queue) => replicaOf(replica.confirmed, queue, replica).visible);
1885
+ const limited = await stepped(limiting(batches, replaced, limits, visibleOver));
1566
1886
  if (limited !== null)
1567
1887
  return { outcome: 'refused', ...limited };
1568
1888
  }
1569
1889
  const carried = carriedDrafts(locator, saved, consumed);
1570
1890
  const left = [...consumed, ...carried.keys()];
1571
1891
  const arrived = [...carried.values()];
1892
+ let fencedRoom;
1572
1893
  try {
1573
- await local.keepAuthored({
1894
+ fencedRoom = await local.saveAuthored(files, {
1574
1895
  locator,
1575
1896
  queue: batches,
1576
1897
  saved: drafted,
@@ -1581,6 +1902,9 @@ export function composeRuntime(options) {
1581
1902
  catch {
1582
1903
  return { outcome: 'refused', code: 'durable-write', retryable: true };
1583
1904
  }
1905
+ if (fencedRoom !== null) {
1906
+ throw runtimeError('room-not-held', `No Local Replica for ${fencedRoom}`);
1907
+ }
1584
1908
  const next = await stepped(replicating(replica.confirmed, batches, replica));
1585
1909
  const set = await stepped(changing(locator, replica, next, {
1586
1910
  queue: true,
@@ -1589,39 +1913,43 @@ export function composeRuntime(options) {
1589
1913
  if (fences.has(locator))
1590
1914
  throw runtimeError('room-not-held', `No Local Replica for ${locator}`);
1591
1915
  for (const origin of withdrawn.keys())
1592
- pendings.delete(origin);
1916
+ pendings.delete(pendingKey(locator, origin));
1593
1917
  for (const [id, drafts] of drafted) {
1594
- pendings.set(id, { drafts, submission: { state: 'waiting' }, lost: false });
1918
+ pendings.set(pendingKey(locator, id), {
1919
+ drafts,
1920
+ submission: { state: 'waiting' },
1921
+ lost: false,
1922
+ });
1595
1923
  }
1596
- if (!localSaves)
1597
- recorded = false;
1598
1924
  for (const path of left)
1599
1925
  forgetRoomDraft(locator, path);
1600
1926
  for (const record of arrived)
1601
1927
  rememberRoomDraft(record);
1602
1928
  replicas.set(locator, next);
1603
1929
  publishChangeSet(set);
1604
- moveTo({ ...(localSaves ? status : saving(status)), ...attention() });
1605
- submitLater(locator);
1930
+ queuedSave(locator);
1606
1931
  const identifier = join?.batch.id ??
1607
1932
  built?.batch.id ??
1608
1933
  [...requeue.withdrawn].map(([origin, replaced]) => replaced?.id ?? origin).at(-1);
1609
1934
  return { outcome: 'saved', identifier };
1610
1935
  }
1611
- async function mark(local, locator, identifier, submission, lost) {
1612
- const state = pendings.get(identifier);
1613
- if (!continuing() ||
1614
- state === undefined ||
1615
- (JSON.stringify(state.submission) === JSON.stringify(submission) && state.lost === lost)) {
1616
- return;
1936
+ async function mark(local, locator, batch, submission, lost) {
1937
+ const partner = partnerOf(batch, locator);
1938
+ for (const held of partner === null ? [locator] : [locator, partner]) {
1939
+ const state = pendings.get(pendingKey(held, batch.id));
1940
+ if (!continuing() ||
1941
+ state === undefined ||
1942
+ (JSON.stringify(state.submission) === JSON.stringify(submission) && state.lost === lost)) {
1943
+ continue;
1944
+ }
1945
+ await local.submit(held, batch.id, submission, lost);
1946
+ if (fences.has(held))
1947
+ continue;
1948
+ pendings.set(pendingKey(held, batch.id), { ...state, submission, lost });
1949
+ const replica = replicas.get(held);
1950
+ if (replica !== undefined)
1951
+ publishChangeSet(changeSet(held, replica, replica, { queue: true }));
1617
1952
  }
1618
- await local.submit(locator, identifier, submission, lost);
1619
- if (fences.has(locator))
1620
- return;
1621
- pendings.set(identifier, { ...state, submission, lost });
1622
- const replica = replicas.get(locator);
1623
- if (replica !== undefined)
1624
- publishChangeSet(changeSet(locator, replica, replica, { queue: true }));
1625
1953
  moveTo({ ...status, ...attention() });
1626
1954
  }
1627
1955
  async function keepUpload(local, locator, identifier, upload) {
@@ -1630,7 +1958,8 @@ export function composeRuntime(options) {
1630
1958
  await local.keepUpload(locator, identifier, upload);
1631
1959
  if (fences.has(locator))
1632
1960
  throw runtimeError('room-not-held', `No Local Replica for ${locator}`);
1633
- pendings.set(identifier, { ...pendings.get(identifier), upload });
1961
+ const key = pendingKey(locator, identifier);
1962
+ pendings.set(key, { ...pendings.get(key), upload });
1634
1963
  if (!continuing())
1635
1964
  throw new Error('connection refused');
1636
1965
  }
@@ -1676,19 +2005,61 @@ export function composeRuntime(options) {
1676
2005
  return UNCERTAIN;
1677
2006
  }
1678
2007
  }
1679
- function queuedBlob(locator, path, localId) {
2008
+ function located(room, path, destination) {
2009
+ const crossing = roomTarget(destination);
2010
+ if (crossing === null) {
2011
+ const named = target(path, destination);
2012
+ return named === null || !rooms.has(room) ? null : { room, path: named };
2013
+ }
2014
+ const held = [...rooms.values()].find((listed) => listed.handle === crossing.handle);
2015
+ return held === undefined ? null : { room: held.locator, path: crossing.path };
2016
+ }
2017
+ async function resolveReference(room, path, destination) {
2018
+ if (roomTarget(destination) === null && target(path, destination) === null) {
2019
+ return ABSENT_TARGET;
2020
+ }
2021
+ const at = located(room, path, destination);
2022
+ if (at === null)
2023
+ return UNREADABLE;
2024
+ if (locked(at.room))
2025
+ return LOCKED_UNREADABLE;
2026
+ const visible = replicas.get(at.room)?.visible;
2027
+ if (visible?.documents[at.path] !== undefined || visible?.binaries[at.path] !== undefined) {
2028
+ return { kind: 'document', ...at };
2029
+ }
2030
+ const history = await readHistory(at.room, at.path);
2031
+ if (history.kind === 'no-history' || history.kind === 'revision')
2032
+ return ABSENT_TARGET;
2033
+ if (history.kind !== 'history')
2034
+ return history;
2035
+ const [last] = history.revisions;
2036
+ if (last === undefined)
2037
+ return ABSENT_TARGET;
2038
+ return history.deleted === true
2039
+ ? { kind: 'deleted', ...at, revision: last }
2040
+ : { kind: 'document', ...at };
2041
+ }
2042
+ async function queuedBlob(locator, path, localId) {
1680
2043
  for (const batch of replicas.get(locator)?.queue ?? []) {
1681
2044
  const change = batch.changes[queuedFile(batch, path, localId)];
1682
2045
  if (change?.kind === 'binary')
1683
- return change.blob;
2046
+ return (await openLocal()).file(localId, change.mediaType);
1684
2047
  }
1685
- return null;
2048
+ const drafted = roomDrafts.get(locator)?.get(path)?.parts.file;
2049
+ return drafted?.localId === localId
2050
+ ? (await openLocal()).file(localId, drafted.mediaType)
2051
+ : null;
2052
+ }
2053
+ function batchFile(batch) {
2054
+ return async (localId) => {
2055
+ const change = batch.changes.find((held) => held.kind === 'binary' && held.localId === localId);
2056
+ return (await openLocal()).file(localId, change?.kind === 'binary' ? change.mediaType : '');
2057
+ };
1686
2058
  }
1687
2059
  function binaryRoom(request) {
1688
2060
  if (request.kind === 'path')
1689
2061
  return request.locator;
1690
- const scope = options.adapter.referrerScope(request.referrer);
1691
- return scope?.kind === 'room' ? scope.locator : null;
2062
+ return options.adapter.referrerScope(request.referrer)?.locator ?? null;
1692
2063
  }
1693
2064
  async function transferBinary(request, transfer, answer) {
1694
2065
  if (closed || refusal !== null)
@@ -1765,11 +2136,14 @@ export function composeRuntime(options) {
1765
2136
  }
1766
2137
  async function readBinaryPath(locator, path, revision) {
1767
2138
  await ready;
1768
- const asked = binaryPath(locator, path, revision);
2139
+ const drafted = roomDrafts.get(locator)?.get(path)?.parts.file;
2140
+ const asked = drafted !== undefined && drafted.localId === revision
2141
+ ? { kind: 'pending', localId: revision }
2142
+ : binaryPath(locator, path, revision);
1769
2143
  if (asked === null)
1770
2144
  return absentBinary(path);
1771
2145
  if (asked.kind === 'pending') {
1772
- const blob = queuedBlob(locator, path, asked.localId);
2146
+ const blob = await queuedBlob(locator, path, asked.localId);
1773
2147
  return blob === null ? absentBinary(path) : { kind: 'binary', blob };
1774
2148
  }
1775
2149
  return readBinary(asked);
@@ -1805,6 +2179,13 @@ export function composeRuntime(options) {
1805
2179
  }
1806
2180
  return undefined;
1807
2181
  }
2182
+ function calledRooms(name, input) {
2183
+ const member = declaredAction(name)?.room;
2184
+ if (member === undefined || input === null || typeof input !== 'object')
2185
+ return [];
2186
+ const named = input[member];
2187
+ return (Array.isArray(named) ? named : [named]).filter((room) => typeof room === 'string');
2188
+ }
1808
2189
  async function invokeAction(name, input, dispatching) {
1809
2190
  await ready;
1810
2191
  if (closed || refusal !== null)
@@ -2143,15 +2524,16 @@ export function composeRuntime(options) {
2143
2524
  ? accepted.roomVersion
2144
2525
  : held, accepted.roomVersion);
2145
2526
  if (applied.kind !== 'staged') {
2146
- const state = pendings.get(submitted.id);
2527
+ const key = pendingKey(room.locator, submitted.id);
2528
+ const state = pendings.get(key);
2147
2529
  await local.accept(room.locator, submitted.id, accepted.roomVersion);
2148
2530
  if (state !== undefined && !fences.has(room.locator))
2149
- pendings.set(submitted.id, { ...state, accepted: accepted.roomVersion });
2531
+ pendings.set(key, { ...state, accepted: accepted.roomVersion });
2150
2532
  emitQueue(room.locator);
2151
2533
  return applied;
2152
2534
  }
2153
2535
  const settled = await settle(local, room.locator, current, applied.confirmed, current.queue.filter((batch) => batch.id !== submitted.id), { room: true });
2154
- pendings.delete(submitted.id);
2536
+ pendings.delete(pendingKey(room.locator, submitted.id));
2155
2537
  unreadable.delete(room.locator);
2156
2538
  sourceRefusals.delete(room.locator);
2157
2539
  declarationRefusals.delete(room.locator);
@@ -2162,22 +2544,150 @@ export function composeRuntime(options) {
2162
2544
  return null;
2163
2545
  }
2164
2546
  async function adopted(room, local, submitted, accepted) {
2165
- const failed = await exclusive(() => adoptAccepted(room, local, submitted, accepted));
2547
+ const failed = await exclusive(async () => {
2548
+ const adopting = await adoptAccepted(room, local, submitted, accepted);
2549
+ await unpair(local);
2550
+ return adopting;
2551
+ });
2166
2552
  if (failed === null)
2167
2553
  return true;
2168
2554
  if (failed.kind === 'refused' && continuing())
2169
2555
  refusedSource(room, failed);
2170
2556
  return false;
2171
2557
  }
2558
+ async function adoptedMove(room, partner, local, submitted, accepted) {
2559
+ const other = discovered.get(partner);
2560
+ const failed = await exclusive(async () => {
2561
+ const left = await adoptAccepted(room, local, submitted, accepted);
2562
+ const arriving = replicas.get(partner)?.queue.find((batch) => batch.id === submitted.id);
2563
+ const arrived = other === undefined || arriving === undefined || accepted.destination === undefined
2564
+ ? null
2565
+ : await adoptAccepted(other, local, arriving, {
2566
+ kind: 'accepted',
2567
+ ...accepted.destination,
2568
+ });
2569
+ if (other !== undefined && arrived?.kind === 'refused' && continuing()) {
2570
+ refusedSource(other, arrived);
2571
+ }
2572
+ await unpair(local);
2573
+ return left;
2574
+ });
2575
+ submitLater(partner);
2576
+ if (failed === null)
2577
+ return true;
2578
+ if (failed.kind === 'refused' && continuing())
2579
+ refusedSource(room, failed);
2580
+ return false;
2581
+ }
2582
+ function queueIn(locator) {
2583
+ return replicas.get(locator)?.queue ?? queuesWithoutReplica.get(locator) ?? [];
2584
+ }
2585
+ function behindIn(locator, batch) {
2586
+ const held = replicas.get(locator)?.queue;
2587
+ if (held === undefined)
2588
+ return queuesWithoutReplica.get(locator) !== undefined;
2589
+ return held.findIndex((one) => one.id === batch.id) > 0;
2590
+ }
2591
+ function unpaired(skipped) {
2592
+ for (const locator of [...replicas.keys(), ...queuesWithoutReplica.keys()]) {
2593
+ for (const batch of queueIn(locator)) {
2594
+ const partner = partnerOf(batch, locator);
2595
+ if (partner === null || skipped.has(pendingKey(locator, batch.id)))
2596
+ continue;
2597
+ if (queueIn(partner).some((held) => held.id === batch.id))
2598
+ continue;
2599
+ if (pendings.get(pendingKey(locator, batch.id))?.accepted !== undefined)
2600
+ continue;
2601
+ if (leavingIn(batch, locator) && !withdrawable(locator)(batch))
2602
+ continue;
2603
+ return { locator, batch };
2604
+ }
2605
+ }
2606
+ return null;
2607
+ }
2608
+ async function unpair(local) {
2609
+ const handled = new Set();
2610
+ for (let held = unpaired(handled); held !== null && continuing(); held = unpaired(handled)) {
2611
+ const { locator, batch } = held;
2612
+ handled.add(pendingKey(locator, batch.id));
2613
+ journal.record(`outcome ${locator} ${batch.id} ${localSaves ? 'dropped' : 'unpaired'}`);
2614
+ const leaving = {
2615
+ batch,
2616
+ returned: !localSaves && leavingIn(batch, locator) ? returnedBy('withdrawn') : null,
2617
+ };
2618
+ const current = replicas.get(locator);
2619
+ if (current !== undefined) {
2620
+ const settled = await settle(local, locator, current, current.confirmed, current.queue.filter((one) => one.id !== batch.id), { leaving });
2621
+ publishChangeSet(settled());
2622
+ continue;
2623
+ }
2624
+ const queue = queueIn(locator).filter((one) => one.id !== batch.id);
2625
+ const written = new Map((leaving.returned === null
2626
+ ? []
2627
+ : (pendings.get(pendingKey(locator, batch.id))?.drafts ?? [])).map((saved) => [
2628
+ saved.path,
2629
+ {
2630
+ locator,
2631
+ path: saved.path,
2632
+ revision: roomDraftRevision(),
2633
+ parts: returnedInto(roomDrafts.get(locator)?.get(saved.path)?.parts, saved.parts),
2634
+ returned: leaving.returned,
2635
+ },
2636
+ ]));
2637
+ const carried = carriedBack(locator, leaving.returned === null ? [] : [batch], written);
2638
+ await local.keepAuthored({
2639
+ locator,
2640
+ queue,
2641
+ roomDrafts: [...written.values()],
2642
+ consumedRoomDrafts: carried.taken,
2643
+ }, ...carried.writes);
2644
+ pendings.delete(pendingKey(locator, batch.id));
2645
+ if (queue.length > 0)
2646
+ queuesWithoutReplica.set(locator, queue);
2647
+ else
2648
+ queuesWithoutReplica.delete(locator);
2649
+ for (const record of written.values())
2650
+ rememberRoomDraft(record);
2651
+ carried.remember();
2652
+ publishChangeSet(mergeChangeSets([
2653
+ changeSet(locator, null, null, {
2654
+ queue: true,
2655
+ drafts: [...written.keys(), ...carried.taken],
2656
+ }),
2657
+ ...carried.changes,
2658
+ ]));
2659
+ }
2660
+ if (handled.size === 0)
2661
+ return;
2662
+ moveTo({ ...status, ...attention() });
2663
+ for (const key of handled)
2664
+ submitLater(key.slice(0, key.indexOf('\u0000')));
2665
+ }
2666
+ async function discardHeld(head, room, local, signal) {
2667
+ const partner = partnerOf(head, room.locator);
2668
+ const other = partner === null ? undefined : discovered.get(partner);
2669
+ await discardPending(room, local, signal);
2670
+ if (other !== undefined && !signal.aborted)
2671
+ await discardPending(other, local, signal);
2672
+ }
2172
2673
  async function submitRoom(room, local, authorized, progress, signal) {
2173
2674
  while (continuing() && authorization !== undefined && !signal.aborted) {
2174
2675
  const replica = replicas.get(room.locator);
2175
2676
  const head = replica?.queue[0];
2176
2677
  if (replica === undefined || head === undefined)
2177
2678
  return 'done';
2178
- if (pendings.get(head.id)?.accepted !== undefined)
2679
+ const state = pendings.get(pendingKey(room.locator, head.id));
2680
+ if (state?.accepted !== undefined)
2681
+ return 'done';
2682
+ const partner = partnerOf(head, room.locator);
2683
+ if (partner !== null && !leavingIn(head, room.locator)) {
2684
+ submitLater(partner);
2179
2685
  return 'done';
2180
- const state = pendings.get(head.id);
2686
+ }
2687
+ if (partner !== null && behindIn(partner, head)) {
2688
+ progress.complete = false;
2689
+ return 'done';
2690
+ }
2181
2691
  const { submission, lost: earlier } = state ?? {
2182
2692
  submission: { state: 'waiting' },
2183
2693
  lost: false,
@@ -2193,16 +2703,16 @@ export function composeRuntime(options) {
2193
2703
  }
2194
2704
  const lost = earlier || submission.state === 'submitted';
2195
2705
  if (lost && pastHorizon(head.id)) {
2196
- await discardPending(room, local, signal);
2706
+ await discardHeld(head, room, local, signal);
2197
2707
  return 'done';
2198
2708
  }
2199
2709
  let result;
2200
2710
  journal.record(`submission ${room.locator} ${head.id}`);
2201
- await exclusive(() => mark(local, room.locator, head.id, { state: 'submitted' }, lost));
2711
+ await exclusive(() => mark(local, room.locator, head, { state: 'submitted' }, lost));
2202
2712
  if (discoveryPaused || fences.has(room.locator))
2203
2713
  return 'done';
2204
2714
  if (signal.aborted || writesHeld()) {
2205
- await exclusive(() => mark(local, room.locator, head.id, submission, earlier));
2715
+ await exclusive(() => mark(local, room.locator, head, submission, earlier));
2206
2716
  if (signal.aborted)
2207
2717
  return 'done';
2208
2718
  progress.complete = false;
@@ -2210,11 +2720,17 @@ export function composeRuntime(options) {
2210
2720
  return 'done';
2211
2721
  }
2212
2722
  const opensBefore = openSignals;
2213
- const answered = awaitingAnswer(room.locator);
2723
+ const answeredHere = awaitingAnswer(room.locator);
2724
+ const answeredThere = partner === null ? () => { } : awaitingAnswer(partner);
2725
+ const answered = () => {
2726
+ answeredHere();
2727
+ answeredThere();
2728
+ };
2214
2729
  try {
2215
2730
  result = await options.adapter.mutate(authorized, {
2216
2731
  locator: room.locator,
2217
2732
  changeBatch: head,
2733
+ file: batchFile(head),
2218
2734
  ...(state?.upload === undefined ? {} : { upload: state.upload }),
2219
2735
  }, (upload) => exclusive(() => keepUpload(local, room.locator, head.id, upload)));
2220
2736
  }
@@ -2239,9 +2755,10 @@ export function composeRuntime(options) {
2239
2755
  return 'again';
2240
2756
  }
2241
2757
  if (result.kind === 'room-not-readable') {
2758
+ const unread = discovered.get(result.locator) ?? room;
2242
2759
  progress.complete = false;
2243
- unreadable.add(room.locator);
2244
- list(room, false);
2760
+ unreadable.add(unread.locator);
2761
+ list(unread, false);
2245
2762
  emitRooms();
2246
2763
  return 'done';
2247
2764
  }
@@ -2256,24 +2773,30 @@ export function composeRuntime(options) {
2256
2773
  }
2257
2774
  if (result.kind === 'write-halted') {
2258
2775
  journal.record(`outcome ${room.locator} ${head.id} write-halted`);
2259
- await exclusive(() => mark(local, room.locator, head.id, { state: 'waiting' }, lost));
2776
+ await exclusive(() => mark(local, room.locator, head, { state: 'waiting' }, lost));
2260
2777
  if (serviceMode === 'open' && openSignals !== opensBefore)
2261
2778
  continue;
2262
2779
  haltedRefusal = true;
2263
2780
  progress.complete = false;
2264
2781
  return 'done';
2265
2782
  }
2783
+ const other = partner === null ? undefined : discovered.get(partner);
2266
2784
  if (result.kind === 'conflicted' ||
2267
2785
  result.kind === 'rejected' ||
2268
2786
  result.kind === 'expired' ||
2269
2787
  result.kind === 'retryable-refusal') {
2270
2788
  if (result.kind === 'expired' && lost && result.restored !== true) {
2271
2789
  journal.record(`outcome ${room.locator} ${head.id} expired`);
2272
- await discardPending(room, local, signal);
2790
+ await discardHeld(head, room, local, signal);
2273
2791
  return 'done';
2274
2792
  }
2275
2793
  if (result.kind === 'expired') {
2276
- await exclusive(() => mark(local, room.locator, head.id, { state: 'waiting' }, false));
2794
+ await exclusive(() => mark(local, room.locator, head, { state: 'waiting' }, partner !== null && lost));
2795
+ const there = other === undefined ? null : await advance(other, local, authorized, continuing);
2796
+ if (other !== undefined && there !== null) {
2797
+ await withhold(there, other, local, progress);
2798
+ return 'done';
2799
+ }
2277
2800
  const withheld = await advance(room, local, authorized, continuing);
2278
2801
  if (withheld !== null) {
2279
2802
  await withhold(withheld, room, local, progress);
@@ -2290,11 +2813,25 @@ export function composeRuntime(options) {
2290
2813
  if (result.kind === 'retryable-refusal') {
2291
2814
  const reset = result.reset;
2292
2815
  progress.complete = false;
2293
- await exclusive(() => mark(local, room.locator, head.id, reset === undefined ? { state: 'waiting' } : { state: 'reset', reset }, lost));
2816
+ await exclusive(() => mark(local, room.locator, head, reset === undefined ? { state: 'waiting' } : { state: 'reset', reset }, lost));
2294
2817
  return reset === undefined ? 'again' : { at: Date.parse(reset) };
2295
2818
  }
2296
2819
  if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
2297
2820
  const left = leaving(head, result);
2821
+ const refreshed = async () => ({
2822
+ there: other === undefined ? null : await advance(other, local, authorized, continuing),
2823
+ here: await advance(room, local, authorized, continuing),
2824
+ });
2825
+ const judged = result.kind === 'conflicted' && other !== undefined ? await refreshed() : null;
2826
+ if (judged !== null && (judged.there !== null || judged.here !== null)) {
2827
+ await exclusive(() => mark(local, room.locator, head, { state: 'waiting' }, lost));
2828
+ if (other !== undefined && judged.there !== null) {
2829
+ await withhold(judged.there, other, local, progress);
2830
+ }
2831
+ if (judged.here !== null)
2832
+ await withhold(judged.here, room, local, progress);
2833
+ return 'done';
2834
+ }
2298
2835
  await exclusive(async () => {
2299
2836
  if (!continuing())
2300
2837
  return;
@@ -2303,24 +2840,30 @@ export function composeRuntime(options) {
2303
2840
  return;
2304
2841
  }
2305
2842
  const settled = await settle(local, room.locator, current, current.confirmed, current.queue.filter((batch) => batch.id !== head.id), {
2306
- leaving: (result.kind === 'expired' && landed(head, current.confirmed)) ||
2307
- (result.kind === 'conflicted' && landed(head, current.confirmed, result.affected))
2843
+ leaving: (result.kind === 'expired' && landedWhole(room.locator, head, current.confirmed)) ||
2844
+ (result.kind === 'conflicted' &&
2845
+ landedWhole(room.locator, head, current.confirmed, result.affected))
2308
2846
  ? { ...left, returned: null }
2309
2847
  : left,
2310
2848
  });
2311
2849
  publishChangeSet(settled());
2850
+ await unpair(local);
2312
2851
  moveTo({ ...status, ...attention() });
2313
2852
  });
2314
2853
  if (result.kind === 'expired')
2315
2854
  continue;
2316
- const withheld = await advance(room, local, authorized, continuing);
2317
- if (withheld !== null) {
2318
- await withhold(withheld, room, local, progress);
2855
+ const { there, here } = judged ?? (await refreshed());
2856
+ if (other !== undefined && there !== null)
2857
+ await withhold(there, other, local, progress);
2858
+ if (here !== null) {
2859
+ await withhold(here, room, local, progress);
2319
2860
  return 'done';
2320
2861
  }
2321
2862
  continue;
2322
2863
  }
2323
- const kept = await adopted(room, local, head, result).finally(answered);
2864
+ const kept = await (partner === null
2865
+ ? adopted(room, local, head, result)
2866
+ : adoptedMove(room, partner, local, head, result)).finally(answered);
2324
2867
  if (!kept) {
2325
2868
  progress.complete = false;
2326
2869
  return 'done';
@@ -2347,9 +2890,9 @@ export function composeRuntime(options) {
2347
2890
  fences.delete(locator);
2348
2891
  }
2349
2892
  for (const batch of queue)
2350
- pendings.delete(batch.id);
2893
+ pendings.delete(pendingKey(locator, batch.id));
2351
2894
  queuesWithoutReplica.delete(locator);
2352
- const after = before === undefined ? null : replicaOf(before.confirmed, [], before);
2895
+ const after = before === undefined ? null : await stepped(replicating(before.confirmed, [], before));
2353
2896
  if (after !== null)
2354
2897
  replicas.set(locator, after);
2355
2898
  journal.record(`room discarded ${locator}`);
@@ -2411,7 +2954,7 @@ export function composeRuntime(options) {
2411
2954
  ...(attached === 'collection' ? { model: next } : { selectionModel: next }),
2412
2955
  };
2413
2956
  const reclassified = sameMembership(declared, held.confirmed.record)
2414
- ? { kind: 'staged', confirmed: confirmedOf(declared, held.confirmed) }
2957
+ ? { kind: 'staged', confirmed: await confirmedOf(declared, held.confirmed) }
2415
2958
  : await readReclassified(declared, held.confirmed);
2416
2959
  const record = reclassified.kind === 'staged'
2417
2960
  ? declared
@@ -2420,12 +2963,13 @@ export function composeRuntime(options) {
2420
2963
  typedSelection: held.confirmed.record.typedSelection,
2421
2964
  };
2422
2965
  const read = reclassified.kind === 'deferred'
2423
- ? { kind: 'staged', confirmed: confirmedOf(record, held.confirmed) }
2966
+ ? { kind: 'staged', confirmed: await confirmedOf(record, held.confirmed) }
2424
2967
  : reclassified;
2425
2968
  await local.write({ next: record, previous: held.confirmed.record });
2426
2969
  const replica = read.kind === 'staged'
2427
2970
  ? await stepped(replicating(read.confirmed, held.queue, held))
2428
2971
  : { ...held, confirmed: { ...held.confirmed, record } };
2972
+ await stepped(counting(replica));
2429
2973
  const set = await stepped(changing(room.locator, held, replica, { room: true }));
2430
2974
  replicas.set(room.locator, replica);
2431
2975
  changed = heldChange(room.locator, held, replica, set, { room: true });
@@ -2453,7 +2997,7 @@ export function composeRuntime(options) {
2453
2997
  });
2454
2998
  }
2455
2999
  async function scoped(result, room, local) {
2456
- if ((result.scope === 'content' || result.scope === 'sync') && !result.retryable) {
3000
+ if (result.scope === 'content' && !result.retryable) {
2457
3001
  journal.record(`scoped refusal ${room.locator} ${result.scope} ${result.code}`);
2458
3002
  await loseRooms([room.locator]);
2459
3003
  return;
@@ -2471,15 +3015,7 @@ export function composeRuntime(options) {
2471
3015
  });
2472
3016
  else if (result.scope !== 'operation') {
2473
3017
  unreadable.add(room.locator);
2474
- scopedAway = {
2475
- ...room,
2476
- replica: { ...room.replica, admission: 'unavailable' },
2477
- operations: {
2478
- ...room.operations,
2479
- ...(result.scope === 'content' ? { read: [] } : {}),
2480
- ...(result.scope === 'sync' ? { sync: [] } : {}),
2481
- },
2482
- };
3018
+ scopedAway = { ...room, replica: { ...room.replica, admission: 'unavailable' } };
2483
3019
  await retainModel(local, scopedAway, 'collection', modelOf(room.locator));
2484
3020
  }
2485
3021
  if (!continuing())
@@ -2639,12 +3175,13 @@ export function composeRuntime(options) {
2639
3175
  }
2640
3176
  for (const room of result.rooms) {
2641
3177
  if (room.operations === undefined ||
2642
- !['read', 'sync', 'write', 'delete'].every((operation) => Array.isArray(room.operations[operation]) &&
3178
+ !['write', 'delete'].every((operation) => Array.isArray(room.operations[operation]) &&
2643
3179
  room.operations[operation].every((scope) => typeof scope === 'string')) ||
2644
3180
  room.replica === undefined ||
2645
3181
  typeof room.replica.selection !== 'string' ||
2646
3182
  room.replica.selection.length === 0 ||
2647
- !['admitted', 'unavailable'].includes(room.replica.admission)) {
3183
+ !['admitted', 'unavailable'].includes(room.replica.admission) ||
3184
+ !['locked', 'open'].includes(room.replica.offline)) {
2648
3185
  journal.record(`invalid model or replica declaration ${room.locator}`);
2649
3186
  await refuse('missing-required-guarantee');
2650
3187
  notifyReady();
@@ -2806,6 +3343,7 @@ export function composeRuntime(options) {
2806
3343
  function closedSocket(revoked, lost) {
2807
3344
  const opened = socketOpen;
2808
3345
  socketOpen = false;
3346
+ connectionLost();
2809
3347
  if (lost) {
2810
3348
  reads.lost();
2811
3349
  conversations.lost();
@@ -3130,6 +3668,7 @@ export function composeRuntime(options) {
3130
3668
  return;
3131
3669
  if (event.kind === 'opened') {
3132
3670
  socketOpen = true;
3671
+ connectionLive();
3133
3672
  generation += 1;
3134
3673
  reads.opened();
3135
3674
  conversations.reopened();
@@ -3216,6 +3755,8 @@ export function composeRuntime(options) {
3216
3755
  closed = true;
3217
3756
  if (rechecking !== null)
3218
3757
  clearInterval(rechecking);
3758
+ connectionLive();
3759
+ lockListeners.clear();
3219
3760
  readinessWatches.clear();
3220
3761
  withdrawStream?.();
3221
3762
  withdrawStream = undefined;
@@ -3301,14 +3842,23 @@ export function composeRuntime(options) {
3301
3842
  stop();
3302
3843
  };
3303
3844
  }
3304
- async function searched(input, reach) {
3845
+ async function searched(input, reach, locked) {
3305
3846
  const asked = searchPlanOf(input);
3306
3847
  const plan = { ...asked, limit: capped(asked.limit) };
3307
- const filter = filterOf(plan.documents ?? {}, roomFacts(reach), planReach(reach));
3848
+ const filter = {
3849
+ ...filterOf(plan.documents ?? {}, roomFacts(reach), planReach(reach)),
3850
+ locked,
3851
+ };
3852
+ const unlocking = (filter.rooms ?? [])
3853
+ .filter((room) => locked.includes(room))
3854
+ .map((room) => ({ room, awaits: 'unlock' }));
3308
3855
  for (;;) {
3309
3856
  const cover = covered(filter);
3310
3857
  awaitPreparation(cover);
3311
- const pending = 'pending' in cover ? [...cover.pending] : [];
3858
+ const pending = [
3859
+ ...unlocking,
3860
+ ...('pending' in cover ? cover.pending : []),
3861
+ ];
3312
3862
  const awaited = new Set('pending' in cover ? cover.pending.map(({ room }) => room) : []);
3313
3863
  const held = covered({
3314
3864
  ...filter,
@@ -3351,7 +3901,11 @@ export function composeRuntime(options) {
3351
3901
  if (searchIndex.changes() !== seen ||
3352
3902
  JSON.stringify(covered(answering)) !== JSON.stringify(answered))
3353
3903
  continue;
3354
- const hits = searchIndex.search(plan, selected === null ? null : selected.documents, searchedConversations, answering.rooms === null ? null : new Set(answering.rooms), plan.collection
3904
+ const hits = searchIndex.search(plan, selected === null ? null : selected.documents, searchedConversations, answering.rooms !== null
3905
+ ? new Set(answering.rooms)
3906
+ : locked.length === 0
3907
+ ? null
3908
+ : new Set(reachedRooms(answering)), plan.collection
3355
3909
  ? new Map(answered.coverage.rooms.map(({ room }) => [
3356
3910
  room,
3357
3911
  replicas.get(room)?.confirmed.record.typedSelection?.roots ?? [],
@@ -3475,7 +4029,7 @@ export function composeRuntime(options) {
3475
4029
  return listed();
3476
4030
  },
3477
4031
  query: ((input) => rowsQuery(planned(input, null))),
3478
- search: (input) => searched(input, null),
4032
+ search: (input) => searched(input, null, []),
3479
4033
  types(locator) {
3480
4034
  return {
3481
4035
  collection: modelTypes(modelOf(locator)),
@@ -3519,7 +4073,9 @@ export function composeRuntime(options) {
3519
4073
  parts: held(current?.parts, parts, file),
3520
4074
  returned: current?.returned ?? null,
3521
4075
  };
3522
- await local.keepRoomDraft(record);
4076
+ await local.keepRoomDraft(record, parts.file === undefined || file === undefined
4077
+ ? []
4078
+ : [{ key: file.localId, blob: parts.file.blob }]);
3523
4079
  rememberRoomDraft(record);
3524
4080
  publishChangeSet(changeSet(room, null, null, { drafts: [path] }));
3525
4081
  return { outcome: 'written', roomDraft: roomDraftOf(record) };
@@ -3568,13 +4124,18 @@ export function composeRuntime(options) {
3568
4124
  history(locator, path, query) {
3569
4125
  return readHistory(locator, path, query);
3570
4126
  },
4127
+ resolve(room, path, destination) {
4128
+ return resolveReference(room, path, destination);
4129
+ },
3571
4130
  binaryFile: {
3572
4131
  read: readBinaryPath,
3573
4132
  link: linkBinaryPath,
3574
4133
  readReference: readBinaryReference,
3575
4134
  },
3576
- call(name, input) {
3577
- return recoveredAction(name, input);
4135
+ async call(name, input) {
4136
+ await ready;
4137
+ const room = calledRooms(name, input).find((one) => locked(one));
4138
+ return room === undefined ? recoveredAction(name, input) : lockedRoom(room);
3578
4139
  },
3579
4140
  reached(locator, roomVersion, path) {
3580
4141
  return path === undefined
@@ -3619,6 +4180,10 @@ export function composeRuntime(options) {
3619
4180
  watchPlan,
3620
4181
  search: searched,
3621
4182
  watchReadiness,
4183
+ locked,
4184
+ lockedRooms,
4185
+ watchLocks,
4186
+ located,
3622
4187
  };
3623
4188
  return {
3624
4189
  md,