@agentshouse/mdruntime 0.1.0-alpha.6 → 0.1.0-alpha.8

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 (54) hide show
  1. package/dist/adapter.d.ts +40 -4
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adapter.js.map +1 -1
  4. package/dist/bootstrap.d.ts +3 -1
  5. package/dist/bootstrap.d.ts.map +1 -1
  6. package/dist/bootstrap.js +12 -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/collection.d.ts +8 -3
  12. package/dist/collection.d.ts.map +1 -1
  13. package/dist/collection.js +66 -28
  14. package/dist/collection.js.map +1 -1
  15. package/dist/connection.d.ts +17 -3
  16. package/dist/connection.d.ts.map +1 -1
  17. package/dist/connection.js +445 -37
  18. package/dist/connection.js.map +1 -1
  19. package/dist/identity.d.ts +2 -0
  20. package/dist/identity.d.ts.map +1 -0
  21. package/dist/identity.js +16 -0
  22. package/dist/identity.js.map +1 -0
  23. package/dist/index.d.ts +5 -2
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js.map +1 -1
  26. package/dist/memory.d.ts +2 -0
  27. package/dist/memory.d.ts.map +1 -1
  28. package/dist/memory.js +27 -6
  29. package/dist/memory.js.map +1 -1
  30. package/dist/model.d.ts +70 -0
  31. package/dist/model.d.ts.map +1 -0
  32. package/dist/model.js +28 -0
  33. package/dist/model.js.map +1 -0
  34. package/dist/query.d.ts +12 -0
  35. package/dist/query.d.ts.map +1 -0
  36. package/dist/query.js +18 -0
  37. package/dist/query.js.map +1 -0
  38. package/dist/refresh.d.ts +2 -1
  39. package/dist/refresh.d.ts.map +1 -1
  40. package/dist/refresh.js +6 -4
  41. package/dist/refresh.js.map +1 -1
  42. package/dist/replica.d.ts +3 -1
  43. package/dist/replica.d.ts.map +1 -1
  44. package/dist/replica.js +10 -6
  45. package/dist/replica.js.map +1 -1
  46. package/dist/save.d.ts +6 -1
  47. package/dist/save.d.ts.map +1 -1
  48. package/dist/save.js +31 -9
  49. package/dist/save.js.map +1 -1
  50. package/dist/store.d.ts +7 -2
  51. package/dist/store.d.ts.map +1 -1
  52. package/dist/store.js +1 -1
  53. package/dist/store.js.map +1 -1
  54. package/package.json +1 -1
@@ -1,7 +1,8 @@
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 { MD_MODEL, PRESENTED, validRoot } from './collection.js';
5
6
  import { createLog } from './log.js';
6
7
  import { queuedOutcome, refused } from './outcome.js';
7
8
  import { refreshRoom } from './refresh.js';
