@dxos/migrations 0.8.4-main.fffef41 → 0.9.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.
package/package.json CHANGED
@@ -1,12 +1,16 @@
1
1
  {
2
2
  "name": "@dxos/migrations",
3
- "version": "0.8.4-main.fffef41",
3
+ "version": "0.9.0",
4
4
  "description": "",
5
5
  "homepage": "https://dxos.org",
6
6
  "bugs": "https://github.com/dxos/dxos/issues",
7
- "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
8
12
  "author": "info@dxos.org",
9
- "sideEffects": true,
13
+ "sideEffects": false,
10
14
  "type": "module",
11
15
  "exports": {
12
16
  ".": {
@@ -17,26 +21,23 @@
17
21
  }
18
22
  },
19
23
  "types": "dist/types/src/index.d.ts",
20
- "typesVersions": {
21
- "*": {}
22
- },
23
24
  "files": [
24
25
  "dist",
25
26
  "src"
26
27
  ],
27
28
  "dependencies": {
28
- "@automerge/automerge": "3.1.2",
29
- "@automerge/automerge-repo": "2.4.0",
30
- "@dxos/client": "0.8.4-main.fffef41",
31
- "@dxos/echo": "0.8.4-main.fffef41",
32
- "@dxos/echo-db": "0.8.4-main.fffef41",
33
- "@dxos/echo-protocol": "0.8.4-main.fffef41",
34
- "@dxos/invariant": "0.8.4-main.fffef41",
35
- "@dxos/log": "0.8.4-main.fffef41",
36
- "@dxos/protocols": "0.8.4-main.fffef41",
37
- "@dxos/util": "0.8.4-main.fffef41"
29
+ "@automerge/automerge": "3.3.0-fragments.1",
30
+ "@automerge/automerge-repo": "2.6.0-subduction.23",
31
+ "@effect-atom/atom": "^0.5.3",
32
+ "effect": "3.21.3",
33
+ "@dxos/client": "0.9.0",
34
+ "@dxos/echo": "0.9.0",
35
+ "@dxos/echo-client": "0.9.0",
36
+ "@dxos/echo-protocol": "0.9.0",
37
+ "@dxos/invariant": "0.9.0",
38
+ "@dxos/keys": "0.9.0",
39
+ "@dxos/util": "0.9.0"
38
40
  },
39
- "devDependencies": {},
40
41
  "publishConfig": {
41
42
  "access": "public"
42
43
  }
@@ -0,0 +1,13 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import * as Schema from 'effect/Schema';
6
+
7
+ import { Annotation } from '@dxos/echo';
8
+
9
+ /** Migration version stored on space properties meta. */
10
+ export const MigrationVersionAnnotation = Annotation.make({
11
+ id: 'org.dxos.migrations.version',
12
+ schema: Schema.String,
13
+ });
@@ -0,0 +1,46 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, expect, test } from 'vitest';
6
+
7
+ import { Client } from '@dxos/client';
8
+ import { TestBuilder } from '@dxos/client/testing';
9
+ import { Filter, Obj } from '@dxos/echo';
10
+ import { TestSchema } from '@dxos/echo/testing';
11
+
12
+ import { compactDocumentsEpochMigration } from './document-compaction';
13
+
14
+ describe('document compaction', () => {
15
+ test('compacts linked documents and creates a new epoch', async () => {
16
+ const testBuilder = new TestBuilder();
17
+ const client = new Client({ services: testBuilder.createLocalClientServices() });
18
+ await client.initialize();
19
+ await client.halo.createIdentity();
20
+ await client.addTypes([TestSchema.Expando]);
21
+
22
+ try {
23
+ const space = await client.spaces.create();
24
+ await space.waitUntilReady();
25
+
26
+ const object = space.db.add(Obj.make(TestSchema.Expando, { title: 'before compaction' }));
27
+ await space.db.flush();
28
+
29
+ const epochsBefore = await space.internal.getEpochs();
30
+ const result = await compactDocumentsEpochMigration(space);
31
+
32
+ expect(result.compacted.length).toBeGreaterThan(0);
33
+ expect(result.epochNumber).toBe(epochsBefore.length);
34
+
35
+ const objects = await space.db.query(Filter.type(TestSchema.Expando)).run();
36
+ expect(objects).toHaveLength(1);
37
+ expect(objects[0]?.title).toBe('before compaction');
38
+ expect(objects[0]?.id).toBe(object.id);
39
+
40
+ const epochsAfter = await space.internal.getEpochs();
41
+ expect(epochsAfter.length).toBe(epochsBefore.length + 1);
42
+ } finally {
43
+ await client.destroy();
44
+ }
45
+ });
46
+ });
@@ -0,0 +1,43 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { type Space, SpaceState } from '@dxos/client/echo';
6
+ import { invariant } from '@dxos/invariant';
7
+
8
+ import { MigrationBuilder } from './migration-builder';
9
+
10
+ export type CompactDocumentsOptions = {
11
+ /**
12
+ * Entity ids whose linked Automerge documents should be compacted.
13
+ * Defaults to all ids in the space root `links` map.
14
+ */
15
+ objectIds?: string[];
16
+ };
17
+
18
+ export type CompactDocumentsResult = {
19
+ compacted: string[];
20
+ skipped: string[];
21
+ epochNumber: number;
22
+ };
23
+
24
+ /**
25
+ * Re-materializes linked object documents into fresh Automerge docs (no history) and commits
26
+ * a new space epoch with {@link CreateEpochRequest.Migration.REPLACE_AUTOMERGE_ROOT}.
27
+ */
28
+ export const compactDocumentsEpochMigration = async (
29
+ space: Space,
30
+ options: CompactDocumentsOptions = {},
31
+ ): Promise<CompactDocumentsResult> => {
32
+ invariant(space.state.get() === SpaceState.SPACE_READY, 'Space must be open and ready before compaction.');
33
+
34
+ const builder = new MigrationBuilder(space);
35
+ const { compacted, skipped } = await builder.compactLinkedDocuments(options.objectIds);
36
+ await builder._commit();
37
+
38
+ const epochs = await space.internal.getEpochs();
39
+ const lastEpoch = epochs[epochs.length - 1];
40
+ const epochNumber = lastEpoch?.subject.assertion.number ?? 0;
41
+
42
+ return { compacted, skipped, epochNumber };
43
+ };
package/src/index.ts CHANGED
@@ -2,7 +2,9 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- export type { ObjectStructure } from '@dxos/echo-protocol';
5
+ export type { EntityStructure } from '@dxos/echo-protocol';
6
6
 
