@dxos/client-services 0.5.9-main.bf0ae3e → 0.5.9-main.bf3bb8f

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 (51) hide show
  1. package/dist/lib/browser/{chunk-4IR3JP4U.mjs → chunk-IUSAD4RP.mjs} +1405 -824
  2. package/dist/lib/browser/chunk-IUSAD4RP.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +13 -4
  4. package/dist/lib/browser/index.mjs.map +1 -1
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/packlets/testing/index.mjs +10 -3
  7. package/dist/lib/browser/packlets/testing/index.mjs.map +1 -1
  8. package/dist/lib/node/{chunk-ZBIDLLZ4.cjs → chunk-5PALJZPW.cjs} +1534 -956
  9. package/dist/lib/node/chunk-5PALJZPW.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +53 -44
  11. package/dist/lib/node/index.cjs.map +1 -1
  12. package/dist/lib/node/meta.json +1 -1
  13. package/dist/lib/node/packlets/testing/index.cjs +17 -10
  14. package/dist/lib/node/packlets/testing/index.cjs.map +1 -1
  15. package/dist/types/src/packlets/identity/contacts-service.d.ts +14 -0
  16. package/dist/types/src/packlets/identity/contacts-service.d.ts.map +1 -0
  17. package/dist/types/src/packlets/identity/identity-service.d.ts.map +1 -1
  18. package/dist/types/src/packlets/invitations/invitations-handler.d.ts.map +1 -1
  19. package/dist/types/src/packlets/invitations/space-invitation-protocol.d.ts.map +1 -1
  20. package/dist/types/src/packlets/services/service-host.d.ts.map +1 -1
  21. package/dist/types/src/packlets/spaces/automerge-space-state.d.ts +4 -1
  22. package/dist/types/src/packlets/spaces/automerge-space-state.d.ts.map +1 -1
  23. package/dist/types/src/packlets/spaces/data-space-manager.d.ts +10 -1
  24. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  25. package/dist/types/src/packlets/spaces/data-space.d.ts +9 -9
  26. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  27. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts +23 -0
  28. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts.map +1 -0
  29. package/dist/types/src/packlets/spaces/spaces-service.d.ts +5 -2
  30. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  31. package/dist/types/src/packlets/storage/index.d.ts +1 -0
  32. package/dist/types/src/packlets/storage/index.d.ts.map +1 -1
  33. package/dist/types/src/packlets/storage/profile-archive.d.ts +14 -0
  34. package/dist/types/src/packlets/storage/profile-archive.d.ts.map +1 -0
  35. package/dist/types/src/version.d.ts +1 -1
  36. package/package.json +36 -36
  37. package/src/packlets/identity/contacts-service.ts +85 -0
  38. package/src/packlets/identity/identity-service.ts +45 -13
  39. package/src/packlets/invitations/invitations-handler.ts +13 -5
  40. package/src/packlets/invitations/space-invitation-protocol.ts +11 -32
  41. package/src/packlets/services/service-host.ts +12 -4
  42. package/src/packlets/spaces/automerge-space-state.ts +11 -2
  43. package/src/packlets/spaces/data-space-manager.ts +90 -16
  44. package/src/packlets/spaces/data-space.ts +78 -148
  45. package/src/packlets/spaces/epoch-migrations.ts +154 -0
  46. package/src/packlets/spaces/spaces-service.ts +56 -4
  47. package/src/packlets/storage/index.ts +1 -0
  48. package/src/packlets/storage/profile-archive.ts +111 -0
  49. package/src/version.ts +1 -1
  50. package/dist/lib/browser/chunk-4IR3JP4U.mjs.map +0 -7
  51. package/dist/lib/node/chunk-ZBIDLLZ4.cjs.map +0 -7
@@ -2,27 +2,25 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import { Event, asyncTimeout, scheduleTask, sleep, synchronized, trackLeaks } from '@dxos/async';
5
+ import { Event, Mutex, scheduleTask, sleep, synchronized, trackLeaks } from '@dxos/async';
6
6
  import { AUTH_TIMEOUT } from '@dxos/client-protocol';
7
7
  import { Context, ContextDisposedError, cancelWithContext } from '@dxos/context';
