@agentshouse/mdruntime 0.1.0-c009335e08279 → 0.1.0-c30ed2b8e04d5

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 (59) hide show
  1. package/AGENTS.md +21 -12
  2. package/README.md +4 -5
  3. package/dist/adapter.d.ts +56 -41
  4. package/dist/adapter.d.ts.map +1 -1
  5. package/dist/adapter.js +1 -9
  6. package/dist/attached-model.d.ts +11 -0
  7. package/dist/attached-model.d.ts.map +1 -0
  8. package/dist/attached-model.js +34 -0
  9. package/dist/bootstrap.d.ts +5 -3
  10. package/dist/bootstrap.d.ts.map +1 -1
  11. package/dist/bootstrap.js +2 -2
  12. package/dist/client.d.ts.map +1 -1
  13. package/dist/client.js +3 -10
  14. package/dist/connection.d.ts +4 -8
  15. package/dist/connection.d.ts.map +1 -1
  16. package/dist/connection.js +485 -502
  17. package/dist/conversation.d.ts +6 -6
  18. package/dist/conversation.d.ts.map +1 -1
  19. package/dist/conversation.js +93 -37
  20. package/dist/host.d.ts +7 -1
  21. package/dist/host.d.ts.map +1 -1
  22. package/dist/host.js +34 -31
  23. package/dist/index.d.ts +2 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/memory.d.ts +10 -5
  26. package/dist/memory.d.ts.map +1 -1
  27. package/dist/memory.js +66 -26
  28. package/dist/model.d.ts +1 -5
  29. package/dist/model.d.ts.map +1 -1
  30. package/dist/observation.d.ts +2 -0
  31. package/dist/observation.d.ts.map +1 -1
  32. package/dist/observation.js +24 -14
  33. package/dist/query.d.ts +7 -2
  34. package/dist/query.d.ts.map +1 -1
  35. package/dist/query.js +4 -3
  36. package/dist/reach.d.ts +40 -0
  37. package/dist/reach.d.ts.map +1 -0
  38. package/dist/reach.js +277 -0
  39. package/dist/read-side.d.ts +4 -0
  40. package/dist/read-side.d.ts.map +1 -1
  41. package/dist/read-side.js +16 -10
  42. package/dist/refresh.d.ts +2 -1
  43. package/dist/refresh.d.ts.map +1 -1
  44. package/dist/refresh.js +7 -3
  45. package/dist/replica.d.ts +4 -4
  46. package/dist/replica.d.ts.map +1 -1
  47. package/dist/replica.js +12 -13
  48. package/dist/room-draft.d.ts +3 -0
  49. package/dist/room-draft.d.ts.map +1 -1
  50. package/dist/room-draft.js +6 -4
  51. package/dist/save.d.ts +0 -2
  52. package/dist/save.d.ts.map +1 -1
  53. package/dist/save.js +2 -4
  54. package/dist/store.d.ts +0 -1
  55. package/dist/store.d.ts.map +1 -1
  56. package/dist/wire.d.ts +50 -16
  57. package/dist/wire.d.ts.map +1 -1
  58. package/dist/wire.js +49 -59
  59. package/package.json +2 -2
@@ -1,14 +1,13 @@
1
- import { verify } from '@agentshouse/mdmodel';
1
+ import { attachedModel } from './attached-model.js';
2
2
  import { cached, modelTypes, NO_MODEL, sameModel, } from './model.js';
3
- import { opened } from './observation.js';
3
+ import { observing } from './observation.js';
4
4
  import { filterOf, identityOf, planOf, } from './query.js';
5
5
  import { createReadSide } from './read-side.js';
6
6
  import { createSearchIndex, searchPlanOf, } from './search.js';
7
- import { ROOM_CONTRACT_VERSION, VERSION_1_CAPABILITIES, } from './adapter.js';
8
7
  import { bootstrapRoom } from './bootstrap.js';
9
8
  import { changeSet, mergeChangeSets } from './change-set.js';
10
9
  import { createConversationStore, } from './conversation.js';
11
- import { MD_MODEL, parseCollection, validRoot } from './collection.js';
10
+ import { parseCollection, validRoot } from './collection.js';
12
11
  import { createLog } from './log.js';
13
12
  import { refreshRoom } from './refresh.js';
14
13
  import { buildBatch } from './save.js';
@@ -20,37 +19,21 @@ import { roomDraftRevision } from './identity.js';
20
19
  import { beneath, normalize } from './paths.js';
21
20
  import { openRuntimeStore, } from './store.js';
22
21
  import { unconfirmed } from './transfer.js';
23
- // Discovery repeats each minute so a Room the stream stopped serving stays unrefreshed no longer.
24
- const DEFAULT_DISCOVERY_INTERVAL_MS = 60_000;
25
- const EMPTY_SCHEMA = { verdict: 'accept', model: { types: {} } };
26
- const knownCapabilities = new Set(VERSION_1_CAPABILITIES);
22
+ import { governs, holds, narrowed, planReach, } from './reach.js';
27
23
  function within(root, path) {
28
24
  return root === '' || beneath(root, path);
29
25
  }
30
- function declaredIdentity(model) {
31
- if (model === undefined)
32
- return false;
33
- if (model === null)
34
- return true;
35
- return (typeof model.identity === 'string' &&
36
- model.identity.length > 0 &&
37
- typeof model.version === 'string' &&
38
- model.version.length > 0);
39
- }
40
26
  function declaredSelection(room) {
41
27
  const selection = room.typedSelection;
42
28
  if (selection === undefined)
43
29
  return true;
44
30
  if (!Array.isArray(selection.roots) || selection.roots.length === 0)
45
31
  return false;
46
- if (!declaredIdentity(selection.attachedModel))
47
- return false;
48
32
  return selection.roots.every((root, index) => validRoot(root) &&
49
33
  root !== '' &&
50
34
  selection.roots.every((other, at) => at === index || !within(other, root)));
51
35
  }
