@aerogel/plugin-local-first 0.0.0-next.004d5deedf15bedf609a660bfd4543d3c0df9d9d

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 (45) hide show
  1. package/dist/aerogel-plugin-local-first.d.ts +299 -0
  2. package/dist/aerogel-plugin-local-first.js +1661 -0
  3. package/dist/aerogel-plugin-local-first.js.map +1 -0
  4. package/package.json +50 -0
  5. package/src/components/Account.vue +9 -0
  6. package/src/components/AccountLoggedIn.vue +85 -0
  7. package/src/components/AccountLoggedOut.vue +26 -0
  8. package/src/components/AccountLoginModal.vue +36 -0
  9. package/src/components/AccountModal.vue +206 -0
  10. package/src/components/index.ts +5 -0
  11. package/src/components/settings/PurgeData.vue +43 -0
  12. package/src/components/settings/index.ts +10 -0
  13. package/src/index.css +1 -0
  14. package/src/index.ts +33 -0
  15. package/src/jobs/Backup.ts +98 -0
  16. package/src/jobs/Migrate.test.ts +217 -0
  17. package/src/jobs/Migrate.ts +186 -0
  18. package/src/jobs/Sync.test.ts +1198 -0
  19. package/src/jobs/Sync.ts +696 -0
  20. package/src/jobs/mixins/LoadsChildren.ts +229 -0
  21. package/src/jobs/mixins/LoadsTypeIndex.ts +25 -0
  22. package/src/jobs/mixins/TracksLocalModels.ts +32 -0
  23. package/src/lib/SyncQueue.ts +94 -0
  24. package/src/lib/inference.ts +124 -0
  25. package/src/lib/mirroring.ts +352 -0
  26. package/src/lib/utils.ts +21 -0
  27. package/src/services/Cloud.state.ts +111 -0
  28. package/src/services/Cloud.ts +494 -0
  29. package/src/services/DocumentsCache.ts +39 -0
  30. package/src/testing/cloud.ts +193 -0
  31. package/src/testing/mocks/Solid.mock.ts +34 -0
  32. package/src/testing/setup.ts +33 -0
  33. package/src/testing/stubs/LegacyTask.schema.ts +15 -0
  34. package/src/testing/stubs/Movie.schema.ts +15 -0
  35. package/src/testing/stubs/Movie.ts +3 -0
  36. package/src/testing/stubs/MoviesContainer.ts +20 -0
  37. package/src/testing/stubs/Person.schema.ts +9 -0
  38. package/src/testing/stubs/Person.ts +18 -0
  39. package/src/testing/stubs/Post.schema.ts +17 -0
  40. package/src/testing/stubs/Post.ts +18 -0
  41. package/src/testing/stubs/Task.schema.ts +12 -0
  42. package/src/testing/stubs/Task.ts +3 -0
  43. package/src/testing/stubs/TasksList.ts +20 -0
  44. package/src/testing/stubs/Workspace.ts +15 -0
  45. package/src/types/shims.d.ts +7 -0