8
+ import type { SpecificCredential } from '@dxos/credentials';
8
9
  import { timed, warnAfterTimeout } from '@dxos/debug';
9
- import { type EchoHost } from '@dxos/echo-db';
10
- import {
11
- AutomergeDocumentLoaderImpl,
12
- createIdFromSpaceKey,
13
- createMappedFeedWriter,
14
- type MetadataStore,
15
- type Space,
16
- } from '@dxos/echo-pipeline';
17
- import { type ObjectStructure, type SpaceDoc } from '@dxos/echo-protocol';
18
- import { TYPE_PROPERTIES } from '@dxos/echo-schema';
10
+ import { type EchoHost, type DatabaseRoot } from '@dxos/echo-db';
11
+ import { createMappedFeedWriter, type MetadataStore, type Space } from '@dxos/echo-pipeline';
12
+ import { SpaceDocVersion } from '@dxos/echo-protocol';
19
13
  import { type FeedStore } from '@dxos/feed-store';
20
- import { failedInvariant, invariant } from '@dxos/invariant';
14
+ import { failedInvariant } from '@dxos/invariant';
21
15
  import { type Keyring } from '@dxos/keyring';
22
16
  import { PublicKey } from '@dxos/keys';
23
17
  import { log } from '@dxos/log';
24
18
  import { CancelledError, SystemError } from '@dxos/protocols';
25
- import { CreateEpochRequest, SpaceState, type Space as SpaceProto } from '@dxos/protocols/proto/dxos/client/services';
19
+ import {
20
+ type CreateEpochRequest,
21
+ SpaceState,
22
+ type Space as SpaceProto,
23
+ } from '@dxos/protocols/proto/dxos/client/services';
26
24
  import { type FeedMessage } from '@dxos/protocols/proto/dxos/echo/feed';
27
25
  import { type SpaceCache } from '@dxos/protocols/proto/dxos/echo/metadata';
