@aerogel/plugin-local-first 0.0.0

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,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];
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
+ }
@@ -0,0 +1,179 @@
1
+ import { PropertyOperation, isContainer } from 'soukai-solid';
2
+ import { Job } from '@aerogel/core';
3
+ import { isInstanceOf, map, mixed, objectMap, tap } from '@noeldemartin/utils';
4
+ import type { JobListener, JobStatus } from '@aerogel/core';
5
+ import type { Nullable } from '@noeldemartin/utils';
6
+ import type { SolidModel, SolidTypeIndex } from 'soukai-solid';
7
+
8
+ import {
9
+ clearSchemaMigrations,
10
+ getRemoteClass,
11
+ getSchemaMigration,
12
+ getSchemaMigrations,
13
+ } from '@aerogel/plugin-local-first/lib/mirroring';
14
+ import { getContainerRegisteredClasses } from '@aerogel/plugin-local-first/lib/inference';
15
+
16
+ import LoadsChildren from './mixins/LoadsChildren';
17
+ import LoadsTypeIndex from './mixins/LoadsTypeIndex';
18
+ import TracksLocalModels from './mixins/TracksLocalModels';
19
+
20
+ const BaseJob = Job as typeof Job<JobListener, MigrateJobStatus, MigrateJobStatus>;
21
+
22
+ export interface MigrateJobStatus extends JobStatus {
23
+ children?: MigrateModelJobStatus[];
24
+ }
25
+
26
+ export interface MigrateModelJobStatus extends JobStatus {
27
+ modelUrl: string;
28
+ children?: MigrateModelJobStatus[];
29
+ }
30
+
31
+ export default class Migrate extends mixed(BaseJob, [LoadsChildren, LoadsTypeIndex, TracksLocalModels]) {
32
+
33
+ public static restore(status: MigrateJobStatus): Migrate {
34
+ return tap(new Migrate(), (job) => {
35
+ job.status = status;
36
+ });
37
+ }
38
+
39
+ protected _models?: SolidModel[];
40
+
41
+ public get models(): SolidModel[] | undefined {
42
+ return this._models;
43
+ }
44
+
45
+ public set models(models: SolidModel[] | undefined) {
46
+ if (!models) {
47
+ delete this._models;
48
+ delete this.localModels;
49
+
50
+ return;
51
+ }
52
+
53
+ const childrenStatus = objectMap(this.status.children ?? [], 'modelUrl');
54
+
55
+ this._models = models;
56
+ this.localModels = map([], 'url');
57
+ this.status.children = models.map(
58
+ (model) =>
59
+ childrenStatus[model.url] ?? {
60
+ modelUrl: model.url,
61
+ completed: false,
62
+ },
63
+ );
64
+ }
65
+
66
+ protected async run(): Promise<void> {
67
+ if (!this.models) {
68
+ throw new Error('Cant start migration without models');
69
+ }
70
+
71
+ await this.indexLocalModels(this.models);
72
+
73
+ const modelStatuses = this.status.children ?? [];
74
+ const typeIndex = await this.loadTypeIndex();
75
+
76
+ for (const modelStatus of modelStatuses) {
77
+ const model = this.models.find(({ url }) => modelStatus.modelUrl === url);
78
+
79
+ if (!model || modelStatus.completed) {
80
+ continue;
81
+ }
82
+
83
+ await this.migrateRegisteredModel(model, modelStatus, typeIndex);
84
+ await this.updateProgress(() => (modelStatus.completed = true));
85
+ }
86
+
87
+ for (const [modelClass, schema] of getSchemaMigrations()) {
88
+ await modelClass.updateSchema(schema);
89
+ }
90
+
91
+ clearSchemaMigrations();
92
+ }
93
+
94
+ protected async migrateRegisteredModel(
95
+ model: SolidModel,
96
+ status: MigrateModelJobStatus,
97
+ typeIndex?: Nullable<SolidTypeIndex>,
98
+ ): Promise<void> {
99
+ await this.migrate(model, status);
100
+
101
+ if (!isContainer(model)) {
102
+ return;
103
+ }
104
+
105
+ const registeredClasses = getContainerRegisteredClasses(model.static());
106
+ const registration = typeIndex?.registrations.find(({ instanceContainer }) => instanceContainer === model.url);
107
+
108
+ if (!typeIndex || !registration || registeredClasses.length === 0) {
109
+ return;
110
+ }
111
+
112
+ registration.setAttribute(
113
+ 'forClass',
114
+ registeredClasses
115
+ .map((modelClass) => getSchemaMigration(modelClass)?.rdfsClasses ?? modelClass.rdfsClass)
116
+ .flat(),
117
+ );
118
+
119
+ await typeIndex.save();
120
+ }
121
+
122
+ protected async migrate(localModel: SolidModel, status: MigrateModelJobStatus): Promise<void> {
123
+ this.assertNotCancelled();
124
+
125
+ await this.migrateChildren(localModel, status);
126
+
127
+ const schema = getSchemaMigration(localModel.static());
128
+
129
+ if (!schema) {
130
+ return;
131
+ }
132
+
133
+ const remoteModel = await getRemoteClass(localModel.static()).find(localModel.url);
134
+ const migratedModel = await remoteModel?.migrateSchema(schema);
135
+
136
+ await localModel.migrateSchema(schema, {
137
+ mintOperationUrl: (operation) => {
138
+ return migratedModel?.operations?.find((migratedOperation) => {
139
+ if (
140
+ !isInstanceOf(operation, PropertyOperation) ||
141
+ !isInstanceOf(migratedOperation, PropertyOperation)
142
+ ) {
143
+ return false;
144
+ }
145
+
146
+ return (
147
+ operation.property === migratedOperation.property &&
148
+ operation.date.getTime() === migratedOperation.date.getTime()
149
+ );
150
+ })?.url;
151
+ },
152
+ });
153
+ }
154
+
155
+ protected async migrateChildren(localModel: SolidModel, status: MigrateModelJobStatus): Promise<void> {
156
+ if (!isContainer(localModel)) {
157
+ return;
158
+ }
159
+
160
+ const children = await this.loadContainedModels(localModel, { ignoreTombstones: true });
161
+
162
+ status.children ??= children.map((child) => ({
163
+ modelUrl: child.url,
164
+ completed: false,
165
+ }));
166
+
167
+ for (const childStatus of status.children) {
168
+ const child = children.find(({ url }) => childStatus.modelUrl === url);
169
+
170
+ if (!child || childStatus.completed) {
171
+ continue;
172
+ }
173
+
174
+ await this.migrate(child, childStatus);
175
+ await this.updateProgress(() => (childStatus.completed = true));
176
+ }
177
+ }
178
+
179
+ }