@agentshouse/mdruntime 0.1.0-alpha.2 → 0.1.0-alpha.21

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 (110) hide show
  1. package/AGENTS.md +87 -0
  2. package/dist/adapter.d.ts +145 -28
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js +2 -1
  5. package/dist/adapter.js.map +1 -1
  6. package/dist/bootstrap.d.ts +11 -13
  7. package/dist/bootstrap.d.ts.map +1 -1
  8. package/dist/bootstrap.js +25 -24
  9. package/dist/bootstrap.js.map +1 -1
  10. package/dist/change-set.d.ts +33 -0
  11. package/dist/change-set.d.ts.map +1 -0
  12. package/dist/change-set.js +74 -0
  13. package/dist/change-set.js.map +1 -0
  14. package/dist/change.d.ts +70 -0
  15. package/dist/change.d.ts.map +1 -0
  16. package/dist/change.js +12 -0
  17. package/dist/change.js.map +1 -0
  18. package/dist/client.d.ts +66 -0
  19. package/dist/client.d.ts.map +1 -0
  20. package/dist/client.js +229 -0
  21. package/dist/client.js.map +1 -0
  22. package/dist/collection.d.ts +25 -3
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +97 -25
  25. package/dist/collection.js.map +1 -1
  26. package/dist/connection.d.ts +87 -5
  27. package/dist/connection.d.ts.map +1 -1
  28. package/dist/connection.js +1653 -70
  29. package/dist/connection.js.map +1 -1
  30. package/dist/conversation.d.ts +57 -0
  31. package/dist/conversation.d.ts.map +1 -0
  32. package/dist/conversation.js +404 -0
  33. package/dist/conversation.js.map +1 -0
  34. package/dist/durability.d.ts +9 -0
  35. package/dist/durability.d.ts.map +1 -0
  36. package/dist/durability.js +11 -0
  37. package/dist/durability.js.map +1 -0
  38. package/dist/file.d.ts +20 -0
  39. package/dist/file.d.ts.map +1 -0
  40. package/dist/file.js +149 -0
  41. package/dist/file.js.map +1 -0
  42. package/dist/host.d.ts +13 -0
  43. package/dist/host.d.ts.map +1 -0
  44. package/dist/host.js +229 -0
  45. package/dist/host.js.map +1 -0
  46. package/dist/identity.d.ts +2 -0
  47. package/dist/identity.d.ts.map +1 -0
  48. package/dist/identity.js +16 -0
  49. package/dist/identity.js.map +1 -0
  50. package/dist/index.d.ts +13 -2
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/log.d.ts +5 -0
  55. package/dist/log.d.ts.map +1 -0
  56. package/dist/log.js +15 -0
  57. package/dist/log.js.map +1 -0
  58. package/dist/memory.d.ts +17 -2
  59. package/dist/memory.d.ts.map +1 -1
  60. package/dist/memory.js +264 -21
  61. package/dist/memory.js.map +1 -1
  62. package/dist/model.d.ts +70 -0
  63. package/dist/model.d.ts.map +1 -0
  64. package/dist/model.js +28 -0
  65. package/dist/model.js.map +1 -0
  66. package/dist/outcome.d.ts +37 -0
  67. package/dist/outcome.d.ts.map +1 -0
  68. package/dist/outcome.js +23 -0
  69. package/dist/outcome.js.map +1 -0
  70. package/dist/overlay.d.ts +10 -0
  71. package/dist/overlay.d.ts.map +1 -0
  72. package/dist/overlay.js +245 -0
  73. package/dist/overlay.js.map +1 -0
  74. package/dist/paths.d.ts +6 -0
  75. package/dist/paths.d.ts.map +1 -0
  76. package/dist/paths.js +47 -0
  77. package/dist/paths.js.map +1 -0
  78. package/dist/query.d.ts +13 -0
  79. package/dist/query.d.ts.map +1 -0
  80. package/dist/query.js +18 -0
  81. package/dist/query.js.map +1 -0
  82. package/dist/refresh.d.ts +14 -0
  83. package/dist/refresh.d.ts.map +1 -0
  84. package/dist/refresh.js +65 -0
  85. package/dist/refresh.js.map +1 -0
  86. package/dist/replica.d.ts +33 -0
  87. package/dist/replica.d.ts.map +1 -0
  88. package/dist/replica.js +134 -0
  89. package/dist/replica.js.map +1 -0
  90. package/dist/save.d.ts +35 -0
  91. package/dist/save.d.ts.map +1 -0
  92. package/dist/save.js +561 -0
  93. package/dist/save.js.map +1 -0
  94. package/dist/status.d.ts +25 -0
  95. package/dist/status.d.ts.map +1 -0
  96. package/dist/status.js +32 -0
  97. package/dist/status.js.map +1 -0
  98. package/dist/store.d.ts +86 -4
  99. package/dist/store.d.ts.map +1 -1
  100. package/dist/store.js +394 -9
  101. package/dist/store.js.map +1 -1
  102. package/dist/transfer.d.ts +5 -0
  103. package/dist/transfer.d.ts.map +1 -0
  104. package/dist/transfer.js +5 -0
  105. package/dist/transfer.js.map +1 -0
  106. package/dist/wire.d.ts +63 -0
  107. package/dist/wire.d.ts.map +1 -0
  108. package/dist/wire.js +142 -0
  109. package/dist/wire.js.map +1 -0
  110. package/package.json +13 -4
@@ -1,39 +1,255 @@
1
- import { query as evaluate } from '@agentshouse/query';
1
+ import { rawQuery } from './query.js';
2
+ import { cached, modelTypes, NO_MODEL, sameModel } from './model.js';
2
3
  import { ROOM_CONTRACT_VERSION, VERSION_1_CAPABILITIES } from './adapter.js';
3
- import { adoptReplica, bootstrapRoom } from './bootstrap.js';
4
- import { MD_MODEL, PRESENTED } from './collection.js';
5
- import { openReplicaStore } from './store.js';
4
+ import { bootstrapRoom } from './bootstrap.js';
5
+ import { changeSet, mergeChangeSets } from './change-set.js';
6
+ import { createConversationStore } from './conversation.js';
7
+ import { MD_MODEL, PRESENTED, validRoot } from './collection.js';
8
+ import { createLog } from './log.js';
9
+ import { queuedOutcome, refusalOf, refused, withheldUntilReset } from './outcome.js';
10
+ import { refreshRoom } from './refresh.js';
11
+ import { buildBatch } from './save.js';
12
+ import { applyReturned, compositionOf, readConfirmed, replicaOf } from './replica.js';
13
+ import { settled, same, saving, STARTING, transferring } from './status.js';
14
+ import { detectStorageLoss, nextGeneration } from './durability.js';
15
+ import { beneath } from './paths.js';
16
+ import { openRuntimeStore } from './store.js';
17
+ import { unconfirmed } from './transfer.js';
6
18
  const DEFAULT_DISCOVERY_INTERVAL_MS = 60_000;
7
19
  const knownCapabilities = new Set(VERSION_1_CAPABILITIES);
