@aerogel/plugin-local-first 0.0.0-next.4c77ee2164e8a4f72bdd920cb56ec059299ccc5f
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/InruptAuthenticator.lazy-BvpQLGoj-BvpQLGoj.js +9 -0
- package/dist/InruptAuthenticator.lazy-BvpQLGoj-BvpQLGoj.js.map +1 -0
- package/dist/LegacyAuthenticator.lazy-ChFFqP14-ChFFqP14.js +5 -0
- package/dist/LegacyAuthenticator.lazy-ChFFqP14-ChFFqP14.js.map +1 -0
- package/dist/aerogel-plugin-local-first.d.ts +269 -0
- package/dist/aerogel-plugin-local-first.js +1686 -0
- package/dist/aerogel-plugin-local-first.js.map +1 -0
- package/package.json +50 -0
- package/src/components/AGCloudStatus.vue +31 -0
- package/src/components/index.ts +3 -0
- package/src/index.ts +29 -0
- package/src/jobs/Backup.ts +98 -0
- package/src/jobs/Migrate.test.ts +217 -0
- package/src/jobs/Migrate.ts +179 -0
- package/src/jobs/Sync.test.ts +982 -0
- package/src/jobs/Sync.ts +590 -0
- package/src/jobs/mixins/LoadsChildren.ts +210 -0
- package/src/jobs/mixins/LoadsTypeIndex.ts +23 -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 +327 -0
- package/src/lib/utils.ts +21 -0
- package/src/services/Cloud.state.ts +111 -0
- package/src/services/Cloud.ts +450 -0
- package/src/services/DocumentsCache.ts +35 -0
- package/src/testing/cloud.ts +193 -0
- package/src/testing/mocks/Solid.mock.ts +34 -0
- package/src/testing/setup.ts +32 -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/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,450 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Semaphore,
|
|
3
|
+
after,
|
|
4
|
+
facade,
|
|
5
|
+
fail,
|
|
6
|
+
getLocationQueryParameter,
|
|
7
|
+
hasLocationQueryParameter,
|
|
8
|
+
isArray,
|
|
9
|
+
parseBoolean,
|
|
10
|
+
} from '@noeldemartin/utils';
|
|
11
|
+
import { App, Errors, Events, dispatch, translateWithDefault } from '@aerogel/core';
|
|
12
|
+
import { Solid } from '@aerogel/plugin-solid';
|
|
13
|
+
import { SolidModel, Tombstone, isContainer, isContainerClass } from 'soukai-solid';
|
|
14
|
+
import { getTrackedModels, trackModels, trackModelsCollection } from '@aerogel/plugin-soukai';
|
|
15
|
+
import { watchEffect } from 'vue';
|
|
16
|
+
import type { Authenticator } from '@aerogel/plugin-solid';
|
|
17
|
+
import type { Engine } from 'soukai';
|
|
18
|
+
import type { JobListener } from '@aerogel/core';
|
|
19
|
+
import type { SolidContainsRelation, SolidModelConstructor, SolidSchemaDefinition } from 'soukai-solid';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
clearLocalModelUpdates,
|
|
23
|
+
createRemoteModel,
|
|
24
|
+
getLocalModels,
|
|
25
|
+
getRemoteClass,
|
|
26
|
+
getRemoteContainersCollection,
|
|
27
|
+
updateRemoteModel,
|
|
28
|
+
} from '@aerogel/plugin-local-first/lib/mirroring';
|
|
29
|
+
import Backup from '@aerogel/plugin-local-first/jobs/Backup';
|
|
30
|
+
import Migrate from '@aerogel/plugin-local-first/jobs/Migrate';
|
|
31
|
+
import Sync from '@aerogel/plugin-local-first/jobs/Sync';
|
|
32
|
+
import SyncQueue from '@aerogel/plugin-local-first/lib/SyncQueue';
|
|
33
|
+
import {
|
|
34
|
+
getContainedModels,
|
|
35
|
+
getContainerRelations,
|
|
36
|
+
getRelatedClasses,
|
|
37
|
+
} from '@aerogel/plugin-local-first/lib/inference';
|
|
38
|
+
|
|
39
|
+
import Service, { CloudStatus } from './Cloud.state';
|
|
40
|
+
|
|
41
|
+
export interface RegisterOptions {
|
|
42
|
+
path?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface SyncOptions extends JobListener {
|
|
46
|
+
refreshUserProfile?: boolean;
|
|
47
|
+
model?: SolidModel;
|
|
48
|
+
models?: SolidModel[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class CloudService extends Service {
|
|
52
|
+
|
|
53
|
+
protected asyncLock: Semaphore = new Semaphore();
|
|
54
|
+
protected engine: Engine | null = null;
|
|
55
|
+
protected pollingInterval: NodeJS.Timeout | null = null;
|
|
56
|
+
|
|
57
|
+
public async whenReady<T>(callback: () => T): Promise<T> {
|
|
58
|
+
if (this.ready) {
|
|
59
|
+
return callback();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return new Promise((resolve) => {
|
|
63
|
+
const onReady = () => resolve(callback());
|
|
64
|
+
|
|
65
|
+
Events.once('cloud:ready', onReady);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public async setup(modelUrlMappings?: WeakMap<SolidModelConstructor, Record<string, string>>): Promise<void> {
|
|
70
|
+
this.setupOngoing = true;
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
await this.backup(modelUrlMappings);
|
|
74
|
+
await this.setReady(true);
|
|
75
|
+
await this.sync();
|
|
76
|
+
} finally {
|
|
77
|
+
this.setupOngoing = false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
public dismissSetup(): void {
|
|
82
|
+
this.setupDismissed = true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
public dismissMigration(): void {
|
|
86
|
+
this.migrationDismissed = true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
public postponeMigration(): void {
|
|
90
|
+
this.migrationPostponed = true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
public async syncIfOnline(options?: SyncOptions): Promise<void>;
|
|
94
|
+
public async syncIfOnline(model: SolidModel): Promise<void>;
|
|
95
|
+
public async syncIfOnline(models: SolidModel[]): Promise<void>;
|
|
96
|
+
public async syncIfOnline(config: SyncOptions | SolidModel | SolidModel[] = {}): Promise<void> {
|
|
97
|
+
if (!this.online) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await this.sync(config as SyncOptions);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public async sync(options?: SyncOptions): Promise<void>;
|
|
105
|
+
public async sync(model: SolidModel): Promise<void>;
|
|
106
|
+
public async sync(models: SolidModel[]): Promise<void>;
|
|
107
|
+
public async sync(config: SyncOptions | SolidModel | SolidModel[] = {}): Promise<void> {
|
|
108
|
+
const options = isArray(config)
|
|
109
|
+
? { models: config }
|
|
110
|
+
: config instanceof SolidModel
|
|
111
|
+
? { model: config }
|
|
112
|
+
: config;
|
|
113
|
+
|
|
114
|
+
if (!Solid.isLoggedIn() || !this.ready || !this.online) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
await this.asyncLock.run(async () => {
|
|
119
|
+
const start = Date.now();
|
|
120
|
+
const models = options.model ? [options.model] : options.models;
|
|
121
|
+
|
|
122
|
+
this.syncError = null;
|
|
123
|
+
this.status = CloudStatus.Syncing;
|
|
124
|
+
|
|
125
|
+
SyncQueue.clear(models);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await Events.emit('cloud:sync-started', models);
|
|
129
|
+
|
|
130
|
+
if (options.refreshUserProfile) {
|
|
131
|
+
await Solid.refreshUserProfile();
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
this.syncJob = new Sync(models);
|
|
135
|
+
|
|
136
|
+
this.syncJob.listeners.add(options);
|
|
137
|
+
|
|
138
|
+
await dispatch(this.syncJob);
|
|
139
|
+
|
|
140
|
+
if (this.syncJob.cancelled) {
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (!App.testing) {
|
|
145
|
+
await after({ milliseconds: Math.max(500, 1000 - (Date.now() - start)) });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this.syncJob = null;
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (App.testing) {
|
|
151
|
+
throw error;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
this.syncJob = null;
|
|
155
|
+
this.syncError = error;
|
|
156
|
+
|
|
157
|
+
await Errors.report(error, translateWithDefault('cloud.syncFailed', 'Sync failed'));
|
|
158
|
+
} finally {
|
|
159
|
+
this.status = CloudStatus.Online;
|
|
160
|
+
|
|
161
|
+
await Events.emit('cloud:sync-completed', models);
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
public shouldMigrate(): boolean {
|
|
167
|
+
return this.schemaMigrations.size > 0;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
public async migrate(listener?: JobListener): Promise<void> {
|
|
171
|
+
if (!this.online || !Solid.isLoggedIn() || !this.ready) {
|
|
172
|
+
throw new Error('Data cannot be migrated at the moment');
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (!this.shouldMigrate()) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
await this.asyncLock.run(async () => {
|
|
180
|
+
this.status = CloudStatus.Migrating;
|
|
181
|
+
this.migrationJob ??= new Migrate();
|
|
182
|
+
|
|
183
|
+
const start = Date.now();
|
|
184
|
+
const removeListeners: Function[] = [];
|
|
185
|
+
const models = (this.migrationJob.models ??= getLocalModels());
|
|
186
|
+
|
|
187
|
+
SyncQueue.clear(models);
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
listener && removeListeners.push(this.migrationJob.listeners.add(listener));
|
|
191
|
+
|
|
192
|
+
await Events.emit('cloud:migration-started', models);
|
|
193
|
+
await dispatch(this.migrationJob);
|
|
194
|
+
|
|
195
|
+
if (this.migrationJob.cancelled) {
|
|
196
|
+
await Events.emit('cloud:migration-cancelled', models);
|
|
197
|
+
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
this.migrationJob = null;
|
|
202
|
+
this.migrationDismissed = false;
|
|
203
|
+
this.migrationPostponed = false;
|
|
204
|
+
|
|
205
|
+
await after({ milliseconds: Math.max(0, 1000 - (Date.now() - start)) });
|
|
206
|
+
await Events.emit('cloud:migration-completed', models);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
await Errors.report(error, translateWithDefault('cloud.migrationFailed', 'Migration failed'));
|
|
209
|
+
} finally {
|
|
210
|
+
this.status = CloudStatus.Online;
|
|
211
|
+
|
|
212
|
+
removeListeners.forEach((removeListener) => removeListener?.());
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
public async register(modelClass: SolidModelConstructor, options: RegisterOptions = {}): Promise<void> {
|
|
218
|
+
if (this.registeredModels.find((registered) => modelClass === registered.modelClass)) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const engine = this.engine;
|
|
223
|
+
|
|
224
|
+
if (engine) {
|
|
225
|
+
getRelatedClasses(modelClass).forEach((relatedClass) => getRemoteClass(relatedClass).setEngine(engine));
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.whenReady(() => {
|
|
229
|
+
if (!isContainerClass(modelClass)) {
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
modelClass.collection = getRemoteContainersCollection(options.path);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
await trackModels(modelClass, {
|
|
237
|
+
created: (model) => createRemoteModel(model),
|
|
238
|
+
updated: (model) => updateRemoteModel(model),
|
|
239
|
+
deleted: (model) => clearLocalModelUpdates([model]),
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
if (isContainerClass(modelClass)) {
|
|
243
|
+
getContainerRelations(modelClass).forEach((relation) => {
|
|
244
|
+
const relatedClass = modelClass
|
|
245
|
+
.instance()
|
|
246
|
+
.requireRelation<SolidContainsRelation>(relation).relatedClass;
|
|
247
|
+
|
|
248
|
+
relatedClass.on('created', (model) => createRemoteModel(model));
|
|
249
|
+
relatedClass.on('updated', (model) => updateRemoteModel(model));
|
|
250
|
+
relatedClass.on('deleted', (model) => clearLocalModelUpdates([model]));
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
this.registeredModels.push({ modelClass, path: options.path });
|
|
255
|
+
|
|
256
|
+
for (const collection of this.modelCollections[modelClass.modelName] ?? []) {
|
|
257
|
+
await trackModelsCollection(modelClass, collection);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
public registerSchemaMigration(
|
|
262
|
+
modelClass: SolidModelConstructor,
|
|
263
|
+
schema: SolidModelConstructor | SolidSchemaDefinition,
|
|
264
|
+
): void {
|
|
265
|
+
this.schemaMigrations.set(modelClass, schema);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
public requireRemoteCollection(modelClass: SolidModelConstructor): string {
|
|
269
|
+
for (const registration of this.registeredModels) {
|
|
270
|
+
if (modelClass !== registration.modelClass) {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return getRemoteContainersCollection(registration.path);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
throw new Error(`Failed resolving remote collection for '${modelClass.modelName}' model`);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
protected override async boot(): Promise<void> {
|
|
281
|
+
await Solid.booted;
|
|
282
|
+
|
|
283
|
+
Solid.isLoggedIn() && this.login(Solid.authenticator);
|
|
284
|
+
|
|
285
|
+
Events.on('auth:login', ({ authenticator }) => this.login(authenticator));
|
|
286
|
+
Events.on('auth:logout', () => this.logout());
|
|
287
|
+
Events.once('application-ready', () => this.onApplicationReady());
|
|
288
|
+
Events.once('application-mounted', () => this.onApplicationMounted());
|
|
289
|
+
|
|
290
|
+
watchEffect(() => {
|
|
291
|
+
this.pollingInterval && clearInterval(this.pollingInterval);
|
|
292
|
+
|
|
293
|
+
if (!this.pollingEnabled || this.migrationJob) {
|
|
294
|
+
this.pollingInterval = null;
|
|
295
|
+
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
this.pollingInterval = setInterval(() => this.sync(), this.pollingMinutes * 60 * 1000);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
protected getDirtyLocalModels(): SolidModel[] {
|
|
304
|
+
const urls = Object.keys(this.localModelUpdates);
|
|
305
|
+
const dirtyLocalModels = [];
|
|
306
|
+
|
|
307
|
+
for (const { modelClass } of this.registeredModels) {
|
|
308
|
+
for (const model of getTrackedModels(modelClass)) {
|
|
309
|
+
dirtyLocalModels.push(...this.getModelsIn(urls, model));
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
return dirtyLocalModels;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
protected getModelsIn(urls: string[], model: SolidModel): SolidModel[] {
|
|
317
|
+
const models = [];
|
|
318
|
+
|
|
319
|
+
if (urls.includes(model.url)) {
|
|
320
|
+
models.push(model);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (isContainer(model)) {
|
|
324
|
+
models.push(
|
|
325
|
+
...getContainedModels(model)
|
|
326
|
+
.map((containedModel) => this.getModelsIn(urls, containedModel))
|
|
327
|
+
.flat(),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return models;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
protected requireEngine(): Engine {
|
|
335
|
+
return this.engine ?? fail('Could not get required Engine');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
protected async setReady(ready: boolean): Promise<void> {
|
|
339
|
+
if (this.ready || !ready) {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
await Events.emit('cloud:ready');
|
|
344
|
+
|
|
345
|
+
this.ready = true;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
protected async backup(modelUrlMappings?: WeakMap<SolidModelConstructor, Record<string, string>>): Promise<void> {
|
|
349
|
+
await Events.emit('cloud:backup-started');
|
|
350
|
+
|
|
351
|
+
modelUrlMappings ??= new WeakMap();
|
|
352
|
+
|
|
353
|
+
const job = new Backup();
|
|
354
|
+
|
|
355
|
+
for (const { modelClass, path } of this.registeredModels) {
|
|
356
|
+
if (!isContainerClass(modelClass)) {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
for (const [local, remote] of Object.entries(modelUrlMappings.get(modelClass) ?? {})) {
|
|
361
|
+
job.migrateUrl(modelClass, local, remote);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const localCollection = modelClass.collection;
|
|
365
|
+
const remoteCollection = getRemoteContainersCollection(path);
|
|
366
|
+
|
|
367
|
+
modelClass.collection = remoteCollection;
|
|
368
|
+
|
|
369
|
+
job.migrateCollection(modelClass, localCollection, remoteCollection);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
await dispatch(job);
|
|
373
|
+
await Events.emit('cloud:backup-completed');
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
protected async onApplicationReady(): Promise<void> {
|
|
377
|
+
if (this.ready || !Solid.isLoggedIn()) {
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
await this.setReady(!getLocalModels().some((model) => model.url.startsWith('solid://')));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
protected async onApplicationMounted(): Promise<void> {
|
|
385
|
+
if (!this.syncOnStartup()) {
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
await this.sync();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
protected login(authenticator: Authenticator): void {
|
|
393
|
+
const relatedClasses = [...this.registeredModels]
|
|
394
|
+
.map(({ modelClass }) => getRelatedClasses(modelClass))
|
|
395
|
+
.flat()
|
|
396
|
+
.concat([Tombstone]);
|
|
397
|
+
|
|
398
|
+
this.status = CloudStatus.Online;
|
|
399
|
+
this.engine = authenticator.engine;
|
|
400
|
+
|
|
401
|
+
for (const relatedClass of relatedClasses) {
|
|
402
|
+
getRemoteClass(relatedClass).setEngine(this.engine);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
protected logout(): void {
|
|
407
|
+
this.setState({
|
|
408
|
+
localModelUpdates: {},
|
|
409
|
+
migrationDismissed: false,
|
|
410
|
+
migrationJob: null,
|
|
411
|
+
migrationPostponed: false,
|
|
412
|
+
modelCollections: {},
|
|
413
|
+
pollingEnabled: true,
|
|
414
|
+
pollingMinutes: 10,
|
|
415
|
+
ready: false,
|
|
416
|
+
setupDismissed: false,
|
|
417
|
+
setupOngoing: false,
|
|
418
|
+
startupSync: true,
|
|
419
|
+
status: CloudStatus.Disconnected,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
private syncOnStartup(): boolean {
|
|
424
|
+
if (hasLocationQueryParameter('startupSync')) {
|
|
425
|
+
return parseBoolean(getLocationQueryParameter('startupSync'));
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (this.migrationJob) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return this.startupSync;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
export default facade(CloudService);
|
|
438
|
+
|
|
439
|
+
declare module '@aerogel/core' {
|
|
440
|
+
export interface EventsPayload {
|
|
441
|
+
'cloud:ready': void;
|
|
442
|
+
'cloud:backup-started': void;
|
|
443
|
+
'cloud:backup-completed': void;
|
|
444
|
+
'cloud:migration-started': SolidModel[];
|
|
445
|
+
'cloud:migration-cancelled': SolidModel[];
|
|
446
|
+
'cloud:migration-completed': SolidModel[];
|
|
447
|
+
'cloud:sync-started': SolidModel[] | undefined;
|
|
448
|
+
'cloud:sync-completed': SolidModel[] | undefined;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Events, Service } from '@aerogel/core';
|
|
2
|
+
import { IndexedDBMap, facade } from '@noeldemartin/utils';
|
|
3
|
+
|
|
4
|
+
export class DocumentsCacheService extends Service {
|
|
5
|
+
|
|
6
|
+
private cache = new IndexedDBMap<{ modifiedAt: number }>('documents-cache');
|
|
7
|
+
|
|
8
|
+
public async isFresh(documentUrl: string, modifiedAt: Date | number): Promise<boolean> {
|
|
9
|
+
const meta = await this.cache.get(documentUrl);
|
|
10
|
+
const modifiedAtTime = typeof modifiedAt !== 'number' ? modifiedAt.getTime() : modifiedAt;
|
|
11
|
+
|
|
12
|
+
return !!meta?.modifiedAt && meta.modifiedAt >= modifiedAtTime;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
public async remember(documentUrl: string, modifiedAt: Date | number): Promise<void> {
|
|
16
|
+
await this.cache.set(documentUrl, {
|
|
17
|
+
modifiedAt: typeof modifiedAt !== 'number' ? modifiedAt.getTime() : modifiedAt,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
public async forget(documentUrl: string): Promise<void> {
|
|
22
|
+
await this.cache.delete(documentUrl);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
protected override async boot(): Promise<void> {
|
|
26
|
+
Events.on('auth:logout', async () => {
|
|
27
|
+
await this.cache.clear();
|
|
28
|
+
|
|
29
|
+
this.cache.close();
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export default facade(DocumentsCacheService);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { App } from '@aerogel/core';
|
|
2
|
+
import { bootSolidModels } from 'soukai-solid';
|
|
3
|
+
import { uuid } from '@noeldemartin/utils';
|
|
4
|
+
import { InMemoryEngine, bootModels, resetModelListeners, setEngine } from 'soukai';
|
|
5
|
+
import { it } from 'vitest';
|
|
6
|
+
import { resetTrackedModels } from '@aerogel/plugin-soukai';
|
|
7
|
+
import { Solid } from '@aerogel/plugin-solid';
|
|
8
|
+
import type { App as AppInstance } from 'vue';
|
|
9
|
+
|
|
10
|
+
import Cloud from '@aerogel/plugin-local-first/services/Cloud';
|
|
11
|
+
|
|
12
|
+
import Movie from '@aerogel/plugin-local-first/testing/stubs/Movie';
|
|
13
|
+
import MoviesContainer from '@aerogel/plugin-local-first/testing/stubs/MoviesContainer';
|
|
14
|
+
import SolidMock from '@aerogel/plugin-local-first/testing/mocks/Solid.mock';
|
|
15
|
+
import Task from '@aerogel/plugin-local-first/testing/stubs/Task';
|
|
16
|
+
import TaskSchema from '@aerogel/plugin-local-first/testing/stubs/Task.schema';
|
|
17
|
+
import TasksList from '@aerogel/plugin-local-first/testing/stubs/TasksList';
|
|
18
|
+
import Workspace from '@aerogel/plugin-local-first/testing/stubs/Workspace';
|
|
19
|
+
import { FakeResponse, mock } from '@noeldemartin/testing';
|
|
20
|
+
|
|
21
|
+
export async function setupCloudTests(): Promise<void> {
|
|
22
|
+
bootSolidModels();
|
|
23
|
+
bootModels({ Movie, MoviesContainer, Task, TasksList, Workspace });
|
|
24
|
+
setEngine(new InMemoryEngine());
|
|
25
|
+
resetTrackedModels();
|
|
26
|
+
resetModelListeners();
|
|
27
|
+
|
|
28
|
+
await Task.updateSchema(TaskSchema);
|
|
29
|
+
|
|
30
|
+
App.instance = mock<AppInstance>({ config: { globalProperties: { $cloud: Cloud } } });
|
|
31
|
+
|
|
32
|
+
Solid.mock();
|
|
33
|
+
Cloud.reset();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function testVariants<T, Variants extends Record<string, T>>(
|
|
37
|
+
description: string,
|
|
38
|
+
variants: Variants,
|
|
39
|
+
test: (variantData: T, variantName: keyof Variants) => unknown,
|
|
40
|
+
options: { skip?: boolean; only?: boolean | keyof Variants } = {},
|
|
41
|
+
): void {
|
|
42
|
+
const only = typeof options.only === 'string' ? [options.only] : options.only ? Object.keys(variants) : [];
|
|
43
|
+
const skip = typeof options.skip === 'string' ? [options.skip] : options.skip ? Object.keys(variants) : [];
|
|
44
|
+
|
|
45
|
+
for (const [name, data] of Object.entries(variants)) {
|
|
46
|
+
if (only.includes(name)) {
|
|
47
|
+
it.only(`[${name}] ${description}`, () => test(data, name));
|
|
48
|
+
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (skip.includes(name)) {
|
|
53
|
+
it.skip(`[${name}] ${description}`, () => test(data, name));
|
|
54
|
+
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
it(`[${name}] ${description}`, () => test(data, name));
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function testRegisterVariants(
|
|
63
|
+
description: string,
|
|
64
|
+
test: (registerModels: () => unknown, variant: 'model registration' | 'container registration') => unknown,
|
|
65
|
+
options: { skip?: boolean; only?: boolean | 'model registration' | 'container registration' } = {},
|
|
66
|
+
): void {
|
|
67
|
+
testVariants(
|
|
68
|
+
description,
|
|
69
|
+
{
|
|
70
|
+
'model registration': () => Cloud.register(Movie),
|
|
71
|
+
'container registration': () => Cloud.register(MoviesContainer),
|
|
72
|
+
},
|
|
73
|
+
test,
|
|
74
|
+
options,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function typeIndexResponse(containers?: Record<string, string>): Response {
|
|
79
|
+
const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
|
|
80
|
+
const registrations = Object.entries(containers ?? []).map(
|
|
81
|
+
([containerUrl, forClass]) => `
|
|
82
|
+
<#${uuid()}>
|
|
83
|
+
a solid:TypeRegistration ;
|
|
84
|
+
solid:forClass ${forClass} ;
|
|
85
|
+
solid:instanceContainer <${containerUrl}> .
|
|
86
|
+
`,
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return FakeResponse.success(`
|
|
90
|
+
@prefix solid: <http://www.w3.org/ns/solid/terms#>.
|
|
91
|
+
|
|
92
|
+
<${typeIndexUrl}> a solid:TypeIndex .
|
|
93
|
+
|
|
94
|
+
${registrations.join('\n')}
|
|
95
|
+
`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function containerResponse(
|
|
99
|
+
options: { name?: string; documentUrls?: string[]; createdAt?: Date; updatedAt?: Date; append?: string } = {},
|
|
100
|
+
): Response {
|
|
101
|
+
const name = options.name ?? 'Movies';
|
|
102
|
+
const documentUrls = options.documentUrls ?? [];
|
|
103
|
+
const createdAt = options.createdAt ?? new Date();
|
|
104
|
+
const updatedAt = options.updatedAt ?? new Date();
|
|
105
|
+
|
|
106
|
+
return FakeResponse.success(`
|
|
107
|
+
@prefix dc: <http://purl.org/dc/terms/>.
|
|
108
|
+
@prefix ldp: <http://www.w3.org/ns/ldp#>.
|
|
109
|
+
@prefix posix: <http://www.w3.org/ns/posix/stat#>.
|
|
110
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
|
|
111
|
+
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
|
|
112
|
+
@prefix crdt: <https://vocab.noeldemartin.com/crdt/>.
|
|
113
|
+
|
|
114
|
+
<./> a ldp:Container, ldp:BasicContainer, ldp:Resource;
|
|
115
|
+
rdfs:label "${name}";
|
|
116
|
+
dc:created "2024-02-05T12:28:42Z"^^xsd:dateTime;
|
|
117
|
+
dc:modified "2024-02-05T12:28:42Z"^^xsd:dateTime;
|
|
118
|
+
${documentUrls.length > 0 ? `ldp:contains ${documentUrls.map((url) => `<${url}>`).join(',')};` : ''}
|
|
119
|
+
posix:mtime 1707136122.
|
|
120
|
+
|
|
121
|
+
<./#metadata>
|
|
122
|
+
a crdt:Metadata ;
|
|
123
|
+
crdt:resource <./> ;
|
|
124
|
+
crdt:createdAt "${createdAt.toISOString()}"^^xsd:dateTime ;
|
|
125
|
+
crdt:updatedAt "${updatedAt.toISOString()}"^^xsd:dateTime .
|
|
126
|
+
|
|
127
|
+
${options.append ?? ''}
|
|
128
|
+
`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function movieResponse(title: string): Response {
|
|
132
|
+
return FakeResponse.success(`
|
|
133
|
+
@prefix schema: <https://schema.org/>.
|
|
134
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
|
|
135
|
+
@prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
|
|
136
|
+
|
|
137
|
+
<#it>
|
|
138
|
+
a schema:Movie;
|
|
139
|
+
schema:datePublished "2001-07-20T00:00:00Z"^^xsd:dateTime;
|
|
140
|
+
schema:name "${title}".
|
|
141
|
+
|
|
142
|
+
<#it-metadata> a crdt:Metadata;
|
|
143
|
+
crdt:resource <#it>;
|
|
144
|
+
crdt:createdAt "2024-02-10T00:00:00Z"^^xsd:dateTime;
|
|
145
|
+
crdt:updatedAt "2024-02-10T00:00:00Z"^^xsd:dateTime.
|
|
146
|
+
`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function taskResponse(name?: string): Response {
|
|
150
|
+
return FakeResponse.success(`
|
|
151
|
+
@prefix schema: <https://schema.org/>.
|
|
152
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
|
|
153
|
+
@prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
|
|
154
|
+
|
|
155
|
+
<#it>
|
|
156
|
+
a schema:Action;
|
|
157
|
+
schema:name "${name ?? 'Task'}".
|
|
158
|
+
|
|
159
|
+
<#it-metadata> a crdt:Metadata;
|
|
160
|
+
crdt:resource <#it>;
|
|
161
|
+
crdt:createdAt "2024-02-10T00:00:00Z"^^xsd:dateTime;
|
|
162
|
+
crdt:updatedAt "2024-02-10T00:00:00Z"^^xsd:dateTime.
|
|
163
|
+
`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function legacyTaskResponse(name?: string): Response {
|
|
167
|
+
return FakeResponse.success(`
|
|
168
|
+
@prefix ical: <http://www.w3.org/2002/12/cal/ical#>.
|
|
169
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#>.
|
|
170
|
+
@prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
|
|
171
|
+
|
|
172
|
+
<#it>
|
|
173
|
+
a ical:Vtodo;
|
|
174
|
+
ical:summary "${name ?? 'Task'}".
|
|
175
|
+
|
|
176
|
+
<#it-metadata> a crdt:Metadata;
|
|
177
|
+
crdt:resource <#it>;
|
|
178
|
+
crdt:createdAt "2024-02-10T00:00:00Z"^^xsd:dateTime;
|
|
179
|
+
crdt:updatedAt "2024-02-10T00:00:00Z"^^xsd:dateTime.
|
|
180
|
+
`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function tombstoneResponse(): Response {
|
|
184
|
+
return FakeResponse.success(`
|
|
185
|
+
@prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
|
|
186
|
+
@prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
|
|
187
|
+
|
|
188
|
+
<#it-metadata>
|
|
189
|
+
a crdt:Tombstone ;
|
|
190
|
+
crdt:resource <#it> ;
|
|
191
|
+
crdt:deletedAt "2025-03-03T00:00:00Z"^^xsd:dateTime .
|
|
192
|
+
`);
|
|
193
|
+
}
|