7
+ export * from './annotations';
8
+ export * from './document-compaction';
7
9
  export * from './migration-builder';
8
10
  export * from './migrations';
@@ -2,22 +2,18 @@
2
2
  // Copyright 2024 DXOS.org
3
3
  //
4
4
 
5
- import { next as A, type Doc } from '@automerge/automerge';
5
+ import { next as A, type Doc, toJS } from '@automerge/automerge';
6
6
  import { type AnyDocumentId, type DocumentId } from '@automerge/automerge-repo';
7
7
  import type * as Schema from 'effect/Schema';
8
8
 
9
9
  import { type Space } from '@dxos/client/echo';
10
10
  import { CreateEpochRequest } from '@dxos/client/halo';
11
- import { requireTypeReference } from '@dxos/echo/internal';
12
- import { type DocHandleProxy, ObjectCore, type RepoProxy, migrateDocument } from '@dxos/echo-db';
13
- import {
14
- type DatabaseDirectory,
15
- type ObjectStructure,
16
- Reference,
17
- SpaceDocVersion,
18
- encodeReference,
19
- } from '@dxos/echo-protocol';
11
+ import { type DocHandleProxy, ObjectCore, type RepoProxy, migrateDocument } from '@dxos/echo-client/internal';
12
+ import { type DatabaseDirectory, EncodedReference, type EntityStructure, SpaceDocVersion } from '@dxos/echo-protocol';
13
+ import { getSchemaURI } from '@dxos/echo/internal';
14
+ import * as Type from '@dxos/echo/Type';
20
15
  import { invariant } from '@dxos/invariant';
16
+ import { EID, EntityId } from '@dxos/keys';
21
17
  import { type MaybePromise } from '@dxos/util';
22
18
 
