@agentshouse/mdruntime 0.1.0-alpha.7 → 0.1.0-alpha.9

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 (86) hide show
  1. package/dist/adapter.d.ts +107 -16
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adapter.js.map +1 -1
  4. package/dist/bootstrap.d.ts +6 -1
  5. package/dist/bootstrap.d.ts.map +1 -1
  6. package/dist/bootstrap.js +14 -6
  7. package/dist/bootstrap.js.map +1 -1
  8. package/dist/change.d.ts +1 -0
  9. package/dist/change.d.ts.map +1 -1
  10. package/dist/change.js.map +1 -1
  11. package/dist/client.d.ts +63 -0
  12. package/dist/client.d.ts.map +1 -0
  13. package/dist/client.js +227 -0
  14. package/dist/client.js.map +1 -0
  15. package/dist/collection.d.ts +19 -4
  16. package/dist/collection.d.ts.map +1 -1
  17. package/dist/collection.js +88 -25
  18. package/dist/collection.js.map +1 -1
  19. package/dist/connection.d.ts +37 -9
  20. package/dist/connection.d.ts.map +1 -1
  21. package/dist/connection.js +818 -274
  22. package/dist/connection.js.map +1 -1
  23. package/dist/conversation.d.ts +57 -0
  24. package/dist/conversation.d.ts.map +1 -0
  25. package/dist/conversation.js +401 -0
  26. package/dist/conversation.js.map +1 -0
  27. package/dist/durability.d.ts +8 -3
  28. package/dist/durability.d.ts.map +1 -1
  29. package/dist/durability.js +5 -59
  30. package/dist/durability.js.map +1 -1
  31. package/dist/file.d.ts +20 -0
  32. package/dist/file.d.ts.map +1 -0
  33. package/dist/file.js +149 -0
  34. package/dist/file.js.map +1 -0
  35. package/dist/host.d.ts +9 -0
  36. package/dist/host.d.ts.map +1 -0
  37. package/dist/host.js +200 -0
  38. package/dist/host.js.map +1 -0
  39. package/dist/identity.d.ts +2 -0
  40. package/dist/identity.d.ts.map +1 -0
  41. package/dist/identity.js +16 -0
  42. package/dist/identity.js.map +1 -0
  43. package/dist/index.d.ts +10 -4
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/memory.d.ts +16 -2
  47. package/dist/memory.d.ts.map +1 -1
  48. package/dist/memory.js +196 -15
  49. package/dist/memory.js.map +1 -1
  50. package/dist/model.d.ts +70 -0
  51. package/dist/model.d.ts.map +1 -0
  52. package/dist/model.js +28 -0
  53. package/dist/model.js.map +1 -0
  54. package/dist/outcome.d.ts +16 -9
  55. package/dist/outcome.d.ts.map +1 -1
  56. package/dist/outcome.js +10 -0
  57. package/dist/outcome.js.map +1 -1
  58. package/dist/query.d.ts +13 -0
  59. package/dist/query.d.ts.map +1 -0
  60. package/dist/query.js +18 -0
  61. package/dist/query.js.map +1 -0
  62. package/dist/refresh.d.ts +2 -1
  63. package/dist/refresh.d.ts.map +1 -1
  64. package/dist/refresh.js +6 -4
  65. package/dist/refresh.js.map +1 -1
  66. package/dist/replica.d.ts +5 -2
  67. package/dist/replica.d.ts.map +1 -1
  68. package/dist/replica.js +23 -6
  69. package/dist/replica.js.map +1 -1
  70. package/dist/save.d.ts +6 -1
  71. package/dist/save.d.ts.map +1 -1
  72. package/dist/save.js +52 -11
  73. package/dist/save.js.map +1 -1
  74. package/dist/status.d.ts +11 -2
  75. package/dist/status.d.ts.map +1 -1
  76. package/dist/status.js +5 -4
  77. package/dist/status.js.map +1 -1
  78. package/dist/store.d.ts +38 -4
  79. package/dist/store.d.ts.map +1 -1
  80. package/dist/store.js +64 -7
  81. package/dist/store.js.map +1 -1
  82. package/dist/wire.d.ts +63 -0
  83. package/dist/wire.d.ts.map +1 -0
  84. package/dist/wire.js +139 -0
  85. package/dist/wire.js.map +1 -0
  86. package/package.json +10 -2
@@ -1,18 +1,36 @@
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
4
  import { bootstrapRoom } from './bootstrap.js';
4
- import { MD_MODEL, PRESENTED } from './collection.js';
5
+ import { createConversationStore } from './conversation.js';
6
+ import { MD_MODEL, PRESENTED, validRoot } from './collection.js';
5
7
  import { createLog } from './log.js';
6
- import { queuedOutcome, refused } from './outcome.js';
8
+ import { queuedOutcome, refusalOf, refused, withheldUntilReset } from './outcome.js';
7
9
  import { refreshRoom } from './refresh.js';
8
10
  import { buildBatch } from './save.js';
9
- import { applyReturned, readConfirmed, replicaOf } from './replica.js';
11
+ import { applyReturned, compositionOf, readConfirmed, replicaOf } from './replica.js';
10
12
  import { settled, same, saving, STARTING, transferring } from './status.js';
11
13
  import { detectStorageLoss, nextGeneration } from './durability.js';
12
- import { openReplicaStore } from './store.js';
14
+ import { beneath } from './paths.js';
15
+ import { openRuntimeStore } from './store.js';
13
16
  import { unconfirmed } from './transfer.js';
14
17
  const DEFAULT_DISCOVERY_INTERVAL_MS = 60_000;
15
18
  const knownCapabilities = new Set(VERSION_1_CAPABILITIES);
