@agentshouse/mdruntime 0.1.0-c8c4b020a3ec7 → 0.1.0-c967d1ca66fee

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 (98) hide show
  1. package/AGENTS.md +94 -33
  2. package/README.md +10 -10
  3. package/dist/adapter.d.ts +92 -46
  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 +47 -0
  9. package/dist/bootstrap.d.ts +20 -6
  10. package/dist/bootstrap.d.ts.map +1 -1
  11. package/dist/bootstrap.js +89 -35
  12. package/dist/change-set.d.ts.map +1 -1
  13. package/dist/change-set.js +13 -7
  14. package/dist/change.d.ts +4 -0
  15. package/dist/change.d.ts.map +1 -1
  16. package/dist/client.d.ts +8 -5
  17. package/dist/client.d.ts.map +1 -1
  18. package/dist/client.js +57 -24
  19. package/dist/collection.d.ts +6 -11
  20. package/dist/collection.d.ts.map +1 -1
  21. package/dist/collection.js +56 -19
  22. package/dist/connection.d.ts +20 -16
  23. package/dist/connection.d.ts.map +1 -1
  24. package/dist/connection.js +711 -587
  25. package/dist/conversation.d.ts +23 -11
  26. package/dist/conversation.d.ts.map +1 -1
  27. package/dist/conversation.js +252 -106
  28. package/dist/failure.d.ts +13 -0
  29. package/dist/failure.d.ts.map +1 -0
  30. package/dist/failure.js +19 -0
  31. package/dist/host.d.ts +8 -1
  32. package/dist/host.d.ts.map +1 -1
  33. package/dist/host.js +38 -38
  34. package/dist/identity.d.ts +1 -0
  35. package/dist/identity.d.ts.map +1 -1
  36. package/dist/identity.js +7 -1
  37. package/dist/index.d.ts +5 -4
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/memory.d.ts +12 -5
  40. package/dist/memory.d.ts.map +1 -1
  41. package/dist/memory.js +108 -36
  42. package/dist/model.d.ts +13 -12
  43. package/dist/model.d.ts.map +1 -1
  44. package/dist/model.js +6 -3
  45. package/dist/observation.d.ts +2 -0
  46. package/dist/observation.d.ts.map +1 -1
  47. package/dist/observation.js +24 -14
  48. package/dist/open.d.ts +2 -0
  49. package/dist/open.d.ts.map +1 -0
  50. package/dist/open.js +1 -0
  51. package/dist/outcome.d.ts +22 -3
  52. package/dist/outcome.d.ts.map +1 -1
  53. package/dist/outcome.js +35 -1
  54. package/dist/overlay.d.ts +1 -1
  55. package/dist/overlay.d.ts.map +1 -1
  56. package/dist/overlay.js +19 -15
  57. package/dist/paths.d.ts +0 -1
  58. package/dist/paths.d.ts.map +1 -1
  59. package/dist/paths.js +0 -16
  60. package/dist/query.d.ts +10 -3
  61. package/dist/query.d.ts.map +1 -1
  62. package/dist/query.js +78 -36
  63. package/dist/reach.d.ts +40 -0
  64. package/dist/reach.d.ts.map +1 -0
  65. package/dist/reach.js +272 -0
  66. package/dist/react.d.ts +40 -0
  67. package/dist/react.d.ts.map +1 -0
  68. package/dist/react.js +64 -0
  69. package/dist/read-side.d.ts +4 -0
  70. package/dist/read-side.d.ts.map +1 -1
  71. package/dist/read-side.js +116 -54
  72. package/dist/refresh.d.ts +12 -1
  73. package/dist/refresh.d.ts.map +1 -1
  74. package/dist/refresh.js +35 -22
  75. package/dist/replica.d.ts +6 -6
  76. package/dist/replica.d.ts.map +1 -1
  77. package/dist/replica.js +53 -39
  78. package/dist/room-draft.d.ts +11 -8
  79. package/dist/room-draft.d.ts.map +1 -1
  80. package/dist/room-draft.js +19 -26
  81. package/dist/rows.d.ts +4 -1
  82. package/dist/rows.d.ts.map +1 -1
  83. package/dist/rows.js +82 -39
  84. package/dist/save.d.ts +4 -3
  85. package/dist/save.d.ts.map +1 -1
  86. package/dist/save.js +127 -68
  87. package/dist/search.d.ts +3 -2
  88. package/dist/search.d.ts.map +1 -1
  89. package/dist/status.d.ts +2 -2
  90. package/dist/status.d.ts.map +1 -1
  91. package/dist/status.js +0 -8
  92. package/dist/store.d.ts +25 -2
  93. package/dist/store.d.ts.map +1 -1
  94. package/dist/store.js +206 -90
  95. package/dist/wire.d.ts +55 -19
  96. package/dist/wire.d.ts.map +1 -1
  97. package/dist/wire.js +62 -61
  98. package/package.json +14 -2
@@ -1,56 +1,43 @@
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
+ import { markdownPath, roomOfKey } from './rows.js';
6
7
  import { createSearchIndex, searchPlanOf, } from './search.js';
7
- import { ROOM_CONTRACT_VERSION, VERSION_1_CAPABILITIES, } from './adapter.js';
8
8
  import { bootstrapRoom } from './bootstrap.js';
9
9
  import { changeSet, mergeChangeSets } from './change-set.js';
10
10
  import { createConversationStore, } from './conversation.js';
11
- import { MD_MODEL, parseCollection, validRoot } from './collection.js';
11
+ import { validRoot } from './collection.js';
12
+ import { runtimeError } from './failure.js';
12
13
  import { createLog } from './log.js';
14
+ import { calledResult, historyAnswer, undeclared, UNCERTAIN, } from './outcome.js';
13
15
  import { refreshRoom } from './refresh.js';
14
16
  import { buildBatch } from './save.js';
15
- import { applyReturned, compositionOf, landed, readConfirmed, readReclassified, replicaOf, sameMembership, withdrawals, } from './replica.js';
16
- import { changedParts, changesOf, held, readParts, returnedInto, underneath, } from './room-draft.js';
17
- import { settled, same, saving, STARTING, transferring, } from './status.js';
17
+ import { applyReturned, compositionOf, confirmedOf, landed, readReclassified, replicaOf, sameMembership, withdrawals, } from './replica.js';
18
+ import { changedParts, changesOf, held, readParts, returnedBy, returnedInto, staleWrite, underneath, } from './room-draft.js';
19
+ import { same, saving, STARTING, transferring, } from './status.js';
18
20
  import { detectStorageLoss, nextGeneration } from './durability.js';
19
21
  import { roomDraftRevision } from './identity.js';
20
22
  import { beneath, normalize } from './paths.js';
21
23
  import { openRuntimeStore, } from './store.js';
22
24
  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);
25
+ import { governs, holds, narrowed, planReach, } from './reach.js';
27
26
  function within(root, path) {
28
27
  return root === '' || beneath(root, path);
29
28
  }
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
29
  function declaredSelection(room) {
41
30
  const selection = room.typedSelection;
42
31
  if (selection === undefined)
43
32
  return true;
44
33
  if (!Array.isArray(selection.roots) || selection.roots.length === 0)
45
34
  return false;
46
- if (!declaredIdentity(selection.attachedModel))
47
- return false;
48
35
  return selection.roots.every((root, index) => validRoot(root) &&
49
36
  root !== '' &&
50
37
  selection.roots.every((other, at) => at === index || !within(other, root)));
51
38
  }
