@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.
Files changed (38) hide show
  1. package/dist/InruptAuthenticator.lazy-BvpQLGoj-BvpQLGoj.js +9 -0
  2. package/dist/InruptAuthenticator.lazy-BvpQLGoj-BvpQLGoj.js.map +1 -0
  3. package/dist/LegacyAuthenticator.lazy-ChFFqP14-ChFFqP14.js +5 -0
  4. package/dist/LegacyAuthenticator.lazy-ChFFqP14-ChFFqP14.js.map +1 -0
  5. package/dist/aerogel-plugin-local-first.d.ts +269 -0
  6. package/dist/aerogel-plugin-local-first.js +1686 -0
  7. package/dist/aerogel-plugin-local-first.js.map +1 -0
  8. package/package.json +50 -0
  9. package/src/components/AGCloudStatus.vue +31 -0
  10. package/src/components/index.ts +3 -0
  11. package/src/index.ts +29 -0
  12. package/src/jobs/Backup.ts +98 -0
  13. package/src/jobs/Migrate.test.ts +217 -0
  14. package/src/jobs/Migrate.ts +179 -0
  15. package/src/jobs/Sync.test.ts +982 -0
  16. package/src/jobs/Sync.ts +590 -0
  17. package/src/jobs/mixins/LoadsChildren.ts +210 -0
  18. package/src/jobs/mixins/LoadsTypeIndex.ts +23 -0
  19. package/src/jobs/mixins/TracksLocalModels.ts +32 -0
  20. package/src/lib/SyncQueue.ts +94 -0
  21. package/src/lib/inference.ts +124 -0
  22. package/src/lib/mirroring.ts +327 -0
  23. package/src/lib/utils.ts +21 -0
  24. package/src/services/Cloud.state.ts +111 -0
  25. package/src/services/Cloud.ts +450 -0
  26. package/src/services/DocumentsCache.ts +35 -0
  27. package/src/testing/cloud.ts +193 -0
  28. package/src/testing/mocks/Solid.mock.ts +34 -0
  29. package/src/testing/setup.ts +32 -0
  30. package/src/testing/stubs/LegacyTask.schema.ts +15 -0
  31. package/src/testing/stubs/Movie.schema.ts +15 -0
  32. package/src/testing/stubs/Movie.ts +3 -0
  33. package/src/testing/stubs/MoviesContainer.ts +20 -0
  34. package/src/testing/stubs/Task.schema.ts +12 -0
  35. package/src/testing/stubs/Task.ts +3 -0
  36. package/src/testing/stubs/TasksList.ts +20 -0
  37. package/src/testing/stubs/Workspace.ts +15 -0
  38. package/src/types/shims.d.ts +7 -0
