@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 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",
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",
@@ -3,10 +3,10 @@ import type {
3
3
  GraffitiObject,
4
4
  GraffitiObjectBase,
5
5
  GraffitiPatch,
6
- GraffitiSessionBase,
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 operations that
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 operations is that an {@link GraffitiObjectBase.actor | `actor`}
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 Operations
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 Operations
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 operation
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 Operations
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
- * operations.
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: GraffitiSessionBase,
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
- * an error is thrown.
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 an error is thrown.
139
+ * otherwise a {@link GraffitiErrorSchemaMismatch} is thrown.
138
140
  *
139
- * @group CRUD Operations
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?: GraffitiSessionBase,
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 Operations
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: GraffitiSessionBase,
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 Operations
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: GraffitiSessionBase,
216
+ session: GraffitiSession,
212
217
  ): Promise<GraffitiObjectBase>;
213
218
 
214
219
  /**
215
- * Returns a stream of {@link GraffitiObjectBase | objects}
216
- * that are contained in at least one of the given {@link GraffitiObjectBase.channels | `channels`}
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
- * @group Query Operations
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?: GraffitiSessionBase,
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 Operations
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?: GraffitiSessionBase,
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: GraffitiSessionBase,
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: GraffitiSessionBase): GraffitiStream<{
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 GraffitiSessionBase | `session` } will be used.
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 GraffitiSessionBase.actor | `actor`} URI the user wants to authenticate with.
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 GraffitiSessionBase {
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: false;
244
+ error?: undefined;
245
245
  value: T;
246
246
  }
247
247
  | {
248
- error: true;
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
+ }>;
@@ -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
@@ -1,3 +1,4 @@
1
- export * from "./types";
1
+ export * from "./1-api";
2
+ export * from "./2-types";
3
+ export * from "./3-errors";
2
4
  export type { JSONSchema4 } from "json-schema";
3
- export * from "./api";
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
@@ -1 +1,4 @@
1
1
  export * from "./location";
2
+ export * from "./crud";
3
+ export * from "./synchronize";
4
+ export * from "./discover";
package/tests/location.ts CHANGED
@@ -1,16 +1,39 @@
1
- import { it, expect } from "vitest";
2
- import type { GraffitiFactory } from "../src/index";
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
- it("url and location", async () => {
6
- const graffiti = useGraffiti();
7
- const location = {
8
- name: "12345",
9
- actor: "https://example.com/actor",
10
- source: "https://example.com/source",
11
- };
12
- const uri = graffiti.locationToUri(location);
13
- const location2 = graffiti.uriToLocation(uri);
14
- expect(location).toEqual(location2);
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
- }