52
39
  export function composeRuntime(options) {
53
- const period = options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
40
+ const localSaves = options.adapter.localSaves === true;
54
41
  let authorization = options.authorization;
55
42
  let opening;
56
43
  let store;
@@ -58,20 +45,14 @@ export function composeRuntime(options) {
58
45
  const pendings = new Map();
59
46
  const roomDrafts = new Map();
60
47
  const presentations = new Map();
61
- const wanted = new Map();
62
48
  let limits = [];
63
49
  let capabilities = [];
64
- function rewriting() {
65
- return capabilities.some((capability) => capability.name === 'move-reference-rewrites')
66
- ? 'provider'
67
- : 'runtime';
68
- }
69
50
  const rooms = new Map();
51
+ const counted = new Map();
70
52
  const unreadable = new Set();
71
53
  const models = new Map();
72
54
  const liveModels = new Map();
73
55
  const invalidModels = new Set();
74
- const modelEpoch = new Map();
75
56
  const discovered = new Map();
76
57
  let roomsListed = false;
77
58
  const scopedRefusals = new Map();
@@ -100,16 +81,21 @@ export function composeRuntime(options) {
100
81
  let status = STARTING;
101
82
  let shown = STARTING;
102
83
  let discoveryPaused = false;
103
- let timer;
84
+ let resetTimer;
104
85
  let closed = false;
105
86
  let stopping = false;
106
- let withdrawNotifications;
107
- let handed;
108
- const notices = new Map();
87
+ let withdrawStream;
88
+ const announced = new Map();
89
+ let underway = null;
90
+ let streamed = null;
91
+ let handed = false;
92
+ const submitting = new Map();
93
+ const answering = new Map();
94
+ let complete = false;
95
+ let recorded = false;
96
+ const stale = new Set();
109
97
  let chain = Promise.resolve();
110
98
  let mutations = Promise.resolve();
111
- let inflight = false;
112
- let queued = false;
113
99
  const roomListeners = new Set();
114
100
  const refusalListeners = new Set();
115
101
  const storageLossListeners = new Set();
@@ -120,13 +106,19 @@ export function composeRuntime(options) {
120
106
  const readSide = createReadSide();
121
107
  let withdrawConversations;
122
108
  let currencyQueued = false;
109
+ let socketOpen = false;
123
110
  let serviceMode = 'open';
124
111
  let haltedRefusal = false;
125
112
  let openSignals = 0;
126
113
  const serviceModeListeners = new Set();
114
+ let member = null;
115
+ const memberListeners = new Set();
116
+ const materialized = new Set();
117
+ let everyRoom = false;
118
+ const transfers = new Map();
127
119
  const conversations = createConversationStore({
128
- cards: declaredCards,
129
- documents: (locator) => readSide.query(planOf({ rooms: [locator], select: 'all' }, roomFacts())),
120
+ agents: declaredAgents,
121
+ documents: (locator) => rowsQuery(planOf({ rooms: [locator], select: 'all' }, roomFacts())),
130
122
  call: (name, input) => invokeAction(name, input),
131
123
  durably,
132
124
  stream: () => options.adapter.conversations,
@@ -202,7 +194,7 @@ export function composeRuntime(options) {
202
194
  }
203
195
  }
204
196
  function pending() {
205
- return [...replicas.values()].some((replica) => replica.queue.length > 0);
197
+ return !localSaves && [...replicas.values()].some((replica) => replica.queue.length > 0);
206
198
  }
207
199
  function attention() {
208
200
  const attentionRequired = [];
@@ -231,14 +223,33 @@ export function composeRuntime(options) {
231
223
  }
232
224
  return { attentionRequired, reset };
233
225
  }
226
+ function documentsIn(locator) {
227
+ const replica = replicas.get(locator);
228
+ if (replica === undefined)
229
+ return 0;
230
+ const roots = replica.confirmed.record.typedSelection?.roots ?? [];
231
+ let count = 0;
232
+ for (const path of Object.keys(replica.visible.documents)) {
233
+ if (markdownPath(path) && !roots.some((root) => beneath(root, path)))
234
+ count += 1;
235
+ }
236
+ return count;
237
+ }
234
238
  function listed() {
235
- return structuredClone([...rooms.values()]);
239
+ return structuredClone([...rooms.values()].map((room) => {
240
+ const documents = documentsIn(room.locator);
241
+ counted.set(room.locator, documents);
242
+ return { ...room, documents };
243
+ }));
236
244
  }
237
- function roomFacts() {
238
- const held = [...rooms.values()].map((room) => {
245
+ function roomFacts(reach = null) {
246
+ const held = [...rooms.values()]
247
+ .filter((room) => holds(reach, room.locator))
248
+ .map((room) => {
249
+ const governed = governs(reach, room);
239
250
  const attached = [
240
- modelOf(room.locator),
241
- ...(room.typedSelection === null ? [] : [modelOf(room.locator, 'typed-selection')]),
251
+ ...(governed.collection ? [modelOf(room.locator)] : []),
252
+ ...(governed.selection ? [modelOf(room.locator, 'typed-selection')] : []),
242
253
  ];
243
254
  return {
244
255
  locator: room.locator,
@@ -261,6 +272,10 @@ export function composeRuntime(options) {
261
272
  for (const listener of storageLossListeners)
262
273
  listener(storageLoss);
263
274
  }
275
+ function emitMember() {
276
+ for (const listener of memberListeners)
277
+ listener(member);
278
+ }
264
279
  function moveTo(next) {
265
280
  if (closed)
266
281
  return;
@@ -280,6 +295,8 @@ export function composeRuntime(options) {
280
295
  listener(shown);
281
296
  }
282
297
  function entryOf(batch) {
298
+ if (localSaves)
299
+ return Object.freeze({ batch, state: 'local' });
283
300
  const state = pendings.get(batch.id);
284
301
  if (state?.accepted !== undefined) {
285
302
  return Object.freeze({ batch, state: 'accepted', roomVersion: state.accepted });
@@ -300,12 +317,47 @@ export function composeRuntime(options) {
300
317
  }
301
318
  }
302
319
  function applyChangeSet(set) {
320
+ const held = everyRoom
321
+ ? set
322
+ : { ...set, documents: set.documents.filter(({ locator }) => materialized.has(locator)) };
323
+ readSide.apply(held);
324
+ searchIndex.changed(held);
325
+ }
326
+ function materialize(locators) {
327
+ if (everyRoom)
328
+ return;
329
+ if (locators === null)
330
+ everyRoom = true;
331
+ const sets = [];
332
+ for (const locator of locators ?? [...replicas.keys()]) {
333
+ if (materialized.has(locator))
334
+ continue;
335
+ materialized.add(locator);
336
+ const replica = replicas.get(locator);
337
+ if (replica !== undefined)
338
+ sets.push(changeSet(locator, null, replica));
339
+ }
340
+ if (sets.length === 0)
341
+ return;
342
+ const set = mergeChangeSets(sets);
303
343
  readSide.apply(set);
304
344
  searchIndex.changed(set);
305
345
  }
346
+ function reachedRooms(plan) {
347
+ if (plan.rooms !== null)
348
+ return plan.rooms;
349
+ if (plan.reach === null)
350
+ return null;
351
+ return [...plan.reach.rooms, ...plan.reach.documents.map(roomOfKey)];
352
+ }
353
+ function rowsQuery(plan) {
354
+ materialize(reachedRooms(plan));
355
+ return readSide.query(plan);
356
+ }
306
357
  function publishChangeSet(set) {
307
358
  applyChangeSet(set);
308
- if (set.rooms.length > 0)
359
+ if (set.rooms.length > 0 ||
360
+ [...new Set(set.documents.map(({ locator }) => locator))].some((locator) => rooms.has(locator) && counted.get(locator) !== documentsIn(locator)))
309
361
  emitRooms();
310
362
  for (const locator of set.queues)
311
363
  emitQueue(locator);
@@ -316,23 +368,32 @@ export function composeRuntime(options) {
316
368
  for (const app of set.presentations)
317
369
  emitPresentation(app);
318
370
  conversations.changed(set);
319
- const cards = declaredCards();
320
- if (cards !== undefined && set.documents.some((change) => change.locator === cards.locator)) {
321
- scheduleCurrency();
322
- }
323
371
  }
324
372
  function stopTimer() {
325
- if (timer === undefined)
373
+ if (resetTimer === undefined)
326
374
  return;
327
- clearInterval(timer);
328
- timer = undefined;
375
+ clearTimeout(resetTimer);
376
+ resetTimer = undefined;
329
377
  }
330
- function startTimer() {
331
- if (timer !== undefined)
378
+ function armTimer() {
379
+ stopTimer();
380
+ if (closed || stopping)
332
381
  return;
333
- timer = setInterval(() => {
334
- void enqueue();
335
- }, period);
382
+ let earliest = null;
383
+ for (const { submission } of pendings.values()) {
384
+ if (submission.state !== 'reset')
385
+ continue;
386
+ const at = Date.parse(submission.reset);
387
+ if (earliest === null || at < earliest)
388
+ earliest = at;
389
+ }
390
+ if (earliest === null)
391
+ return;
392
+ // A Save House held back goes again only once the reset its answer named has passed.
393
+ resetTimer = setTimeout(() => {
394
+ resetTimer = undefined;
395
+ submitAll();
396
+ }, Math.max(0, earliest - Date.now()));
336
397
  }
337
398
  async function refuse(reason, provider = null) {
338
399
  stopTimer();
@@ -358,7 +419,6 @@ export function composeRuntime(options) {
358
419
  }
359
420
  for (const key of keys) {
360
421
  invalidModels.add(key);
361
- modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
362
422
  models.set(key, revoked);
363
423
  liveModels.delete(key);
364
424
  }
@@ -371,10 +431,7 @@ export function composeRuntime(options) {
371
431
  model: revoked,
372
432
  selectionModel: revoked,
373
433
  };
374
- const read = readConfirmed(record, before.confirmed);
375
- replicas.set(locator, read.kind === 'staged'
376
- ? replicaOf(read.confirmed, before.queue, rewriting(), before)
377
- : { ...before, confirmed: { ...before.confirmed, record } });
434
+ replicas.set(locator, replicaOf(confirmedOf(record, before.confirmed), before.queue, before));
378
435
  }
379
436
  rooms.set(locator, {
380
437
  ...room,
@@ -391,7 +448,8 @@ export function composeRuntime(options) {
391
448
  }
392
449
  finally {
393
450
  refusal = {
394
- reason,
451
+ code: reason,
452
+ retryable: false,
395
453
  reauthorizationOffered: reason === 'revoked-authorization',
396
454
  refusal: provider,
397
455
  };
@@ -404,15 +462,14 @@ export function composeRuntime(options) {
404
462
  rooms.set(room.locator, {
405
463
  name: room.name,
406
464
  locator: room.locator,
465
+ handle: room.handle,
407
466
  operations: room.operations,
408
467
  replica: room.replica,
409
- attachedModel: room.attachedModel,
410
468
  model: modelTypes(modelOf(room.locator)),
411
469
  typedSelection: selection === null
412
470
  ? null
413
471
  : {
414
472
  roots: selection.roots,
415
- attachedModel: selection.attachedModel,
416
473
  model: modelTypes(modelOf(room.locator, 'typed-selection')),
417
474
  },
418
475
  providerOwnedPaths: room.providerOwnedPaths,
@@ -429,9 +486,6 @@ export function composeRuntime(options) {
429
486
  discovered.clear();
430
487
  for (const room of listed)
431
488
  discovered.set(room.locator, room);
432
- for (const locator of wanted.keys())
433
- if (!listedLocators.has(locator))
434
- wanted.delete(locator);
435
489
  await dropUnlisted(listedLocators, local);
436
490
  for (const room of listed) {
437
491
  const held = replicas.get(room.locator)?.confirmed.record;
@@ -445,7 +499,7 @@ export function composeRuntime(options) {
445
499
  emitRooms();
446
500
  }
447
501
  function unlistedHeld(listed) {
448
- return [...new Set([...rooms.keys(), ...roomDrafts.keys()])].filter((locator) => !listed.has(locator));
502
+ return [...new Set([...rooms.keys(), ...roomDrafts.keys(), ...transfers.keys()])].filter((locator) => !listed.has(locator));
449
503
  }
450
504
  async function dropUnlisted(listed, local) {
451
505
  if (unlistedHeld(listed).length === 0)
@@ -464,7 +518,9 @@ export function composeRuntime(options) {
464
518
  replicas.delete(locator);
465
519
  rooms.delete(locator);
466
520
  roomDrafts.delete(locator);
521
+ transfers.delete(locator);
467
522
  unreadable.delete(locator);
523
+ stale.delete(locator);
468
524
  scopedRefusals.delete(locator);
469
525
  sourceRefusals.delete(locator);
470
526
  declarationRefusals.delete(locator);
@@ -473,7 +529,6 @@ export function composeRuntime(options) {
473
529
  models.delete(key);
474
530
  liveModels.delete(key);
475
531
  invalidModels.delete(key);
476
- modelEpoch.delete(key);
477
532
  }
478
533
  journal.record(`room unlisted ${locator}`);
479
534
  }
@@ -486,7 +541,6 @@ export function composeRuntime(options) {
486
541
  if (prior !== undefined &&
487
542
  prior.status !== 'unavailable' &&
488
543
  (persisted.status === 'unavailable' || !sameModel(prior.snapshot, persisted.snapshot))) {
489
- modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
490
544
  invalidModels.add(key);
491
545
  }
492
546
  const model = persisted.status === 'unavailable' ||
@@ -501,7 +555,7 @@ export function composeRuntime(options) {
501
555
  return model;
502
556
  }
503
557
  async function hydrate(opened) {
504
- const [records, queues, kept, presented, held, chatDrafts, lastCompleted, declared, named] = await Promise.all([
558
+ const [records, queues, kept, presented, held, chatDrafts, lastCompleted, declared, named, known, staged,] = await Promise.all([
505
559
  opened.all(),
506
560
  opened.queues(),
507
561
  opened.roomDrafts(),
@@ -511,9 +565,15 @@ export function composeRuntime(options) {
511
565
  opened.lastCompleted(),
512
566
  opened.providerLimits(),
513
567
  opened.capabilities(),
568
+ opened.member(),
569
+ opened.transfers(),
514
570
  ]);
571
+ transfers.clear();
572
+ for (const record of staged)
573
+ transfers.set(record.locator, record);
515
574
  limits = declared;
516
575
  capabilities = named;
576
+ member = known;
517
577
  roomDrafts.clear();
518
578
  for (const record of kept)
519
579
  rememberRoomDraft(record);
@@ -533,15 +593,8 @@ export function composeRuntime(options) {
533
593
  const model = restored(modelKey(record.locator, 'collection'), record.model);
534
594
  const selectionModel = restored(modelKey(record.locator, 'typed-selection'), record.selectionModel);
535
595
  const prior = previousReplicas.get(record.locator);
536
- const retyped = { ...record, model, selectionModel };
537
- const read = readConfirmed(retyped, prior?.confirmed);
538
- const confirmed = read.kind === 'staged'
539
- ? read.confirmed
540
- : {
541
- record: retyped,
542
- collection: parseCollection(retyped.documents, compositionOf(retyped)),
543
- };
544
- replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? [], rewriting(), prior));
596
+ const confirmed = confirmedOf({ ...record, model, selectionModel }, prior?.confirmed);
597
+ replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? [], prior));
545
598
  const existing = rooms.get(record.locator);
546
599
  if (existing === undefined)
547
600
  list(record, false);
@@ -552,21 +605,15 @@ export function composeRuntime(options) {
552
605
  attachments: record.attachments,
553
606
  operations: record.operations,
554
607
  replica: record.replica,
555
- attachedModel: record.attachedModel,
556
608
  model: modelTypes(model),
557
609
  typedSelection: record.typedSelection === null
558
610
  ? null
559
611
  : {
560
612
  roots: record.typedSelection.roots,
561
- attachedModel: record.typedSelection.attachedModel,
562
613
  model: modelTypes(selectionModel),
563
614
  },
564
615
  });
565
616
  }
566
- if (read.kind === 'refused') {
567
- refusedSource(record, read);
568
- declarationRefusals.add(record.locator);
569
- }
570
617
  }
571
618
  return {
572
619
  lastCompleted,
@@ -579,6 +626,8 @@ export function composeRuntime(options) {
579
626
  applyChangeSet(read);
580
627
  if (replicas.size > 0)
581
628
  emitRooms();
629
+ if (member !== null)
630
+ emitMember();
582
631
  for (const locator of locators)
583
632
  emitQueue(locator);
584
633
  publishRoomDrafts(false);
@@ -626,10 +675,14 @@ export function composeRuntime(options) {
626
675
  return state !== undefined && state.submission.state !== 'submitted' && !state.lost;
627
676
  }
628
677
  async function settle(local, locator, before, confirmed, queue, settling = {}) {
629
- const withdrawn = withdrawals(confirmed, queue, withdrawable, rewriting());
678
+ const withdrawn = withdrawals(confirmed, queue, withdrawable, localSaves);
630
679
  const kept = queue.filter((batch) => !withdrawn.includes(batch));
680
+ const unqueued = withdrawn.map((batch) => ({
681
+ batch,
682
+ returned: localSaves || landed(batch, confirmed) ? null : returnedBy('withdrawn'),
683
+ }));
631
684
  const returning = [
632
- ...withdrawn.map((batch) => ({ batch, returned: { kind: 'withdrawn' } })).reverse(),
685
+ ...[...unqueued].reverse(),
633
686
  ...(settling.leaving === undefined ? [] : [settling.leaving]),
634
687
  ];
635
688
  const written = new Map();
@@ -650,6 +703,7 @@ export function composeRuntime(options) {
650
703
  await local.write({
651
704
  next: confirmed.record,
652
705
  previous: before?.confirmed.record ?? null,
706
+ ...(settling.stored === undefined ? {} : { stored: settling.stored }),
653
707
  queue: kept,
654
708
  previousQueue: before?.queue ?? [],
655
709
  roomDrafts: [...written.values()],
@@ -660,44 +714,80 @@ export function composeRuntime(options) {
660
714
  }
661
715
  for (const record of written.values())
662
716
  rememberRoomDraft(record);
663
- for (const batch of withdrawn)
664
- journal.record(`outcome ${locator} ${batch.id} withdrawn`);
665
- replicas.set(locator, replicaOf(confirmed, kept, rewriting(), before));
717
+ for (const { batch, returned } of unqueued) {
718
+ const outcome = localSaves ? 'dropped' : returned === null ? 'landed' : 'withdrawn';
719
+ journal.record(`outcome ${locator} ${batch.id} ${outcome}`);
720
+ }
721
+ replicas.set(locator, replicaOf(confirmed, kept, before));
666
722
  return changeSet(locator, before ?? null, replicas.get(locator) ?? null, {
667
723
  room: settling.room,
668
724
  queue: kept.length !== (before?.queue.length ?? 0),
669
725
  drafts: [...written.keys()],
670
726
  });
671
727
  }
672
- function publish(local, room, confirmed) {
728
+ function adoptModels(locator, record) {
729
+ for (const [attached, model] of [
730
+ ['collection', record.model],
731
+ ['typed-selection', record.selectionModel],
732
+ ]) {
733
+ const key = modelKey(locator, attached);
734
+ models.set(key, model);
735
+ invalidModels.delete(key);
736
+ if (model.status === 'current')
737
+ liveModels.set(key, JSON.stringify(model.snapshot));
738
+ else if (model.status === 'unavailable')
739
+ liveModels.delete(key);
740
+ }
741
+ }
742
+ function remodel(locator, answers, held) {
743
+ const taken = (answer, previous, attached) => {
744
+ if (answer === undefined)
745
+ return previous;
746
+ const { state, refused } = attachedModel(answer, previous);
747
+ if (refused !== null) {
748
+ const where = refused.location === undefined ? '' : ` ${refused.location}`;
749
+ journal.record(`model invalid ${locator} ${attached} ${refused.identity} ${refused.rule}${where}`);
750
+ }
751
+ models.set(modelKey(locator, attached), state);
752
+ return state;
753
+ };
754
+ return {
755
+ collection: taken(answers?.collection, held.collection, 'collection'),
756
+ selection: taken(answers?.selection, held.selection, 'typed-selection'),
757
+ };
758
+ }
759
+ function publish(local, room, confirmed, landing = {}) {
760
+ const { base, stored } = landing;
673
761
  return exclusive(async () => {
674
762
  if (!continuing())
675
- return;
763
+ return true;
676
764
  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}`);
765
+ if (base !== undefined &&
766
+ (prior?.confirmed.record.roomVersion !== base.record.roomVersion ||
767
+ prior.confirmed.record.revisions !== base.record.revisions)) {
768
+ return false;
769
+ }
770
+ adoptModels(room.locator, confirmed.record);
684
771
  const queue = prior?.queue ?? [];
685
- const roomVersion = retained.confirmed.record.roomVersion;
772
+ const roomVersion = confirmed.record.roomVersion;
686
773
  const accepted = queue.filter((batch) => {
687
774
  const accepted = pendings.get(batch.id)?.accepted;
688
775
  return (accepted !== undefined && options.adapter.reached(room.locator, roomVersion, accepted));
689
776
  });
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);
777
+ const set = await settle(local, room.locator, prior, confirmed, queue.filter((batch) => !accepted.includes(batch)), { room: true, ...(stored === undefined ? {} : { stored }) });
778
+ if (stored !== undefined)
779
+ transfers.delete(room.locator);
780
+ for (const batch of accepted)
781
+ pendings.delete(batch.id);
782
+ stale.delete(room.locator);
783
+ if (unreadable.delete(room.locator))
784
+ scopedRefusals.delete(room.locator);
696
785
  sourceRefusals.delete(room.locator);
697
786
  declarationRefusals.delete(room.locator);
698
787
  list(room, true);
699
788
  publishChangeSet(set);
700
789
  moveTo({ ...status, ...attention() });
790
+ return true;
701
791
  });
702
792
  }
703
793
  async function saveRoomDrafts(locator, named, writtenBy) {
@@ -705,28 +795,29 @@ export function composeRuntime(options) {
705
795
  const replica = replicas.get(locator);
706
796
  const room = rooms.get(locator);
707
797
  if (replica === undefined || room === undefined) {
708
- throw new Error(`No Local Replica for ${locator}`);
798
+ throw runtimeError('room-not-held', `No Local Replica for ${locator}`);
709
799
  }
710
800
  const saved = [];
711
801
  for (const { path, revision } of named) {
712
802
  const record = roomDrafts.get(locator)?.get(path);
713
803
  if (record === undefined || record.revision !== revision) {
714
- return { outcome: 'refused', rule: 'stale', file: path };
804
+ return { outcome: 'refused', code: 'stale', retryable: false, file: path };
715
805
  }
716
806
  if (!saved.some((draft) => draft.path === path))
717
807
  saved.push({ path, parts: record.parts });
718
808
  }
719
809
  const changed = saved.flatMap(({ path, parts }) => changedParts(path, underneath(parts, path, replica.visible)));
720
810
  if (changed.length > 0)
721
- return { outcome: 'refused', rule: 'changed-underneath', parts: changed };
811
+ return { outcome: 'refused', code: 'changed-underneath', retryable: false, parts: changed };
722
812
  const parts = saved.map(({ path, parts }) => changesOf(path, parts));
723
813
  const built = buildBatch({
814
+ local: localSaves,
724
815
  baseRoomVersion: replica.confirmed.record.roomVersion,
725
816
  composition: compositionFor(replica.confirmed.record),
726
817
  operations: room.operations,
727
818
  providerOwnedPaths: room.providerOwnedPaths,
728
819
  providerLimits: limits,
729
- rewriting: rewriting(),
820
+ handle: room.handle,
730
821
  visible: replica.visible,
731
822
  changes: parts.flatMap(({ changes }) => changes),
732
823
  bases: parts.flatMap(({ bases }) => bases),
@@ -747,18 +838,20 @@ export function composeRuntime(options) {
747
838
  });
748
839
  }
749
840
  catch {
750
- return { outcome: 'refused', rule: 'durable-write' };
841
+ return { outcome: 'refused', code: 'durable-write', retryable: true };
751
842
  }
752
843
  pendings.set(built.batch.id, { drafts: saved, submission: { state: 'waiting' }, lost: false });
844
+ if (!localSaves)
845
+ recorded = false;
753
846
  for (const path of consumed)
754
847
  forgetRoomDraft(locator, path);
755
- replicas.set(locator, replicaOf(replica.confirmed, batches, rewriting(), replica));
848
+ replicas.set(locator, replicaOf(replica.confirmed, batches, replica));
756
849
  publishChangeSet(changeSet(locator, replica, replicas.get(locator) ?? null, {
757
850
  queue: true,
758
851
  drafts: consumed,
759
852
  }));
760
- moveTo({ ...saving(status), ...attention() });
761
- void enqueue();
853
+ moveTo({ ...(localSaves ? status : saving(status)), ...attention() });
854
+ submitLater(locator);
762
855
  return { outcome: 'saved', identifier: built.batch.id };
763
856
  }
764
857
  async function mark(local, locator, identifier, submission, lost) {
@@ -785,16 +878,15 @@ export function composeRuntime(options) {
785
878
  }
786
879
  async function readHistory(locator, path, query = {}) {
787
880
  if (closed || refusal !== null)
788
- return { kind: 'transport-uncertainty' };
881
+ return UNCERTAIN;
789
882
  try {
790
883
  await ensureAuthorization();
791
884
  }
792
885
  catch {
793
- return { kind: 'transport-uncertainty' };
794
- }
795
- if (closed || authorization === undefined || refusal !== null) {
796
- return { kind: 'transport-uncertainty' };
886
+ return UNCERTAIN;
797
887
  }
888
+ if (closed || authorization === undefined || refusal !== null)
889
+ return UNCERTAIN;
798
890
  const transfer = ['transfer', 'history', locator, path];
799
891
  if (query.revision !== undefined)
800
892
  transfer.push(query.revision);
@@ -816,17 +908,17 @@ export function composeRuntime(options) {
816
908
  }
817
909
  if (result.kind === 'revoked-authorization')
818
910
  await refuse('revoked-authorization');
819
- return result;
911
+ return historyAnswer(result);
820
912
  }
821
913
  catch {
822
914
  journal.record('outcome history transport-uncertainty');
823
- return { kind: 'transport-uncertainty' };
915
+ return UNCERTAIN;
824
916
  }
825
917
  }
826
- function declaredCards() {
918
+ function declaredAgents() {
827
919
  for (const capability of capabilities) {
828
- if (capability.conversationCards !== undefined)
829
- return capability.conversationCards;
920
+ if (capability.conversationAgents !== undefined)
921
+ return capability.conversationAgents;
830
922
  }
831
923
  return undefined;
832
924
  }
@@ -842,23 +934,20 @@ export function composeRuntime(options) {
842
934
  async function invokeAction(name, input) {
843
935
  await ready;
844
936
  if (closed || refusal !== null)
845
- return { kind: 'transport-uncertainty' };
937
+ return UNCERTAIN;
846
938
  if (declaredAction(name) === undefined)
847
- return { kind: 'undeclared' };
939
+ return undeclared(name);
848
940
  try {
849
941
  await ensureAuthorization();
850
942
  }
851
943
  catch {
852
- return { kind: 'transport-uncertainty' };
853
- }
854
- if (closed || authorization === undefined || refusal !== null) {
855
- return { kind: 'transport-uncertainty' };
944
+ return UNCERTAIN;
856
945
  }
946
+ if (closed || authorization === undefined || refusal !== null)
947
+ return UNCERTAIN;
857
948
  journal.record(`transfer action ${name}`);
858
949
  try {
859
950
  const result = await options.adapter.action(authorization, { name, input });
860
- if (result.kind === 'action' && result.at !== undefined)
861
- want(result.at);
862
951
  if (result.kind === 'scoped-refusal') {
863
952
  const room = replicas.get(result.locator)?.confirmed.record;
864
953
  if (room !== undefined)
@@ -869,11 +958,11 @@ export function composeRuntime(options) {
869
958
  journal.record(`outcome action ${result.kind}`);
870
959
  if (result.kind === 'rejected')
871
960
  journal.record(`refusal rejected ${result.code}`);
872
- return result;
961
+ return calledResult(result);
873
962
  }
874
963
  catch {
875
964
  journal.record('outcome action transport-uncertainty');
876
- return { kind: 'transport-uncertainty' };
965
+ return UNCERTAIN;
877
966
  }
878
967
  }
879
968
  function continuing() {
@@ -890,32 +979,11 @@ export function composeRuntime(options) {
890
979
  options.adapter.reached(locator, written, roomVersion) &&
891
980
  !options.adapter.reached(locator, roomVersion, written));
892
981
  }
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) {
982
+ async function pullRefresh(authorized, confirmed) {
915
983
  if (!continuing())
916
984
  return unconfirmed;
917
985
  try {
918
- const result = await refreshRoom(options.adapter, authorized, confirmed, continuing, minPosition);
986
+ const result = await refreshRoom(options.adapter, authorized, confirmed, (models, held) => remodel(confirmed.record.locator, models, held), continuing);
919
987
  const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
920
988
  journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
921
989
  return result;
@@ -925,14 +993,33 @@ export function composeRuntime(options) {
925
993
  throw error;
926
994
  }
927
995
  }
928
- async function pullBootstrap(authorized, room) {
996
+ function staging(local) {
997
+ return {
998
+ held: (locator) => transfers.get(locator) ?? null,
999
+ staged: (record) => local.staged(record),
1000
+ stage: (record, page) => exclusive(async () => {
1001
+ if (!continuing())
1002
+ return false;
1003
+ await local.stage(record, page);
1004
+ transfers.set(record.locator, record);
1005
+ return true;
1006
+ }),
1007
+ abandon: (locator) => exclusive(async () => {
1008
+ if (!continuing())
1009
+ return;
1010
+ await local.abandon(locator);
1011
+ transfers.delete(locator);
1012
+ }),
1013
+ };
1014
+ }
1015
+ async function pullBootstrap(authorized, room, local) {
929
1016
  if (!continuing())
930
1017
  return unconfirmed;
931
1018
  try {
932
1019
  const result = await bootstrapRoom(options.adapter, authorized, room, {
933
1020
  collection: modelOf(room.locator),
934
1021
  selection: modelOf(room.locator, 'typed-selection'),
935
- }, continuing);
1022
+ }, (models, held) => remodel(room.locator, models, held), continuing, staging(local));
936
1023
  const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
937
1024
  journal.record(`transfer bootstrap ${room.locator} ${detail}`);
938
1025
  return result;
@@ -945,35 +1032,46 @@ export function composeRuntime(options) {
945
1032
  async function advance(room, local, authorized) {
946
1033
  const held = replicas.get(room.locator);
947
1034
  if (held !== undefined) {
948
- const refreshed = await pullRefresh(authorized, held.confirmed, reachOf(room.locator));
1035
+ const refreshed = await pullRefresh(authorized, held.confirmed);
949
1036
  if (!continuing())
950
1037
  return unconfirmed;
951
1038
  if (refreshed.kind === 'still-current') {
1039
+ stale.delete(room.locator);
952
1040
  if (sourceRefusals.has(room.locator))
953
- return unconfirmed;
1041
+ return null;
954
1042
  if (unreadable.delete(room.locator)) {
1043
+ scopedRefusals.delete(room.locator);
955
1044
  list(room, true);
956
1045
  emitRooms();
957
1046
  }
958
1047
  return null;
959
1048
  }
960
1049
  if (refreshed.kind === 'advanced') {
961
- await publish(local, room, refreshed.confirmed);
1050
+ if (!(await publish(local, room, refreshed.confirmed, { base: held.confirmed }))) {
1051
+ const now = replicas.get(room.locator)?.confirmed.record.roomVersion;
1052
+ const reached = now !== undefined &&
1053
+ options.adapter.reached(room.locator, now, refreshed.confirmed.record.roomVersion);
1054
+ return reached ? null : advance(room, local, authorized);
1055
+ }
1056
+ await retainModel(local, room, 'collection', modelOf(room.locator));
962
1057
  return null;
963
1058
  }
964
1059
  if (refreshed.kind !== 'beyond-horizon')
965
1060
  return refreshed;
966
1061
  }
967
- const bootstrapped = await pullBootstrap(authorized, room);
1062
+ const bootstrapped = await pullBootstrap(authorized, room, local);
968
1063
  if (!continuing())
969
1064
  return unconfirmed;
970
1065
  if (bootstrapped.kind !== 'staged')
971
1066
  return bootstrapped;
972
- await publish(local, room, bootstrapped.confirmed);
1067
+ await publish(local, room, bootstrapped.confirmed, { stored: bootstrapped.stored });
973
1068
  return null;
974
1069
  }
975
1070
  async function withhold(withheld, room, local, progress) {
976
- progress.complete = false;
1071
+ if (withheld.kind === 'refused')
1072
+ stale.delete(room.locator);
1073
+ else
1074
+ stale.add(room.locator);
977
1075
  if (withheld.kind === 'scoped-refusal') {
978
1076
  await scoped(withheld, room, local);
979
1077
  }
@@ -998,6 +1096,7 @@ export function composeRuntime(options) {
998
1096
  function refusedSource(room, { refusal }) {
999
1097
  sourceRefusals.set(room.locator, {
1000
1098
  code: refusal.rule,
1099
+ retryable: false,
1001
1100
  fields: {
1002
1101
  file: refusal.file,
1003
1102
  ...(refusal.location === undefined ? {} : { location: refusal.location }),
@@ -1012,52 +1111,46 @@ export function composeRuntime(options) {
1012
1111
  }
1013
1112
  function leaving(batch, result) {
1014
1113
  if (result.kind === 'expired')
1015
- return { batch, returned: { kind: 'expired' } };
1016
- return { batch, returned: { kind: result.kind, code: result.code, fields: result.fields } };
1114
+ return { batch, returned: returnedBy('expired') };
1115
+ return {
1116
+ batch,
1117
+ returned: {
1118
+ kind: result.kind,
1119
+ code: result.code,
1120
+ fields: result.fields,
1121
+ retryable: result.retryable,
1122
+ },
1123
+ };
1017
1124
  }
1018
1125
  function logAccepted(locator, submitted, accepted) {
1019
1126
  journal.record(`outcome ${locator} ${submitted.id} accepted ${accepted.roomVersion}` +
1020
1127
  (accepted.author === undefined ? '' : ` author ${accepted.author}`) +
1021
1128
  (accepted.time === undefined ? '' : ` time ${accepted.time}`));
1022
1129
  }
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) {
1130
+ async function adoptAccepted(room, local, submitted, accepted) {
1041
1131
  if (!continuing())
1042
1132
  return unconfirmed;
1043
1133
  const current = replicas.get(room.locator);
1044
1134
  if (current === undefined || !current.queue.some((batch) => batch.id === submitted.id)) {
1045
1135
  return null;
1046
1136
  }
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')
1137
+ const held = current.confirmed.record.roomVersion;
1138
+ logAccepted(room.locator, submitted, accepted);
1139
+ const applied = options.adapter.reached(room.locator, held, accepted.roomVersion)
1140
+ ? { kind: 'staged', confirmed: current.confirmed }
1141
+ : applyReturned(current.confirmed, accepted.documents, accepted.revisions, accepted.changes, accepted.removals, accepted.attachments, options.adapter.follows(room.locator, held, accepted.roomVersion)
1142
+ ? accepted.roomVersion
1143
+ : held, accepted.roomVersion);
1144
+ if (applied.kind !== 'staged') {
1145
+ const state = pendings.get(submitted.id);
1146
+ await local.accept(room.locator, submitted.id, accepted.roomVersion);
1147
+ if (state !== undefined)
1148
+ pendings.set(submitted.id, { ...state, accepted: accepted.roomVersion });
1149
+ emitQueue(room.locator);
1057
1150
  return applied;
1058
- const set = await settle(local, room.locator, current, applied.confirmed, remaining, {
1059
- room: true,
1060
- });
1151
+ }
1152
+ const set = await settle(local, room.locator, current, applied.confirmed, current.queue.filter((batch) => batch.id !== submitted.id), { room: true });
1153
+ pendings.delete(submitted.id);
1061
1154
  unreadable.delete(room.locator);
1062
1155
  sourceRefusals.delete(room.locator);
1063
1156
  declarationRefusals.delete(room.locator);
@@ -1066,17 +1159,8 @@ export function composeRuntime(options) {
1066
1159
  moveTo({ ...status, ...attention() });
1067
1160
  return null;
1068
1161
  }
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));
1162
+ async function adopted(room, local, submitted, accepted) {
1163
+ const failed = await exclusive(() => adoptAccepted(room, local, submitted, accepted));
1080
1164
  if (failed === null)
1081
1165
  return true;
1082
1166
  if (failed.kind === 'refused' && continuing())
@@ -1089,10 +1173,8 @@ export function composeRuntime(options) {
1089
1173
  const head = replica?.queue[0];
1090
1174
  if (replica === undefined || head === undefined)
1091
1175
  return;
1092
- if (pendings.get(head.id)?.accepted !== undefined) {
1093
- progress.complete = false;
1176
+ if (pendings.get(head.id)?.accepted !== undefined)
1094
1177
  return;
1095
- }
1096
1178
  const state = pendings.get(head.id);
1097
1179
  const { submission, lost: earlier } = state ?? {
1098
1180
  submission: { state: 'waiting' },
@@ -1120,6 +1202,7 @@ export function composeRuntime(options) {
1120
1202
  return;
1121
1203
  }
1122
1204
  const opensBefore = openSignals;
1205
+ const answered = awaitingAnswer(room.locator);
1123
1206
  try {
1124
1207
  result = await options.adapter.mutate(authorized, {
1125
1208
  locator: room.locator,
@@ -1128,11 +1211,14 @@ export function composeRuntime(options) {
1128
1211
  }, (upload) => exclusive(() => keepUpload(local, room.locator, head.id, upload)));
1129
1212
  }
1130
1213
  catch {
1214
+ answered();
1131
1215
  progress.complete = false;
1132
1216
  if (continuing())
1133
1217
  progress.reachable = false;
1134
1218
  return;
1135
1219
  }
1220
+ if (result.kind !== 'accepted' || !continuing())
1221
+ answered();
1136
1222
  if (!continuing())
1137
1223
  return;
1138
1224
  if (result.kind === 'transport-uncertainty') {
@@ -1185,6 +1271,7 @@ export function composeRuntime(options) {
1185
1271
  const reset = result.reset;
1186
1272
  progress.complete = false;
1187
1273
  await exclusive(() => mark(local, room.locator, head.id, reset === undefined ? { state: 'waiting' } : { state: 'reset', reset }, lost));
1274
+ armTimer();
1188
1275
  return;
1189
1276
  }
1190
1277
  if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
@@ -1197,7 +1284,8 @@ export function composeRuntime(options) {
1197
1284
  return;
1198
1285
  }
1199
1286
  const set = await settle(local, room.locator, current, current.confirmed, current.queue.filter((batch) => batch.id !== head.id), {
1200
- leaving: result.kind === 'expired' && landed(head, current.confirmed)
1287
+ leaving: (result.kind === 'expired' && landed(head, current.confirmed)) ||
1288
+ (result.kind === 'conflicted' && landed(head, current.confirmed, result.affected))
1201
1289
  ? { ...left, returned: null }
1202
1290
  : left,
1203
1291
  });
@@ -1211,95 +1299,33 @@ export function composeRuntime(options) {
1211
1299
  return withhold(withheld, room, local, progress);
1212
1300
  continue;
1213
1301
  }
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);
1227
- progress.complete = false;
1228
- return;
1229
- }
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
- }
1302
+ const kept = await adopted(room, local, head, result).finally(answered);
1303
+ if (!kept) {
1258
1304
  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
1305
  return;
1271
1306
  }
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
1307
  }
1289
- return;
1308
+ }
1309
+ function awaitingAnswer(locator) {
1310
+ let release = () => { };
1311
+ const awaited = new Promise((resolve) => {
1312
+ release = resolve;
1313
+ });
1314
+ answering.set(locator, awaited);
1315
+ return () => {
1316
+ if (answering.get(locator) === awaited)
1317
+ answering.delete(locator);
1318
+ release();
1319
+ };
1290
1320
  }
1291
1321
  async function retainModel(local, room, attached, next) {
1292
1322
  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
1323
  const previous = modelOf(room.locator, attached);
1297
1324
  const heldBefore = replicas.get(room.locator)?.confirmed.record;
1298
1325
  if (JSON.stringify(previous) === JSON.stringify(next) &&
1299
1326
  (heldBefore === undefined ||
1300
1327
  (JSON.stringify(heldBefore.operations) === JSON.stringify(room.operations) &&
1301
1328
  JSON.stringify(heldBefore.replica) === JSON.stringify(room.replica) &&
1302
- JSON.stringify(heldBefore.attachedModel) === JSON.stringify(room.attachedModel) &&
1303
1329
  JSON.stringify(heldBefore.typedSelection) ===
1304
1330
  JSON.stringify(room.typedSelection ?? null))))
1305
1331
  return;
@@ -1310,20 +1336,17 @@ export function composeRuntime(options) {
1310
1336
  await exclusive(async () => {
1311
1337
  if (!continuing())
1312
1338
  return;
1313
- if (next.status !== 'unavailable' && epoch !== (modelEpoch.get(key) ?? 0))
1314
- return;
1315
1339
  const held = replicas.get(room.locator);
1316
1340
  if (held !== undefined) {
1317
1341
  const declared = {
1318
1342
  ...held.confirmed.record,
1319
1343
  operations: room.operations,
1320
1344
  replica: room.replica,
1321
- attachedModel: room.attachedModel,
1322
1345
  typedSelection: room.typedSelection ?? null,
1323
1346
  ...(attached === 'collection' ? { model: next } : { selectionModel: next }),
1324
1347
  };
1325
1348
  const reclassified = sameMembership(declared, held.confirmed.record)
1326
- ? readConfirmed(declared, held.confirmed)
1349
+ ? { kind: 'staged', confirmed: confirmedOf(declared, held.confirmed) }
1327
1350
  : readReclassified(declared, held.confirmed);
1328
1351
  const record = reclassified.kind === 'staged'
1329
1352
  ? declared
@@ -1331,14 +1354,16 @@ export function composeRuntime(options) {
1331
1354
  ...declared,
1332
1355
  typedSelection: held.confirmed.record.typedSelection,
1333
1356
  };
1334
- const read = reclassified.kind === 'deferred' ? readConfirmed(record, held.confirmed) : reclassified;
1357
+ const read = reclassified.kind === 'deferred'
1358
+ ? { kind: 'staged', confirmed: confirmedOf(record, held.confirmed) }
1359
+ : reclassified;
1335
1360
  await local.write({
1336
1361
  next: record,
1337
1362
  previous: held.confirmed.record,
1338
1363
  queue: held.queue,
1339
1364
  });
1340
1365
  if (read.kind === 'staged') {
1341
- replicas.set(room.locator, replicaOf(read.confirmed, held.queue, rewriting(), held));
1366
+ replicas.set(room.locator, replicaOf(read.confirmed, held.queue, held));
1342
1367
  if (declarationRefusals.delete(room.locator)) {
1343
1368
  sourceRefusals.delete(room.locator);
1344
1369
  unreadable.delete(room.locator);
@@ -1362,79 +1387,12 @@ export function composeRuntime(options) {
1362
1387
  publishChangeSet(changeSet(room.locator, held, replicas.get(room.locator) ?? null, { room: true }));
1363
1388
  });
1364
1389
  }
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
1390
  async function scoped(result, room, local) {
1437
- scopedRefusals.set(room.locator, { code: result.code, fields: result.fields });
1391
+ scopedRefusals.set(room.locator, {
1392
+ code: result.code,
1393
+ fields: result.fields,
1394
+ retryable: result.retryable,
1395
+ });
1438
1396
  let scopedAway = room;
1439
1397
  if (result.scope === 'model')
1440
1398
  await retainModel(local, room, 'collection', {
@@ -1460,14 +1418,36 @@ export function composeRuntime(options) {
1460
1418
  emitRooms();
1461
1419
  journal.record(`scoped refusal ${room.locator} ${result.scope} ${result.code}`);
1462
1420
  }
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) => {
1421
+ function heldRooms(listed) {
1422
+ return listed.flatMap((room) => {
1423
+ const record = replicas.get(room.locator)?.confirmed.record;
1424
+ if (record === undefined || record.replica.selection !== room.replica.selection)
1425
+ return [];
1426
+ return [
1427
+ {
1428
+ locator: room.locator,
1429
+ roomVersion: record.roomVersion,
1430
+ documents: record.documents,
1431
+ revisions: record.revisions,
1432
+ attachments: record.attachments,
1433
+ },
1434
+ ];
1435
+ });
1436
+ }
1437
+ async function synchronize(listed, local, progress, attempt) {
1438
+ const prepared = await Promise.allSettled(listed.map((room) => prepareRoom(room, local, progress)));
1439
+ const ready = listed.filter((_, index) => {
1466
1440
  const outcome = prepared[index];
1467
1441
  return outcome?.status === 'fulfilled' && outcome.value;
1468
1442
  });
1469
- const rooms = await Promise.allSettled(ready.map((room) => synchronizeRoom(room, local, progress)));
1470
- const failed = [...prepared, ...rooms].find((room) => room.status === 'rejected');
1443
+ if (attempt === 'opened') {
1444
+ options.adapter.roomStream?.hold(heldRooms(ready));
1445
+ handed = true;
1446
+ }
1447
+ const loaded = await Promise.allSettled(ready
1448
+ .filter((room) => attempt === 'unstreamed' || !replicas.has(room.locator))
1449
+ .map((room) => loadRoom(room, local, progress)));
1450
+ const failed = [...prepared, ...loaded].find((room) => room.status === 'rejected');
1471
1451
  if (failed !== undefined)
1472
1452
  throw failed.reason;
1473
1453
  }
@@ -1484,11 +1464,8 @@ export function composeRuntime(options) {
1484
1464
  progress.complete = false;
1485
1465
  return false;
1486
1466
  }
1487
- const selection = room.typedSelection;
1488
- await Promise.all([
1489
- acquireModel(local, room, authorization, null),
1490
- ...(selection === undefined ? [] : [acquireModel(local, room, authorization, selection)]),
1491
- ]);
1467
+ if (prior !== undefined)
1468
+ await retainModel(local, room, 'collection', modelOf(room.locator));
1492
1469
  if (discoveryPaused || authorization === undefined)
1493
1470
  return false;
1494
1471
  if (room.replica.admission !== 'admitted') {
@@ -1496,24 +1473,53 @@ export function composeRuntime(options) {
1496
1473
  progress.complete = false;
1497
1474
  return false;
1498
1475
  }
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
1476
  return true;
1505
1477
  }
1506
- async function synchronizeRoom(room, local, progress) {
1478
+ async function caughtUp(room, local, progress) {
1479
+ const held = replicas.get(room.locator)?.confirmed.record.roomVersion;
1480
+ if (held === undefined || authorization === undefined)
1481
+ return;
1482
+ const behind = [announced.get(room.locator), room.roomVersion].some((later) => later !== undefined && !options.adapter.reached(room.locator, held, later));
1483
+ if (!behind)
1484
+ return;
1485
+ const withheld = await advance(room, local, authorization);
1486
+ if (withheld !== null)
1487
+ await withhold(withheld, room, local, progress);
1488
+ }
1489
+ function revoked(locator) {
1490
+ return ['collection', 'typed-selection'].some((attached) => {
1491
+ const model = modelOf(locator, attached);
1492
+ return model.status === 'unavailable' && model.reason === 'revoked';
1493
+ });
1494
+ }
1495
+ async function loadRoom(room, local, progress) {
1507
1496
  if (discoveryPaused || authorization === undefined)
1508
1497
  return;
1498
+ if (replicas.has(room.locator) && revoked(room.locator)) {
1499
+ await reload(room, local, progress);
1500
+ return;
1501
+ }
1509
1502
  const withheld = await advance(room, local, authorization);
1510
1503
  if (withheld !== null) {
1511
1504
  await withhold(withheld, room, local, progress);
1512
1505
  return;
1513
1506
  }
1514
- await submitRoom(room, local, authorization, progress);
1507
+ await caughtUp(room, local, progress);
1508
+ }
1509
+ async function reload(room, local, progress) {
1510
+ if (discoveryPaused || authorization === undefined)
1511
+ return;
1512
+ const bootstrapped = await pullBootstrap(authorization, room, local);
1513
+ if (!continuing())
1514
+ return;
1515
+ if (bootstrapped.kind !== 'staged') {
1516
+ await withhold(bootstrapped, room, local, progress);
1517
+ return;
1518
+ }
1519
+ await publish(local, room, bootstrapped.confirmed, { stored: bootstrapped.stored });
1520
+ await caughtUp(room, local, progress);
1515
1521
  }
1516
- async function cycle(local, progress) {
1522
+ async function discoverRooms(local, progress, attempt) {
1517
1523
  if (closed || authorization === undefined) {
1518
1524
  notifyReady();
1519
1525
  return;
@@ -1539,12 +1545,17 @@ export function composeRuntime(options) {
1539
1545
  }
1540
1546
  }
1541
1547
  progress.reachable = false;
1548
+ progress.complete = false;
1542
1549
  notifyReady();
1543
1550
  return;
1544
1551
  }
1545
1552
  if (result.kind === 'contract-unavailable') {
1546
1553
  journal.record(`connection contract unavailable ${result.code}`);
1547
- await refuse('missing-required-guarantee', { code: result.code, fields: result.fields });
1554
+ await refuse('missing-required-guarantee', {
1555
+ code: result.code,
1556
+ fields: result.fields,
1557
+ retryable: result.retryable,
1558
+ });
1548
1559
  notifyReady();
1549
1560
  return;
1550
1561
  }
@@ -1553,17 +1564,6 @@ export function composeRuntime(options) {
1553
1564
  notifyReady();
1554
1565
  return;
1555
1566
  }
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
1567
  for (const room of result.rooms) {
1568
1568
  if (room.operations === undefined ||
1569
1569
  !['read', 'sync', 'write', 'delete'].every((operation) => Array.isArray(room.operations[operation]) &&
@@ -1571,8 +1571,7 @@ export function composeRuntime(options) {
1571
1571
  room.replica === undefined ||
1572
1572
  typeof room.replica.selection !== 'string' ||
1573
1573
  room.replica.selection.length === 0 ||
1574
- !['admitted', 'unavailable'].includes(room.replica.admission) ||
1575
- !declaredIdentity(room.attachedModel)) {
1574
+ !['admitted', 'unavailable'].includes(room.replica.admission)) {
1576
1575
  journal.record(`invalid model or replica declaration ${room.locator}`);
1577
1576
  await refuse('missing-required-guarantee');
1578
1577
  notifyReady();
@@ -1585,14 +1584,6 @@ export function composeRuntime(options) {
1585
1584
  return;
1586
1585
  }
1587
1586
  }
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
1587
  if (refusal !== null) {
1597
1588
  refusal = null;
1598
1589
  emitRefusal();
@@ -1600,26 +1591,19 @@ export function composeRuntime(options) {
1600
1591
  const previousCapabilities = capabilities;
1601
1592
  capabilities = result.capabilities;
1602
1593
  notifyReady();
1603
- if (declared.has('room-version-notifications'))
1604
- observeRoomVersions();
1605
1594
  if (JSON.stringify(result.providerLimits) !== JSON.stringify(limits) ||
1606
1595
  JSON.stringify(result.capabilities) !== JSON.stringify(previousCapabilities)) {
1607
1596
  limits = result.providerLimits;
1608
1597
  await local.declare(limits, capabilities);
1609
1598
  }
1599
+ const signedIn = result.member ?? null;
1600
+ if (JSON.stringify(signedIn) !== JSON.stringify(member)) {
1601
+ await local.keepMember(signedIn);
1602
+ member = signedIn;
1603
+ emitMember();
1604
+ }
1610
1605
  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);
1606
+ await synchronize(result.rooms, local, progress, attempt);
1623
1607
  }
1624
1608
  async function ensureAuthorization() {
1625
1609
  if (authorization !== undefined)
@@ -1633,64 +1617,262 @@ export function composeRuntime(options) {
1633
1617
  throw error;
1634
1618
  }
1635
1619
  }
1636
- async function connect() {
1637
- if (closed || stopping || discoveryPaused) {
1638
- notifyReady();
1620
+ function task(work) {
1621
+ if (stopping)
1622
+ return chain;
1623
+ chain = chain.then(work, work).catch(() => undefined);
1624
+ return chain;
1625
+ }
1626
+ function sync(attempt) {
1627
+ return task(async () => {
1628
+ if (closed || stopping || discoveryPaused) {
1629
+ notifyReady();
1630
+ return;
1631
+ }
1632
+ const progress = { complete: true, reachable: true };
1633
+ underway = attempt === 'opened' ? progress : null;
1634
+ if (attempt === 'opened')
1635
+ streamed = progress;
1636
+ complete = false;
1637
+ recorded = false;
1638
+ moveTo(transferring(status));
1639
+ handed = false;
1640
+ try {
1641
+ const local = await openLocal();
1642
+ if (closed)
1643
+ return;
1644
+ await ensureAuthorization();
1645
+ if (closed)
1646
+ return;
1647
+ await discoverRooms(local, progress, attempt);
1648
+ }
1649
+ catch {
1650
+ progress.complete = false;
1651
+ if (attempt === 'opened' && !handed)
1652
+ progress.reachable = false;
1653
+ }
1654
+ finally {
1655
+ notifyReady();
1656
+ }
1657
+ if (attempt !== 'opened') {
1658
+ await finishOpening(progress);
1659
+ return;
1660
+ }
1661
+ if (handed || closed || progress.reachable)
1662
+ return;
1663
+ underway = null;
1664
+ journal.record('connection reopen');
1665
+ await rest(progress);
1666
+ options.adapter.roomStream?.reopen();
1667
+ });
1668
+ }
1669
+ function closedSocket(revoked) {
1670
+ const opened = socketOpen;
1671
+ socketOpen = false;
1672
+ if (revoked) {
1673
+ void refuse('revoked-authorization');
1639
1674
  return;
1640
1675
  }
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)
1676
+ complete = false;
1677
+ if (streamed !== null)
1678
+ streamed.complete = false;
1679
+ moveTo({
1680
+ ...status,
1681
+ state: status.state === 'synced' ? 'syncing' : status.state,
1682
+ providerUnreachable: status.providerUnreachable || !opened,
1683
+ });
1684
+ }
1685
+ function applyOpening(expired) {
1686
+ return task(async () => {
1687
+ const progress = underway;
1688
+ underway = null;
1689
+ if (closed || stopping)
1650
1690
  return;
1651
- await cycle(local, progress);
1652
- if (closed)
1691
+ if (progress === null) {
1692
+ await reloadExpired(expired);
1653
1693
  return;
1654
- if (progress.complete) {
1655
- completedAt = new Date().toISOString();
1656
- await local.complete(completedAt);
1657
1694
  }
1695
+ try {
1696
+ const local = await openLocal();
1697
+ const held = [...discovered.values()].filter((room) => replicas.has(room.locator));
1698
+ const reloading = held.filter((room) => expired.includes(room.locator) || revoked(room.locator));
1699
+ const reloaded = await Promise.allSettled(reloading.map((room) => reload(room, local, progress)));
1700
+ if (progress.reachable) {
1701
+ for (const room of held)
1702
+ if (!reloading.includes(room))
1703
+ await confirmModels(room, local);
1704
+ }
1705
+ const failed = reloaded.find((outcome) => outcome.status === 'rejected');
1706
+ if (failed !== undefined)
1707
+ throw failed.reason;
1708
+ }
1709
+ catch {
1710
+ progress.complete = false;
1711
+ progress.reachable = false;
1712
+ }
1713
+ await finishOpening(progress);
1714
+ });
1715
+ }
1716
+ async function reloadExpired(expired) {
1717
+ const progress = { complete: true, reachable: true };
1718
+ try {
1719
+ const local = await openLocal();
1720
+ await Promise.allSettled([...discovered.values()]
1721
+ .filter((room) => replicas.has(room.locator) && expired.includes(room.locator))
1722
+ .map((room) => reload(room, local, progress)));
1658
1723
  }
1659
1724
  catch {
1660
- progress.complete = false;
1661
- notifyReady();
1725
+ progress.reachable = false;
1726
+ }
1727
+ await rest(progress);
1728
+ }
1729
+ async function confirmModels(room, local) {
1730
+ for (const attached of ['collection', 'typed-selection']) {
1731
+ const model = modelOf(room.locator, attached);
1732
+ if (model.status === 'cached') {
1733
+ await retainModel(local, room, attached, { ...model, status: 'current' });
1734
+ }
1735
+ }
1736
+ }
1737
+ function submitLater(locator) {
1738
+ if (localSaves)
1739
+ return;
1740
+ const running = submitting.get(locator);
1741
+ if (running !== undefined) {
1742
+ running.again = true;
1662
1743
  return;
1663
1744
  }
1664
- finally {
1665
- notifyReady();
1666
- moveTo(settled(status, progress, completedAt, pending(), attention()));
1745
+ const run = { again: false, done: Promise.resolve() };
1746
+ submitting.set(locator, run);
1747
+ run.done = (async () => {
1748
+ try {
1749
+ do {
1750
+ run.again = false;
1751
+ const room = discovered.get(locator);
1752
+ if (closed || stopping || discoveryPaused || room === undefined)
1753
+ return;
1754
+ const progress = { complete: true, reachable: true };
1755
+ try {
1756
+ await ensureAuthorization();
1757
+ if (authorization === undefined)
1758
+ return;
1759
+ await submitRoom(room, await openLocal(), authorization, progress);
1760
+ }
1761
+ catch {
1762
+ progress.reachable = false;
1763
+ }
1764
+ armTimer();
1765
+ await rest(progress);
1766
+ } while (run.again);
1767
+ }
1768
+ finally {
1769
+ submitting.delete(locator);
1770
+ }
1771
+ })();
1772
+ }
1773
+ function submitAll() {
1774
+ for (const [locator, replica] of replicas) {
1775
+ if (replica.queue.length > 0)
1776
+ submitLater(locator);
1667
1777
  }
1668
1778
  }
1669
- async function run() {
1779
+ async function finishOpening(progress) {
1670
1780
  try {
1671
- await connect();
1781
+ if (closed || discoveryPaused) {
1782
+ progress.complete = false;
1783
+ return;
1784
+ }
1785
+ submitAll();
1786
+ await conversations.reconcile();
1787
+ if (status.providerUnreachable)
1788
+ conversations.reconnected();
1789
+ const reading = { complete: true, reachable: true };
1790
+ await conversations.catchUp(reading);
1791
+ if (!reading.reachable)
1792
+ progress.reachable = false;
1793
+ }
1794
+ catch {
1795
+ progress.complete = false;
1672
1796
  }
1673
1797
  finally {
1674
- inflight = false;
1675
- if (queued && !closed) {
1676
- queued = false;
1677
- void enqueue();
1678
- }
1798
+ complete = progress.complete;
1799
+ await rest(progress);
1679
1800
  }
1680
1801
  }
1681
- function observeRoomVersions() {
1682
- const path = options.adapter.roomVersions;
1683
- if (path === undefined || withdrawNotifications !== undefined)
1802
+ async function rest(progress) {
1803
+ if (closed)
1684
1804
  return;
1685
- withdrawNotifications = path.observe((notification) => {
1686
- if (closed || stopping || discoveryPaused)
1805
+ let lastCompleted = status.lastCompleted;
1806
+ let synced = complete && stale.size === 0 && !pending();
1807
+ if (synced && !recorded) {
1808
+ const completedAt = new Date().toISOString();
1809
+ try {
1810
+ await (await openLocal()).complete(completedAt);
1811
+ recorded = true;
1812
+ lastCompleted = completedAt;
1813
+ }
1814
+ catch {
1815
+ synced = false;
1816
+ }
1817
+ }
1818
+ moveTo({
1819
+ state: pending() ? 'pending' : synced && complete ? 'synced' : 'syncing',
1820
+ lastCompleted,
1821
+ providerUnreachable: !progress.reachable,
1822
+ ...attention(),
1823
+ });
1824
+ }
1825
+ function applyRoom(locator, roomVersion) {
1826
+ announced.set(locator, roomVersion);
1827
+ return task(async () => {
1828
+ await answering.get(locator);
1829
+ if (closed || stopping || discoveryPaused || authorization === undefined)
1687
1830
  return;
1688
- if (notification.modelChanged === true && handed?.includes(notification.locator) === true) {
1689
- void enqueue();
1831
+ const room = discovered.get(locator);
1832
+ const held = replicas.get(locator);
1833
+ if (room === undefined || room.replica.admission !== 'admitted')
1690
1834
  return;
1835
+ if (held !== undefined &&
1836
+ (held.confirmed.record.replica.selection !== room.replica.selection ||
1837
+ options.adapter.reached(locator, held.confirmed.record.roomVersion, roomVersion))) {
1838
+ return;
1839
+ }
1840
+ const during = underway;
1841
+ const progress = during ?? { complete: true, reachable: true };
1842
+ if (during === null)
1843
+ moveTo(transferring(status));
1844
+ try {
1845
+ const local = await openLocal();
1846
+ const withheld = await advance(room, local, authorization);
1847
+ if (withheld !== null)
1848
+ await withhold(withheld, room, local, progress);
1849
+ }
1850
+ catch {
1851
+ stale.add(locator);
1852
+ }
1853
+ finally {
1854
+ if (during === null)
1855
+ await rest(progress);
1691
1856
  }
1692
- notices.set(notification.locator, notification.roomVersion);
1693
- chain = chain.then(runNotified, runNotified);
1857
+ });
1858
+ }
1859
+ function observeStream() {
1860
+ const stream = options.adapter.roomStream;
1861
+ if (stream === undefined || withdrawStream !== undefined)
1862
+ return;
1863
+ withdrawStream = stream.observe((event) => {
1864
+ if (closed || stopping)
1865
+ return;
1866
+ if (event.kind === 'opened') {
1867
+ socketOpen = true;
1868
+ void sync('opened');
1869
+ }
1870
+ else if (event.kind === 'closed')
1871
+ closedSocket(event.revoked);
1872
+ else if (event.kind === 'opening')
1873
+ void applyOpening(event.expired);
1874
+ else
1875
+ void applyRoom(event.locator, event.roomVersion);
1694
1876
  });
1695
1877
  }
1696
1878
  function writesHeld() {
@@ -1711,14 +1893,17 @@ export function composeRuntime(options) {
1711
1893
  listener(mode);
1712
1894
  }
1713
1895
  if (releasing)
1714
- void enqueue();
1896
+ submitAll();
1715
1897
  }
1716
1898
  function observeConversations() {
1717
1899
  const stream = options.adapter.conversations;
1718
1900
  if (stream === undefined || withdrawConversations !== undefined)
1719
1901
  return;
1720
1902
  withdrawConversations = stream.observe((event) => {
1721
- conversations.receive(event);
1903
+ if (conversations.receive(event)) {
1904
+ void ready.then(scheduleCurrency);
1905
+ return;
1906
+ }
1722
1907
  if (event.kind !== 'gap' || conversations.current())
1723
1908
  return;
1724
1909
  show();
@@ -1729,7 +1914,7 @@ export function composeRuntime(options) {
1729
1914
  if (currencyQueued || closed || stopping)
1730
1915
  return;
1731
1916
  currencyQueued = true;
1732
- chain = chain.then(runCurrency, runCurrency);
1917
+ void task(runCurrency);
1733
1918
  }
1734
1919
  async function runCurrency() {
1735
1920
  currencyQueued = false;
@@ -1740,110 +1925,22 @@ export function composeRuntime(options) {
1740
1925
  show();
1741
1926
  return;
1742
1927
  }
1743
- const resting = status;
1744
- const progress = { complete: false, reachable: !resting.providerUnreachable };
1745
- moveTo(transferring(resting));
1928
+ const progress = { complete: false, reachable: !status.providerUnreachable };
1929
+ moveTo(transferring(status));
1746
1930
  try {
1747
1931
  await conversations.catchUp(progress);
1748
1932
  }
1749
1933
  catch {
1750
1934
  progress.reachable = false;
1751
1935
  }
1752
- finally {
1753
- if (!closed) {
1754
- moveTo(settled(resting, progress, resting.lastCompleted, pending(), attention()));
1755
- }
1756
- }
1757
- }
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;
1936
+ await rest(progress);
1840
1937
  }
1841
1938
  function close() {
1842
1939
  if (closed)
1843
1940
  return;
1844
1941
  closed = true;
1845
- withdrawNotifications?.();
1846
- withdrawNotifications = undefined;
1942
+ withdrawStream?.();
1943
+ withdrawStream = undefined;
1847
1944
  withdrawConversations?.();
1848
1945
  withdrawConversations = undefined;
1849
1946
  withdrawServiceMode?.();
@@ -1858,6 +1955,7 @@ export function composeRuntime(options) {
1858
1955
  storageLossListeners.clear();
1859
1956
  statusListeners.clear();
1860
1957
  serviceModeListeners.clear();
1958
+ memberListeners.clear();
1861
1959
  readSide.close();
1862
1960
  queueSubscriptions.clear();
1863
1961
  roomDraftListeners.clear();
@@ -1867,6 +1965,7 @@ export function composeRuntime(options) {
1867
1965
  stopping = true;
1868
1966
  stopTimer();
1869
1967
  await chain.catch(() => undefined);
1968
+ await Promise.all([...submitting.values()].map((run) => run.done));
1870
1969
  await mutations;
1871
1970
  close();
1872
1971
  }
@@ -1875,11 +1974,32 @@ export function composeRuntime(options) {
1875
1974
  options.signal?.addEventListener('abort', close, { once: true });
1876
1975
  if (options.signal?.aborted)
1877
1976
  close();
1878
- void openLocal().catch(() => {
1977
+ observeStream();
1978
+ void openLocal().then(() => {
1979
+ if (capabilities.length > 0)
1980
+ notifyReady();
1981
+ }, () => {
1879
1982
  notifyReady();
1880
1983
  });
1881
- void enqueue();
1882
- function watch(topic, input, deliver) {
1984
+ if (options.adapter.roomStream === undefined)
1985
+ void sync('unstreamed');
1986
+ function planned(input, reach) {
1987
+ return planOf(input, roomFacts(reach), planReach(reach));
1988
+ }
1989
+ function watchPlan(plan, deliver) {
1990
+ materialize(reachedRooms(plan));
1991
+ return readSide.watch(plan, identityOf(plan), deliver);
1992
+ }
1993
+ async function searched(input, reach) {
1994
+ const plan = searchPlanOf(input);
1995
+ const selected = plan.documents === undefined && reach === null
1996
+ ? null
1997
+ : (await rowsQuery(filterOf(plan.documents ?? {}, roomFacts(reach), planReach(reach)))).documents;
1998
+ if (selected === null)
1999
+ materialize(null);
2000
+ return searchIndex.search(reach === null || reach.conversations ? plan : { ...plan, conversations: [] }, selected);
2001
+ }
2002
+ const watch = (topic, input, deliver) => {
1883
2003
  if (topic === 'room-drafts') {
1884
2004
  const listener = { notify: deliver, ready: false };
1885
2005
  roomDraftListeners.add(listener);
@@ -1900,6 +2020,8 @@ export function composeRuntime(options) {
1900
2020
  if (topic === 'conversation') {
1901
2021
  return conversations.watch(input[0], input[1], deliver);
1902
2022
  }
2023
+ if (topic === 'conversations')
2024
+ return conversations.watchList(deliver);
1903
2025
  if (topic === 'presentation') {
1904
2026
  const listener = {
1905
2027
  app: input[0],
@@ -1954,6 +2076,13 @@ export function composeRuntime(options) {
1954
2076
  serviceModeListeners.delete(deliver);
1955
2077
  };
1956
2078
  }
2079
+ if (topic === 'member') {
2080
+ memberListeners.add(deliver);
2081
+ deliver(member);
2082
+ return () => {
2083
+ memberListeners.delete(deliver);
2084
+ };
2085
+ }
1957
2086
  if (topic === 'queue') {
1958
2087
  const queue = { locator: input[0], notify: deliver };
1959
2088
  queueSubscriptions.add(queue);
@@ -1962,9 +2091,8 @@ export function composeRuntime(options) {
1962
2091
  queueSubscriptions.delete(queue);
1963
2092
  };
1964
2093
  }
1965
- const plan = planOf(input[0], roomFacts());
1966
- return readSide.watch(plan, identityOf(plan), deliver);
1967
- }
2094
+ return watchPlan(planned(input[0], null), deliver);
2095
+ };
1968
2096
  const md = {
1969
2097
  loaded() {
1970
2098
  return openLocal().then(() => undefined);
@@ -1972,15 +2100,8 @@ export function composeRuntime(options) {
1972
2100
  rooms() {
1973
2101
  return listed();
1974
2102
  },
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
- },
2103
+ query: ((input) => rowsQuery(planned(input, null))),
2104
+ search: (input) => searched(input, null),
1984
2105
  types(locator) {
1985
2106
  return {
1986
2107
  collection: modelTypes(modelOf(locator)),
@@ -2008,14 +2129,11 @@ export function composeRuntime(options) {
2008
2129
  return exclusive(async () => {
2009
2130
  const parts = readParts(input);
2010
2131
  if (path === '' || normalize(path) !== path)
2011
- throw new Error('room-draft-malformed');
2132
+ throw runtimeError('room-draft-malformed');
2012
2133
  const local = await openLocal();
2013
2134
  const current = roomDrafts.get(room)?.get(path);
2014
2135
  if ((current?.revision ?? null) !== revision) {
2015
- return {
2016
- outcome: 'stale',
2017
- current: current === undefined ? null : roomDraftOf(current),
2018
- };
2136
+ return staleWrite(current === undefined ? null : roomDraftOf(current));
2019
2137
  }
2020
2138
  const record = {
2021
2139
  locator: room,
@@ -2035,10 +2153,7 @@ export function composeRuntime(options) {
2035
2153
  const local = await openLocal();
2036
2154
  const current = roomDrafts.get(room)?.get(path);
2037
2155
  if (current === undefined || current.revision !== revision) {
2038
- return {
2039
- outcome: 'stale',
2040
- current: current === undefined ? null : roomDraftOf(current),
2041
- };
2156
+ return staleWrite(current === undefined ? null : roomDraftOf(current));
2042
2157
  }
2043
2158
  await local.discardRoomDraft(room, path);
2044
2159
  forgetRoomDraft(room, path);
@@ -2087,9 +2202,7 @@ export function composeRuntime(options) {
2087
2202
  persistence() {
2088
2203
  return options.shell.persistentStorage;
2089
2204
  },
2090
- observe: ((topic, ...input) => opened((deliver) => watch(topic, input, (message) => {
2091
- deliver(structuredClone(message));
2092
- }), topic === 'query')),
2205
+ observe: observing(watch),
2093
2206
  async reauthorize() {
2094
2207
  if (refusal === null || !refusal.reauthorizationOffered)
2095
2208
  return;
@@ -2098,8 +2211,19 @@ export function composeRuntime(options) {
2098
2211
  await mutations;
2099
2212
  authorization = undefined;
2100
2213
  discoveryPaused = false;
2101
- await enqueue();
2214
+ if (options.adapter.roomStream === undefined)
2215
+ await sync('unstreamed');
2216
+ else
2217
+ await task(ensureAuthorization);
2102
2218
  },
2103
2219
  };
2104
- return { md, stop, watch };
2220
+ const reachable = {
2221
+ md,
2222
+ watch,
2223
+ plan: planned,
2224
+ query: (plan) => rowsQuery(plan),
2225
+ watchPlan,
2226
+ search: searched,
2227
+ };
2228
+ return { md, stop, watch, within: (reach) => narrowed(reachable, reach) };
2105
2229
  }