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

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 (67) hide show
  1. package/dist/adapter.d.ts +33 -14
  2. package/dist/adapter.d.ts.map +1 -1
  3. package/dist/adapter.js.map +1 -1
  4. package/dist/bootstrap.d.ts +5 -12
  5. package/dist/bootstrap.d.ts.map +1 -1
  6. package/dist/bootstrap.js +13 -20
  7. package/dist/bootstrap.js.map +1 -1
  8. package/dist/change.d.ts +64 -0
  9. package/dist/change.d.ts.map +1 -0
  10. package/dist/change.js +12 -0
  11. package/dist/change.js.map +1 -0
  12. package/dist/collection.d.ts +1 -0
  13. package/dist/collection.d.ts.map +1 -1
  14. package/dist/collection.js +10 -6
  15. package/dist/collection.js.map +1 -1
  16. package/dist/connection.d.ts +36 -1
  17. package/dist/connection.d.ts.map +1 -1
  18. package/dist/connection.js +657 -38
  19. package/dist/connection.js.map +1 -1
  20. package/dist/index.d.ts +6 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/log.d.ts +5 -0
  24. package/dist/log.d.ts.map +1 -0
  25. package/dist/log.js +15 -0
  26. package/dist/log.js.map +1 -0
  27. package/dist/memory.d.ts +1 -0
  28. package/dist/memory.d.ts.map +1 -1
  29. package/dist/memory.js +69 -7
  30. package/dist/memory.js.map +1 -1
  31. package/dist/outcome.d.ts +30 -0
  32. package/dist/outcome.d.ts.map +1 -0
  33. package/dist/outcome.js +13 -0
  34. package/dist/outcome.js.map +1 -0
  35. package/dist/overlay.d.ts +8 -0
  36. package/dist/overlay.d.ts.map +1 -0
  37. package/dist/overlay.js +139 -0
  38. package/dist/overlay.js.map +1 -0
  39. package/dist/paths.d.ts +6 -0
  40. package/dist/paths.d.ts.map +1 -0
  41. package/dist/paths.js +47 -0
  42. package/dist/paths.js.map +1 -0
  43. package/dist/refresh.d.ts +13 -0
  44. package/dist/refresh.d.ts.map +1 -0
  45. package/dist/refresh.js +63 -0
  46. package/dist/refresh.js.map +1 -0
  47. package/dist/replica.d.ts +20 -0
  48. package/dist/replica.d.ts.map +1 -0
  49. package/dist/replica.js +61 -0
  50. package/dist/replica.js.map +1 -0
  51. package/dist/save.d.ts +26 -0
  52. package/dist/save.d.ts.map +1 -0
  53. package/dist/save.js +496 -0
  54. package/dist/save.js.map +1 -0
  55. package/dist/status.d.ts +16 -0
  56. package/dist/status.d.ts.map +1 -0
  57. package/dist/status.js +31 -0
  58. package/dist/status.js.map +1 -0
  59. package/dist/store.d.ts +24 -0
  60. package/dist/store.d.ts.map +1 -1
  61. package/dist/store.js +82 -2
  62. package/dist/store.js.map +1 -1
  63. package/dist/transfer.d.ts +5 -0
  64. package/dist/transfer.d.ts.map +1 -0
  65. package/dist/transfer.js +5 -0
  66. package/dist/transfer.js.map +1 -0
  67. package/package.json +3 -3
@@ -1,27 +1,79 @@
1
1
  import { query as evaluate } from '@agentshouse/query';
2
2
  import { ROOM_CONTRACT_VERSION, VERSION_1_CAPABILITIES } from './adapter.js';
3
- import { adoptReplica, bootstrapRoom } from './bootstrap.js';
3
+ import { bootstrapRoom } from './bootstrap.js';
4
4
  import { MD_MODEL, PRESENTED } from './collection.js';
5
+ import { createLog } from './log.js';
6
+ import { queuedOutcome, refused } from './outcome.js';
7
+ import { refreshRoom } from './refresh.js';
8
+ import { buildBatch } from './save.js';
9
+ import { applyReturned, readConfirmed, replicaOf } from './replica.js';
10
+ import { settled, same, saving, STARTING, transferring } from './status.js';
5
11
  import { openReplicaStore } from './store.js';
12
+ import { unconfirmed } from './transfer.js';
6
13
  const DEFAULT_DISCOVERY_INTERVAL_MS = 60_000;