20
+ function within(root, path) {
21
+ return root === '' || beneath(root, path);
22
+ }
23
+ function declaredIdentity(model) {
24
+ if (model === undefined) return false;
25
+ if (model === null) return true;
26
+ return typeof model.identity === 'string' && model.identity.length > 0 && typeof model.version === 'string' && model.version.length > 0;
27
+ }
28
+ function declaredSelection(room) {
29
+ const selection = room.typedSelection;
30
+ if (selection === undefined) return true;
31
+ if (!Array.isArray(selection.roots) || selection.roots.length === 0) return false;
32
+ if (!declaredIdentity(selection.attachedModel)) return false;
33
+ return selection.roots.every((root, index)=>validRoot(root) && root !== '' && !within(room.collectionRoot, root) && selection.roots.every((other, at)=>at === index || !within(other, root)));
34
+ }
8
35
  export function createMdRuntime(options) {
9
36
  const period = options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
10
37
  let authorization = options.authorization;
38
+ let opening;
11
39
  let store;
12
40
  const replicas = new Map();
41
+ const drafts = new Map();
42
+ const presentations = new Map();
43
+ const outcomes = new Map();
44
+ const acceptedIn = new Map();
45
+ let limits = [];
46
+ let slots = [];
47
+ let capabilities = [];
48
+ function rewriting() {
49
+ return capabilities.some((capability)=>capability.name === 'move-reference-rewrites') ? 'provider' : 'runtime';
50
+ }
13
51
  const rooms = new Map();
14
52
  const unreadable = new Set();
53
+ const models = new Map();
54
+ const liveModels = new Map();
55
+ const invalidModels = new Set();
56
+ const modelEpoch = new Map();
57
+ const discovered = new Map();
58
+ const scopedRefusals = new Map();
59
+ function modelKey(locator, attached) {
60
+ return `${attached} ${locator}`;
61
+ }
62
+ function modelOf(locator, attached = 'collection') {
63
+ return models.get(modelKey(locator, attached)) ?? NO_MODEL;
64
+ }
65
+ function savedModel(locator, attached) {
66
+ return invalidModels.has(modelKey(locator, attached)) ? {
67
+ status: 'unavailable',
68
+ reason: 'revoked'
69
+ } : modelOf(locator, attached);
70
+ }
71
+ function compositionFor(record) {
72
+ return compositionOf({
73
+ ...record,
74
+ model: savedModel(record.locator, 'collection'),
75
+ selectionModel: savedModel(record.locator, 'typed-selection')
76
+ });
77
+ }
78
+ const mismatchedRoots = new Set();
15
79
  let refusal = null;
80
+ let storageLoss = null;
81
+ let status = STARTING;
16
82
  let discoveryPaused = false;
17
83
  let timer;
18
84
  let closed = false;
85
+ let withdrawNotifications;
86
+ let handed;
87
+ const notices = new Map();
19
88
  let chain = Promise.resolve();
89
+ let mutations = Promise.resolve();
20
90
  let inflight = false;
21
91
  let queued = false;
22
92
  const roomListeners = new Set();
23
93
  const refusalListeners = new Set();
94
+ const storageLossListeners = new Set();
95
+ const statusListeners = new Set();
24
96
  const querySubscriptions = new Set();
25
- function listed() {
97
+ const queueSubscriptions = new Set();
98
+ const draftListeners = new Set();
99
+ const presentationListeners = new Set();
100
+ let withdrawConversations;
101
+ const conversations = createConversationStore({
102
+ cards: declaredCards,
103
+ documents: ()=>visible({}),
104
+ call: (name, input)=>invokeAction(name, input),
105
+ durably,
106
+ path: ()=>options.adapter.conversations,
107
+ closed: ()=>closed
108
+ });
109
+ const journal = createLog();
110
+ let notifyReady = ()=>{};
111
+ const ready = new Promise((resolve)=>{
112
+ notifyReady = resolve;
113
+ });
114
+ function draftContent(locator, path) {
115
+ return drafts.get(locator)?.get(path);
116
+ }
117
+ function rememberDraft(locator, path, content) {
118
+ const room = drafts.get(locator) ?? new Map();
119
+ room.set(path, content);
120
+ drafts.set(locator, room);
121
+ }
122
+ function forgetDraft(locator, path) {
123
+ const room = drafts.get(locator);
124
+ if (room === undefined) return;
125
+ room.delete(path);
126
+ if (room.size === 0) drafts.delete(locator);
127
+ }
128
+ function snapshotDrafts() {
129
+ const entries = [];
130
+ for (const [locator, room] of drafts){
131
+ for (const [path, content] of room){
132
+ entries.push({
133
+ locator,
134
+ path,
135
+ content: structuredClone(content)
136
+ });
137
+ }
138
+ }
139
+ entries.sort((left, right)=>{
140
+ if (left.locator !== right.locator) return left.locator < right.locator ? -1 : 1;
141
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1;
142
+ return 0;
143
+ });
144
+ return entries;
145
+ }
146
+ function publishDrafts(waitingOnly) {
147
+ if (closed) return;
148
+ const value = snapshotDrafts();
149
+ for (const listener of draftListeners){
150
+ if (waitingOnly && listener.ready) continue;
151
+ listener.ready = true;
152
+ listener.notify(value);
153
+ }
154
+ }
155
+ function emitDrafts() {
156
+ publishDrafts(false);
157
+ }
158
+ function releaseWaitingDrafts() {
159
+ publishDrafts(true);
160
+ }
161
+ function presentationOf(app) {
162
+ const value = presentations.get(app);
163
+ return value === undefined ? null : structuredClone(value);
164
+ }
165
+ function emitPresentation(app) {
166
+ if (closed) return;
167
+ for (const listener of presentationListeners){
168
+ if (listener.app === app && listener.ready) listener.notify(presentationOf(app));
169
+ }
170
+ }
171
+ function commitDraft(locator, path, work) {
172
+ return exclusive(async ()=>{
173
+ const local = await openLocal();
174
+ await work(local);
175
+ publishChangeSet(changeSet(locator, null, null, {
176
+ drafts: [
177
+ path
178
+ ]
179
+ }));
180
+ });
181
+ }
182
+ function pending() {
26
183
  return [
184
+ ...replicas.values()
185
+ ].some((replica)=>replica.queue.length > 0);
186
+ }
187
+ function attention() {
188
+ const attentionRequired = [];
189
+ let reset = null;
190
+ for (const [locator, replica] of replicas){
191
+ const head = replica.queue[0];
192
+ if (head === undefined) continue;
193
+ const outcome = outcomes.get(head.id);
194
+ if (outcome?.kind === 'retryable-refusal') {
195
+ if (outcome.reset !== undefined && (reset === null || outcome.reset < reset)) {
196
+ reset = outcome.reset;
197
+ }
198
+ continue;
199
+ }
200
+ if (!refused(outcome)) continue;
201
+ attentionRequired.push({
202
+ locator,
203
+ batch: head.id,
204
+ outcome: outcome.kind,
205
+ refusal: refusalOf(outcome),
206
+ ...outcome.kind === 'conflicted' && outcome.roomVersion !== undefined ? {
207
+ roomVersion: outcome.roomVersion
208
+ } : {}
209
+ });
210
+ }
211
+ return {
212
+ attentionRequired,
213
+ reset
214
+ };
215
+ }
216
+ async function persistQueue(local, replica, batches, changedOutcomes = []) {
217
+ await local.write({
218
+ next: replica.confirmed.record,
219
+ previous: replica.confirmed.record,
220
+ queue: batches,
221
+ previousQueue: replica.queue,
222
+ outcomes: changedOutcomes
223
+ });
224
+ }
225
+ function listed() {
226
+ return structuredClone([
27
227
  ...rooms.values()
28
- ];
228
+ ]);
229
+ }
230
+ function slotted() {
231
+ return structuredClone(slots.map(({ slot, entries })=>({
232
+ slot,
233
+ entries: entries.flatMap(({ locator, segment })=>{
234
+ const room = rooms.get(locator);
235
+ return room === undefined ? [] : [
236
+ {
237
+ room,
238
+ segment
239
+ }
240
+ ];
241
+ })
242
+ })));
29
243
  }
30
244
  function queryRooms() {
31
245
  return [
32
246
  ...replicas.values()
33
247
  ].map((replica)=>({
34
- room: replica.record.locator,
35
- roomVersion: replica.record.roomVersion,
36
- documents: replica.collection.documents
248
+ room: replica.confirmed.record.locator,
249
+ roomVersion: replica.confirmed.record.roomVersion,
250
+ documents: replica.visible.collection.documents,
251
+ model: modelTypes(modelOf(replica.confirmed.record.locator)),
252
+ typedSelection: replica.confirmed.record.typedSelection === null ? null : modelTypes(modelOf(replica.confirmed.record.locator, 'typed-selection'))
37
253
  }));
38
254
  }
39
255
  function emitRooms() {
@@ -43,8 +259,20 @@ export function createMdRuntime(options) {
43
259
  function emitRefusal() {
44
260
  for (const listener of refusalListeners)listener(refusal);
45
261
  }
262
+ function emitStorageLoss() {
263
+ for (const listener of storageLossListeners)listener(storageLoss);
264
+ }
265
+ function moveTo(next) {
266
+ if (closed || same(status, next)) return;
267
+ status = next;
268
+ for (const listener of statusListeners)listener(status);
269
+ }
46
270
  function visible(request) {
47
- return evaluate(queryRooms(), {
271
+ if (options.queryBinding === null) {
272
+ if (Object.keys(request).length > 0) throw new Error('query-binding-unavailable');
273
+ return rawQuery(queryRooms());
274
+ }
275
+ return options.queryBinding(queryRooms(), {
48
276
  ...request,
49
277
  selector: {
50
278
  $and: [
@@ -59,6 +287,27 @@ export function createMdRuntime(options) {
59
287
  subscription.notify(visible(subscription.query));
60
288
  }
61
289
  }
290
+ function entriesOf(locator) {
291
+ const replica = replicas.get(locator);
292
+ if (replica === undefined) return [];
293
+ return replica.queue.map((batch)=>Object.freeze({
294
+ batch,
295
+ outcome: queuedOutcome(outcomes.get(batch.id))
296
+ }));
297
+ }
298
+ function emitQueue(locator) {
299
+ const entries = entriesOf(locator);
300
+ for (const subscription of queueSubscriptions){
301
+ if (subscription.locator === locator) subscription.notify(entries);
302
+ }
303
+ }
304
+ function publishChangeSet(set) {
305
+ if (set.rooms.length > 0) emitRooms();
306
+ if (set.documents.length > 0 || set.query) emitVisible();
307
+ for (const locator of set.queues)emitQueue(locator);
308
+ if (set.drafts.length > 0) emitDrafts();
309
+ for (const app of set.presentations)emitPresentation(app);
310
+ }
62
311
  function stopTimer() {
63
312
  if (timer === undefined) return;
64
313
  clearInterval(timer);
@@ -70,102 +319,1141 @@ export function createMdRuntime(options) {
70
319
  void enqueue();
71
320
  }, period);
72
321
  }
73
- function refuse(reason) {
322
+ async function refuse(reason, provider = null) {
74
323
  stopTimer();
75
324
  discoveryPaused = true;
76
- refusal = {
77
- reason,
78
- reauthorizationOffered: reason === 'revoked-authorization'
79
- };
80
- emitRefusal();
325
+ try {
326
+ if (reason === 'revoked-authorization') {
327
+ await exclusive(async ()=>{
328
+ const revoked = {
329
+ status: 'unavailable',
330
+ reason: 'revoked'
331
+ };
332
+ try {
333
+ const local = await openLocal();
334
+ await local.revokeModels([
335
+ ...replicas.values()
336
+ ].map((replica)=>({
337
+ record: replica.confirmed.record,
338
+ queue: replica.queue
339
+ })));
340
+ } catch {
341
+ journal.record('model revocation persistence failed');
342
+ }
343
+ const keys = new Set(models.keys());
344
+ for (const locator of rooms.keys()){
345
+ keys.add(modelKey(locator, 'collection'));
346
+ keys.add(modelKey(locator, 'typed-selection'));
347
+ }
348
+ for (const key of keys){
349
+ invalidModels.add(key);
350
+ modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
351
+ models.set(key, revoked);
352
+ liveModels.delete(key);
353
+ }
354
+ const changed = [];
355
+ for (const [locator, room] of rooms){
356
+ const before = replicas.get(locator) ?? null;
357
+ if (before !== null) {
358
+ const record = {
359
+ ...before.confirmed.record,
360
+ model: revoked,
361
+ selectionModel: revoked
362
+ };
363
+ const confirmed = readConfirmed(record, before.confirmed);
364
+ if (confirmed === null) throw new Error(`Cannot retype revoked Room ${locator}`);
365
+ replicas.set(locator, replicaOf(confirmed, before.queue, rewriting(), before));
366
+ }
367
+ rooms.set(locator, {
368
+ ...room,
369
+ model: modelTypes(revoked),
370
+ typedSelection: room.typedSelection === null ? null : {
371
+ ...room.typedSelection,
372
+ model: modelTypes(revoked)
373
+ }
374
+ });
375
+ changed.push(changeSet(locator, before, replicas.get(locator) ?? null, {
376
+ room: true,
377
+ query: true
378
+ }));
379
+ }
380
+ publishChangeSet(mergeChangeSets(changed));
381
+ });
382
+ }
383
+ } finally{
384
+ refusal = {
385
+ reason,
386
+ reauthorizationOffered: reason === 'revoked-authorization',
387
+ refusal: provider
388
+ };
389
+ journal.record(`connection refused ${reason}`);
390
+ emitRefusal();
391
+ }
81
392
  }
82
393
  function list(room, synchronizationAvailable) {
394
+ const selection = room.typedSelection ?? null;
83
395
  rooms.set(room.locator, {
84
396
  name: room.name,
397
+ collectionRoot: room.collectionRoot,
85
398
  locator: room.locator,
86
- writeGrants: room.writeGrants,
399
+ operations: room.operations,
400
+ replica: room.replica,
401
+ attachedModel: room.attachedModel,
402
+ model: modelTypes(modelOf(room.locator)),
403
+ typedSelection: selection === null ? null : {
404
+ roots: selection.roots,
405
+ attachedModel: selection.attachedModel,
406
+ model: modelTypes(modelOf(room.locator, 'typed-selection'))
407
+ },
87
408
  providerOwnedPaths: room.providerOwnedPaths,
88
- roomVersion: replicas.get(room.locator)?.record.roomVersion ?? null,
89
- synchronizationAvailable
409
+ roomVersion: replicas.get(room.locator)?.confirmed.record.roomVersion ?? null,
410
+ synchronizationAvailable,
411
+ refusal: scopedRefusals.get(room.locator) ?? null
90
412
  });
91
413
  }
92
- function applyDiscovery(discovered) {
93
- const listedLocators = new Set(discovered.map((room)=>room.locator));
94
- for (const room of discovered){
95
- list(room, !unreadable.has(room.locator));
414
+ async function applyDiscovery(listed, local) {
415
+ const listedLocators = new Set(listed.map((room)=>room.locator));
416
+ for (const locator of listedLocators)scopedRefusals.delete(locator);
417
+ discovered.clear();
418
+ for (const room of listed)discovered.set(room.locator, room);
419
+ await dropUnlisted(listedLocators, local);
420
+ for (const room of listed){
421
+ const held = replicas.get(room.locator)?.confirmed.record;
422
+ if (held !== undefined && held.replica.selection !== room.replica.selection) {
423
+ list(held, false);
424
+ continue;
425
+ }
426
+ list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
96
427
  }
97
- for (const [locator, room] of rooms){
98
- if (listedLocators.has(locator)) continue;
99
- rooms.set(locator, {
100
- ...room,
101
- synchronizationAvailable: false
428
+ emitRooms();
429
+ }
430
+ function unlistedHeld(listed) {
431
+ return [
432
+ ...new Set([
433
+ ...rooms.keys(),
434
+ ...drafts.keys()
435
+ ])
436
+ ].filter((locator)=>!listed.has(locator));
437
+ }
438
+ async function dropUnlisted(listed, local) {
439
+ if (unlistedHeld(listed).length === 0) return;
440
+ await exclusive(async ()=>{
441
+ const unlisted = unlistedHeld(listed);
442
+ await local.dropRooms(unlisted);
443
+ for (const [batch, locator] of acceptedIn){
444
+ if (listed.has(locator)) continue;
445
+ acceptedIn.delete(batch);
446
+ outcomes.delete(batch);
447
+ }
448
+ const removed = unlisted.map((locator)=>changeSet(locator, replicas.get(locator) ?? null, null, {
449
+ room: true,
450
+ queue: true,
451
+ query: true,
452
+ drafts: [
453
+ ...drafts.get(locator)?.keys() ?? []
454
+ ]
455
+ }));
456
+ for (const locator of unlisted){
457
+ for (const batch of replicas.get(locator)?.queue ?? [])outcomes.delete(batch.id);
458
+ replicas.delete(locator);
459
+ rooms.delete(locator);
460
+ drafts.delete(locator);
461
+ unreadable.delete(locator);
462
+ scopedRefusals.delete(locator);
463
+ for (const attached of [
464
+ 'collection',
465
+ 'typed-selection'
466
+ ]){
467
+ const key = modelKey(locator, attached);
468
+ models.delete(key);
469
+ liveModels.delete(key);
470
+ invalidModels.delete(key);
471
+ modelEpoch.delete(key);
472
+ }
473
+ journal.record(`room unlisted ${locator}`);
474
+ }
475
+ publishChangeSet(mergeChangeSets(removed));
476
+ moveTo({
477
+ ...status,
478
+ ...attention()
102
479
  });
480
+ });
481
+ }
482
+ function restored(key, persisted) {
483
+ const prior = models.get(key);
484
+ if (prior !== undefined && prior.status !== 'unavailable' && (persisted.status === 'unavailable' || !sameModel(prior.snapshot, persisted.snapshot))) {
485
+ modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
486
+ invalidModels.add(key);
103
487
  }
104
- emitRooms();
488
+ const model = persisted.status === 'unavailable' || liveModels.get(key) === JSON.stringify(persisted.snapshot) ? persisted : cached(persisted);
489
+ models.set(key, model);
490
+ if (model.status === 'unavailable') {
491
+ liveModels.delete(key);
492
+ invalidModels.delete(key);
493
+ }
494
+ return model;
105
495
  }
106
- async function openLocal() {
107
- if (store !== undefined) return store;
108
- const opened = await openReplicaStore();
109
- const records = await opened.all();
110
- store = opened;
496
+ async function hydrate(opened) {
497
+ const [records, queues, recordedOutcomes, kept, presented, cached, unsent, lastCompleted, declared, named, listedSlots] = await Promise.all([
498
+ opened.all(),
499
+ opened.queues(),
500
+ opened.outcomes(),
501
+ opened.drafts(),
502
+ opened.presentation(),
503
+ opened.conversations(),
504
+ opened.instructions(),
505
+ opened.lastCompleted(),
506
+ opened.providerLimits(),
507
+ opened.capabilities(),
508
+ opened.slots()
509
+ ]);
510
+ limits = declared;
511
+ capabilities = named;
512
+ slots = listedSlots;
513
+ drafts.clear();
514
+ for (const draft of kept)rememberDraft(draft.locator, draft.path, draft.content);
515
+ presentations.clear();
516
+ for (const record of presented)presentations.set(record.app, record.value);
517
+ conversations.hydrate(cached, unsent);
518
+ const queuedBatches = new Map(queues.map((queue)=>[
519
+ queue.locator,
520
+ queue.batches
521
+ ]));
522
+ outcomes.clear();
523
+ acceptedIn.clear();
524
+ for (const record of recordedOutcomes){
525
+ outcomes.set(record.id, record.outcome);
526
+ if (record.outcome.kind === 'accepted') acceptedIn.set(record.id, record.locator);
527
+ }
528
+ const previousReplicas = new Map(replicas);
529
+ replicas.clear();
111
530
  for (const record of records){
112
- const staged = adoptReplica(record);
113
- if (staged === null) continue;
114
- replicas.set(record.locator, staged);
115
- list(record, false);
531
+ const model = restored(modelKey(record.locator, 'collection'), record.model);
532
+ const selectionModel = restored(modelKey(record.locator, 'typed-selection'), record.selectionModel);
533
+ const prior = previousReplicas.get(record.locator);
534
+ const confirmed = readConfirmed({
535
+ ...record,
536
+ model,
537
+ selectionModel
538
+ }, prior?.confirmed);
539
+ if (confirmed === null) continue;
540
+ replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? [], rewriting(), prior));
541
+ const existing = rooms.get(record.locator);
542
+ if (existing === undefined) list(record, false);
543
+ else {
544
+ rooms.set(record.locator, {
545
+ ...existing,
546
+ roomVersion: record.roomVersion,
547
+ operations: record.operations,
548
+ replica: record.replica,
549
+ attachedModel: record.attachedModel,
550
+ model: modelTypes(model),
551
+ typedSelection: record.typedSelection === null ? null : {
552
+ roots: record.typedSelection.roots,
553
+ attachedModel: record.typedSelection.attachedModel,
554
+ model: modelTypes(selectionModel)
555
+ }
556
+ });
557
+ }
116
558
  }
559
+ return {
560
+ lastCompleted,
561
+ locators: new Set([
562
+ ...queuedBatches.keys(),
563
+ ...replicas.keys()
564
+ ])
565
+ };
566
+ }
567
+ async function readState(opened) {
568
+ const { lastCompleted, locators } = await hydrate(opened);
117
569
  if (replicas.size > 0) {
118
570
  emitRooms();
119
571
  emitVisible();
120
572
  }
573
+ for (const locator of locators)emitQueue(locator);
574
+ emitDrafts();
575
+ return lastCompleted;
576
+ }
577
+ async function open() {
578
+ const opened = await openRuntimeStore();
579
+ store = opened;
580
+ const kept = options.shell.generation;
581
+ const storedGeneration = await opened.generation();
582
+ const loss = detectStorageLoss(storedGeneration, kept);
583
+ const generation = nextGeneration(storedGeneration, kept);
584
+ if (generation !== storedGeneration) await opened.rememberGeneration(generation);
585
+ if (generation !== kept) await options.shell.keepGeneration(generation);
586
+ if (loss !== null) {
587
+ storageLoss = loss;
588
+ emitStorageLoss();
589
+ }
590
+ const lastCompleted = await readState(opened);
591
+ moveTo({
592
+ ...status,
593
+ lastCompleted,
594
+ ...attention()
595
+ });
121
596
  return opened;
122
597
  }
123
- async function bootstrapMissing(discovered, local) {
124
- for (const room of discovered){
125
- if (closed || authorization === undefined) return;
126
- if (replicas.has(room.locator)) continue;
127
- const outcome = await bootstrapRoom(options.adapter, authorization, room);
128
- if (outcome.kind === 'revoked-authorization') {
129
- refuse('revoked-authorization');
130
- return;
598
+ function durably(work) {
599
+ return exclusive(async ()=>{
600
+ const local = await openLocal();
601
+ return work(local);
602
+ });
603
+ }
604
+ function openLocal() {
605
+ opening ??= open().catch((error)=>{
606
+ opening = undefined;
607
+ throw error;
608
+ });
609
+ return opening;
610
+ }
611
+ function exclusive(work) {
612
+ const next = mutations.then(work, work);
613
+ mutations = next.then(()=>undefined, ()=>undefined);
614
+ return next;
615
+ }
616
+ function publish(local, room, confirmed) {
617
+ return exclusive(async ()=>{
618
+ const prior = replicas.get(room.locator);
619
+ const retained = readConfirmed({
620
+ ...confirmed.record,
621
+ model: modelOf(room.locator),
622
+ selectionModel: modelOf(room.locator, 'typed-selection')
623
+ }, prior?.confirmed);
624
+ if (retained !== null) {
625
+ await local.write({
626
+ next: retained.record,
627
+ previous: prior?.confirmed.record ?? null,
628
+ queue: prior?.queue ?? []
629
+ });
630
+ unreadable.delete(room.locator);
631
+ replicas.set(room.locator, replicaOf(retained, prior?.queue ?? [], rewriting(), prior));
632
+ }
633
+ list(room, true);
634
+ publishChangeSet(changeSet(room.locator, prior ?? null, replicas.get(room.locator) ?? null, {
635
+ room: true,
636
+ query: true
637
+ }));
638
+ });
639
+ }
640
+ async function write(locator, changes, replacing, writtenBy) {
641
+ const local = await openLocal();
642
+ const replica = replicas.get(locator);
643
+ const room = rooms.get(locator);
644
+ if (replica === undefined || room === undefined) {
645
+ throw new Error(`No Local Replica for ${locator}`);
646
+ }
647
+ if (mismatchedRoots.has(locator)) return {
648
+ outcome: 'refused',
649
+ rule: 'collection-root-mismatch'
650
+ };
651
+ const built = buildBatch({
652
+ baseRoomVersion: replica.confirmed.record.roomVersion,
653
+ composition: compositionFor(replica.confirmed.record),
654
+ operations: room.operations,
655
+ binding: options.modelBinding,
656
+ providerOwnedPaths: room.providerOwnedPaths,
657
+ providerLimits: limits,
658
+ rewriting: rewriting(),
659
+ visible: replica.visible,
660
+ changes,
661
+ writtenBy
662
+ });
663
+ if (built.kind === 'refused') return {
664
+ outcome: 'refused',
665
+ ...built.refusal
666
+ };
667
+ const head = replica.queue[0];
668
+ if (replacing !== undefined) {
669
+ if (head === undefined || head.id !== replacing || !refused(outcomes.get(head.id))) {
670
+ return {
671
+ outcome: 'refused',
672
+ rule: 'no-refused-head'
673
+ };
131
674
  }
132
- if (outcome.kind === 'room-not-readable') {
675
+ }
676
+ const batches = replacing === undefined ? [
677
+ ...replica.queue,
678
+ built.batch
679
+ ] : [
680
+ built.batch,
681
+ ...replica.queue.slice(1)
682
+ ];
683
+ const consumed = [
684
+ ...new Set(changes.map((change)=>change.path))
685
+ ];
686
+ try {
687
+ await local.write({
688
+ next: replica.confirmed.record,
689
+ previous: replica.confirmed.record,
690
+ queue: batches,
691
+ previousQueue: replica.queue,
692
+ outcomes: replacing === undefined ? [] : [
693
+ {
694
+ id: replacing,
695
+ outcome: null
696
+ }
697
+ ],
698
+ consumedDrafts: consumed
699
+ });
700
+ } catch {
701
+ return {
702
+ outcome: 'refused',
703
+ rule: 'durable-write'
704
+ };
705
+ }
706
+ if (replacing !== undefined) outcomes.delete(replacing);
707
+ for (const path of consumed)forgetDraft(locator, path);
708
+ replicas.set(locator, replicaOf(replica.confirmed, batches, rewriting(), replica));
709
+ publishChangeSet(changeSet(locator, replica, replicas.get(locator) ?? null, {
710
+ queue: true,
711
+ drafts: consumed
712
+ }));
713
+ moveTo({
714
+ ...saving(status),
715
+ ...attention()
716
+ });
717
+ void enqueue();
718
+ return {
719
+ outcome: 'saved',
720
+ id: built.batch.id
721
+ };
722
+ }
723
+ async function discardHead(locator, identifier) {
724
+ const local = await openLocal();
725
+ const replica = replicas.get(locator);
726
+ const head = replica?.queue[0];
727
+ if (replica === undefined || head === undefined || head.id !== identifier || !refused(outcomes.get(head.id))) {
728
+ return;
729
+ }
730
+ const batches = replica.queue.slice(1);
731
+ await persistQueue(local, replica, batches, [
732
+ {
733
+ id: identifier,
734
+ outcome: null
735
+ }
736
+ ]);
737
+ outcomes.delete(identifier);
738
+ replicas.set(locator, replicaOf(replica.confirmed, batches, rewriting(), replica));
739
+ publishChangeSet(changeSet(locator, replica, replicas.get(locator) ?? null, {
740
+ queue: true
741
+ }));
742
+ moveTo({
743
+ ...status,
744
+ state: pending() ? 'pending' : status.state,
745
+ ...attention()
746
+ });
747
+ void enqueue();
748
+ }
749
+ async function readHistory(locator, path, query = {}) {
750
+ if (closed || refusal !== null) return {
751
+ kind: 'transport-uncertainty'
752
+ };
753
+ try {
754
+ await ensureAuthorization();
755
+ } catch {
756
+ return {
757
+ kind: 'transport-uncertainty'
758
+ };
759
+ }
760
+ if (closed || authorization === undefined || refusal !== null) {
761
+ return {
762
+ kind: 'transport-uncertainty'
763
+ };
764
+ }
765
+ const transfer = [
766
+ 'transfer',
767
+ 'history',
768
+ locator,
769
+ path
770
+ ];
771
+ if (query.revision !== undefined) transfer.push(query.revision);
772
+ if (query.pageToken !== undefined) transfer.push(query.pageToken);
773
+ journal.record(transfer.join(' '));
774
+ try {
775
+ const result = await options.adapter.history(authorization, {
776
+ locator,
777
+ path,
778
+ ...query.revision === undefined ? {} : {
779
+ revision: query.revision
780
+ },
781
+ ...query.pageToken === undefined ? {} : {
782
+ pageToken: query.pageToken
783
+ }
784
+ });
785
+ journal.record(`outcome history ${result.kind}`);
786
+ if (result.kind === 'scoped-refusal') {
787
+ const room = replicas.get(result.locator)?.confirmed.record;
788
+ if (room !== undefined) await scoped(result, room, await openLocal());
789
+ }
790
+ if (result.kind === 'revoked-authorization') await refuse('revoked-authorization');
791
+ return result;
792
+ } catch {
793
+ journal.record('outcome history transport-uncertainty');
794
+ return {
795
+ kind: 'transport-uncertainty'
796
+ };
797
+ }
798
+ }
799
+ function declaredCards() {
800
+ for (const capability of capabilities){
801
+ if (capability.conversationCards !== undefined) return capability.conversationCards;
802
+ }
803
+ return undefined;
804
+ }
805
+ function declaredAction(name) {
806
+ for (const capability of capabilities){
807
+ for (const action of capability.actions ?? []){
808
+ if (action.name === name) return action;
809
+ }
810
+ }
811
+ return undefined;
812
+ }
813
+ async function invokeAction(name, input) {
814
+ await ready;
815
+ if (closed || refusal !== null) return {
816
+ kind: 'transport-uncertainty'
817
+ };
818
+ if (declaredAction(name) === undefined) return {
819
+ kind: 'undeclared'
820
+ };
821
+ try {
822
+ await ensureAuthorization();
823
+ } catch {
824
+ return {
825
+ kind: 'transport-uncertainty'
826
+ };
827
+ }
828
+ if (closed || authorization === undefined || refusal !== null) {
829
+ return {
830
+ kind: 'transport-uncertainty'
831
+ };
832
+ }
833
+ journal.record(`transfer action ${name}`);
834
+ try {
835
+ const result = await options.adapter.action(authorization, {
836
+ name,
837
+ input
838
+ });
839
+ if (result.kind === 'scoped-refusal') {
840
+ const room = replicas.get(result.locator)?.confirmed.record;
841
+ if (room !== undefined) await scoped(result, room, await openLocal());
842
+ }
843
+ if (result.kind === 'revoked-authorization') await refuse('revoked-authorization');
844
+ journal.record(`outcome action ${result.kind}`);
845
+ if (result.kind === 'rejected') journal.record(`refusal rejected ${result.code}`);
846
+ return result;
847
+ } catch {
848
+ journal.record('outcome action transport-uncertainty');
849
+ return {
850
+ kind: 'transport-uncertainty'
851
+ };
852
+ }
853
+ }
854
+ async function pullRefresh(authorized, confirmed) {
855
+ try {
856
+ const result = await refreshRoom(options.adapter, authorized, confirmed, options.modelBinding);
857
+ const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
858
+ journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
859
+ return result;
860
+ } catch (error) {
861
+ journal.record(`transfer refresh ${confirmed.record.locator} transport-uncertainty`);
862
+ throw error;
863
+ }
864
+ }
865
+ async function pullBootstrap(authorized, room) {
866
+ try {
867
+ const result = await bootstrapRoom(options.adapter, authorized, room, {
868
+ collection: modelOf(room.locator),
869
+ selection: modelOf(room.locator, 'typed-selection')
870
+ }, options.modelBinding);
871
+ const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
872
+ journal.record(`transfer bootstrap ${room.locator} ${detail}`);
873
+ return result;
874
+ } catch (error) {
875
+ journal.record(`transfer bootstrap ${room.locator} transport-uncertainty`);
876
+ throw error;
877
+ }
878
+ }
879
+ async function advance(room, local, authorized) {
880
+ const held = replicas.get(room.locator);
881
+ if (held !== undefined) {
882
+ const refreshed = await pullRefresh(authorized, held.confirmed);
883
+ if (refreshed.kind === 'still-current') {
884
+ if (unreadable.delete(room.locator)) {
885
+ list(room, true);
886
+ emitRooms();
887
+ }
888
+ return null;
889
+ }
890
+ if (refreshed.kind === 'advanced') {
891
+ if (closed) return unconfirmed;
892
+ await publish(local, room, refreshed.confirmed);
893
+ return null;
894
+ }
895
+ if (refreshed.kind !== 'beyond-horizon') return refreshed;
896
+ }
897
+ const bootstrapped = await pullBootstrap(authorized, room);
898
+ if (bootstrapped.kind !== 'staged') return bootstrapped;
899
+ if (closed) return unconfirmed;
900
+ await publish(local, room, bootstrapped.confirmed);
901
+ return null;
902
+ }
903
+ function recordRefusal(result) {
904
+ if (result.kind === 'expired') return {
905
+ kind: 'expired'
906
+ };
907
+ if (result.kind === 'retryable-refusal') {
908
+ return {
909
+ kind: 'retryable-refusal',
910
+ code: result.code,
911
+ fields: result.fields,
912
+ ...result.reset === undefined ? {} : {
913
+ reset: result.reset
914
+ }
915
+ };
916
+ }
917
+ if (result.kind === 'conflicted') {
918
+ return {
919
+ kind: 'conflicted',
920
+ ...result.roomVersion === undefined ? {} : {
921
+ roomVersion: result.roomVersion
922
+ },
923
+ affected: result.affected,
924
+ code: result.code,
925
+ fields: result.fields
926
+ };
927
+ }
928
+ return {
929
+ kind: 'rejected',
930
+ affected: result.affected,
931
+ code: result.code,
932
+ fields: result.fields
933
+ };
934
+ }
935
+ async function commitAccept(room, local, submitted, accepted, base, overlay) {
936
+ const current = replicas.get(room.locator);
937
+ if (current === undefined || !current.queue.some((batch)=>batch.id === submitted.id)) {
938
+ return true;
939
+ }
940
+ const remaining = current.queue.filter((batch)=>batch.id !== submitted.id);
941
+ const retainedBase = readConfirmed({
942
+ ...base.record,
943
+ model: modelOf(room.locator),
944
+ selectionModel: modelOf(room.locator, 'typed-selection')
945
+ }, base);
946
+ if (retainedBase === null) return false;
947
+ const applied = overlay ? applyReturned(retainedBase, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion, options.modelBinding) : retainedBase;
948
+ if (applied === null) return false;
949
+ const acceptedOutcome = {
950
+ kind: 'accepted',
951
+ roomVersion: accepted.roomVersion,
952
+ ...accepted.author === undefined ? {} : {
953
+ author: accepted.author
954
+ },
955
+ ...accepted.time === undefined ? {} : {
956
+ time: accepted.time
957
+ }
958
+ };
959
+ await local.write({
960
+ next: applied.record,
961
+ previous: current.confirmed.record,
962
+ queue: remaining,
963
+ previousQueue: current.queue,
964
+ outcomes: [
965
+ {
966
+ id: submitted.id,
967
+ outcome: acceptedOutcome
968
+ }
969
+ ]
970
+ });
971
+ acceptedIn.set(submitted.id, room.locator);
972
+ outcomes.set(submitted.id, acceptedOutcome);
973
+ unreadable.delete(room.locator);
974
+ replicas.set(room.locator, replicaOf(applied, remaining, rewriting(), current));
975
+ list(room, true);
976
+ publishChangeSet(changeSet(room.locator, current, replicas.get(room.locator) ?? null, {
977
+ room: true,
978
+ queue: true,
979
+ query: true
980
+ }));
981
+ journal.record(`outcome ${room.locator} ${submitted.id} accepted ${accepted.roomVersion}` + (accepted.author === undefined ? '' : ` author ${accepted.author}`) + (accepted.time === undefined ? '' : ` time ${accepted.time}`));
982
+ return true;
983
+ }
984
+ async function submitRoom(room, local, authorized, progress) {
985
+ while(!closed && authorization !== undefined){
986
+ const replica = replicas.get(room.locator);
987
+ const head = replica?.queue[0];
988
+ if (replica === undefined || head === undefined) return null;
989
+ const held = outcomes.get(head.id);
990
+ if (refused(held)) return null;
991
+ if (held?.kind === 'retryable-refusal') {
992
+ if (withheldUntilReset(held, Date.now())) {
993
+ progress.complete = false;
994
+ return null;
995
+ }
996
+ await exclusive(async ()=>{
997
+ const current = replicas.get(room.locator);
998
+ if (current === undefined || current.queue[0]?.id !== head.id) return;
999
+ await persistQueue(local, current, current.queue, [
1000
+ {
1001
+ id: head.id,
1002
+ outcome: null
1003
+ }
1004
+ ]);
1005
+ outcomes.delete(head.id);
1006
+ publishChangeSet(changeSet(room.locator, current, current, {
1007
+ queue: true
1008
+ }));
1009
+ moveTo({
1010
+ ...status,
1011
+ ...attention()
1012
+ });
1013
+ });
1014
+ }
1015
+ let result;
1016
+ journal.record(`submission ${room.locator} ${head.id}`);
1017
+ if (await local.markSubmitted(room.locator, head.id)) {
1018
+ publishChangeSet(changeSet(room.locator, replica, replica, {
1019
+ queue: true
1020
+ }));
1021
+ }
1022
+ try {
1023
+ result = await options.adapter.mutate(authorized, {
1024
+ locator: room.locator,
1025
+ changeBatch: head
1026
+ });
1027
+ } catch {
1028
+ progress.complete = false;
1029
+ progress.reachable = false;
1030
+ return null;
1031
+ }
1032
+ if (closed) return null;
1033
+ if (result.kind === 'transport-uncertainty') {
1034
+ progress.complete = false;
1035
+ progress.reachable = false;
1036
+ return null;
1037
+ }
1038
+ if (result.kind === 'room-not-readable') {
1039
+ progress.complete = false;
133
1040
  unreadable.add(room.locator);
134
1041
  list(room, false);
135
1042
  emitRooms();
1043
+ return null;
1044
+ }
1045
+ if (result.kind === 'revoked-authorization') {
1046
+ await refuse('revoked-authorization');
1047
+ return 'stop';
1048
+ }
1049
+ if (result.kind === 'scoped-refusal') {
1050
+ await scoped(result, room, local);
1051
+ progress.complete = false;
1052
+ return null;
1053
+ }
1054
+ if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired' || result.kind === 'retryable-refusal') {
1055
+ journal.record(`outcome ${room.locator} ${head.id} ${result.kind}` + (result.kind === 'conflicted' && result.roomVersion !== undefined ? ` ${result.roomVersion}` : ''));
1056
+ if (result.kind !== 'expired') journal.record(`refusal ${result.kind} ${result.code}`);
1057
+ if (result.kind === 'retryable-refusal') progress.complete = false;
1058
+ await exclusive(async ()=>{
1059
+ const current = replicas.get(room.locator);
1060
+ if (current === undefined || !current.queue.some((batch)=>batch.id === head.id)) {
1061
+ return;
1062
+ }
1063
+ const recorded = recordRefusal(result);
1064
+ await persistQueue(local, current, current.queue, [
1065
+ {
1066
+ id: head.id,
1067
+ outcome: recorded
1068
+ }
1069
+ ]);
1070
+ outcomes.set(head.id, recorded);
1071
+ publishChangeSet(changeSet(room.locator, current, current, {
1072
+ queue: true
1073
+ }));
1074
+ moveTo({
1075
+ ...status,
1076
+ ...attention()
1077
+ });
1078
+ });
1079
+ return null;
1080
+ }
1081
+ const from = replicas.get(room.locator)?.confirmed;
1082
+ if (from === undefined) return null;
1083
+ const pulled = await pullRefresh(authorized, from);
1084
+ if (pulled.kind === 'revoked-authorization') {
1085
+ await refuse('revoked-authorization');
1086
+ return 'stop';
1087
+ }
1088
+ if (pulled.kind === 'scoped-refusal') {
1089
+ await scoped(pulled, room, local);
1090
+ progress.complete = false;
1091
+ return null;
1092
+ }
1093
+ if (pulled.kind === 'still-current') {
1094
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, from, true));
1095
+ if (!committed) {
1096
+ progress.complete = false;
1097
+ return null;
1098
+ }
136
1099
  continue;
137
1100
  }
138
- if (outcome.kind === 'unconfirmed') continue;
139
- if (closed) return;
140
- await local.publish(outcome.replica.record);
141
- unreadable.delete(room.locator);
142
- replicas.set(room.locator, outcome.replica);
143
- list(room, true);
144
- emitRooms();
145
- emitVisible();
1101
+ if (pulled.kind === 'advanced') {
1102
+ const overlay = pulled.confirmed.record.roomVersion === result.roomVersion;
1103
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, pulled.confirmed, overlay));
1104
+ if (!committed) {
1105
+ progress.complete = false;
1106
+ return null;
1107
+ }
1108
+ continue;
1109
+ }
1110
+ if (pulled.kind === 'beyond-horizon') {
1111
+ const bootstrapped = await pullBootstrap(authorized, room);
1112
+ if (bootstrapped.kind === 'revoked-authorization') {
1113
+ await refuse('revoked-authorization');
1114
+ return 'stop';
1115
+ }
1116
+ if (bootstrapped.kind === 'scoped-refusal') {
1117
+ await scoped(bootstrapped, room, local);
1118
+ progress.complete = false;
1119
+ return null;
1120
+ }
1121
+ if (bootstrapped.kind === 'staged') {
1122
+ const overlay = bootstrapped.confirmed.record.roomVersion === result.roomVersion;
1123
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, bootstrapped.confirmed, overlay));
1124
+ if (!committed) {
1125
+ progress.complete = false;
1126
+ return null;
1127
+ }
1128
+ continue;
1129
+ }
1130
+ progress.complete = false;
1131
+ if (bootstrapped.kind === 'transport-uncertainty') {
1132
+ progress.reachable = false;
1133
+ } else if (bootstrapped.kind === 'room-not-readable') {
1134
+ unreadable.add(room.locator);
1135
+ list(room, false);
1136
+ emitRooms();
1137
+ }
1138
+ return null;
1139
+ }
1140
+ progress.complete = false;
1141
+ if (pulled.kind === 'transport-uncertainty') {
1142
+ progress.reachable = false;
1143
+ } else if (pulled.kind === 'room-not-readable') {
1144
+ unreadable.add(room.locator);
1145
+ list(room, false);
1146
+ emitRooms();
1147
+ }
1148
+ return null;
146
1149
  }
1150
+ return null;
147
1151
  }
148
- async function cycle(local) {
149
- if (closed || authorization === undefined) return;
150
- const result = await options.adapter.discover(authorization);
151
- if (closed) return;
152
- if (result.kind === 'transport-uncertainty') return;
1152
+ async function retainModel(local, room, attached, next) {
1153
+ const key = modelKey(room.locator, attached);
1154
+ if (next.status === 'unavailable') modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
1155
+ const epoch = modelEpoch.get(key) ?? 0;
1156
+ const previous = modelOf(room.locator, attached);
1157
+ const heldBefore = replicas.get(room.locator)?.confirmed.record;
1158
+ if (JSON.stringify(previous) === JSON.stringify(next) && (heldBefore === undefined || JSON.stringify(heldBefore.operations) === JSON.stringify(room.operations) && JSON.stringify(heldBefore.replica) === JSON.stringify(room.replica) && JSON.stringify(heldBefore.attachedModel) === JSON.stringify(room.attachedModel) && JSON.stringify(heldBefore.typedSelection) === JSON.stringify(room.typedSelection ?? null))) return;
1159
+ const queryChanged = previous.status === 'unavailable' || next.status === 'unavailable' ? previous.status !== next.status : JSON.stringify(previous.types) !== JSON.stringify(next.types);
1160
+ if (next.status === 'unavailable') {
1161
+ invalidModels.add(key);
1162
+ liveModels.delete(key);
1163
+ }
1164
+ await exclusive(async ()=>{
1165
+ if (next.status !== 'unavailable' && epoch !== (modelEpoch.get(key) ?? 0)) return;
1166
+ const held = replicas.get(room.locator);
1167
+ if (held !== undefined) {
1168
+ const record = {
1169
+ ...held.confirmed.record,
1170
+ operations: room.operations,
1171
+ replica: room.replica,
1172
+ attachedModel: room.attachedModel,
1173
+ typedSelection: room.typedSelection ?? null,
1174
+ ...attached === 'collection' ? {
1175
+ model: next
1176
+ } : {
1177
+ selectionModel: next
1178
+ }
1179
+ };
1180
+ await local.write({
1181
+ next: record,
1182
+ previous: held.confirmed.record,
1183
+ queue: held.queue
1184
+ });
1185
+ const confirmed = readConfirmed(record, held.confirmed);
1186
+ if (confirmed !== null) {
1187
+ replicas.set(room.locator, replicaOf(confirmed, held.queue, rewriting(), held));
1188
+ }
1189
+ }
1190
+ models.set(key, next);
1191
+ if (next.status === 'current') {
1192
+ liveModels.set(key, JSON.stringify(next.snapshot));
1193
+ invalidModels.delete(key);
1194
+ }
1195
+ list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
1196
+ if (held === undefined) emitRooms();
1197
+ else publishChangeSet(changeSet(room.locator, held, replicas.get(room.locator) ?? null, {
1198
+ room: true,
1199
+ query: queryChanged
1200
+ }));
1201
+ });
1202
+ }
1203
+ async function acquireModel(local, room, authorized, selection) {
1204
+ const attached = selection === null ? 'collection' : 'typed-selection';
1205
+ const key = modelKey(room.locator, attached);
1206
+ const declared = selection === null ? room.attachedModel : selection.attachedModel;
1207
+ let previous = modelOf(room.locator, attached);
1208
+ if (previous.status !== 'unavailable' && (declared === null || !sameModel(previous.snapshot, declared))) {
1209
+ previous = {
1210
+ status: 'unavailable',
1211
+ reason: 'version-changed'
1212
+ };
1213
+ await retainModel(local, room, attached, previous);
1214
+ }
1215
+ const epoch = modelEpoch.get(key) ?? 0;
1216
+ let answer;
1217
+ try {
1218
+ answer = await options.adapter.model(authorized, selection === null ? {
1219
+ locator: room.locator,
1220
+ attached: 'collection',
1221
+ collectionRoot: room.collectionRoot,
1222
+ model: declared
1223
+ } : {
1224
+ locator: room.locator,
1225
+ attached: 'typed-selection',
1226
+ roots: selection.roots,
1227
+ model: declared
1228
+ });
1229
+ } catch {
1230
+ answer = {
1231
+ kind: 'transport-uncertainty'
1232
+ };
1233
+ }
1234
+ if (epoch !== (modelEpoch.get(key) ?? 0)) return;
1235
+ if (answer.kind === 'revoked-authorization') {
1236
+ await refuse('revoked-authorization');
1237
+ return;
1238
+ }
1239
+ if (answer.kind === 'transport-uncertainty') {
1240
+ await retainModel(local, room, attached, cached(previous));
1241
+ return;
1242
+ }
1243
+ if (answer.kind === 'model-unavailable') {
1244
+ await retainModel(local, room, attached, {
1245
+ status: 'unavailable',
1246
+ reason: answer.reason
1247
+ });
1248
+ return;
1249
+ }
1250
+ if (declared === null || !sameModel(answer.snapshot, declared)) {
1251
+ await retainModel(local, room, attached, {
1252
+ status: 'unavailable',
1253
+ reason: 'version-changed'
1254
+ });
1255
+ return;
1256
+ }
1257
+ if (previous.status !== 'unavailable' && sameModel(previous.snapshot, answer.snapshot) && JSON.stringify(Object.entries(previous.snapshot.definitions).sort()) !== JSON.stringify(Object.entries(answer.snapshot.definitions).sort())) {
1258
+ await retainModel(local, room, attached, {
1259
+ status: 'unavailable',
1260
+ reason: 'invalid'
1261
+ });
1262
+ return;
1263
+ }
1264
+ const verified = options.modelBinding?.verify(answer.snapshot);
1265
+ await retainModel(local, room, attached, verified?.kind === 'verified' ? {
1266
+ status: 'current',
1267
+ snapshot: structuredClone(answer.snapshot),
1268
+ verifiedWith: MD_MODEL,
1269
+ types: verified.types
1270
+ } : {
1271
+ status: 'unavailable',
1272
+ reason: verified === undefined ? 'binding-unavailable' : 'invalid'
1273
+ });
1274
+ }
1275
+ async function scoped(result, room, local) {
1276
+ scopedRefusals.set(room.locator, {
1277
+ code: result.code,
1278
+ fields: result.fields
1279
+ });
1280
+ let scopedAway = room;
1281
+ if (result.scope === 'model') await retainModel(local, room, 'collection', {
1282
+ status: 'unavailable',
1283
+ reason: 'not-readable'
1284
+ });
1285
+ else if (result.scope !== 'operation') {
1286
+ unreadable.add(room.locator);
1287
+ scopedAway = {
1288
+ ...room,
1289
+ replica: {
1290
+ ...room.replica,
1291
+ admission: 'unavailable'
1292
+ },
1293
+ operations: {
1294
+ ...room.operations,
1295
+ ...result.scope === 'content' ? {
1296
+ read: []
1297
+ } : {},
1298
+ ...result.scope === 'sync' ? {
1299
+ sync: []
1300
+ } : {}
1301
+ }
1302
+ };
1303
+ await retainModel(local, scopedAway, 'collection', modelOf(room.locator));
1304
+ }
1305
+ list(scopedAway, scopedAway.replica.admission === 'admitted' && !unreadable.has(room.locator));
1306
+ emitRooms();
1307
+ journal.record(`scoped refusal ${room.locator} ${result.scope} ${result.code}`);
1308
+ }
1309
+ async function synchronize(discovered, local, progress) {
1310
+ for (const room of discovered){
1311
+ if (closed || authorization === undefined) {
1312
+ progress.complete = false;
1313
+ return;
1314
+ }
1315
+ const prior = replicas.get(room.locator)?.confirmed.record;
1316
+ if (prior !== undefined && prior.replica.selection !== room.replica.selection) {
1317
+ list(prior, false);
1318
+ emitRooms();
1319
+ journal.record(`selection changed ${room.locator}`);
1320
+ progress.complete = false;
1321
+ continue;
1322
+ }
1323
+ await acquireModel(local, room, authorization, null);
1324
+ if (discoveryPaused) return;
1325
+ if (room.typedSelection !== undefined) {
1326
+ await acquireModel(local, room, authorization, room.typedSelection);
1327
+ if (discoveryPaused) return;
1328
+ }
1329
+ if (room.replica.admission !== 'admitted') {
1330
+ progress.complete = false;
1331
+ continue;
1332
+ }
1333
+ const held = replicas.get(room.locator);
1334
+ if (held !== undefined && held.confirmed.record.replica.selection !== room.replica.selection) {
1335
+ progress.complete = false;
1336
+ continue;
1337
+ }
1338
+ const withheld = await advance(room, local, authorization);
1339
+ if (withheld !== null) {
1340
+ progress.complete = false;
1341
+ if (withheld.kind === 'scoped-refusal') {
1342
+ await scoped(withheld, room, local);
1343
+ continue;
1344
+ }
1345
+ if (withheld.kind === 'transport-uncertainty') {
1346
+ progress.reachable = false;
1347
+ continue;
1348
+ }
1349
+ if (withheld.kind === 'room-not-readable') {
1350
+ unreadable.add(room.locator);
1351
+ list(room, false);
1352
+ emitRooms();
1353
+ continue;
1354
+ }
1355
+ if (withheld.kind === 'revoked-authorization') {
1356
+ await refuse('revoked-authorization');
1357
+ return;
1358
+ }
1359
+ continue;
1360
+ }
1361
+ if (await submitRoom(room, local, authorization, progress) === 'stop') return;
1362
+ }
1363
+ }
1364
+ async function cycle(local, progress) {
1365
+ if (closed || authorization === undefined) {
1366
+ notifyReady();
1367
+ return;
1368
+ }
1369
+ moveTo(transferring(status));
1370
+ let result;
1371
+ try {
1372
+ result = await options.adapter.discover(authorization);
1373
+ } catch {
1374
+ result = {
1375
+ kind: 'transport-uncertainty'
1376
+ };
1377
+ }
1378
+ if (closed) {
1379
+ notifyReady();
1380
+ return;
1381
+ }
1382
+ journal.record(`connection discover ${result.kind}`);
1383
+ if (result.kind === 'transport-uncertainty') {
1384
+ for (const room of rooms.values()){
1385
+ await retainModel(local, room, 'collection', cached(modelOf(room.locator)));
1386
+ if (room.typedSelection !== null) {
1387
+ await retainModel(local, room, 'typed-selection', cached(modelOf(room.locator, 'typed-selection')));
1388
+ }
1389
+ }
1390
+ progress.reachable = false;
1391
+ notifyReady();
1392
+ return;
1393
+ }
1394
+ if (result.kind === 'contract-unavailable') {
1395
+ journal.record(`connection contract unavailable ${result.code}`);
1396
+ await refuse('missing-required-guarantee', {
1397
+ code: result.code,
1398
+ fields: result.fields
1399
+ });
1400
+ notifyReady();
1401
+ return;
1402
+ }
153
1403
  if (result.kind === 'revoked-authorization') {
154
- refuse('revoked-authorization');
1404
+ await refuse('revoked-authorization');
1405
+ notifyReady();
155
1406
  return;
156
1407
  }
157
1408
  if (!result.supportedVersions.includes(ROOM_CONTRACT_VERSION)) {
158
- refuse('no-matching-version');
1409
+ await refuse('no-matching-version');
1410
+ notifyReady();
159
1411
  return;
160
1412
  }
161
1413
  if (result.mdModel.identity !== MD_MODEL.identity || result.mdModel.version !== MD_MODEL.version) {
162
- refuse('md-model-version-mismatch');
1414
+ await refuse('md-model-version-mismatch');
1415
+ notifyReady();
1416
+ return;
1417
+ }
1418
+ mismatchedRoots.clear();
1419
+ for (const room of result.rooms){
1420
+ const held = replicas.get(room.locator)?.confirmed.record;
1421
+ const malformed = !validRoot(room.collectionRoot);
1422
+ if (room.operations === undefined || ![
1423
+ 'read',
1424
+ 'sync',
1425
+ 'write',
1426
+ 'delete'
1427
+ ].every((operation)=>Array.isArray(room.operations[operation]) && room.operations[operation].every((scope)=>typeof scope === 'string')) || room.replica === undefined || typeof room.replica.selection !== 'string' || room.replica.selection.length === 0 || ![
1428
+ 'admitted',
1429
+ 'unavailable'
1430
+ ].includes(room.replica.admission) || !declaredIdentity(room.attachedModel)) {
1431
+ journal.record(`invalid model or replica declaration ${room.locator}`);
1432
+ await refuse('missing-required-guarantee');
1433
+ notifyReady();
1434
+ return;
1435
+ }
1436
+ if (!declaredSelection(room)) {
1437
+ journal.record(`invalid typed selection declaration ${room.locator}`);
1438
+ await refuse('missing-required-guarantee');
1439
+ notifyReady();
1440
+ return;
1441
+ }
1442
+ if (malformed || held !== undefined && held.collectionRoot !== room.collectionRoot) {
1443
+ mismatchedRoots.add(room.locator);
1444
+ journal.record(`collection root ${malformed ? 'invalid' : 'changed'} ${room.locator}`);
1445
+ }
1446
+ }
1447
+ if (mismatchedRoots.size > 0) {
1448
+ await refuse('missing-required-guarantee');
1449
+ notifyReady();
163
1450
  return;
164
1451
  }
165
1452
  const declared = new Set(result.capabilities.map((capability)=>capability.name));
166
1453
  for (const required of options.requiredGuarantees){
167
1454
  if (!knownCapabilities.has(required) || !declared.has(required)) {
168
- refuse('missing-required-guarantee');
1455
+ await refuse('missing-required-guarantee');
1456
+ notifyReady();
169
1457
  return;
170
1458
  }
171
1459
  }
@@ -173,24 +1461,61 @@ export function createMdRuntime(options) {
173
1461
  refusal = null;
174
1462
  emitRefusal();
175
1463
  }
176
- applyDiscovery(result.rooms);
177
- await bootstrapMissing(result.rooms, local);
1464
+ const previousCapabilities = capabilities;
1465
+ capabilities = result.capabilities;
1466
+ notifyReady();
1467
+ if (declared.has('room-version-notifications')) observeRoomVersions();
1468
+ if (declared.has('provider-actions')) observeConversations();
1469
+ const discoveredSlots = result.slots ?? [];
1470
+ if (JSON.stringify(result.providerLimits) !== JSON.stringify(limits) || JSON.stringify(result.capabilities) !== JSON.stringify(previousCapabilities) || JSON.stringify(discoveredSlots) !== JSON.stringify(slots)) {
1471
+ limits = result.providerLimits;
1472
+ slots = discoveredSlots;
1473
+ await local.declare(limits, capabilities, slots);
1474
+ }
1475
+ await applyDiscovery(result.rooms, local);
1476
+ handSelected(result.rooms.filter((room)=>room.replica.admission === 'admitted').map((room)=>room.locator).sort());
1477
+ progress.complete = true;
1478
+ await synchronize(result.rooms, local, progress);
178
1479
  }
179
1480
  async function ensureAuthorization() {
180
1481
  if (authorization !== undefined) return;
181
- authorization = await options.adapter.authorize();
1482
+ try {
1483
+ authorization = await options.adapter.authorize();
1484
+ journal.record('connection authorize');
1485
+ } catch (error) {
1486
+ journal.record('connection authorize transport-uncertainty');
1487
+ throw error;
1488
+ }
182
1489
  }
183
1490
  async function connect() {
184
- if (closed || discoveryPaused) return;
1491
+ if (closed || discoveryPaused) {
1492
+ notifyReady();
1493
+ return;
1494
+ }
185
1495
  startTimer();
1496
+ const progress = {
1497
+ complete: false,
1498
+ reachable: true
1499
+ };
1500
+ let completedAt = new Date().toISOString();
186
1501
  try {
187
1502
  const local = await openLocal();
188
1503
  if (closed) return;
189
1504
  await ensureAuthorization();
190
1505
  if (closed) return;
191
- await cycle(local);
1506
+ await cycle(local, progress);
1507
+ if (closed) return;
1508
+ if (progress.complete) {
1509
+ completedAt = new Date().toISOString();
1510
+ await local.complete(completedAt);
1511
+ }
192
1512
  } catch {
1513
+ progress.complete = false;
1514
+ notifyReady();
193
1515
  return;
1516
+ } finally{
1517
+ notifyReady();
1518
+ moveTo(settled(status, progress, completedAt, pending(), attention()));
194
1519
  }
195
1520
  }
196
1521
  async function run() {
@@ -204,6 +1529,79 @@ export function createMdRuntime(options) {
204
1529
  }
205
1530
  }
206
1531
  }
1532
+ function observeRoomVersions() {
1533
+ const path = options.adapter.roomVersions;
1534
+ if (path === undefined || withdrawNotifications !== undefined) return;
1535
+ withdrawNotifications = path.observe((notification)=>{
1536
+ if (closed || discoveryPaused) return;
1537
+ notices.set(notification.locator, notification.roomVersion);
1538
+ chain = chain.then(runNotified, runNotified);
1539
+ });
1540
+ }
1541
+ function observeConversations() {
1542
+ const path = options.adapter.conversations;
1543
+ if (path === undefined || withdrawConversations !== undefined) return;
1544
+ withdrawConversations = path.observe((event)=>{
1545
+ conversations.receive(event);
1546
+ });
1547
+ }
1548
+ function handSelected(locators) {
1549
+ const path = options.adapter.roomVersions;
1550
+ if (path === undefined || withdrawNotifications === undefined) return;
1551
+ if (handed !== undefined && JSON.stringify(locators) === JSON.stringify(handed)) return;
1552
+ handed = locators;
1553
+ path.select(locators);
1554
+ }
1555
+ async function runNotified() {
1556
+ const announced = [
1557
+ ...notices
1558
+ ];
1559
+ notices.clear();
1560
+ if (announced.length === 0 || closed || discoveryPaused) return;
1561
+ const resting = status;
1562
+ const progress = {
1563
+ complete: false,
1564
+ reachable: !resting.providerUnreachable
1565
+ };
1566
+ moveTo(transferring(resting));
1567
+ try {
1568
+ const local = await openLocal();
1569
+ for (const [locator, roomVersion] of announced){
1570
+ if (closed || discoveryPaused || authorization === undefined) return;
1571
+ if (handed?.includes(locator) !== true) continue;
1572
+ const room = discovered.get(locator);
1573
+ const held = replicas.get(locator);
1574
+ if (room === undefined || held === undefined) continue;
1575
+ if (held.confirmed.record.roomVersion === roomVersion) continue;
1576
+ const withheld = await advance(room, local, authorization);
1577
+ if (withheld === null) continue;
1578
+ if (withheld.kind === 'scoped-refusal') {
1579
+ await scoped(withheld, room, local);
1580
+ continue;
1581
+ }
1582
+ if (withheld.kind === 'transport-uncertainty') {
1583
+ progress.reachable = false;
1584
+ continue;
1585
+ }
1586
+ if (withheld.kind === 'room-not-readable') {
1587
+ unreadable.add(locator);
1588
+ list(room, false);
1589
+ emitRooms();
1590
+ continue;
1591
+ }
1592
+ if (withheld.kind === 'revoked-authorization') {
1593
+ await refuse('revoked-authorization');
1594
+ return;
1595
+ }
1596
+ }
1597
+ } catch {
1598
+ progress.reachable = false;
1599
+ } finally{
1600
+ if (!closed) {
1601
+ moveTo(settled(resting, progress, resting.lastCompleted, pending(), attention()));
1602
+ }
1603
+ }
1604
+ }
207
1605
  function enqueue() {
208
1606
  if (inflight) {
209
1607
  queued = true;
@@ -215,28 +1613,184 @@ export function createMdRuntime(options) {
215
1613
  }
216
1614
  function close() {
217
1615
  closed = true;
1616
+ withdrawNotifications?.();
1617
+ withdrawNotifications = undefined;
1618
+ withdrawConversations?.();
1619
+ withdrawConversations = undefined;
1620
+ conversations.close();
218
1621
  stopTimer();
219
1622
  store?.close();
1623
+ void opening?.then((opened)=>{
1624
+ opened.close();
1625
+ }, ()=>{});
220
1626
  roomListeners.clear();
221
1627
  refusalListeners.clear();
1628
+ storageLossListeners.clear();
1629
+ statusListeners.clear();
222
1630
  querySubscriptions.clear();
1631
+ queueSubscriptions.clear();
1632
+ draftListeners.clear();
1633
+ presentationListeners.clear();
223
1634
  }
224
1635
  options.signal?.addEventListener('abort', close, {
225
1636
  once: true
226
1637
  });
227
1638
  if (options.signal?.aborted) close();
1639
+ void openLocal().catch(()=>{
1640
+ notifyReady();
1641
+ });
228
1642
  void enqueue();
229
1643
  return {
1644
+ loaded () {
1645
+ return openLocal().then(()=>undefined);
1646
+ },
230
1647
  rooms () {
231
1648
  return listed();
232
1649
  },
1650
+ slots () {
1651
+ return slotted();
1652
+ },
233
1653
  query (request = {}) {
234
1654
  return visible(request);
235
1655
  },
236
1656
  types (locator) {
237
- return replicas.get(locator)?.collection.types ?? [];
1657
+ return {
1658
+ collection: modelTypes(modelOf(locator)),
1659
+ typedSelection: rooms.get(locator)?.typedSelection === undefined || rooms.get(locator)?.typedSelection === null ? null : modelTypes(modelOf(locator, 'typed-selection'))
1660
+ };
1661
+ },
1662
+ syncStatus () {
1663
+ return status;
1664
+ },
1665
+ draft: {
1666
+ list () {
1667
+ return exclusive(async ()=>{
1668
+ await openLocal();
1669
+ return snapshotDrafts();
1670
+ }).then((entries)=>{
1671
+ releaseWaitingDrafts();
1672
+ return entries;
1673
+ });
1674
+ },
1675
+ read (locator, path) {
1676
+ const content = draftContent(locator, path);
1677
+ return content === undefined ? null : structuredClone(content);
1678
+ },
1679
+ replace (locator, path, content) {
1680
+ return commitDraft(locator, path, async (local)=>{
1681
+ const stored = structuredClone(content);
1682
+ await local.keep({
1683
+ locator,
1684
+ path,
1685
+ content: stored
1686
+ });
1687
+ rememberDraft(locator, path, stored);
1688
+ });
1689
+ },
1690
+ discard (locator, path) {
1691
+ return commitDraft(locator, path, async (local)=>{
1692
+ await local.discard(locator, path);
1693
+ forgetDraft(locator, path);
1694
+ });
1695
+ }
1696
+ },
1697
+ conversation: conversations.surface,
1698
+ presentation: {
1699
+ read (app) {
1700
+ return exclusive(async ()=>{
1701
+ await openLocal();
1702
+ return presentationOf(app);
1703
+ });
1704
+ },
1705
+ write (app, value) {
1706
+ return exclusive(async ()=>{
1707
+ const local = await openLocal();
1708
+ const stored = structuredClone(value);
1709
+ await local.present({
1710
+ app,
1711
+ value: stored
1712
+ });
1713
+ presentations.set(app, stored);
1714
+ publishChangeSet(changeSet('', null, null, {
1715
+ presentation: app
1716
+ }));
1717
+ });
1718
+ }
1719
+ },
1720
+ save (locator, changes, replacing, writtenBy) {
1721
+ return exclusive(()=>write(locator, changes, replacing, writtenBy));
1722
+ },
1723
+ outcome (identifier) {
1724
+ const recorded = outcomes.get(identifier);
1725
+ if (recorded !== undefined) return recorded;
1726
+ for (const replica of replicas.values()){
1727
+ if (replica.queue.some((batch)=>batch.id === identifier)) return {
1728
+ kind: 'pending'
1729
+ };
1730
+ }
1731
+ return null;
1732
+ },
1733
+ queue: {
1734
+ discard (locator, identifier) {
1735
+ return exclusive(()=>discardHead(locator, identifier));
1736
+ }
1737
+ },
1738
+ history (locator, path, query) {
1739
+ return readHistory(locator, path, query);
1740
+ },
1741
+ call (name, input) {
1742
+ return invokeAction(name, input);
1743
+ },
1744
+ log () {
1745
+ return journal.export();
1746
+ },
1747
+ storageLoss () {
1748
+ return storageLoss;
1749
+ },
1750
+ persistence () {
1751
+ return options.shell.persistentStorage;
238
1752
  },
239
1753
  observe (topic, second, third) {
1754
+ if (topic === 'drafts') {
1755
+ const notify = second;
1756
+ const listener = {
1757
+ notify,
1758
+ ready: false
1759
+ };
1760
+ draftListeners.add(listener);
1761
+ void exclusive(async ()=>{
1762
+ await openLocal();
1763
+ if (closed || !draftListeners.has(listener)) return;
1764
+ listener.ready = true;
1765
+ }).then(()=>{
1766
+ if (closed || !listener.ready || !draftListeners.has(listener)) return;
1767
+ listener.notify(snapshotDrafts());
1768
+ }, ()=>{});
1769
+ return ()=>{
1770
+ draftListeners.delete(listener);
1771
+ };
1772
+ }
1773
+ if (topic === 'conversation') {
1774
+ return conversations.observe(second, third);
1775
+ }
1776
+ if (topic === 'presentation') {
1777
+ const listener = {
1778
+ app: second,
1779
+ notify: third,
1780
+ ready: false
1781
+ };
1782
+ presentationListeners.add(listener);
1783
+ void exclusive(async ()=>{
1784
+ await openLocal();
1785
+ }).then(()=>{
1786
+ if (closed || !presentationListeners.has(listener)) return;
1787
+ listener.ready = true;
1788
+ listener.notify(presentationOf(listener.app));
1789
+ }, ()=>{});
1790
+ return ()=>{
1791
+ presentationListeners.delete(listener);
1792
+ };
1793
+ }
240
1794
  if (topic === 'rooms') {
241
1795
  const notify = second;
242
1796
  roomListeners.add(notify);
@@ -253,6 +1807,33 @@ export function createMdRuntime(options) {
253
1807
  refusalListeners.delete(notify);
254
1808
  };
255
1809
  }
1810
+ if (topic === 'storage-loss') {
1811
+ const notify = second;
1812
+ storageLossListeners.add(notify);
1813
+ notify(storageLoss);
1814
+ return ()=>{
1815
+ storageLossListeners.delete(notify);
1816
+ };
1817
+ }
1818
+ if (topic === 'sync-status') {
1819
+ const notify = second;
1820
+ statusListeners.add(notify);
1821
+ notify(status);
1822
+ return ()=>{
1823
+ statusListeners.delete(notify);
1824
+ };
1825
+ }
1826
+ if (topic === 'queue') {
1827
+ const queue = {
1828
+ locator: second,
1829
+ notify: third
1830
+ };
1831
+ queueSubscriptions.add(queue);
1832
+ queue.notify(entriesOf(queue.locator));
1833
+ return ()=>{
1834
+ queueSubscriptions.delete(queue);
1835
+ };
1836
+ }
256
1837
  const subscription = {
257
1838
  query: second,
258
1839
  notify: third
@@ -266,6 +1847,8 @@ export function createMdRuntime(options) {
266
1847
  },
267
1848
  async reauthorize () {
268
1849
  if (refusal === null || !refusal.reauthorizationOffered) return;
1850
+ journal.record('connection reauthorize');
1851
+ await mutations;
269
1852
  authorization = undefined;
270
1853
  discoveryPaused = false;
271
1854
  await enqueue();