@dxos/client-services 0.5.9-next.a50ff17 → 0.6.0

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 (48) hide show
  1. package/dist/lib/browser/{chunk-LCPF6KL6.mjs → chunk-IC4DRPNT.mjs} +953 -527
  2. package/dist/lib/browser/chunk-IC4DRPNT.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +13 -2
  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-L7MVHCXK.cjs → chunk-NPCEVOJK.cjs} +988 -562
  9. package/dist/lib/node/chunk-NPCEVOJK.cjs.map +7 -0
  10. package/dist/lib/node/index.cjs +53 -42
  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/data-space-manager.d.ts +10 -1
  22. package/dist/types/src/packlets/spaces/data-space-manager.d.ts.map +1 -1
  23. package/dist/types/src/packlets/spaces/data-space.d.ts.map +1 -1
  24. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts +2 -2
  25. package/dist/types/src/packlets/spaces/epoch-migrations.d.ts.map +1 -1
  26. package/dist/types/src/packlets/spaces/spaces-service.d.ts +4 -1
  27. package/dist/types/src/packlets/spaces/spaces-service.d.ts.map +1 -1
  28. package/dist/types/src/packlets/storage/index.d.ts +1 -0
  29. package/dist/types/src/packlets/storage/index.d.ts.map +1 -1
  30. package/dist/types/src/packlets/storage/profile-archive.d.ts +14 -0
  31. package/dist/types/src/packlets/storage/profile-archive.d.ts.map +1 -0
  32. package/dist/types/src/version.d.ts +1 -1
  33. package/dist/types/src/version.d.ts.map +1 -1
  34. package/package.json +36 -36
  35. package/src/packlets/identity/contacts-service.ts +85 -0
  36. package/src/packlets/identity/identity-service.ts +28 -22
  37. package/src/packlets/invitations/invitations-handler.ts +13 -5
  38. package/src/packlets/invitations/space-invitation-protocol.ts +11 -32
  39. package/src/packlets/services/service-host.ts +12 -4
  40. package/src/packlets/spaces/data-space-manager.ts +55 -2
  41. package/src/packlets/spaces/data-space.ts +8 -6
  42. package/src/packlets/spaces/epoch-migrations.ts +57 -38
  43. package/src/packlets/spaces/spaces-service.ts +52 -2
  44. package/src/packlets/storage/index.ts +1 -0
  45. package/src/packlets/storage/profile-archive.ts +111 -0
  46. package/src/version.ts +5 -1
  47. package/dist/lib/browser/chunk-LCPF6KL6.mjs.map +0 -7
  48. package/dist/lib/node/chunk-L7MVHCXK.cjs.map +0 -7