7
14
  const knownCapabilities = new Set(VERSION_1_CAPABILITIES);
8
15
  export function createMdRuntime(options) {
9
16
  const period = options.discoveryIntervalMs ?? DEFAULT_DISCOVERY_INTERVAL_MS;
10
17
  let authorization = options.authorization;
18
+ let opening;
11
19
  let store;
12
20
  const replicas = new Map();
21
+ const drafts = new Map();
22
+ const outcomes = new Map();
23
+ let limits = [];
24
+ let capabilities = [];
13
25
  const rooms = new Map();
14
26
  const unreadable = new Set();
15
27
  let refusal = null;
28
+ let status = STARTING;
16
29
  let discoveryPaused = false;
17
30
  let timer;
18
31
  let closed = false;
19
32
  let chain = Promise.resolve();
33
+ let mutations = Promise.resolve();
20
34
  let inflight = false;
21
35
  let queued = false;
22
36
  const roomListeners = new Set();
23
37
  const refusalListeners = new Set();
38
+ const statusListeners = new Set();
24
39
  const querySubscriptions = new Set();
40
+ const queueSubscriptions = new Set();
41
+ const journal = createLog();
42
+ let notifyReady = ()=>{};
43
+ const ready = new Promise((resolve)=>{
44
+ notifyReady = resolve;
45
+ });
46
+ function at(locator, path) {
47
+ return `${locator} ${path}`;
48
+ }
49
+ function pending() {
50
+ return [
51
+ ...replicas.values()
52
+ ].some((replica)=>replica.queue.length > 0);
53
+ }
54
+ function attention() {
55
+ return [
56
+ ...replicas.values()
57
+ ].some((replica)=>{
58
+ const head = replica.queue[0];
59
+ return head !== undefined && refused(outcomes.get(head.id));
60
+ });
61
+ }
62
+ function storedOutcomes(batches) {
63
+ const stored = {};
64
+ for (const batch of batches){
65
+ const outcome = queuedOutcome(outcomes.get(batch.id));
66
+ if (outcome.kind !== 'pending') stored[batch.id] = outcome;
67
+ }
68
+ return stored;
69
+ }
70
+ async function persistQueue(local, locator, batches) {
71
+ await local.writeQueue({
72
+ locator,
73
+ batches,
74
+ outcomes: storedOutcomes(batches)
75
+ });
76
+ }
25
77
  function listed() {
26
78
  return [
27
79
  ...rooms.values()
@@ -31,9 +83,9 @@ export function createMdRuntime(options) {
31
83
  return [
32
84
  ...replicas.values()
33
85
  ].map((replica)=>({
34
- room: replica.record.locator,
35
- roomVersion: replica.record.roomVersion,
36
- documents: replica.collection.documents
86
+ room: replica.confirmed.record.locator,
87
+ roomVersion: replica.confirmed.record.roomVersion,
88
+ documents: replica.visible.collection.documents
37
89
  }));
38
90
  }
39
91
  function emitRooms() {
@@ -43,6 +95,11 @@ export function createMdRuntime(options) {
43
95
  function emitRefusal() {
44
96
  for (const listener of refusalListeners)listener(refusal);
45
97
  }
98
+ function moveTo(next) {
99
+ if (closed || same(status, next)) return;
100
+ status = next;
101
+ for (const listener of statusListeners)listener(status);
102
+ }
46
103
  function visible(request) {
47
104
  return evaluate(queryRooms(), {
48
105
  ...request,
@@ -59,6 +116,20 @@ export function createMdRuntime(options) {
59
116
  subscription.notify(visible(subscription.query));
60
117
  }
61
118
  }
119
+ function entriesOf(locator) {
120
+ const replica = replicas.get(locator);
121
+ if (replica === undefined) return [];
122
+ return replica.queue.map((batch)=>Object.freeze({
123
+ batch,
124
+ outcome: queuedOutcome(outcomes.get(batch.id))
125
+ }));
126
+ }
127
+ function emitQueue(locator) {
128
+ const entries = entriesOf(locator);
129
+ for (const subscription of queueSubscriptions){
130
+ if (subscription.locator === locator) subscription.notify(entries);
131
+ }
132
+ }
62
133
  function stopTimer() {
63
134
  if (timer === undefined) return;
64
135
  clearInterval(timer);
@@ -77,6 +148,7 @@ export function createMdRuntime(options) {
77
148
  reason,
78
149
  reauthorizationOffered: reason === 'revoked-authorization'
79
150
  };
151
+ journal.record(`connection refused ${reason}`);
80
152
  emitRefusal();
81
153
  }
82
154
  function list(room, synchronizationAvailable) {
@@ -85,7 +157,7 @@ export function createMdRuntime(options) {
85
157
  locator: room.locator,
86
158
  writeGrants: room.writeGrants,
87
159
  providerOwnedPaths: room.providerOwnedPaths,
88
- roomVersion: replicas.get(room.locator)?.record.roomVersion ?? null,
160
+ roomVersion: replicas.get(room.locator)?.confirmed.record.roomVersion ?? null,
89
161
  synchronizationAvailable
90
162
  });
91
163
  }
@@ -102,70 +174,513 @@ export function createMdRuntime(options) {
102
174
  });
103
175
  }
104
176
  emitRooms();
177
+ return listedLocators;
105
178
  }
106
- async function openLocal() {
107
- if (store !== undefined) return store;
179
+ async function open() {
108
180
  const opened = await openReplicaStore();
109
- const records = await opened.all();
181
+ const [records, queues, kept, lastCompleted, declared] = await Promise.all([
182
+ opened.all(),
183
+ opened.queues(),
184
+ opened.drafts(),
185
+ opened.lastCompleted(),
186
+ opened.providerLimits()
187
+ ]);
110
188
  store = opened;
189
+ limits = declared;
190
+ for (const draft of kept)drafts.set(at(draft.locator, draft.path), draft.content);
191
+ const queued = new Map(queues.map((queue)=>[
192
+ queue.locator,
193
+ queue.batches
194
+ ]));
195
+ for (const queue of queues){
196
+ for (const [id, outcome] of Object.entries(queue.outcomes)){
197
+ outcomes.set(id, outcome);
198
+ }
199
+ }
111
200
  for (const record of records){
112
- const staged = adoptReplica(record);
113
- if (staged === null) continue;
114
- replicas.set(record.locator, staged);
201
+ const confirmed = readConfirmed(record);
202
+ if (confirmed === null) continue;
203
+ replicas.set(record.locator, replicaOf(confirmed, queued.get(record.locator) ?? []));
115
204
  list(record, false);
116
205
  }
206
+ moveTo({
207
+ ...status,
208
+ lastCompleted,
209
+ attentionRequired: attention()
210
+ });
117
211
  if (replicas.size > 0) {
118
212
  emitRooms();
119
213
  emitVisible();
120
214
  }
121
215
  return opened;
122
216
  }
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') {
217
+ function openLocal() {
218
+ opening ??= open().catch((error)=>{
219
+ opening = undefined;
220
+ throw error;
221
+ });
222
+ return opening;
223
+ }
224
+ function exclusive(work) {
225
+ const next = mutations.then(work, work);
226
+ mutations = next.then(()=>undefined, ()=>undefined);
227
+ return next;
228
+ }
229
+ function publish(local, room, confirmed) {
230
+ return exclusive(async ()=>{
231
+ await local.publish(confirmed.record);
232
+ unreadable.delete(room.locator);
233
+ replicas.set(room.locator, replicaOf(confirmed, replicas.get(room.locator)?.queue ?? []));
234
+ list(room, true);
235
+ emitRooms();
236
+ emitVisible();
237
+ });
238
+ }
239
+ async function write(locator, changes, replacing) {
240
+ const replica = replicas.get(locator);
241
+ const room = rooms.get(locator);
242
+ if (replica === undefined || room === undefined) {
243
+ throw new Error(`No Local Replica for ${locator}`);
244
+ }
245
+ const built = buildBatch({
246
+ baseRoomVersion: replica.confirmed.record.roomVersion,
247
+ writeGrants: room.writeGrants,
248
+ providerOwnedPaths: room.providerOwnedPaths,
249
+ providerLimits: limits,
250
+ visible: replica.visible,
251
+ changes
252
+ });
253
+ if (built.kind === 'refused') return {
254
+ outcome: 'refused',
255
+ ...built.refusal
256
+ };
257
+ const head = replica.queue[0];
258
+ if (replacing !== undefined) {
259
+ if (head === undefined || head.id !== replacing || !refused(outcomes.get(head.id))) {
260
+ return {
261
+ outcome: 'refused',
262
+ rule: 'no-refused-head'
263
+ };
264
+ }
265
+ }
266
+ const local = await openLocal();
267
+ const batches = replacing === undefined ? [
268
+ ...replica.queue,
269
+ built.batch
270
+ ] : [
271
+ built.batch,
272
+ ...replica.queue.slice(1)
273
+ ];
274
+ if (replacing !== undefined) outcomes.delete(replacing);
275
+ const consumed = [
276
+ ...new Set(changes.map((change)=>change.path))
277
+ ];
278
+ await local.append({
279
+ locator,
280
+ batches,
281
+ outcomes: storedOutcomes(batches)
282
+ }, consumed);
283
+ for (const path of consumed)drafts.delete(at(locator, path));
284
+ replicas.set(locator, replicaOf(replica.confirmed, batches));
285
+ emitQueue(locator);
286
+ emitVisible();
287
+ moveTo({
288
+ ...saving(status),
289
+ attentionRequired: attention()
290
+ });
291
+ void enqueue();
292
+ return {
293
+ outcome: 'saved',
294
+ id: built.batch.id
295
+ };
296
+ }
297
+ async function discardHead(locator, identifier) {
298
+ const replica = replicas.get(locator);
299
+ const head = replica?.queue[0];
300
+ if (replica === undefined || head === undefined || head.id !== identifier || !refused(outcomes.get(head.id))) {
301
+ return;
302
+ }
303
+ const local = await openLocal();
304
+ const batches = replica.queue.slice(1);
305
+ outcomes.delete(identifier);
306
+ await persistQueue(local, locator, batches);
307
+ replicas.set(locator, replicaOf(replica.confirmed, batches));
308
+ emitQueue(locator);
309
+ emitVisible();
310
+ moveTo({
311
+ ...status,
312
+ state: pending() ? 'pending' : status.state,
313
+ attentionRequired: attention()
314
+ });
315
+ void enqueue();
316
+ }
317
+ async function readHistory(locator, path, query = {}) {
318
+ if (closed || refusal !== null) return {
319
+ kind: 'transport-uncertainty'
320
+ };
321
+ try {
322
+ await ensureAuthorization();
323
+ } catch {
324
+ return {
325
+ kind: 'transport-uncertainty'
326
+ };
327
+ }
328
+ if (closed || authorization === undefined || refusal !== null) {
329
+ return {
330
+ kind: 'transport-uncertainty'
331
+ };
332
+ }
333
+ const transfer = [
334
+ 'transfer',
335
+ 'history',
336
+ locator,
337
+ path
338
+ ];
339
+ if (query.revision !== undefined) transfer.push(query.revision);
340
+ if (query.pageToken !== undefined) transfer.push(query.pageToken);
341
+ journal.record(transfer.join(' '));
342
+ try {
343
+ const result = await options.adapter.history(authorization, {
344
+ locator,
345
+ path,
346
+ ...query.revision === undefined ? {} : {
347
+ revision: query.revision
348
+ },
349
+ ...query.pageToken === undefined ? {} : {
350
+ pageToken: query.pageToken
351
+ }
352
+ });
353
+ journal.record(`outcome history ${result.kind}`);
354
+ if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
355
+ return result;
356
+ } catch {
357
+ journal.record('outcome history transport-uncertainty');
358
+ return {
359
+ kind: 'transport-uncertainty'
360
+ };
361
+ }
362
+ }
363
+ function declaredAction(name) {
364
+ for (const capability of capabilities){
365
+ for (const action of capability.actions ?? []){
366
+ if (action.name === name) return action;
367
+ }
368
+ }
369
+ return undefined;
370
+ }
371
+ async function invokeAction(name, input) {
372
+ await ready;
373
+ if (closed || refusal !== null) return {
374
+ kind: 'transport-uncertainty'
375
+ };
376
+ if (declaredAction(name) === undefined) return {
377
+ kind: 'undeclared'
378
+ };
379
+ try {
380
+ await ensureAuthorization();
381
+ } catch {
382
+ return {
383
+ kind: 'transport-uncertainty'
384
+ };
385
+ }
386
+ if (closed || authorization === undefined || refusal !== null) {
387
+ return {
388
+ kind: 'transport-uncertainty'
389
+ };
390
+ }
391
+ journal.record(`transfer action ${name}`);
392
+ try {
393
+ const result = await options.adapter.action(authorization, {
394
+ name,
395
+ input
396
+ });
397
+ if (result.kind === 'revoked-authorization') refuse('revoked-authorization');
398
+ journal.record(`outcome action ${result.kind}`);
399
+ if (result.kind === 'rejected') journal.record(`refusal rejected ${result.text}`);
400
+ return result;
401
+ } catch {
402
+ journal.record('outcome action transport-uncertainty');
403
+ return {
404
+ kind: 'transport-uncertainty'
405
+ };
406
+ }
407
+ }
408
+ async function pullRefresh(authorized, confirmed) {
409
+ try {
410
+ const result = await refreshRoom(options.adapter, authorized, confirmed);
411
+ const detail = result.kind === 'advanced' ? result.confirmed.record.roomVersion : result.kind;
412
+ journal.record(`transfer refresh ${confirmed.record.locator} ${detail}`);
413
+ return result;
414
+ } catch (error) {
415
+ journal.record(`transfer refresh ${confirmed.record.locator} transport-uncertainty`);
416
+ throw error;
417
+ }
418
+ }
419
+ async function pullBootstrap(authorized, room) {
420
+ try {
421
+ const result = await bootstrapRoom(options.adapter, authorized, room);
422
+ const detail = result.kind === 'staged' ? result.confirmed.record.roomVersion : result.kind;
423
+ journal.record(`transfer bootstrap ${room.locator} ${detail}`);
424
+ return result;
425
+ } catch (error) {
426
+ journal.record(`transfer bootstrap ${room.locator} transport-uncertainty`);
427
+ throw error;
428
+ }
429
+ }
430
+ async function advance(room, local, authorized) {
431
+ const held = replicas.get(room.locator);
432
+ if (held !== undefined) {
433
+ const refreshed = await pullRefresh(authorized, held.confirmed);
434
+ if (refreshed.kind === 'still-current') {
435
+ if (unreadable.delete(room.locator)) {
436
+ list(room, true);
437
+ emitRooms();
438
+ }
439
+ return null;
440
+ }
441
+ if (refreshed.kind === 'advanced') {
442
+ if (closed) return unconfirmed;
443
+ await publish(local, room, refreshed.confirmed);
444
+ return null;
445
+ }
446
+ if (refreshed.kind !== 'beyond-horizon') return refreshed;
447
+ }
448
+ const bootstrapped = await pullBootstrap(authorized, room);
449
+ if (bootstrapped.kind !== 'staged') return bootstrapped;
450
+ if (closed) return unconfirmed;
451
+ await publish(local, room, bootstrapped.confirmed);
452
+ return null;
453
+ }
454
+ function recordRefusal(result) {
455
+ if (result.kind === 'expired') return {
456
+ kind: 'expired'
457
+ };
458
+ if (result.kind === 'conflicted') {
459
+ return {
460
+ kind: 'conflicted',
461
+ roomVersion: result.roomVersion,
462
+ affected: result.affected,
463
+ text: result.text
464
+ };
465
+ }
466
+ return {
467
+ kind: 'rejected',
468
+ affected: result.affected,
469
+ text: result.text
470
+ };
471
+ }
472
+ async function commitAccept(room, local, submitted, accepted, base, overlay) {
473
+ const current = replicas.get(room.locator);
474
+ if (current === undefined || !current.queue.some((batch)=>batch.id === submitted.id)) {
475
+ return true;
476
+ }
477
+ const remaining = current.queue.filter((batch)=>batch.id !== submitted.id);
478
+ const applied = overlay ? applyReturned(base, accepted.documents, accepted.revisions, accepted.removals, accepted.roomVersion) : base;
479
+ if (applied === null) return false;
480
+ await local.publish(applied.record);
481
+ await persistQueue(local, room.locator, remaining);
482
+ outcomes.set(submitted.id, {
483
+ kind: 'accepted',
484
+ roomVersion: accepted.roomVersion,
485
+ author: accepted.author,
486
+ time: accepted.time
487
+ });
488
+ unreadable.delete(room.locator);
489
+ replicas.set(room.locator, replicaOf(applied, remaining));
490
+ list(room, true);
491
+ emitRooms();
492
+ emitQueue(room.locator);
493
+ emitVisible();
494
+ journal.record(`outcome ${room.locator} ${submitted.id} accepted`);
495
+ return true;
496
+ }
497
+ async function submitRoom(room, local, authorized, progress) {
498
+ while(!closed && authorization !== undefined){
499
+ const replica = replicas.get(room.locator);
500
+ const head = replica?.queue[0];
501
+ if (replica === undefined || head === undefined || refused(outcomes.get(head.id))) {
502
+ return null;
503
+ }
504
+ let result;
505
+ journal.record(`submission ${room.locator} ${head.id}`);
506
+ try {
507
+ result = await options.adapter.mutate(authorized, {
508
+ locator: room.locator,
509
+ changeBatch: head
510
+ });
511
+ } catch {
512
+ progress.complete = false;
513
+ progress.reachable = false;
514
+ return null;
515
+ }
516
+ if (closed) return null;
517
+ if (result.kind === 'transport-uncertainty') {
518
+ progress.complete = false;
519
+ progress.reachable = false;
520
+ return null;
521
+ }
522
+ if (result.kind === 'room-not-readable') {
523
+ progress.complete = false;
524
+ unreadable.add(room.locator);
525
+ list(room, false);
526
+ emitRooms();
527
+ return null;
528
+ }
529
+ if (result.kind === 'revoked-authorization') {
129
530
  refuse('revoked-authorization');
130
- return;
531
+ return 'stop';
532
+ }
533
+ if (result.kind === 'conflicted' || result.kind === 'rejected' || result.kind === 'expired') {
534
+ journal.record(`outcome ${room.locator} ${head.id} ${result.kind}`);
535
+ if (result.kind !== 'expired') journal.record(`refusal ${result.kind} ${result.text}`);
536
+ await exclusive(async ()=>{
537
+ const current = replicas.get(room.locator);
538
+ if (current === undefined || !current.queue.some((batch)=>batch.id === head.id)) {
539
+ return;
540
+ }
541
+ outcomes.set(head.id, recordRefusal(result));
542
+ await persistQueue(local, room.locator, current.queue);
543
+ emitQueue(room.locator);
544
+ moveTo({
545
+ ...status,
546
+ attentionRequired: true
547
+ });
548
+ });
549
+ return null;
550
+ }
551
+ const from = replicas.get(room.locator)?.confirmed;
552
+ if (from === undefined) return null;
553
+ const pulled = await pullRefresh(authorized, from);
554
+ if (pulled.kind === 'revoked-authorization') {
555
+ refuse('revoked-authorization');
556
+ return 'stop';
557
+ }
558
+ if (pulled.kind === 'still-current') {
559
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, from, true));
560
+ if (!committed) {
561
+ progress.complete = false;
562
+ return null;
563
+ }
564
+ continue;
131
565
  }
132
- if (outcome.kind === 'room-not-readable') {
566
+ if (pulled.kind === 'advanced') {
567
+ const overlay = pulled.confirmed.record.roomVersion === result.roomVersion;
568
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, pulled.confirmed, overlay));
569
+ if (!committed) {
570
+ progress.complete = false;
571
+ return null;
572
+ }
573
+ continue;
574
+ }
575
+ if (pulled.kind === 'beyond-horizon') {
576
+ const bootstrapped = await pullBootstrap(authorized, room);
577
+ if (bootstrapped.kind === 'revoked-authorization') {
578
+ refuse('revoked-authorization');
579
+ return 'stop';
580
+ }
581
+ if (bootstrapped.kind === 'staged') {
582
+ const overlay = bootstrapped.confirmed.record.roomVersion === result.roomVersion;
583
+ const committed = await exclusive(()=>commitAccept(room, local, head, result, bootstrapped.confirmed, overlay));
584
+ if (!committed) {
585
+ progress.complete = false;
586
+ return null;
587
+ }
588
+ continue;
589
+ }
590
+ progress.complete = false;
591
+ if (bootstrapped.kind === 'transport-uncertainty') {
592
+ progress.reachable = false;
593
+ } else if (bootstrapped.kind === 'room-not-readable') {
594
+ unreadable.add(room.locator);
595
+ list(room, false);
596
+ emitRooms();
597
+ }
598
+ return null;
599
+ }
600
+ progress.complete = false;
601
+ if (pulled.kind === 'transport-uncertainty') {
602
+ progress.reachable = false;
603
+ } else if (pulled.kind === 'room-not-readable') {
133
604
  unreadable.add(room.locator);
134
605
  list(room, false);
135
606
  emitRooms();
607
+ }
608
+ return null;
609
+ }
610
+ return null;
611
+ }
612
+ async function synchronize(discovered, local, progress) {
613
+ for (const room of discovered){
614
+ if (closed || authorization === undefined) {
615
+ progress.complete = false;
616
+ return;
617
+ }
618
+ const withheld = await advance(room, local, authorization);
619
+ if (withheld !== null) {
620
+ progress.complete = false;
621
+ if (withheld.kind === 'transport-uncertainty') {
622
+ progress.reachable = false;
623
+ continue;
624
+ }
625
+ if (withheld.kind === 'room-not-readable') {
626
+ unreadable.add(room.locator);
627
+ list(room, false);
628
+ emitRooms();
629
+ continue;
630
+ }
631
+ if (withheld.kind === 'revoked-authorization') {
632
+ refuse('revoked-authorization');
633
+ return;
634
+ }
136
635
  continue;
137
636
  }
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();
637
+ if (await submitRoom(room, local, authorization, progress) === 'stop') return;
146
638
  }
147
639
  }
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;
640
+ async function cycle(local, progress) {
641
+ if (closed || authorization === undefined) {
642
+ notifyReady();
643
+ return;
644
+ }
645
+ moveTo(transferring(status));
646
+ let result;
647
+ try {
648
+ result = await options.adapter.discover(authorization);
649
+ } catch (error) {
650
+ journal.record('connection discover transport-uncertainty');
651
+ notifyReady();
652
+ throw error;
653
+ }
654
+ if (closed) {
655
+ notifyReady();
656
+ return;
657
+ }
658
+ journal.record(`connection discover ${result.kind}`);
659
+ if (result.kind === 'transport-uncertainty') {
660
+ progress.reachable = false;
661
+ notifyReady();
662
+ return;
663
+ }
153
664
  if (result.kind === 'revoked-authorization') {
154
665
  refuse('revoked-authorization');
666
+ notifyReady();
155
667
  return;
156
668
  }
157
669
  if (!result.supportedVersions.includes(ROOM_CONTRACT_VERSION)) {
158
670
  refuse('no-matching-version');
671
+ notifyReady();
159
672
  return;
160
673
  }
161
674
  if (result.mdModel.identity !== MD_MODEL.identity || result.mdModel.version !== MD_MODEL.version) {
162
675
  refuse('md-model-version-mismatch');
676
+ notifyReady();
163
677
  return;
164
678
  }
165
679
  const declared = new Set(result.capabilities.map((capability)=>capability.name));
166
680
  for (const required of options.requiredGuarantees){
167
681
  if (!knownCapabilities.has(required) || !declared.has(required)) {
168
682
  refuse('missing-required-guarantee');
683
+ notifyReady();
169
684
  return;
170
685
  }
171
686
  }
@@ -173,24 +688,57 @@ export function createMdRuntime(options) {
173
688
  refusal = null;
174
689
  emitRefusal();
175
690
  }
176
- applyDiscovery(result.rooms);
177
- await bootstrapMissing(result.rooms, local);
691
+ capabilities = result.capabilities;
692
+ notifyReady();
693
+ if (JSON.stringify(result.providerLimits) !== JSON.stringify(limits)) {
694
+ limits = result.providerLimits;
695
+ await local.declare(limits);
696
+ }
697
+ const listedLocators = applyDiscovery(result.rooms);
698
+ progress.complete = [
699
+ ...replicas.keys()
700
+ ].every((locator)=>listedLocators.has(locator));
701
+ await synchronize(result.rooms, local, progress);
178
702
  }
179
703
  async function ensureAuthorization() {
180
704
  if (authorization !== undefined) return;
181
- authorization = await options.adapter.authorize();
705
+ try {
706
+ authorization = await options.adapter.authorize();
707
+ journal.record('connection authorize');
708
+ } catch (error) {
709
+ journal.record('connection authorize transport-uncertainty');
710
+ throw error;
711
+ }
182
712
  }
183
713
  async function connect() {
184
- if (closed || discoveryPaused) return;
714
+ if (closed || discoveryPaused) {
715
+ notifyReady();
716
+ return;
717
+ }
185
718
  startTimer();
719
+ const progress = {
720
+ complete: false,
721
+ reachable: true
722
+ };
723
+ let completedAt = new Date().toISOString();
186
724
  try {
187
725
  const local = await openLocal();
188
726
  if (closed) return;
189
727
  await ensureAuthorization();
190
728
  if (closed) return;
191
- await cycle(local);
729
+ await cycle(local, progress);
730
+ if (closed) return;
731
+ if (progress.complete) {
732
+ completedAt = new Date().toISOString();
733
+ await local.complete(completedAt);
734
+ }
192
735
  } catch {
736
+ progress.complete = false;
737
+ notifyReady();
193
738
  return;
739
+ } finally{
740
+ notifyReady();
741
+ moveTo(settled(status, progress, completedAt, pending(), attention()));
194
742
  }
195
743
  }
196
744
  async function run() {
@@ -219,7 +767,9 @@ export function createMdRuntime(options) {
219
767
  store?.close();
220
768
  roomListeners.clear();
221
769
  refusalListeners.clear();
770
+ statusListeners.clear();
222
771
  querySubscriptions.clear();
772
+ queueSubscriptions.clear();
223
773
  }
224
774
  options.signal?.addEventListener('abort', close, {
225
775
  once: true
@@ -234,7 +784,56 @@ export function createMdRuntime(options) {
234
784
  return visible(request);
235
785
  },
236
786
  types (locator) {
237
- return replicas.get(locator)?.collection.types ?? [];
787
+ return replicas.get(locator)?.visible.collection.types ?? [];
788
+ },
789
+ syncStatus () {
790
+ return status;
791
+ },
792
+ draft: {
793
+ read (locator, path) {
794
+ return drafts.get(at(locator, path)) ?? null;
795
+ },
796
+ async replace (locator, path, content) {
797
+ const local = await openLocal();
798
+ await local.keep({
799
+ locator,
800
+ path,
801
+ content
802
+ });
803
+ drafts.set(at(locator, path), content);
804
+ },
805
+ async discard (locator, path) {
806
+ const local = await openLocal();
807
+ await local.discard(locator, path);
808
+ drafts.delete(at(locator, path));
809
+ }
810
+ },
811
+ save (locator, changes, replacing) {
812
+ return exclusive(()=>write(locator, changes, replacing));
813
+ },
814
+ outcome (identifier) {
815
+ const recorded = outcomes.get(identifier);
816
+ if (recorded !== undefined) return recorded;
817
+ for (const replica of replicas.values()){
818
+ if (replica.queue.some((batch)=>batch.id === identifier)) return {
819
+ kind: 'pending'
820
+ };
821
+ }
822
+ return null;
823
+ },
824
+ queue: {
825
+ discard (locator, identifier) {
826
+ return exclusive(()=>discardHead(locator, identifier));
827
+ }
828
+ },
829
+ history (locator, path, query) {
830
+ return readHistory(locator, path, query);
831
+ },
832
+ call (name, input) {
833
+ return invokeAction(name, input);
834
+ },
835
+ log () {
836
+ return journal.export();
238
837
  },
239
838
  observe (topic, second, third) {
240
839
  if (topic === 'rooms') {
@@ -253,6 +852,25 @@ export function createMdRuntime(options) {
253
852
  refusalListeners.delete(notify);
254
853
  };
255
854
  }
855
+ if (topic === 'sync-status') {
856
+ const notify = second;
857
+ statusListeners.add(notify);
858
+ notify(status);
859
+ return ()=>{
860
+ statusListeners.delete(notify);
861
+ };
862
+ }
863
+ if (topic === 'queue') {
864
+ const queue = {
865
+ locator: second,
866
+ notify: third
867
+ };
868
+ queueSubscriptions.add(queue);
869
+ queue.notify(entriesOf(queue.locator));
870
+ return ()=>{
871
+ queueSubscriptions.delete(queue);
872
+ };
873
+ }
256
874
  const subscription = {
257
875
  query: second,
258
876
  notify: third
@@ -266,6 +884,7 @@ export function createMdRuntime(options) {
266
884
  },
267
885
  async reauthorize () {
268
886
  if (refusal === null || !refusal.reauthorizationOffered) return;
887
+ journal.record('connection reauthorize');
269
888
  authorization = undefined;
270
889
  discoveryPaused = false;
271
890
  await enqueue();