@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.
- package/dist/aerogel-plugin-local-first.d.ts +299 -0
- package/dist/aerogel-plugin-local-first.js +1661 -0
- package/dist/aerogel-plugin-local-first.js.map +1 -0
- package/package.json +50 -0
- package/src/components/Account.vue +9 -0
- package/src/components/AccountLoggedIn.vue +85 -0
- package/src/components/AccountLoggedOut.vue +26 -0
- package/src/components/AccountLoginModal.vue +36 -0
- package/src/components/AccountModal.vue +206 -0
- package/src/components/index.ts +5 -0
- package/src/components/settings/PurgeData.vue +43 -0
- package/src/components/settings/index.ts +10 -0
- package/src/index.css +1 -0
- package/src/index.ts +33 -0
- package/src/jobs/Backup.ts +98 -0
- package/src/jobs/Migrate.test.ts +217 -0
- package/src/jobs/Migrate.ts +186 -0
- package/src/jobs/Sync.test.ts +1198 -0
- package/src/jobs/Sync.ts +696 -0
- package/src/jobs/mixins/LoadsChildren.ts +229 -0
- package/src/jobs/mixins/LoadsTypeIndex.ts +25 -0
- package/src/jobs/mixins/TracksLocalModels.ts +32 -0
- package/src/lib/SyncQueue.ts +94 -0
- package/src/lib/inference.ts +124 -0
- package/src/lib/mirroring.ts +352 -0
- package/src/lib/utils.ts +21 -0
- package/src/services/Cloud.state.ts +111 -0
- package/src/services/Cloud.ts +494 -0
- package/src/services/DocumentsCache.ts +39 -0
- package/src/testing/cloud.ts +193 -0
- package/src/testing/mocks/Solid.mock.ts +34 -0
- package/src/testing/setup.ts +33 -0
- package/src/testing/stubs/LegacyTask.schema.ts +15 -0
- package/src/testing/stubs/Movie.schema.ts +15 -0
- package/src/testing/stubs/Movie.ts +3 -0
- package/src/testing/stubs/MoviesContainer.ts +20 -0
- package/src/testing/stubs/Person.schema.ts +9 -0
- package/src/testing/stubs/Person.ts +18 -0
- package/src/testing/stubs/Post.schema.ts +17 -0
- package/src/testing/stubs/Post.ts +18 -0
- package/src/testing/stubs/Task.schema.ts +12 -0
- package/src/testing/stubs/Task.ts +3 -0
- package/src/testing/stubs/TasksList.ts +20 -0
- package/src/testing/stubs/Workspace.ts +15 -0
- 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>
|
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
|
+
}
|