@@ -25,6 +26,14 @@ export function createMdRuntime(options) {
25
26
  let capabilities = [];
26
27
  const rooms = new Map();
27
28
  const unreadable = new Set();
29
+ const models = new Map();
30
+ const liveModels = new Map();
31
+ const invalidModels = new Set();
32
+ const modelEpoch = new Map();
33
+ function modelOf(locator) {
34
+ return models.get(locator) ?? NO_MODEL;
35
+ }
36
+ const mismatchedRoots = new Set();
28
37
  let refusal = null;
29
38
  let storageLoss = null;
30
39
  let status = STARTING;
@@ -41,6 +50,7 @@ export function createMdRuntime(options) {
41
50
  const statusListeners = new Set();
42
51
  const querySubscriptions = new Set();
43
52
  const queueSubscriptions = new Set();
53
+ const draftListeners = new Set();
44
54
  const journal = createLog();
45
55
  let notifyReady = ()=>{};
46
56
  const ready = new Promise((resolve)=>{
@@ -50,8 +60,70 @@ export function createMdRuntime(options) {
50
60
  let applyingRemote = false;
51
61
  let releaseLeadership = ()=>{};
52
62
  const channel = typeof window !== 'undefined' ? new BroadcastChannel('mdruntime') : undefined;
53
- function at(locator, path) {
54
- return `${locator} ${path}`;
63
+ function draftContent(locator, path) {
64
+ return drafts.get(locator)?.get(path);
65
+ }
66
+ function rememberDraft(locator, path, content) {
67
+ const room = drafts.get(locator) ?? new Map();
68
+ room.set(path, content);
69
+ drafts.set(locator, room);
70
+ }
71
+ function forgetDraft(locator, path) {
72
+ const room = drafts.get(locator);
73
+ if (room === undefined) return;
74
+ room.delete(path);
75
+ if (room.size === 0) drafts.delete(locator);
76
+ }
77
+ function snapshotDrafts() {
78
+ const entries = [];
79
+ for (const [locator, room] of drafts){
80
+ for (const [path, content] of room){
81
+ entries.push({
82
+ locator,
83
+ path,
84
+ content: structuredClone(content)
85
+ });
86
+ }
87
+ }
88
+ entries.sort((left, right)=>{
89
+ if (left.locator !== right.locator) return left.locator < right.locator ? -1 : 1;
90
+ if (left.path !== right.path) return left.path < right.path ? -1 : 1;
91
+ return 0;
92
+ });
93
+ return entries;
94
+ }
95
+ async function readDrafts(opened) {
96
+ await acrossTabs('mdruntime-replica', async ()=>{
97
+ await hydrate(opened);
98
+ });
99
+ return snapshotDrafts();
100
+ }
101
+ function publishDrafts(waitingOnly) {
102
+ if (closed) return;
103
+ const value = snapshotDrafts();
104
+ for (const listener of draftListeners){
105
+ if (waitingOnly && listener.ready) continue;
106
+ listener.ready = true;
107
+ listener.notify(value);
108
+ }
109
+ }
110
+ function emitDrafts() {
111
+ publishDrafts(false);
112
+ }
113
+ function releaseWaitingDrafts() {
114
+ publishDrafts(true);
115
+ }
116
+ function commitDraft(work) {
117
+ return exclusive(async ()=>{
118
+ const local = await openLocal();
119
+ await acrossTabs('mdruntime-replica', async ()=>{
120
+ await hydrate(local);
121
+ await work(local);
122
+ });
123
+ }).then(()=>{
124
+ emitDrafts();
125
+ announce();
126
+ });
55
127
  }
56
128
  function pending() {
57
129
  return [
@@ -83,9 +155,9 @@ export function createMdRuntime(options) {
83
155
  announce();
84
156
  }
85
157
  function listed() {
86
- return [
158
+ return structuredClone([
87
159
  ...rooms.values()
88
- ];
160
+ ]);
89
161
  }
90
162
  function queryRooms() {
91
163
  return [
@@ -93,7 +165,8 @@ export function createMdRuntime(options) {
93
165
  ].map((replica)=>({
94
166
  room: replica.confirmed.record.locator,
95
167
  roomVersion: replica.confirmed.record.roomVersion,
96
- documents: replica.visible.collection.documents
168
+ documents: replica.visible.collection.documents,
169
+ model: modelTypes(modelOf(replica.confirmed.record.locator))
97
170
  }));
98
171
  }
99
172
  function emitRooms() {
@@ -130,7 +203,11 @@ export function createMdRuntime(options) {
130
203
  announce();
131
204
  }
132
205
  function visible(request) {
133
- return evaluate(queryRooms(), {
206
+ if (options.queryBinding === null) {
207
+ if (Object.keys(request).length > 0) throw new Error('query-binding-unavailable');
208
+ return rawQuery(queryRooms());
209
+ }
210
+ return options.queryBinding(queryRooms(), {
134
211
  ...request,
135
212
  selector: {
136
213
  $and: [
@@ -171,6 +248,38 @@ export function createMdRuntime(options) {
171
248
  }, period);
172
249
  }
173
250
  function refuse(reason) {
251
+ if (reason === 'revoked-authorization') {
252
+ const revoked = {
253
+ status: 'unavailable',
254
+ reason: 'revoked'
255
+ };
256
+ for (const locator of new Set([
257
+ ...models.keys(),
258
+ ...rooms.keys()
259
+ ])){
260
+ invalidModels.add(locator);
261
+ modelEpoch.set(locator, (modelEpoch.get(locator) ?? 0) + 1);
262
+ models.set(locator, revoked);
263
+ liveModels.delete(locator);
264
+ }
265
+ for (const [locator, room] of rooms)rooms.set(locator, {
266
+ ...room,
267
+ model: modelTypes(revoked)
268
+ });
269
+ emitRooms();
270
+ void exclusive(async ()=>{
271
+ const local = await openLocal();
272
+ await acrossTabs('mdruntime-replica', async ()=>{
273
+ for (const record of (await local.all()))await local.publish({
274
+ ...record,
275
+ model: revoked
276
+ });
277
+ });
278
+ announce();
279
+ }).catch(()=>{
280
+ journal.record('model invalidation durable-write failure');
281
+ });
282
+ }
174
283
  stopTimer();
175
284
  discoveryPaused = true;
176
285
  refusal = {
@@ -183,8 +292,12 @@ export function createMdRuntime(options) {
183
292
  function list(room, synchronizationAvailable) {
184
293
  rooms.set(room.locator, {
185
294
  name: room.name,
295
+ collectionRoot: room.collectionRoot,
186
296
  locator: room.locator,
187
- writeGrants: room.writeGrants,
297
+ operations: room.operations,
298
+ replica: room.replica,
299
+ attachedModel: room.attachedModel,
300
+ model: modelTypes(modelOf(room.locator)),
188
301
  providerOwnedPaths: room.providerOwnedPaths,
189
302
  roomVersion: replicas.get(room.locator)?.confirmed.record.roomVersion ?? null,
190
303
  synchronizationAvailable
@@ -193,7 +306,12 @@ export function createMdRuntime(options) {
193
306
  function applyDiscovery(discovered) {
194
307
  const listedLocators = new Set(discovered.map((room)=>room.locator));
195
308
  for (const room of discovered){
196
- list(room, !unreadable.has(room.locator));
309
+ const held = replicas.get(room.locator)?.confirmed.record;
310
+ if (held !== undefined && held.replica.selection !== room.replica.selection) {
311
+ list(held, false);
312
+ continue;
313
+ }
314
+ list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
197
315
  }
198
316
  for (const [locator, room] of rooms){
199
317
  if (listedLocators.has(locator)) continue;
@@ -215,7 +333,7 @@ export function createMdRuntime(options) {
215
333
  ]);
216
334
  limits = declared;
217
335
  drafts.clear();
218
- for (const draft of kept)drafts.set(at(draft.locator, draft.path), draft.content);
336
+ for (const draft of kept)rememberDraft(draft.locator, draft.path, draft.content);
219
337
  const queuedBatches = new Map(queues.map((queue)=>[
220
338
  queue.locator,
221
339
  queue.batches
@@ -228,7 +346,22 @@ export function createMdRuntime(options) {
228
346
  }
229
347
  replicas.clear();
230
348
  for (const record of records){
231
- const confirmed = readConfirmed(record);
349
+ const persisted = record.model;
350
+ const priorModel = models.get(record.locator);
351
+ if (priorModel !== undefined && priorModel.status !== 'unavailable' && (persisted.status === 'unavailable' || !sameModel(priorModel.snapshot, persisted.snapshot))) {
352
+ modelEpoch.set(record.locator, (modelEpoch.get(record.locator) ?? 0) + 1);
353
+ invalidModels.add(record.locator);
354
+ }
355
+ const model = persisted.status === 'unavailable' || liveModels.get(record.locator) === JSON.stringify(persisted.snapshot) ? persisted : cached(persisted);
356
+ models.set(record.locator, model);
357
+ if (model.status === 'unavailable') {
358
+ liveModels.delete(record.locator);
359
+ invalidModels.delete(record.locator);
360
+ }
361
+ const confirmed = readConfirmed({
362
+ ...record,
363
+ model
364
+ });
232
365
  if (confirmed === null) continue;
233
366
  replicas.set(record.locator, replicaOf(confirmed, queuedBatches.get(record.locator) ?? []));
234
367
  const existing = rooms.get(record.locator);
@@ -236,7 +369,11 @@ export function createMdRuntime(options) {
236
369
  else {
237
370
  rooms.set(record.locator, {
238
371
  ...existing,
239
- roomVersion: record.roomVersion
372
+ roomVersion: record.roomVersion,
373
+ operations: record.operations,
374
+ replica: record.replica,
375
+ attachedModel: record.attachedModel,
376
+ model: modelTypes(model)
240
377
  });
241
378
  }
242
379
  }
@@ -255,6 +392,7 @@ export function createMdRuntime(options) {
255
392
  emitVisible();
256
393
  }
257
394
  for (const locator of locators)emitQueue(locator);
395
+ emitDrafts();
258
396
  return lastCompleted;
259
397
  }
260
398
  async function applyRemote(message) {
@@ -323,9 +461,14 @@ export function createMdRuntime(options) {
323
461
  return exclusive(async ()=>{
324
462
  await acrossTabs('mdruntime-replica', async ()=>{
325
463
  await hydrate(local);
326
- await local.publish(confirmed.record);
464
+ const retained = readConfirmed({
465
+ ...confirmed.record,
466
+ model: modelOf(room.locator)
467
+ });
468
+ if (retained === null) return;
469
+ await local.publish(retained.record);
327
470
  unreadable.delete(room.locator);
328
- replicas.set(room.locator, replicaOf(confirmed, replicas.get(room.locator)?.queue ?? []));
471
+ replicas.set(room.locator, replicaOf(retained, replicas.get(room.locator)?.queue ?? []));
329
472
  });
330
473
  list(room, true);
331
474
  emitRooms();
@@ -342,9 +485,19 @@ export function createMdRuntime(options) {
342
485
  if (replica === undefined || room === undefined) {
343
486
  throw new Error(`No Local Replica for ${locator}`);
344
487
  }
488
+ if (mismatchedRoots.has(locator)) return {
489
+ outcome: 'refused',
490
+ rule: 'collection-root-mismatch'
491
+ };
345
492
  const built = buildBatch({
346
493
  baseRoomVersion: replica.confirmed.record.roomVersion,
347
- writeGrants: room.writeGrants,
494
+ collectionRoot: replica.confirmed.record.collectionRoot,
495
+ operations: room.operations,
496
+ model: invalidModels.has(locator) ? {
497
+ status: 'unavailable',
498
+ reason: 'revoked'
499
+ } : modelOf(locator),
500
+ binding: options.modelBinding,
348
501
  providerOwnedPaths: room.providerOwnedPaths,
349
502
  providerLimits: limits,
350
503
  visible: replica.visible,
@@ -386,10 +539,11 @@ export function createMdRuntime(options) {
386
539
  };
387
540
  }
388
541
  if (replacing !== undefined) outcomes.delete(replacing);
389
- for (const path of consumed)drafts.delete(at(locator, path));
542
+ for (const path of consumed)forgetDraft(locator, path);
390
543
  replicas.set(locator, replicaOf(replica.confirmed, batches));
391
544
  emitQueue(locator);
392
545
  emitVisible();
546
+ emitDrafts();
393
547
  moveTo({
394
548
  ...saving(status),
395
549
  attentionRequired: attention()
@@ -463,6 +617,10 @@ export function createMdRuntime(options) {
463
617
  }
464
618
  });
465
619
  journal.record(`outcome history ${result.kind}`);
620
+ if (result.kind === 'scoped-refusal') {
621
+ const room = replicas.get(result.locator)?.confirmed.record;
622
+ if (room !== undefined) await scoped(result, room, await openLocal());
623
+ }
466
624
  if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
467
625
  return result;
468
626
  } catch {
@@ -506,6 +664,10 @@ export function createMdRuntime(options) {
506
664
  name,
507
665
  input
508
666
  });
667
+ if (result.kind === 'scoped-refusal') {
668
+ const room = replicas.get(result.locator)?.confirmed.record;
669
+ if (room !== undefined) await scoped(result, room, await openLocal());
670
+ }
509
671
  if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
510
672
  journal.record(`outcome action ${result.kind}`);
511
673
  if (result.kind === 'rejected') journal.record(`refusal rejected ${result.text}`);
@@ -519,7 +681,7 @@ export function createMdRuntime(options) {
519
681
  }
520
682
  async function pullRefresh(authorized, confirmed) {
521
683
  try {
522
- const result = await refreshRoom(options.adapter, authorized, confirmed);
684
+ const result = await refreshRoom(options.adapter, authorized, confirmed, options.modelBinding);
523
685
  const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
524
686
  journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
525
687
  return result;
@@ -530,7 +692,7 @@ export function createMdRuntime(options) {
530
692
  }
531
693
  async function pullBootstrap(authorized, room) {
532
694
  try {
533
- const result = await bootstrapRoom(options.adapter, authorized, room);
695
+ const result = await bootstrapRoom(options.adapter, authorized, room, modelOf(room.locator), options.modelBinding);
534
696
  const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
535
697
  journal.record(`transfer bootstrap ${room.locator} ${detail}`);
536
698
  return result;
@@ -589,7 +751,12 @@ export function createMdRuntime(options) {
589
751
  return true;
590
752
  }
591
753
  const remaining = current.queue.filter((batch)=>batch.id !== submitted.id);
592
- const applied = overlay ? applyReturned(base, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion) : base;
754
+ const retainedBase = readConfirmed({
755
+ ...base.record,
756
+ model: modelOf(room.locator)
757
+ });
758
+ if (retainedBase === null) return false;
759
+ const applied = overlay ? applyReturned(retainedBase, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion, options.modelBinding) : retainedBase;
593
760
  if (applied === null) return false;
594
761
  await local.publish(applied.record);
595
762
  await persistQueue(local, room.locator, remaining);
@@ -645,6 +812,11 @@ export function createMdRuntime(options) {
645
812
  refuse('revoked-authorization');
646
813
  return 'stop';
647
814
  }
815
+ if (result.kind === 'scoped-refusal') {
816
+ await scoped(result, room, local);
817
+ progress.complete = false;
818
+ return null;
819
+ }
648
820
  if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
649
821
  journal.record(`outcome ${room.locator} ${head.id} ${result.kind}`);
650
822
  if (result.kind !== 'expired') journal.record(`refusal ${result.kind} ${result.text}`);
@@ -673,6 +845,11 @@ export function createMdRuntime(options) {
673
845
  refuse('revoked-authorization');
674
846
  return 'stop';
675
847
  }
848
+ if (pulled.kind === 'scoped-refusal') {
849
+ await scoped(pulled, room, local);
850
+ progress.complete = false;
851
+ return null;
852
+ }
676
853
  if (pulled.kind === 'still-current') {
677
854
  const committed = await exclusive(()=>commitAccept(room, local, head, result, from, true));
678
855
  if (!committed) {
@@ -696,6 +873,11 @@ export function createMdRuntime(options) {
696
873
  refuse('revoked-authorization');
697
874
  return 'stop';
698
875
  }
876
+ if (bootstrapped.kind === 'scoped-refusal') {
877
+ await scoped(bootstrapped, room, local);
878
+ progress.complete = false;
879
+ return null;
880
+ }
699
881
  if (bootstrapped.kind === 'staged') {
700
882
  const overlay = bootstrapped.confirmed.record.roomVersion === result.roomVersion;
701
883
  const committed = await exclusive(()=>commitAccept(room, local, head, result, bootstrapped.confirmed, overlay));
@@ -727,15 +909,172 @@ export function createMdRuntime(options) {
727
909
  }
728
910
  return null;
729
911
  }
912
+ async function retainModel(local, room, next) {
913
+ if (next.status === 'unavailable') modelEpoch.set(room.locator, (modelEpoch.get(room.locator) ?? 0) + 1);
914
+ const epoch = modelEpoch.get(room.locator) ?? 0;
915
+ const previous = modelOf(room.locator);
916
+ const heldBefore = replicas.get(room.locator)?.confirmed.record;
917
+ 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))) return;
918
+ const queryChanged = previous.status === 'unavailable' || next.status === 'unavailable' ? previous.status !== next.status : JSON.stringify(previous.types) !== JSON.stringify(next.types);
919
+ if (next.status === 'unavailable') {
920
+ invalidModels.add(room.locator);
921
+ liveModels.delete(room.locator);
922
+ }
923
+ await exclusive(async ()=>acrossTabs('mdruntime-replica', async ()=>{
924
+ await hydrate(local);
925
+ if (next.status !== 'unavailable' && epoch !== (modelEpoch.get(room.locator) ?? 0)) return;
926
+ const held = replicas.get(room.locator);
927
+ if (held !== undefined) {
928
+ const record = {
929
+ ...held.confirmed.record,
930
+ operations: room.operations,
931
+ replica: room.replica,
932
+ attachedModel: room.attachedModel,
933
+ model: next
934
+ };
935
+ await local.publish(record);
936
+ const confirmed = readConfirmed(record);
937
+ if (confirmed !== null) replicas.set(room.locator, replicaOf(confirmed, held.queue));
938
+ }
939
+ models.set(room.locator, next);
940
+ if (next.status === 'current') {
941
+ liveModels.set(room.locator, JSON.stringify(next.snapshot));
942
+ invalidModels.delete(room.locator);
943
+ }
944
+ list(room, room.replica.admission === 'admitted' && !unreadable.has(room.locator));
945
+ emitRooms();
946
+ if (queryChanged && held !== undefined) emitVisible();
947
+ announce();
948
+ }));
949
+ }
950
+ async function acquireModel(local, room, authorized) {
951
+ let previous = modelOf(room.locator);
952
+ if (previous.status !== 'unavailable' && (room.attachedModel === null || !sameModel(previous.snapshot, room.attachedModel))) {
953
+ previous = {
954
+ status: 'unavailable',
955
+ reason: 'version-changed'
956
+ };
957
+ await retainModel(local, room, previous);
958
+ }
959
+ const epoch = modelEpoch.get(room.locator) ?? 0;
960
+ let answer;
961
+ try {
962
+ answer = await options.adapter.model(authorized, {
963
+ locator: room.locator,
964
+ collectionRoot: room.collectionRoot,
965
+ model: room.attachedModel
966
+ });
967
+ } catch {
968
+ answer = {
969
+ kind: 'transport-uncertainty'
970
+ };
971
+ }
972
+ if (epoch !== (modelEpoch.get(room.locator) ?? 0)) return;
973
+ if (answer.kind === 'revoked-authorization') {
974
+ await retainModel(local, room, {
975
+ status: 'unavailable',
976
+ reason: 'revoked'
977
+ });
978
+ refuse('revoked-authorization');
979
+ return;
980
+ }
981
+ if (answer.kind === 'transport-uncertainty') {
982
+ await retainModel(local, room, cached(previous));
983
+ return;
984
+ }
985
+ if (answer.kind === 'model-unavailable') {
986
+ await retainModel(local, room, {
987
+ status: 'unavailable',
988
+ reason: answer.reason
989
+ });
990
+ return;
991
+ }
992
+ if (room.attachedModel === null || !sameModel(answer.snapshot, room.attachedModel)) {
993
+ await retainModel(local, room, {
994
+ status: 'unavailable',
995
+ reason: 'version-changed'
996
+ });
997
+ return;
998
+ }
999
+ 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())) {
1000
+ await retainModel(local, room, {
1001
+ status: 'unavailable',
1002
+ reason: 'invalid'
1003
+ });
1004
+ return;
1005
+ }
1006
+ const verified = options.modelBinding?.verify(answer.snapshot);
1007
+ await retainModel(local, room, verified?.kind === 'verified' ? {
1008
+ status: 'current',
1009
+ snapshot: structuredClone(answer.snapshot),
1010
+ verifiedWith: MD_MODEL,
1011
+ types: verified.types
1012
+ } : {
1013
+ status: 'unavailable',
1014
+ reason: verified === undefined ? 'binding-unavailable' : 'invalid'
1015
+ });
1016
+ }
1017
+ async function scoped(result, room, local) {
1018
+ if (result.scope === 'model') await retainModel(local, room, {
1019
+ status: 'unavailable',
1020
+ reason: 'not-readable'
1021
+ });
1022
+ else if (result.scope !== 'operation') {
1023
+ unreadable.add(room.locator);
1024
+ const unavailable = {
1025
+ ...room,
1026
+ replica: {
1027
+ ...room.replica,
1028
+ admission: 'unavailable'
1029
+ },
1030
+ operations: {
1031
+ ...room.operations,
1032
+ ...result.scope === 'content' ? {
1033
+ read: []
1034
+ } : {},
1035
+ ...result.scope === 'sync' ? {
1036
+ sync: []
1037
+ } : {}
1038
+ }
1039
+ };
1040
+ await retainModel(local, unavailable, modelOf(room.locator));
1041
+ list(unavailable, false);
1042
+ emitRooms();
1043
+ }
1044
+ journal.record(`scoped refusal ${room.locator} ${result.scope} ${result.text}`);
1045
+ }
730
1046
  async function synchronize(discovered, local, progress) {
731
1047
  for (const room of discovered){
732
1048
  if (closed || authorization === undefined) {
733
1049
  progress.complete = false;
734
1050
  return;
735
1051
  }
1052
+ const prior = replicas.get(room.locator)?.confirmed.record;
1053
+ if (prior !== undefined && prior.replica.selection !== room.replica.selection) {
1054
+ list(prior, false);
1055
+ emitRooms();
1056
+ journal.record(`selection changed ${room.locator}`);
1057
+ progress.complete = false;
1058
+ continue;
1059
+ }
1060
+ await acquireModel(local, room, authorization);
1061
+ if (discoveryPaused) return;
1062
+ if (room.replica.admission !== 'admitted') {
1063
+ progress.complete = false;
1064
+ continue;
1065
+ }
1066
+ const held = replicas.get(room.locator);
1067
+ if (held !== undefined && held.confirmed.record.replica.selection !== room.replica.selection) {
1068
+ progress.complete = false;
1069
+ continue;
1070
+ }
736
1071
  const withheld = await advance(room, local, authorization);
737
1072
  if (withheld !== null) {
738
1073
  progress.complete = false;
1074
+ if (withheld.kind === 'scoped-refusal') {
1075
+ await scoped(withheld, room, local);
1076
+ continue;
1077
+ }
739
1078
  if (withheld.kind === 'transport-uncertainty') {
740
1079
  progress.reachable = false;
741
1080
  continue;
@@ -764,10 +1103,10 @@ export function createMdRuntime(options) {
764
1103
  let result;
765
1104
  try {
766
1105
  result = await options.adapter.discover(authorization);
767
- } catch (error) {
768
- journal.record('connection discover transport-uncertainty');
769
- notifyReady();
770
- throw error;
1106
+ } catch {
1107
+ result = {
1108
+ kind: 'transport-uncertainty'
1109
+ };
771
1110
  }
772
1111
  if (closed) {
773
1112
  notifyReady();
@@ -775,10 +1114,17 @@ export function createMdRuntime(options) {
775
1114
  }
776
1115
  journal.record(`connection discover ${result.kind}`);
777
1116
  if (result.kind === 'transport-uncertainty') {
1117
+ for (const room of rooms.values())await retainModel(local, room, cached(modelOf(room.locator)));
778
1118
  progress.reachable = false;
779
1119
  notifyReady();
780
1120
  return;
781
1121
  }
1122
+ if (result.kind === 'contract-unavailable') {
1123
+ journal.record(`connection contract unavailable ${result.text}`);
1124
+ refuse('missing-required-guarantee');
1125
+ notifyReady();
1126
+ return;
1127
+ }
782
1128
  if (result.kind === 'revoked-authorization') {
783
1129
  refuse('revoked-authorization');
784
1130
  notifyReady();
@@ -794,6 +1140,34 @@ export function createMdRuntime(options) {
794
1140
  notifyReady();
795
1141
  return;
796
1142
  }
1143
+ mismatchedRoots.clear();
1144
+ for (const room of result.rooms){
1145
+ const held = replicas.get(room.locator)?.confirmed.record;
1146
+ const malformed = !validRoot(room.collectionRoot);
1147
+ if (room.operations === undefined || ![
1148
+ 'read',
1149
+ 'sync',
1150
+ 'write',
1151
+ 'delete'
1152
+ ].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 || ![
1153
+ 'admitted',
1154
+ 'unavailable'
1155
+ ].includes(room.replica.admission) || room.attachedModel === undefined || room.attachedModel !== null && (typeof room.attachedModel.identity !== 'string' || !room.attachedModel.identity || typeof room.attachedModel.version !== 'string' || !room.attachedModel.version)) {
1156
+ journal.record(`invalid model or replica declaration ${room.locator}`);
1157
+ refuse('missing-required-guarantee');
1158
+ notifyReady();
1159
+ return;
1160
+ }
1161
+ if (malformed || held !== undefined && held.collectionRoot !== room.collectionRoot) {
1162
+ mismatchedRoots.add(room.locator);
1163
+ journal.record(`collection root ${malformed ? 'invalid' : 'changed'} ${room.locator}`);
1164
+ }
1165
+ }
1166
+ if (mismatchedRoots.size > 0) {
1167
+ refuse('missing-required-guarantee');
1168
+ notifyReady();
1169
+ return;
1170
+ }
797
1171
  const declared = new Set(result.capabilities.map((capability)=>capability.name));
798
1172
  for (const required of options.requiredGuarantees){
799
1173
  if (!knownCapabilities.has(required) || !declared.has(required)) {
@@ -915,6 +1289,7 @@ export function createMdRuntime(options) {
915
1289
  statusListeners.clear();
916
1290
  querySubscriptions.clear();
917
1291
  queueSubscriptions.clear();
1292
+ draftListeners.clear();
918
1293
  }
919
1294
  channel?.addEventListener('message', (event)=>{
920
1295
  const data = event.data;
@@ -939,28 +1314,41 @@ export function createMdRuntime(options) {
939
1314
  return visible(request);
940
1315
  },
941
1316
  types (locator) {
942
- return replicas.get(locator)?.visible.collection.types ?? [];
1317
+ return modelTypes(modelOf(locator));
943
1318
  },
944
1319
  syncStatus () {
945
1320
  return status;
946
1321
  },
947
1322
  draft: {
1323
+ list () {
1324
+ return exclusive(async ()=>{
1325
+ const local = await openLocal();
1326
+ return readDrafts(local);
1327
+ }).then((entries)=>{
1328
+ releaseWaitingDrafts();
1329
+ return entries;
1330
+ });
1331
+ },
948
1332
  read (locator, path) {
949
- return drafts.get(at(locator, path)) ?? null;
1333
+ const content = draftContent(locator, path);
1334
+ return content === undefined ? null : structuredClone(content);
950
1335
  },
951
- async replace (locator, path, content) {
952
- const local = await openLocal();
953
- await local.keep({
954
- locator,
955
- path,
956
- content
1336
+ replace (locator, path, content) {
1337
+ return commitDraft(async (local)=>{
1338
+ const stored = structuredClone(content);
1339
+ await local.keep({
1340
+ locator,
1341
+ path,
1342
+ content: stored
1343
+ });
1344
+ rememberDraft(locator, path, stored);
957
1345
  });
958
- drafts.set(at(locator, path), content);
959
1346
  },
960
- async discard (locator, path) {
961
- const local = await openLocal();
962
- await local.discard(locator, path);
963
- drafts.delete(at(locator, path));
1347
+ discard (locator, path) {
1348
+ return commitDraft(async (local)=>{
1349
+ await local.discard(locator, path);
1350
+ forgetDraft(locator, path);
1351
+ });
964
1352
  }
965
1353
  },
966
1354
  save (locator, changes, replacing) {
@@ -994,6 +1382,26 @@ export function createMdRuntime(options) {
994
1382
  return storageLoss;
995
1383
  },
996
1384
  observe (topic, second, third) {
1385
+ if (topic === 'drafts') {
1386
+ const notify = second;
1387
+ const listener = {
1388
+ notify,
1389
+ ready: false
1390
+ };
1391
+ draftListeners.add(listener);
1392
+ void exclusive(async ()=>{
1393
+ const local = await openLocal();
1394
+ await readDrafts(local);
1395
+ if (closed || !draftListeners.has(listener)) return;
1396
+ listener.ready = true;
1397
+ }).then(()=>{
1398
+ if (closed || !listener.ready || !draftListeners.has(listener)) return;
1399
+ listener.notify(snapshotDrafts());
1400
+ }, ()=>{});
1401
+ return ()=>{
1402
+ draftListeners.delete(listener);
1403
+ };
1404
+ }
997
1405
  if (topic === 'rooms') {
998
1406
  const notify = second;
999
1407
  roomListeners.add(notify);