28
26
  import {
@@ -36,10 +34,11 @@ import { type GossipMessage } from '@dxos/protocols/proto/dxos/mesh/teleport/gos
36
34
  import { type Gossip, type Presence } from '@dxos/teleport-extension-gossip';
37
35
  import { Timeframe } from '@dxos/timeframe';
38
36
  import { trace } from '@dxos/tracing';
39
- import { ComplexSet, assignDeep } from '@dxos/util';
37
+ import { ComplexSet } from '@dxos/util';
40
38
 
41
39
  import { AutomergeSpaceState } from './automerge-space-state';
42
40
  import { type SigningContext } from './data-space-manager';
41
+ import { runEpochMigration } from './epoch-migrations';
43
42
  import { NotarizationPlugin } from './notarization-plugin';
44
43
  import { TrustedKeySetAuthVerifier } from '../identity';
45
44
 
@@ -100,8 +99,12 @@ export class DataSpace {
100
99
  // TODO(dmaretskyi): Move into Space?
101
100
  private readonly _automergeSpaceState = new AutomergeSpaceState((rootUrl) => this._onNewAutomergeRoot(rootUrl));
102
101
 
102
+ private readonly _epochProcessingMutex = new Mutex();
103
+
103
104
  private _state = SpaceState.CLOSED;
104
105
 
106
+ private _databaseRoot: DatabaseRoot | null = null;
107
+
105
108
  /**
106
109
  * Error for _state === SpaceState.ERROR.
107
110
  */
@@ -183,6 +186,10 @@ export class DataSpace {
183
186
  return this._automergeSpaceState;
184
187
  }
185
188
 
189
+ get databaseRoot(): DatabaseRoot | null {
190
+ return this._databaseRoot;
191
+ }
192
+
186
193
  @trace.info({ depth: null })
187
194
  private get _automergeInfo() {
188
195
  return {
@@ -203,6 +210,7 @@ export class DataSpace {
203
210
  await this._gossip.open();
204
211
  await this._notarizationPlugin.open();
205
212
  await this._inner.spaceState.addCredentialProcessor(this._notarizationPlugin);
213
+ await this._automergeSpaceState.open();
206
214
  await this._inner.spaceState.addCredentialProcessor(this._automergeSpaceState);
207
215
  await this._inner.open(new Context());
208
216
  this._state = SpaceState.CONTROL_ONLY;
@@ -228,6 +236,7 @@ export class DataSpace {
228
236
 
229
237
  await this._inner.close();
230
238
  await this._inner.spaceState.removeCredentialProcessor(this._automergeSpaceState);
239
+ await this._automergeSpaceState.close();
231
240
  await this._inner.spaceState.removeCredentialProcessor(this._notarizationPlugin);
232
241
  await this._notarizationPlugin.close();
233
242
 
@@ -282,12 +291,15 @@ export class DataSpace {
282
291
  // Allow other tasks to run before loading the data pipeline.
283
292
  await sleep(1);
284
293
 
294
+ const ready = this.stateUpdate.waitForCondition(() => this._state === SpaceState.READY);
295
+
285
296
  this._automergeSpaceState.startProcessingRootDocs();
286
297
 
287
- // Wait for the first epoch.
288
- await cancelWithContext(this._ctx, this.automergeSpaceState.ensureEpochInitialized());
298
+ // TODO(dmaretskyi): Change so `initializeDataPipeline` doesn't wait for the space to be READY, but rather any state with a valid root.
299
+ await ready;
300
+ }
289
301
 
290
- log('data pipeline ready');
302
+ private async _enterReadyState() {
291
303
  await this._callbacks.beforeReady?.();
292
304
 
293
305
  this._state = SpaceState.READY;
@@ -374,11 +386,10 @@ export class DataSpace {
374
386
 
375
387
  private _onNewAutomergeRoot(rootUrl: string) {
376
388
  log('loading automerge root doc for space', { space: this.key, rootUrl });
377
- // Override share policy = true for the root document.
378
- // Workaround for https://github.com/automerge/automerge-repo/pull/292
379
- this._echoHost.replicateDocument(rootUrl);
389
+
380
390
  const handle = this._echoHost.automergeRepo.find(rootUrl as any);
381
391
 
392
+ // TODO(dmaretskyi): Make this single-threaded (but doc loading should still be parallel to not block epoch processing).
382
393
  queueMicrotask(async () => {
383
394
  try {
384
395
  await warnAfterTimeout(5_000, 'Automerge root doc load timeout (DataSpace)', async () => {
@@ -388,6 +399,10 @@ export class DataSpace {
388
399
  return;
389
400
  }
390
401
 
402
+ // Ensure only one root is processed at a time.
403
+ using _guard = await this._epochProcessingMutex.acquire();
404
+
405
+ // Attaching space keys to legacy documents.
391
406
  const doc = handle.docSync() ?? failedInvariant();
392
407
  if (!doc.access?.spaceKey) {
393
408
  handle.change((doc: any) => {
@@ -397,10 +412,19 @@ export class DataSpace {
397
412
 
398
413
  // TODO(dmaretskyi): Close roots.
399
414
  // TODO(dmaretskyi): How do we handle changing to the next EPOCH?
400
- if (!this._echoHost.roots.has(handle.documentId)) {
401
- await this._echoHost.openSpaceRoot(handle.url);
415
+ const root = await this._echoHost.openSpaceRoot(handle.url);
416
+
417
+ // NOTE: Make sure this assignment happens synchronously together with the state change.
418
+ this._databaseRoot = root;
419
+ if (root.getVersion() !== SpaceDocVersion.CURRENT) {
420
+ if (this._state !== SpaceState.REQUIRES_MIGRATION) {
421
+ this._state = SpaceState.REQUIRES_MIGRATION;
422
+ this.stateUpdate.emit();
423
+ }
402
424
  } else {
403
- log.warn('echo database root already exists', { space: this.key, rootUrl });
425
+ if (this._state !== SpaceState.READY) {
426
+ await this._enterReadyState();
427
+ }
404
428
  }
405
429
  } catch (err) {
406
430
  if (err instanceof ContextDisposedError) {
@@ -423,127 +447,46 @@ export class DataSpace {
423
447
  await this.inner.controlPipeline.writer.write({ credential: { credential } });
424
448
  }
425
449
 
426
- async createEpoch(options?: CreateEpochOptions) {
427
- let epoch: Epoch | undefined;
428
- switch (options?.migration) {
429
- case undefined:
430
- case CreateEpochRequest.Migration.NONE:
431
- {
432
- // TODO(dmaretskyi): Unify epoch construction.
433
- epoch = {
434
- previousId: this._automergeSpaceState.lastEpoch?.id,
435
- number: (this._automergeSpaceState.lastEpoch?.subject.assertion.number ?? -1) + 1,
436
- timeframe: this._automergeSpaceState.lastEpoch?.subject.assertion.timeframe ?? new Timeframe(),
437
- automergeRoot: this._automergeSpaceState.lastEpoch?.subject.assertion?.automergeRoot,
438
- };
439
- }
440
- break;
441
- case CreateEpochRequest.Migration.INIT_AUTOMERGE:
442
- {
443
- const document = this._echoHost.automergeRepo.create();
444
- // TODO(dmaretskyi): Unify epoch construction.
445
- epoch = {
446
- previousId: this._automergeSpaceState.lastEpoch?.id,
447
- number: (this._automergeSpaceState.lastEpoch?.subject.assertion.number ?? -1) + 1,
448
- timeframe: this._automergeSpaceState.lastEpoch?.subject.assertion.timeframe ?? new Timeframe(),
449
- automergeRoot: document.url,
450
- };
451
- }
452
- break;
453
- case CreateEpochRequest.Migration.PRUNE_AUTOMERGE_ROOT_HISTORY:
454
- {
455
- const currentRootUrl = this._automergeSpaceState.rootUrl;
456
- const rootHandle = this._echoHost.automergeRepo.find(currentRootUrl as any);
457
- await cancelWithContext(this._ctx, asyncTimeout(rootHandle.whenReady(), 10_000));
458
- const newRoot = this._echoHost.automergeRepo.create(rootHandle.docSync());
459
- await this._echoHost.automergeRepo.flush([newRoot.documentId]);
460
- invariant(typeof newRoot.url === 'string' && newRoot.url.length > 0);
461
- // TODO(dmaretskyi): Unify epoch construction.
462
- epoch = {
463
- previousId: this._automergeSpaceState.lastEpoch?.id,
464
- number: (this._automergeSpaceState.lastEpoch?.subject.assertion.number ?? -1) + 1,
465
- timeframe: this._automergeSpaceState.lastEpoch?.subject.assertion.timeframe ?? new Timeframe(),
466
- automergeRoot: newRoot.url,
467
- };
468
- }
469
- break;
470
- case CreateEpochRequest.Migration.FRAGMENT_AUTOMERGE_ROOT:
471
- {
472
- log.info('Fragmenting');
473
-
474
- const currentRootUrl = this._automergeSpaceState.rootUrl;
475
- const rootHandle = this._echoHost.automergeRepo.find<SpaceDoc>(currentRootUrl as any);
476
- await cancelWithContext(this._ctx, asyncTimeout(rootHandle.whenReady(), 10_000));
477
-
478
- // Find properties object.
479
- const objects = Object.entries((rootHandle.docSync() as SpaceDoc).objects!);
480
- const properties = findPropertiesObject(rootHandle.docSync() as SpaceDoc);
481
- const otherObjects = objects.filter(([key]) => key !== properties?.[0]);
482
- invariant(properties, 'Properties not found');
483
-
484
- // Create a new space doc with the properties object.
485
- const newSpaceDoc: SpaceDoc = { ...rootHandle.docSync(), objects: Object.fromEntries([properties]) };
486
- const newRoot = this._echoHost.automergeRepo.create(newSpaceDoc);
487
- invariant(typeof newRoot.url === 'string' && newRoot.url.length > 0);
488
-
489
- // Create new automerge documents for all objects.
490
- const docLoader = new AutomergeDocumentLoaderImpl(
491
- await createIdFromSpaceKey(this.key),
492
- this._echoHost.automergeRepo,
493
- this.key,
494
- );
495
- await docLoader.loadSpaceRootDocHandle(this._ctx, { rootUrl: newRoot.url });
496
-
497
- otherObjects.forEach(([key, value]) => {
498
- const handle = docLoader.createDocumentForObject(key);
499
- handle.change((doc: any) => {
500
- assignDeep(doc, ['objects', key], value);
501
- });
502
- });
503
-
504
- // TODO(mykola): Delete old root.
450
+ async createEpoch(options?: CreateEpochOptions): Promise<SpecificCredential<Epoch> | null> {
451
+ const ctx = this._ctx.derive();
505
452
 
506
- // TODO(dmaretskyi): Unify epoch construction.
507
- epoch = {
508
- previousId: this._automergeSpaceState.lastEpoch?.id,
509
- number: (this._automergeSpaceState.lastEpoch?.subject.assertion.number ?? -1) + 1,
510
- timeframe: this._automergeSpaceState.lastEpoch?.subject.assertion.timeframe ?? new Timeframe(),
511
- automergeRoot: newRoot.url,
512
- };
513
- }
514
- break;
515
- case CreateEpochRequest.Migration.REPLACE_AUTOMERGE_ROOT:
516
- {
517
- invariant(options.newAutomergeRoot);
518
- // TODO(dmaretskyi): Unify epoch construction.
519
- epoch = {
520
- previousId: this._automergeSpaceState.lastEpoch?.id,
521
- number: (this._automergeSpaceState.lastEpoch?.subject.assertion.number ?? -1) + 1,
522
- timeframe: this._automergeSpaceState.lastEpoch?.subject.assertion.timeframe ?? new Timeframe(),
523
- automergeRoot: options.newAutomergeRoot,
524
- };
525
- }
526
- break;
453
+ // Preserving existing behavior.
454
+ if (!options?.migration) {
455
+ return null;
527
456
  }
528
457
 
529
- if (!epoch) {
530
- return;
531
- }
458
+ const { newRoot } = await runEpochMigration(ctx, {
459
+ echoHost: this._echoHost,
460
+ spaceId: this.id,
461
+ spaceKey: this.key,
462
+ migration: options.migration,
463
+ currentRoot: this._automergeSpaceState.rootUrl ?? null,
464
+ newAutomergeRoot: options.newAutomergeRoot,
465
+ });
532
466
 
533
- const receipt = await this.inner.controlPipeline.writer.write({
534
- credential: {
535
- credential: await this._signingContext.credentialSigner.createCredential({
536
- subject: this.key,
537
- assertion: {
538
- '@type': 'dxos.halo.credentials.Epoch',
539
- ...epoch,
540
- },
541
- }),
467
+ const epoch: Epoch = {
468
+ previousId: this._automergeSpaceState.lastEpoch?.id,
469
+ number: (this._automergeSpaceState.lastEpoch?.subject.assertion.number ?? -1) + 1,
470
+ timeframe: this._automergeSpaceState.lastEpoch?.subject.assertion.timeframe ?? new Timeframe(),
471
+ automergeRoot: newRoot ?? this._automergeSpaceState.rootUrl,
472
+ };
473
+
474
+ const credential = (await this._signingContext.credentialSigner.createCredential({
475
+ subject: this.key,
476
+ assertion: {
477
+ '@type': 'dxos.halo.credentials.Epoch',
478
+ ...epoch,
542
479
  },
480
+ })) as SpecificCredential<Epoch>;
481
+
482
+ const receipt = await this.inner.controlPipeline.writer.write({
483
+ credential: { credential },
543
484
  });
544
485
 
545
486
  await this.inner.controlPipeline.state.waitUntilTimeframe(new Timeframe([[receipt.feedKey, receipt.seq]]));
546
487
  await this._echoHost.updateIndexes();
488
+
489
+ return credential;
547
490
  }
548
491
 
549
492
  @synchronized
@@ -572,16 +515,3 @@ export class DataSpace {
572
515
  this.stateUpdate.emit();
573
516
  }
574
517
  }
575
-
576
- /**
577
- * Assumes properties are at root.
578
- */
579
- export const findPropertiesObject = (spaceDoc: SpaceDoc): [string, ObjectStructure] | undefined => {
580
- for (const id in spaceDoc.objects ?? {}) {
581
- const obj = spaceDoc.objects![id];
582
- if (obj.system.type?.itemId === TYPE_PROPERTIES) {
583
- return [id, obj];
584
- }
585
- }
586
- return undefined;
587
- };
@@ -0,0 +1,154 @@
1
+ //
2
+ // Copyright 2024 DXOS.org
3
+ //
4
+
5
+ import type { AutomergeUrl } from '@dxos/automerge/automerge-repo';
6
+ import { type Context } from '@dxos/context';
7
+ import {
8
+ convertLegacyReferences,
9
+ convertLegacySpaceRootDoc,
10
+ findInlineObjectOfType,
11
+ migrateDocument,
12
+ type EchoHost,
13
+ } from '@dxos/echo-db';
14
+ import { SpaceDocVersion, type SpaceDoc } from '@dxos/echo-protocol';
15
+ import { TYPE_PROPERTIES } from '@dxos/echo-schema';
16
+ import { invariant } from '@dxos/invariant';
17
+ import type { PublicKey, SpaceId } from '@dxos/keys';
18
+ import { log } from '@dxos/log';
19
+ import { CreateEpochRequest } from '@dxos/protocols/proto/dxos/client/services';
20
+
21
+ export type MigrationContext = {
22
+ echoHost: EchoHost;
23
+
24
+ spaceId: SpaceId;
25
+ /**
26
+ * @deprecated Remove.
27
+ */
28
+ spaceKey: PublicKey;
29
+ migration: CreateEpochRequest.Migration;
30
+ currentRoot: string | null;
31
+
32
+ /**
33
+ * For set automerge root migration type.
34
+ */
35
+ newAutomergeRoot?: string;
36
+ };
37
+
38
+ export type MigrationResult = {
39
+ newRoot?: string;
40
+ };
41
+
42
+ const LOAD_DOC_TIMEOUT = 10_000;
43
+
44
+ export const runEpochMigration = async (ctx: Context, context: MigrationContext): Promise<MigrationResult> => {
45
+ switch (context.migration) {
46
+ case CreateEpochRequest.Migration.INIT_AUTOMERGE: {
47
+ const document = context.echoHost.createDoc();
48
+ await context.echoHost.flush();
49
+ return { newRoot: document.url };
50
+ }
51
+ case CreateEpochRequest.Migration.PRUNE_AUTOMERGE_ROOT_HISTORY: {
52
+ if (!context.currentRoot) {
53
+ throw new Error('Space does not have an automerge root');
54
+ }
55
+ const rootHandle = await context.echoHost.loadDoc(ctx, context.currentRoot as AutomergeUrl, {
56
+ timeout: LOAD_DOC_TIMEOUT,
57
+ });
58
+
59
+ const newRoot = context.echoHost.createDoc(rootHandle.docSync());
60
+ await context.echoHost.flush();
61
+ return { newRoot: newRoot.url };
62
+ }
63
+ case CreateEpochRequest.Migration.FRAGMENT_AUTOMERGE_ROOT: {
64
+ log.info('Fragmenting');
65
+
66
+ const currentRootUrl = context.currentRoot;
67
+ const rootHandle = await context.echoHost.loadDoc<SpaceDoc>(ctx, currentRootUrl as any, {
68
+ timeout: LOAD_DOC_TIMEOUT,
69
+ });
70
+
71
+ // Find properties object.
72
+ const objects = Object.entries((rootHandle.docSync() as SpaceDoc).objects!);
73
+ const properties = findInlineObjectOfType(rootHandle.docSync() as SpaceDoc, TYPE_PROPERTIES);
74
+ const otherObjects = objects.filter(([key]) => key !== properties?.[0]);
75
+ invariant(properties, 'Properties not found');
76
+
77
+ // Create a new space doc with the properties object.
78
+ const newRoot = context.echoHost.createDoc({
79
+ ...rootHandle.docSync(),
80
+ objects: Object.fromEntries([properties]),
81
+ });
82
+ invariant(typeof newRoot.url === 'string' && newRoot.url.length > 0);
83
+
84
+ // Create new automerge documents for all objects.
85
+ const newLinks: [string, AutomergeUrl][] = [];
86
+ for (const [id, objData] of otherObjects) {
87
+ const handle = context.echoHost.createDoc<SpaceDoc>({
88
+ version: SpaceDocVersion.CURRENT,
89
+ access: {
90
+ spaceKey: context.spaceKey.toHex(),
91
+ },
92
+ objects: {
93
+ [id]: objData,
94
+ },
95
+ });
96
+ newLinks.push([id, handle.url]);
97
+ }
98
+ newRoot.change((doc: SpaceDoc) => {
99
+ doc.links ??= {};
100
+ for (const [id, url] of newLinks) {
101
+ doc.links[id] = url;
102
+ }
103
+ });
104
+
105
+ await context.echoHost.flush();
106
+ return {
107
+ newRoot: newRoot.url,
108
+ };
109
+ }
110
+ case CreateEpochRequest.Migration.MIGRATE_REFERENCES_TO_DXN: {
111
+ const currentRootUrl = context.currentRoot;
112
+ const rootHandle = await context.echoHost.loadDoc<SpaceDoc>(ctx, currentRootUrl as any, {
113
+ timeout: LOAD_DOC_TIMEOUT,
114
+ });
115
+ invariant(rootHandle.docSync(), 'Root doc not found');
116
+
117
+ const newRootContent = await convertLegacySpaceRootDoc(structuredClone(rootHandle.docSync()!));
118
+
119
+ for (const [id, url] of Object.entries(newRootContent.links ?? {})) {
120
+ try {
121
+ const handle = await context.echoHost.loadDoc(ctx, url as any, { timeout: LOAD_DOC_TIMEOUT });
122
+ invariant(handle.docSync());
123
+ const newDoc = await convertLegacyReferences(structuredClone(handle.docSync()!));
124
+ const migratedDoc = migrateDocument(handle.docSync(), newDoc);
125
+ const newHandle = context.echoHost.createDoc(migratedDoc, { preserveHistory: true });
126
+ newRootContent.links![id] = newHandle.url;
127
+ } catch (err) {
128
+ log.warn('Failed to migrate reference', { id, url, error: err });
129
+ delete newRootContent.links![id];
130
+ }
131
+ }
132
+
133
+ const migratedRoot = migrateDocument(rootHandle.docSync(), newRootContent);
134
+ const newRoot = context.echoHost.createDoc(migratedRoot, { preserveHistory: true });
135
+
136
+ await context.echoHost.flush();
137
+ return {
138
+ newRoot: newRoot.url,
139
+ };
140
+ }
141
+ // TODO(dmaretskyi): This path doesn't seem to fit here. This is not a migration.
142
+ case CreateEpochRequest.Migration.REPLACE_AUTOMERGE_ROOT: {
143
+ invariant(context.newAutomergeRoot);
144
+
145
+ // Defensive programming - it should be the responsibility of the caller to flush the new root.
146
+ await context.echoHost.flush();
147
+ return {
148
+ newRoot: context.newAutomergeRoot,
149
+ };
150
+ }
151
+ }
152
+
153
+ return {};
154
+ };
@@ -30,6 +30,11 @@ import {
30
30
  type UpdateSpaceRequest,
31
31
  type WriteCredentialsRequest,
32
32
  type UpdateMemberRoleRequest,
33
+ type AdmitContactRequest,
34
+ type ContactAdmission,
35
+ type JoinSpaceResponse,
36
+ type JoinBySpaceKeyRequest,
37
+ type CreateEpochResponse,
33
38
  } from '@dxos/protocols/proto/dxos/client/services';
34
39
  import { type Credential } from '@dxos/protocols/proto/dxos/halo/credentials';
35
40
  import { type GossipMessage } from '@dxos/protocols/proto/dxos/mesh/teleport/gossip';
@@ -125,8 +130,18 @@ export class SpacesServiceImpl implements SpacesService {
125
130
  subscriptions.clear();
126
131
 
127
132
  for (const space of dataSpaceManager.spaces.values()) {
128
- // TODO(dmaretskyi): This can skip updates and not report intermediate states. Potential race condition here.
129
- subscriptions.add(space.stateUpdate.on(ctx, () => scheduler.forceTrigger()));
133
+ let lastState: SpaceState | undefined;
134
+ subscriptions.add(
135
+ space.stateUpdate.on(ctx, () => {
136
+ // Always send a separate update if the space state has changed.
137
+ if (space.state !== lastState) {
138
+ scheduler.forceTrigger();
139
+ } else {
140
+ scheduler.trigger();
141
+ }
142
+ lastState = space.state;
143
+ }),
144
+ );
130
145
 
131
146
  subscriptions.add(space.presence.updated.on(ctx, () => scheduler.trigger()));
132
147
  subscriptions.add(space.automergeSpaceState.onNewEpoch.on(ctx, () => scheduler.trigger()));
@@ -208,10 +223,45 @@ export class SpacesServiceImpl implements SpacesService {
208
223
  }
209
224
  }
210
225
 
211
- async createEpoch({ spaceKey, migration, automergeRootUrl }: CreateEpochRequest) {
226
+ async createEpoch({ spaceKey, migration, automergeRootUrl }: CreateEpochRequest): Promise<CreateEpochResponse> {
212
227
  const dataSpaceManager = await this._getDataSpaceManager();
213
228
  const space = dataSpaceManager.spaces.get(spaceKey) ?? raise(new SpaceNotFoundError(spaceKey));
214
- await space.createEpoch({ migration, newAutomergeRoot: automergeRootUrl });
229
+ const credential = await space.createEpoch({ migration, newAutomergeRoot: automergeRootUrl });
230
+ return { epochCredential: credential ?? undefined };
231
+ }
232
+
233
+ async admitContact(request: AdmitContactRequest): Promise<void> {
234
+ const dataSpaceManager = await this._getDataSpaceManager();
235
+ await dataSpaceManager.admitMember({
236
+ spaceKey: request.spaceKey,
237
+ identityKey: request.contact.identityKey,
238
+ role: request.role,
239
+ });
240
+ }
241
+
242
+ async joinBySpaceKey({ spaceKey }: JoinBySpaceKeyRequest): Promise<JoinSpaceResponse> {
243
+ const dataSpaceManager = await this._getDataSpaceManager();
244
+ const credential = await dataSpaceManager.requestSpaceAdmissionCredential(spaceKey);
245
+ return this._joinByAdmission({ credential });
246
+ }
247
+
248
+ private async _joinByAdmission({ credential }: ContactAdmission): Promise<JoinSpaceResponse> {
249
+ const assertion = getCredentialAssertion(credential);
250
+ invariant(assertion['@type'] === 'dxos.halo.credentials.SpaceMember', 'Invalid credential');
251
+ const myIdentity = this._identityManager.identity;
252
+ invariant(myIdentity && credential.subject.id.equals(myIdentity.identityKey));
253
+
254
+ const dataSpaceManager = await this._getDataSpaceManager();
255
+ let dataSpace = dataSpaceManager.spaces.get(assertion.spaceKey);
256
+ if (!dataSpace) {
257
+ dataSpace = await dataSpaceManager.acceptSpace({
258
+ spaceKey: assertion.spaceKey,
259
+ genesisFeedKey: assertion.genesisFeedKey,
260
+ });
261
+ await myIdentity.controlPipeline.writer.write({ credential: { credential } });
262
+ }
263
+
264
+ return { space: this._serializeSpace(dataSpace) };
215
265
  }
216
266
 
217
267
  private _serializeSpace(space: DataSpace): Space {
@@ -234,6 +284,8 @@ export class SpacesServiceImpl implements SpacesService {
234
284
  currentDataTimeframe: undefined,
235
285
  targetDataTimeframe: undefined,
236
286
  totalDataTimeframe: undefined,
287
+
288
+ spaceRootUrl: space.databaseRoot?.url,
237
289
  },
238
290
  members: Array.from(space.inner.spaceState.members.values()).map((member) => {
239
291
  const peers = space.presence.getPeersOnline().filter(({ identityKey }) => identityKey.equals(member.key));
@@ -4,3 +4,4 @@
4
4
 
5
5
  export * from './storage';
6
6
  export * from './level';
7
+ export * from './profile-archive';