@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/LICENSE +102 -5
- package/README.md +3 -3
- package/dist/lib/browser/index.mjs +147 -109
- package/dist/lib/browser/index.mjs.map +4 -4
- package/dist/lib/browser/meta.json +1 -1
- package/dist/lib/node-esm/index.mjs +147 -109
- package/dist/lib/node-esm/index.mjs.map +4 -4
- package/dist/lib/node-esm/meta.json +1 -1
- package/dist/types/src/annotations.d.ts +4 -0
- package/dist/types/src/annotations.d.ts.map +1 -0
- package/dist/types/src/document-compaction.d.ts +19 -0
- package/dist/types/src/document-compaction.d.ts.map +1 -0
- package/dist/types/src/document-compaction.test.d.ts +2 -0
- package/dist/types/src/document-compaction.test.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +3 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/migration-builder.d.ts +16 -8
- package/dist/types/src/migration-builder.d.ts.map +1 -1
- package/dist/types/src/migrations.d.ts +5 -1
- package/dist/types/src/migrations.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +18 -17
- package/src/annotations.ts +13 -0
- package/src/document-compaction.test.ts +46 -0
- package/src/document-compaction.ts +43 -0
- package/src/index.ts +3 -1
- package/src/migration-builder.ts +71 -38
- package/src/migrations.test.ts +36 -21
- package/src/migrations.ts +32 -17
package/package.json
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dxos/migrations",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"homepage": "https://dxos.org",
|
|
6
6
|
"bugs": "https://github.com/dxos/dxos/issues",
|
|
7
|
-
"
|
|
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":
|
|
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
|
|
29
|
-
"@automerge/automerge-repo": "2.
|
|
30
|
-
"@
|
|
31
|
-
"
|
|
32
|
-
"@dxos/
|
|
33
|
-
"@dxos/echo
|
|
34
|
-
"@dxos/
|
|
35
|
-
"@dxos/
|
|
36
|
-
"@dxos/
|
|
37
|
-
"@dxos/
|
|
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 {
|
|
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';
|
package/src/migration-builder.ts
CHANGED
|
@@ -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 {
|
|
12
|
-
import { type
|
|
13
|
-
import {
|
|
14
|
-
|
|
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
|
-
//
|
|
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.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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<
|
|
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:
|
|
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 {
|
|
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:
|
|
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(
|
|
110
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(
|
|
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
|
|
239
|
+
[core.id]: core.getDoc() as EntityStructure,
|
|
208
240
|
},
|
|
209
241
|
});
|
|
210
|
-
|
|
211
|
-
this.
|
|
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(
|
|
217
|
-
this._flushIds.push(
|
|
249
|
+
private _addHandleToFlushList(id: DocumentId): void {
|
|
250
|
+
this._flushIds.push(id);
|
|
218
251
|
}
|
|
219
252
|
}
|
package/src/migrations.test.ts
CHANGED
|
@@ -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 {
|
|
9
|
+
import { type Space } from '@dxos/client/echo';
|
|
9
10
|
import { TestBuilder } from '@dxos/client/testing';
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
79
|
-
|
|
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
|
|
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
|
|
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
|
|
99
|
+
Obj.update(space.properties, (properties) => {
|
|
100
|
+
Annotation.set(properties, MigrationVersionAnnotation, '1970-01-03');
|
|
101
|
+
});
|
|
89
102
|
await Migrations.migrate(space);
|
|
90
|
-
const
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|