23
19
  /*
@@ -42,7 +38,7 @@ export class MigrationBuilder {
42
38
  private readonly _repo: RepoProxy;
43
39
  private readonly _rootDoc: Doc<DatabaseDirectory>;
44
40
 
45
- // echoId -> automergeUrl
41
+ // echoUri -> automergeUrl
46
42
  private readonly _newLinks: Record<string, string> = {};
47
43
  private readonly _flushIds: DocumentId[] = [];
48
44
  private readonly _deleteObjects: string[] = [];
@@ -50,14 +46,13 @@ export class MigrationBuilder {
50
46
  private _newRoot?: DocHandleProxy<DatabaseDirectory> = undefined;
51
47
 
52
48
  constructor(private readonly _space: Space) {
53
- this._repo = this._space.db.coreDatabase._repo;
54
- // TODO(wittjosiah): Accessing private API.
55
- this._rootDoc = (this._space.db.coreDatabase as any)._automergeDocLoader
56
- .getSpaceRootDocHandle()
57
- .doc() as Doc<DatabaseDirectory>;
49
+ this._repo = this._space.internal.db._repo;
50
+ const rootDoc = this._space.internal.db._getSpaceRootDocHandle().doc();
51
+ invariant(rootDoc, 'Space root document must be available when creating MigrationBuilder');
52
+ this._rootDoc = rootDoc;
58
53
  }
59
54
 
60
- async findObject(id: string): Promise<ObjectStructure | undefined> {
55
+ async findObject(id: string): Promise<EntityStructure | undefined> {
61
56
  const documentId = (this._rootDoc.links?.[id] || this._newLinks[id])?.toString() as AnyDocumentId | undefined;
62
57
  const docHandle = documentId && this._repo.find(documentId);
63
58
  if (!docHandle) {
@@ -71,14 +66,15 @@ export class MigrationBuilder {
71
66
 
72
67
  async migrateObject(
73
68
  id: string,
74
- migrate: (objectStructure: ObjectStructure) => MaybePromise<{ schema: Schema.Schema.AnyNoContext; props: any }>,
69
+ migrate: (objectStructure: EntityStructure) => MaybePromise<{ type: Type.AnyEntity; props: any }>,
75
70
  ): Promise<void> {
76
71
  const objectStructure = await this.findObject(id);
77
72
  if (!objectStructure) {
78
73
  return;
79
74
  }
80
75
 
81
- const { schema, props } = await migrate(objectStructure);
76
+ const { type, props } = await migrate(objectStructure);
77
+ const schema = Type.getSchema(type);
82
78
 
83
79
  const oldHandle = await this._findObjectContainingHandle(id);
84
80
  invariant(oldHandle);
@@ -91,7 +87,7 @@ export class MigrationBuilder {
91
87
  objects: {
92
88
  [id]: {
93
89
  system: {
94
- type: encodeReference(requireTypeReference(schema)),
90
+ type: EncodedReference.fromURI(getSchemaURI(schema)!),
95
91
  },
96
92
  data: props,
97
93
  meta: {
@@ -102,26 +98,59 @@ export class MigrationBuilder {
102
98
  };
103
99
  const migratedDoc = migrateDocument(oldHandle.doc() as Doc<DatabaseDirectory>, newState);
104
100
  const newHandle = this._repo.import<DatabaseDirectory>(A.save(migratedDoc));
101
+ await newHandle.whenReady();
102
+ invariant(newHandle.url, 'Migrated document URL not available after whenReady');
105
103
  this._newLinks[id] = newHandle.url;
106
- this._addHandleToFlushList(newHandle);
104
+ this._addHandleToFlushList(newHandle.documentId!);
107
105
  }
108
106
 
109
- async addObject(schema: Schema.Schema.AnyNoContext, props: any): Promise<string> {
110
- const core = this._createObject({ schema, props });
107
+ async addObject(type: Type.AnyEntity, props: any): Promise<string> {
108
+ const resolved = Type.getSchema(type);
109
+ const core = await this._createObject({ schema: resolved, props });
111
110
  return core.id;
112
111
  }
113
112
 
114
113
  createReference(id: string) {
115
- return encodeReference(Reference.localObjectReference(id));
114
+ invariant(EntityId.isValid(id), 'Invalid EntityId.');
115
+ return EncodedReference.fromURI(EID.make({ entityId: id }));
116
116
  }
117
117
 
118
118
  deleteObject(id: string): void {
119
119
  this._deleteObjects.push(id);
120
120
  }
121
121
 
122
- changeProperties(changeFn: (properties: ObjectStructure) => void): void {
122
+ /**
123
+ * Re-materializes linked object documents into fresh Automerge docs without history.
124
+ * Call {@link _commit} to publish a new space epoch with updated root links.
125
+ */
126
+ async compactLinkedDocuments(objectIds?: string[]): Promise<{ compacted: string[]; skipped: string[] }> {
127
+ const linkIds = objectIds ?? Object.keys(this._rootDoc.links ?? {});
128
+ const compacted: string[] = [];
129
+ const skipped: string[] = [];
130
+
131
+ for (const id of linkIds) {
132
+ const oldHandle = await this._findObjectContainingHandle(id);
133
+ if (!oldHandle) {
134
+ skipped.push(id);
135
+ continue;
136
+ }
137
+
138
+ await oldHandle.whenReady();
139
+ const materialized = toJS(oldHandle.doc()!) as DatabaseDirectory;
140
+ const newHandle = this._repo.create<DatabaseDirectory>(materialized);
141
+ await newHandle.whenReady();
142
+ invariant(newHandle.url, 'Compacted document URL not available after whenReady');
143
+ this._newLinks[id] = newHandle.url;
144
+ this._addHandleToFlushList(newHandle.documentId!);
145
+ compacted.push(id);
146
+ }
147
+
148
+ return { compacted, skipped };
149
+ }
150
+
151
+ async changeProperties(changeFn: (properties: EntityStructure) => void): Promise<void> {
123
152
  if (!this._newRoot) {
124
- this._buildNewRoot();
153
+ await this._buildNewRoot();
125
154
  }
126
155
  invariant(this._newRoot, 'New root not created');
127
156
 
@@ -129,7 +158,8 @@ export class MigrationBuilder {
129
158
  const propertiesStructure = doc.objects?.[this._space.properties.id];
130
159
  propertiesStructure && changeFn(propertiesStructure);
131
160
  });
