@graffiti-garden/api 0.0.3 → 0.0.5
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.
- package/README.md +25 -0
- package/package.json +3 -2
- package/src/{api.ts → 1-api.ts} +116 -27
- package/src/{types.ts → 2-types.ts} +38 -7
- package/src/3-errors.ts +63 -0
- package/src/index.ts +3 -2
- package/tests/crud.ts +483 -0
- package/tests/discover.ts +32 -0
- package/tests/index.ts +3 -0
- package/tests/location.ts +35 -12
- package/tests/synchronize.ts +262 -0
- package/tests/utils.ts +22 -0
- package/src/errors.ts +0 -14
package/README.md
CHANGED
|
@@ -20,3 +20,28 @@ Then run a local server to view the documentation:
|
|
|
20
20
|
cd docs
|
|
21
21
|
npx http-server
|
|
22
22
|
```
|
|
23
|
+
|
|
24
|
+
## Testing
|
|
25
|
+
|
|
26
|
+
We have written a number of unit tests to verify implementations of the API with [vitest](https://vitest.dev/).
|
|
27
|
+
To use them, create a test file in that ends in `*.spec.ts` and format it as follows:
|
|
28
|
+
|
|
29
|
+
```typescript
|
|
30
|
+
import { graffitiCRUDTests } from "@graffiti-garden/api/tests";
|
|
31
|
+
|
|
32
|
+
const useGraffiti = () => new MyGraffitiImplementation();
|
|
33
|
+
// Fill in with implementation-specific information
|
|
34
|
+
// to provide to valid actor sessions for the tests
|
|
35
|
+
// to use as identities.
|
|
36
|
+
const useSession1 = () => ({ actor: "someone" });
|
|
37
|
+
const useSession2 = () => ({ actor: "someoneelse" });
|
|
38
|
+
|
|
39
|
+
// Run the tests
|
|
40
|
+
graffitiCRUDTests(useGraffiti, useSession1, useSession2);
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Then run the tests in the root of your directory with:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
npx vitest
|
|
47
|
+
```
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graffiti-garden/api",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.5",
|
|
4
4
|
"description": "The heart of Graffiti",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"author": "Theia Henderson",
|
|
18
18
|
"license": "GPL-3.0-or-later",
|
|
19
19
|
"scripts": {
|
|
20
|
-
"docs": "typedoc --options typedoc.json"
|
|
20
|
+
"docs": "typedoc --options typedoc.json",
|
|
21
|
+
"prepublishOnly": "npm install"
|
|
21
22
|
},
|
|
22
23
|
"repository": {
|
|
23
24
|
"type": "git",
|
package/src/{api.ts → 1-api.ts}
RENAMED
|
@@ -3,10 +3,10 @@ import type {
|
|
|
3
3
|
GraffitiObject,
|
|
4
4
|
GraffitiObjectBase,
|
|
5
5
|
GraffitiPatch,
|
|
6
|
-
|
|
6
|
+
GraffitiSession,
|
|
7
7
|
GraffitiPutObject,
|
|
8
8
|
GraffitiStream,
|
|
9
|
-
} from "./types";
|
|
9
|
+
} from "./2-types";
|
|
10
10
|
import type { JSONSchema4 } from "json-schema";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -26,10 +26,10 @@ import type { JSONSchema4 } from "json-schema";
|
|
|
26
26
|
* other important properties (e.g. privacy, security, scalability), those properties
|
|
27
27
|
* are useless if the system as a whole is unusable. Build APIs before protocols!
|
|
28
28
|
*
|
|
29
|
-
* The first group of methods are like standard CRUD
|
|
29
|
+
* The first group of methods are like standard CRUD methods that
|
|
30
30
|
* allow applications to {@link put}, {@link get}, {@link patch}, and {@link delete}
|
|
31
31
|
* {@link GraffitiObjectBase} objects. The main difference between these
|
|
32
|
-
* methods and standard database
|
|
32
|
+
* methods and standard database methods is that an {@link GraffitiObjectBase.actor | `actor`}
|
|
33
33
|
* (essentially a user) can only modify objects that they created.
|
|
34
34
|
* Applications may also specify an an array of actors that are {@link GraffitiObjectBase.allowed | `allowed`}
|
|
35
35
|
* to access the object and an array of {@link GraffitiObjectBase.channels | `channels`}
|
|
@@ -51,11 +51,13 @@ import type { JSONSchema4 } from "json-schema";
|
|
|
51
51
|
* Finally, other utility functions provide simple type conversions and
|
|
52
52
|
* allow users to find objects "lost" to forgotten or misspelled channels.
|
|
53
53
|
*
|
|
54
|
-
* @groupDescription CRUD
|
|
54
|
+
* @groupDescription CRUD Methods
|
|
55
55
|
* Methods for {@link put | creating}, {@link get | reading}, {@link patch | updating},
|
|
56
56
|
* and {@link delete | deleting} {@link GraffitiObjectBase | Graffiti objects}.
|
|
57
|
-
* @groupDescription Query
|
|
57
|
+
* @groupDescription Query Methods
|
|
58
58
|
* Methods for retrieving multiple {@link GraffitiObjectBase | Graffiti objects} at a time.
|
|
59
|
+
* @groupDescription Session Management
|
|
60
|
+
* Methods and properties for logging in and out of a Graffiti implementation.
|
|
59
61
|
* @groupDescription Utilities
|
|
60
62
|
* Methods for for converting Graffiti objects to and from URIs
|
|
61
63
|
* and for finding lost objects.
|
|
@@ -103,27 +105,27 @@ export abstract class Graffiti {
|
|
|
103
105
|
* and {@link GraffitiObjectBase.source | `source`}) exactly match the location of an existing object.
|
|
104
106
|
*
|
|
105
107
|
* @returns The object that was replaced if one exists or an object with
|
|
106
|
-
* with a `null` {@link GraffitiObjectBase.value | `value`} if this
|
|
108
|
+
* with a `null` {@link GraffitiObjectBase.value | `value`} if this method
|
|
107
109
|
* created a new object.
|
|
108
110
|
* The object will have a {@link GraffitiObjectBase.tombstone | `tombstone`}
|
|
109
111
|
* field set to `true` and a {@link GraffitiObjectBase.lastModified | `lastModified`}
|
|
110
112
|
* field updated to the time of replacement/creation.
|
|
111
113
|
*
|
|
112
|
-
* @group CRUD
|
|
114
|
+
* @group CRUD Methods
|
|
113
115
|
*/
|
|
114
116
|
abstract put<Schema>(
|
|
115
117
|
/**
|
|
116
118
|
* The object to be put. This object is statically type-checked against the [JSON schema](https://json-schema.org/) that can be optionally provided
|
|
117
119
|
* as the generic type parameter. We highly recommend providing a schema to
|
|
118
120
|
* ensure that the PUT object matches subsequent {@link get} or {@link discover}
|
|
119
|
-
*
|
|
121
|
+
* methods.
|
|
120
122
|
*/
|
|
121
123
|
object: GraffitiPutObject<Schema>,
|
|
122
124
|
/**
|
|
123
125
|
* An implementation-specific object with information to authenticate the
|
|
124
126
|
* {@link GraffitiObjectBase.actor | `actor`}.
|
|
125
127
|
*/
|
|
126
|
-
session:
|
|
128
|
+
session: GraffitiSession,
|
|
127
129
|
): Promise<GraffitiObjectBase>;
|
|
128
130
|
|
|
129
131
|
/**
|
|
@@ -131,12 +133,12 @@ export abstract class Graffiti {
|
|
|
131
133
|
* If no object exists at that location or if the retrieving
|
|
132
134
|
* {@link GraffitiObjectBase.actor | `actor`} is not the creator or included in
|
|
133
135
|
* the object's {@link GraffitiObjectBase.allowed | `allowed`} property,
|
|
134
|
-
*
|
|
136
|
+
* a {@link GraffitiErrorNotFound} is thrown.
|
|
135
137
|
*
|
|
136
138
|
* The retrieved object is also type-checked against the provided [JSON schema](https://json-schema.org/)
|
|
137
|
-
* otherwise
|
|
139
|
+
* otherwise a {@link GraffitiErrorSchemaMismatch} is thrown.
|
|
138
140
|
*
|
|
139
|
-
* @group CRUD
|
|
141
|
+
* @group CRUD Methods
|
|
140
142
|
*/
|
|
141
143
|
abstract get<Schema extends JSONSchema4>(
|
|
142
144
|
/**
|
|
@@ -153,7 +155,7 @@ export abstract class Graffiti {
|
|
|
153
155
|
* the retrieved object's {@link GraffitiObjectBase.allowed | `allowed`}
|
|
154
156
|
* property must be `undefined`.
|
|
155
157
|
*/
|
|
156
|
-
session?:
|
|
158
|
+
session?: GraffitiSession,
|
|
157
159
|
): Promise<GraffitiObject<Schema>>;
|
|
158
160
|
|
|
159
161
|
/**
|
|
@@ -167,7 +169,7 @@ export abstract class Graffiti {
|
|
|
167
169
|
* field set to `true` and a {@link GraffitiObjectBase.lastModified | `lastModified`}
|
|
168
170
|
* field updated to the time of deletion.
|
|
169
171
|
*
|
|
170
|
-
* @group CRUD
|
|
172
|
+
* @group CRUD Methods
|
|
171
173
|
*/
|
|
172
174
|
abstract patch(
|
|
173
175
|
/**
|
|
@@ -183,7 +185,7 @@ export abstract class Graffiti {
|
|
|
183
185
|
* An implementation-specific object with information to authenticate the
|
|
184
186
|
* {@link GraffitiObjectBase.actor | `actor`}.
|
|
185
187
|
*/
|
|
186
|
-
session:
|
|
188
|
+
session: GraffitiSession,
|
|
187
189
|
): Promise<GraffitiObjectBase>;
|
|
188
190
|
|
|
189
191
|
/**
|
|
@@ -191,13 +193,16 @@ export abstract class Graffiti {
|
|
|
191
193
|
* The deleting {@link GraffitiObjectBase.actor | `actor`} must be the same as the
|
|
192
194
|
* `actor` that created the object.
|
|
193
195
|
*
|
|
196
|
+
* If the object does not exist or has already been deleted,
|
|
197
|
+
* {@link GraffitiErrorNotFound} is thrown.
|
|
198
|
+
*
|
|
194
199
|
* @returns The object that was deleted if one exists or an object with
|
|
195
200
|
* with a `null` {@link GraffitiObjectBase.value | `value`} otherwise.
|
|
196
201
|
* The object will have a {@link GraffitiObjectBase.tombstone | `tombstone`}
|
|
197
202
|
* field set to `true` and a {@link GraffitiObjectBase.lastModified | `lastModified`}
|
|
198
203
|
* field updated to the time of deletion.
|
|
199
204
|
*
|
|
200
|
-
* @group CRUD
|
|
205
|
+
* @group CRUD Methods
|
|
201
206
|
*/
|
|
202
207
|
abstract delete(
|
|
203
208
|
/**
|
|
@@ -208,22 +213,41 @@ export abstract class Graffiti {
|
|
|
208
213
|
* An implementation-specific object with information to authenticate the
|
|
209
214
|
* {@link GraffitiObjectBase.actor | `actor`}.
|
|
210
215
|
*/
|
|
211
|
-
session:
|
|
216
|
+
session: GraffitiSession,
|
|
212
217
|
): Promise<GraffitiObjectBase>;
|
|
213
218
|
|
|
214
219
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* and match the given [JSON Schema](https://json-schema.org)
|
|
220
|
+
* Discovers objects created by any user that are contained
|
|
221
|
+
* in at least one of the given {@link GraffitiObjectBase.channels | `channels`}
|
|
222
|
+
* and match the given [JSON Schema](https://json-schema.org).
|
|
218
223
|
*
|
|
219
224
|
* Objects are returned asynchronously as they are discovered but the stream
|
|
220
225
|
* will end once all leads have been exhausted.
|
|
221
226
|
* The method must be polled again for new objects.
|
|
222
227
|
*
|
|
228
|
+
* `discover` will not return objects that the {@link GraffitiObjectBase.actor | `actor`}
|
|
229
|
+
* is not {@link GraffitiObjectBase.allowed | `allowed`} to access.
|
|
230
|
+
* If the actor is not the creator of a discovered object,
|
|
231
|
+
* the allowed list will be masked to only contain the querying actor if the
|
|
232
|
+
* allowed list is not `undefined` (public). Additionally, if the actor is not the
|
|
233
|
+
* creator of a discovered object, any {@link GraffitiObjectBase.channels | `channels`}
|
|
234
|
+
* not specified by the `discover` method will not be revealed. This masking happens
|
|
235
|
+
* before the supplied schema is applied.
|
|
236
|
+
*
|
|
237
|
+
* Since different implementations may fetch data from multiple sources there is
|
|
238
|
+
* no guarentee on the order that objects are returned in. Additionally, the method
|
|
239
|
+
* may return objects that have been deleted but with a
|
|
240
|
+
* {@link GraffitiObjectBase.tombstone | `tombstone`} field set to `true` for
|
|
241
|
+
* cache invalidation purposes. Implementations must make aware when, if ever,
|
|
242
|
+
* tombstoned objects are removed.
|
|
243
|
+
*
|
|
223
244
|
* {@link discover} can be used in conjunction with {@link synchronize}
|
|
224
245
|
* to provide a responsive and consistent user experience.
|
|
225
246
|
*
|
|
226
|
-
* @
|
|
247
|
+
* @returns A stream of objects that match the given {@link GraffitiObjectBase.channels | `channels`}
|
|
248
|
+
* and [JSON Schema](https://json-schema.org).
|
|
249
|
+
*
|
|
250
|
+
* @group Query Methods
|
|
227
251
|
*/
|
|
228
252
|
abstract discover<Schema extends JSONSchema4>(
|
|
229
253
|
/**
|
|
@@ -240,7 +264,7 @@ export abstract class Graffiti {
|
|
|
240
264
|
* only objects that have no {@link GraffitiObjectBase.allowed | `allowed`}
|
|
241
265
|
* property will be returned.
|
|
242
266
|
*/
|
|
243
|
-
session?:
|
|
267
|
+
session?: GraffitiSession,
|
|
244
268
|
): GraffitiStream<GraffitiObject<Schema>>;
|
|
245
269
|
|
|
246
270
|
/**
|
|
@@ -264,7 +288,7 @@ export abstract class Graffiti {
|
|
|
264
288
|
* all instance's of that friend's name in the user's application instantly,
|
|
265
289
|
* providing a consistent user experience.
|
|
266
290
|
*
|
|
267
|
-
* @group Query
|
|
291
|
+
* @group Query Methods
|
|
268
292
|
*/
|
|
269
293
|
abstract synchronize<Schema extends JSONSchema4>(
|
|
270
294
|
/**
|
|
@@ -281,7 +305,7 @@ export abstract class Graffiti {
|
|
|
281
305
|
* only objects that have no {@link GraffitiObjectBase.allowed | `allowed`}
|
|
282
306
|
* property will be returned.
|
|
283
307
|
*/
|
|
284
|
-
session?:
|
|
308
|
+
session?: GraffitiSession,
|
|
285
309
|
): GraffitiStream<GraffitiObject<Schema>>;
|
|
286
310
|
|
|
287
311
|
/**
|
|
@@ -305,7 +329,7 @@ export abstract class Graffiti {
|
|
|
305
329
|
* An implementation-specific object with information to authenticate the
|
|
306
330
|
* {@link GraffitiObjectBase.actor | `actor`}.
|
|
307
331
|
*/
|
|
308
|
-
session:
|
|
332
|
+
session: GraffitiSession,
|
|
309
333
|
): GraffitiStream<{
|
|
310
334
|
channel: string;
|
|
311
335
|
source: string;
|
|
@@ -331,12 +355,77 @@ export abstract class Graffiti {
|
|
|
331
355
|
* {@link GraffitiObjectBase.tombstone | `tombstone`} field is `true`
|
|
332
356
|
* if the object has been deleted.
|
|
333
357
|
*/
|
|
334
|
-
abstract listOrphans(session:
|
|
358
|
+
abstract listOrphans(session: GraffitiSession): GraffitiStream<{
|
|
335
359
|
name: string;
|
|
336
360
|
source: string;
|
|
337
361
|
lastModified: Date;
|
|
338
362
|
tombstone: boolean;
|
|
339
363
|
}>;
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Begins the login process. Depending on the implementation, this may
|
|
367
|
+
* involve redirecting the user to a login page or opening a popup,
|
|
368
|
+
* so it should always be called in response to a user action.
|
|
369
|
+
*
|
|
370
|
+
* The {@link GraffitiSession | session} object is returned
|
|
371
|
+
* asynchronously via {@link Graffiti.sessionEvents | sessionEvents}
|
|
372
|
+
* as a {@link GraffitiLoginEvent}.
|
|
373
|
+
*
|
|
374
|
+
* @group Session Management
|
|
375
|
+
*/
|
|
376
|
+
abstract login(
|
|
377
|
+
/**
|
|
378
|
+
* An optional actor to prompt the user to login as. For example,
|
|
379
|
+
* if a session expired and the user is trying to reauthenticate,
|
|
380
|
+
* or if the user entered their username in an application-side login form.
|
|
381
|
+
*
|
|
382
|
+
* If not provided, the implementation should prompt the user to
|
|
383
|
+
* supply an actor ID along with their other login information
|
|
384
|
+
* (e.g. password).
|
|
385
|
+
*/
|
|
386
|
+
actor?: string,
|
|
387
|
+
/**
|
|
388
|
+
* An arbitrary string that will be returned with the
|
|
389
|
+
* {@link GraffitiSession | session} object
|
|
390
|
+
* when the login process is complete.
|
|
391
|
+
* See {@link GraffitiLoginEvent}.
|
|
392
|
+
*/
|
|
393
|
+
state?: string,
|
|
394
|
+
): Promise<void>;
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Begins the logout process. Depending on the implementation, this may
|
|
398
|
+
* involve redirecting the user to a logout page or opening a popup,
|
|
399
|
+
* so it should always be called in response to a user action.
|
|
400
|
+
*
|
|
401
|
+
* A confirmation will be returned asynchronously via
|
|
402
|
+
* {@link Graffiti.sessionEvents | sessionEvents}
|
|
403
|
+
* as a {@link GraffitiLogoutEvent}.
|
|
404
|
+
*
|
|
405
|
+
* @group Session Management
|
|
406
|
+
*/
|
|
407
|
+
abstract logout(
|
|
408
|
+
/**
|
|
409
|
+
* The {@link GraffitiSession | session} object to logout.
|
|
410
|
+
*/
|
|
411
|
+
session: GraffitiSession,
|
|
412
|
+
/**
|
|
413
|
+
* An arbitrary string that will be returned with the
|
|
414
|
+
* when the logout process is complete.
|
|
415
|
+
* See {@link GraffitiLogoutEvent}.
|
|
416
|
+
*/
|
|
417
|
+
state?: string,
|
|
418
|
+
): Promise<void>;
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* An event target that can be used to listen for `login`
|
|
422
|
+
* and `logout` events. They are custom events of types
|
|
423
|
+
* {@link GraffitiLoginEvent`} and {@link GraffitiLogoutEvent }
|
|
424
|
+
* respectively.
|
|
425
|
+
*
|
|
426
|
+
* @group Session Management
|
|
427
|
+
*/
|
|
428
|
+
abstract readonly sessionEvents: EventTarget;
|
|
340
429
|
}
|
|
341
430
|
|
|
342
431
|
/**
|
|
@@ -146,7 +146,7 @@ export type GraffitiLocation = Pick<
|
|
|
146
146
|
* {@link GraffitiObjectBase.name | `name`}, and {@link GraffitiObjectBase.source | `source`}.
|
|
147
147
|
* If the location provided exactly matches an existing object, the existing object will be replaced.
|
|
148
148
|
* If no `name` is provided, one will be randomly generated.
|
|
149
|
-
* If no `actor` is provided, the `actor` from the supplied {@link
|
|
149
|
+
* If no `actor` is provided, the `actor` from the supplied {@link GraffitiSession | `session` } will be used.
|
|
150
150
|
* If no `source` is provided, one may be inferred by the depending on implementation.
|
|
151
151
|
*
|
|
152
152
|
* This object does not need a {@link GraffitiObjectBase.lastModified | `lastModified`} or {@link GraffitiObjectBase.tombstone | `tombstone`}
|
|
@@ -168,7 +168,7 @@ export type GraffitiPutObject<Schema> = Pick<
|
|
|
168
168
|
* that modify objects and optional for methods that read objects.
|
|
169
169
|
*
|
|
170
170
|
* At a minimum the `session` object must contain the
|
|
171
|
-
* {@link
|
|
171
|
+
* {@link GraffitiSession.actor | `actor`} URI the user wants to authenticate with.
|
|
172
172
|
* However it is likely that the `session` object must contain other
|
|
173
173
|
* implementation-specific properties.
|
|
174
174
|
* For example, a Solid implementation might include a
|
|
@@ -179,7 +179,7 @@ export type GraffitiPutObject<Schema> = Pick<
|
|
|
179
179
|
* It may also include other implementation specific properties
|
|
180
180
|
* that provide hints for performance or security.
|
|
181
181
|
*/
|
|
182
|
-
export interface
|
|
182
|
+
export interface GraffitiSession {
|
|
183
183
|
/**
|
|
184
184
|
* The {@link GraffitiObjectBase.actor | `actor`} a user wants to authenticate with.
|
|
185
185
|
*/
|
|
@@ -241,14 +241,45 @@ export interface GraffitiPatch {
|
|
|
241
241
|
*/
|
|
242
242
|
export type GraffitiStream<T> = AsyncGenerator<
|
|
243
243
|
| {
|
|
244
|
-
error
|
|
244
|
+
error?: undefined;
|
|
245
245
|
value: T;
|
|
246
246
|
}
|
|
247
247
|
| {
|
|
248
|
-
error:
|
|
249
|
-
value: Error;
|
|
248
|
+
error: Error;
|
|
250
249
|
source: string;
|
|
251
250
|
},
|
|
252
|
-
void,
|
|
253
251
|
void
|
|
254
252
|
>;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The event type produced in {@link Graffiti.sessionEvents}
|
|
256
|
+
* when a user logs in manually from {@link Graffiti.login}
|
|
257
|
+
* or when their session is restored from a previous login.
|
|
258
|
+
* The event name to listen for is `login`.
|
|
259
|
+
*/
|
|
260
|
+
export type GraffitiLoginEvent = CustomEvent<
|
|
261
|
+
{
|
|
262
|
+
state?: string;
|
|
263
|
+
} & (
|
|
264
|
+
| {
|
|
265
|
+
error: Error;
|
|
266
|
+
session?: undefined;
|
|
267
|
+
}
|
|
268
|
+
| {
|
|
269
|
+
error?: undefined;
|
|
270
|
+
session: GraffitiSession;
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
>;
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The event type produced in {@link Graffiti.sessionEvents}
|
|
277
|
+
* when a user logs out either manually with {@link Graffiti.logout}
|
|
278
|
+
* or when their session times out or otherwise becomes invalid.
|
|
279
|
+
* The event name to listen for is `logout`.
|
|
280
|
+
*/
|
|
281
|
+
export type GraffitiLogoutEvent = CustomEvent<{
|
|
282
|
+
actor: string;
|
|
283
|
+
state?: string;
|
|
284
|
+
error?: Error;
|
|
285
|
+
}>;
|
package/src/3-errors.ts
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export class GraffitiErrorUnauthorized extends Error {
|
|
2
|
+
constructor(message?: string) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "GraffitiErrorUnauthorized";
|
|
5
|
+
Object.setPrototypeOf(this, GraffitiErrorUnauthorized.prototype);
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class GraffitiErrorForbidden extends Error {
|
|
10
|
+
constructor(message?: string) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "GraffitiErrorForbidden";
|
|
13
|
+
Object.setPrototypeOf(this, GraffitiErrorForbidden.prototype);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class GraffitiErrorNotFound extends Error {
|
|
18
|
+
constructor(message?: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "GraffitiErrorNotFound";
|
|
21
|
+
Object.setPrototypeOf(this, GraffitiErrorNotFound.prototype);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class GraffitiErrorInvalidSchema extends Error {
|
|
26
|
+
constructor(message?: string) {
|
|
27
|
+
super(message);
|
|
28
|
+
this.name = "GraffitiErrorInvalidSchema";
|
|
29
|
+
Object.setPrototypeOf(this, GraffitiErrorInvalidSchema.prototype);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class GraffitiErrorSchemaMismatch extends Error {
|
|
34
|
+
constructor(message?: string) {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "GraffitiErrorSchemaMismatch";
|
|
37
|
+
Object.setPrototypeOf(this, GraffitiErrorSchemaMismatch.prototype);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export class GraffitiErrorPatchTestFailed extends Error {
|
|
42
|
+
constructor(message?: string) {
|
|
43
|
+
super(message);
|
|
44
|
+
this.name = "GraffitiErrorPatchTestFailed";
|
|
45
|
+
Object.setPrototypeOf(this, GraffitiErrorPatchTestFailed.prototype);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class GraffitiErrorPatchError extends Error {
|
|
50
|
+
constructor(message?: string) {
|
|
51
|
+
super(message);
|
|
52
|
+
this.name = "GraffitiErrorPatchError";
|
|
53
|
+
Object.setPrototypeOf(this, GraffitiErrorPatchError.prototype);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class GraffitiErrorInvalidUri extends Error {
|
|
58
|
+
constructor(message?: string) {
|
|
59
|
+
super(message);
|
|
60
|
+
this.name = "GraffitiErrorInvalidUri";
|
|
61
|
+
Object.setPrototypeOf(this, GraffitiErrorInvalidUri.prototype);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/src/index.ts
CHANGED
package/tests/crud.ts
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
import { it, expect, describe } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
type GraffitiFactory,
|
|
4
|
+
type GraffitiSession,
|
|
5
|
+
type GraffitiPatch,
|
|
6
|
+
GraffitiErrorNotFound,
|
|
7
|
+
GraffitiErrorSchemaMismatch,
|
|
8
|
+
GraffitiErrorInvalidSchema,
|
|
9
|
+
GraffitiErrorForbidden,
|
|
10
|
+
GraffitiErrorPatchTestFailed,
|
|
11
|
+
GraffitiErrorPatchError,
|
|
12
|
+
} from "../src/index";
|
|
13
|
+
import { randomPutObject, randomString } from "./utils";
|
|
14
|
+
|
|
15
|
+
export const graffitiCRUDTests = (
|
|
16
|
+
useGraffiti: GraffitiFactory,
|
|
17
|
+
useSession1: () => GraffitiSession,
|
|
18
|
+
useSession2: () => GraffitiSession,
|
|
19
|
+
) => {
|
|
20
|
+
describe("CRUD", () => {
|
|
21
|
+
it("put, get, delete", async () => {
|
|
22
|
+
const graffiti = useGraffiti();
|
|
23
|
+
const session = useSession1();
|
|
24
|
+
const value = {
|
|
25
|
+
something: "hello, world~ c:",
|
|
26
|
+
};
|
|
27
|
+
const channels = [randomString(), randomString()];
|
|
28
|
+
|
|
29
|
+
// Put the object
|
|
30
|
+
const previous = await graffiti.put({ value, channels }, session);
|
|
31
|
+
expect(previous.value).toEqual({});
|
|
32
|
+
expect(previous.channels).toEqual([]);
|
|
33
|
+
expect(previous.allowed).toBeUndefined();
|
|
34
|
+
expect(previous.actor).toEqual(session.actor);
|
|
35
|
+
|
|
36
|
+
// Get it back
|
|
37
|
+
const gotten = await graffiti.get(previous, {});
|
|
38
|
+
expect(gotten.value).toEqual(value);
|
|
39
|
+
expect(gotten.channels).toEqual([]);
|
|
40
|
+
expect(gotten.allowed).toBeUndefined();
|
|
41
|
+
expect(gotten.name).toEqual(previous.name);
|
|
42
|
+
expect(gotten.actor).toEqual(previous.actor);
|
|
43
|
+
expect(gotten.source).toEqual(previous.source);
|
|
44
|
+
expect(gotten.lastModified.getTime()).toEqual(
|
|
45
|
+
previous.lastModified.getTime(),
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
// Replace it
|
|
49
|
+
const newValue = {
|
|
50
|
+
something: "goodbye, world~ :c",
|
|
51
|
+
};
|
|
52
|
+
const beforeReplaced = await graffiti.put(
|
|
53
|
+
{ ...previous, value: newValue, channels: [] },
|
|
54
|
+
session,
|
|
55
|
+
);
|
|
56
|
+
expect(beforeReplaced.value).toEqual(value);
|
|
57
|
+
expect(beforeReplaced.tombstone).toEqual(true);
|
|
58
|
+
expect(beforeReplaced.name).toEqual(previous.name);
|
|
59
|
+
expect(beforeReplaced.actor).toEqual(previous.actor);
|
|
60
|
+
expect(beforeReplaced.source).toEqual(previous.source);
|
|
61
|
+
expect(beforeReplaced.lastModified.getTime()).toBeGreaterThan(
|
|
62
|
+
gotten.lastModified.getTime(),
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Get it again
|
|
66
|
+
const afterReplaced = await graffiti.get(previous, {});
|
|
67
|
+
expect(afterReplaced.value).toEqual(newValue);
|
|
68
|
+
expect(afterReplaced.lastModified.getTime()).toEqual(
|
|
69
|
+
beforeReplaced.lastModified.getTime(),
|
|
70
|
+
);
|
|
71
|
+
expect(afterReplaced.tombstone).toEqual(false);
|
|
72
|
+
|
|
73
|
+
// Delete it
|
|
74
|
+
const beforeDeleted = await graffiti.delete(afterReplaced, session);
|
|
75
|
+
expect(beforeDeleted.tombstone).toEqual(true);
|
|
76
|
+
expect(beforeDeleted.value).toEqual(newValue);
|
|
77
|
+
expect(beforeDeleted.lastModified.getTime()).toBeGreaterThan(
|
|
78
|
+
beforeReplaced.lastModified.getTime(),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
// Try to get it and fail
|
|
82
|
+
await expect(graffiti.get(afterReplaced, {})).rejects.toThrow(
|
|
83
|
+
GraffitiErrorNotFound,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("put, get, delete with wrong actor", async () => {
|
|
88
|
+
const graffiti = useGraffiti();
|
|
89
|
+
const session1 = useSession1();
|
|
90
|
+
const session2 = useSession2();
|
|
91
|
+
|
|
92
|
+
await expect(
|
|
93
|
+
graffiti.put(
|
|
94
|
+
{ value: {}, channels: [], actor: session2.actor },
|
|
95
|
+
session1,
|
|
96
|
+
),
|
|
97
|
+
).rejects.toThrow(GraffitiErrorForbidden);
|
|
98
|
+
|
|
99
|
+
await expect(
|
|
100
|
+
graffiti.delete(
|
|
101
|
+
{
|
|
102
|
+
name: "asdf",
|
|
103
|
+
source: "asdf",
|
|
104
|
+
actor: session2.actor,
|
|
105
|
+
},
|
|
106
|
+
session1,
|
|
107
|
+
),
|
|
108
|
+
).rejects.toThrow(GraffitiErrorForbidden);
|
|
109
|
+
|
|
110
|
+
await expect(
|
|
111
|
+
graffiti.patch(
|
|
112
|
+
{},
|
|
113
|
+
{
|
|
114
|
+
name: "asdf",
|
|
115
|
+
source: "asdf",
|
|
116
|
+
actor: session2.actor,
|
|
117
|
+
},
|
|
118
|
+
session1,
|
|
119
|
+
),
|
|
120
|
+
).rejects.toThrow(GraffitiErrorForbidden);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("put and get with schema", async () => {
|
|
124
|
+
const graffiti = useGraffiti();
|
|
125
|
+
const session = useSession1();
|
|
126
|
+
|
|
127
|
+
const schema = {
|
|
128
|
+
properties: {
|
|
129
|
+
value: {
|
|
130
|
+
properties: {
|
|
131
|
+
something: {
|
|
132
|
+
type: "string",
|
|
133
|
+
},
|
|
134
|
+
another: {
|
|
135
|
+
type: "integer",
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
} as const;
|
|
141
|
+
|
|
142
|
+
const goodValue = {
|
|
143
|
+
something: "hello",
|
|
144
|
+
another: 42,
|
|
145
|
+
} as const;
|
|
146
|
+
|
|
147
|
+
const putted = await graffiti.put<typeof schema>(
|
|
148
|
+
{
|
|
149
|
+
value: goodValue,
|
|
150
|
+
channels: [],
|
|
151
|
+
},
|
|
152
|
+
session,
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
const gotten = await graffiti.get(putted, schema);
|
|
156
|
+
expect(gotten.value.something).toEqual(goodValue.something);
|
|
157
|
+
expect(gotten.value.another).toEqual(goodValue.another);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("put and get with invalid schema", async () => {
|
|
161
|
+
const graffiti = useGraffiti();
|
|
162
|
+
const session = useSession1();
|
|
163
|
+
|
|
164
|
+
const putted = await graffiti.put({ value: {}, channels: [] }, session);
|
|
165
|
+
await expect(
|
|
166
|
+
graffiti.get(putted, {
|
|
167
|
+
properties: {
|
|
168
|
+
value: {
|
|
169
|
+
//@ts-ignore
|
|
170
|
+
type: "asdf",
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
}),
|
|
174
|
+
).rejects.toThrow(GraffitiErrorInvalidSchema);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("put and get with wrong schema", async () => {
|
|
178
|
+
const graffiti = useGraffiti();
|
|
179
|
+
const session = useSession1();
|
|
180
|
+
|
|
181
|
+
const putted = await graffiti.put(
|
|
182
|
+
{
|
|
183
|
+
value: {
|
|
184
|
+
hello: "world",
|
|
185
|
+
},
|
|
186
|
+
channels: [],
|
|
187
|
+
},
|
|
188
|
+
session,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
await expect(
|
|
192
|
+
graffiti.get(putted, {
|
|
193
|
+
properties: {
|
|
194
|
+
value: {
|
|
195
|
+
properties: {
|
|
196
|
+
hello: {
|
|
197
|
+
type: "number",
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
).rejects.toThrow(GraffitiErrorSchemaMismatch);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("put and get with empty access control", async () => {
|
|
207
|
+
const graffiti = useGraffiti();
|
|
208
|
+
const session1 = useSession1();
|
|
209
|
+
const session2 = useSession2();
|
|
210
|
+
|
|
211
|
+
const value = {
|
|
212
|
+
um: "hi",
|
|
213
|
+
};
|
|
214
|
+
const allowed = [randomString()];
|
|
215
|
+
const channels = [randomString()];
|
|
216
|
+
const putted = await graffiti.put({ value, allowed, channels }, session1);
|
|
217
|
+
|
|
218
|
+
// Get it with authenticated session
|
|
219
|
+
const gotten = await graffiti.get(putted, {}, session1);
|
|
220
|
+
expect(gotten.value).toEqual(value);
|
|
221
|
+
expect(gotten.allowed).toEqual(allowed);
|
|
222
|
+
expect(gotten.channels).toEqual(channels);
|
|
223
|
+
|
|
224
|
+
// But not without session
|
|
225
|
+
await expect(graffiti.get(putted, {})).rejects.toThrow();
|
|
226
|
+
|
|
227
|
+
// Or the wrong session
|
|
228
|
+
await expect(graffiti.get(putted, {}, session2)).rejects.toThrow();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("put and get with specific access control", async () => {
|
|
232
|
+
const graffiti = useGraffiti();
|
|
233
|
+
const session1 = useSession1();
|
|
234
|
+
const session2 = useSession2();
|
|
235
|
+
|
|
236
|
+
const value = {
|
|
237
|
+
um: "hi",
|
|
238
|
+
};
|
|
239
|
+
const allowed = [randomString(), session2.actor, randomString()];
|
|
240
|
+
const channels = [randomString()];
|
|
241
|
+
const putted = await graffiti.put(
|
|
242
|
+
{
|
|
243
|
+
value,
|
|
244
|
+
allowed,
|
|
245
|
+
channels,
|
|
246
|
+
},
|
|
247
|
+
session1,
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
// Get it with authenticated session
|
|
251
|
+
const gotten = await graffiti.get(putted, {}, session1);
|
|
252
|
+
expect(gotten.value).toEqual(value);
|
|
253
|
+
expect(gotten.allowed).toEqual(allowed);
|
|
254
|
+
expect(gotten.channels).toEqual(channels);
|
|
255
|
+
|
|
256
|
+
// But not without session
|
|
257
|
+
await expect(graffiti.get(putted, {})).rejects.toThrow();
|
|
258
|
+
|
|
259
|
+
const gotten2 = await graffiti.get(putted, {}, session2);
|
|
260
|
+
expect(gotten2.value).toEqual(value);
|
|
261
|
+
// They should only see that is is private to them
|
|
262
|
+
expect(gotten2.allowed).toEqual([session2.actor]);
|
|
263
|
+
// And not see any channels
|
|
264
|
+
expect(gotten2.channels).toEqual([]);
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it("patch value", async () => {
|
|
268
|
+
const graffiti = useGraffiti();
|
|
269
|
+
const session = useSession1();
|
|
270
|
+
|
|
271
|
+
const value = {
|
|
272
|
+
something: "hello, world~ c:",
|
|
273
|
+
};
|
|
274
|
+
const putted = await graffiti.put({ value, channels: [] }, session);
|
|
275
|
+
|
|
276
|
+
const patch: GraffitiPatch = {
|
|
277
|
+
value: [
|
|
278
|
+
{ op: "replace", path: "/something", value: "goodbye, world~ :c" },
|
|
279
|
+
],
|
|
280
|
+
};
|
|
281
|
+
const beforePatched = await graffiti.patch(patch, putted, session);
|
|
282
|
+
expect(beforePatched.value).toEqual(value);
|
|
283
|
+
expect(beforePatched.tombstone).toBe(true);
|
|
284
|
+
|
|
285
|
+
const gotten = await graffiti.get(putted, {});
|
|
286
|
+
expect(gotten.value).toEqual({
|
|
287
|
+
something: "goodbye, world~ :c",
|
|
288
|
+
});
|
|
289
|
+
expect(beforePatched.lastModified.getTime()).toBe(
|
|
290
|
+
gotten.lastModified.getTime(),
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await graffiti.delete(putted, session);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
it("deep patch", async () => {
|
|
297
|
+
const graffiti = useGraffiti();
|
|
298
|
+
const session = useSession1();
|
|
299
|
+
|
|
300
|
+
const value = {
|
|
301
|
+
something: {
|
|
302
|
+
another: {
|
|
303
|
+
somethingElse: "hello",
|
|
304
|
+
},
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
const putted = await graffiti.put(
|
|
308
|
+
{ value: value, channels: [] },
|
|
309
|
+
session,
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
const beforePatch = await graffiti.patch(
|
|
313
|
+
{
|
|
314
|
+
value: [
|
|
315
|
+
{
|
|
316
|
+
op: "replace",
|
|
317
|
+
path: "/something/another/somethingElse",
|
|
318
|
+
value: "goodbye",
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
},
|
|
322
|
+
putted,
|
|
323
|
+
session,
|
|
324
|
+
);
|
|
325
|
+
const gotten = await graffiti.get(putted, {});
|
|
326
|
+
|
|
327
|
+
expect(beforePatch.value).toEqual(value);
|
|
328
|
+
expect(gotten.value).toEqual({
|
|
329
|
+
something: {
|
|
330
|
+
another: {
|
|
331
|
+
somethingElse: "goodbye",
|
|
332
|
+
},
|
|
333
|
+
},
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
it("patch channels", async () => {
|
|
338
|
+
const graffiti = useGraffiti();
|
|
339
|
+
const session = useSession1();
|
|
340
|
+
|
|
341
|
+
const channelsBefore = [randomString()];
|
|
342
|
+
const channelsAfter = [randomString()];
|
|
343
|
+
|
|
344
|
+
const putted = await graffiti.put(
|
|
345
|
+
{ value: {}, channels: channelsBefore },
|
|
346
|
+
session,
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
const patch: GraffitiPatch = {
|
|
350
|
+
channels: [{ op: "replace", path: "/0", value: channelsAfter[0] }],
|
|
351
|
+
};
|
|
352
|
+
const patched = await graffiti.patch(patch, putted, session);
|
|
353
|
+
expect(patched.channels).toEqual(channelsBefore);
|
|
354
|
+
const gotten = await graffiti.get(putted, {}, session);
|
|
355
|
+
expect(gotten.channels).toEqual(channelsAfter);
|
|
356
|
+
await graffiti.delete(putted, session);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("patch 'increment' with test", async () => {
|
|
360
|
+
const graffiti = useGraffiti();
|
|
361
|
+
const session = useSession1();
|
|
362
|
+
|
|
363
|
+
const putted = await graffiti.put(
|
|
364
|
+
{
|
|
365
|
+
value: {
|
|
366
|
+
counter: 1,
|
|
367
|
+
},
|
|
368
|
+
channels: [],
|
|
369
|
+
},
|
|
370
|
+
session,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
const previous = await graffiti.patch(
|
|
374
|
+
{
|
|
375
|
+
value: [
|
|
376
|
+
{ op: "test", path: "/counter", value: 1 },
|
|
377
|
+
{ op: "replace", path: "/counter", value: 2 },
|
|
378
|
+
],
|
|
379
|
+
},
|
|
380
|
+
putted,
|
|
381
|
+
session,
|
|
382
|
+
);
|
|
383
|
+
expect(previous.value).toEqual({ counter: 1 });
|
|
384
|
+
const result = await graffiti.get(previous, {
|
|
385
|
+
properties: {
|
|
386
|
+
value: {
|
|
387
|
+
properties: {
|
|
388
|
+
counter: {
|
|
389
|
+
type: "integer",
|
|
390
|
+
},
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
},
|
|
394
|
+
});
|
|
395
|
+
expect(result.value.counter).toEqual(2);
|
|
396
|
+
|
|
397
|
+
await expect(
|
|
398
|
+
graffiti.patch(
|
|
399
|
+
{
|
|
400
|
+
value: [
|
|
401
|
+
{ op: "test", path: "/counter", value: 1 },
|
|
402
|
+
{ op: "replace", path: "/counter", value: 3 },
|
|
403
|
+
],
|
|
404
|
+
},
|
|
405
|
+
putted,
|
|
406
|
+
session,
|
|
407
|
+
),
|
|
408
|
+
).rejects.toThrow(GraffitiErrorPatchTestFailed);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("invalid patch", async () => {
|
|
412
|
+
const graffiti = useGraffiti();
|
|
413
|
+
const session = useSession1();
|
|
414
|
+
const object = randomPutObject();
|
|
415
|
+
const putted = await graffiti.put(object, session);
|
|
416
|
+
|
|
417
|
+
await expect(
|
|
418
|
+
graffiti.patch(
|
|
419
|
+
{
|
|
420
|
+
value: [
|
|
421
|
+
{ op: "add", path: "/root", value: [] },
|
|
422
|
+
{ op: "add", path: "/root/2", value: 2 }, // out of bounds
|
|
423
|
+
],
|
|
424
|
+
},
|
|
425
|
+
putted,
|
|
426
|
+
session,
|
|
427
|
+
),
|
|
428
|
+
).rejects.toThrow(GraffitiErrorPatchError);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("patch channels to be wrong", async () => {
|
|
432
|
+
const graffiti = useGraffiti();
|
|
433
|
+
const session = useSession1();
|
|
434
|
+
const object = randomPutObject();
|
|
435
|
+
object.allowed = [randomString()];
|
|
436
|
+
const putted = await graffiti.put(object, session);
|
|
437
|
+
|
|
438
|
+
const patches: GraffitiPatch[] = [
|
|
439
|
+
{
|
|
440
|
+
channels: [{ op: "replace", path: "", value: null }],
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
channels: [{ op: "replace", path: "", value: {} }],
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
channels: [{ op: "replace", path: "", value: ["hello", ["hi"]] }],
|
|
447
|
+
},
|
|
448
|
+
{
|
|
449
|
+
channels: [{ op: "add", path: "/0", value: 1 }],
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
value: [{ op: "replace", path: "", value: "not an object" }],
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
value: [{ op: "replace", path: "", value: null }],
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
value: [{ op: "replace", path: "", value: [] }],
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
allowed: [{ op: "replace", path: "", value: {} }],
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
allowed: [{ op: "replace", path: "", value: ["hello", ["hi"]] }],
|
|
465
|
+
},
|
|
466
|
+
];
|
|
467
|
+
|
|
468
|
+
for (const patch of patches) {
|
|
469
|
+
await expect(graffiti.patch(patch, putted, session)).rejects.toThrow(
|
|
470
|
+
GraffitiErrorPatchError,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const gotten = await graffiti.get(putted, {}, session);
|
|
475
|
+
expect(gotten.value).toEqual(object.value);
|
|
476
|
+
expect(gotten.channels).toEqual(object.channels);
|
|
477
|
+
expect(gotten.allowed).toEqual(object.allowed);
|
|
478
|
+
expect(gotten.lastModified.getTime()).toEqual(
|
|
479
|
+
putted.lastModified.getTime(),
|
|
480
|
+
);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { it, expect, describe } from "vitest";
|
|
2
|
+
import { type GraffitiFactory, type GraffitiSession } from "../src/index";
|
|
3
|
+
import { randomString, randomValue, randomPutObject } from "./utils";
|
|
4
|
+
|
|
5
|
+
export const graffitiDiscoverTests = (
|
|
6
|
+
useGraffiti: GraffitiFactory,
|
|
7
|
+
useSession1: () => GraffitiSession,
|
|
8
|
+
useSession2: () => GraffitiSession,
|
|
9
|
+
) => {
|
|
10
|
+
describe("discover", () => {
|
|
11
|
+
it("discover single", async () => {
|
|
12
|
+
const graffiti = useGraffiti();
|
|
13
|
+
const session = useSession1();
|
|
14
|
+
const object = randomPutObject();
|
|
15
|
+
|
|
16
|
+
const putted = await graffiti.put(object, session);
|
|
17
|
+
|
|
18
|
+
const queryChannels = [randomString(), object.channels[0]];
|
|
19
|
+
const iterator = graffiti.discover(queryChannels, {});
|
|
20
|
+
const result = (await iterator.next()).value;
|
|
21
|
+
if (!result || result.error) throw new Error();
|
|
22
|
+
expect(result.value.value).toEqual(object.value);
|
|
23
|
+
expect(result.value.channels).toEqual([object.channels[0]]);
|
|
24
|
+
expect(result.value.allowed).toBeUndefined();
|
|
25
|
+
expect(result.value.actor).toEqual(session.actor);
|
|
26
|
+
expect(result.value.tombstone).toBe(false);
|
|
27
|
+
expect(result.value.lastModified).toEqual(putted.lastModified);
|
|
28
|
+
const result2 = await iterator.next();
|
|
29
|
+
expect(result2.done).toBe(true);
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
};
|
package/tests/index.ts
CHANGED
package/tests/location.ts
CHANGED
|
@@ -1,16 +1,39 @@
|
|
|
1
|
-
import { it, expect } from "vitest";
|
|
2
|
-
import type
|
|
1
|
+
import { it, expect, describe } from "vitest";
|
|
2
|
+
import { GraffitiErrorInvalidUri, type GraffitiFactory } from "../src/index";
|
|
3
|
+
import { randomString } from "./utils";
|
|
3
4
|
|
|
4
5
|
export const graffitiLocationTests = (useGraffiti: GraffitiFactory) => {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
6
|
+
describe("URI and location conversion", () => {
|
|
7
|
+
it("location to uri and back", async () => {
|
|
8
|
+
const graffiti = useGraffiti();
|
|
9
|
+
const location = {
|
|
10
|
+
name: randomString(),
|
|
11
|
+
actor: randomString(),
|
|
12
|
+
source: randomString(),
|
|
13
|
+
};
|
|
14
|
+
const uri = graffiti.locationToUri(location);
|
|
15
|
+
const location2 = graffiti.uriToLocation(uri);
|
|
16
|
+
expect(location).toEqual(location2);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("collision resistance", async () => {
|
|
20
|
+
const graffiti = useGraffiti();
|
|
21
|
+
const location1 = {
|
|
22
|
+
name: randomString(),
|
|
23
|
+
actor: randomString(),
|
|
24
|
+
source: randomString(),
|
|
25
|
+
};
|
|
26
|
+
for (const prop of ["name", "actor", "source"] as const) {
|
|
27
|
+
const location2 = { ...location1, [prop]: randomString() };
|
|
28
|
+
const uri1 = graffiti.locationToUri(location1);
|
|
29
|
+
const uri2 = graffiti.locationToUri(location2);
|
|
30
|
+
expect(uri1).not.toEqual(uri2);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("random URI should not be a valid location", async () => {
|
|
35
|
+
const graffiti = useGraffiti();
|
|
36
|
+
expect(() => graffiti.uriToLocation("")).toThrow(GraffitiErrorInvalidUri);
|
|
37
|
+
});
|
|
15
38
|
});
|
|
16
39
|
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { it, expect, describe } from "vitest";
|
|
2
|
+
import { type GraffitiFactory, type GraffitiSession } from "../src/index";
|
|
3
|
+
import { randomPutObject, randomString } from "./utils";
|
|
4
|
+
import { randomInt } from "crypto";
|
|
5
|
+
|
|
6
|
+
export const graffitiSynchronizeTests = (
|
|
7
|
+
useGraffiti: GraffitiFactory,
|
|
8
|
+
useSession1: () => GraffitiSession,
|
|
9
|
+
useSession2: () => GraffitiSession,
|
|
10
|
+
) => {
|
|
11
|
+
describe("synchronize", () => {
|
|
12
|
+
it("get", async () => {
|
|
13
|
+
const graffiti1 = useGraffiti();
|
|
14
|
+
const session = useSession1();
|
|
15
|
+
|
|
16
|
+
const object = randomPutObject();
|
|
17
|
+
const channels = object.channels.slice(1);
|
|
18
|
+
const putted = await graffiti1.put(object, session);
|
|
19
|
+
|
|
20
|
+
const graffiti2 = useGraffiti();
|
|
21
|
+
const next = graffiti2.synchronize(channels, {}).next();
|
|
22
|
+
const gotten = await graffiti2.get(putted, {}, session);
|
|
23
|
+
|
|
24
|
+
const result = (await next).value;
|
|
25
|
+
if (!result || result.error) {
|
|
26
|
+
throw new Error("Error in synchronize");
|
|
27
|
+
}
|
|
28
|
+
expect(result.value.value).toEqual(object.value);
|
|
29
|
+
expect(result.value.channels).toEqual(channels);
|
|
30
|
+
expect(result.value.tombstone).toBe(false);
|
|
31
|
+
expect(result.value.lastModified.getTime()).toEqual(
|
|
32
|
+
gotten.lastModified.getTime(),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("put", async () => {
|
|
37
|
+
const graffiti = useGraffiti();
|
|
38
|
+
const session = useSession1();
|
|
39
|
+
|
|
40
|
+
const beforeChannel = randomString();
|
|
41
|
+
const afterChannel = randomString();
|
|
42
|
+
const sharedChannel = randomString();
|
|
43
|
+
|
|
44
|
+
const oldValue = { hello: "world" };
|
|
45
|
+
const oldChannels = [beforeChannel, sharedChannel];
|
|
46
|
+
const putted = await graffiti.put(
|
|
47
|
+
{
|
|
48
|
+
value: oldValue,
|
|
49
|
+
channels: oldChannels,
|
|
50
|
+
},
|
|
51
|
+
session,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// Start listening for changes...
|
|
55
|
+
const before = graffiti.synchronize([beforeChannel], {}).next();
|
|
56
|
+
const after = graffiti.synchronize([afterChannel], {}).next();
|
|
57
|
+
const shared = graffiti.synchronize([sharedChannel], {}).next();
|
|
58
|
+
|
|
59
|
+
// Replace the object
|
|
60
|
+
const newValue = { goodbye: "world" };
|
|
61
|
+
const newChannels = [afterChannel, sharedChannel];
|
|
62
|
+
await graffiti.put(
|
|
63
|
+
{
|
|
64
|
+
...putted,
|
|
65
|
+
value: newValue,
|
|
66
|
+
channels: newChannels,
|
|
67
|
+
},
|
|
68
|
+
session,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const beforeResult = (await before).value;
|
|
72
|
+
const afterResult = (await after).value;
|
|
73
|
+
const sharedResult = (await shared).value;
|
|
74
|
+
if (
|
|
75
|
+
!beforeResult ||
|
|
76
|
+
beforeResult.error ||
|
|
77
|
+
!afterResult ||
|
|
78
|
+
afterResult.error ||
|
|
79
|
+
!sharedResult ||
|
|
80
|
+
sharedResult.error
|
|
81
|
+
) {
|
|
82
|
+
throw new Error("Error in synchronize");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
expect(beforeResult.value.value).toEqual(oldValue);
|
|
86
|
+
expect(beforeResult.value.channels).toEqual([beforeChannel]);
|
|
87
|
+
expect(beforeResult.value.tombstone).toBe(true);
|
|
88
|
+
expect(afterResult.value.value).toEqual(newValue);
|
|
89
|
+
expect(afterResult.value.channels).toEqual([afterChannel]);
|
|
90
|
+
expect(afterResult.value.tombstone).toBe(false);
|
|
91
|
+
expect(sharedResult.value.value).toEqual(newValue);
|
|
92
|
+
expect(sharedResult.value.channels).toEqual([sharedChannel]);
|
|
93
|
+
expect(sharedResult.value.tombstone).toBe(false);
|
|
94
|
+
expect(beforeResult.value.lastModified.getTime()).toEqual(
|
|
95
|
+
afterResult.value.lastModified.getTime(),
|
|
96
|
+
);
|
|
97
|
+
expect(sharedResult.value.lastModified.getTime()).toEqual(
|
|
98
|
+
afterResult.value.lastModified.getTime(),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("patch", async () => {
|
|
103
|
+
const graffiti = useGraffiti();
|
|
104
|
+
const session = useSession1();
|
|
105
|
+
|
|
106
|
+
const beforeChannel = randomString();
|
|
107
|
+
const afterChannel = randomString();
|
|
108
|
+
const sharedChannel = randomString();
|
|
109
|
+
|
|
110
|
+
const oldValue = { hello: "world" };
|
|
111
|
+
const oldChannels = [beforeChannel, sharedChannel];
|
|
112
|
+
const putted = await graffiti.put(
|
|
113
|
+
{
|
|
114
|
+
value: oldValue,
|
|
115
|
+
channels: oldChannels,
|
|
116
|
+
},
|
|
117
|
+
session,
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
// Start listening for changes...
|
|
121
|
+
const before = graffiti.synchronize([beforeChannel], {}).next();
|
|
122
|
+
const after = graffiti.synchronize([afterChannel], {}).next();
|
|
123
|
+
const shared = graffiti.synchronize([sharedChannel], {}).next();
|
|
124
|
+
|
|
125
|
+
await graffiti.patch(
|
|
126
|
+
{
|
|
127
|
+
value: [
|
|
128
|
+
{
|
|
129
|
+
op: "add",
|
|
130
|
+
path: "/something",
|
|
131
|
+
value: "new value",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
channels: [
|
|
135
|
+
{
|
|
136
|
+
op: "add",
|
|
137
|
+
path: "/-",
|
|
138
|
+
value: afterChannel,
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
op: "remove",
|
|
142
|
+
path: `/${oldChannels.indexOf(beforeChannel)}`,
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
},
|
|
146
|
+
putted,
|
|
147
|
+
session,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const beforeResult = (await before).value;
|
|
151
|
+
const afterResult = (await after).value;
|
|
152
|
+
const sharedResult = (await shared).value;
|
|
153
|
+
if (
|
|
154
|
+
!beforeResult ||
|
|
155
|
+
beforeResult.error ||
|
|
156
|
+
!afterResult ||
|
|
157
|
+
afterResult.error ||
|
|
158
|
+
!sharedResult ||
|
|
159
|
+
sharedResult.error
|
|
160
|
+
) {
|
|
161
|
+
throw new Error("Error in synchronize");
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const newValue = { ...oldValue, something: "new value" };
|
|
165
|
+
const newChannels = [sharedChannel, afterChannel];
|
|
166
|
+
expect(beforeResult.value.value).toEqual(oldValue);
|
|
167
|
+
expect(beforeResult.value.channels).toEqual([beforeChannel]);
|
|
168
|
+
expect(beforeResult.value.tombstone).toBe(true);
|
|
169
|
+
expect(afterResult.value.value).toEqual(newValue);
|
|
170
|
+
expect(afterResult.value.channels).toEqual([afterChannel]);
|
|
171
|
+
expect(afterResult.value.tombstone).toBe(false);
|
|
172
|
+
expect(sharedResult.value.value).toEqual(newValue);
|
|
173
|
+
expect(sharedResult.value.channels).toEqual([sharedChannel]);
|
|
174
|
+
expect(sharedResult.value.tombstone).toBe(false);
|
|
175
|
+
expect(beforeResult.value.lastModified.getTime()).toEqual(
|
|
176
|
+
afterResult.value.lastModified.getTime(),
|
|
177
|
+
);
|
|
178
|
+
expect(sharedResult.value.lastModified.getTime()).toEqual(
|
|
179
|
+
afterResult.value.lastModified.getTime(),
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("delete", async () => {
|
|
184
|
+
const graffiti = useGraffiti();
|
|
185
|
+
const session = useSession1();
|
|
186
|
+
|
|
187
|
+
const channels = [randomString(), randomString(), randomString()];
|
|
188
|
+
|
|
189
|
+
const oldValue = { hello: "world" };
|
|
190
|
+
const oldChannels = [randomString(), ...channels.slice(1)];
|
|
191
|
+
const putted = await graffiti.put(
|
|
192
|
+
{
|
|
193
|
+
value: oldValue,
|
|
194
|
+
channels: oldChannels,
|
|
195
|
+
},
|
|
196
|
+
session,
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
const next = graffiti.synchronize(channels, {}).next();
|
|
200
|
+
|
|
201
|
+
graffiti.delete(putted, session);
|
|
202
|
+
|
|
203
|
+
const result = (await next).value;
|
|
204
|
+
if (!result || result.error) {
|
|
205
|
+
throw new Error("Error in synchronize");
|
|
206
|
+
}
|
|
207
|
+
expect(result.value.tombstone).toBe(true);
|
|
208
|
+
expect(result.value.value).toEqual(oldValue);
|
|
209
|
+
expect(result.value.channels).toEqual(
|
|
210
|
+
channels.filter((c) => oldChannels.includes(c)),
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("not allowed", async () => {
|
|
215
|
+
const graffiti = useGraffiti();
|
|
216
|
+
const session1 = useSession1();
|
|
217
|
+
const session2 = useSession2();
|
|
218
|
+
|
|
219
|
+
const allChannels = [randomString(), randomString(), randomString()];
|
|
220
|
+
const channels = allChannels.slice(1);
|
|
221
|
+
|
|
222
|
+
const creatorNext = graffiti.synchronize(channels, {}, session1).next();
|
|
223
|
+
const allowedNext = graffiti.synchronize(channels, {}, session2).next();
|
|
224
|
+
const noSession = graffiti.synchronize(channels, {}).next();
|
|
225
|
+
|
|
226
|
+
const value = {
|
|
227
|
+
hello: "world",
|
|
228
|
+
};
|
|
229
|
+
const allowed = [randomString(), session2.actor];
|
|
230
|
+
await graffiti.put({ value, channels: allChannels, allowed }, session1);
|
|
231
|
+
|
|
232
|
+
// Expect no session to time out!
|
|
233
|
+
await expect(
|
|
234
|
+
Promise.race([
|
|
235
|
+
noSession,
|
|
236
|
+
new Promise((resolve, rejects) =>
|
|
237
|
+
setTimeout(rejects, 100, "Timeout"),
|
|
238
|
+
),
|
|
239
|
+
]),
|
|
240
|
+
).rejects.toThrow("Timeout");
|
|
241
|
+
|
|
242
|
+
const creatorResult = (await creatorNext).value;
|
|
243
|
+
const allowedResult = (await allowedNext).value;
|
|
244
|
+
|
|
245
|
+
if (
|
|
246
|
+
!creatorResult ||
|
|
247
|
+
creatorResult.error ||
|
|
248
|
+
!allowedResult ||
|
|
249
|
+
allowedResult.error
|
|
250
|
+
) {
|
|
251
|
+
throw new Error("Error in synchronize");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
expect(creatorResult.value.value).toEqual(value);
|
|
255
|
+
expect(creatorResult.value.allowed).toEqual(allowed);
|
|
256
|
+
expect(creatorResult.value.channels).toEqual(allChannels);
|
|
257
|
+
expect(allowedResult.value.value).toEqual(value);
|
|
258
|
+
expect(allowedResult.value.allowed).toEqual([session2.actor]);
|
|
259
|
+
expect(allowedResult.value.channels).toEqual(channels);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
};
|
package/tests/utils.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { GraffitiPutObject } from "../src";
|
|
2
|
+
|
|
3
|
+
export function randomString(): string {
|
|
4
|
+
const array = new Uint8Array(16);
|
|
5
|
+
crypto.getRandomValues(array);
|
|
6
|
+
return Array.from(array)
|
|
7
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
8
|
+
.join("");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function randomValue() {
|
|
12
|
+
return {
|
|
13
|
+
[randomString()]: randomString(),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function randomPutObject(): GraffitiPutObject<{}> {
|
|
18
|
+
return {
|
|
19
|
+
value: randomValue(),
|
|
20
|
+
channels: [randomString(), randomString()],
|
|
21
|
+
};
|
|
22
|
+
}
|
package/src/errors.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
class GraffitiUnauthorizedError extends Error {
|
|
2
|
-
constructor(message: string) {
|
|
3
|
-
super(message);
|
|
4
|
-
this.name = "UnauthorizedError";
|
|
5
|
-
Object.setPrototypeOf(this, GraffitiUnauthorizedError.prototype);
|
|
6
|
-
}
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
class ForbiddenError extends Error {
|
|
10
|
-
constructor(message: string) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.name = "ForbiddenError";
|
|
13
|
-
}
|
|
14
|
-
}
|