@@ -111,29 +111,35 @@ export class IdentityServiceImpl extends Resource implements IdentityService {
111
111
 
112
112
  const recordedDefaultSpaceTrigger = new Trigger();
113
113
 
114
- const allProcessed = safeAwaitAll(dataSpaceManager.spaces.values(), async (space) => {
115
- if (space.state === SpaceState.CLOSED) {
116
- await space.open();
117
-
118
- // Wait until the space is either READY or REQUIRES_MIGRATION.
119
- // NOTE: Space could potentially never initialize if the space data is corrupted.
120
- const requiresMigration = space.stateUpdate.waitForCondition(
121
- () => space.state === SpaceState.REQUIRES_MIGRATION,
122
- );
123
- await Promise.race([space.initializeDataPipeline(), requiresMigration]);
124
- }
125
- if (await dataSpaceManager.isDefaultSpace(space)) {
126
- if (foundDefaultSpace) {
127
- log.warn('Multiple default spaces found. Using the first one.', { duplicate: space.id });
128
- return;
114
+ const allProcessed = safeAwaitAll(
115
+ dataSpaceManager.spaces.values(),
116
+ async (space) => {
117
+ if (space.state === SpaceState.CLOSED) {
118
+ await space.open();
119
+
120
+ // Wait until the space is either READY or REQUIRES_MIGRATION.
121
+ // NOTE: Space could potentially never initialize if the space data is corrupted.
122
+ const requiresMigration = space.stateUpdate.waitForCondition(
123
+ () => space.state === SpaceState.REQUIRES_MIGRATION,
124
+ );
125
+ await Promise.race([space.initializeDataPipeline(), requiresMigration]);
129
126
  }
130
-
131
- foundDefaultSpace = true;
132
- await identity.updateDefaultSpace(space.id);
133
- recodedDefaultSpace = true;
134
- recordedDefaultSpaceTrigger.wake();
135
- }
136
- });
127
+ if (await dataSpaceManager.isDefaultSpace(space)) {
128
+ if (foundDefaultSpace) {
129
+ log.warn('Multiple default spaces found. Using the first one.', { duplicate: space.id });
130
+ return;
131
+ }
132
+
133
+ foundDefaultSpace = true;
134
+ await identity.updateDefaultSpace(space.id);
135
+ recodedDefaultSpace = true;
136
+ recordedDefaultSpaceTrigger.wake();
137
+ }
138
+ },
139
+ (err) => {
140
+ log.catch(err);
141
+ },
142
+ );
137
143
 
138
144
  // Wait for all spaces to be processed or until the default space is recorded.
139
145
  // If the timeout is reached, create a new default space.
@@ -435,11 +435,19 @@ export class InvitationsHandler {
435
435
  }
436
436
 
437
437
  private _logStateUpdate(invitation: Invitation, actor: any, newState: Invitation.State) {
438
- log('invitation state update', {
439
- actor: actor?.constructor.name,
440
- newState: stateToString(newState),
441
- oldState: stateToString(invitation.state),
442
- });
438
+ if (this._isNotTerminal(newState)) {
439
+ log('invitation state update', {
440
+ actor: actor?.constructor.name,
441
+ newState: stateToString(newState),
442
+ oldState: stateToString(invitation.state),
443
+ });
444
+ } else {
445
+ log.info('invitation state update', {
446
+ actor: actor?.constructor.name,
447
+ newState: stateToString(newState),
448
+ oldState: stateToString(invitation.state),
449
+ });
450
+ }
443
451
  }
444
452
 
445
453
  private _isNotTerminal(currentState: Invitation.State): boolean {
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  import {
6
- createAdmissionCredentials,
7
6
  createCancelDelegatedSpaceInvitationCredential,
8
7
  createDelegatedSpaceInvitationCredential,
9
8
  getCredentialAssertion,
@@ -21,7 +20,6 @@ import {
21
20
  SpaceNotFoundError,
22
21
  } from '@dxos/protocols';
23
22
  import { Invitation } from '@dxos/protocols/proto/dxos/client/services';
24
- import { type FeedMessage } from '@dxos/protocols/proto/dxos/echo/feed';
25
23
  import { SpaceMember, type ProfileDocument } from '@dxos/protocols/proto/dxos/halo/credentials';
26
24
  import {
27
25
  type AdmissionRequest,
@@ -73,41 +71,22 @@ export class SpaceInvitationProtocol implements InvitationProtocol {
73
71
  request: AdmissionRequest,
74
72
  guestProfile?: ProfileDocument | undefined,
75
73
  ): Promise<AdmissionResponse> {
76
- invariant(this._spaceKey);
77
- const space = this._spaceManager.spaces.get(this._spaceKey);
78
- invariant(space);
79
-
80
- invariant(request.space);
81
- const { identityKey, deviceKey } = request.space;
74
+ invariant(this._spaceKey && request.space);
75
+ log('writing guest credentials', { host: this._signingContext.deviceKey, guest: request.space.deviceKey });
82
76
 
83
- if (space.inner.spaceState.getMemberRole(identityKey) !== SpaceMember.Role.REMOVED) {
84
- throw new AlreadyJoinedError();
85
- }
86
-
87
- log('writing guest credentials', { host: this._signingContext.deviceKey, guest: deviceKey });
88
- // TODO(burdon): Check if already admitted.
89
- const credentials: FeedMessage.Payload[] = await createAdmissionCredentials(
90
- this._signingContext.credentialSigner,
91
- identityKey,
92
- space.key,
93
- space.inner.genesisFeedKey,
94
- invitation.role ?? SpaceMember.Role.ADMIN,
95
- space.inner.spaceState.membershipChainHeads,
96
- guestProfile,
97
- invitation.delegationCredentialId,
98
- );
99
-
100
- // TODO(dmaretskyi): Refactor.
101
- invariant(credentials[0].credential);
102
- const spaceMemberCredential = credentials[0].credential.credential;
103
- invariant(getCredentialAssertion(spaceMemberCredential)['@type'] === 'dxos.halo.credentials.SpaceMember');
104
-
105
- await writeMessages(space.inner.controlPipeline.writer, credentials);
77
+ const spaceMemberCredential = await this._spaceManager.admitMember({
78
+ spaceKey: this._spaceKey,
79
+ identityKey: request.space.identityKey,
80
+ role: invitation.role ?? SpaceMember.Role.ADMIN,
81
+ profile: guestProfile,
82
+ delegationCredentialId: invitation.delegationCredentialId,
83
+ });
106
84
 
85
+ const space = this._spaceManager.spaces.get(this._spaceKey);
107
86
  return {
108
87
  space: {
109
88
  credential: spaceMemberCredential,
110
- controlTimeframe: space.inner.controlPipeline.state.timeframe,
89
+ controlTimeframe: space?.inner.controlPipeline.state.timeframe,
111
90
  },
112
91
  };
113
92
  }
@@ -28,6 +28,7 @@ import {
28
28
  createDiagnostics,
29
29
  } from '../diagnostics';
30
30
  import { IdentityServiceImpl, type CreateIdentityOptions } from '../identity';
31
+ import { ContactsServiceImpl } from '../identity/contacts-service';
31
32
  import { InvitationsServiceImpl } from '../invitations';
32
33
  import { Lock, type ResourceLock } from '../locks';
33
34
  import { LoggingServiceImpl } from '../logging';
@@ -251,6 +252,11 @@ export class ClientServicesHost {
251
252
  this._runtimeParams,
252
253
  );
253
254
 
255
+ const dataSpaceManagerProvider = async () => {
256
+ await this._serviceContext.initialized.wait();
257
+ return this._serviceContext.dataSpaceManager!;
258
+ };
259
+
254
260
  const identityService = new IdentityServiceImpl(
255
261
  this._serviceContext.identityManager,
256
262
  this._serviceContext.keyring,
@@ -262,6 +268,11 @@ export class ClientServicesHost {
262
268
  this._serviceRegistry.setServices({
263
269
  SystemService: this._systemService,
264
270
  IdentityService: identityService,
271
+ ContactsService: new ContactsServiceImpl(
272
+ this._serviceContext.identityManager,
273
+ this._serviceContext.spaceManager,
274
+ dataSpaceManagerProvider,
275
+ ),
265
276
 
266
277
  InvitationsService: new InvitationsServiceImpl(this._serviceContext.invitationsManager),
267
278
 
@@ -270,10 +281,7 @@ export class ClientServicesHost {
270
281
  SpacesService: new SpacesServiceImpl(
271
282
  this._serviceContext.identityManager,
272
283
  this._serviceContext.spaceManager,
273
- async () => {
274
- await this._serviceContext.initialized.wait();
275
- return this._serviceContext.dataSpaceManager!;
276
- },
284
+ dataSpaceManagerProvider,
277
285
  ),
278
286
 
279
287
  DataService: this._serviceContext.echoHost.dataService,
@@ -11,6 +11,7 @@ import {
11
11
  getCredentialAssertion,
12
12
  type CredentialSigner,
13
13
  type DelegateInvitationCredential,
14
+ createAdmissionCredentials,
14
15
  type MemberInfo,
15
16
  } from '@dxos/credentials';
16
17
  import { convertLegacyReferences, findInlineObjectOfType, type EchoHost } from '@dxos/echo-db';
@@ -22,6 +23,7 @@ import {
22
23
  type SpaceProtocol,
23
24
  type SpaceProtocolSession,
24
25
  } from '@dxos/echo-pipeline';
26
+ import { CredentialServerExtension } from '@dxos/echo-pipeline';
25
27
  import {
26
28
  LEGACY_TYPE_PROPERTIES,
27
29
  SpaceDocVersion,
@@ -30,12 +32,12 @@ import {
30
32
  type SpaceDoc,
31
33
  } from '@dxos/echo-protocol';
32
34
  import { TYPE_PROPERTIES, generateEchoId, getTypeReference } from '@dxos/echo-schema';
33
- import { type FeedStore } from '@dxos/feed-store';
35
+ import { type FeedStore, writeMessages } from '@dxos/feed-store';
34
36
  import { invariant } from '@dxos/invariant';
35
37
  import { type Keyring } from '@dxos/keyring';
36
38
  import { PublicKey } from '@dxos/keys';
37
39
  import { log } from '@dxos/log';
38
- import { trace as Trace } from '@dxos/protocols';
40
+ import { trace as Trace, AlreadyJoinedError } from '@dxos/protocols';
39
41
  import { Invitation, SpaceState } from '@dxos/protocols/proto/dxos/client/services';
40
42
  import { type FeedMessage } from '@dxos/protocols/proto/dxos/echo/feed';
41
43
  import { type SpaceMetadata } from '@dxos/protocols/proto/dxos/echo/metadata';
@@ -84,6 +86,14 @@ export type AcceptSpaceOptions = {
84
86
  dataTimeframe?: Timeframe;
85
87
  };
86
88
 
89
+ export type AdmitMemberOptions = {
90
+ spaceKey: PublicKey;
91
+ identityKey: PublicKey;
92
+ role: SpaceMember.Role;
93
+ profile?: ProfileDocument;
94
+ delegationCredentialId?: PublicKey;
95
+ };
96
+
87
97
  export type DataSpaceManagerRuntimeParams = {
88
98
  spaceMemberPresenceAnnounceInterval?: number;
89
99
  spaceMemberPresenceOfflineTimeout?: number;
@@ -287,6 +297,35 @@ export class DataSpaceManager {
287
297
  return space;
288
298
  }
289
299
 
300
+ async admitMember(options: AdmitMemberOptions): Promise<Credential> {
301
+ const space = this._spaceManager.spaces.get(options.spaceKey);
302
+ invariant(space);
303
+
304
+ if (space.spaceState.getMemberRole(options.identityKey) !== SpaceMember.Role.REMOVED) {
305
+ throw new AlreadyJoinedError();
306
+ }
307
+
308
+ // TODO(burdon): Check if already admitted.
309
+ const credentials: FeedMessage.Payload[] = await createAdmissionCredentials(
310
+ this._signingContext.credentialSigner,
311
+ options.identityKey,
312
+ space.key,
313
+ space.genesisFeedKey,
314
+ options.role,
315
+ space.spaceState.membershipChainHeads,
316
+ options.profile,
317
+ options.delegationCredentialId,
318
+ );
319
+
320
+ // TODO(dmaretskyi): Refactor.
321
+ invariant(credentials[0].credential);
322
+ const spaceMemberCredential = credentials[0].credential.credential;
323
+ invariant(getCredentialAssertion(spaceMemberCredential)['@type'] === 'dxos.halo.credentials.SpaceMember');
324
+ await writeMessages(space.controlPipeline.writer, credentials);
325
+
326
+ return spaceMemberCredential;
327
+ }
328
+
290
329
  /**
291
330
  * Wait until the space data pipeline is fully initialized.
292
331
  * Used by invitation handler.
@@ -302,6 +341,19 @@ export class DataSpaceManager {
302
341
  );
303
342
  }
304
343
 
344
+ public async requestSpaceAdmissionCredential(spaceKey: PublicKey): Promise<Credential> {
345
+ return this._spaceManager.requestSpaceAdmissionCredential({
346
+ spaceKey,
347
+ identityKey: this._signingContext.identityKey,
348
+ timeout: 15_000,
349
+ swarmIdentity: {
350
+ peerKey: this._signingContext.deviceKey,
351
+ credentialProvider: createAuthProvider(this._signingContext.credentialSigner),
352
+ credentialAuthenticator: async () => true,
353
+ },
354
+ });
355
+ }
356
+
305
357
  private async _constructSpace(metadata: SpaceMetadata) {
306
358
  log('construct space', { metadata });
307
359
  const gossip = new Gossip({
@@ -331,6 +383,7 @@ export class DataSpaceManager {
331
383
  credentialAuthenticator: deferFunction(() => dataSpace.authVerifier.verifier),
332
384
  },
333
385
  onAuthorizedConnection: (session) => {
386
+ session.addExtension('dxos.mesh.teleport.admission-discovery', new CredentialServerExtension(space));
334
387
  session.addExtension(
335
388
  'dxos.mesh.teleport.gossip',
336
389
  gossip.createExtension({ remotePeerId: session.remotePeerId }),
@@ -386,9 +386,7 @@ export class DataSpace {
386
386
 
387
387
  private _onNewAutomergeRoot(rootUrl: string) {
388
388
  log('loading automerge root doc for space', { space: this.key, rootUrl });
389
- // Override share policy = true for the root document.
390
- // Workaround for https://github.com/automerge/automerge-repo/pull/292
391
- this._echoHost.replicateDocument(rootUrl);
389
+
392
390
  const handle = this._echoHost.automergeRepo.find(rootUrl as any);
393
391
 
394
392
  // TODO(dmaretskyi): Make this single-threaded (but doc loading should still be parallel to not block epoch processing).
@@ -415,10 +413,14 @@ export class DataSpace {
415
413
  // TODO(dmaretskyi): Close roots.
416
414
  // TODO(dmaretskyi): How do we handle changing to the next EPOCH?
417
415
  const root = await this._echoHost.openSpaceRoot(handle.url);
416
+
417
+ // NOTE: Make sure this assignment happens synchronously together with the state change.
418
418
  this._databaseRoot = root;
419
419
  if (root.getVersion() !== SpaceDocVersion.CURRENT) {
420
- this._state = SpaceState.REQUIRES_MIGRATION;
421
- this.stateUpdate.emit();
420
+ if (this._state !== SpaceState.REQUIRES_MIGRATION) {
421
+ this._state = SpaceState.REQUIRES_MIGRATION;
422
+ this.stateUpdate.emit();
423
+ }
422
424
  } else {
423
425
  if (this._state !== SpaceState.READY) {
424
426
  await this._enterReadyState();
@@ -454,7 +456,7 @@ export class DataSpace {
454
456
  }
455
457
 
456
458
  const { newRoot } = await runEpochMigration(ctx, {
457
- repo: this._echoHost.automergeRepo,
459
+ echoHost: this._echoHost,
458
460
  spaceId: this.id,
459
461
  spaceKey: this.key,
460
462
  migration: options.migration,
@@ -2,27 +2,25 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { asyncTimeout } from '@dxos/async';
6
- import { next as am } from '@dxos/automerge/automerge';
7
- import type { Repo, AutomergeUrl } from '@dxos/automerge/automerge-repo';
8
- import { cancelWithContext, type Context } from '@dxos/context';
5
+ import type { AutomergeUrl } from '@dxos/automerge/automerge-repo';
6
+ import { type Context } from '@dxos/context';
9
7
  import {
10
8
  convertLegacyReferences,
11
9
  convertLegacySpaceRootDoc,
12
10
  findInlineObjectOfType,
13
11
  migrateDocument,
12
+ type EchoHost,
14
13
  } from '@dxos/echo-db';
15
- import { AutomergeDocumentLoaderImpl } from '@dxos/echo-pipeline';
16
- import type { SpaceDoc } from '@dxos/echo-protocol';
14
+ import { SpaceDocVersion, type SpaceDoc } from '@dxos/echo-protocol';
17
15
  import { TYPE_PROPERTIES } from '@dxos/echo-schema';
18
16
  import { invariant } from '@dxos/invariant';
19
17
  import type { PublicKey, SpaceId } from '@dxos/keys';
20
18
  import { log } from '@dxos/log';
21
19
  import { CreateEpochRequest } from '@dxos/protocols/proto/dxos/client/services';
22
- import { assignDeep } from '@dxos/util';
23
20
 
24
21
  export type MigrationContext = {
25
- repo: Repo;
22
+ echoHost: EchoHost;
23
+
26
24
  spaceId: SpaceId;
27
25
  /**
28
26
  * @deprecated Remove.
@@ -41,30 +39,34 @@ export type MigrationResult = {
41
39
  newRoot?: string;
42
40
  };
43
41
 
42
+ const LOAD_DOC_TIMEOUT = 10_000;
43
+
44
44
  export const runEpochMigration = async (ctx: Context, context: MigrationContext): Promise<MigrationResult> => {
45
45
  switch (context.migration) {
46
46
  case CreateEpochRequest.Migration.INIT_AUTOMERGE: {
47
- const document = context.repo.create();
48
- await context.repo.flush();
47
+ const document = context.echoHost.createDoc();
48
+ await context.echoHost.flush();
49
49
  return { newRoot: document.url };
50
50
  }
51
51
  case CreateEpochRequest.Migration.PRUNE_AUTOMERGE_ROOT_HISTORY: {
52
52
  if (!context.currentRoot) {
53
53
  throw new Error('Space does not have an automerge root');
54
54
  }
55
- const rootHandle = context.repo.find(context.currentRoot as AutomergeUrl);
56
- await cancelWithContext(ctx, asyncTimeout(rootHandle.whenReady(), 10_000));
55
+ const rootHandle = await context.echoHost.loadDoc(ctx, context.currentRoot as AutomergeUrl, {
56
+ timeout: LOAD_DOC_TIMEOUT,
57
+ });
57
58
 
58
- const newRoot = context.repo.create(rootHandle.docSync());
59
- await context.repo.flush();
59
+ const newRoot = context.echoHost.createDoc(rootHandle.docSync());
60
+ await context.echoHost.flush();
60
61
  return { newRoot: newRoot.url };
61
62
  }
62
63
  case CreateEpochRequest.Migration.FRAGMENT_AUTOMERGE_ROOT: {
63
64
  log.info('Fragmenting');
64
65
 
65
66
  const currentRootUrl = context.currentRoot;
66
- const rootHandle = context.repo.find<SpaceDoc>(currentRootUrl as any);
67
- await cancelWithContext(ctx, asyncTimeout(rootHandle.whenReady(), 10_000));
67
+ const rootHandle = await context.echoHost.loadDoc<SpaceDoc>(ctx, currentRootUrl as any, {
68
+ timeout: LOAD_DOC_TIMEOUT,
69
+ });
68
70
 
69
71
  // Find properties object.
70
72
  const objects = Object.entries((rootHandle.docSync() as SpaceDoc).objects!);
@@ -73,48 +75,65 @@ export const runEpochMigration = async (ctx: Context, context: MigrationContext)
73
75
  invariant(properties, 'Properties not found');
74
76
 
75
77
  // Create a new space doc with the properties object.
76
- const newSpaceDoc: SpaceDoc = { ...rootHandle.docSync(), objects: Object.fromEntries([properties]) };
77
- const newRoot = context.repo.create(newSpaceDoc);
78
+ const newRoot = context.echoHost.createDoc({
79
+ ...rootHandle.docSync(),
80
+ objects: Object.fromEntries([properties]),
81
+ });
78
82
  invariant(typeof newRoot.url === 'string' && newRoot.url.length > 0);
79
83
 
80
84
  // Create new automerge documents for all objects.
81
- const docLoader = new AutomergeDocumentLoaderImpl(context.spaceId, context.repo, context.spaceKey);
82
- await docLoader.loadSpaceRootDocHandle(ctx, { rootUrl: newRoot.url });
83
-
84
- otherObjects.forEach(([key, value]) => {
85
- const handle = docLoader.createDocumentForObject(key);
86
- handle.change((doc: any) => {
87
- assignDeep(doc, ['objects', key], value);
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
+ },
88
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
+ }
89
103
  });
90
104
 
91
- await context.repo.flush();
105
+ await context.echoHost.flush();
92
106
  return {
93
107
  newRoot: newRoot.url,
94
108
  };
95
109
  }
96
110
  case CreateEpochRequest.Migration.MIGRATE_REFERENCES_TO_DXN: {
97
111
  const currentRootUrl = context.currentRoot;
98
- const rootHandle = context.repo.find<SpaceDoc>(currentRootUrl as any);
99
- await cancelWithContext(ctx, asyncTimeout(rootHandle.whenReady(), 10_000));
112
+ const rootHandle = await context.echoHost.loadDoc<SpaceDoc>(ctx, currentRootUrl as any, {
113
+ timeout: LOAD_DOC_TIMEOUT,
114
+ });
100
115
  invariant(rootHandle.docSync(), 'Root doc not found');
101
116
 
102
117
  const newRootContent = await convertLegacySpaceRootDoc(structuredClone(rootHandle.docSync()!));
103
118
 
104
119
  for (const [id, url] of Object.entries(newRootContent.links ?? {})) {
105
- const handle = context.repo.find(url as any);
106
- await cancelWithContext(ctx, asyncTimeout(handle.whenReady(), 10_000));
107
- invariant(handle.docSync(), 'Doc not found');
108
- const newDoc = await convertLegacyReferences(structuredClone(handle.docSync()!));
109
- const migratedDoc = migrateDocument(handle.docSync(), newDoc);
110
- const newHandle = context.repo.import(am.save(migratedDoc));
111
- newRootContent.links![id] = newHandle.url;
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
+ }
112
131
  }
113
132
 
114
133
  const migratedRoot = migrateDocument(rootHandle.docSync(), newRootContent);
115
- const newRoot = context.repo.import(am.save(migratedRoot));
134
+ const newRoot = context.echoHost.createDoc(migratedRoot, { preserveHistory: true });
116
135
 
117
- await context.repo.flush();
136
+ await context.echoHost.flush();
118
137
  return {
119
138
  newRoot: newRoot.url,
120
139
  };
@@ -124,7 +143,7 @@ export const runEpochMigration = async (ctx: Context, context: MigrationContext)
124
143
  invariant(context.newAutomergeRoot);
125
144
 
126
145
  // Defensive programming - it should be the responsibility of the caller to flush the new root.
127
- await context.repo.flush();
146
+ await context.echoHost.flush();
128
147
  return {
129
148
  newRoot: context.newAutomergeRoot,
130
149
  };
@@ -30,6 +30,10 @@ 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,
33
37
  type CreateEpochResponse,
34
38
  } from '@dxos/protocols/proto/dxos/client/services';
35
39
  import { type Credential } from '@dxos/protocols/proto/dxos/halo/credentials';
@@ -126,8 +130,18 @@ export class SpacesServiceImpl implements SpacesService {
126
130
  subscriptions.clear();
127
131
 
128
132
  for (const space of dataSpaceManager.spaces.values()) {
129
- // TODO(dmaretskyi): This can skip updates and not report intermediate states. Potential race condition here.
130
- 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
+ );
131
145
 
132
146
  subscriptions.add(space.presence.updated.on(ctx, () => scheduler.trigger()));
133
147
  subscriptions.add(space.automergeSpaceState.onNewEpoch.on(ctx, () => scheduler.trigger()));
@@ -216,6 +230,40 @@ export class SpacesServiceImpl implements SpacesService {
216
230
  return { epochCredential: credential ?? undefined };
217
231
  }
218
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) };
265
+ }
266
+
219
267
  private _serializeSpace(space: DataSpace): Space {
220
268
  return {
221
269
  id: space.id,
@@ -236,6 +284,8 @@ export class SpacesServiceImpl implements SpacesService {
236
284
  currentDataTimeframe: undefined,
237
285
  targetDataTimeframe: undefined,
238
286
  totalDataTimeframe: undefined,
287
+
288
+ spaceRootUrl: space.databaseRoot?.url,
239
289
  },
240
290
  members: Array.from(space.inner.spaceState.members.values()).map((member) => {
241
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';