132
- this._addHandleToFlushList(this._newRoot);
161
+ await this._newRoot.whenReady();
162
+ this._addHandleToFlushList(this._newRoot.documentId!);
133
163
  }
134
164
 
135
165
  /**
@@ -137,13 +167,14 @@ export class MigrationBuilder {
137
167
  */
138
168
  async _commit(): Promise<void> {
139
169
  if (!this._newRoot) {
140
- this._buildNewRoot();
170
+ await this._buildNewRoot();
141
171
  }
142
172
  invariant(this._newRoot, 'New root not created');
143
173
 
144
174
  await this._space.db.flush();
145
175
 
146
176
  // Create new epoch.
177
+ invariant(this._newRoot.url, 'New root URL not available');
147
178
  await this._space.internal.createEpoch({
148
179
  migration: CreateEpochRequest.Migration.REPLACE_AUTOMERGE_ROOT,
149
180
  automergeRootUrl: this._newRoot.url,
@@ -161,7 +192,7 @@ export class MigrationBuilder {
161
192
  return docHandle;
162
193
  }
163
194
 
164
- private _buildNewRoot(): void {
195
+ private async _buildNewRoot(): Promise<void> {
165
196
  const links = { ...(this._rootDoc.links ?? {}) };
166
197
  for (const id of this._deleteObjects) {
167
198
  delete links[id];
@@ -179,10 +210,11 @@ export class MigrationBuilder {
179
210
  objects: this._rootDoc.objects,
180
211
  links,
181
212
  });
182
- this._addHandleToFlushList(this._newRoot);
213
+ await this._newRoot.whenReady();
214
+ this._addHandleToFlushList(this._newRoot.documentId!);
183
215
  }
184
216
 
185
- private _createObject({
217
+ private async _createObject({
186
218
  id,
187
219
  schema,
188
220
  props,
@@ -190,30 +222,31 @@ export class MigrationBuilder {
190
222
  id?: string;
191
223
  schema: Schema.Schema.AnyNoContext;
192
224
  props: any;
193
- }): ObjectCore {
225
+ }): Promise<ObjectCore> {
194
226
  const core = new ObjectCore();
195
227
  if (id) {
196
228
  core.id = id;
197
229
  }
198
230
 
199
231
  core.initNewObject(props);
200
- core.setType(requireTypeReference(schema));
232
+ core.setType(EncodedReference.fromURI(getSchemaURI(schema)!));
201
233
  const newHandle = this._repo.create<DatabaseDirectory>({
202
234
  version: SpaceDocVersion.CURRENT,
203
235
  access: {
204
236
  spaceKey: this._space.key.toHex(),
205
237
  },
206
238
  objects: {
207
- [core.id]: core.getDoc() as ObjectStructure,
239
+ [core.id]: core.getDoc() as EntityStructure,
208
240
  },
209
241
  });
210
- this._newLinks[core.id] = newHandle.url;
211
- this._addHandleToFlushList(newHandle);
242
+ await newHandle.whenReady();
243
+ this._newLinks[core.id] = newHandle.url!;
244
+ this._addHandleToFlushList(newHandle.documentId!);
212
245
 
213
246
  return core;
214
247
  }
215
248
 
216
- private _addHandleToFlushList(handle: DocHandleProxy<any>): void {
217
- this._flushIds.push(handle.documentId);
249
+ private _addHandleToFlushList(id: DocumentId): void {
250
+ this._flushIds.push(id);
218
251
  }
219
252
  }
@@ -2,31 +2,33 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import * as Option from 'effect/Option';
5
6
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
6
7
 
7
8
  import { Client } from '@dxos/client';
8
- import { Filter, type Space } from '@dxos/client/echo';
9
+ import { type Space } from '@dxos/client/echo';
9
10
  import { TestBuilder } from '@dxos/client/testing';
10
- import { Obj, Type } from '@dxos/echo';
11
- import { Expando } from '@dxos/echo/internal';
11
+ import { Annotation, Filter, Obj } from '@dxos/echo';
12
+ import { TestSchema } from '@dxos/echo/testing';
12
13
 
14
+ import { MigrationVersionAnnotation } from './annotations';
13
15
  import { Migrations } from './migrations';
14
16
 
15
17
  Migrations.define('test', [
16
18
  {
17
19
  version: '1970-01-01',
18
20
  next: async ({ builder }) => {
19
- await builder.addObject(Expando, { namespace: 'test', count: 1 });
21
+ await builder.addObject(TestSchema.Expando, { namespace: 'test', count: 1 });
20
22
  },
21
23
  },
22
24
  {
23
25
  version: '1970-01-02',
24
26
  next: async ({ space, builder }) => {
25
- // TODO(dmaretskyi): Is this intended to query only expando objects? Change to `Filter.type(Expando, { namespace: 'test' })`
26
- const { objects } = await space.db.query(Filter.props<any>({ namespace: 'test' })).run();
27
+ // TODO(dmaretskyi): Is this intended to query only expando objects? Change to `Filter.type(TestSchema.Expando, { namespace: 'test' })`
28
+ const objects = await space.db.query(Filter.props<any>({ namespace: 'test' })).run();
27
29
  for (const object of objects) {
28
30
  await builder.migrateObject(object.id, ({ data }) => ({
29
- schema: Expando,
31
+ type: TestSchema.Expando,
30
32
  props: { namespace: data.namespace, count: 2 },
31
33
  }));
32
34
  }
@@ -35,11 +37,11 @@ Migrations.define('test', [
35
37
  {
36
38
  version: '1970-01-03',
37
39
  next: async ({ space, builder }) => {
38
- // TODO(dmaretskyi): Is this intended to query only expando objects? Change to `Filter.type(Expando, { namespace: 'test' })`
39
- const { objects } = await space.db.query(Filter.props<any>({ namespace: 'test' })).run();
40
+ // TODO(dmaretskyi): Is this intended to query only expando objects? Change to `Filter.type(TestSchema.Expando, { namespace: 'test' })`
41
+ const objects = await space.db.query(Filter.props<any>({ namespace: 'test' })).run();
40
42
  for (const object of objects) {
41
43
  await builder.migrateObject(object.id, ({ data }) => ({
42
- schema: Expando,
44
+ type: TestSchema.Expando,
43
45
  props: { namespace: data.namespace, count: data.count * 3 },
44
46
  }));
45
47
  }
@@ -47,7 +49,8 @@ Migrations.define('test', [
47
49
  },
48
50
  ]);
49
51
 
50
- describe('Migrations', () => {
52
+ // Flaky. We wanna depreacate and rewrite migration builder anyway.
53
+ describe.skip('Migrations', () => {
51
54
  let client: Client;
52
55
  let space: Space;
53
56
 
@@ -68,34 +71,46 @@ describe('Migrations', () => {
68
71
 
69
72
  test('if no migrations have been run before, runs all migrations', async () => {
70
73
  await Migrations.migrate(space);
71
- const { objects } = await space.db.query(Filter.type(Expando, { namespace: 'test' })).run();
74
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { namespace: 'test' })).run();
72
75
  expect(objects).to.have.length(1);
73
76
  expect(objects[0].count).to.equal(6);
74
- expect(space.properties['test.version']).to.equal('1970-01-03');
77
+ expect(Annotation.get(space.properties, MigrationVersionAnnotation).pipe(Option.getOrUndefined)).to.equal(
78
+ '1970-01-03',
79
+ );
75
80
  });
76
81
 
77
82
  test('if some migrations have been run before, runs only the remaining migrations', async () => {
78
- space.properties['test.version'] = '1970-01-02';
79
- space.db.add(Obj.make(Type.Expando, { namespace: 'test', count: 5 }));
83
+ Obj.update(space.properties, (properties) => {
84
+ Annotation.set(properties, MigrationVersionAnnotation, '1970-01-02');
85
+ });
86
+ space.db.graph.registry.add([TestSchema.Expando]);
87
+ space.db.add(Obj.make(TestSchema.Expando, { namespace: 'test', count: 5 }));
88
+ await space.db.flush();
80
89
  await Migrations.migrate(space);
81
- const { objects } = await space.db.query(Filter.type(Expando, { namespace: 'test' })).run();
90
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { namespace: 'test' })).run();
82
91
  expect(objects).to.have.length(1);
83
92
  expect(objects[0].count).to.equal(15);
84
- expect(space.properties['test.version']).to.equal('1970-01-03');
93
+ expect(Annotation.get(space.properties, MigrationVersionAnnotation).pipe(Option.getOrUndefined)).to.equal(
94
+ '1970-01-03',
95
+ );
85
96
  });
86
97
 
87
98
  test('if all migrations have been run before, does nothing', async () => {
88
- space.properties['test.version'] = '1970-01-03';
99
+ Obj.update(space.properties, (properties) => {
100
+ Annotation.set(properties, MigrationVersionAnnotation, '1970-01-03');
101
+ });
89
102
  await Migrations.migrate(space);
90
- const { objects } = await space.db.query(Filter.type(Expando, { namespace: 'test' })).run();
103
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { namespace: 'test' })).run();
91
104
  expect(objects).to.have.length(0);
92
105
  });
93
106
 
94
107
  test('if target version is specified, runs only the migrations up to that version', async () => {
95
108
  await Migrations.migrate(space, '1970-01-02');
96
- const { objects } = await space.db.query(Filter.type(Expando, { namespace: 'test' })).run();
109
+ const objects = await space.db.query(Filter.type(TestSchema.Expando, { namespace: 'test' })).run();
97
110
  expect(objects).to.have.length(1);
98
111
  expect(objects[0].count).to.equal(2);
99
- expect(space.properties['test.version']).to.equal('1970-01-02');
112
+ expect(Annotation.get(space.properties, MigrationVersionAnnotation).pipe(Option.getOrUndefined)).to.equal(
113
+ '1970-01-02',
114
+ );
100
115
  });
101
116
  });
package/src/migrations.ts CHANGED
@@ -2,10 +2,16 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
- import { type Space, SpaceState, live } from '@dxos/client/echo';
5
+ import { Atom } from '@effect-atom/atom';
6
+ import * as Registry from '@effect-atom/atom/Registry';
7
+ import * as Option from 'effect/Option';
8
+
9
+ import { type Space, SpaceState } from '@dxos/client/echo';
10
+ import { Annotation, Obj } from '@dxos/echo';
6
11
  import { invariant } from '@dxos/invariant';
7
12
  import { type MaybePromise } from '@dxos/util';
8
13
 
14
+ import { MigrationVersionAnnotation } from './annotations';
9
15
  import { MigrationBuilder } from './migration-builder';
10
16
 
11
17
  export type MigrationContext = {
@@ -21,8 +27,12 @@ export type Migration = {
21
27
  export class Migrations {
22
28
  static namespace?: string;
23
29
  static migrations: Migration[] = [];
24
- private static _state = live<{ running: string[] }>({ running: [] });
30
+ private static _registry = Registry.make();
31
+ private static _stateAtom = Atom.make<{ running: string[] }>({ running: [] }).pipe(Atom.keepAlive);
25
32
 
33
+ /**
34
+ * @deprecated Use `MigrationVersionAnnotation` via `Annotation.get/set` on space properties.
35
+ */
26
36
  static get versionProperty() {
27
37
  return this.namespace && `${this.namespace}.version`;
28
38
  }
@@ -32,7 +42,8 @@ export class Migrations {
32
42
  }
33
43
 
34
44
  static running(space: Space): boolean {
35
- return this._state.running.includes(space.key.toHex());
45
+ const state = this._registry.get(this._stateAtom);
46
+ return state.running.includes(space.key.toHex());
36
47
  }
37
48
 
38
49
  static define(namespace: string, migrations: Migration[]): void {
@@ -42,9 +53,8 @@ export class Migrations {
42
53
 
43
54
  static async migrate(space: Space, targetVersion?: string | number): Promise<boolean> {
44
55
  invariant(!this.running(space), 'Migration already running');
45
- invariant(this.versionProperty, 'Migrations namespace not set');
46
56
  invariant(space.state.get() === SpaceState.SPACE_READY, 'Space not ready');
47
- const currentVersion = space.properties[this.versionProperty];
57
+ const currentVersion = Annotation.get(space.properties, MigrationVersionAnnotation).pipe(Option.getOrUndefined);
48
58
  const currentIndex = this.migrations.findIndex((m) => m.version === currentVersion) + 1;
49
59
  const i = this.migrations.findIndex((m) => m.version === targetVersion);
50
60
  const targetIndex = i === -1 ? this.migrations.length : i + 1;
@@ -52,20 +62,25 @@ export class Migrations {
52
62
  return false;
53
63
  }
54
64
 
55
- this._state.running.push(space.key.toHex());
56
- if (targetIndex > currentIndex) {
57
- const migrations = this.migrations.slice(currentIndex, targetIndex);
58
- for (const migration of migrations) {
59
- const builder = new MigrationBuilder(space);
60
- await migration.next({ space, builder });
61
- builder.changeProperties((propertiesStructure) => {
62
- invariant(this.versionProperty, 'Migrations namespace not set');
63
- propertiesStructure.data[this.versionProperty] = migration.version;
64
- });
65
- await builder._commit();
65
+ const spaceKey = space.key.toHex();
66
+ const currentState = this._registry.get(this._stateAtom);
67
+ this._registry.set(this._stateAtom, { running: [...currentState.running, spaceKey] });
68
+ try {
69
+ if (targetIndex > currentIndex) {
70
+ const migrations = this.migrations.slice(currentIndex, targetIndex);
71
+ for (const migration of migrations) {
72
+ const builder = new MigrationBuilder(space);
73
+ await migration.next({ space, builder });
74
+ await builder._commit();
75
+ Obj.update(space.properties, (properties) => {
76
+ Annotation.set(properties, MigrationVersionAnnotation, migration.version);
77
+ });
78
+ }
66
79
  }
80
+ } finally {
81
+ const finalState = this._registry.get(this._stateAtom);
82
+ this._registry.set(this._stateAtom, { running: finalState.running.filter((key) => key !== spaceKey) });
67
83
  }
68
- this._state.running.splice(this._state.running.indexOf(space.key.toHex()), 1);
69
84
 
70
85
  return true;
71
86
  }