52
36
  export function composeRuntime(options) {
53
- const period = options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
54
37
  let authorization = options.authorization;
55
38
  let opening;
56
39
  let store;
@@ -58,20 +41,13 @@ export function composeRuntime(options) {
58
41
  const pendings = new Map();
59
42
  const roomDrafts = new Map();
60
43
  const presentations = new Map();
61
- const wanted = new Map();
62
44
  let limits = [];
63
45
  let capabilities = [];
64
- function rewriting() {
65
- return capabilities.some((capability) => capability.name === 'move-reference-rewrites')
66
- ? 'provider'
67
- : 'runtime';
68
- }
69
46
  const rooms = new Map();
70
47
  const unreadable = new Set();
71
48
  const models = new Map();
72
49
  const liveModels = new Map();
73
50
  const invalidModels = new Set();
74
- const modelEpoch = new Map();
75
51
  const discovered = new Map();
76
52
  let roomsListed = false;
77
53
  const scopedRefusals = new Map();
@@ -100,16 +76,19 @@ export function composeRuntime(options) {
100
76
  let status = STARTING;
101
77
  let shown = STARTING;
102
78
  let discoveryPaused = false;
103
- let timer;
79
+ let resetTimer;
104
80
  let closed = false;
105
81
  let stopping = false;
106
- let withdrawNotifications;
107
- let handed;
108
- const notices = new Map();
82
+ let withdrawStream;
83
+ const announced = new Map();
84
+ let underway = null;
85
+ const submitting = new Map();
86
+ const answering = new Map();
87
+ let complete = false;
88
+ let recorded = false;
89
+ const stale = new Set();
109
90
  let chain = Promise.resolve();
110
91
  let mutations = Promise.resolve();
111
- let inflight = false;
112
- let queued = false;
113
92
  const roomListeners = new Set();
114
93
  const refusalListeners = new Set();
115
94
  const storageLossListeners = new Set();
@@ -120,6 +99,7 @@ export function composeRuntime(options) {
120
99
  const readSide = createReadSide();
121
100
  let withdrawConversations;
122
101
  let currencyQueued = false;
102
+ let socketOpen = false;
123
103
  let serviceMode = 'open';
124
104
  let haltedRefusal = false;
125
105
  let openSignals = 0;
@@ -234,11 +214,14 @@ export function composeRuntime(options) {
234
214
  function listed() {
235
215
  return structuredClone([...rooms.values()]);
236
216
  }
237
- function roomFacts() {
238
- const held = [...rooms.values()].map((room) => {
217
+ function roomFacts(reach = null) {
218
+ const held = [...rooms.values()]
219
+ .filter((room) => holds(reach, room.locator))
220
+ .map((room) => {
221
+ const governed = governs(reach, room);
239
222
  const attached = [
240
- modelOf(room.locator),
241
- ...(room.typedSelection === null ? [] : [modelOf(room.locator, 'typed-selection')]),
223
+ ...(governed.collection ? [modelOf(room.locator)] : []),
224
+ ...(governed.selection ? [modelOf(room.locator, 'typed-selection')] : []),
242
225
  ];
243
226
  return {
244
227
  locator: room.locator,
@@ -322,17 +305,30 @@ export function composeRuntime(options) {
322
305
  }
323
306
  }
324
307
  function stopTimer() {
325
- if (timer === undefined)
308
+ if (resetTimer === undefined)
326
309
  return;
327
- clearInterval(timer);
328
- timer = undefined;
310
+ clearTimeout(resetTimer);
311
+ resetTimer = undefined;
329
312
  }
330
- function startTimer() {
331
- if (timer !== undefined)
313
+ function armTimer() {
314
+ stopTimer();
315
+ if (closed || stopping)
316
+ return;
317
+ let earliest = null;
318
+ for (const { submission } of pendings.values()) {
319
+ if (submission.state !== 'reset')
320
+ continue;
321
+ const at = Date.parse(submission.reset);
322
+ if (earliest === null || at < earliest)
323
+ earliest = at;
324
+ }
325
+ if (earliest === null)
332
326
  return;
333
- timer = setInterval(() => {
334
- void enqueue();
335
- }, period);
327
+ // A Save House held back goes again only once the reset its answer named has passed.
328
+ resetTimer = setTimeout(() => {
329
+ resetTimer = undefined;
330
+ submitAll();
331
+ }, Math.max(0, earliest - Date.now()));
336
332
  }
337
333
  async function refuse(reason, provider = null) {
338
334
  stopTimer();
@@ -358,7 +354,6 @@ export function composeRuntime(options) {
358
354
  }
359
355
  for (const key of keys) {
360
356
  invalidModels.add(key);
361
- modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
362
357
  models.set(key, revoked);
363
358
  liveModels.delete(key);
364
359
  }
@@ -373,7 +368,7 @@ export function composeRuntime(options) {
373
368
  };
374
369
  const read = readConfirmed(record, before.confirmed);
375
370
  replicas.set(locator, read.kind === 'staged'
376
- ? replicaOf(read.confirmed, before.queue, rewriting(), before)
371
+ ? replicaOf(read.confirmed, before.queue, before)
377
372
  : { ...before, confirmed: { ...before.confirmed, record } });
378
373
  }
379
374
  rooms.set(locator, {
@@ -406,13 +401,11 @@ export function composeRuntime(options) {
406
401
  locator: room.locator,
407
402
  operations: room.operations,
408
403
  replica: room.replica,
409
- attachedModel: room.attachedModel,
410
404
  model: modelTypes(modelOf(room.locator)),
411
405
  typedSelection: selection === null
412
406
  ? null
413
407
  : {
414
408
  roots: selection.roots,
415
- attachedModel: selection.attachedModel,
416
409
  model: modelTypes(modelOf(room.locator, 'typed-selection')),
417
410
  },
418
411
  providerOwnedPaths: room.providerOwnedPaths,
@@ -429,9 +422,6 @@ export function composeRuntime(options) {
429
422
  discovered.clear();
430
423
  for (const room of listed)
431
424
  discovered.set(room.locator, room);
432
- for (const locator of wanted.keys())
433
- if (!listedLocators.has(locator))
434
- wanted.delete(locator);
435
425
  await dropUnlisted(listedLocators, local);
436
426
  for (const room of listed) {
437
427
  const held = replicas.get(room.locator)?.confirmed.record;
@@ -465,6 +455,7 @@ export function composeRuntime(options) {
465
455
  rooms.delete(locator);
466
456
  roomDrafts.delete(locator);
467
457
  unreadable.delete(locator);
458
+ stale.delete(locator);
468
459
  scopedRefusals.delete(locator);
469
460
  sourceRefusals.delete(locator);
470
461
  declarationRefusals.delete(locator);
@@ -473,7 +464,6 @@ export function composeRuntime(options) {
473
464
  models.delete(key);
474
465
  liveModels.delete(key);
475
466
  invalidModels.delete(key);
476
- modelEpoch.delete(key);
477
467
  }
478
468
  journal.record(`room unlisted ${locator}`);
479
469
  }
@@ -486,7 +476,6 @@ export function composeRuntime(options) {
486
476
  if (prior !== undefined &&
487
477
  prior.status !== 'unavailable' &&
488
478
  (persisted.status === 'unavailable' || !sameModel(prior.snapshot, persisted.snapshot))) {
489
- modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
490
479
  invalidModels.add(key);
491
480
  }
492
481
  const model = persisted.status === 'unavailable' ||
@@ -541,7 +530,7 @@ export function composeRuntime(options) {
541
530
  record: retyped,
542
531
  collection: parseCollection(retyped.documents, compositionOf(retyped)),
543
532
  };
544
- replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? [], rewriting(), prior));
533
+ replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? [], prior));
545
534
  const existing = rooms.get(record.locator);
546
535
  if (existing === undefined)
547
536
  list(record, false);
@@ -552,13 +541,11 @@ export function composeRuntime(options) {
552
541
  attachments: record.attachments,
553
542
  operations: record.operations,
554
543
  replica: record.replica,
555
- attachedModel: record.attachedModel,
556
544
  model: modelTypes(model),
557
545
  typedSelection: record.typedSelection === null
558
546
  ? null
559
547
  : {
560
548
  roots: record.typedSelection.roots,
561
- attachedModel: record.typedSelection.attachedModel,
562
549
  model: modelTypes(selectionModel),
563
550
  },
564
551
  });
@@ -626,7 +613,7 @@ export function composeRuntime(options) {
626
613
  return state !== undefined && state.submission.state !== 'submitted' && !state.lost;
627
614
  }
628
615
  async function settle(local, locator, before, confirmed, queue, settling = {}) {
629
- const withdrawn = withdrawals(confirmed, queue, withdrawable, rewriting());
616
+ const withdrawn = withdrawals(confirmed, queue, withdrawable);
630
617
  const kept = queue.filter((batch) => !withdrawn.includes(batch));
631
618
  const returning = [
632
619
  ...withdrawn.map((batch) => ({ batch, returned: { kind: 'withdrawn' } })).reverse(),
@@ -662,42 +649,73 @@ export function composeRuntime(options) {
662
649
  rememberRoomDraft(record);
663
650
  for (const batch of withdrawn)
664
651
  journal.record(`outcome ${locator} ${batch.id} withdrawn`);
665
- replicas.set(locator, replicaOf(confirmed, kept, rewriting(), before));
652
+ replicas.set(locator, replicaOf(confirmed, kept, before));
666
653
  return changeSet(locator, before ?? null, replicas.get(locator) ?? null, {
667
654
  room: settling.room,
668
655
  queue: kept.length !== (before?.queue.length ?? 0),
669
656
  drafts: [...written.keys()],
670
657
  });
671
658
  }
672
- function publish(local, room, confirmed) {
659
+ function adoptModels(locator, record) {
660
+ for (const [attached, model] of [
661
+ ['collection', record.model],
662
+ ['typed-selection', record.selectionModel],
663
+ ]) {
664
+ const key = modelKey(locator, attached);
665
+ models.set(key, model);
666
+ invalidModels.delete(key);
667
+ if (model.status === 'current')
668
+ liveModels.set(key, JSON.stringify(model.snapshot));
669
+ else if (model.status === 'unavailable')
670
+ liveModels.delete(key);
671
+ }
672
+ }
673
+ function remodel(locator, answers, held) {
674
+ const taken = (answer, previous, attached) => {
675
+ if (answer === undefined)
676
+ return previous;
677
+ const { state, refused } = attachedModel(answer, previous);
678
+ if (refused !== null) {
679
+ const where = refused.location === undefined ? '' : ` ${refused.location}`;
680
+ journal.record(`model invalid ${locator} ${attached} ${refused.identity} ${refused.rule}${where}`);
681
+ }
682
+ models.set(modelKey(locator, attached), state);
683
+ return state;
684
+ };
685
+ return {
686
+ collection: taken(answers?.collection, held.collection, 'collection'),
687
+ selection: taken(answers?.selection, held.selection, 'typed-selection'),
688
+ };
689
+ }
690
+ function publish(local, room, confirmed, base) {
673
691
  return exclusive(async () => {
674
692
  if (!continuing())
675
- return;
693
+ return true;
676
694
  const prior = replicas.get(room.locator);
677
- const retained = readConfirmed({
678
- ...confirmed.record,
679
- model: modelOf(room.locator),
680
- selectionModel: modelOf(room.locator, 'typed-selection'),
681
- }, prior?.confirmed);
682
- if (retained.kind !== 'staged')
683
- throw new Error(`Cannot retype staged Room ${room.locator}`);
695
+ if (base !== undefined &&
696
+ (prior?.confirmed.record.roomVersion !== base.record.roomVersion ||
697
+ prior.confirmed.record.revisions !== base.record.revisions)) {
698
+ return false;
699
+ }
700
+ adoptModels(room.locator, confirmed.record);
684
701
  const queue = prior?.queue ?? [];
685
- const roomVersion = retained.confirmed.record.roomVersion;
702
+ const roomVersion = confirmed.record.roomVersion;
686
703
  const accepted = queue.filter((batch) => {
687
704
  const accepted = pendings.get(batch.id)?.accepted;
688
705
  return (accepted !== undefined && options.adapter.reached(room.locator, roomVersion, accepted));
689
706
  });
690
- const set = await settle(local, room.locator, prior, retained.confirmed, queue.filter((batch) => !accepted.includes(batch)), { room: true });
691
- const target = wanted.get(room.locator);
692
- if (target !== undefined && options.adapter.reached(room.locator, roomVersion, target)) {
693
- wanted.delete(room.locator);
694
- }
695
- unreadable.delete(room.locator);
707
+ const set = await settle(local, room.locator, prior, confirmed, queue.filter((batch) => !accepted.includes(batch)), { room: true });
708
+ for (const batch of accepted)
709
+ pendings.delete(batch.id);
710
+ stale.delete(room.locator);
711
+ if (unreadable.delete(room.locator))
712
+ scopedRefusals.delete(room.locator);
696
713
  sourceRefusals.delete(room.locator);
697
714
  declarationRefusals.delete(room.locator);
698
715
  list(room, true);
699
716
  publishChangeSet(set);
700
717
  moveTo({ ...status, ...attention() });
718
+ return true;
701
719
  });
702
720
  }
703
721
  async function saveRoomDrafts(locator, named, writtenBy) {
@@ -726,7 +744,6 @@ export function composeRuntime(options) {
726
744
  operations: room.operations,
727
745
  providerOwnedPaths: room.providerOwnedPaths,
728
746
  providerLimits: limits,
729
- rewriting: rewriting(),
730
747
  visible: replica.visible,
731
748
  changes: parts.flatMap(({ changes }) => changes),
732
749
  bases: parts.flatMap(({ bases }) => bases),
@@ -750,15 +767,16 @@ export function composeRuntime(options) {
750
767
  return { outcome: 'refused', rule: 'durable-write' };
751
768
  }
752
769
  pendings.set(built.batch.id, { drafts: saved, submission: { state: 'waiting' }, lost: false });
770
+ recorded = false;
753
771
  for (const path of consumed)
754
772
  forgetRoomDraft(locator, path);
755
- replicas.set(locator, replicaOf(replica.confirmed, batches, rewriting(), replica));
773
+ replicas.set(locator, replicaOf(replica.confirmed, batches, replica));
756
774
  publishChangeSet(changeSet(locator, replica, replicas.get(locator) ?? null, {
757
775
  queue: true,
758
776
  drafts: consumed,
759
777
  }));
760
778
  moveTo({ ...saving(status), ...attention() });
761
- void enqueue();
779
+ submitLater(locator);
762
780
  return { outcome: 'saved', identifier: built.batch.id };
763
781
  }
764
782
  async function mark(local, locator, identifier, submission, lost) {
@@ -857,8 +875,6 @@ export function composeRuntime(options) {
857
875
  journal.record(`transfer action ${name}`);
858
876
  try {
859
877
  const result = await options.adapter.action(authorization, { name, input });
860
- if (result.kind === 'action' && result.at !== undefined)
861
- want(result.at);
862
878
  if (result.kind === 'scoped-refusal') {
863
879
  const room = replicas.get(result.locator)?.confirmed.record;
864
880
  if (room !== undefined)
@@ -890,32 +906,11 @@ export function composeRuntime(options) {
890
906
  options.adapter.reached(locator, written, roomVersion) &&
891
907
  !options.adapter.reached(locator, roomVersion, written));
892
908
  }
893
- function want({ locator, roomVersion }) {
894
- if (reachedBy(locator, roomVersion))
895
- return;
896
- const held = wanted.get(locator);
897
- if (held === undefined || options.adapter.reached(locator, roomVersion, held)) {
898
- wanted.set(locator, roomVersion);
899
- }
900
- }
901
- function reachOf(locator) {
902
- let target = wanted.get(locator);
903
- const versions = (replicas.get(locator)?.queue ?? []).flatMap((batch) => {
904
- const accepted = pendings.get(batch.id)?.accepted;
905
- return accepted === undefined ? [] : [accepted];
906
- });
907
- for (const version of versions) {
908
- if (target === undefined || options.adapter.reached(locator, version, target)) {
909
- target = version;
910
- }
911
- }
912
- return target === undefined || reachedBy(locator, target) ? undefined : target;
913
- }
914
- async function pullRefresh(authorized, confirmed, minPosition) {
909
+ async function pullRefresh(authorized, confirmed) {
915
910
  if (!continuing())
916
911
  return unconfirmed;
917
912
  try {
918
- const result = await refreshRoom(options.adapter, authorized, confirmed, continuing, minPosition);
913
+ const result = await refreshRoom(options.adapter, authorized, confirmed, (models, held) => remodel(confirmed.record.locator, models, held), continuing);
919
914
  const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
920
915
  journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
921
916
  return result;
@@ -932,7 +927,7 @@ export function composeRuntime(options) {
932
927
  const result = await bootstrapRoom(options.adapter, authorized, room, {
933
928
  collection: modelOf(room.locator),
934
929
  selection: modelOf(room.locator, 'typed-selection'),
935
- }, continuing);
930
+ }, (models, held) => remodel(room.locator, models, held), continuing);
936
931
  const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
937
932
  journal.record(`transfer bootstrap ${room.locator} ${detail}`);
938
933
  return result;
@@ -945,20 +940,28 @@ export function composeRuntime(options) {
945
940
  async function advance(room, local, authorized) {
946
941
  const held = replicas.get(room.locator);
947
942
  if (held !== undefined) {
948
- const refreshed = await pullRefresh(authorized, held.confirmed, reachOf(room.locator));
943
+ const refreshed = await pullRefresh(authorized, held.confirmed);
949
944
  if (!continuing())
950
945
  return unconfirmed;
951
946
  if (refreshed.kind === 'still-current') {
952
947
  if (sourceRefusals.has(room.locator))
953
948
  return unconfirmed;
949
+ stale.delete(room.locator);
954
950
  if (unreadable.delete(room.locator)) {
951
+ scopedRefusals.delete(room.locator);
955
952
  list(room, true);
956
953
  emitRooms();
957
954
  }
958
955
  return null;
959
956
  }
960
957
  if (refreshed.kind === 'advanced') {
961
- await publish(local, room, refreshed.confirmed);
958
+ if (!(await publish(local, room, refreshed.confirmed, held.confirmed))) {
959
+ const now = replicas.get(room.locator)?.confirmed.record.roomVersion;
960
+ const reached = now !== undefined &&
961
+ options.adapter.reached(room.locator, now, refreshed.confirmed.record.roomVersion);
962
+ return reached ? null : advance(room, local, authorized);
963
+ }
964
+ await retainModel(local, room, 'collection', modelOf(room.locator));
962
965
  return null;
963
966
  }
964
967
  if (refreshed.kind !== 'beyond-horizon')
@@ -973,7 +976,7 @@ export function composeRuntime(options) {
973
976
  return null;
974
977
  }
975
978
  async function withhold(withheld, room, local, progress) {
976
- progress.complete = false;
979
+ stale.add(room.locator);
977
980
  if (withheld.kind === 'scoped-refusal') {
978
981
  await scoped(withheld, room, local);
979
982
  }
@@ -1020,44 +1023,30 @@ export function composeRuntime(options) {
1020
1023
  (accepted.author === undefined ? '' : ` author ${accepted.author}`) +
1021
1024
  (accepted.time === undefined ? '' : ` time ${accepted.time}`));
1022
1025
  }
1023
- async function recordAccepted(room, local, submitted, accepted) {
1024
- await exclusive(async () => {
1025
- if (!continuing())
1026
- return;
1027
- const current = replicas.get(room.locator);
1028
- if (current === undefined || !current.queue.some((batch) => batch.id === submitted.id)) {
1029
- return;
1030
- }
1031
- const state = pendings.get(submitted.id);
1032
- if (state === undefined)
1033
- return;
1034
- await local.accept(room.locator, submitted.id, accepted.roomVersion);
1035
- pendings.set(submitted.id, { ...state, accepted: accepted.roomVersion });
1036
- logAccepted(room.locator, submitted, accepted);
1037
- emitQueue(room.locator);
1038
- });
1039
- }
1040
- async function commitAccept(room, local, submitted, accepted, base, overlay) {
1026
+ async function adoptAccepted(room, local, submitted, accepted) {
1041
1027
  if (!continuing())
1042
1028
  return unconfirmed;
1043
1029
  const current = replicas.get(room.locator);
1044
1030
  if (current === undefined || !current.queue.some((batch) => batch.id === submitted.id)) {
1045
1031
  return null;
1046
1032
  }
1047
- const remaining = current.queue.filter((batch) => batch.id !== submitted.id);
1048
- const retainedBase = readConfirmed({
1049
- ...base.record,
1050
- model: modelOf(room.locator),
1051
- selectionModel: modelOf(room.locator, 'typed-selection'),
1052
- }, base);
1053
- const applied = overlay && retainedBase.kind === 'staged'
1054
- ? applyReturned(retainedBase.confirmed, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion)
1055
- : retainedBase;
1056
- if (applied.kind !== 'staged')
1033
+ const held = current.confirmed.record.roomVersion;
1034
+ logAccepted(room.locator, submitted, accepted);
1035
+ const applied = options.adapter.reached(room.locator, held, accepted.roomVersion)
1036
+ ? { kind: 'staged', confirmed: current.confirmed }
1037
+ : applyReturned(current.confirmed, accepted.documents, accepted.revisions, accepted.removals, accepted.attachments, options.adapter.follows(room.locator, held, accepted.roomVersion)
1038
+ ? accepted.roomVersion
1039
+ : held, accepted.roomVersion);
1040
+ if (applied.kind !== 'staged') {
1041
+ const state = pendings.get(submitted.id);
1042
+ await local.accept(room.locator, submitted.id, accepted.roomVersion);
1043
+ if (state !== undefined)
1044
+ pendings.set(submitted.id, { ...state, accepted: accepted.roomVersion });
1045
+ emitQueue(room.locator);
1057
1046
  return applied;
1058
- const set = await settle(local, room.locator, current, applied.confirmed, remaining, {
1059
- room: true,
1060
- });
1047
+ }
1048
+ const set = await settle(local, room.locator, current, applied.confirmed, current.queue.filter((batch) => batch.id !== submitted.id), { room: true });
1049
+ pendings.delete(submitted.id);
1061
1050
  unreadable.delete(room.locator);
1062
1051
  sourceRefusals.delete(room.locator);
1063
1052
  declarationRefusals.delete(room.locator);
@@ -1066,17 +1055,8 @@ export function composeRuntime(options) {
1066
1055
  moveTo({ ...status, ...attention() });
1067
1056
  return null;
1068
1057
  }
1069
- async function accepting(room, local, submitted, accepted, pulled, fresh) {
1070
- const roomVersion = pulled.record.roomVersion;
1071
- if (options.adapter.reached(room.locator, roomVersion, accepted.roomVersion)) {
1072
- return committed(room, local, submitted, accepted, pulled, roomVersion === accepted.roomVersion);
1073
- }
1074
- if (fresh && continuing())
1075
- await publish(local, room, pulled);
1076
- return false;
1077
- }
1078
- async function committed(room, local, submitted, accepted, base, overlay) {
1079
- const failed = await exclusive(() => commitAccept(room, local, submitted, accepted, base, overlay));
1058
+ async function adopted(room, local, submitted, accepted) {
1059
+ const failed = await exclusive(() => adoptAccepted(room, local, submitted, accepted));
1080
1060
  if (failed === null)
1081
1061
  return true;
1082
1062
  if (failed.kind === 'refused' && continuing())
@@ -1089,10 +1069,8 @@ export function composeRuntime(options) {
1089
1069
  const head = replica?.queue[0];
1090
1070
  if (replica === undefined || head === undefined)
1091
1071
  return;
1092
- if (pendings.get(head.id)?.accepted !== undefined) {
1093
- progress.complete = false;
1072
+ if (pendings.get(head.id)?.accepted !== undefined)
1094
1073
  return;
1095
- }
1096
1074
  const state = pendings.get(head.id);
1097
1075
  const { submission, lost: earlier } = state ?? {
1098
1076
  submission: { state: 'waiting' },
@@ -1120,6 +1098,7 @@ export function composeRuntime(options) {
1120
1098
  return;
1121
1099
  }
1122
1100
  const opensBefore = openSignals;
1101
+ const answered = awaitingAnswer(room.locator);
1123
1102
  try {
1124
1103
  result = await options.adapter.mutate(authorized, {
1125
1104
  locator: room.locator,
@@ -1128,11 +1107,14 @@ export function composeRuntime(options) {
1128
1107
  }, (upload) => exclusive(() => keepUpload(local, room.locator, head.id, upload)));
1129
1108
  }
1130
1109
  catch {
1110
+ answered();
1131
1111
  progress.complete = false;
1132
1112
  if (continuing())
1133
1113
  progress.reachable = false;
1134
1114
  return;
1135
1115
  }
1116
+ if (result.kind !== 'accepted' || !continuing())
1117
+ answered();
1136
1118
  if (!continuing())
1137
1119
  return;
1138
1120
  if (result.kind === 'transport-uncertainty') {
@@ -1185,6 +1167,7 @@ export function composeRuntime(options) {
1185
1167
  const reset = result.reset;
1186
1168
  progress.complete = false;
1187
1169
  await exclusive(() => mark(local, room.locator, head.id, reset === undefined ? { state: 'waiting' } : { state: 'reset', reset }, lost));
1170
+ armTimer();
1188
1171
  return;
1189
1172
  }
1190
1173
  if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
@@ -1211,95 +1194,33 @@ export function composeRuntime(options) {
1211
1194
  return withhold(withheld, room, local, progress);
1212
1195
  continue;
1213
1196
  }
1214
- await recordAccepted(room, local, head, result);
1215
- const from = replicas.get(room.locator)?.confirmed;
1216
- if (from === undefined)
1217
- return;
1218
- const pulled = await pullRefresh(authorized, from, reachOf(room.locator));
1219
- if (!continuing())
1220
- return;
1221
- if (pulled.kind === 'revoked-authorization') {
1222
- await refuse('revoked-authorization');
1223
- return;
1224
- }
1225
- if (pulled.kind === 'scoped-refusal') {
1226
- await scoped(pulled, room, local);
1197
+ const kept = await adopted(room, local, head, result).finally(answered);
1198
+ if (!kept) {
1227
1199
  progress.complete = false;
1228
1200
  return;
1229
1201
  }
1230
- if (pulled.kind === 'still-current' || pulled.kind === 'advanced') {
1231
- const fresh = pulled.kind === 'advanced';
1232
- if (!(await accepting(room, local, head, result, fresh ? pulled.confirmed : from, fresh))) {
1233
- progress.complete = false;
1234
- return;
1235
- }
1236
- continue;
1237
- }
1238
- if (pulled.kind === 'beyond-horizon') {
1239
- const bootstrapped = await pullBootstrap(authorized, room);
1240
- if (!continuing())
1241
- return;
1242
- if (bootstrapped.kind === 'revoked-authorization') {
1243
- await refuse('revoked-authorization');
1244
- return;
1245
- }
1246
- if (bootstrapped.kind === 'scoped-refusal') {
1247
- await scoped(bootstrapped, room, local);
1248
- progress.complete = false;
1249
- return;
1250
- }
1251
- if (bootstrapped.kind === 'staged') {
1252
- if (!(await accepting(room, local, head, result, bootstrapped.confirmed, true))) {
1253
- progress.complete = false;
1254
- return;
1255
- }
1256
- continue;
1257
- }
1258
- progress.complete = false;
1259
- if (bootstrapped.kind === 'transport-uncertainty') {
1260
- progress.reachable = false;
1261
- }
1262
- else if (bootstrapped.kind === 'room-not-readable') {
1263
- unreadable.add(room.locator);
1264
- list(room, false);
1265
- emitRooms();
1266
- }
1267
- else if (bootstrapped.kind === 'refused') {
1268
- refusedSource(room, bootstrapped);
1269
- }
1270
- return;
1271
- }
1272
- progress.complete = false;
1273
- if (pulled.kind === 'transport-uncertainty') {
1274
- progress.reachable = false;
1275
- }
1276
- else if (pulled.kind === 'room-not-readable') {
1277
- unreadable.add(room.locator);
1278
- list(room, false);
1279
- emitRooms();
1280
- }
1281
- else if (pulled.kind === 'retryable-refusal') {
1282
- journal.record(`refusal ${pulled.kind} ${pulled.code}`);
1283
- }
1284
- else if (pulled.kind === 'refused') {
1285
- refusedSource(room, pulled);
1286
- }
1287
- return;
1288
1202
  }
1289
- return;
1203
+ }
1204
+ function awaitingAnswer(locator) {
1205
+ let release = () => { };
1206
+ const awaited = new Promise((resolve) => {
1207
+ release = resolve;
1208
+ });
1209
+ answering.set(locator, awaited);
1210
+ return () => {
1211
+ if (answering.get(locator) === awaited)
1212
+ answering.delete(locator);
1213
+ release();
1214
+ };
1290
1215
  }
1291
1216
  async function retainModel(local, room, attached, next) {
1292
1217
  const key = modelKey(room.locator, attached);
1293
- if (next.status === 'unavailable')
1294
- modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
1295
- const epoch = modelEpoch.get(key) ?? 0;
1296
1218
  const previous = modelOf(room.locator, attached);
1297
1219
  const heldBefore = replicas.get(room.locator)?.confirmed.record;
1298
1220
  if (JSON.stringify(previous) === JSON.stringify(next) &&
1299
1221
  (heldBefore === undefined ||
1300
1222
  (JSON.stringify(heldBefore.operations) === JSON.stringify(room.operations) &&
1301
1223
  JSON.stringify(heldBefore.replica) === JSON.stringify(room.replica) &&
1302
- JSON.stringify(heldBefore.attachedModel) === JSON.stringify(room.attachedModel) &&
1303
1224
  JSON.stringify(heldBefore.typedSelection) ===
1304
1225
  JSON.stringify(room.typedSelection ?? null))))
1305
1226
  return;
@@ -1310,15 +1231,12 @@ export function composeRuntime(options) {
1310
1231
  await exclusive(async () => {
1311
1232
  if (!continuing())
1312
1233
  return;
1313
- if (next.status !== 'unavailable' && epoch !== (modelEpoch.get(key) ?? 0))
1314
- return;
1315
1234
  const held = replicas.get(room.locator);
1316
1235
  if (held !== undefined) {
1317
1236
  const declared = {
1318
1237
  ...held.confirmed.record,
1319
1238
  operations: room.operations,
1320
1239
  replica: room.replica,
1321
- attachedModel: room.attachedModel,
1322
1240
  typedSelection: room.typedSelection ?? null,
1323
1241
  ...(attached === 'collection' ? { model: next } : { selectionModel: next }),
1324
1242
  };
@@ -1338,7 +1256,7 @@ export function composeRuntime(options) {
1338
1256
  queue: held.queue,
1339
1257
  });
1340
1258
  if (read.kind === 'staged') {
1341
- replicas.set(room.locator, replicaOf(read.confirmed, held.queue, rewriting(), held));
1259
+ replicas.set(room.locator, replicaOf(read.confirmed, held.queue, held));
1342
1260
  if (declarationRefusals.delete(room.locator)) {
1343
1261
  sourceRefusals.delete(room.locator);
1344
1262
  unreadable.delete(room.locator);
@@ -1362,77 +1280,6 @@ export function composeRuntime(options) {
1362
1280
  publishChangeSet(changeSet(room.locator, held, replicas.get(room.locator) ?? null, { room: true }));
1363
1281
  });
1364
1282
  }
1365
- async function acquireModel(local, room, authorized, selection) {
1366
- const attached = selection === null ? 'collection' : 'typed-selection';
1367
- const key = modelKey(room.locator, attached);
1368
- const declared = selection === null ? room.attachedModel : selection.attachedModel;
1369
- let previous = modelOf(room.locator, attached);
1370
- if (previous.status !== 'unavailable' &&
1371
- (declared === null || !sameModel(previous.snapshot, declared))) {
1372
- previous = { status: 'unavailable', reason: 'version-changed' };
1373
- await retainModel(local, room, attached, previous);
1374
- if (discoveryPaused)
1375
- return;
1376
- }
1377
- const epoch = modelEpoch.get(key) ?? 0;
1378
- let answer;
1379
- try {
1380
- answer = await options.adapter.model(authorized, selection === null
1381
- ? { locator: room.locator, attached: 'collection', model: declared }
1382
- : {
1383
- locator: room.locator,
1384
- attached: 'typed-selection',
1385
- roots: selection.roots,
1386
- model: declared,
1387
- });
1388
- }
1389
- catch {
1390
- answer = { kind: 'transport-uncertainty' };
1391
- }
1392
- if (discoveryPaused || epoch !== (modelEpoch.get(key) ?? 0))
1393
- return;
1394
- if (answer.kind === 'revoked-authorization') {
1395
- await refuse('revoked-authorization');
1396
- return;
1397
- }
1398
- if (answer.kind === 'transport-uncertainty') {
1399
- await retainModel(local, room, attached, cached(previous));
1400
- return;
1401
- }
1402
- if (answer.kind === 'model-unavailable') {
1403
- await retainModel(local, room, attached, {
1404
- status: 'unavailable',
1405
- reason: answer.reason,
1406
- });
1407
- return;
1408
- }
1409
- if (declared === null || !sameModel(answer.snapshot, declared)) {
1410
- await retainModel(local, room, attached, {
1411
- status: 'unavailable',
1412
- reason: 'version-changed',
1413
- });
1414
- return;
1415
- }
1416
- if (previous.status !== 'unavailable' &&
1417
- sameModel(previous.snapshot, answer.snapshot) &&
1418
- previous.snapshot.schema !== answer.snapshot.schema) {
1419
- await retainModel(local, room, attached, { status: 'unavailable', reason: 'invalid' });
1420
- return;
1421
- }
1422
- const verified = answer.snapshot.schema === null ? EMPTY_SCHEMA : verify(answer.snapshot.schema);
1423
- if (verified.verdict === 'refuse') {
1424
- const where = verified.location === undefined ? '' : ` ${verified.location}`;
1425
- journal.record(`model invalid ${room.locator} ${attached} ${answer.snapshot.identity} ${verified.rule}${where}`);
1426
- }
1427
- await retainModel(local, room, attached, verified.verdict === 'accept'
1428
- ? {
1429
- status: 'current',
1430
- snapshot: structuredClone(answer.snapshot),
1431
- verifiedWith: MD_MODEL,
1432
- types: verified.model.types,
1433
- }
1434
- : { status: 'unavailable', reason: 'invalid' });
1435
- }
1436
1283
  async function scoped(result, room, local) {
1437
1284
  scopedRefusals.set(room.locator, { code: result.code, fields: result.fields });
1438
1285
  let scopedAway = room;
@@ -1460,14 +1307,34 @@ export function composeRuntime(options) {
1460
1307
  emitRooms();
1461
1308
  journal.record(`scoped refusal ${room.locator} ${result.scope} ${result.code}`);
1462
1309
  }
1463
- async function synchronize(discovered, local, progress) {
1464
- const prepared = await Promise.allSettled(discovered.map((room) => prepareRoom(room, local, progress)));
1465
- const ready = discovered.filter((_, index) => {
1310
+ function heldRooms(listed) {
1311
+ return listed.flatMap((room) => {
1312
+ const record = replicas.get(room.locator)?.confirmed.record;
1313
+ if (record === undefined || record.replica.selection !== room.replica.selection)
1314
+ return [];
1315
+ return [
1316
+ {
1317
+ locator: room.locator,
1318
+ roomVersion: record.roomVersion,
1319
+ documents: record.documents,
1320
+ revisions: record.revisions,
1321
+ attachments: record.attachments,
1322
+ },
1323
+ ];
1324
+ });
1325
+ }
1326
+ async function synchronize(listed, local, progress, attempt) {
1327
+ const prepared = await Promise.allSettled(listed.map((room) => prepareRoom(room, local, progress)));
1328
+ const ready = listed.filter((_, index) => {
1466
1329
  const outcome = prepared[index];
1467
1330
  return outcome?.status === 'fulfilled' && outcome.value;
1468
1331
  });
1469
- const rooms = await Promise.allSettled(ready.map((room) => synchronizeRoom(room, local, progress)));
1470
- const failed = [...prepared, ...rooms].find((room) => room.status === 'rejected');
1332
+ if (attempt === 'opened')
1333
+ options.adapter.roomStream?.hold(heldRooms(ready));
1334
+ const loaded = await Promise.allSettled(ready
1335
+ .filter((room) => attempt === 'unstreamed' || !replicas.has(room.locator))
1336
+ .map((room) => loadRoom(room, local, progress)));
1337
+ const failed = [...prepared, ...loaded].find((room) => room.status === 'rejected');
1471
1338
  if (failed !== undefined)
1472
1339
  throw failed.reason;
1473
1340
  }
@@ -1484,11 +1351,8 @@ export function composeRuntime(options) {
1484
1351
  progress.complete = false;
1485
1352
  return false;
1486
1353
  }
1487
- const selection = room.typedSelection;
1488
- await Promise.all([
1489
- acquireModel(local, room, authorization, null),
1490
- ...(selection === undefined ? [] : [acquireModel(local, room, authorization, selection)]),
1491
- ]);
1354
+ if (prior !== undefined)
1355
+ await retainModel(local, room, 'collection', modelOf(room.locator));
1492
1356
  if (discoveryPaused || authorization === undefined)
1493
1357
  return false;
1494
1358
  if (room.replica.admission !== 'admitted') {
@@ -1496,24 +1360,53 @@ export function composeRuntime(options) {
1496
1360
  progress.complete = false;
1497
1361
  return false;
1498
1362
  }
1499
- const held = replicas.get(room.locator);
1500
- if (held !== undefined && held.confirmed.record.replica.selection !== room.replica.selection) {
1501
- progress.complete = false;
1502
- return false;
1503
- }
1504
1363
  return true;
1505
1364
  }
1506
- async function synchronizeRoom(room, local, progress) {
1365
+ async function caughtUp(room, local, progress) {
1366
+ const held = replicas.get(room.locator)?.confirmed.record.roomVersion;
1367
+ if (held === undefined || authorization === undefined)
1368
+ return;
1369
+ const behind = [announced.get(room.locator), room.roomVersion].some((later) => later !== undefined && !options.adapter.reached(room.locator, held, later));
1370
+ if (!behind)
1371
+ return;
1372
+ const withheld = await advance(room, local, authorization);
1373
+ if (withheld !== null)
1374
+ await withhold(withheld, room, local, progress);
1375
+ }
1376
+ function revoked(locator) {
1377
+ return ['collection', 'typed-selection'].some((attached) => {
1378
+ const model = modelOf(locator, attached);
1379
+ return model.status === 'unavailable' && model.reason === 'revoked';
1380
+ });
1381
+ }
1382
+ async function loadRoom(room, local, progress) {
1507
1383
  if (discoveryPaused || authorization === undefined)
1508
1384
  return;
1385
+ if (replicas.has(room.locator) && revoked(room.locator)) {
1386
+ await reload(room, local, progress);
1387
+ return;
1388
+ }
1509
1389
  const withheld = await advance(room, local, authorization);
1510
1390
  if (withheld !== null) {
1511
1391
  await withhold(withheld, room, local, progress);
1512
1392
  return;
1513
1393
  }
1514
- await submitRoom(room, local, authorization, progress);
1394
+ await caughtUp(room, local, progress);
1515
1395
  }
1516
- async function cycle(local, progress) {
1396
+ async function reload(room, local, progress) {
1397
+ if (discoveryPaused || authorization === undefined)
1398
+ return;
1399
+ const bootstrapped = await pullBootstrap(authorization, room);
1400
+ if (!continuing())
1401
+ return;
1402
+ if (bootstrapped.kind !== 'staged') {
1403
+ await withhold(bootstrapped, room, local, progress);
1404
+ return;
1405
+ }
1406
+ await publish(local, room, bootstrapped.confirmed);
1407
+ await caughtUp(room, local, progress);
1408
+ }
1409
+ async function discoverRooms(local, progress, attempt) {
1517
1410
  if (closed || authorization === undefined) {
1518
1411
  notifyReady();
1519
1412
  return;
@@ -1539,6 +1432,10 @@ export function composeRuntime(options) {
1539
1432
  }
1540
1433
  }
1541
1434
  progress.reachable = false;
1435
+ progress.complete = false;
1436
+ if (attempt === 'opened') {
1437
+ options.adapter.roomStream?.hold(heldRooms([...discovered.values()]));
1438
+ }
1542
1439
  notifyReady();
1543
1440
  return;
1544
1441
  }
@@ -1553,17 +1450,6 @@ export function composeRuntime(options) {
1553
1450
  notifyReady();
1554
1451
  return;
1555
1452
  }
1556
- if (!result.supportedVersions.includes(ROOM_CONTRACT_VERSION)) {
1557
- await refuse('no-matching-version');
1558
- notifyReady();
1559
- return;
1560
- }
1561
- if (result.mdModel.identity !== MD_MODEL.identity ||
1562
- result.mdModel.version !== MD_MODEL.version) {
1563
- await refuse('md-model-version-mismatch');
1564
- notifyReady();
1565
- return;
1566
- }
1567
1453
  for (const room of result.rooms) {
1568
1454
  if (room.operations === undefined ||
1569
1455
  !['read', 'sync', 'write', 'delete'].every((operation) => Array.isArray(room.operations[operation]) &&
@@ -1571,8 +1457,7 @@ export function composeRuntime(options) {
1571
1457
  room.replica === undefined ||
1572
1458
  typeof room.replica.selection !== 'string' ||
1573
1459
  room.replica.selection.length === 0 ||
1574
- !['admitted', 'unavailable'].includes(room.replica.admission) ||
1575
- !declaredIdentity(room.attachedModel)) {
1460
+ !['admitted', 'unavailable'].includes(room.replica.admission)) {
1576
1461
  journal.record(`invalid model or replica declaration ${room.locator}`);
1577
1462
  await refuse('missing-required-guarantee');
1578
1463
  notifyReady();
@@ -1585,14 +1470,6 @@ export function composeRuntime(options) {
1585
1470
  return;
1586
1471
  }
1587
1472
  }
1588
- const declared = new Set(result.capabilities.map((capability) => capability.name));
1589
- for (const required of options.requiredGuarantees) {
1590
- if (!knownCapabilities.has(required) || !declared.has(required)) {
1591
- await refuse('missing-required-guarantee');
1592
- notifyReady();
1593
- return;
1594
- }
1595
- }
1596
1473
  if (refusal !== null) {
1597
1474
  refusal = null;
1598
1475
  emitRefusal();
@@ -1600,26 +1477,13 @@ export function composeRuntime(options) {
1600
1477
  const previousCapabilities = capabilities;
1601
1478
  capabilities = result.capabilities;
1602
1479
  notifyReady();
1603
- if (declared.has('room-version-notifications'))
1604
- observeRoomVersions();
1605
1480
  if (JSON.stringify(result.providerLimits) !== JSON.stringify(limits) ||
1606
1481
  JSON.stringify(result.capabilities) !== JSON.stringify(previousCapabilities)) {
1607
1482
  limits = result.providerLimits;
1608
1483
  await local.declare(limits, capabilities);
1609
1484
  }
1610
1485
  await applyDiscovery(result.rooms, local);
1611
- handSelected(result.rooms
1612
- .filter((room) => room.replica.admission === 'admitted')
1613
- .map((room) => room.locator)
1614
- .sort());
1615
- progress.complete = true;
1616
- await synchronize(result.rooms, local, progress);
1617
- if (closed || discoveryPaused)
1618
- return;
1619
- await conversations.reconcile();
1620
- if (status.providerUnreachable)
1621
- conversations.reconnected();
1622
- await conversations.catchUp(progress);
1486
+ await synchronize(result.rooms, local, progress, attempt);
1623
1487
  }
1624
1488
  async function ensureAuthorization() {
1625
1489
  if (authorization !== undefined)
@@ -1633,64 +1497,246 @@ export function composeRuntime(options) {
1633
1497
  throw error;
1634
1498
  }
1635
1499
  }
1636
- async function connect() {
1637
- if (closed || stopping || discoveryPaused) {
1638
- notifyReady();
1500
+ function task(work) {
1501
+ if (stopping)
1502
+ return chain;
1503
+ chain = chain.then(work, work).catch(() => undefined);
1504
+ return chain;
1505
+ }
1506
+ function sync(attempt) {
1507
+ return task(async () => {
1508
+ if (closed || stopping || discoveryPaused) {
1509
+ notifyReady();
1510
+ return;
1511
+ }
1512
+ const progress = { complete: true, reachable: true };
1513
+ underway = attempt === 'opened' ? progress : null;
1514
+ complete = false;
1515
+ recorded = false;
1516
+ moveTo(transferring(status));
1517
+ try {
1518
+ const local = await openLocal();
1519
+ if (closed)
1520
+ return;
1521
+ await ensureAuthorization();
1522
+ if (closed)
1523
+ return;
1524
+ await discoverRooms(local, progress, attempt);
1525
+ }
1526
+ catch {
1527
+ progress.complete = false;
1528
+ if (attempt === 'opened') {
1529
+ options.adapter.roomStream?.hold(heldRooms([...discovered.values()]));
1530
+ }
1531
+ }
1532
+ finally {
1533
+ notifyReady();
1534
+ }
1535
+ if (attempt === 'opened')
1536
+ return;
1537
+ await finishOpening(progress);
1538
+ });
1539
+ }
1540
+ function closedSocket(revoked) {
1541
+ const opened = socketOpen;
1542
+ socketOpen = false;
1543
+ if (revoked) {
1544
+ void refuse('revoked-authorization');
1639
1545
  return;
1640
1546
  }
1641
- startTimer();
1642
- const progress = { complete: false, reachable: true };
1643
- let completedAt = new Date().toISOString();
1644
- try {
1645
- const local = await openLocal();
1646
- if (closed)
1647
- return;
1648
- await ensureAuthorization();
1649
- if (closed)
1547
+ if (opened || status.providerUnreachable)
1548
+ return;
1549
+ moveTo({ ...status, providerUnreachable: true });
1550
+ }
1551
+ function applyOpening(expired) {
1552
+ return task(async () => {
1553
+ const progress = underway;
1554
+ underway = null;
1555
+ if (closed || stopping)
1650
1556
  return;
1651
- await cycle(local, progress);
1652
- if (closed)
1557
+ if (progress === null) {
1558
+ await reloadExpired(expired);
1653
1559
  return;
1654
- if (progress.complete) {
1655
- completedAt = new Date().toISOString();
1656
- await local.complete(completedAt);
1657
1560
  }
1561
+ try {
1562
+ const local = await openLocal();
1563
+ const held = [...discovered.values()].filter((room) => replicas.has(room.locator));
1564
+ const reloading = held.filter((room) => expired.includes(room.locator) || revoked(room.locator));
1565
+ const reloaded = await Promise.allSettled(reloading.map((room) => reload(room, local, progress)));
1566
+ if (progress.reachable) {
1567
+ for (const room of held)
1568
+ if (!reloading.includes(room))
1569
+ await confirmModels(room, local);
1570
+ }
1571
+ const failed = reloaded.find((outcome) => outcome.status === 'rejected');
1572
+ if (failed !== undefined)
1573
+ throw failed.reason;
1574
+ }
1575
+ catch {
1576
+ progress.complete = false;
1577
+ progress.reachable = false;
1578
+ }
1579
+ await finishOpening(progress);
1580
+ });
1581
+ }
1582
+ async function reloadExpired(expired) {
1583
+ const progress = { complete: true, reachable: true };
1584
+ try {
1585
+ const local = await openLocal();
1586
+ await Promise.allSettled([...discovered.values()]
1587
+ .filter((room) => replicas.has(room.locator) && expired.includes(room.locator))
1588
+ .map((room) => reload(room, local, progress)));
1658
1589
  }
1659
1590
  catch {
1660
- progress.complete = false;
1661
- notifyReady();
1591
+ progress.reachable = false;
1592
+ }
1593
+ await rest(progress);
1594
+ }
1595
+ async function confirmModels(room, local) {
1596
+ for (const attached of ['collection', 'typed-selection']) {
1597
+ const model = modelOf(room.locator, attached);
1598
+ if (model.status === 'cached') {
1599
+ await retainModel(local, room, attached, { ...model, status: 'current' });
1600
+ }
1601
+ }
1602
+ }
1603
+ function submitLater(locator) {
1604
+ const running = submitting.get(locator);
1605
+ if (running !== undefined) {
1606
+ running.again = true;
1662
1607
  return;
1663
1608
  }
1664
- finally {
1665
- notifyReady();
1666
- moveTo(settled(status, progress, completedAt, pending(), attention()));
1609
+ const run = { again: false, done: Promise.resolve() };
1610
+ submitting.set(locator, run);
1611
+ run.done = (async () => {
1612
+ try {
1613
+ do {
1614
+ run.again = false;
1615
+ const room = discovered.get(locator);
1616
+ if (closed || stopping || discoveryPaused || room === undefined)
1617
+ return;
1618
+ const progress = { complete: true, reachable: true };
1619
+ try {
1620
+ await ensureAuthorization();
1621
+ if (authorization === undefined)
1622
+ return;
1623
+ await submitRoom(room, await openLocal(), authorization, progress);
1624
+ }
1625
+ catch {
1626
+ progress.reachable = false;
1627
+ }
1628
+ armTimer();
1629
+ await rest(progress);
1630
+ } while (run.again);
1631
+ }
1632
+ finally {
1633
+ submitting.delete(locator);
1634
+ }
1635
+ })();
1636
+ }
1637
+ function submitAll() {
1638
+ for (const [locator, replica] of replicas) {
1639
+ if (replica.queue.length > 0)
1640
+ submitLater(locator);
1667
1641
  }
1668
1642
  }
1669
- async function run() {
1643
+ async function finishOpening(progress) {
1670
1644
  try {
1671
- await connect();
1645
+ if (closed || discoveryPaused) {
1646
+ progress.complete = false;
1647
+ return;
1648
+ }
1649
+ submitAll();
1650
+ await conversations.reconcile();
1651
+ if (status.providerUnreachable)
1652
+ conversations.reconnected();
1653
+ const reading = { complete: true, reachable: true };
1654
+ await conversations.catchUp(reading);
1655
+ if (!reading.reachable)
1656
+ progress.reachable = false;
1657
+ }
1658
+ catch {
1659
+ progress.complete = false;
1672
1660
  }
1673
1661
  finally {
1674
- inflight = false;
1675
- if (queued && !closed) {
1676
- queued = false;
1677
- void enqueue();
1678
- }
1662
+ complete = progress.complete;
1663
+ await rest(progress);
1679
1664
  }
1680
1665
  }
1681
- function observeRoomVersions() {
1682
- const path = options.adapter.roomVersions;
1683
- if (path === undefined || withdrawNotifications !== undefined)
1666
+ async function rest(progress) {
1667
+ if (closed)
1684
1668
  return;
1685
- withdrawNotifications = path.observe((notification) => {
1686
- if (closed || stopping || discoveryPaused)
1669
+ let lastCompleted = status.lastCompleted;
1670
+ let synced = complete && stale.size === 0 && !pending();
1671
+ if (synced && !recorded) {
1672
+ const completedAt = new Date().toISOString();
1673
+ try {
1674
+ await (await openLocal()).complete(completedAt);
1675
+ recorded = true;
1676
+ lastCompleted = completedAt;
1677
+ }
1678
+ catch {
1679
+ synced = false;
1680
+ }
1681
+ }
1682
+ moveTo({
1683
+ state: pending() ? 'pending' : synced ? 'synced' : 'syncing',
1684
+ lastCompleted,
1685
+ providerUnreachable: !progress.reachable,
1686
+ ...attention(),
1687
+ });
1688
+ }
1689
+ function applyRoom(locator, roomVersion) {
1690
+ announced.set(locator, roomVersion);
1691
+ return task(async () => {
1692
+ await answering.get(locator);
1693
+ if (closed || stopping || discoveryPaused || authorization === undefined)
1687
1694
  return;
1688
- if (notification.modelChanged === true && handed?.includes(notification.locator) === true) {
1689
- void enqueue();
1695
+ const room = discovered.get(locator);
1696
+ const held = replicas.get(locator);
1697
+ if (room === undefined || room.replica.admission !== 'admitted')
1698
+ return;
1699
+ if (held !== undefined &&
1700
+ (held.confirmed.record.replica.selection !== room.replica.selection ||
1701
+ options.adapter.reached(locator, held.confirmed.record.roomVersion, roomVersion))) {
1702
+ return;
1703
+ }
1704
+ const during = underway;
1705
+ const progress = during ?? { complete: true, reachable: true };
1706
+ if (during === null)
1707
+ moveTo(transferring(status));
1708
+ try {
1709
+ const local = await openLocal();
1710
+ const withheld = await advance(room, local, authorization);
1711
+ if (withheld !== null)
1712
+ await withhold(withheld, room, local, progress);
1713
+ }
1714
+ catch {
1715
+ stale.add(locator);
1716
+ }
1717
+ finally {
1718
+ if (during === null)
1719
+ await rest(progress);
1720
+ }
1721
+ });
1722
+ }
1723
+ function observeStream() {
1724
+ const stream = options.adapter.roomStream;
1725
+ if (stream === undefined || withdrawStream !== undefined)
1726
+ return;
1727
+ withdrawStream = stream.observe((event) => {
1728
+ if (closed || stopping)
1690
1729
  return;
1730
+ if (event.kind === 'opened') {
1731
+ socketOpen = true;
1732
+ void sync('opened');
1691
1733
  }
1692
- notices.set(notification.locator, notification.roomVersion);
1693
- chain = chain.then(runNotified, runNotified);
1734
+ else if (event.kind === 'closed')
1735
+ closedSocket(event.revoked);
1736
+ else if (event.kind === 'opening')
1737
+ void applyOpening(event.expired);
1738
+ else
1739
+ void applyRoom(event.locator, event.roomVersion);
1694
1740
  });
1695
1741
  }
1696
1742
  function writesHeld() {
@@ -1711,7 +1757,7 @@ export function composeRuntime(options) {
1711
1757
  listener(mode);
1712
1758
  }
1713
1759
  if (releasing)
1714
- void enqueue();
1760
+ submitAll();
1715
1761
  }
1716
1762
  function observeConversations() {
1717
1763
  const stream = options.adapter.conversations;
@@ -1729,7 +1775,7 @@ export function composeRuntime(options) {
1729
1775
  if (currencyQueued || closed || stopping)
1730
1776
  return;
1731
1777
  currencyQueued = true;
1732
- chain = chain.then(runCurrency, runCurrency);
1778
+ void task(runCurrency);
1733
1779
  }
1734
1780
  async function runCurrency() {
1735
1781
  currencyQueued = false;
@@ -1755,95 +1801,12 @@ export function composeRuntime(options) {
1755
1801
  }
1756
1802
  }
1757
1803
  }
1758
- function handSelected(locators) {
1759
- const path = options.adapter.roomVersions;
1760
- if (path === undefined || withdrawNotifications === undefined)
1761
- return;
1762
- if (handed !== undefined && JSON.stringify(locators) === JSON.stringify(handed))
1763
- return;
1764
- handed = locators;
1765
- path.select(locators);
1766
- }
1767
- async function runNotified() {
1768
- const announced = [...notices];
1769
- notices.clear();
1770
- if (announced.length === 0 || closed || stopping || discoveryPaused)
1771
- return;
1772
- const resting = status;
1773
- const progress = { complete: false, reachable: !resting.providerUnreachable };
1774
- moveTo(transferring(resting));
1775
- try {
1776
- const local = await openLocal();
1777
- for (const [locator, roomVersion] of announced) {
1778
- if (closed || discoveryPaused || authorization === undefined)
1779
- return;
1780
- if (handed?.includes(locator) !== true)
1781
- continue;
1782
- const room = discovered.get(locator);
1783
- const held = replicas.get(locator);
1784
- if (room === undefined)
1785
- continue;
1786
- if (sourceRefusals.has(locator)) {
1787
- void enqueue();
1788
- continue;
1789
- }
1790
- if (held === undefined)
1791
- continue;
1792
- if (held.confirmed.record.roomVersion === roomVersion)
1793
- continue;
1794
- const withheld = await advance(room, local, authorization);
1795
- if (withheld === null)
1796
- continue;
1797
- if (withheld.kind === 'refused') {
1798
- void enqueue();
1799
- continue;
1800
- }
1801
- if (withheld.kind === 'scoped-refusal') {
1802
- await scoped(withheld, room, local);
1803
- continue;
1804
- }
1805
- if (withheld.kind === 'transport-uncertainty') {
1806
- progress.reachable = false;
1807
- continue;
1808
- }
1809
- if (withheld.kind === 'room-not-readable') {
1810
- unreadable.add(locator);
1811
- list(room, false);
1812
- emitRooms();
1813
- continue;
1814
- }
1815
- if (withheld.kind === 'revoked-authorization') {
1816
- await refuse('revoked-authorization');
1817
- return;
1818
- }
1819
- }
1820
- }
1821
- catch {
1822
- progress.reachable = false;
1823
- }
1824
- finally {
1825
- if (!closed) {
1826
- moveTo(settled(resting, progress, resting.lastCompleted, pending(), attention()));
1827
- }
1828
- }
1829
- }
1830
- function enqueue() {
1831
- if (stopping)
1832
- return chain;
1833
- if (inflight) {
1834
- queued = true;
1835
- return chain;
1836
- }
1837
- inflight = true;
1838
- chain = chain.then(run, run);
1839
- return chain;
1840
- }
1841
1804
  function close() {
1842
1805
  if (closed)
1843
1806
  return;
1844
1807
  closed = true;
1845
- withdrawNotifications?.();
1846
- withdrawNotifications = undefined;
1808
+ withdrawStream?.();
1809
+ withdrawStream = undefined;
1847
1810
  withdrawConversations?.();
1848
1811
  withdrawConversations = undefined;
1849
1812
  withdrawServiceMode?.();
@@ -1867,6 +1830,7 @@ export function composeRuntime(options) {
1867
1830
  stopping = true;
1868
1831
  stopTimer();
1869
1832
  await chain.catch(() => undefined);
1833
+ await Promise.all([...submitting.values()].map((run) => run.done));
1870
1834
  await mutations;
1871
1835
  close();
1872
1836
  }
@@ -1875,11 +1839,29 @@ export function composeRuntime(options) {
1875
1839
  options.signal?.addEventListener('abort', close, { once: true });
1876
1840
  if (options.signal?.aborted)
1877
1841
  close();
1878
- void openLocal().catch(() => {
1842
+ observeStream();
1843
+ void openLocal().then(() => {
1844
+ if (capabilities.length > 0)
1845
+ notifyReady();
1846
+ }, () => {
1879
1847
  notifyReady();
1880
1848
  });
1881
- void enqueue();
1882
- function watch(topic, input, deliver) {
1849
+ if (options.adapter.roomStream === undefined)
1850
+ void sync('unstreamed');
1851
+ function planned(input, reach) {
1852
+ return planOf(input, roomFacts(reach), planReach(reach));
1853
+ }
1854
+ function watchPlan(plan, deliver) {
1855
+ return readSide.watch(plan, identityOf(plan), deliver);
1856
+ }
1857
+ async function searched(input, reach) {
1858
+ const plan = searchPlanOf(input);
1859
+ const selected = plan.documents === undefined && reach === null
1860
+ ? null
1861
+ : (await readSide.query(filterOf(plan.documents ?? {}, roomFacts(reach), planReach(reach)))).documents;
1862
+ return searchIndex.search(reach === null || reach.conversations ? plan : { ...plan, conversations: [] }, selected);
1863
+ }
1864
+ const watch = (topic, input, deliver) => {
1883
1865
  if (topic === 'room-drafts') {
1884
1866
  const listener = { notify: deliver, ready: false };
1885
1867
  roomDraftListeners.add(listener);
@@ -1962,9 +1944,8 @@ export function composeRuntime(options) {
1962
1944
  queueSubscriptions.delete(queue);
1963
1945
  };
1964
1946
  }
1965
- const plan = planOf(input[0], roomFacts());
1966
- return readSide.watch(plan, identityOf(plan), deliver);
1967
- }
1947
+ return watchPlan(planned(input[0], null), deliver);
1948
+ };
1968
1949
  const md = {
1969
1950
  loaded() {
1970
1951
  return openLocal().then(() => undefined);
@@ -1972,15 +1953,8 @@ export function composeRuntime(options) {
1972
1953
  rooms() {
1973
1954
  return listed();
1974
1955
  },
1975
- query: ((input) => readSide.query(planOf(input, roomFacts()))),
1976
- async search(input) {
1977
- const plan = searchPlanOf(input);
1978
- const selected = plan.documents === undefined
1979
- ? null
1980
- : (await readSide.query(filterOf(plan.documents, roomFacts())))
1981
- .documents;
1982
- return searchIndex.search(plan, selected);
1983
- },
1956
+ query: ((input) => readSide.query(planned(input, null))),
1957
+ search: (input) => searched(input, null),
1984
1958
  types(locator) {
1985
1959
  return {
1986
1960
  collection: modelTypes(modelOf(locator)),
@@ -2087,9 +2061,7 @@ export function composeRuntime(options) {
2087
2061
  persistence() {
2088
2062
  return options.shell.persistentStorage;
2089
2063
  },
2090
- observe: ((topic, ...input) => opened((deliver) => watch(topic, input, (message) => {
2091
- deliver(structuredClone(message));
2092
- }), topic === 'query')),
2064
+ observe: observing(watch),
2093
2065
  async reauthorize() {
2094
2066
  if (refusal === null || !refusal.reauthorizationOffered)
2095
2067
  return;
@@ -2098,8 +2070,19 @@ export function composeRuntime(options) {
2098
2070
  await mutations;
2099
2071
  authorization = undefined;
2100
2072
  discoveryPaused = false;
2101
- await enqueue();
2073
+ if (options.adapter.roomStream === undefined)
2074
+ await sync('unstreamed');
2075
+ else
2076
+ await task(ensureAuthorization);
2102
2077
  },
2103
2078
  };
2104
- return { md, stop, watch };
2079
+ const reachable = {
2080
+ md,
2081
+ watch,
2082
+ plan: planned,
2083
+ query: (plan) => readSide.query(plan),
2084
+ watchPlan,
2085
+ search: searched,
2086
+ };
2087
+ return { md, stop, watch, within: (reach) => narrowed(reachable, reach) };
2105
2088
  }