@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,494 @@
1
+ import {
2
+ Semaphore,
3
+ after,
4
+ facade,
5
+ fail,
6
+ getLocationQueryParameter,
7
+ hasLocationQueryParameter,
8
+ isArray,
9
+ isTesting,
10
+ parseBoolean,
11
+ } from '@noeldemartin/utils';
12
+ import { Errors, Events, dispatch, translateWithDefault } from '@aerogel/core';
13
+ import { Solid } from '@aerogel/plugin-solid';
14
+ import { SolidContainer, SolidModel, Tombstone, isContainer, isContainerClass, isSolidModel } from 'soukai-solid';
15
+ import { getTrackedModels, trackModels, trackModelsCollection } from '@aerogel/plugin-soukai';
16
+ import { watchEffect } from 'vue';
17
+ import { getBootedModels } from 'soukai';
18
+ import type { Authenticator } from '@aerogel/plugin-solid';
19
+ import type { Engine } from 'soukai';
20
+ import type { JobListener } from '@aerogel/core';
21
+ import type { SolidContainsRelation, SolidModelConstructor, SolidSchemaDefinition } from 'soukai-solid';
22
+
23
+ import {
24
+ clearLocalModelUpdates,
25
+ createRemoteModel,
26
+ getLocalModels,
27
+ getRemoteClass,
28
+ getRemoteContainersCollection,
29
+ updateRemoteModel,
30
+ } from '@aerogel/plugin-local-first/lib/mirroring';
31
+ import Backup from '@aerogel/plugin-local-first/jobs/Backup';
32
+ import DocumentsCache from '@aerogel/plugin-local-first/services/DocumentsCache';
33
+ import Migrate from '@aerogel/plugin-local-first/jobs/Migrate';
34
+ import Sync from '@aerogel/plugin-local-first/jobs/Sync';
35
+ import SyncQueue from '@aerogel/plugin-local-first/lib/SyncQueue';
36
+ import {
37
+ getContainedModels,
38
+ getContainerRelations,
39
+ getRelatedClasses,
40
+ } from '@aerogel/plugin-local-first/lib/inference';
41
+
42
+ import Service, { CloudStatus } from './Cloud.state';
43
+
44
+ export interface RegisterOptions {
45
+ path?: string;
46
+ }
47
+
48
+ export interface SyncOptions extends JobListener {
49
+ refreshUserProfile?: boolean;
50
+ model?: SolidModel;
51
+ models?: SolidModel[];
52
+ }
53
+
54
+ export class CloudService extends Service {
55
+
56
+ protected asyncLock: Semaphore = new Semaphore();
57
+ protected engine: Engine | null = null;
58
+ protected pollingInterval: NodeJS.Timeout | null = null;
59
+
60
+ public async whenReady<T>(callback: () => T): Promise<T> {
61
+ if (this.ready) {
62
+ return callback();
63
+ }
64
+
65
+ return new Promise((resolve) => {
66
+ const onReady = () => resolve(callback());
67
+
68
+ Events.once('cloud:ready', onReady);
69
+ });
70
+ }
71
+
72
+ public async setup(modelUrlMappings?: WeakMap<SolidModelConstructor, Record<string, string>>): Promise<void> {
73
+ this.setupOngoing = true;
74
+
75
+ try {
76
+ await this.backup(modelUrlMappings);
77
+ await this.setReady(true);
78
+ await this.sync();
79
+ } finally {
80
+ this.setupOngoing = false;
81
+ }
82
+ }
83
+
84
+ public dismissSetup(): void {
85
+ this.setupDismissed = true;
86
+ }
87
+
88
+ public dismissMigration(): void {
89
+ this.migrationDismissed = true;
90
+ }
91
+
92
+ public postponeMigration(): void {
93
+ this.migrationPostponed = true;
94
+ }
95
+
96
+ public async syncIfOnline(options?: SyncOptions): Promise<void>;
97
+ public async syncIfOnline(model: SolidModel): Promise<void>;
98
+ public async syncIfOnline(models: SolidModel[]): Promise<void>;
99
+ public async syncIfOnline(config: SyncOptions | SolidModel | SolidModel[] = {}): Promise<void> {
100
+ if (!this.online) {
101
+ return;
102
+ }
103
+
104
+ await this.sync(config as SyncOptions);
105
+ }
106
+
107
+ public async sync(options?: SyncOptions): Promise<void>;
108
+ public async sync(model: SolidModel): Promise<void>;
109
+ public async sync(models: SolidModel[]): Promise<void>;
110
+ public async sync(config: SyncOptions | SolidModel | SolidModel[] = {}): Promise<void> {
111
+ const options = isArray(config)
112
+ ? { models: config }
113
+ : config instanceof SolidModel
114
+ ? { model: config }
115
+ : config;
116
+
117
+ if (!Solid.isLoggedIn() || !this.ready || !this.online) {
118
+ return;
119
+ }
120
+
121
+ await this.asyncLock.run(async () => {
122
+ const start = Date.now();
123
+ const models = options.model ? [options.model] : options.models;
124
+
125
+ this.syncError = null;
126
+ this.status = CloudStatus.Syncing;
127
+
128
+ SyncQueue.clear(models);
129
+
130
+ try {
131
+ await Events.emit('cloud:sync-started', models);
132
+
133
+ if (options.refreshUserProfile) {
134
+ await Solid.refreshUserProfile();
135
+ }
136
+
137
+ this.syncJob = new Sync(models);
138
+
139
+ this.syncJob.listeners.add(options);
140
+
141
+ await dispatch(this.syncJob);
142
+
143
+ if (this.syncJob.cancelled) {
144
+ return;
145
+ }
146
+
147
+ if (!isTesting('unit')) {
148
+ await after({ milliseconds: Math.max(500, 1000 - (Date.now() - start)) });
149
+ }
150
+
151
+ this.syncJob = null;
152
+ } catch (error) {
153
+ if (isTesting()) {
154
+ throw error;
155
+ }
156
+
157
+ this.syncJob = null;
158
+ this.syncError = error;
159
+
160
+ await Errors.report(error, translateWithDefault('cloud.syncFailed', 'Sync failed'));
161
+ } finally {
162
+ this.status = CloudStatus.Online;
163
+
164
+ await Events.emit('cloud:sync-completed', models);
165
+ }
166
+ });
167
+ }
168
+
169
+ public shouldMigrate(): boolean {
170
+ return this.schemaMigrations.size > 0;
171
+ }
172
+
173
+ public async migrate(listener?: JobListener): Promise<void> {
174
+ if (!this.online || !Solid.isLoggedIn() || !this.ready) {
175
+ throw new Error('Data cannot be migrated at the moment');
176
+ }
177
+
178
+ if (!this.shouldMigrate()) {
179
+ return;
180
+ }
181
+
182
+ await this.asyncLock.run(async () => {
183
+ this.status = CloudStatus.Migrating;
184
+ this.migrationJob ??= new Migrate();
185
+
186
+ const start = Date.now();
187
+ const removeListeners: Function[] = [];
188
+ const models = (this.migrationJob.models ??= getLocalModels());
189
+
190
+ SyncQueue.clear(models);
191
+
192
+ try {
193
+ listener && removeListeners.push(this.migrationJob.listeners.add(listener));
194
+
195
+ await Events.emit('cloud:migration-started', models);
196
+ await dispatch(this.migrationJob);
197
+
198
+ if (this.migrationJob.cancelled) {
199
+ await Events.emit('cloud:migration-cancelled', models);
200
+
201
+ return;
202
+ }
203
+
204
+ this.migrationJob = null;
205
+ this.migrationDismissed = false;
206
+ this.migrationPostponed = false;
207
+
208
+ await after({ milliseconds: Math.max(0, 1000 - (Date.now() - start)) });
209
+ await Events.emit('cloud:migration-completed', models);
210
+ } catch (error) {
211
+ await Errors.report(error, translateWithDefault('cloud.migrationFailed', 'Migration failed'));
212
+ } finally {
213
+ this.status = CloudStatus.Online;
214
+
215
+ removeListeners.forEach((removeListener) => removeListener?.());
216
+ }
217
+ });
218
+ }
219
+
220
+ public async register(modelClass: SolidModelConstructor, options: RegisterOptions = {}): Promise<void> {
221
+ if (this.registeredModels.find((registered) => modelClass === registered.modelClass)) {
222
+ return;
223
+ }
224
+
225
+ const engine = this.engine;
226
+
227
+ if (engine) {
228
+ getRelatedClasses(modelClass).forEach((relatedClass) => getRemoteClass(relatedClass).setEngine(engine));
229
+ }
230
+
231
+ this.whenReady(() => {
232
+ const [existingCollection] = this.modelCollections[modelClass.modelName] ?? [];
233
+
234
+ modelClass.useSoftDeletes(true);
235
+
236
+ modelClass.collection = existingCollection ?? getRemoteContainersCollection(modelClass, options.path);
237
+ });
238
+
239
+ await trackModels(modelClass, {
240
+ created: (model) => this.ready && createRemoteModel(model),
241
+ updated: (model) => this.ready && updateRemoteModel(model),
242
+ deleted: (model) =>
243
+ this.ready && clearLocalModelUpdates(new Set([model.url ?? model.getDeletedPrimaryKey()])),
244
+ });
245
+
246
+ if (isContainerClass(modelClass)) {
247
+ getContainerRelations(modelClass).forEach((relation) => {
248
+ const relatedClass = modelClass
249
+ .instance()
250
+ .requireRelation<SolidContainsRelation>(relation).relatedClass;
251
+
252
+ relatedClass.on('created', (model) => this.ready && createRemoteModel(model));
253
+ relatedClass.on('updated', (model) => this.ready && updateRemoteModel(model));
254
+ relatedClass.on(
255
+ 'deleted',
256
+ (model) =>
257
+ this.ready && clearLocalModelUpdates(new Set([model.url ?? model.getDeletedPrimaryKey()])),
258
+ );
259
+ });
260
+ }
261
+
262
+ this.registeredModels.push({ modelClass, path: options.path });
263
+
264
+ for (const collection of this.modelCollections[modelClass.modelName] ?? []) {
265
+ await trackModelsCollection(modelClass, collection);
266
+ }
267
+ }
268
+
269
+ public registerSchemaMigration(
270
+ modelClass: SolidModelConstructor,
271
+ schema: SolidModelConstructor | SolidSchemaDefinition,
272
+ ): void {
273
+ this.schemaMigrations.set(modelClass, schema);
274
+ }
275
+
276
+ public requireRemoteCollection(modelClass: SolidModelConstructor): string {
277
+ for (const registration of this.registeredModels) {
278
+ if (modelClass !== registration.modelClass) {
279
+ continue;
280
+ }
281
+
282
+ return getRemoteContainersCollection(registration.modelClass, registration.path);
283
+ }
284
+
285
+ throw new Error(`Failed resolving remote collection for '${modelClass.modelName}' model`);
286
+ }
287
+
288
+ protected override async boot(): Promise<void> {
289
+ await Solid.booted;
290
+
291
+ Solid.isLoggedIn() && this.login(Solid.authenticator);
292
+
293
+ Events.on('auth:login', ({ authenticator }) => this.login(authenticator));
294
+ Events.on('auth:logout', () => this.logout());
295
+ Events.once('application-ready', () => this.onApplicationReady());
296
+ Events.once('application-mounted', () => this.onApplicationMounted());
297
+
298
+ watchEffect(() => {
299
+ this.pollingInterval && clearInterval(this.pollingInterval);
300
+
301
+ if (!this.pollingEnabled || this.migrationJob) {
302
+ this.pollingInterval = null;
303
+
304
+ return;
305
+ }
306
+
307
+ this.pollingInterval = setInterval(() => this.sync(), this.pollingMinutes * 60 * 1000);
308
+ });
309
+
310
+ await this.bustDocumentsCache();
311
+ await this.registerModels();
312
+ }
313
+
314
+ protected getDirtyLocalModels(): SolidModel[] {
315
+ const urls = Object.keys(this.localModelUpdates);
316
+ const dirtyLocalModels = [];
317
+
318
+ for (const { modelClass } of this.registeredModels) {
319
+ for (const model of getTrackedModels(modelClass, { includeSoftDeleted: true })) {
320
+ dirtyLocalModels.push(...this.getModelsIn(urls, model));
321
+ }
322
+ }
323
+
324
+ return dirtyLocalModels;
325
+ }
326
+
327
+ protected getModelsIn(urls: string[], model: SolidModel): SolidModel[] {
328
+ const models = [];
329
+
330
+ if (urls.includes(model.url)) {
331
+ models.push(model);
332
+ }
333
+
334
+ if (isContainer(model)) {
335
+ models.push(
336
+ ...getContainedModels(model)
337
+ .map((containedModel) => this.getModelsIn(urls, containedModel))
338
+ .flat(),
339
+ );
340
+ }
341
+
342
+ return models;
343
+ }
344
+
345
+ protected requireEngine(): Engine {
346
+ return this.engine ?? fail('Could not get required Engine');
347
+ }
348
+
349
+ protected async setReady(ready: boolean): Promise<void> {
350
+ if (this.ready || !ready) {
351
+ return;
352
+ }
353
+
354
+ await Events.emit('cloud:ready');
355
+
356
+ this.ready = true;
357
+ }
358
+
359
+ protected async backup(modelUrlMappings?: WeakMap<SolidModelConstructor, Record<string, string>>): Promise<void> {
360
+ await Events.emit('cloud:backup-started');
361
+
362
+ modelUrlMappings ??= new WeakMap();
363
+
364
+ const job = new Backup();
365
+
366
+ for (const { modelClass, path } of this.registeredModels) {
367
+ if (!isContainerClass(modelClass)) {
368
+ continue;
369
+ }
370
+
371
+ for (const [local, remote] of Object.entries(modelUrlMappings.get(modelClass) ?? {})) {
372
+ job.migrateUrl(modelClass, local, remote);
373
+ }
374
+
375
+ const localCollection = modelClass.collection;
376
+ const remoteCollection = getRemoteContainersCollection(modelClass, path);
377
+
378
+ modelClass.collection = remoteCollection;
379
+
380
+ job.migrateCollection(modelClass, localCollection, remoteCollection);
381
+ }
382
+
383
+ await dispatch(job);
384
+ await Events.emit('cloud:backup-completed');
385
+ }
386
+
387
+ protected async onApplicationReady(): Promise<void> {
388
+ if (this.ready || !Solid.isLoggedIn()) {
389
+ return;
390
+ }
391
+
392
+ await this.setReady(!getLocalModels().some((model) => model.url.startsWith('solid://')));
393
+ }
394
+
395
+ protected async onApplicationMounted(): Promise<void> {
396
+ if (!this.syncOnStartup()) {
397
+ return;
398
+ }
399
+
400
+ await this.sync();
401
+ }
402
+
403
+ protected login(authenticator: Authenticator): void {
404
+ const relatedClasses = [...this.registeredModels]
405
+ .map(({ modelClass }) => getRelatedClasses(modelClass))
406
+ .flat()
407
+ .concat([Tombstone]);
408
+
409
+ this.status = CloudStatus.Online;
410
+ this.engine = authenticator.engine;
411
+
412
+ getRemoteClass(SolidContainer).setEngine(this.engine);
413
+
414
+ for (const relatedClass of relatedClasses) {
415
+ getRemoteClass(relatedClass).setEngine(this.engine);
416
+ }
417
+ }
418
+
419
+ protected logout(): void {
420
+ this.setState({
421
+ localModelUpdates: {},
422
+ migrationDismissed: false,
423
+ migrationJob: null,
424
+ migrationPostponed: false,
425
+ modelCollections: {},
426
+ pollingEnabled: true,
427
+ pollingMinutes: 10,
428
+ ready: false,
429
+ setupDismissed: false,
430
+ setupOngoing: false,
431
+ startupSync: true,
432
+ status: CloudStatus.Disconnected,
433
+ });
434
+ }
435
+
436
+ private syncOnStartup(): boolean {
437
+ if (hasLocationQueryParameter('startupSync')) {
438
+ return parseBoolean(getLocationQueryParameter('startupSync'));
439
+ }
440
+
441
+ if (this.migrationJob) {
442
+ return false;
443
+ }
444
+
445
+ return this.startupSync;
446
+ }
447
+
448
+ private async bustDocumentsCache(): Promise<void> {
449
+ if (
450
+ !hasLocationQueryParameter('bustDocumentsCache') ||
451
+ !parseBoolean(getLocationQueryParameter('bustDocumentsCache'))
452
+ ) {
453
+ return;
454
+ }
455
+
456
+ await DocumentsCache.booted;
457
+ await DocumentsCache.clear();
458
+ }
459
+
460
+ private async registerModels(): Promise<void> {
461
+ const models = getBootedModels();
462
+
463
+ for (const model of models.values()) {
464
+ if (!isSolidModel(model) || !model.cloud) {
465
+ continue;
466
+ }
467
+
468
+ await this.register(model, typeof model.cloud === 'boolean' ? {} : model.cloud);
469
+ }
470
+ }
471
+
472
+ }
473
+
474
+ export default facade(CloudService);
475
+
476
+ declare module '@aerogel/core' {
477
+ export interface EventsPayload {
478
+ 'cloud:ready': void;
479
+ 'cloud:backup-started': void;
480
+ 'cloud:backup-completed': void;
481
+ 'cloud:migration-started': SolidModel[];
482
+ 'cloud:migration-cancelled': SolidModel[];
483
+ 'cloud:migration-completed': SolidModel[];
484
+ 'cloud:sync-started': SolidModel[] | undefined;
485
+ 'cloud:sync-completed': SolidModel[] | undefined;
486
+ }
487
+ }
488
+
489
+ declare module 'soukai-solid' {
490
+ // eslint-disable-next-line @typescript-eslint/no-shadow
491
+ namespace SolidModel {
492
+ export const cloud: boolean | RegisterOptions | undefined;
493
+ }
494
+ }
@@ -0,0 +1,39 @@
1
+ import { Events, Service, appNamespace } 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 }>(`${appNamespace()}-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
+ public async clear(): Promise<void> {
26
+ await this.cache.clear();
27
+ }
28
+
29
+ protected override async boot(): Promise<void> {
30
+ Events.on('auth:logout', async () => {
31
+ await this.cache.clear();
32
+
33
+ this.cache.close();
34
+ });
35
+ }
36
+
37
+ }
38
+
39
+ export default facade(DocumentsCacheService);