@@ -0,0 +1,206 @@
1
+ <template>
2
+ <Modal
3
+ ref="$modalRef"
4
+ title-hidden
5
+ close-hidden
6
+ :title="$td('account.title', 'Account')"
7
+ >
8
+ <div class="relative flex items-center rounded-md bg-gray-100 p-4 pr-12">
9
+ <SolidAvatar class="mr-2 size-16 shrink-0" />
10
+ <div class="flex flex-col overflow-hidden">
11
+ <span class="font-semibold">{{ $solid.user?.name ?? $td('user.anonymous', 'Anonymous') }}</span>
12
+ <Link :href="$solid.user?.webId" class="mt-0.5 truncate text-sm font-light text-gray-500">
13
+ {{ $solid.user?.webId }}
14
+ </Link>
15
+ </div>
16
+ <Button
17
+ size="icon"
18
+ variant="ghost"
19
+ class="absolute top-1 right-1 hover:bg-gray-200"
20
+ :aria-label="$td('user.settings', 'Settings')"
21
+ :title="$td('user.settings', 'Settings')"
22
+ @click="$ui.modal(SettingsModal)"
23
+ >
24
+ <IconCog class="size-5" />
25
+ </Button>
26
+ </div>
27
+
28
+ <div class="px-2">
29
+ <div v-if="$cloud.syncing" class="mt-4 flex items-center gap-2">
30
+ <IconRefresh class="mt-0.5 size-6 shrink-0 animate-spin self-start text-green-500" />
31
+ <div class="flex w-full flex-col">
32
+ <Markdown lang-key="cloud.info.syncing" lang-default="Synchronizing..." />
33
+ <ProgressBar v-if="$cloud.syncJob" :job="$cloud.syncJob" filled-class="bg-green-500" />
34
+ </div>
35
+ </div>
36
+ <div v-else-if="$solid.loginOngoing" class="mt-4 flex items-center gap-2">
37
+ <IconRefresh class="mt-0.5 size-6 shrink-0 animate-spin self-start text-green-500" />
38
+ <Markdown lang-key="cloud.info.reconnecting" lang-default="Reconnecting..." />
39
+ </div>
40
+ <div v-else-if="error" class="mt-4 flex items-center gap-2">
41
+ <IconWarning class="mt-0.5 size-6 shrink-0 self-start text-red-500" />
42
+ <div>
43
+ <Markdown :text="errorDescription" />
44
+ <Link
45
+ v-if="showErrorDetails"
46
+ class="text-sm text-red-700 opacity-50 hover:opacity-75 focus-visible:opacity-75"
47
+ @click="$errors.inspect(error)"
48
+ >
49
+ {{ $td('errors.viewDetails', 'View details') }}
50
+ </Link>
51
+ </div>
52
+ </div>
53
+ <div v-else-if="!$cloud.ready" class="mt-4 flex items-center gap-2">
54
+ <IconWarning class="mt-0.5 size-6 shrink-0 self-start text-yellow-500" />
55
+ <Markdown lang-key="cloud.info.setup" lang-default="Your data is not backed up yet." />
56
+ </div>
57
+ <div v-else-if="$cloud.dirty" class="mt-4 flex items-center gap-2">
58
+ <IconWarning class="mt-0.5 size-6 shrink-0 self-start text-yellow-500" />
59
+ <Markdown
60
+ lang-key="cloud.info.changes"
61
+ lang-default="Everything is up to date. | There is one local change. | There are {n} local changes."
62
+ :lang-params="$cloud.localChanges"
63
+ />
64
+ </div>
65
+ <div v-else-if="$cloud.online" class="mt-4 flex items-center gap-2">
66
+ <IconCheckmarkOutline class="mt-0.5 size-6 shrink-0 self-start text-green-500" />
67
+ <Markdown lang-key="cloud.info.changes" lang-default="Everything is up to date." :lang-params="0" />
68
+ </div>
69
+ <div v-else class="mt-4 flex items-center gap-2">
70
+ <IconWarning class="mt-0.5 size-6 shrink-0 self-start text-yellow-500" />
71
+ <Markdown
72
+ lang-key="cloud.info.disconnected"
73
+ lang-default="You are disconnected from your Solid account, all the changes you make will remain in your device."
74
+ />
75
+ </div>
76
+ <AdvancedOptions class="mt-2">
77
+ <ul class="flex flex-col gap-2">
78
+ <li>
79
+ <Checkbox
80
+ v-model="$solid.autoReconnect"
81
+ label-class="text-base"
82
+ :label="$td('cloud.advanced.reconnect', 'Reconnect on startup')"
83
+ />
84
+ </li>
85
+ <li>
86
+ <Checkbox
87
+ v-model="$cloud.startupSync"
88
+ label-class="text-base"
89
+ :label="$td('cloud.advanced.startupSync', 'Synchronize on startup')"
90
+ />
91
+ </li>
92
+ <li>
93
+ <Checkbox v-model="$cloud.pollingEnabled" label-class="text-base">
94
+ <div class="flex items-center">
95
+ <span aria-hidden="true">{{ pollingText[0] }}</span>
96
+ <EditableContent
97
+ type="number"
98
+ :text="`${$cloud.pollingMinutes}`"
99
+ :aria-label="
100
+ $td('cloud.advanced.pollingMinutes', 'Synchronization interval (in minutes)')
101
+ "
102
+ class="focus-within:border-primary-600 mx-1 -mb-px border-b"
103
+ @update="$cloud.pollingMinutes = $event as number"
104
+ >
105
+ {{ $cloud.pollingMinutes }}
106
+ </EditableContent>
107
+ <span v-if="pollingText.length > 1" aria-hidden="true">{{ pollingText[1] }}</span>
108
+ </div>
109
+ </Checkbox>
110
+ </li>
111
+ </ul>
112
+ </AdvancedOptions>
113
+ </div>
114
+
115
+ <div v-if="!$solid.loginOngoing" class="mt-4 flex flex-row-reverse justify-start gap-2">
116
+ <Button
117
+ v-if="$cloud.syncing"
118
+ variant="secondary"
119
+ :disabled="cancellingSync"
120
+ @click="cancelSync()"
121
+ >
122
+ <IconStop class="size-5" />
123
+ <span>{{
124
+ cancellingSync ? $td('cloud.stopping', 'Stopping...') : $td('cloud.stop', 'Stop synchronization')
125
+ }}</span>
126
+ </Button>
127
+ <Button v-else-if="!$solid.isLoggedIn()" @click="$solid.reconnect({ force: true })">
128
+ <IconRefresh class="size-5" />
129
+ <span>{{ $td('cloud.reconnect', 'Reconnect') }}</span>
130
+ </Button>
131
+ <Button v-else-if="$cloud.ready" @click="$cloud.sync({ refreshUserProfile: true })">
132
+ <IconRefresh class="size-5" />
133
+ <span>{{ $td('cloud.sync', 'Synchronize') }}</span>
134
+ </Button>
135
+ <Button v-else @click="(($cloud.setupDismissed = false), $modal?.close())">
136
+ <IconCloudUpload class="size-5" />
137
+ <span>{{ $td('cloud.setup.submit', 'Back up now') }}</span>
138
+ </Button>
139
+ <Button v-if="!$cloud.syncing" variant="secondary" @click="$solid.logout()">
140
+ <IconLogout class="size-5" />
141
+ <span>{{ $cloud.ready ? $td('cloud.logOut', 'Log out') : $td('cloud.disconnect', 'Disconnect') }}</span>
142
+ </Button>
143
+ </div>
144
+ </Modal>
145
+ </template>
146
+
147
+ <script setup lang="ts">
148
+ import IconCheckmarkOutline from '~icons/zondicons/checkmark-outline';
149
+ import IconCloudUpload from '~icons/ic/sharp-cloud-upload';
150
+ import IconCog from '~icons/zondicons/cog';
151
+ import IconLogout from '~icons/material-symbols/logout-rounded';
152
+ import IconRefresh from '~icons/zondicons/refresh';
153
+ import IconStop from '~icons/ic/baseline-stop';
154
+ import IconWarning from '~icons/ion/warning';
155
+
156
+ import {
157
+ AdvancedOptions,
158
+ Button,
159
+ Checkbox,
160
+ EditableContent,
161
+ Link,
162
+ Markdown,
163
+ Modal,
164
+ ProgressBar,
165
+ SettingsModal,
166
+ translateWithDefault,
167
+ useEvent,
168
+ } from '@aerogel/core';
169
+ import { computed, ref, useTemplateRef } from 'vue';
170
+ import { Solid, SolidAvatar } from '@aerogel/plugin-solid';
171
+
172
+ import Cloud from '@aerogel/plugin-local-first/services/Cloud';
173
+
174
+ const $modal = useTemplateRef('$modalRef');
175
+ const cancellingSync = ref(false);
176
+ const pollingText = translateWithDefault('cloud.advanced.polling', 'Synchronize every {minutes} minutes', {
177
+ minutes: '%%separator%%',
178
+ }).split('%%separator%%');
179
+ const error = computed(() => Solid.error ?? Cloud.syncError);
180
+ const errorDescription = computed(() => {
181
+ if (!error.value) {
182
+ return;
183
+ }
184
+
185
+ if (typeof error.value === 'string') {
186
+ return error.value;
187
+ }
188
+
189
+ return translateWithDefault('cloud.info.error', 'There has been an error connecting.');
190
+ });
191
+ const showErrorDetails = computed(() => error.value && typeof error.value !== 'string');
192
+
193
+ async function cancelSync() {
194
+ if (!Cloud.syncJob) {
195
+ return;
196
+ }
197
+
198
+ cancellingSync.value = true;
199
+
200
+ await Promise.race([Cloud.syncJob.cancel(), Cloud.syncJob.completed]);
201
+
202
+ cancellingSync.value = false;
203
+ }
204
+
205
+ useEvent('auth:logout', () => $modal.value?.close());
206
+ </script>
@@ -0,0 +1,5 @@
1
+ export { default as Account } from './Account.vue';
2
+ export { default as AccountLoggedIn } from './AccountLoggedIn.vue';
3
+ export { default as AccountLoggedOut } from './AccountLoggedOut.vue';
4
+ export { default as AccountLoginModal } from './AccountLoginModal.vue';
5
+ export { default as AccountModal } from './AccountModal.vue';
@@ -0,0 +1,43 @@
1
+ <template>
2
+ <details v-if="!$solid.hasLoggedIn()" class="mt-4">
3
+ <summary>
4
+ <span class="text-base font-semibold">{{ $td('settings.dangerZone', 'Danger zone') }}</span>
5
+ </summary>
6
+ <Button variant="danger" class="mt-4" @click="purgeData()">
7
+ <IconTrash class="size-4" />
8
+ <span class="ml-1">{{ $td('settings.purge', 'Purge data') }}</span>
9
+ </Button>
10
+ </details>
11
+ </template>
12
+
13
+ <script setup lang="ts">
14
+ import IconTrash from '~icons/zondicons/trash';
15
+
16
+ import { Button, Storage, UI, translateWithDefault } from '@aerogel/core';
17
+
18
+ import AccountLoginModal from '@aerogel/plugin-local-first/components/AccountLoginModal.vue';
19
+
20
+ async function purgeData(): Promise<void> {
21
+ const confirmed = await UI.confirm(
22
+ translateWithDefault('settings.purgeConfirmTitle', 'Delete everything?'),
23
+ translateWithDefault(
24
+ 'settings.purgeConfirmMessage',
25
+ 'If you proceed, all your data will be deleted forever. ' +
26
+ 'If you want to save it before wiping this device, ' +
27
+ 'you can [connect to your Solid account](#action:connect) first.',
28
+ ),
29
+ {
30
+ acceptVariant: 'danger',
31
+ acceptText: translateWithDefault('settings.purgeConfirmAccept', 'Purge data'),
32
+ actions: { connect: () => UI.modal(AccountLoginModal) },
33
+ },
34
+ );
35
+
36
+ if (!confirmed) {
37
+ return;
38
+ }
39
+
40
+ await UI.closeAllModals();
41
+ await Storage.purge();
42
+ }
43
+ </script>
@@ -0,0 +1,10 @@
1
+ import { defineSettings } from '@aerogel/core';
2
+
3
+ import PurgeData from './PurgeData.vue';
4
+
5
+ export default defineSettings([
6
+ {
7
+ priority: 0,
8
+ component: PurgeData,
9
+ },
10
+ ]);
package/src/index.css ADDED
@@ -0,0 +1 @@
1
+ @source './';
package/src/index.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { App, bootServices } from '@aerogel/core';
2
+ import type { Plugin } from '@aerogel/core';
3
+
4
+ import Cloud from '@aerogel/plugin-local-first/services/Cloud';
5
+ import DocumentsCache from '@aerogel/plugin-local-first/services/DocumentsCache';
6
+
7
+ import settings from './components/settings';
8
+
9
+ const services = { $cloud: Cloud };
10
+
11
+ export { Cloud };
12
+ export * from './components';
13
+ export * from './services/Cloud';
14
+
15
+ export type LocalFirstServices = typeof services;
16
+
17
+ export default function localFirst(): Plugin {
18
+ return {
19
+ name: '@aerogel/local-first',
20
+ async install(app) {
21
+ settings.forEach((setting) => App.addSetting(setting));
22
+
23
+ await bootServices(app, {
24
+ ...services,
25
+ $_documentsCache: DocumentsCache,
26
+ });
27
+ },
28
+ };
29
+ }
30
+
31
+ declare module '@aerogel/core' {
32
+ interface Services extends LocalFirstServices {}
33
+ }
@@ -0,0 +1,98 @@
1
+ import { Job } from '@aerogel/core';
2
+ import { requireEngine } from 'soukai';
3
+ import { requireUrlParentDirectory } from '@noeldemartin/utils';
4
+ import { ignoreModelsCollection, trackModelsCollection } from '@aerogel/plugin-soukai';
5
+ import type { EngineDocument, IndexedDBEngine } from 'soukai';
6
+ import type { SolidModelConstructor } from 'soukai-solid';
7
+
8
+ interface Migration {
9
+ modelClass: SolidModelConstructor;
10
+ local: string;
11
+ remote: string;
12
+ }
13
+
14
+ export default class Backup extends Job {
15
+
16
+ private urlMigrations: Migration[] = [];
17
+ private collectionMigrations: Migration[] = [];
18
+
19
+ public migrateUrl(modelClass: SolidModelConstructor, local: string, remote: string): void {
20
+ this.urlMigrations.push({
21
+ modelClass,
22
+ local,
23
+ remote,
24
+ });
25
+ }
26
+
27
+ public migrateCollection(modelClass: SolidModelConstructor, local: string, remote: string): void {
28
+ this.collectionMigrations.push({
29
+ modelClass,
30
+ local,
31
+ remote,
32
+ });
33
+ }
34
+
35
+ protected async run(): Promise<void> {
36
+ const engine = requireEngine<IndexedDBEngine>();
37
+ const collections = await engine.getCollections();
38
+
39
+ for (const collection of collections) {
40
+ const documents = await engine.readMany(collection);
41
+
42
+ for (const [id, document] of Object.entries(documents)) {
43
+ const url = this.replaceUrl(id);
44
+
45
+ await engine.create(requireUrlParentDirectory(url), this.replaceDocumentUrls(document), url);
46
+ await engine.delete(collection, id);
47
+ }
48
+ }
49
+
50
+ for (const { modelClass, remote } of this.urlMigrations) {
51
+ trackModelsCollection(modelClass, requireUrlParentDirectory(remote), { refresh: false });
52
+ }
53
+
54
+ for (const { modelClass, local, remote } of this.collectionMigrations) {
55
+ ignoreModelsCollection(modelClass, local);
56
+ trackModelsCollection(modelClass, remote, { refresh: false });
57
+ }
58
+ }
59
+
60
+ protected replaceDocumentUrls(document: EngineDocument): EngineDocument {
61
+ for (const [key, value] of Object.entries(document)) {
62
+ if (key === '@id' && typeof value === 'string') {
63
+ document[key] = this.replaceUrl(value);
64
+
65
+ continue;
66
+ }
67
+
68
+ if (typeof value !== 'object' || value === null) {
69
+ continue;
70
+ }
71
+
72
+ document[key] = this.replaceDocumentUrls(value as EngineDocument);
73
+ }
74
+
75
+ return document;
76
+ }
77
+
78
+ protected replaceUrl(url: string): string {
79
+ for (const { local, remote } of this.urlMigrations) {
80
+ if (!url.startsWith(local)) {
81
+ continue;
82
+ }
83
+
84
+ return url.replace(local, remote);
85
+ }
86
+
87
+ for (const { local, remote } of this.collectionMigrations) {
88
+ if (!url.startsWith(local)) {
89
+ continue;
90
+ }
91
+
92
+ return url.replace(local, remote);
93
+ }
94
+
95
+ return url;
96
+ }
97
+
98
+ }
@@ -0,0 +1,217 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { Events } from '@aerogel/core';
3
+ import { FakeServer, fakeContainerUrl, fakeDocumentUrl } from '@noeldemartin/testing';
4
+ import { range } from '@noeldemartin/utils';
5
+ import { Solid } from '@aerogel/plugin-solid';
6
+
7
+ import Cloud from '@aerogel/plugin-local-first/services/Cloud';
8
+ import { getRemoteClass } from '@aerogel/plugin-local-first/lib/mirroring';
9
+
10
+ import Workspace from '@aerogel/plugin-local-first/testing/stubs/Workspace';
11
+ import SolidMock from '@aerogel/plugin-local-first/testing/mocks/Solid.mock';
12
+ import LegacyTaskSchema from '@aerogel/plugin-local-first/testing/stubs/LegacyTask.schema';
13
+ import TaskSchema from '@aerogel/plugin-local-first/testing/stubs/Task.schema';
14
+ import Task from '@aerogel/plugin-local-first/testing/stubs/Task';
15
+ import { legacyTaskResponse, setupCloudTests, typeIndexResponse } from '@aerogel/plugin-local-first/testing/cloud';
16
+
17
+ import Migrate from './Migrate';
18
+
19
+ describe('Migrate', () => {
20
+
21
+ beforeEach(setupCloudTests);
22
+
23
+ it('Migrates containers', async () => {
24
+ // Arrange
25
+ const updates: number[] = [];
26
+ const {
27
+ workspace,
28
+ typeIndexUrl,
29
+ documentUrls: [documentUrl],
30
+ } = await setupMigration(1);
31
+
32
+ // Act
33
+ await Cloud.migrate({ onUpdated: (progress) => updates.push(progress) });
34
+
35
+ // Assert - Progress
36
+ expect(updates).toEqual([0, 1]);
37
+
38
+ // Assert - Local models
39
+ expect(Task.rdfsClasses).toEqual(['https://schema.org/Action']);
40
+
41
+ const freshWorkspace = await workspace.fresh();
42
+
43
+ await freshWorkspace.loadRelation('tasks');
44
+
45
+ expect(freshWorkspace.tasks).toHaveLength(1);
46
+
47
+ // Assert - Remote models
48
+ expect(getRemoteClass(Task).rdfsClasses).toEqual(['https://schema.org/Action']);
49
+
50
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(3);
51
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(3);
52
+ expect(FakeServer.getRequests()).toHaveLength(6);
53
+
54
+ expect(FakeServer.getRequest('PATCH', documentUrl)?.body).toEqualSparql(`
55
+ DELETE DATA {
56
+ @prefix ical: <http://www.w3.org/2002/12/cal/ical#>.
57
+
58
+ <#it>
59
+ a ical:Vtodo ;
60
+ ical:summary "Migrate schemas" .
61
+ } ;
62
+
63
+ INSERT DATA {
64
+ @prefix schema: <https://schema.org/>.
65
+
66
+ <#it>
67
+ a schema:Action ;
68
+ schema:name "Migrate schemas" .
69
+ } .
70
+ `);
71
+
72
+ expect(FakeServer.getRequest('PATCH', typeIndexUrl)?.body).toEqualSparql(`
73
+ DELETE DATA {
74
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
75
+ @prefix ical: <http://www.w3.org/2002/12/cal/ical#>.
76
+
77
+ <#[[registration][.*]]> solid:forClass ical:Vtodo .
78
+ } ;
79
+
80
+ INSERT DATA {
81
+ @prefix solid: <http://www.w3.org/ns/solid/terms#>.
82
+ @prefix schema: <https://schema.org/>.
83
+
84
+ <#[[registration][.*]]> solid:forClass schema:Action .
85
+ } .
86
+ `);
87
+ });
88
+
89
+ it('Tracks progress', async () => {
90
+ // Arrange
91
+ const updates: number[] = [];
92
+
93
+ await setupMigration(5);
94
+
95
+ // Act
96
+ await Cloud.migrate({ onUpdated: (progress) => updates.push(progress) });
97
+
98
+ // Assert
99
+ expect(updates).toEqual([0, 0.2, 0.4, 0.6, 0.8, 1]);
100
+ });
101
+
102
+ it('Resumes after cancellation', async () => {
103
+ // Arrange
104
+ const updates: number[] = [];
105
+
106
+ await setupMigration(5);
107
+
108
+ // Act - Cancel
109
+ await Cloud.migrate({
110
+ onUpdated: (progress) => {
111
+ updates.push(progress);
112
+
113
+ if (progress > 0.5) {
114
+ Cloud.migrationJob?.cancel();
115
+ }
116
+ },
117
+ });
118
+
119
+ // Assert - Cancel
120
+ const job = Cloud.migrationJob;
121
+
122
+ expect(job?.cancelled).toBe(true);
123
+ expect(updates).toEqual([0, 0.2, 0.4, 0.6]);
124
+
125
+ // Act - Resume
126
+ await Cloud.migrate({ onUpdated: (progress) => updates.push(progress) });
127
+
128
+ // Assert - Resume
129
+ expect(job?.cancelled).toBe(false);
130
+ expect(Cloud.migrationJob).toBe(null);
131
+ expect(updates).toEqual([0, 0.2, 0.4, 0.6, 0.6, 0.8, 1]);
132
+ });
133
+
134
+ it('Resumes after going through serialization', async () => {
135
+ // Arrange
136
+ const updates: number[] = [];
137
+
138
+ await setupMigration(5);
139
+
140
+ // Act - Cancel and serialize
141
+ await Cloud.migrate({
142
+ onUpdated: (progress) => {
143
+ updates.push(progress);
144
+
145
+ if (progress > 0.5) {
146
+ Cloud.migrationJob?.cancel();
147
+ }
148
+ },
149
+ });
150
+
151
+ Cloud.migrationJob = Migrate.restore(JSON.parse(JSON.stringify(Cloud.migrationJob?.serialize())));
152
+
153
+ // Assert - Cancel and serialize
154
+ expect(Cloud.migrationJob?.models).toBeUndefined();
155
+ expect(updates).toEqual([0, 0.2, 0.4, 0.6]);
156
+
157
+ // Act - Resume
158
+ await Cloud.migrate({
159
+ onUpdated: (progress) => updates.push(progress),
160
+ });
161
+
162
+ // Assert - Resume
163
+ expect(Cloud.migrationJob).toBe(null);
164
+ expect(updates).toEqual([0, 0.2, 0.4, 0.6, 0.6, 0.8, 1]);
165
+ });
166
+
167
+ });
168
+
169
+ async function setupMigration(count: number = 1) {
170
+ // Arrange - Mint urls
171
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'tasks/';
172
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
173
+ const documentUrls = range(count).map(() => fakeDocumentUrl({ containerUrl })) as [string, ...string[]];
174
+
175
+ // Arrange - Prepare models
176
+ await Task.updateSchema(LegacyTaskSchema);
177
+
178
+ const workspace = await Workspace.at(parentContainerUrl).create({
179
+ url: containerUrl,
180
+ name: 'Tasks',
181
+ });
182
+
183
+ for (const documentUrl of documentUrls) {
184
+ await workspace.relatedTasks.create({
185
+ url: `${documentUrl}#it`,
186
+ name: 'Migrate schemas',
187
+ });
188
+ }
189
+
190
+ // Arrange - Prepare responses
191
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
192
+ const typeIndex = typeIndexResponse({ [containerUrl]: '<http://www.w3.org/2002/12/cal/ical#Vtodo>' });
193
+
194
+ FakeServer.respondOnce(typeIndexUrl, typeIndex);
195
+ documentUrls.forEach((documentUrl) => {
196
+ FakeServer.respondOnce(documentUrl, legacyTaskResponse('Migrate schemas'));
197
+ FakeServer.respondOnce(documentUrl, legacyTaskResponse('Migrate schemas'));
198
+ FakeServer.respondOnce(documentUrl);
199
+ });
200
+ FakeServer.respondOnce(typeIndexUrl, typeIndex);
201
+ FakeServer.respondOnce(typeIndexUrl);
202
+
203
+ // Arrange - Prepare service
204
+ Cloud.ready = true;
205
+
206
+ Cloud.registerSchemaMigration(Task, TaskSchema);
207
+
208
+ await Cloud.launch();
209
+ await Cloud.register(Workspace);
210
+ await Events.emit('application-ready');
211
+
212
+ return {
213
+ workspace,
214
+ typeIndexUrl,
215
+ documentUrls,
216
+ };
217
+ }