@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,352 @@
1
+ import { bootModels } from 'soukai';
2
+ import {
3
+ fail,
4
+ objectWithout,
5
+ requireUrlParentDirectory,
6
+ required,
7
+ tap,
8
+ urlResolveDirectory,
9
+ urlRoute,
10
+ } from '@noeldemartin/utils';
11
+ import { App } from '@aerogel/core';
12
+ import { getTrackedModels, trackModelsCollection as trackSoukaiModelsCollection } from '@aerogel/plugin-soukai';
13
+ import { Solid } from '@aerogel/plugin-solid';
14
+ import { PropertyOperation, SolidContainer, SolidContainsRelation, Tombstone, isContainerClass } from 'soukai-solid';
15
+ import type { ObjectsMap } from '@noeldemartin/utils';
16
+ import type { Attributes } from 'soukai';
17
+ import type { Operation, SolidModel, SolidModelConstructor, SolidSchemaDefinition } from 'soukai-solid';
18
+
19
+ import DocumentsCache from '@aerogel/plugin-local-first/services/DocumentsCache';
20
+ import SyncQueue from '@aerogel/plugin-local-first/lib/SyncQueue';
21
+ import { getContainerRelations, getRelatedAppModels } from '@aerogel/plugin-local-first/lib/inference';
22
+
23
+ interface CloneOptions {
24
+ clean?: boolean;
25
+ }
26
+
27
+ const Cloud = required(() => App.service('$cloud'));
28
+ const remoteClasses: WeakMap<SolidModelConstructor, SolidModelConstructor> = new WeakMap();
29
+ const localClasses: WeakMap<SolidModelConstructor, SolidModelConstructor> = new WeakMap();
30
+
31
+ function cleanRemoteModelClone(remoteModel: SolidModel): void {
32
+ const relatedModels = remoteModel.getRelatedModels().concat([remoteModel]);
33
+
34
+ for (const relatedModel of relatedModels) {
35
+ relatedModel.cleanDirty(true);
36
+ }
37
+ }
38
+
39
+ function fixRemoteModelClone(remoteModel: SolidModel): void {
40
+ const relatedModels = getRelatedAppModels(remoteModel).concat([remoteModel]);
41
+
42
+ for (const relatedModel of relatedModels) {
43
+ if (!relatedModel.operations || relatedModel.operations.length === 0) {
44
+ continue;
45
+ }
46
+
47
+ const operations = (relatedModel.operations ?? []).filter((operation) => {
48
+ return (
49
+ !(operation instanceof PropertyOperation) ||
50
+ !relatedModel.ignoreRdfPropertyHistory(operation.property, true)
51
+ );
52
+ });
53
+
54
+ if (!operations.some((operation) => !operation.isInception(relatedModel))) {
55
+ relatedModel.setRelationModels('operations', []);
56
+
57
+ continue;
58
+ }
59
+
60
+ relatedModel.setRelationModels('operations', operations);
61
+ }
62
+ }
63
+
64
+ export async function createRemoteModel(localModel: SolidModel): Promise<void> {
65
+ Cloud.setState({
66
+ localModelUpdates: {
67
+ ...Cloud.localModelUpdates,
68
+ [localModel.url]: 1,
69
+ },
70
+ });
71
+
72
+ if (isRegisteredModelClass(localModel.static())) {
73
+ await trackModelsCollection(localModel.static(), requireUrlParentDirectory(localModel.url));
74
+ }
75
+
76
+ if (Cloud.autoPush && Cloud.ready && !Cloud.syncing) {
77
+ SyncQueue.push(localModel);
78
+ }
79
+ }
80
+
81
+ export async function trackModelsCollection(modelClass: SolidModelConstructor, collection: string): Promise<void> {
82
+ if (Cloud.modelCollections[modelClass.modelName]?.includes(collection)) {
83
+ return;
84
+ }
85
+
86
+ Cloud.setState('modelCollections', {
87
+ ...Cloud.modelCollections,
88
+ [modelClass.modelName]: (Cloud.modelCollections[modelClass.modelName] ?? []).concat(collection),
89
+ });
90
+
91
+ if (!Cloud.modelCollections[modelClass.modelName]?.includes(modelClass.collection)) {
92
+ modelClass.collection = collection;
93
+ }
94
+
95
+ await trackSoukaiModelsCollection(modelClass, collection);
96
+ }
97
+
98
+ export async function updateRemoteModel(localModel: SolidModel): Promise<void> {
99
+ if (ignoreModelUpdates(localModel)) {
100
+ return;
101
+ }
102
+
103
+ const modelUpdates = Cloud.localModelUpdates[localModel.url] ?? 0;
104
+
105
+ Cloud.setState({
106
+ localModelUpdates: localModel.isSoftDeleted()
107
+ ? objectWithout(Cloud.localModelUpdates, localModel.url)
108
+ : {
109
+ ...Cloud.localModelUpdates,
110
+ [localModel.url]: modelUpdates + 1,
111
+ },
112
+ });
113
+
114
+ await DocumentsCache.forget(localModel.requireDocumentUrl());
115
+
116
+ if (Cloud.autoPush && Cloud.ready && !Cloud.syncing) {
117
+ SyncQueue.push(localModel);
118
+ }
119
+ }
120
+
121
+ export function clearLocalModelUpdates(syncedModelUrls: Set<string>, documentsWithErrors?: Set<string>): void {
122
+ const localModelUpdates = {} as Record<string, number>;
123
+ const syncedModelUrlsArray = Array.from(syncedModelUrls);
124
+
125
+ for (const [url, count] of Object.entries(Cloud.localModelUpdates)) {
126
+ const documentUrl = urlRoute(url);
127
+ const wasSynced =
128
+ syncedModelUrls.has(url) ||
129
+ syncedModelUrlsArray.some((modelUrl) => modelUrl.endsWith('/') && url.startsWith(modelUrl));
130
+
131
+ if (wasSynced && !documentsWithErrors?.has(documentUrl)) {
132
+ continue;
133
+ }
134
+
135
+ localModelUpdates[url] = count;
136
+ }
137
+
138
+ Cloud.setState({ localModelUpdates });
139
+ }
140
+
141
+ export function cloneLocalModel(localModel: SolidModel, options: CloneOptions = {}): SolidModel {
142
+ const localModelClasses = new Set<typeof SolidModel>();
143
+
144
+ localModelClasses.add(localModel.static());
145
+
146
+ if (localModel instanceof SolidContainer) {
147
+ for (const relation of getContainerRelations(localModel.static())) {
148
+ localModelClasses.add(localModel.requireRelation<SolidContainsRelation>(relation).relatedClass);
149
+ }
150
+ }
151
+
152
+ const constructors = [...localModelClasses.values()].map(
153
+ (localClass) => [localClass, getRemoteClass(localClass)] as [typeof SolidModel, typeof SolidModel],
154
+ );
155
+
156
+ return tap(localModel.clone({ constructors }), (model) => {
157
+ fixRemoteModelClone(model);
158
+
159
+ options.clean && cleanRemoteModelClone(model);
160
+ });
161
+ }
162
+
163
+ export function cloneRemoteModel(remoteModel: SolidModel): SolidModel {
164
+ const remoteModelClasses = new Set<typeof SolidModel>();
165
+
166
+ remoteModelClasses.add(remoteModel.static());
167
+
168
+ if (remoteModel instanceof SolidContainer) {
169
+ for (const relation of getContainerRelations(remoteModel.static())) {
170
+ remoteModelClasses.add(remoteModel.requireRelation<SolidContainsRelation>(relation).relatedClass);
171
+ }
172
+ }
173
+
174
+ const constructors = [...remoteModelClasses.values()].map(
175
+ (remoteClass) => [remoteClass, getLocalClass(remoteClass)] as [typeof SolidModel, typeof SolidModel],
176
+ );
177
+
178
+ return remoteModel.clone({ constructors });
179
+ }
180
+
181
+ export async function completeRemoteModels(
182
+ localModels: SolidModel[],
183
+ remoteModels: SolidModel[],
184
+ documentsWithErrors: Set<string>,
185
+ ): Promise<SolidModel[]> {
186
+ const RemoteTombstone = getRemoteClass(Tombstone);
187
+ const remoteModelUrls = remoteModels.map((remoteModel) => remoteModel.url);
188
+ const missingModelDocumentUrls = localModels
189
+ .filter((localModel) => !remoteModelUrls.includes(localModel.url))
190
+ .map((localModel) => localModel.requireDocumentUrl())
191
+ .filter((documentUrl) => !documentsWithErrors.has(documentUrl));
192
+ const tombstones = await RemoteTombstone.all({ $in: missingModelDocumentUrls });
193
+
194
+ return remoteModels.concat(tombstones);
195
+ }
196
+
197
+ export function ignoreModelUpdates(localModel: SolidModel): boolean {
198
+ const documentModels = localModel.getDocumentModels();
199
+ const documentOperations = documentModels.flatMap(
200
+ (model) => model.operations?.map((operation) => [model, operation] as [SolidModel, Operation]) ?? [],
201
+ );
202
+ const updatedAt = Math.max(...documentModels.map((model) => model.metadata?.updatedAt?.getTime() ?? 0));
203
+
204
+ return !documentOperations.some(([model, operation]) => {
205
+ if (operation.date.getTime() !== updatedAt) {
206
+ return false;
207
+ }
208
+
209
+ if (!(operation instanceof PropertyOperation)) {
210
+ return true;
211
+ }
212
+
213
+ return !model.ignoreRdfPropertyHistory(operation.property, true);
214
+ });
215
+ }
216
+
217
+ export function getLocalModel(remoteModel: SolidModel, localModels: ObjectsMap<SolidModel>): SolidModel {
218
+ return localModels.get(remoteModel.url) ?? cloneRemoteModel(remoteModel);
219
+ }
220
+
221
+ export function getLocalModels(): SolidModel[] {
222
+ return [...Cloud.registeredModels]
223
+ .map(({ modelClass }) => getTrackedModels(modelClass, { includeSoftDeleted: true }))
224
+ .flat();
225
+ }
226
+
227
+ export function getSchemaMigrations(): Map<SolidModelConstructor, SolidModelConstructor | SolidSchemaDefinition> {
228
+ return Cloud.schemaMigrations;
229
+ }
230
+
231
+ export function clearSchemaMigrations(): void {
232
+ Cloud.schemaMigrations.clear();
233
+ }
234
+
235
+ export function getSchemaMigration(
236
+ modelClass: SolidModelConstructor,
237
+ ): SolidModelConstructor | SolidSchemaDefinition | undefined {
238
+ return Cloud.schemaMigrations.get(modelClass);
239
+ }
240
+
241
+ export function getRemoteClass<T extends SolidModelConstructor>(localClass: T): T {
242
+ return (remoteClasses.get(localClass) as T) ?? makeRemoteClass(localClass);
243
+ }
244
+
245
+ export function getRemoteContainersCollection(modelClass: SolidModelConstructor, path?: string): string {
246
+ const rootStorage = Solid.requireUser().storageUrls[0];
247
+ const containedClass =
248
+ isContainerClass(modelClass) &&
249
+ getContainerRelations(modelClass)
250
+ .map((relation) => {
251
+ const relatedClass = modelClass
252
+ .instance()
253
+ .requireRelation<SolidContainsRelation>(relation).relatedClass;
254
+
255
+ if (isContainerClass(relatedClass)) {
256
+ return null;
257
+ }
258
+
259
+ return relatedClass;
260
+ })
261
+ .filter(Boolean)[0];
262
+
263
+ path ??= `/${(containedClass || modelClass).modelName.toLowerCase()}s/`;
264
+
265
+ return urlResolveDirectory((path.startsWith('/') ? rootStorage.slice(0, -1) : rootStorage) + path);
266
+ }
267
+
268
+ export function getRemoteModel(localModel: SolidModel, remoteModels: ObjectsMap<SolidModel>): SolidModel {
269
+ return remoteModels.get(localModel.url) ?? cloneLocalModel(localModel);
270
+ }
271
+
272
+ export function isRegisteredModel(model: SolidModel): boolean {
273
+ for (const { modelClass } of Cloud.registeredModels) {
274
+ const models = getTrackedModels(modelClass);
275
+
276
+ if (models.some((registeredModel) => registeredModel.url === model.url)) {
277
+ return true;
278
+ }
279
+ }
280
+
281
+ return false;
282
+ }
283
+
284
+ export function isRemoteModel(model: SolidModel): boolean {
285
+ return localClasses.has(model.static());
286
+ }
287
+
288
+ export function isLocalModel(model: SolidModel): boolean {
289
+ return !isRemoteModel(model);
290
+ }
291
+
292
+ export function isRegisteredModelClass(modelClass: SolidModelConstructor): boolean {
293
+ return Cloud.registeredModels.some(({ modelClass: registeredModelClass }) => registeredModelClass === modelClass);
294
+ }
295
+
296
+ export function makeRemoteClass<T extends SolidModelConstructor>(localClass: T): T {
297
+ const LocalClass = localClass as typeof SolidModel;
298
+ const withLocalHistoryConfig = (modelClass: typeof SolidModel, operation: () => unknown) => {
299
+ modelClass.timestamps = LocalClass.timestamps;
300
+ modelClass.history = LocalClass.history;
301
+
302
+ operation();
303
+
304
+ modelClass.timestamps = [];
305
+ modelClass.history = false;
306
+ };
307
+ const RemoteClass = class extends LocalClass {
308
+
309
+ public static override timestamps = false;
310
+ public static override history = false;
311
+
312
+ protected override initialize(attributes: Attributes, exists: boolean): void {
313
+ withLocalHistoryConfig(this._proxy.static(), () => super.initialize(attributes, exists));
314
+ }
315
+
316
+ protected override initializeRelations(): void {
317
+ super.initializeRelations();
318
+
319
+ for (const relation of Object.values(this._relations)) {
320
+ if (!(relation instanceof SolidContainsRelation)) {
321
+ continue;
322
+ }
323
+
324
+ relation.relatedClass = getRemoteClass(relation.relatedClass);
325
+ }
326
+ }
327
+
328
+ public override fixMalformedAttributes(): void {
329
+ withLocalHistoryConfig(this.static(), () => super.fixMalformedAttributes());
330
+ }
331
+
332
+ } as unknown as T;
333
+
334
+ remoteClasses.set(LocalClass, RemoteClass);
335
+ localClasses.set(RemoteClass, LocalClass);
336
+
337
+ LocalClass.on('schema-updated', async (schema) => {
338
+ await RemoteClass.updateSchema({
339
+ ...schema,
340
+ timestamps: false,
341
+ history: false,
342
+ });
343
+ });
344
+
345
+ bootModels({ [`Remote${localClass.modelName}`]: RemoteClass });
346
+
347
+ return RemoteClass;
348
+ }
349
+
350
+ export function getLocalClass<T extends SolidModelConstructor>(remoteClass: T): T {
351
+ return (localClasses.get(remoteClass) as T) ?? fail(`Couldn't find local class for ${remoteClass.modelName}`);
352
+ }
@@ -0,0 +1,21 @@
1
+ export function mapFromArray<K, V>(items: [K, V][]): Map<K, V> {
2
+ const itemsMap = new Map();
3
+
4
+ for (const [key, value] of items) {
5
+ itemsMap.set(key, value);
6
+ }
7
+
8
+ return itemsMap;
9
+ }
10
+
11
+ export function lazy<T>(getter: () => Promise<T>): () => Promise<T> {
12
+ const result: { value?: T } = {};
13
+
14
+ return async () => {
15
+ if (!('value' in result)) {
16
+ result.value = await getter();
17
+ }
18
+
19
+ return result.value as T;
20
+ };
21
+ }
@@ -0,0 +1,111 @@
1
+ import { defineServiceState, replaceExisting, translateWithDefault } from '@aerogel/core';
2
+ import { Solid } from '@aerogel/plugin-solid';
3
+ import type { ErrorSource } from '@aerogel/core';
4
+ import type { Nullable } from '@noeldemartin/utils';
5
+ import type { SolidModelConstructor, SolidSchemaDefinition } from 'soukai-solid';
6
+
7
+ import Migrate from '@aerogel/plugin-local-first/jobs/Migrate';
8
+ import type Sync from '@aerogel/plugin-local-first/jobs/Sync';
9
+
10
+ export const CloudStatus = {
11
+ Disconnected: 'disconnected',
12
+ Online: 'online',
13
+ Syncing: 'syncing',
14
+ Migrating: 'migrating',
15
+ } as const;
16
+
17
+ export interface ModelRegistration {
18
+ modelClass: SolidModelConstructor;
19
+ path?: string;
20
+ }
21
+
22
+ export type TCloudStatus = (typeof CloudStatus)[keyof typeof CloudStatus];
23
+
24
+ export default defineServiceState({
25
+ name: 'cloud',
26
+ persist: [
27
+ 'localModelUpdates',
28
+ 'migrationDismissed',
29
+ 'migrationJob',
30
+ 'modelCollections',
31
+ 'pollingEnabled',
32
+ 'pollingMinutes',
33
+ 'ready',
34
+ 'startupSync',
35
+ ],
36
+ initialState: () => ({
37
+ autoPush: true,
38
+ localModelUpdates: {} as Record<string, number>,
39
+ migrationDismissed: false,
40
+ migrationJob: null as Nullable<Migrate>,
41
+ migrationPostponed: false,
42
+ modelCollections: {} as Record<string, string[]>,
43
+ pollingEnabled: true,
44
+ pollingMinutes: 10,
45
+ ready: false,
46
+ registeredModels: [] as ModelRegistration[],
47
+ schemaMigrations: new Map<SolidModelConstructor, SolidModelConstructor | SolidSchemaDefinition>(),
48
+ setupDismissed: false,
49
+ setupOngoing: false,
50
+ startupSync: true,
51
+ status: CloudStatus.Disconnected as TCloudStatus,
52
+ syncError: null as Nullable<ErrorSource>,
53
+ syncJob: null as Nullable<Sync>,
54
+ }),
55
+ serialize: (state) => replaceExisting(state, { migrationJob: state.migrationJob?.serialize() }),
56
+ restore: (state) =>
57
+ replaceExisting(state, {
58
+ migrationJob: state.migrationJob && Migrate.restore(state.migrationJob),
59
+ }),
60
+ watch: {
61
+ migrationJob(migrationJob) {
62
+ migrationJob?.listeners.add({ onUpdated: () => this.updatePersistedState('migrationJob') });
63
+ },
64
+ },
65
+ computed: {
66
+ disconnected: ({ status }) => status === CloudStatus.Disconnected,
67
+ online: ({ status }) => status === CloudStatus.Online,
68
+ syncing: ({ status }) => status === CloudStatus.Syncing,
69
+ migrating: ({ status }) => status === CloudStatus.Migrating,
70
+ localChanges({ localModelUpdates }) {
71
+ return Object.values(localModelUpdates).reduce((total, count) => total + count, 0);
72
+ },
73
+ dirty(): boolean {
74
+ return this.localChanges > 0;
75
+ },
76
+ migrationStarted({ migrationJob }): boolean {
77
+ return !!migrationJob;
78
+ },
79
+ migrationDisabledReason({ status, ready }): string | null {
80
+ if (!Solid.isLoggedIn()) {
81
+ return translateWithDefault(
82
+ 'cloud.migrationNotLoggedIn',
83
+ 'You need to reconnect before starting the migration',
84
+ );
85
+ }
86
+
87
+ if (!ready) {
88
+ return translateWithDefault(
89
+ 'cloud.migrationNotReady',
90
+ 'You need to backup your data before starting the migration',
91
+ );
92
+ }
93
+
94
+ if (status === CloudStatus.Syncing || status === CloudStatus.Migrating) {
95
+ return translateWithDefault(
96
+ 'cloud.migrationNotSyncing',
97
+ 'Wait for syncing to finish before starting the migration',
98
+ );
99
+ }
100
+
101
+ if (!this.online) {
102
+ return translateWithDefault(
103
+ 'cloud.migrationNotOnline',
104
+ 'You need to be online in order to start the migration',
105
+ );
106
+ }
107
+
108
+ return null;
109
+ },
110
+ },
111
+ });