@@ -0,0 +1,327 @@
1
+ import { bootModels } from 'soukai';
2
+ import {
3
+ fail,
4
+ objectWithout,
5
+ requireUrlParentDirectory,
6
+ required,
7
+ tap,
8
+ toString,
9
+ urlResolveDirectory,
10
+ urlRoute,
11
+ } from '@noeldemartin/utils';
12
+ import { App } from '@aerogel/core';
13
+ import { getTrackedModels, trackModelsCollection as trackSoukaiModelsCollection } from '@aerogel/plugin-soukai';
14
+ import { Solid } from '@aerogel/plugin-solid';
15
+ import { PropertyOperation, SolidContainer, SolidContainsRelation, Tombstone } from 'soukai-solid';
16
+ import type { ObjectsMap } from '@noeldemartin/utils';
17
+ import type { Attributes } from 'soukai';
18
+ import type { SolidModel, SolidModelConstructor, SolidSchemaDefinition } from 'soukai-solid';
19
+
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
+ await trackSoukaiModelsCollection(modelClass, collection);
92
+ }
93
+
94
+ export async function updateRemoteModel(localModel: SolidModel): Promise<void> {
95
+ if (ignoreModelUpdates(localModel)) {
96
+ return;
97
+ }
98
+
99
+ const modelUpdates = Cloud.localModelUpdates[localModel.url] ?? 0;
100
+
101
+ Cloud.setState({
102
+ localModelUpdates: localModel.isSoftDeleted()
103
+ ? objectWithout(Cloud.localModelUpdates, localModel.url)
104
+ : {
105
+ ...Cloud.localModelUpdates,
106
+ [localModel.url]: modelUpdates + 1,
107
+ },
108
+ });
109
+
110
+ if (Cloud.autoPush && Cloud.ready && !Cloud.syncing) {
111
+ SyncQueue.push(localModel);
112
+ }
113
+ }
114
+
115
+ export function clearLocalModelUpdates(models?: SolidModel[], malformedDocuments?: Set<string>): void {
116
+ const syncedModelUrls = models?.map((model) => toString(model.getDeletedPrimaryKey() ?? model.getPrimaryKey()));
117
+ const localModelUpdates = {} as Record<string, number>;
118
+
119
+ for (const [url, count] of Object.entries(Cloud.localModelUpdates)) {
120
+ const documentUrl = urlRoute(url);
121
+ const wasSynced =
122
+ !syncedModelUrls ||
123
+ syncedModelUrls.some(
124
+ (modelUrl) => url === modelUrl || (modelUrl.endsWith('/') && url.startsWith(modelUrl)),
125
+ );
126
+
127
+ if (wasSynced && !malformedDocuments?.has(documentUrl)) {
128
+ continue;
129
+ }
130
+
131
+ localModelUpdates[url] = count;
132
+ }
133
+
134
+ Cloud.setState({ localModelUpdates });
135
+ }
136
+
137
+ export function cloneLocalModel(localModel: SolidModel, options: CloneOptions = {}): SolidModel {
138
+ const localModelClasses = new Set<typeof SolidModel>();
139
+
140
+ localModelClasses.add(localModel.static());
141
+
142
+ if (localModel instanceof SolidContainer) {
143
+ for (const relation of getContainerRelations(localModel.static())) {
144
+ localModelClasses.add(localModel.requireRelation<SolidContainsRelation>(relation).relatedClass);
145
+ }
146
+ }
147
+
148
+ const constructors = [...localModelClasses.values()].map(
149
+ (localClass) => [localClass, getRemoteClass(localClass)] as [typeof SolidModel, typeof SolidModel],
150
+ );
151
+
152
+ return tap(localModel.clone({ constructors }), (model) => {
153
+ fixRemoteModelClone(model);
154
+
155
+ options.clean && cleanRemoteModelClone(model);
156
+ });
157
+ }
158
+
159
+ export function cloneRemoteModel(remoteModel: SolidModel): SolidModel {
160
+ const remoteModelClasses = new Set<typeof SolidModel>();
161
+
162
+ remoteModelClasses.add(remoteModel.static());
163
+
164
+ if (remoteModel instanceof SolidContainer) {
165
+ for (const relation of getContainerRelations(remoteModel.static())) {
166
+ remoteModelClasses.add(remoteModel.requireRelation<SolidContainsRelation>(relation).relatedClass);
167
+ }
168
+ }
169
+
170
+ const constructors = [...remoteModelClasses.values()].map(
171
+ (remoteClass) => [remoteClass, getLocalClass(remoteClass)] as [typeof SolidModel, typeof SolidModel],
172
+ );
173
+
174
+ return remoteModel.clone({ constructors });
175
+ }
176
+
177
+ export async function completeRemoteModels(
178
+ localModels: SolidModel[],
179
+ remoteModels: SolidModel[],
180
+ malformedDocuments: Set<string>,
181
+ ): Promise<SolidModel[]> {
182
+ const RemoteTombstone = getRemoteClass(Tombstone);
183
+ const remoteModelUrls = remoteModels.map((remoteModel) => remoteModel.url);
184
+ const missingModelDocumentUrls = localModels
185
+ .filter((localModel) => !remoteModelUrls.includes(localModel.url))
186
+ .map((localModel) => localModel.requireDocumentUrl())
187
+ .filter((documentUrl) => !malformedDocuments.has(documentUrl));
188
+ const tombstones = await RemoteTombstone.all({ $in: missingModelDocumentUrls });
189
+
190
+ return remoteModels.concat(tombstones);
191
+ }
192
+
193
+ export function ignoreModelUpdates(localModel: SolidModel): boolean {
194
+ const updatedAt = localModel.metadata.updatedAt?.getTime();
195
+
196
+ return !localModel.operations.some((operation) => {
197
+ if (operation.date.getTime() !== updatedAt) {
198
+ return false;
199
+ }
200
+
201
+ if (!(operation instanceof PropertyOperation)) {
202
+ return true;
203
+ }
204
+
205
+ return !localModel.ignoreRdfPropertyHistory(operation.property, true);
206
+ });
207
+ }
208
+
209
+ export function getLocalModel(remoteModel: SolidModel, localModels: ObjectsMap<SolidModel>): SolidModel {
210
+ return localModels.get(remoteModel.url) ?? cloneRemoteModel(remoteModel);
211
+ }
212
+
213
+ export function getLocalModels(): SolidModel[] {
214
+ return [...Cloud.registeredModels].map(({ modelClass }) => getTrackedModels(modelClass)).flat();
215
+ }
216
+
217
+ export function getSchemaMigrations(): Map<SolidModelConstructor, SolidModelConstructor | SolidSchemaDefinition> {
218
+ return Cloud.schemaMigrations;
219
+ }
220
+
221
+ export function clearSchemaMigrations(): void {
222
+ Cloud.schemaMigrations.clear();
223
+ }
224
+
225
+ export function getSchemaMigration(
226
+ modelClass: SolidModelConstructor,
227
+ ): SolidModelConstructor | SolidSchemaDefinition | undefined {
228
+ return Cloud.schemaMigrations.get(modelClass);
229
+ }
230
+
231
+ export function getRemoteClass<T extends SolidModelConstructor>(localClass: T): T {
232
+ return (remoteClasses.get(localClass) as T) ?? makeRemoteClass(localClass);
233
+ }
234
+
235
+ export function getRemoteContainersCollection(path?: string): string {
236
+ const rootStorage = Solid.requireUser().storageUrls[0];
237
+
238
+ return path
239
+ ? urlResolveDirectory((path.startsWith('/') ? rootStorage.slice(0, -1) : rootStorage) + path)
240
+ : rootStorage;
241
+ }
242
+
243
+ export function getRemoteModel(localModel: SolidModel, remoteModels: ObjectsMap<SolidModel>): SolidModel {
244
+ return remoteModels.get(localModel.url) ?? cloneLocalModel(localModel);
245
+ }
246
+
247
+ export function isRegisteredModel(model: SolidModel): boolean {
248
+ for (const { modelClass } of Cloud.registeredModels) {
249
+ const models = getTrackedModels(modelClass);
250
+
251
+ if (models.some((registeredModel) => registeredModel.url === model.url)) {
252
+ return true;
253
+ }
254
+ }
255
+
256
+ return false;
257
+ }
258
+
259
+ export function isRemoteModel(model: SolidModel): boolean {
260
+ return localClasses.has(model.static());
261
+ }
262
+
263
+ export function isLocalModel(model: SolidModel): boolean {
264
+ return !isRemoteModel(model);
265
+ }
266
+
267
+ export function isRegisteredModelClass(modelClass: SolidModelConstructor): boolean {
268
+ return Cloud.registeredModels.some(({ modelClass: registeredModelClass }) => registeredModelClass === modelClass);
269
+ }
270
+
271
+ export function makeRemoteClass<T extends SolidModelConstructor>(localClass: T): T {
272
+ const LocalClass = localClass as typeof SolidModel;
273
+ const withLocalHistoryConfig = (modelClass: typeof SolidModel, operation: () => unknown) => {
274
+ modelClass.timestamps = LocalClass.timestamps;
275
+ modelClass.history = LocalClass.history;
276
+
277
+ operation();
278
+
279
+ modelClass.timestamps = [];
280
+ modelClass.history = false;
281
+ };
282
+ const RemoteClass = class extends LocalClass {
283
+
284
+ public static override timestamps = false;
285
+ public static override history = false;
286
+
287
+ protected override initialize(attributes: Attributes, exists: boolean): void {
288
+ withLocalHistoryConfig(this._proxy.static(), () => super.initialize(attributes, exists));
289
+ }
290
+
291
+ protected override initializeRelations(): void {
292
+ super.initializeRelations();
293
+
294
+ for (const relation of Object.values(this._relations)) {
295
+ if (!(relation instanceof SolidContainsRelation)) {
296
+ continue;
297
+ }
298
+
299
+ relation.relatedClass = getRemoteClass(relation.relatedClass);
300
+ }
301
+ }
302
+
303
+ public override fixMalformedAttributes(): void {
304
+ withLocalHistoryConfig(this.static(), () => super.fixMalformedAttributes());
305
+ }
306
+
307
+ } as unknown as T;
308
+
309
+ remoteClasses.set(LocalClass, RemoteClass);
310
+ localClasses.set(RemoteClass, LocalClass);
311
+
312
+ LocalClass.on('schema-updated', async (schema) => {
313
+ await RemoteClass.updateSchema({
314
+ ...schema,
315
+ timestamps: false,
316
+ history: false,
317
+ });
318
+ });
319
+
320
+ bootModels({ [`Remote${localClass.modelName}`]: RemoteClass });
321
+
322
+ return RemoteClass;
323
+ }
324
+
325
+ export function getLocalClass<T extends SolidModelConstructor>(remoteClass: T): T {
326
+ return (localClasses.get(remoteClass) as T) ?? fail(`Couldn't find local class for ${remoteClass.modelName}`);
327
+ }
@@ -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
+ });