19
+ function within(root, path) {
20
+ return root === '' || beneath(root, path);
21
+ }
22
+ function declaredIdentity(model) {
23
+ if (model === undefined) return false;
24
+ if (model === null) return true;
25
+ return typeof model.identity === 'string' && model.identity.length > 0 && typeof model.version === 'string' && model.version.length > 0;
26
+ }
27
+ function declaredSelection(room) {
28
+ const selection = room.typedSelection;
29
+ if (selection === undefined) return true;
30
+ if (!Array.isArray(selection.roots) || selection.roots.length === 0) return false;
31
+ if (!declaredIdentity(selection.attachedModel)) return false;
32
+ return selection.roots.every((root, index)=>validRoot(root) && root !== '' && !within(room.collectionRoot, root) && selection.roots.every((other, at)=>at === index || !within(other, root)));
33
+ }
16
34
  export function createMdRuntime(options) {
17
35
  const period = options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
18
36
  let authorization = options.authorization;
@@ -20,17 +38,47 @@ export function createMdRuntime(options) {
20
38
  let store;
21
39
  const replicas = new Map();
22
40
  const drafts = new Map();
41
+ const presentations = new Map();
23
42
  const outcomes = new Map();
24
43
  let limits = [];
25
44
  let capabilities = [];
26
45
  const rooms = new Map();
27
46
  const unreadable = new Set();
47
+ const models = new Map();
48
+ const liveModels = new Map();
49
+ const invalidModels = new Set();
50
+ const modelEpoch = new Map();
51
+ const discovered = new Map();
52
+ const scopedRefusals = new Map();
53
+ function modelKey(locator, attached) {
54
+ return `${attached} ${locator}`;
55
+ }
56
+ function modelOf(locator, attached = 'collection') {
57
+ return models.get(modelKey(locator, attached)) ?? NO_MODEL;
58
+ }
59
+ function savedModel(locator, attached) {
60
+ return invalidModels.has(modelKey(locator, attached)) ? {
61
+ status: 'unavailable',
62
+ reason: 'revoked'
63
+ } : modelOf(locator, attached);
64
+ }
65
+ function compositionFor(record) {
66
+ return compositionOf({
67
+ ...record,
68
+ model: savedModel(record.locator, 'collection'),
69
+ selectionModel: savedModel(record.locator, 'typed-selection')
70
+ });
71
+ }
72
+ const mismatchedRoots = new Set();
28
73
  let refusal = null;
29
74
  let storageLoss = null;
30
75
  let status = STARTING;
31
76
  let discoveryPaused = false;
32
77
  let timer;
33
78
  let closed = false;
79
+ let withdrawNotifications;
80
+ let handed;
81
+ const notices = new Map();
34
82
  let chain = Promise.resolve();
35
83
  let mutations = Promise.resolve();
36
84
  let inflight = false;
@@ -42,15 +90,21 @@ export function createMdRuntime(options) {
42
90
  const querySubscriptions = new Set();
43
91
  const queueSubscriptions = new Set();
44
92
  const draftListeners = new Set();
93
+ const presentationListeners = new Set();
94
+ let withdrawConversations;
95
+ const conversations = createConversationStore({
96
+ cards: declaredCards,
97
+ documents: ()=>visible({}),
98
+ call: (name, input)=>invokeAction(name, input),
99
+ durably,
100
+ path: ()=>options.adapter.conversations,
101
+ closed: ()=>closed
102
+ });
45
103
  const journal = createLog();
46
104
  let notifyReady = ()=>{};
47
105
  const ready = new Promise((resolve)=>{
48
106
  notifyReady = resolve;
49
107
  });
50
- let leader = typeof window === 'undefined';
51
- let applyingRemote = false;
52
- let releaseLeadership = ()=>{};
53
- const channel = typeof window !== 'undefined' ? new BroadcastChannel('mdruntime') : undefined;
54
108
  function draftContent(locator, path) {
55
109
  return drafts.get(locator)?.get(path);
56
110
  }
@@ -84,9 +138,7 @@ export function createMdRuntime(options) {
84
138
  return entries;
85
139
  }
86
140
  async function readDrafts(opened) {
87
- await acrossTabs('mdruntime-replica', async ()=>{
88
- await hydrate(opened);
89
- });
141
+ await hydrate(opened);
90
142
  return snapshotDrafts();
91
143
  }
92
144
  function publishDrafts(waitingOnly) {
@@ -104,16 +156,23 @@ export function createMdRuntime(options) {
104
156
  function releaseWaitingDrafts() {
105
157
  publishDrafts(true);
106
158
  }
159
+ function presentationOf(app) {
160
+ const value = presentations.get(app);
161
+ return value === undefined ? null : structuredClone(value);
162
+ }
163
+ function emitPresentation(app) {
164
+ if (closed) return;
165
+ for (const listener of presentationListeners){
166
+ if (listener.app === app && listener.ready) listener.notify(presentationOf(app));
167
+ }
168
+ }
107
169
  function commitDraft(work) {
108
170
  return exclusive(async ()=>{
109
171
  const local = await openLocal();
110
- await acrossTabs('mdruntime-replica', async ()=>{
111
- await hydrate(local);
112
- await work(local);
113
- });
172
+ await hydrate(local);
173
+ await work(local);
114
174
  }).then(()=>{
115
175
  emitDrafts();
116
- announce();
117
176
  });
118
177
  }
119
178
  function pending() {
@@ -122,12 +181,33 @@ export function createMdRuntime(options) {
122
181
  ].some((replica)=>replica.queue.length > 0);
123
182
  }
124
183
  function attention() {
125
- return [
126
- ...replicas.values()
127
- ].some((replica)=>{
184
+ const attentionRequired = [];
185
+ let reset = null;
186
+ for (const [locator, replica] of replicas){
128
187
  const head = replica.queue[0];
129
- return head !== undefined && refused(outcomes.get(head.id));
130
- });
188
+ if (head === undefined) continue;
189
+ const outcome = outcomes.get(head.id);
190
+ if (outcome?.kind === 'retryable-refusal') {
191
+ if (outcome.reset !== undefined && (reset === null || outcome.reset < reset)) {
192
+ reset = outcome.reset;
193
+ }
194
+ continue;
195
+ }
196
+ if (!refused(outcome)) continue;
197
+ attentionRequired.push({
198
+ locator,
199
+ batch: head.id,
200
+ outcome: outcome.kind,
201
+ refusal: refusalOf(outcome),
202
+ ...outcome.kind === 'conflicted' && outcome.roomVersion !== undefined ? {
203
+ roomVersion: outcome.roomVersion
204
+ } : {}
205
+ });
206
+ }
207
+ return {
208
+ attentionRequired,
209
+ reset
210
+ };
131
211
  }
132
212
  function storedOutcomes(batches) {
133
213
  const stored = {};
@@ -143,12 +223,11 @@ export function createMdRuntime(options) {
143
223
  batches,
144
224
  outcomes: storedOutcomes(batches)
145
225
  });
146
- announce();
147
226
  }
148
227
  function listed() {
149
- return [
228
+ return structuredClone([
150
229
  ...rooms.values()
151
- ];
230
+ ]);
152
231
  }
153
232
  function queryRooms() {
154
233
  return [
@@ -156,7 +235,9 @@ export function createMdRuntime(options) {
156
235
  ].map((replica)=>({
157
236
  room: replica.confirmed.record.locator,
158
237
  roomVersion: replica.confirmed.record.roomVersion,
159
- documents: replica.visible.collection.documents
238
+ documents: replica.visible.collection.documents,
239
+ model: modelTypes(modelOf(replica.confirmed.record.locator)),
240
+ typedSelection: replica.confirmed.record.typedSelection === null ? null : modelTypes(modelOf(replica.confirmed.record.locator, 'typed-selection'))
160
241
  }));
161
242
  }
162
243
  function emitRooms() {
@@ -169,31 +250,17 @@ export function createMdRuntime(options) {
169
250
  function emitStorageLoss() {
170
251
  for (const listener of storageLossListeners)listener(storageLoss);
171
252
  }
172
- function announce(type = 'changed') {
173
- if (applyingRemote || channel === undefined) return;
174
- if (type === 'hello') {
175
- channel.postMessage({
176
- type: 'hello'
177
- });
178
- return;
179
- }
180
- channel.postMessage({
181
- type,
182
- status
183
- });
184
- }
185
- function adopt(next) {
253
+ function moveTo(next) {
186
254
  if (closed || same(status, next)) return;
187
255
  status = next;
188
256
  for (const listener of statusListeners)listener(status);
189
257
  }
190
- function moveTo(next) {
191
- if (closed || same(status, next)) return;
192
- adopt(next);
193
- announce();
194
- }
195
258
  function visible(request) {
196
- return evaluate(queryRooms(), {
259
+ if (options.queryBinding === null) {
260
+ if (Object.keys(request).length > 0) throw new Error('query-binding-unavailable');
261
+ return rawQuery(queryRooms());
262
+ }
263
+ return options.queryBinding(queryRooms(), {
197
264
  ...request,
198
265
  selector: {
199
266
  $and: [
@@ -233,30 +300,89 @@ export function createMdRuntime(options) {
233
300
  void enqueue();
234
301
  }, period);
235
302
  }
236
- function refuse(reason) {
303
+ function refuse(reason, provider = null) {
304
+ if (reason === 'revoked-authorization') {
305
+ const revoked = {
306
+ status: 'unavailable',
307
+ reason: 'revoked'
308
+ };
309
+ const attachments = [
310
+ 'collection',
311
+ 'typed-selection'
312
+ ];
313
+ const keys = new Set(models.keys());
314
+ for (const locator of rooms.keys()){
315
+ for (const attached of attachments)keys.add(modelKey(locator, attached));
316
+ }
317
+ for (const key of keys){
318
+ invalidModels.add(key);
319
+ modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
320
+ models.set(key, revoked);
321
+ liveModels.delete(key);
322
+ }
323
+ for (const [locator, room] of rooms)rooms.set(locator, {
324
+ ...room,
325
+ model: modelTypes(revoked),
326
+ typedSelection: room.typedSelection === null ? null : {
327
+ ...room.typedSelection,
328
+ model: modelTypes(revoked)
329
+ }
330
+ });
331
+ emitRooms();
332
+ void exclusive(async ()=>{
333
+ const local = await openLocal();
334
+ for (const record of (await local.all()))await local.publish({
335
+ ...record,
336
+ model: revoked,
337
+ selectionModel: revoked
338
+ });
339
+ }).catch(()=>{
340
+ journal.record('model invalidation durable-write failure');
341
+ });
342
+ }
237
343
  stopTimer();
238
344
  discoveryPaused = true;
239
345
  refusal = {
240
346
  reason,
241
- reauthorizationOffered: reason === 'revoked-authorization'
347
+ reauthorizationOffered: reason === 'revoked-authorization',
348
+ refusal: provider
242
349
  };
243
350
  journal.record(`connection refused ${reason}`);
244
351
  emitRefusal();
245
352
  }
246
353
  function list(room, synchronizationAvailable) {
354
+ const selection = room.typedSelection ?? null;
247
355
  rooms.set(room.locator, {
248
356
  name: room.name,
357
+ collectionRoot: room.collectionRoot,
249
358
  locator: room.locator,
250
- writeGrants: room.writeGrants,
359
+ operations: room.operations,
360
+ replica: room.replica,
361
+ attachedModel: room.attachedModel,
362
+ model: modelTypes(modelOf(room.locator)),
363
+ typedSelection: selection === null ? null : {
364
+ roots: selection.roots,
365
+ attachedModel: selection.attachedModel,
366
+ model: modelTypes(modelOf(room.locator, 'typed-selection'))
367
+ },
251
368
  providerOwnedPaths: room.providerOwnedPaths,
252
369
  roomVersion: replicas.get(room.locator)?.confirmed.record.roomVersion ?? null,
253
- synchronizationAvailable
370
+ synchronizationAvailable,
371
+ refusal: scopedRefusals.get(room.locator) ?? null
254
372
  });
255
373
  }
256
- function applyDiscovery(discovered) {
257
- const listedLocators = new Set(discovered.map((room)=>room.locator));
258
- for (const room of discovered){
259
- list(room, !unreadable.has(room.locator));
374
+ function applyDiscovery(listed) {
375
+ const listedLocators = new Set(listed.map((room)=>room.locator));
376
+ for (const locator of listedLocators)scopedRefusals.delete(locator);
377
+ discovered.clear();
378
+ for (const room of listed)discovered.set(room.locator, room);
379
+ for (const room of listed){
380
+ const held = replicas.get(room.locator)?.confirmed.record;
381
+ if (held !== undefined && held.replica.selection !== room.replica.selection) {
382
+ list(held, false);
383
+ continue;
384
+ }
385
+ list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
260
386
  }
261
387
  for (const [locator, room] of rooms){
262
388
  if (listedLocators.has(locator)) continue;
@@ -268,22 +394,41 @@ export function createMdRuntime(options) {
268
394
  emitRooms();
269
395
  return listedLocators;
270
396
  }
397
+ function restored(key, persisted) {
398
+ const prior = models.get(key);
399
+ if (prior !== undefined && prior.status !== 'unavailable' && (persisted.status === 'unavailable' || !sameModel(prior.snapshot, persisted.snapshot))) {
400
+ modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
401
+ invalidModels.add(key);
402
+ }
403
+ const model = persisted.status === 'unavailable' || liveModels.get(key) === JSON.stringify(persisted.snapshot) ? persisted : cached(persisted);
404
+ models.set(key, model);
405
+ if (model.status === 'unavailable') {
406
+ liveModels.delete(key);
407
+ invalidModels.delete(key);
408
+ }
409
+ return model;
410
+ }
271
411
  async function hydrate(opened) {
272
- const [records, queues, kept, lastCompleted, declared] = await Promise.all([
412
+ const [records, queues, kept, presented, cached, unsent, lastCompleted, declared] = await Promise.all([
273
413
  opened.all(),
274
414
  opened.queues(),
275
415
  opened.drafts(),
416
+ opened.presentation(),
417
+ opened.conversations(),
418
+ opened.instructions(),
276
419
  opened.lastCompleted(),
277
420
  opened.providerLimits()
278
421
  ]);
279
422
  limits = declared;
280
423
  drafts.clear();
281
424
  for (const draft of kept)rememberDraft(draft.locator, draft.path, draft.content);
425
+ presentations.clear();
426
+ for (const record of presented)presentations.set(record.app, record.value);
427
+ conversations.hydrate(cached, unsent);
282
428
  const queuedBatches = new Map(queues.map((queue)=>[
283
429
  queue.locator,
284
430
  queue.batches
285
431
  ]));
286
- outcomes.clear();
287
432
  for (const queue of queues){
288
433
  for (const [id, outcome] of Object.entries(queue.outcomes)){
289
434
  outcomes.set(id, outcome);
@@ -291,7 +436,13 @@ export function createMdRuntime(options) {
291
436
  }
292
437
  replicas.clear();
293
438
  for (const record of records){
294
- const confirmed = readConfirmed(record);
439
+ const model = restored(modelKey(record.locator, 'collection'), record.model);
440
+ const selectionModel = restored(modelKey(record.locator, 'typed-selection'), record.selectionModel);
441
+ const confirmed = readConfirmed({
442
+ ...record,
443
+ model,
444
+ selectionModel
445
+ });
295
446
  if (confirmed === null) continue;
296
447
  replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? []));
297
448
  const existing = rooms.get(record.locator);
@@ -299,7 +450,16 @@ export function createMdRuntime(options) {
299
450
  else {
300
451
  rooms.set(record.locator, {
301
452
  ...existing,
302
- roomVersion: record.roomVersion
453
+ roomVersion: record.roomVersion,
454
+ operations: record.operations,
455
+ replica: record.replica,
456
+ attachedModel: record.attachedModel,
457
+ model: modelTypes(model),
458
+ typedSelection: record.typedSelection === null ? null : {
459
+ roots: record.typedSelection.roots,
460
+ attachedModel: record.typedSelection.attachedModel,
461
+ model: modelTypes(selectionModel)
462
+ }
303
463
  });
304
464
  }
305
465
  }
@@ -321,52 +481,34 @@ export function createMdRuntime(options) {
321
481
  emitDrafts();
322
482
  return lastCompleted;
323
483
  }
324
- async function applyRemote(message) {
325
- if (closed) return;
326
- if (message.type === 'hello') {
327
- if (leader) announce();
328
- return;
329
- }
330
- applyingRemote = true;
331
- try {
332
- const local = await openLocal();
333
- const lastCompleted = await readState(local);
334
- if (message.status !== undefined) adopt(message.status);
335
- else {
336
- adopt({
337
- ...status,
338
- lastCompleted,
339
- attentionRequired: attention(),
340
- state: pending() ? 'pending' : status.state
341
- });
342
- }
343
- } finally{
344
- applyingRemote = false;
345
- }
346
- if (message.type === 'enqueue' && leader) void enqueue();
347
- }
348
484
  async function open() {
349
- const opened = await openReplicaStore();
485
+ const opened = await openRuntimeStore();
350
486
  store = opened;
351
- await acrossTabs('mdruntime-generation', async ()=>{
352
- const storedGeneration = await opened.generation();
353
- const loss = detectStorageLoss(storedGeneration);
354
- const generation = nextGeneration(storedGeneration);
355
- if (generation !== storedGeneration) await opened.rememberGeneration(generation);
356
- if (loss !== null) {
357
- storageLoss = loss;
358
- emitStorageLoss();
359
- }
360
- });
487
+ const kept = options.shell.generation;
488
+ const storedGeneration = await opened.generation();
489
+ const loss = detectStorageLoss(storedGeneration, kept);
490
+ const generation = nextGeneration(storedGeneration, kept);
491
+ if (generation !== storedGeneration) await opened.rememberGeneration(generation);
492
+ if (generation !== kept) await options.shell.keepGeneration(generation);
493
+ if (loss !== null) {
494
+ storageLoss = loss;
495
+ emitStorageLoss();
496
+ }
361
497
  const lastCompleted = await readState(opened);
362
- adopt({
498
+ moveTo({
363
499
  ...status,
364
500
  lastCompleted,
365
- attentionRequired: attention()
501
+ ...attention()
366
502
  });
367
- if (!leader) announce('hello');
368
503
  return opened;
369
504
  }
505
+ function durably(work) {
506
+ return exclusive(async ()=>{
507
+ const local = await openLocal();
508
+ await hydrate(local);
509
+ return work(local);
510
+ });
511
+ }
370
512
  function openLocal() {
371
513
  opening ??= open().catch((error)=>{
372
514
  opening = undefined;
@@ -379,117 +521,117 @@ export function createMdRuntime(options) {
379
521
  mutations = next.then(()=>undefined, ()=>undefined);
380
522
  return next;
381
523
  }
382
- function acrossTabs(name, work) {
383
- if (typeof window === 'undefined') return work();
384
- return window.navigator.locks.request(name, work);
385
- }
386
524
  function publish(local, room, confirmed) {
387
525
  return exclusive(async ()=>{
388
- await acrossTabs('mdruntime-replica', async ()=>{
389
- await hydrate(local);
390
- await local.publish(confirmed.record);
391
- unreadable.delete(room.locator);
392
- replicas.set(room.locator, replicaOf(confirmed, replicas.get(room.locator)?.queue ?? []));
526
+ await hydrate(local);
527
+ const retained = readConfirmed({
528
+ ...confirmed.record,
529
+ model: modelOf(room.locator),
530
+ selectionModel: modelOf(room.locator, 'typed-selection')
393
531
  });
532
+ if (retained !== null) {
533
+ await local.publish(retained.record);
534
+ unreadable.delete(room.locator);
535
+ replicas.set(room.locator, replicaOf(retained, replicas.get(room.locator)?.queue ?? []));
536
+ }
394
537
  list(room, true);
395
538
  emitRooms();
396
539
  emitVisible();
397
- announce();
398
540
  });
399
541
  }
400
542
  async function write(locator, changes, replacing) {
401
543
  const local = await openLocal();
402
- const result = await acrossTabs('mdruntime-replica', async ()=>{
403
- await hydrate(local);
404
- const replica = replicas.get(locator);
405
- const room = rooms.get(locator);
406
- if (replica === undefined || room === undefined) {
407
- throw new Error(`No Local Replica for ${locator}`);
408
- }
409
- const built = buildBatch({
410
- baseRoomVersion: replica.confirmed.record.roomVersion,
411
- writeGrants: room.writeGrants,
412
- providerOwnedPaths: room.providerOwnedPaths,
413
- providerLimits: limits,
414
- visible: replica.visible,
415
- changes
416
- });
417
- if (built.kind === 'refused') return {
418
- outcome: 'refused',
419
- ...built.refusal
420
- };
421
- const head = replica.queue[0];
422
- if (replacing !== undefined) {
423
- if (head === undefined || head.id !== replacing || !refused(outcomes.get(head.id))) {
424
- return {
425
- outcome: 'refused',
426
- rule: 'no-refused-head'
427
- };
428
- }
429
- }
430
- const batches = replacing === undefined ? [
431
- ...replica.queue,
432
- built.batch
433
- ] : [
434
- built.batch,
435
- ...replica.queue.slice(1)
436
- ];
437
- const consumed = [
438
- ...new Set(changes.map((change)=>change.path))
439
- ];
440
- try {
441
- await local.append({
442
- locator,
443
- batches,
444
- outcomes: storedOutcomes(batches)
445
- }, consumed);
446
- } catch {
544
+ await hydrate(local);
545
+ const replica = replicas.get(locator);
546
+ const room = rooms.get(locator);
547
+ if (replica === undefined || room === undefined) {
548
+ throw new Error(`No Local Replica for ${locator}`);
549
+ }
550
+ if (mismatchedRoots.has(locator)) return {
551
+ outcome: 'refused',
552
+ rule: 'collection-root-mismatch'
553
+ };
554
+ const built = buildBatch({
555
+ baseRoomVersion: replica.confirmed.record.roomVersion,
556
+ composition: compositionFor(replica.confirmed.record),
557
+ operations: room.operations,
558
+ binding: options.modelBinding,
559
+ providerOwnedPaths: room.providerOwnedPaths,
560
+ providerLimits: limits,
561
+ visible: replica.visible,
562
+ changes
563
+ });
564
+ if (built.kind === 'refused') return {
565
+ outcome: 'refused',
566
+ ...built.refusal
567
+ };
568
+ const head = replica.queue[0];
569
+ if (replacing !== undefined) {
570
+ if (head === undefined || head.id !== replacing || !refused(outcomes.get(head.id))) {
447
571
  return {
448
572
  outcome: 'refused',
449
- rule: 'durable-write'
573
+ rule: 'no-refused-head'
450
574
  };
451
575
  }
452
- if (replacing !== undefined) outcomes.delete(replacing);
453
- for (const path of consumed)forgetDraft(locator, path);
454
- replicas.set(locator, replicaOf(replica.confirmed, batches));
455
- emitQueue(locator);
456
- emitVisible();
457
- emitDrafts();
458
- moveTo({
459
- ...saving(status),
460
- attentionRequired: attention()
461
- });
576
+ }
577
+ const batches = replacing === undefined ? [
578
+ ...replica.queue,
579
+ built.batch
580
+ ] : [
581
+ built.batch,
582
+ ...replica.queue.slice(1)
583
+ ];
584
+ const consumed = [
585
+ ...new Set(changes.map((change)=>change.path))
586
+ ];
587
+ try {
588
+ await local.append({
589
+ locator,
590
+ batches,
591
+ outcomes: storedOutcomes(batches)
592
+ }, consumed);
593
+ } catch {
462
594
  return {
463
- outcome: 'saved',
464
- id: built.batch.id
595
+ outcome: 'refused',
596
+ rule: 'durable-write'
465
597
  };
598
+ }
599
+ if (replacing !== undefined) outcomes.delete(replacing);
600
+ for (const path of consumed)forgetDraft(locator, path);
601
+ replicas.set(locator, replicaOf(replica.confirmed, batches));
602
+ emitQueue(locator);
603
+ emitVisible();
604
+ emitDrafts();
605
+ moveTo({
606
+ ...saving(status),
607
+ ...attention()
466
608
  });
467
- if (result.outcome === 'saved') void enqueue();
468
- return result;
609
+ void enqueue();
610
+ return {
611
+ outcome: 'saved',
612
+ id: built.batch.id
613
+ };
469
614
  }
470
615
  async function discardHead(locator, identifier) {
471
616
  const local = await openLocal();
472
- const changed = await acrossTabs('mdruntime-replica', async ()=>{
473
- await hydrate(local);
474
- const replica = replicas.get(locator);
475
- const head = replica?.queue[0];
476
- if (replica === undefined || head === undefined || head.id !== identifier || !refused(outcomes.get(head.id))) {
477
- return false;
478
- }
479
- const batches = replica.queue.slice(1);
480
- outcomes.delete(identifier);
481
- await persistQueue(local, locator, batches);
482
- replicas.set(locator, replicaOf(replica.confirmed, batches));
483
- emitQueue(locator);
484
- emitVisible();
485
- moveTo({
486
- ...status,
487
- state: pending() ? 'pending' : status.state,
488
- attentionRequired: attention()
489
- });
490
- return true;
617
+ await hydrate(local);
618
+ const replica = replicas.get(locator);
619
+ const head = replica?.queue[0];
620
+ if (replica === undefined || head === undefined || head.id !== identifier || !refused(outcomes.get(head.id))) {
621
+ return;
622
+ }
623
+ const batches = replica.queue.slice(1);
624
+ outcomes.delete(identifier);
625
+ await persistQueue(local, locator, batches);
626
+ replicas.set(locator, replicaOf(replica.confirmed, batches));
627
+ emitQueue(locator);
628
+ emitVisible();
629
+ moveTo({
630
+ ...status,
631
+ state: pending() ? 'pending' : status.state,
632
+ ...attention()
491
633
  });
492
- if (changed) void enqueue();
634
+ void enqueue();
493
635
  }
494
636
  async function readHistory(locator, path, query = {}) {
495
637
  if (closed || refusal !== null) return {
@@ -528,6 +670,10 @@ export function createMdRuntime(options) {
528
670
  }
529
671
  });
530
672
  journal.record(`outcome history ${result.kind}`);
673
+ if (result.kind === 'scoped-refusal') {
674
+ const room = replicas.get(result.locator)?.confirmed.record;
675
+ if (room !== undefined) await scoped(result, room, await openLocal());
676
+ }
531
677
  if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
532
678
  return result;
533
679
  } catch {
@@ -537,6 +683,12 @@ export function createMdRuntime(options) {
537
683
  };
538
684
  }
539
685
  }
686
+ function declaredCards() {
687
+ for (const capability of capabilities){
688
+ if (capability.conversationCards !== undefined) return capability.conversationCards;
689
+ }
690
+ return undefined;
691
+ }
540
692
  function declaredAction(name) {
541
693
  for (const capability of capabilities){
542
694
  for (const action of capability.actions ?? []){
@@ -571,9 +723,13 @@ export function createMdRuntime(options) {
571
723
  name,
572
724
  input
573
725
  });
726
+ if (result.kind === 'scoped-refusal') {
727
+ const room = replicas.get(result.locator)?.confirmed.record;
728
+ if (room !== undefined) await scoped(result, room, await openLocal());
729
+ }
574
730
  if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
575
731
  journal.record(`outcome action ${result.kind}`);
576
- if (result.kind === 'rejected') journal.record(`refusal rejected ${result.text}`);
732
+ if (result.kind === 'rejected') journal.record(`refusal rejected ${result.code}`);
577
733
  return result;
578
734
  } catch {
579
735
  journal.record('outcome action transport-uncertainty');
@@ -584,7 +740,7 @@ export function createMdRuntime(options) {
584
740
  }
585
741
  async function pullRefresh(authorized, confirmed) {
586
742
  try {
587
- const result = await refreshRoom(options.adapter, authorized, confirmed);
743
+ const result = await refreshRoom(options.adapter, authorized, confirmed, options.modelBinding);
588
744
  const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
589
745
  journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
590
746
  return result;
@@ -595,7 +751,10 @@ export function createMdRuntime(options) {
595
751
  }
596
752
  async function pullBootstrap(authorized, room) {
597
753
  try {
598
- const result = await bootstrapRoom(options.adapter, authorized, room);
754
+ const result = await bootstrapRoom(options.adapter, authorized, room, {
755
+ collection: modelOf(room.locator),
756
+ selection: modelOf(room.locator, 'typed-selection')
757
+ }, options.modelBinding);
599
758
  const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
600
759
  journal.record(`transfer bootstrap ${room.locator} ${detail}`);
601
760
  return result;
@@ -632,54 +791,94 @@ export function createMdRuntime(options) {
632
791
  if (result.kind === 'expired') return {
633
792
  kind: 'expired'
634
793
  };
794
+ if (result.kind === 'retryable-refusal') {
795
+ return {
796
+ kind: 'retryable-refusal',
797
+ code: result.code,
798
+ fields: result.fields,
799
+ ...result.reset === undefined ? {} : {
800
+ reset: result.reset
801
+ }
802
+ };
803
+ }
635
804
  if (result.kind === 'conflicted') {
636
805
  return {
637
806
  kind: 'conflicted',
638
- roomVersion: result.roomVersion,
807
+ ...result.roomVersion === undefined ? {} : {
808
+ roomVersion: result.roomVersion
809
+ },
639
810
  affected: result.affected,
640
- text: result.text
811
+ code: result.code,
812
+ fields: result.fields
641
813
  };
642
814
  }
643
815
  return {
644
816
  kind: 'rejected',
645
817
  affected: result.affected,
646
- text: result.text
818
+ code: result.code,
819
+ fields: result.fields
647
820
  };
648
821
  }
649
822
  async function commitAccept(room, local, submitted, accepted, base, overlay) {
650
- return acrossTabs('mdruntime-replica', async ()=>{
651
- await hydrate(local);
652
- const current = replicas.get(room.locator);
653
- if (current === undefined || !current.queue.some((batch)=>batch.id === submitted.id)) {
654
- return true;
655
- }
656
- const remaining = current.queue.filter((batch)=>batch.id !== submitted.id);
657
- const applied = overlay ? applyReturned(base, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion) : base;
658
- if (applied === null) return false;
659
- await local.publish(applied.record);
660
- await persistQueue(local, room.locator, remaining);
661
- outcomes.set(submitted.id, {
662
- kind: 'accepted',
663
- roomVersion: accepted.roomVersion,
664
- author: accepted.author,
665
- time: accepted.time
666
- });
667
- unreadable.delete(room.locator);
668
- replicas.set(room.locator, replicaOf(applied, remaining));
669
- list(room, true);
670
- emitRooms();
671
- emitQueue(room.locator);
672
- emitVisible();
673
- journal.record(`outcome ${room.locator} ${submitted.id} accepted`);
823
+ await hydrate(local);
824
+ const current = replicas.get(room.locator);
825
+ if (current === undefined || !current.queue.some((batch)=>batch.id === submitted.id)) {
674
826
  return true;
827
+ }
828
+ const remaining = current.queue.filter((batch)=>batch.id !== submitted.id);
829
+ const retainedBase = readConfirmed({
830
+ ...base.record,
831
+ model: modelOf(room.locator),
832
+ selectionModel: modelOf(room.locator, 'typed-selection')
833
+ });
834
+ if (retainedBase === null) return false;
835
+ const applied = overlay ? applyReturned(retainedBase, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion, options.modelBinding) : retainedBase;
836
+ if (applied === null) return false;
837
+ await local.publish(applied.record);
838
+ await persistQueue(local, room.locator, remaining);
839
+ outcomes.set(submitted.id, {
840
+ kind: 'accepted',
841
+ roomVersion: accepted.roomVersion,
842
+ ...accepted.author === undefined ? {} : {
843
+ author: accepted.author
844
+ },
845
+ ...accepted.time === undefined ? {} : {
846
+ time: accepted.time
847
+ }
675
848
  });
849
+ unreadable.delete(room.locator);
850
+ replicas.set(room.locator, replicaOf(applied, remaining));
851
+ list(room, true);
852
+ emitRooms();
853
+ emitQueue(room.locator);
854
+ emitVisible();
855
+ journal.record(`outcome ${room.locator} ${submitted.id} accepted ${accepted.roomVersion}` + (accepted.author === undefined ? '' : ` author ${accepted.author}`) + (accepted.time === undefined ? '' : ` time ${accepted.time}`));
856
+ return true;
676
857
  }
677
858
  async function submitRoom(room, local, authorized, progress) {
678
859
  while(!closed && authorization !== undefined){
679
860
  const replica = replicas.get(room.locator);
680
861
  const head = replica?.queue[0];
681
- if (replica === undefined || head === undefined || refused(outcomes.get(head.id))) {
682
- return null;
862
+ if (replica === undefined || head === undefined) return null;
863
+ const held = outcomes.get(head.id);
864
+ if (refused(held)) return null;
865
+ if (held?.kind === 'retryable-refusal') {
866
+ if (withheldUntilReset(held, Date.now())) {
867
+ progress.complete = false;
868
+ return null;
869
+ }
870
+ await exclusive(async ()=>{
871
+ await hydrate(local);
872
+ const current = replicas.get(room.locator);
873
+ if (current === undefined || current.queue[0]?.id !== head.id) return;
874
+ outcomes.delete(head.id);
875
+ await persistQueue(local, room.locator, current.queue);
876
+ emitQueue(room.locator);
877
+ moveTo({
878
+ ...status,
879
+ ...attention()
880
+ });
881
+ });
683
882
  }
684
883
  let result;
685
884
  journal.record(`submission ${room.locator} ${head.id}`);
@@ -710,23 +909,27 @@ export function createMdRuntime(options) {
710
909
  refuse('revoked-authorization');
711
910
  return 'stop';
712
911
  }
713
- if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
714
- journal.record(`outcome ${room.locator} ${head.id} ${result.kind}`);
715
- if (result.kind !== 'expired') journal.record(`refusal ${result.kind} ${result.text}`);
912
+ if (result.kind === 'scoped-refusal') {
913
+ await scoped(result, room, local);
914
+ progress.complete = false;
915
+ return null;
916
+ }
917
+ if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired' || result.kind === 'retryable-refusal') {
918
+ journal.record(`outcome ${room.locator} ${head.id} ${result.kind}` + (result.kind === 'conflicted' && result.roomVersion !== undefined ? ` ${result.roomVersion}` : ''));
919
+ if (result.kind !== 'expired') journal.record(`refusal ${result.kind} ${result.code}`);
920
+ if (result.kind === 'retryable-refusal') progress.complete = false;
716
921
  await exclusive(async ()=>{
717
- await acrossTabs('mdruntime-replica', async ()=>{
718
- await hydrate(local);
719
- const current = replicas.get(room.locator);
720
- if (current === undefined || !current.queue.some((batch)=>batch.id === head.id)) {
721
- return;
722
- }
723
- outcomes.set(head.id, recordRefusal(result));
724
- await persistQueue(local, room.locator, current.queue);
725
- emitQueue(room.locator);
726
- moveTo({
727
- ...status,
728
- attentionRequired: true
729
- });
922
+ await hydrate(local);
923
+ const current = replicas.get(room.locator);
924
+ if (current === undefined || !current.queue.some((batch)=>batch.id === head.id)) {
925
+ return;
926
+ }
927
+ outcomes.set(head.id, recordRefusal(result));
928
+ await persistQueue(local, room.locator, current.queue);
929
+ emitQueue(room.locator);
930
+ moveTo({
931
+ ...status,
932
+ ...attention()
730
933
  });
731
934
  });
732
935
  return null;
@@ -738,6 +941,11 @@ export function createMdRuntime(options) {
738
941
  refuse('revoked-authorization');
739
942
  return 'stop';
740
943
  }
944
+ if (pulled.kind === 'scoped-refusal') {
945
+ await scoped(pulled, room, local);
946
+ progress.complete = false;
947
+ return null;
948
+ }
741
949
  if (pulled.kind === 'still-current') {
742
950
  const committed = await exclusive(()=>commitAccept(room, local, head, result, from, true));
743
951
  if (!committed) {
@@ -761,6 +969,11 @@ export function createMdRuntime(options) {
761
969
  refuse('revoked-authorization');
762
970
  return 'stop';
763
971
  }
972
+ if (bootstrapped.kind === 'scoped-refusal') {
973
+ await scoped(bootstrapped, room, local);
974
+ progress.complete = false;
975
+ return null;
976
+ }
764
977
  if (bootstrapped.kind === 'staged') {
765
978
  const overlay = bootstrapped.confirmed.record.roomVersion === result.roomVersion;
766
979
  const committed = await exclusive(()=>commitAccept(room, local, head, result, bootstrapped.confirmed, overlay));
@@ -792,15 +1005,195 @@ export function createMdRuntime(options) {
792
1005
  }
793
1006
  return null;
794
1007
  }
1008
+ async function retainModel(local, room, attached, next) {
1009
+ const key = modelKey(room.locator, attached);
1010
+ if (next.status === 'unavailable') modelEpoch.set(key, (modelEpoch.get(key) ?? 0) + 1);
1011
+ const epoch = modelEpoch.get(key) ?? 0;
1012
+ const previous = modelOf(room.locator, attached);
1013
+ const heldBefore = replicas.get(room.locator)?.confirmed.record;
1014
+ 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;
1015
+ const queryChanged = previous.status === 'unavailable' || next.status === 'unavailable' ? previous.status !== next.status : JSON.stringify(previous.types) !== JSON.stringify(next.types);
1016
+ if (next.status === 'unavailable') {
1017
+ invalidModels.add(key);
1018
+ liveModels.delete(key);
1019
+ }
1020
+ await exclusive(async ()=>{
1021
+ await hydrate(local);
1022
+ if (next.status !== 'unavailable' && epoch !== (modelEpoch.get(key) ?? 0)) return;
1023
+ const held = replicas.get(room.locator);
1024
+ if (held !== undefined) {
1025
+ const record = {
1026
+ ...held.confirmed.record,
1027
+ operations: room.operations,
1028
+ replica: room.replica,
1029
+ attachedModel: room.attachedModel,
1030
+ typedSelection: room.typedSelection ?? null,
1031
+ ...attached === 'collection' ? {
1032
+ model: next
1033
+ } : {
1034
+ selectionModel: next
1035
+ }
1036
+ };
1037
+ await local.publish(record);
1038
+ const confirmed = readConfirmed(record);
1039
+ if (confirmed !== null) replicas.set(room.locator, replicaOf(confirmed, held.queue));
1040
+ }
1041
+ models.set(key, next);
1042
+ if (next.status === 'current') {
1043
+ liveModels.set(key, JSON.stringify(next.snapshot));
1044
+ invalidModels.delete(key);
1045
+ }
1046
+ list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
1047
+ emitRooms();
1048
+ if (queryChanged && held !== undefined) emitVisible();
1049
+ });
1050
+ }
1051
+ async function acquireModel(local, room, authorized, selection) {
1052
+ const attached = selection === null ? 'collection' : 'typed-selection';
1053
+ const key = modelKey(room.locator, attached);
1054
+ const declared = selection === null ? room.attachedModel : selection.attachedModel;
1055
+ let previous = modelOf(room.locator, attached);
1056
+ if (previous.status !== 'unavailable' && (declared === null || !sameModel(previous.snapshot, declared))) {
1057
+ previous = {
1058
+ status: 'unavailable',
1059
+ reason: 'version-changed'
1060
+ };
1061
+ await retainModel(local, room, attached, previous);
1062
+ }
1063
+ const epoch = modelEpoch.get(key) ?? 0;
1064
+ let answer;
1065
+ try {
1066
+ answer = await options.adapter.model(authorized, selection === null ? {
1067
+ locator: room.locator,
1068
+ attached: 'collection',
1069
+ collectionRoot: room.collectionRoot,
1070
+ model: declared
1071
+ } : {
1072
+ locator: room.locator,
1073
+ attached: 'typed-selection',
1074
+ roots: selection.roots,
1075
+ model: declared
1076
+ });
1077
+ } catch {
1078
+ answer = {
1079
+ kind: 'transport-uncertainty'
1080
+ };
1081
+ }
1082
+ if (epoch !== (modelEpoch.get(key) ?? 0)) return;
1083
+ if (answer.kind === 'revoked-authorization') {
1084
+ await retainModel(local, room, attached, {
1085
+ status: 'unavailable',
1086
+ reason: 'revoked'
1087
+ });
1088
+ refuse('revoked-authorization');
1089
+ return;
1090
+ }
1091
+ if (answer.kind === 'transport-uncertainty') {
1092
+ await retainModel(local, room, attached, cached(previous));
1093
+ return;
1094
+ }
1095
+ if (answer.kind === 'model-unavailable') {
1096
+ await retainModel(local, room, attached, {
1097
+ status: 'unavailable',
1098
+ reason: answer.reason
1099
+ });
1100
+ return;
1101
+ }
1102
+ if (declared === null || !sameModel(answer.snapshot, declared)) {
1103
+ await retainModel(local, room, attached, {
1104
+ status: 'unavailable',
1105
+ reason: 'version-changed'
1106
+ });
1107
+ return;
1108
+ }
1109
+ 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())) {
1110
+ await retainModel(local, room, attached, {
1111
+ status: 'unavailable',
1112
+ reason: 'invalid'
1113
+ });
1114
+ return;
1115
+ }
1116
+ const verified = options.modelBinding?.verify(answer.snapshot);
1117
+ await retainModel(local, room, attached, verified?.kind === 'verified' ? {
1118
+ status: 'current',
1119
+ snapshot: structuredClone(answer.snapshot),
1120
+ verifiedWith: MD_MODEL,
1121
+ types: verified.types
1122
+ } : {
1123
+ status: 'unavailable',
1124
+ reason: verified === undefined ? 'binding-unavailable' : 'invalid'
1125
+ });
1126
+ }
1127
+ async function scoped(result, room, local) {
1128
+ scopedRefusals.set(room.locator, {
1129
+ code: result.code,
1130
+ fields: result.fields
1131
+ });
1132
+ let scopedAway = room;
1133
+ if (result.scope === 'model') await retainModel(local, room, 'collection', {
1134
+ status: 'unavailable',
1135
+ reason: 'not-readable'
1136
+ });
1137
+ else if (result.scope !== 'operation') {
1138
+ unreadable.add(room.locator);
1139
+ scopedAway = {
1140
+ ...room,
1141
+ replica: {
1142
+ ...room.replica,
1143
+ admission: 'unavailable'
1144
+ },
1145
+ operations: {
1146
+ ...room.operations,
1147
+ ...result.scope === 'content' ? {
1148
+ read: []
1149
+ } : {},
1150
+ ...result.scope === 'sync' ? {
1151
+ sync: []
1152
+ } : {}
1153
+ }
1154
+ };
1155
+ await retainModel(local, scopedAway, 'collection', modelOf(room.locator));
1156
+ }
1157
+ list(scopedAway, scopedAway.replica.admission === 'admitted' && !unreadable.has(room.locator));
1158
+ emitRooms();
1159
+ journal.record(`scoped refusal ${room.locator} ${result.scope} ${result.code}`);
1160
+ }
795
1161
  async function synchronize(discovered, local, progress) {
796
1162
  for (const room of discovered){
797
1163
  if (closed || authorization === undefined) {
798
1164
  progress.complete = false;
799
1165
  return;
800
1166
  }
1167
+ const prior = replicas.get(room.locator)?.confirmed.record;
1168
+ if (prior !== undefined && prior.replica.selection !== room.replica.selection) {
1169
+ list(prior, false);
1170
+ emitRooms();
1171
+ journal.record(`selection changed ${room.locator}`);
1172
+ progress.complete = false;
1173
+ continue;
1174
+ }
1175
+ await acquireModel(local, room, authorization, null);
1176
+ if (discoveryPaused) return;
1177
+ if (room.typedSelection !== undefined) {
1178
+ await acquireModel(local, room, authorization, room.typedSelection);
1179
+ if (discoveryPaused) return;
1180
+ }
1181
+ if (room.replica.admission !== 'admitted') {
1182
+ progress.complete = false;
1183
+ continue;
1184
+ }
1185
+ const held = replicas.get(room.locator);
1186
+ if (held !== undefined && held.confirmed.record.replica.selection !== room.replica.selection) {
1187
+ progress.complete = false;
1188
+ continue;
1189
+ }
801
1190
  const withheld = await advance(room, local, authorization);
802
1191
  if (withheld !== null) {
803
1192
  progress.complete = false;
1193
+ if (withheld.kind === 'scoped-refusal') {
1194
+ await scoped(withheld, room, local);
1195
+ continue;
1196
+ }
804
1197
  if (withheld.kind === 'transport-uncertainty') {
805
1198
  progress.reachable = false;
806
1199
  continue;
@@ -829,10 +1222,10 @@ export function createMdRuntime(options) {
829
1222
  let result;
830
1223
  try {
831
1224
  result = await options.adapter.discover(authorization);
832
- } catch (error) {
833
- journal.record('connection discover transport-uncertainty');
834
- notifyReady();
835
- throw error;
1225
+ } catch {
1226
+ result = {
1227
+ kind: 'transport-uncertainty'
1228
+ };
836
1229
  }
837
1230
  if (closed) {
838
1231
  notifyReady();
@@ -840,10 +1233,25 @@ export function createMdRuntime(options) {
840
1233
  }
841
1234
  journal.record(`connection discover ${result.kind}`);
842
1235
  if (result.kind === 'transport-uncertainty') {
1236
+ for (const room of rooms.values()){
1237
+ await retainModel(local, room, 'collection', cached(modelOf(room.locator)));
1238
+ if (room.typedSelection !== null) {
1239
+ await retainModel(local, room, 'typed-selection', cached(modelOf(room.locator, 'typed-selection')));
1240
+ }
1241
+ }
843
1242
  progress.reachable = false;
844
1243
  notifyReady();
845
1244
  return;
846
1245
  }
1246
+ if (result.kind === 'contract-unavailable') {
1247
+ journal.record(`connection contract unavailable ${result.code}`);
1248
+ refuse('missing-required-guarantee', {
1249
+ code: result.code,
1250
+ fields: result.fields
1251
+ });
1252
+ notifyReady();
1253
+ return;
1254
+ }
847
1255
  if (result.kind === 'revoked-authorization') {
848
1256
  refuse('revoked-authorization');
849
1257
  notifyReady();
@@ -859,6 +1267,40 @@ export function createMdRuntime(options) {
859
1267
  notifyReady();
860
1268
  return;
861
1269
  }
1270
+ mismatchedRoots.clear();
1271
+ for (const room of result.rooms){
1272
+ const held = replicas.get(room.locator)?.confirmed.record;
1273
+ const malformed = !validRoot(room.collectionRoot);
1274
+ if (room.operations === undefined || ![
1275
+ 'read',
1276
+ 'sync',
1277
+ 'write',
1278
+ 'delete'
1279
+ ].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 || ![
1280
+ 'admitted',
1281
+ 'unavailable'
1282
+ ].includes(room.replica.admission) || !declaredIdentity(room.attachedModel)) {
1283
+ journal.record(`invalid model or replica declaration ${room.locator}`);
1284
+ refuse('missing-required-guarantee');
1285
+ notifyReady();
1286
+ return;
1287
+ }
1288
+ if (!declaredSelection(room)) {
1289
+ journal.record(`invalid typed selection declaration ${room.locator}`);
1290
+ refuse('missing-required-guarantee');
1291
+ notifyReady();
1292
+ return;
1293
+ }
1294
+ if (malformed || held !== undefined && held.collectionRoot !== room.collectionRoot) {
1295
+ mismatchedRoots.add(room.locator);
1296
+ journal.record(`collection root ${malformed ? 'invalid' : 'changed'} ${room.locator}`);
1297
+ }
1298
+ }
1299
+ if (mismatchedRoots.size > 0) {
1300
+ refuse('missing-required-guarantee');
1301
+ notifyReady();
1302
+ return;
1303
+ }
862
1304
  const declared = new Set(result.capabilities.map((capability)=>capability.name));
863
1305
  for (const required of options.requiredGuarantees){
864
1306
  if (!knownCapabilities.has(required) || !declared.has(required)) {
@@ -873,11 +1315,14 @@ export function createMdRuntime(options) {
873
1315
  }
874
1316
  capabilities = result.capabilities;
875
1317
  notifyReady();
1318
+ if (declared.has('room-version-notifications')) observeRoomVersions();
1319
+ if (declared.has('provider-actions')) observeConversations();
876
1320
  if (JSON.stringify(result.providerLimits) !== JSON.stringify(limits)) {
877
1321
  limits = result.providerLimits;
878
1322
  await local.declare(limits);
879
1323
  }
880
1324
  const listedLocators = applyDiscovery(result.rooms);
1325
+ handSelected(result.rooms.filter((room)=>room.replica.admission === 'admitted').map((room)=>room.locator).sort());
881
1326
  progress.complete = [
882
1327
  ...replicas.keys()
883
1328
  ].every((locator)=>listedLocators.has(locator));
@@ -935,11 +1380,80 @@ export function createMdRuntime(options) {
935
1380
  }
936
1381
  }
937
1382
  }
938
- function enqueue() {
939
- if (!leader) {
940
- announce('enqueue');
941
- return chain;
1383
+ function observeRoomVersions() {
1384
+ const path = options.adapter.roomVersions;
1385
+ if (path === undefined || withdrawNotifications !== undefined) return;
1386
+ withdrawNotifications = path.observe((notification)=>{
1387
+ if (closed || discoveryPaused) return;
1388
+ notices.set(notification.locator, notification.roomVersion);
1389
+ chain = chain.then(runNotified, runNotified);
1390
+ });
1391
+ }
1392
+ function observeConversations() {
1393
+ const path = options.adapter.conversations;
1394
+ if (path === undefined || withdrawConversations !== undefined) return;
1395
+ withdrawConversations = path.observe((event)=>{
1396
+ conversations.receive(event);
1397
+ });
1398
+ }
1399
+ function handSelected(locators) {
1400
+ const path = options.adapter.roomVersions;
1401
+ if (path === undefined || withdrawNotifications === undefined) return;
1402
+ if (handed !== undefined && JSON.stringify(locators) === JSON.stringify(handed)) return;
1403
+ handed = locators;
1404
+ path.select(locators);
1405
+ }
1406
+ async function runNotified() {
1407
+ const announced = [
1408
+ ...notices
1409
+ ];
1410
+ notices.clear();
1411
+ if (announced.length === 0 || closed || discoveryPaused) return;
1412
+ const resting = status;
1413
+ const progress = {
1414
+ complete: false,
1415
+ reachable: !resting.providerUnreachable
1416
+ };
1417
+ moveTo(transferring(resting));
1418
+ try {
1419
+ const local = await openLocal();
1420
+ for (const [locator, roomVersion] of announced){
1421
+ if (closed || discoveryPaused || authorization === undefined) return;
1422
+ if (handed?.includes(locator) !== true) continue;
1423
+ const room = discovered.get(locator);
1424
+ const held = replicas.get(locator);
1425
+ if (room === undefined || held === undefined) continue;
1426
+ if (held.confirmed.record.roomVersion === roomVersion) continue;
1427
+ const withheld = await advance(room, local, authorization);
1428
+ if (withheld === null) continue;
1429
+ if (withheld.kind === 'scoped-refusal') {
1430
+ await scoped(withheld, room, local);
1431
+ continue;
1432
+ }
1433
+ if (withheld.kind === 'transport-uncertainty') {
1434
+ progress.reachable = false;
1435
+ continue;
1436
+ }
1437
+ if (withheld.kind === 'room-not-readable') {
1438
+ unreadable.add(locator);
1439
+ list(room, false);
1440
+ emitRooms();
1441
+ continue;
1442
+ }
1443
+ if (withheld.kind === 'revoked-authorization') {
1444
+ refuse('revoked-authorization');
1445
+ return;
1446
+ }
1447
+ }
1448
+ } catch {
1449
+ progress.reachable = false;
1450
+ } finally{
1451
+ if (!closed) {
1452
+ moveTo(settled(resting, progress, resting.lastCompleted, pending(), attention()));
1453
+ }
942
1454
  }
1455
+ }
1456
+ function enqueue() {
943
1457
  if (inflight) {
944
1458
  queued = true;
945
1459
  return chain;
@@ -948,32 +1462,18 @@ export function createMdRuntime(options) {
948
1462
  chain = chain.then(run, run);
949
1463
  return chain;
950
1464
  }
951
- function startProcessing() {
952
- if (leader) {
953
- void enqueue();
954
- return;
955
- }
956
- void window.navigator.locks.request('mdruntime-sync', {
957
- signal: options.signal
958
- }, async ()=>{
959
- if (closed) return;
960
- leader = true;
961
- announce();
962
- void enqueue();
963
- await new Promise((resolve)=>{
964
- releaseLeadership = ()=>{
965
- leader = false;
966
- resolve();
967
- };
968
- });
969
- }).catch(()=>{});
970
- }
971
1465
  function close() {
972
1466
  closed = true;
1467
+ withdrawNotifications?.();
1468
+ withdrawNotifications = undefined;
1469
+ withdrawConversations?.();
1470
+ withdrawConversations = undefined;
1471
+ conversations.close();
973
1472
  stopTimer();
974
- releaseLeadership();
975
- channel?.close();
976
1473
  store?.close();
1474
+ void opening?.then((opened)=>{
1475
+ opened.close();
1476
+ }, ()=>{});
977
1477
  roomListeners.clear();
978
1478
  refusalListeners.clear();
979
1479
  storageLossListeners.clear();
@@ -981,22 +1481,16 @@ export function createMdRuntime(options) {
981
1481
  querySubscriptions.clear();
982
1482
  queueSubscriptions.clear();
983
1483
  draftListeners.clear();
1484
+ presentationListeners.clear();
984
1485
  }
985
- channel?.addEventListener('message', (event)=>{
986
- const data = event.data;
987
- if (data === null || typeof data !== 'object') return;
988
- void exclusive(()=>applyRemote(data));
989
- });
990
1486
  options.signal?.addEventListener('abort', close, {
991
1487
  once: true
992
1488
  });
993
1489
  if (options.signal?.aborted) close();
994
- void openLocal().then(()=>{
995
- if (!leader) notifyReady();
996
- }, ()=>{
1490
+ void openLocal().catch(()=>{
997
1491
  notifyReady();
998
1492
  });
999
- startProcessing();
1493
+ void enqueue();
1000
1494
  return {
1001
1495
  rooms () {
1002
1496
  return listed();
@@ -1005,7 +1499,10 @@ export function createMdRuntime(options) {
1005
1499
  return visible(request);
1006
1500
  },
1007
1501
  types (locator) {
1008
- return replicas.get(locator)?.visible.collection.types ?? [];
1502
+ return {
1503
+ collection: modelTypes(modelOf(locator)),
1504
+ typedSelection: rooms.get(locator)?.typedSelection === undefined || rooms.get(locator)?.typedSelection === null ? null : modelTypes(modelOf(locator, 'typed-selection'))
1505
+ };
1009
1506
  },
1010
1507
  syncStatus () {
1011
1508
  return status;
@@ -1042,6 +1539,29 @@ export function createMdRuntime(options) {
1042
1539
  });
1043
1540
  }
1044
1541
  },
1542
+ conversation: conversations.surface,
1543
+ presentation: {
1544
+ read (app) {
1545
+ return exclusive(async ()=>{
1546
+ await hydrate(await openLocal());
1547
+ return presentationOf(app);
1548
+ });
1549
+ },
1550
+ write (app, value) {
1551
+ return exclusive(async ()=>{
1552
+ const local = await openLocal();
1553
+ await hydrate(local);
1554
+ const stored = structuredClone(value);
1555
+ await local.present({
1556
+ app,
1557
+ value: stored
1558
+ });
1559
+ presentations.set(app, stored);
1560
+ }).then(()=>{
1561
+ emitPresentation(app);
1562
+ });
1563
+ }
1564
+ },
1045
1565
  save (locator, changes, replacing) {
1046
1566
  return exclusive(()=>write(locator, changes, replacing));
1047
1567
  },
@@ -1072,6 +1592,9 @@ export function createMdRuntime(options) {
1072
1592
  storageLoss () {
1073
1593
  return storageLoss;
1074
1594
  },
1595
+ persistence () {
1596
+ return options.shell.persistentStorage;
1597
+ },
1075
1598
  observe (topic, second, third) {
1076
1599
  if (topic === 'drafts') {
1077
1600
  const notify = second;
@@ -1093,6 +1616,27 @@ export function createMdRuntime(options) {
1093
1616
  draftListeners.delete(listener);
1094
1617
  };
1095
1618
  }
1619
+ if (topic === 'conversation') {
1620
+ return conversations.observe(second, third);
1621
+ }
1622
+ if (topic === 'presentation') {
1623
+ const listener = {
1624
+ app: second,
1625
+ notify: third,
1626
+ ready: false
1627
+ };
1628
+ presentationListeners.add(listener);
1629
+ void exclusive(async ()=>{
1630
+ await hydrate(await openLocal());
1631
+ }).then(()=>{
1632
+ if (closed || !presentationListeners.has(listener)) return;
1633
+ listener.ready = true;
1634
+ listener.notify(presentationOf(listener.app));
1635
+ }, ()=>{});
1636
+ return ()=>{
1637
+ presentationListeners.delete(listener);
1638
+ };
1639
+ }
1096
1640
  if (topic === 'rooms') {
1097
1641
  const notify = second;
1098
1642
  roomListeners.add(notify);