@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,1198 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import { Events } from '@aerogel/core';
3
+ import { FakeResponse, FakeServer, fakeContainerUrl, fakeDocumentUrl } from '@noeldemartin/testing';
4
+ import { arrayFind, required } from '@noeldemartin/utils';
5
+ import { Solid } from '@aerogel/plugin-solid';
6
+ import { SolidTypeRegistration } from 'soukai-solid';
7
+
8
+ import Cloud from '@aerogel/plugin-local-first/services/Cloud';
9
+
10
+ import {
11
+ containerResponse,
12
+ movieResponse,
13
+ setupCloudTests,
14
+ taskResponse,
15
+ testRegisterVariants,
16
+ tombstoneResponse,
17
+ typeIndexResponse,
18
+ } from '@aerogel/plugin-local-first/testing/cloud';
19
+ import Movie from '@aerogel/plugin-local-first/testing/stubs/Movie';
20
+ import MoviesContainer from '@aerogel/plugin-local-first/testing/stubs/MoviesContainer';
21
+ import Post from '@aerogel/plugin-local-first/testing/stubs/Post';
22
+ import SolidMock from '@aerogel/plugin-local-first/testing/mocks/Solid.mock';
23
+ import Workspace from '@aerogel/plugin-local-first/testing/stubs/Workspace';
24
+ import Person from '@aerogel/plugin-local-first/testing/stubs/Person';
25
+
26
+ describe('Sync', () => {
27
+
28
+ beforeEach(setupCloudTests);
29
+
30
+ it('Syncs containers', async () => {
31
+ // Arrange - Mint urls
32
+ const parentContainerUrl = SolidMock.requireUser().storageUrls[0];
33
+ const remoteContainerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
34
+ const localContainerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
35
+
36
+ // Arrange - Prepare models
37
+ await MoviesContainer.at(parentContainerUrl).create({
38
+ url: localContainerUrl,
39
+ name: 'Local Movies',
40
+ });
41
+
42
+ // Arrange - Prepare responses
43
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
44
+
45
+ FakeServer.respond(typeIndexUrl, typeIndexResponse({ [remoteContainerUrl]: '<https://schema.org/Movie>' }));
46
+ FakeServer.respond(remoteContainerUrl, containerResponse({ name: 'Remote Movies' }));
47
+ FakeServer.respondOnce(localContainerUrl, FakeResponse.notFound()); // Tombstone check
48
+ FakeServer.respondOnce(localContainerUrl, FakeResponse.notFound()); // Check before create
49
+ FakeServer.respondOnce(localContainerUrl, FakeResponse.created()); // Create
50
+ FakeServer.respondOnce(
51
+ // Read described-by header
52
+ localContainerUrl,
53
+ FakeResponse.success('<> a <http://www.w3.org/ns/ldp#Container> .'),
54
+ );
55
+ FakeServer.respondOnce(`${localContainerUrl}.meta`, FakeResponse.success()); // Update meta
56
+
57
+ // Arrange - Prepare service
58
+ await Cloud.launch();
59
+ await Cloud.register(MoviesContainer);
60
+ await Events.emit('application-ready');
61
+
62
+ // Act
63
+ const updates: number[] = [];
64
+
65
+ await Cloud.sync({ onUpdated: (progress) => updates.push(progress) });
66
+
67
+ // Assert
68
+ expect(updates).toEqual([0, 0.9, 1]);
69
+
70
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(3);
71
+ expect(FakeServer.getRequests(remoteContainerUrl)).toHaveLength(1);
72
+ expect(FakeServer.getRequests(localContainerUrl)).toHaveLength(4);
73
+ expect(FakeServer.getRequests(localContainerUrl + '.meta')).toHaveLength(1);
74
+ expect(FakeServer.getRequests()).toHaveLength(9);
75
+ expect(FakeServer.getRequest('PATCH', typeIndexUrl)?.body).toEqualSparql(`
76
+ INSERT DATA {
77
+ <#[[.*]]>
78
+ a <http://www.w3.org/ns/solid/terms#TypeRegistration> ;
79
+ <http://www.w3.org/ns/solid/terms#forClass> <https://schema.org/Movie> ;
80
+ <http://www.w3.org/ns/solid/terms#instanceContainer> <${localContainerUrl}> .
81
+ }
82
+ `);
83
+
84
+ const typeIndex = await Solid.findPrivateTypeIndex();
85
+ const findRegistration = (url: string) => arrayFind(typeIndex?.registrations ?? [], 'instanceContainer', url);
86
+ expect(typeIndex).not.toBeNull();
87
+ expect(typeIndex?.registrations).toHaveLength(2);
88
+ expect(findRegistration(remoteContainerUrl)).toBeInstanceOf(SolidTypeRegistration);
89
+ expect(findRegistration(localContainerUrl)).toBeInstanceOf(SolidTypeRegistration);
90
+
91
+ const containers = await MoviesContainer.all();
92
+ expect(containers).toHaveLength(2);
93
+ expect(arrayFind(containers, 'name', 'Local Movies')).toBeInstanceOf(MoviesContainer);
94
+ expect(arrayFind(containers, 'name', 'Remote Movies')).toBeInstanceOf(MoviesContainer);
95
+
96
+ expect(Cloud.localModelUpdates).toEqual({});
97
+ });
98
+
99
+ it('Syncs container documents', async () => {
100
+ // Arrange - Mint urls
101
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
102
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
103
+ const localDocumentUrl = fakeDocumentUrl({ containerUrl });
104
+ const remoteDocumentUrl = fakeDocumentUrl({ containerUrl });
105
+
106
+ // Arrange - Prepare models
107
+ const container = await MoviesContainer.at(parentContainerUrl).create({
108
+ url: containerUrl,
109
+ name: 'Remote Movies',
110
+ });
111
+
112
+ await container.relatedMovies.create({ url: `${localDocumentUrl}#it`, name: 'The Tale of Princess Kaguya' });
113
+
114
+ // Arrange - Prepare responses
115
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
116
+
117
+ FakeServer.respond(typeIndexUrl, typeIndexResponse({ [containerUrl]: '<https://schema.org/Movie>' }));
118
+ FakeServer.respond(
119
+ containerUrl,
120
+ containerResponse({
121
+ name: 'Remote Movies',
122
+ documentUrls: [remoteDocumentUrl],
123
+ createdAt: container.createdAt,
124
+ updatedAt: container.updatedAt,
125
+ }),
126
+ );
127
+ FakeServer.respond(remoteDocumentUrl, movieResponse('Spirited Away'));
128
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.notFound()); // Check before create
129
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.created()); // Create
130
+
131
+ // Arrange - Prepare service
132
+ await Cloud.launch();
133
+ await Cloud.register(MoviesContainer);
134
+ await Events.emit('application-ready');
135
+
136
+ // Act
137
+ const updates: number[] = [];
138
+
139
+ await Cloud.sync({ onUpdated: (progress) => updates.push(progress) });
140
+
141
+ // Assert
142
+ expect(updates).toEqual([0, 0.9, 0.95, 1]);
143
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(1);
144
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
145
+ expect(FakeServer.getRequests(localDocumentUrl)).toHaveLength(2);
146
+ expect(FakeServer.getRequests(remoteDocumentUrl)).toHaveLength(1);
147
+ expect(FakeServer.getRequests()).toHaveLength(5);
148
+
149
+ const movies = await Movie.from(containerUrl).all();
150
+ expect(movies).toHaveLength(2);
151
+ expect(arrayFind(movies, 'name', 'The Tale of Princess Kaguya')).toBeInstanceOf(Movie);
152
+ expect(arrayFind(movies, 'name', 'Spirited Away')).toBeInstanceOf(Movie);
153
+
154
+ expect(Cloud.localModelUpdates).toEqual({});
155
+ });
156
+
157
+ it('Pushes container documents', async () => {
158
+ // Arrange - Mint urls
159
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
160
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
161
+ const localDocumentUrl = fakeDocumentUrl({ containerUrl });
162
+
163
+ // Arrange - Prepare models
164
+ const container = await MoviesContainer.at(parentContainerUrl).create({
165
+ url: containerUrl,
166
+ name: 'Remote Movies',
167
+ });
168
+
169
+ await container.relatedMovies.create({ url: `${localDocumentUrl}#it`, name: 'The Tale of Princess Kaguya' });
170
+
171
+ // Arrange - Prepare responses
172
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
173
+
174
+ FakeServer.respond(typeIndexUrl, typeIndexResponse({ [containerUrl]: '<https://schema.org/Movie>' }));
175
+ FakeServer.respond(
176
+ containerUrl,
177
+ containerResponse({
178
+ name: 'Remote Movies',
179
+ documentUrls: [],
180
+ createdAt: container.createdAt,
181
+ updatedAt: container.updatedAt,
182
+ }),
183
+ );
184
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.notFound()); // Check before create
185
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.created()); // Create
186
+
187
+ // Arrange - Prepare service
188
+ await Cloud.launch();
189
+ await Cloud.register(MoviesContainer);
190
+ await Events.emit('application-ready');
191
+
192
+ // Act
193
+ const updates: number[] = [];
194
+
195
+ await Cloud.sync({ onUpdated: (progress) => updates.push(progress) });
196
+
197
+ // Assert
198
+ expect(updates).toEqual([0, 0.9, 1]);
199
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(1);
200
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
201
+ expect(FakeServer.getRequests(localDocumentUrl)).toHaveLength(2);
202
+ expect(FakeServer.getRequests()).toHaveLength(4);
203
+
204
+ const movies = await Movie.from(containerUrl).all();
205
+ expect(movies).toHaveLength(1);
206
+ expect(arrayFind(movies, 'name', 'The Tale of Princess Kaguya')).toBeInstanceOf(Movie);
207
+
208
+ expect(Cloud.localModelUpdates).toEqual({});
209
+ });
210
+
211
+ it('Pulls container documents', async () => {
212
+ // Arrange - Mint urls
213
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
214
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
215
+ const remoteDocumentUrl = fakeDocumentUrl({ containerUrl });
216
+
217
+ // Arrange - Prepare models
218
+ const container = await MoviesContainer.at(parentContainerUrl).create({
219
+ url: containerUrl,
220
+ name: 'Remote Movies',
221
+ });
222
+
223
+ // Arrange - Prepare responses
224
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
225
+
226
+ FakeServer.respond(typeIndexUrl, typeIndexResponse({ [containerUrl]: '<https://schema.org/Movie>' }));
227
+ FakeServer.respond(
228
+ containerUrl,
229
+ containerResponse({
230
+ name: 'Remote Movies',
231
+ documentUrls: [remoteDocumentUrl],
232
+ createdAt: container.createdAt,
233
+ updatedAt: container.updatedAt,
234
+ }),
235
+ );
236
+ FakeServer.respond(remoteDocumentUrl, movieResponse('Spirited Away'));
237
+
238
+ // Arrange - Prepare service
239
+ await Cloud.launch();
240
+ await Cloud.register(MoviesContainer);
241
+ await Events.emit('application-ready');
242
+
243
+ // Act
244
+ const updates: number[] = [];
245
+
246
+ await Cloud.sync({ onUpdated: (progress) => updates.push(progress) });
247
+
248
+ // Assert
249
+ expect(updates).toEqual([0, 0.9, 1]);
250
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(1);
251
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
252
+ expect(FakeServer.getRequests(remoteDocumentUrl)).toHaveLength(1);
253
+ expect(FakeServer.getRequests()).toHaveLength(3);
254
+
255
+ const movies = await Movie.from(containerUrl).all();
256
+ expect(movies).toHaveLength(1);
257
+ expect(arrayFind(movies, 'name', 'Spirited Away')).toBeInstanceOf(Movie);
258
+
259
+ expect(Cloud.localModelUpdates).toEqual({});
260
+ });
261
+
262
+ it('Syncs documents', async () => {
263
+ // Arrange - Mint urls
264
+ const containerUrl = Solid.requireUser().storageUrls[0];
265
+ const remoteDocumentUrl = fakeDocumentUrl({ containerUrl });
266
+ const localDocumentUrl = fakeDocumentUrl({ containerUrl });
267
+
268
+ // Arrange - Prepare models
269
+ await Movie.at(containerUrl).create({
270
+ url: `${localDocumentUrl}#it`,
271
+ name: 'The Tale of Princess Kaguya',
272
+ });
273
+
274
+ // Arrange - Prepare responses
275
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
276
+
277
+ FakeServer.respond(typeIndexUrl, typeIndexResponse({ [containerUrl]: '<https://schema.org/Movie>' }));
278
+ FakeServer.respond(containerUrl, containerResponse({ name: 'Movies', documentUrls: [remoteDocumentUrl] }));
279
+ FakeServer.respond(remoteDocumentUrl, movieResponse('Spirited Away'));
280
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.notFound()); // Tombstone check
281
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.notFound()); // Check before create
282
+ FakeServer.respondOnce(localDocumentUrl, FakeResponse.created()); // Create
283
+
284
+ // Arrange - Prepare service
285
+ await Cloud.launch();
286
+ await Cloud.register(Movie);
287
+ await Events.emit('application-ready');
288
+
289
+ // Act
290
+ const updates: number[] = [];
291
+
292
+ await Cloud.sync({ onUpdated: (progress) => updates.push(progress) });
293
+
294
+ // Assert
295
+ expect(updates).toEqual([0, 0.9, 1]);
296
+
297
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(1);
298
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
299
+ expect(FakeServer.getRequests(localDocumentUrl)).toHaveLength(3);
300
+ expect(FakeServer.getRequests(remoteDocumentUrl)).toHaveLength(1);
301
+ expect(FakeServer.getRequests()).toHaveLength(6);
302
+
303
+ const movies = await Movie.all();
304
+ expect(movies).toHaveLength(2);
305
+ expect(arrayFind(movies, 'name', 'The Tale of Princess Kaguya')).toBeInstanceOf(Movie);
306
+ expect(arrayFind(movies, 'name', 'Spirited Away')).toBeInstanceOf(Movie);
307
+
308
+ expect(Cloud.localModelUpdates).toEqual({});
309
+ });
310
+
311
+ it('Syncs individual container updates', async () => {
312
+ // Arrange - Mint urls
313
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
314
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
315
+ const movieDocumentUrl = fakeDocumentUrl({ containerUrl });
316
+
317
+ // Arrange - Prepare models
318
+ const container = await MoviesContainer.at(parentContainerUrl).create({
319
+ url: containerUrl,
320
+ name: 'Movies',
321
+ });
322
+
323
+ const movie = await container.relatedMovies.create({
324
+ url: `${movieDocumentUrl}#it`,
325
+ name: 'The Tale of Princess Kaguya',
326
+ });
327
+
328
+ // Arrange - Prepare responses
329
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
330
+ const typeIndexTurtle = `<${typeIndexUrl}> a <http://www.w3.org/ns/solid/terms#TypeIndex> .`;
331
+
332
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle)); // Initial fetch
333
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle)); // Check before update
334
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success()); // Update
335
+
336
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Tombstone check
337
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Check before create
338
+ FakeServer.respondOnce(containerUrl, FakeResponse.created()); // Create
339
+ FakeServer.respondOnce(
340
+ // Read described-by header
341
+ containerUrl,
342
+ FakeResponse.success('<> a <http://www.w3.org/ns/ldp#Container> .'),
343
+ );
344
+ FakeServer.respondOnce(`${containerUrl}.meta`, FakeResponse.success()); // Update meta
345
+
346
+ FakeServer.respondOnce(movieDocumentUrl, FakeResponse.notFound()); // Check before create
347
+ FakeServer.respondOnce(movieDocumentUrl, FakeResponse.created()); // Create
348
+
349
+ FakeServer.respondOnce(
350
+ containerUrl,
351
+ containerResponse({
352
+ name: 'Movies',
353
+ documentUrls: [movieDocumentUrl],
354
+ createdAt: container.createdAt,
355
+ updatedAt: container.updatedAt,
356
+ }),
357
+ ); // Sync
358
+ FakeServer.respondOnce(movieDocumentUrl, FakeResponse.success(await movie.toTurtle())); // Sync children
359
+ FakeServer.respondOnce(
360
+ `${containerUrl}.meta`,
361
+ containerResponse({
362
+ name: 'Movies',
363
+ documentUrls: [movieDocumentUrl],
364
+ createdAt: container.createdAt,
365
+ updatedAt: container.updatedAt,
366
+ }),
367
+ ); // Before update
368
+ FakeServer.respondOnce(`${containerUrl}.meta`, FakeResponse.success()); // Update
369
+
370
+ // Arrange - Prepare service
371
+ await Cloud.launch();
372
+ await Cloud.register(MoviesContainer);
373
+ await Events.emit('application-ready');
374
+
375
+ // Arrange - Initial sync
376
+ await Cloud.sync();
377
+
378
+ // Act
379
+ const updates: number[] = [];
380
+
381
+ await container.update({ name: 'Great Movies' });
382
+ await Cloud.sync({ model: container, onUpdated: (progress) => updates.push(progress) });
383
+
384
+ // Assert
385
+ expect(updates).toEqual([0, 0.9, 1]);
386
+
387
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(3);
388
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(5);
389
+ expect(FakeServer.getRequests(`${containerUrl}.meta`)).toHaveLength(3);
390
+ expect(FakeServer.getRequests(movieDocumentUrl)).toHaveLength(3);
391
+ expect(FakeServer.getRequests()).toHaveLength(14);
392
+
393
+ expect(FakeServer.getRequests('PATCH', `${containerUrl}.meta`)[1]?.body).toEqualSparql(`
394
+ DELETE DATA {
395
+ @prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
396
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
397
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
398
+
399
+ <${containerUrl}> rdfs:label "Movies" .
400
+ <${containerUrl}#metadata> crdt:updatedAt "[[createdAt][.*]]"^^xsd:dateTime .
401
+ } ;
402
+ INSERT DATA {
403
+ @prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
404
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
405
+ @prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#>.
406
+
407
+ <${containerUrl}> rdfs:label "Great Movies" .
408
+ <${containerUrl}#metadata> crdt:updatedAt "[[updatedAt][.*]]"^^xsd:dateTime .
409
+
410
+ <${containerUrl}#operation-[[.*]]>
411
+ a crdt:SetPropertyOperation ;
412
+ crdt:resource <${containerUrl}> ;
413
+ crdt:date "[[createdAt][.*]]"^^xsd:dateTime ;
414
+ crdt:property rdfs:label ;
415
+ crdt:value "Movies" .
416
+
417
+ <${containerUrl}#operation-[[.*]]>
418
+ a crdt:SetPropertyOperation ;
419
+ crdt:resource <${containerUrl}> ;
420
+ crdt:date "[[updatedAt][.*]]"^^xsd:dateTime ;
421
+ crdt:property rdfs:label ;
422
+ crdt:value "Great Movies" .
423
+ } .
424
+ `);
425
+
426
+ expect(Cloud.localModelUpdates).toEqual({});
427
+ });
428
+
429
+ it('Syncs individual container documents', async () => {
430
+ // Arrange - Mint urls
431
+ const containerUrl = Solid.requireUser().storageUrls[0];
432
+ const documentUrl = fakeDocumentUrl({ containerUrl });
433
+
434
+ // Arrange - Prepare models
435
+ const container = await MoviesContainer.create({ url: containerUrl });
436
+
437
+ // Arrange - Prepare responses
438
+
439
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Pull
440
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Tombstone check
441
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Check before create
442
+ FakeServer.respondOnce(documentUrl, FakeResponse.created()); // Create
443
+
444
+ // Arrange - Prepare service
445
+ await Cloud.launch();
446
+ await Cloud.register(MoviesContainer);
447
+ await Events.emit('application-ready');
448
+
449
+ // Act
450
+ const updates: number[] = [];
451
+ const movie = await container.relatedMovies.create({
452
+ url: `${documentUrl}#it`,
453
+ name: 'The Tale of Princess Kaguya',
454
+ });
455
+
456
+ await Cloud.sync({ model: movie, onUpdated: (progress) => updates.push(progress) });
457
+
458
+ // Assert
459
+ expect(updates).toEqual([0, 0.9, 1]);
460
+
461
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(4);
462
+ expect(FakeServer.getRequests()).toHaveLength(4);
463
+
464
+ expect(Cloud.localModelUpdates).toEqual({});
465
+ });
466
+
467
+ it('Creates containers with documents', async () => {
468
+ // Arrange - Mint urls
469
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
470
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
471
+ const documentUrl = fakeDocumentUrl({ containerUrl });
472
+
473
+ // Arrange - Prepare models
474
+ const container = await MoviesContainer.at(parentContainerUrl).create({
475
+ url: containerUrl,
476
+ name: 'Movies',
477
+ });
478
+
479
+ await container.relatedMovies.create({
480
+ url: `${documentUrl}#it`,
481
+ name: 'The Tale of Princess Kaguya',
482
+ });
483
+
484
+ // Arrange - Prepare responses
485
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
486
+ const typeIndexTurtle = `<${typeIndexUrl}> a <http://www.w3.org/ns/solid/terms#TypeIndex> .`;
487
+
488
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle)); // Initial fetch
489
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle)); // Check before update
490
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success()); // Update
491
+
492
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Tombstone check
493
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Check before create
494
+ FakeServer.respondOnce(containerUrl, FakeResponse.created()); // Create
495
+ FakeServer.respondOnce(
496
+ // Read described-by header
497
+ containerUrl,
498
+ FakeResponse.success('<> a <http://www.w3.org/ns/ldp#Container> .'),
499
+ );
500
+ FakeServer.respondOnce(`${containerUrl}.meta`, FakeResponse.success()); // Update meta
501
+
502
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Check before create
503
+ FakeServer.respondOnce(documentUrl, FakeResponse.created()); // Create
504
+
505
+ // Arrange - Prepare service
506
+ await Cloud.launch();
507
+ await Cloud.register(MoviesContainer);
508
+ await Events.emit('application-ready');
509
+
510
+ // Act
511
+ await Cloud.sync();
512
+
513
+ // Assert
514
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(3);
515
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(4);
516
+ expect(FakeServer.getRequests(`${containerUrl}.meta`)).toHaveLength(1);
517
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(2);
518
+ expect(FakeServer.getRequests()).toHaveLength(10);
519
+
520
+ expect(Cloud.localModelUpdates).toEqual({});
521
+ });
522
+
523
+ it('Skips pulling fresh documents', async () => {
524
+ // Arrange - Mint urls
525
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'tasks/';
526
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
527
+ const childContainerUrl = fakeContainerUrl({ baseUrl: containerUrl });
528
+ const freshDocumentUrl = fakeDocumentUrl({ containerUrl });
529
+ const staleDocumentUrl = fakeDocumentUrl({ containerUrl });
530
+ const freshChildDocumentUrl = fakeDocumentUrl({ containerUrl: childContainerUrl });
531
+ const staleChildDocumentUrl = fakeDocumentUrl({ containerUrl: childContainerUrl });
532
+
533
+ // Arrange - Prepare models
534
+ const workspace = await Workspace.at(parentContainerUrl).create({
535
+ url: containerUrl,
536
+ name: 'Tasks',
537
+ });
538
+
539
+ const tasksList = await workspace.relatedLists.create({
540
+ url: childContainerUrl,
541
+ name: 'Child Tasks',
542
+ });
543
+
544
+ await workspace.relatedTasks.create({
545
+ url: `${freshDocumentUrl}#it`,
546
+ name: 'One',
547
+ });
548
+
549
+ await workspace.relatedTasks.create({
550
+ url: `${staleDocumentUrl}#it`,
551
+ name: 'Two',
552
+ });
553
+
554
+ await tasksList.relatedTasks.create({
555
+ url: `${freshChildDocumentUrl}#it`,
556
+ name: 'Three',
557
+ });
558
+
559
+ await tasksList.relatedTasks.create({
560
+ url: `${staleChildDocumentUrl}#it`,
561
+ name: 'Four',
562
+ });
563
+
564
+ // Arrange - Prepare responses
565
+ const now = Date.now();
566
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
567
+
568
+ FakeServer.respond(
569
+ typeIndexUrl,
570
+ `
571
+ <${typeIndexUrl}> a <http://www.w3.org/ns/solid/terms#TypeIndex> .
572
+
573
+ <#tasks>
574
+ a <http://www.w3.org/ns/solid/terms#TypeRegistration> ;
575
+ <http://www.w3.org/ns/solid/terms#forClass> <https://schema.org/Action> ;
576
+ <http://www.w3.org/ns/solid/terms#instanceContainer> <${containerUrl}> .
577
+ `,
578
+ );
579
+ FakeServer.respondOnce(
580
+ containerUrl,
581
+ containerResponse({
582
+ name: 'Tasks',
583
+ documentUrls: [freshDocumentUrl, staleDocumentUrl, childContainerUrl],
584
+ createdAt: workspace.createdAt,
585
+ updatedAt: workspace.updatedAt,
586
+ append: `
587
+ <${freshDocumentUrl}>
588
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
589
+ <http://purl.org/dc/terms/modified>
590
+ "${new Date(now).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
591
+
592
+ <${staleDocumentUrl}>
593
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
594
+ <http://purl.org/dc/terms/modified>
595
+ "${new Date(now).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
596
+ `,
597
+ }),
598
+ ); // Container
599
+ FakeServer.respondOnce(
600
+ childContainerUrl,
601
+ containerResponse({
602
+ name: 'Child Tasks',
603
+ documentUrls: [freshChildDocumentUrl, staleChildDocumentUrl],
604
+ createdAt: tasksList.createdAt,
605
+ updatedAt: tasksList.updatedAt,
606
+ append: `
607
+ <${freshChildDocumentUrl}>
608
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
609
+ <http://purl.org/dc/terms/modified>
610
+ "${new Date(now).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
611
+
612
+ <${staleChildDocumentUrl}>
613
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
614
+ <http://purl.org/dc/terms/modified>
615
+ "${new Date(now).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
616
+ `,
617
+ }),
618
+ ); // Child container
619
+ FakeServer.respondOnce(freshDocumentUrl, taskResponse('One'));
620
+ FakeServer.respondOnce(staleDocumentUrl, taskResponse('Two'));
621
+ FakeServer.respondOnce(freshChildDocumentUrl, taskResponse('Three'));
622
+ FakeServer.respondOnce(staleChildDocumentUrl, taskResponse('Four'));
623
+
624
+ FakeServer.respondOnce(
625
+ containerUrl,
626
+ containerResponse({
627
+ name: 'Tasks',
628
+ documentUrls: [freshDocumentUrl, staleDocumentUrl, childContainerUrl],
629
+ createdAt: workspace.createdAt,
630
+ updatedAt: workspace.updatedAt,
631
+ append: `
632
+ <${freshDocumentUrl}>
633
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
634
+ <http://purl.org/dc/terms/modified>
635
+ "${new Date(now).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
636
+
637
+ <${staleDocumentUrl}>
638
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
639
+ <http://purl.org/dc/terms/modified>
640
+ "${new Date(now + 1000).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
641
+ `,
642
+ }),
643
+ ); // Container
644
+ FakeServer.respondOnce(
645
+ childContainerUrl,
646
+ containerResponse({
647
+ name: 'Child Tasks',
648
+ documentUrls: [freshChildDocumentUrl, staleChildDocumentUrl],
649
+ createdAt: tasksList.createdAt,
650
+ updatedAt: tasksList.updatedAt,
651
+ append: `
652
+ <${freshChildDocumentUrl}>
653
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
654
+ <http://purl.org/dc/terms/modified>
655
+ "${new Date(now).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
656
+
657
+ <${staleChildDocumentUrl}>
658
+ a <http://www.w3.org/ns/iana/media-types/text/turtle#Resource> ;
659
+ <http://purl.org/dc/terms/modified>
660
+ "${new Date(now + 1000).toISOString()}"^^<http://www.w3.org/2001/XMLSchema#dateTime> .
661
+ `,
662
+ }),
663
+ ); // Child container
664
+ FakeServer.respondOnce(staleDocumentUrl, taskResponse('Two'));
665
+ FakeServer.respondOnce(staleChildDocumentUrl, taskResponse('Four'));
666
+
667
+ // Arrange - Prepare service
668
+ Cloud.ready = true;
669
+
670
+ await Cloud.launch();
671
+ await Cloud.register(Workspace);
672
+ await Events.emit('application-ready');
673
+
674
+ // Arrange - Initial sync
675
+ await Cloud.sync();
676
+
677
+ // Act
678
+ await Cloud.sync();
679
+
680
+ // Assert
681
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(2);
682
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(2);
683
+ expect(FakeServer.getRequests(childContainerUrl)).toHaveLength(2);
684
+ expect(FakeServer.getRequests(freshDocumentUrl)).toHaveLength(1);
685
+ expect(FakeServer.getRequests(staleDocumentUrl)).toHaveLength(2);
686
+ expect(FakeServer.getRequests(freshChildDocumentUrl)).toHaveLength(1);
687
+ expect(FakeServer.getRequests(staleChildDocumentUrl)).toHaveLength(2);
688
+ expect(FakeServer.getRequests()).toHaveLength(12);
689
+ });
690
+
691
+ it('Ignores missing children', async () => {
692
+ // Arrange - Mint urls
693
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'tasks/';
694
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
695
+ const existingChildDocumentUrl = fakeContainerUrl({ baseUrl: containerUrl });
696
+ const notFoundChildDocumentUrl = fakeDocumentUrl({ containerUrl });
697
+ const existingChildContainerUrl = fakeContainerUrl({ baseUrl: containerUrl });
698
+ const notFoundChildContainerUrl = fakeContainerUrl({ baseUrl: containerUrl });
699
+
700
+ // Arrange - Prepare responses
701
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
702
+ const typeIndexTurtle = `
703
+ <${typeIndexUrl}> a <http://www.w3.org/ns/solid/terms#TypeIndex> .
704
+
705
+ <#tasks>
706
+ a <http://www.w3.org/ns/solid/terms#TypeRegistration> ;
707
+ <http://www.w3.org/ns/solid/terms#forClass> <https://schema.org/Action> ;
708
+ <http://www.w3.org/ns/solid/terms#instanceContainer> <${containerUrl}> .
709
+ `;
710
+
711
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle)); // Initial fetch
712
+ FakeServer.respondOnce(
713
+ containerUrl,
714
+ containerResponse({
715
+ name: 'Tasks',
716
+ documentUrls: [
717
+ existingChildDocumentUrl,
718
+ notFoundChildDocumentUrl,
719
+ existingChildContainerUrl,
720
+ notFoundChildContainerUrl,
721
+ ],
722
+ }),
723
+ ); // Container
724
+ FakeServer.respondOnce(existingChildDocumentUrl, taskResponse());
725
+ FakeServer.respondOnce(notFoundChildDocumentUrl, FakeResponse.notFound());
726
+ FakeServer.respondOnce(existingChildContainerUrl, containerResponse());
727
+ FakeServer.respondOnce(notFoundChildContainerUrl, FakeResponse.notFound());
728
+
729
+ // Arrange - Prepare service
730
+ Cloud.ready = true;
731
+
732
+ await Cloud.launch();
733
+ await Cloud.register(Workspace);
734
+ await Events.emit('application-ready');
735
+
736
+ // Act
737
+ await Cloud.sync();
738
+
739
+ // Assert
740
+ const workspace = required(await Workspace.find(containerUrl));
741
+
742
+ await workspace.loadRelationIfUnloaded('tasks');
743
+ await workspace.loadRelationIfUnloaded('lists');
744
+
745
+ expect(workspace.tasks).toHaveLength(1);
746
+ expect(workspace.lists).toHaveLength(1);
747
+ });
748
+
749
+ it('Ignores malformed documents', async () => {
750
+ // Arrange - Mint urls
751
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
752
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
753
+ const firstDocumentUrl = fakeDocumentUrl({ containerUrl: containerUrl });
754
+ const secondDocumentUrl = fakeDocumentUrl({ containerUrl: containerUrl });
755
+ const thirdDocumentUrl = fakeDocumentUrl({ containerUrl: containerUrl });
756
+ const fourthDocumentUrl = fakeDocumentUrl({ containerUrl: containerUrl });
757
+
758
+ // Arrange - Prepare models
759
+ const container = await MoviesContainer.at(parentContainerUrl).create({
760
+ url: containerUrl,
761
+ name: 'Movies',
762
+ });
763
+ const movie = await container.relatedMovies.create({
764
+ url: `${thirdDocumentUrl}#it`,
765
+ name: 'The Tale of Princess Kaguya',
766
+ });
767
+
768
+ await container.relatedMovies.create({ url: `${secondDocumentUrl}#it`, name: 'Spirited Away' });
769
+ await movie.update({ name: 'かぐや姫の物語' });
770
+
771
+ // Arrange - Prepare responses
772
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
773
+ const typeIndexTurtle = `
774
+ <${typeIndexUrl}> a <http://www.w3.org/ns/solid/terms#TypeIndex> .
775
+
776
+ <#tasks>
777
+ a <http://www.w3.org/ns/solid/terms#TypeRegistration> ;
778
+ <http://www.w3.org/ns/solid/terms#forClass> <https://schema.org/Movie> ;
779
+ <http://www.w3.org/ns/solid/terms#instanceContainer> <${containerUrl}> .
780
+ `;
781
+
782
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle)); // Initial fetch
783
+ FakeServer.respondOnce(
784
+ containerUrl,
785
+ containerResponse({
786
+ documentUrls: [firstDocumentUrl, secondDocumentUrl, thirdDocumentUrl, fourthDocumentUrl],
787
+ createdAt: container.createdAt,
788
+ updatedAt: container.updatedAt,
789
+ }),
790
+ ); // Container
791
+ FakeServer.respondOnce(firstDocumentUrl, FakeResponse.success('invalid turtle'));
792
+ FakeServer.respondOnce(secondDocumentUrl, FakeResponse.success('invalid turtle'));
793
+ FakeServer.respondOnce(thirdDocumentUrl, FakeResponse.success('invalid turtle'));
794
+ FakeServer.respondOnce(fourthDocumentUrl, FakeResponse.success('invalid turtle'));
795
+
796
+ // Arrange - Prepare service
797
+ Cloud.ready = true;
798
+ Cloud.localModelUpdates = { [movie.url]: 1 };
799
+
800
+ await Cloud.launch();
801
+ await Cloud.register(MoviesContainer);
802
+ await Events.emit('application-ready');
803
+
804
+ // Act
805
+ await Cloud.sync();
806
+
807
+ // Assert
808
+ expect(FakeServer.getRequests()).toHaveLength(6);
809
+ expect(Cloud.localModelUpdates).toEqual({ [movie.url]: 1 });
810
+ });
811
+
812
+ it('Ignores malformed documents in individual syncs', async () => {
813
+ // Arrange - Mint urls
814
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
815
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
816
+ const documentUrl = fakeDocumentUrl({ containerUrl: containerUrl });
817
+
818
+ // Arrange - Prepare models
819
+ const container = await MoviesContainer.at(parentContainerUrl).create({ url: containerUrl, name: 'Movies' });
820
+ const movie = await container.relatedMovies.create({
821
+ url: `${documentUrl}#it`,
822
+ name: 'The Tale of Princess Kaguya',
823
+ });
824
+
825
+ await movie.update({ name: 'かぐや姫の物語' });
826
+
827
+ // Arrange - Prepare responses
828
+
829
+ FakeServer.respondOnce(documentUrl, FakeResponse.success('invalid turtle'));
830
+
831
+ // Arrange - Prepare service
832
+ Cloud.ready = true;
833
+ Cloud.localModelUpdates = { [movie.url]: 1 };
834
+
835
+ await Cloud.launch();
836
+ await Cloud.register(MoviesContainer);
837
+ await Events.emit('application-ready');
838
+
839
+ // Act
840
+ await Cloud.sync(movie);
841
+
842
+ // Assert
843
+ expect(FakeServer.getRequests()).toHaveLength(1);
844
+ expect(Cloud.localModelUpdates).toEqual({ [movie.url]: 1 });
845
+ });
846
+
847
+ it('Ignores nested container registrations', async () => {
848
+ // Arrange - Mint urls
849
+ const rootContainerUrl = Solid.requireUser().storageUrls[0];
850
+ const parentContainerUrl = fakeContainerUrl({ baseUrl: rootContainerUrl });
851
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
852
+ const parentDocumentUrl = fakeDocumentUrl({ containerUrl: parentContainerUrl });
853
+ const documentUrl = fakeDocumentUrl({ containerUrl });
854
+
855
+ // Arrange - Prepare responses
856
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
857
+
858
+ FakeServer.respond(
859
+ typeIndexUrl,
860
+ typeIndexResponse({
861
+ [parentContainerUrl]: '<https://schema.org/Movie>',
862
+ [containerUrl]: '<https://schema.org/Movie>',
863
+ }),
864
+ );
865
+ FakeServer.respond(parentContainerUrl, containerResponse({ documentUrls: [containerUrl, parentDocumentUrl] }));
866
+ FakeServer.respond(containerUrl, containerResponse({ documentUrls: [documentUrl] }));
867
+ FakeServer.respond(parentDocumentUrl, movieResponse());
868
+
869
+ // Arrange - Prepare service
870
+ await Cloud.launch();
871
+ await Cloud.register(MoviesContainer);
872
+ await Events.emit('application-ready');
873
+
874
+ // Act
875
+ await Cloud.sync();
876
+
877
+ // Assert
878
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(1);
879
+ expect(FakeServer.getRequests(parentContainerUrl)).toHaveLength(1);
880
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
881
+ expect(FakeServer.getRequests(parentDocumentUrl)).toHaveLength(1);
882
+ expect(FakeServer.getRequests()).toHaveLength(4);
883
+ });
884
+
885
+ it('Works with circular relationships', async () => {
886
+ // Arrange
887
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
888
+ const containerUrl = fakeContainerUrl();
889
+ const documentUrl = fakeDocumentUrl({ containerUrl });
890
+ const typeIndexTurtle = `
891
+ <${typeIndexUrl}> a <http://www.w3.org/ns/solid/terms#TypeIndex> .
892
+
893
+ <#posts>
894
+ a <http://www.w3.org/ns/solid/terms#TypeRegistration> ;
895
+ <http://www.w3.org/ns/solid/terms#forClass> <https://schema.org/Article> ;
896
+ <http://www.w3.org/ns/solid/terms#instanceContainer> <${containerUrl}> .
897
+ `;
898
+
899
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success(typeIndexTurtle));
900
+ FakeServer.respondOnce(containerUrl, containerResponse({ documentUrls: [documentUrl] }));
901
+ FakeServer.respondOnce(
902
+ documentUrl,
903
+ `
904
+ <#it>
905
+ a <https://schema.org/Article> ;
906
+ <https://schema.org/name> "Title" ;
907
+ <https://schema.org/author> <#author> .
908
+
909
+ <#author>
910
+ a <https://schema.org/Person> ;
911
+ <https://schema.org/name> "Author" .
912
+ `,
913
+ );
914
+
915
+ Cloud.ready = true;
916
+
917
+ await Cloud.launch();
918
+ await Cloud.register(Post);
919
+ await Events.emit('application-ready');
920
+
921
+ // Act
922
+ await Cloud.sync();
923
+
924
+ // Assert
925
+ const [post] = await Post.all();
926
+
927
+ expect(post).toBeInstanceOf(Post);
928
+ expect(post?.author).toBeInstanceOf(Person);
929
+ expect(post?.author?.posts?.[0]).toBe(post);
930
+ });
931
+
932
+ testRegisterVariants('Leaves tombstones behind', async (registerModels) => {
933
+ // Arrange - Mint urls
934
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
935
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
936
+ const documentUrl = fakeDocumentUrl({ containerUrl });
937
+
938
+ // Arrange - Prepare models
939
+ const container = await MoviesContainer.at(parentContainerUrl).create({
940
+ url: containerUrl,
941
+ name: 'Movies',
942
+ });
943
+ const movie = await container.relatedMovies.create({ url: `${documentUrl}#it`, name: 'Spirited Away' });
944
+ const movieTurtle = (await movie.toTurtle()).replaceAll(documentUrl, '');
945
+
946
+ // Arrange - Prepare responses
947
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
948
+
949
+ FakeServer.respond(typeIndexUrl, typeIndexResponse({ [containerUrl]: '<https://schema.org/Movie>' }));
950
+ FakeServer.respond(
951
+ containerUrl,
952
+ containerResponse({
953
+ documentUrls: [documentUrl],
954
+ createdAt: container.createdAt,
955
+ updatedAt: container.updatedAt,
956
+ }),
957
+ );
958
+ FakeServer.respondOnce(documentUrl, FakeResponse.success(movieTurtle)); // Pull
959
+ FakeServer.respondOnce(documentUrl, FakeResponse.success(movieTurtle)); // Model GET before delete
960
+ FakeServer.respondOnce(documentUrl, FakeResponse.success(movieTurtle)); // Client GET before update
961
+ FakeServer.respondOnce(documentUrl, FakeResponse.success()); // Client Tombstone PATCH
962
+
963
+ // Arrange - Prepare service
964
+ await Cloud.launch();
965
+ await registerModels();
966
+ await Events.emit('application-ready');
967
+
968
+ // Act
969
+ await movie.softDelete();
970
+ await Cloud.sync();
971
+
972
+ // Assert
973
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(1);
974
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
975
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(4);
976
+ expect(FakeServer.getRequests()).toHaveLength(6);
977
+
978
+ expect(FakeServer.getRequest('PATCH', documentUrl)?.body).toEqualSparql(`
979
+ DELETE DATA { ${movieTurtle} } ;
980
+ INSERT DATA {
981
+ @prefix crdt: <https://vocab.noeldemartin.com/crdt/> .
982
+ @prefix xsd: <http://www.w3.org/2001/XMLSchema#> .
983
+
984
+ <#it-metadata>
985
+ a crdt:Tombstone ;
986
+ crdt:resource <#it> ;
987
+ crdt:deletedAt "[[.*]]"^^xsd:dateTime .
988
+ } .
989
+ `);
990
+
991
+ expect(Cloud.localModelUpdates).toEqual({});
992
+ });
993
+
994
+ testRegisterVariants('Deletes tombstone models', async (registerModels, variant) => {
995
+ const usingContainers = variant === 'container registration';
996
+
997
+ // Arrange - Mint urls
998
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
999
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
1000
+ const documentUrl = fakeDocumentUrl({ containerUrl });
1001
+
1002
+ // Arrange - Prepare models
1003
+ const container = await MoviesContainer.at(parentContainerUrl).create({
1004
+ url: containerUrl,
1005
+ name: 'Movies',
1006
+ });
1007
+
1008
+ const movie = await container.relatedMovies.create({ url: `${documentUrl}#it`, name: 'Spirited Away' });
1009
+
1010
+ // Arrange - Prepare initial sync responses
1011
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
1012
+
1013
+ FakeServer.respondOnce(typeIndexUrl, typeIndexResponse());
1014
+
1015
+ if (usingContainers) {
1016
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Tombstone check
1017
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Check before create
1018
+ FakeServer.respondOnce(containerUrl, FakeResponse.created()); // Create
1019
+ FakeServer.respondOnce(
1020
+ // Read described-by header
1021
+ containerUrl,
1022
+ FakeResponse.success('<> a <http://www.w3.org/ns/ldp#Container> .'),
1023
+ );
1024
+ FakeServer.respondOnce(`${containerUrl}.meta`, FakeResponse.success()); // Update meta
1025
+ } else {
1026
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Tombstone check
1027
+ }
1028
+
1029
+ FakeServer.respondOnce(typeIndexUrl, typeIndexResponse()); // Check before update
1030
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success()); // Update
1031
+ FakeServer.respondOnce(
1032
+ typeIndexUrl,
1033
+ typeIndexResponse({ [containerUrl]: '<https://schema.org/Movie>' }), // Second sync
1034
+ );
1035
+
1036
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Existence check
1037
+ FakeServer.respondOnce(documentUrl, FakeResponse.success()); // Create
1038
+
1039
+ // Arrange - Prepare service
1040
+ if (!usingContainers) {
1041
+ Cloud.modelCollections[Movie.modelName] = [containerUrl];
1042
+ }
1043
+
1044
+ await Cloud.launch();
1045
+ await registerModels();
1046
+ await Events.emit('application-ready');
1047
+
1048
+ // Arrange - Initial sync
1049
+ await Cloud.sync();
1050
+
1051
+ // Arrange - Sync responses
1052
+ FakeServer.respond(
1053
+ containerUrl,
1054
+ containerResponse({
1055
+ documentUrls: [documentUrl],
1056
+ createdAt: container.createdAt,
1057
+ updatedAt: container.updatedAt,
1058
+ }),
1059
+ );
1060
+
1061
+ FakeServer.respond(documentUrl, tombstoneResponse());
1062
+
1063
+ // Act
1064
+ await Cloud.sync();
1065
+
1066
+ // Assert
1067
+ const freshMovie = await Movie.at(containerUrl).find(movie.url);
1068
+
1069
+ expect(freshMovie).toBeNull();
1070
+
1071
+ if (usingContainers) {
1072
+ const freshContainer = await container.fresh();
1073
+
1074
+ await freshContainer.loadRelation('movies');
1075
+
1076
+ expect(freshContainer.movies).toHaveLength(0);
1077
+ expect(freshContainer.resourceUrls).toHaveLength(1);
1078
+
1079
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(4);
1080
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(5);
1081
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(3);
1082
+ expect(FakeServer.getRequests(`${containerUrl}.meta`)).toHaveLength(1);
1083
+ expect(FakeServer.getRequests()).toHaveLength(13);
1084
+ } else {
1085
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(4);
1086
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(1);
1087
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(5);
1088
+ expect(FakeServer.getRequests()).toHaveLength(10);
1089
+ }
1090
+
1091
+ expect(Cloud.localModelUpdates).toEqual({});
1092
+ });
1093
+
1094
+ testRegisterVariants('Deletes updated tombstone models', async (registerModels, variant) => {
1095
+ const usingContainers = variant === 'container registration';
1096
+
1097
+ // Arrange - Mint urls
1098
+ const parentContainerUrl = Solid.requireUser().storageUrls[0] + 'movies/';
1099
+ const containerUrl = fakeContainerUrl({ baseUrl: parentContainerUrl });
1100
+ const documentUrl = fakeDocumentUrl({ containerUrl });
1101
+
1102
+ // Arrange - Prepare models
1103
+ const container = await MoviesContainer.at(parentContainerUrl).create({
1104
+ url: containerUrl,
1105
+ name: 'Movies',
1106
+ });
1107
+
1108
+ const movie = await container.relatedMovies.create({ url: `${documentUrl}#it`, name: 'Spirited Away' });
1109
+
1110
+ // Arrange - Prepare initial sync responses
1111
+ const typeIndexUrl = SolidMock.requireUser().privateTypeIndexUrl ?? '';
1112
+
1113
+ FakeServer.respondOnce(typeIndexUrl, typeIndexResponse());
1114
+
1115
+ if (usingContainers) {
1116
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Tombstone check
1117
+ FakeServer.respondOnce(containerUrl, FakeResponse.notFound()); // Check before create
1118
+ FakeServer.respondOnce(containerUrl, FakeResponse.created()); // Create
1119
+ FakeServer.respondOnce(
1120
+ // Read described-by header
1121
+ containerUrl,
1122
+ FakeResponse.success('<> a <http://www.w3.org/ns/ldp#Container> .'),
1123
+ );
1124
+ FakeServer.respondOnce(`${containerUrl}.meta`, FakeResponse.success()); // Update meta
1125
+ } else {
1126
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Tombstone check
1127
+ }
1128
+
1129
+ FakeServer.respondOnce(typeIndexUrl, typeIndexResponse()); // Check before update
1130
+ FakeServer.respondOnce(typeIndexUrl, FakeResponse.success()); // Update
1131
+
1132
+ FakeServer.respondOnce(documentUrl, FakeResponse.notFound()); // Existence check
1133
+ FakeServer.respondOnce(documentUrl, FakeResponse.success()); // Create
1134
+
1135
+ // Arrange - Prepare service
1136
+ if (!usingContainers) {
1137
+ Cloud.modelCollections[Movie.modelName] = [containerUrl];
1138
+ }
1139
+
1140
+ Cloud.autoPush = false;
1141
+
1142
+ await Cloud.launch();
1143
+ await registerModels();
1144
+ await Events.emit('application-ready');
1145
+
1146
+ // Arrange - Initial sync
1147
+ await Cloud.sync();
1148
+
1149
+ // Arrange - Sync responses
1150
+ FakeServer.respond(documentUrl, tombstoneResponse());
1151
+
1152
+ // Act
1153
+ await movie.update({ name: 'Updated' });
1154
+ await Cloud.sync([movie]); // Simulate auto push
1155
+
1156
+ // Assert
1157
+ expect(await Movie.at(containerUrl).find(movie.url)).toBeNull();
1158
+
1159
+ if (usingContainers) {
1160
+ const freshContainer = await container.fresh();
1161
+
1162
+ await freshContainer.loadRelation('movies');
1163
+
1164
+ expect(freshContainer.movies).toHaveLength(0);
1165
+ expect(freshContainer.resourceUrls).toHaveLength(1);
1166
+
1167
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(3);
1168
+ expect(FakeServer.getRequests(containerUrl)).toHaveLength(4);
1169
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(4);
1170
+ expect(FakeServer.getRequests(`${containerUrl}.meta`)).toHaveLength(1);
1171
+ expect(FakeServer.getRequests()).toHaveLength(12);
1172
+ } else {
1173
+ expect(FakeServer.getRequests(typeIndexUrl)).toHaveLength(3);
1174
+ expect(FakeServer.getRequests(documentUrl)).toHaveLength(5);
1175
+ expect(FakeServer.getRequests()).toHaveLength(8);
1176
+ }
1177
+
1178
+ expect(Cloud.localModelUpdates).toEqual({});
1179
+ });
1180
+
1181
+ testRegisterVariants('Tracks model updates', async (registerModels) => {
1182
+ // Arrange
1183
+ const container = await MoviesContainer.create({ name: 'Movies' });
1184
+ const movie = await container.relatedMovies.create({ name: 'The Boy and The Heron' });
1185
+
1186
+ await registerModels();
1187
+
1188
+ Cloud.ready = true;
1189
+
1190
+ // Act
1191
+ await movie.update({ releaseDate: new Date('2023-07-14') });
1192
+ await container.relatedMovies.create({ name: 'The Tale of Princess Kaguya' });
1193
+
1194
+ // Assert
1195
+ expect(Cloud.localChanges).toEqual(2);
1196
+ });
1197
+
1198
+ });