@graffiti-garden/api 0.0.9 → 0.1.1

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
@@ -1,47 +1,7 @@
1
1
  # Graffiti API
2
2
 
3
- The Graffiti API makes it possible to build social applications that are flexible and interoperable.
4
- This repository contains the abstract API and it's documentation.
3
+ The Graffiti API makes it possible to build many different types of social applications
4
+ that naturally interoperate each other, all using only standard client-side tools.
5
+ This repository contains the abstract API and its documentation.
5
6
 
6
- [View the Documentation](https://api.graffiti.garden/classes/Graffiti.html)
7
-
8
- ## Building the Documentation
9
-
10
- To build the [TypeDoc](https://typedoc.org/) documentation, run the following commands:
11
-
12
- ```bash
13
- npm run install
14
- npm run docs
15
- ```
16
-
17
- Then run a local server to view the documentation:
18
-
19
- ```bash
20
- cd docs
21
- npx http-server
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
- ```
7
+ [**View the Documentation**](https://api.graffiti.garden/classes/Graffiti.html)
@@ -0,0 +1,2 @@
1
+ "use strict";class r extends Error{constructor(t){super(t),this.name="GraffitiErrorUnauthorized",Object.setPrototypeOf(this,r.prototype)}}class t extends Error{constructor(r){super(r),this.name="GraffitiErrorForbidden",Object.setPrototypeOf(this,t.prototype)}}class o extends Error{constructor(r){super(r),this.name="GraffitiErrorNotFound",Object.setPrototypeOf(this,o.prototype)}}class e extends Error{constructor(r){super(r),this.name="GraffitiErrorInvalidSchema",Object.setPrototypeOf(this,e.prototype)}}class s extends Error{constructor(r){super(r),this.name="GraffitiErrorSchemaMismatch",Object.setPrototypeOf(this,s.prototype)}}class i extends Error{constructor(r){super(r),this.name="GraffitiErrorPatchTestFailed",Object.setPrototypeOf(this,i.prototype)}}class a extends Error{constructor(r){super(r),this.name="GraffitiErrorPatchError",Object.setPrototypeOf(this,a.prototype)}}class c extends Error{constructor(r){super(r),this.name="GraffitiErrorInvalidUri",Object.setPrototypeOf(this,c.prototype)}}exports.Graffiti=class{objectToUri(r){return this.locationToUri(r)}},exports.GraffitiErrorForbidden=t,exports.GraffitiErrorInvalidSchema=e,exports.GraffitiErrorInvalidUri=c,exports.GraffitiErrorNotFound=o,exports.GraffitiErrorPatchError=a,exports.GraffitiErrorPatchTestFailed=i,exports.GraffitiErrorSchemaMismatch=s,exports.GraffitiErrorUnauthorized=r;
2
+ //# sourceMappingURL=index.cjs.js.map
package/package.json CHANGED
@@ -1,24 +1,36 @@
1
1
  {
2
2
  "name": "@graffiti-garden/api",
3
- "version": "0.0.9",
3
+ "version": "0.1.1",
4
4
  "description": "The heart of Graffiti",
5
- "type": "module",
6
- "main": "src/index.ts",
5
+ "types": "src/index.ts",
6
+ "module": "dist/index.js",
7
+ "main": "dist/index.cjs.js",
7
8
  "exports": {
8
- ".": "./src/index.ts",
9
+ ".": {
10
+ "import": {
11
+ "types": "./src/index.ts",
12
+ "node": "./dist/index.cjs.js",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "require": {
16
+ "types": "./src/index.ts",
17
+ "default": "./dist/index.cjs.js"
18
+ }
19
+ },
9
20
  "./tests": "./tests/index.ts"
10
21
  },
11
22
  "files": [
12
23
  "src",
13
24
  "tests",
14
25
  "package.json",
15
- "tsconfig.json"
26
+ "README.md"
16
27
  ],
17
28
  "author": "Theia Henderson",
18
29
  "license": "GPL-3.0-or-later",
19
30
  "scripts": {
20
31
  "docs": "typedoc --options typedoc.json",
21
- "prepublishOnly": "npm install"
32
+ "build": "rollup -c rollup.config.ts --configPlugin rollup-plugin-typescript2",
33
+ "prepublishOnly": "npm install && npm run build"
22
34
  },
23
35
  "repository": {
24
36
  "type": "git",
@@ -29,6 +41,9 @@
29
41
  },
30
42
  "homepage": "https://api.graffiti.garden/classes/Graffiti.html",
31
43
  "devDependencies": {
44
+ "@rollup/plugin-terser": "^0.4.4",
45
+ "rollup": "^4.31.0",
46
+ "rollup-plugin-typescript2": "^0.36.0",
32
47
  "tslib": "^2.8.1",
33
48
  "typedoc": "^0.26.11",
34
49
  "vitest": "^2.1.8"
package/src/1-api.ts CHANGED
@@ -10,50 +10,229 @@ import type {
10
10
  import type { JSONSchema4 } from "json-schema";
11
11
 
12
12
  /**
13
- * This API describes a small but mighty set of methods that
13
+ * This API describes a small but powerful set of methods that
14
14
  * can be used to create many different kinds of social media applications,
15
15
  * all of which can interoperate.
16
16
  * These methods should satisfy all of an application's needs for
17
17
  * the communication, storage, and access management of social data.
18
18
  * The rest of the application can be built with standard client-side
19
- * user interface tools to present and interact with the data.
19
+ * user interface tools to present and interact with the data
20
+ * no server code necessary.
21
+ * The Typescript source for this API is available at
22
+ * [graffiti-garden/api](https://github.com/graffiti-garden/api).
20
23
  *
21
24
  * There are several different implementations of this Graffiti API available,
22
25
  * including a [decentralized implementation](https://github.com/graffiti-garden/client-core),
23
26
  * and a [local implementation](https://github.com/graffiti-garden/implementation-pouchdb)
24
- * that can be used for testing. In the design of Graffiti we prioritized
25
- * the design of this API first as it is the layer that shapes the experience
26
- * of developing applications. While different implementations provide tradeoffs between
27
+ * that can be used for testing. In our design of Graffiti, this API is our
28
+ * primary focus as it is the layer that shapes the experience
29
+ * of developing applications. While different implementations can provide tradeoffs between
27
30
  * other important properties (e.g. privacy, security, scalability), those properties
28
- * are useless if the system as a whole is unusable. Build APIs before protocols!
29
- *
30
- * There is also a wrapper around this API that provides a [Vue plugin](https://github.com/graffiti-garden/wrapper-vue/)
31
- * that enables reactivity. Other front-end frameworks can be supported in the future.
32
- *
33
- * The first group of methods are like standard CRUD methods that
34
- * allow applications to {@link put}, {@link get}, {@link patch}, and {@link delete}
35
- * {@link GraffitiObjectBase} objects. The main difference between these
36
- * methods and standard database methods is that an {@link GraffitiObjectBase.actor | `actor`}
37
- * (essentially a user) can only modify objects that they created.
38
- * Applications may also specify an an array of actors that are {@link GraffitiObjectBase.allowed | `allowed`}
39
- * to access the object and an array of {@link GraffitiObjectBase.channels | `channels`}
40
- * that the object is associated with.
41
- *
42
- * The "social" part of the API is the {@link discover} method, which allows
43
- * an application to query for objects made by other users.
44
- * This function only returns objects that are associated with one or more
45
- * of the {@link GraffitiObjectBase.channels | `channels`}
46
- * provided by a querying application. This helps to prevent
47
- * [context collapse](https://en.wikipedia.org/wiki/Context_collapse) and
48
- * allows users to express their intended audience, even in an interoperable
49
- * environment.
50
- *
51
- * Additionally, {@link synchronize} keeps track of changes to data
52
- * from any of the aforementioned methods and routes these changes internally
53
- * to provide a consistent user experience.
54
- *
55
- * Finally, other utility functions provide simple type conversions and
56
- * allow users to find objects "lost" to forgotten or misspelled channels.
31
+ * are useless if the system as a whole doesn't expose useful functionality to developers.
32
+ *
33
+ * On the other side of the stack, there is [Vue plugin](https://github.com/graffiti-garden/wrapper-vue/)
34
+ * that wraps around this API to provide reactivity. Other high-level libraries
35
+ * will be available in the future.
36
+ *
37
+ * ## Overview
38
+ *
39
+ * This API tries to draw from well-known concepts and standards wherever possible.
40
+ * JSON objects, representing social artifacts (e.g. posts, profiles) and activities
41
+ * (e.g. likes, follows) can be interacted with through standard CRUD operations:
42
+ * {@link put}, {@link get}, {@link patch}, and {@link delete}.
43
+ * Objects can be typed with [JSON Schema](https://json-schema.org/) and patches
44
+ * can be applied with [JSON Patch](https://jsonpatch.com).
45
+ * For interoperability between Graffiti applications, we recommend using established properties from the
46
+ * [Activity Vocabulary](https://www.w3.org/TR/activitystreams-vocabulary/) when available.
47
+ *
48
+ * The social aspect of Graffiti comes from the {@link discover} operation
49
+ * which allows applications to find objects that other users made.
50
+ * It is a lot like a traditional query operation, but it only
51
+ * returns objects that have been placed in particular
52
+ * {@link GraffitiObjectBase.channels | `channels`}
53
+ * specified by the discovering application.
54
+ *
55
+ * {@link GraffitiObjectBase.channels | `channels`} are one of the major concepts
56
+ * unique to Graffiti along with *interaction relativity*.
57
+ * Channels create boundaries between public spaces and work to prevent
58
+ * [context collapse](https://en.wikipedia.org/wiki/Context_collapse)
59
+ * even in a highly interoperable environment.
60
+ * Interaction relativity means that all interactions between users are
61
+ * actually atomic single-user operations that can be interpreted in different ways,
62
+ * which also supports interoperability and pluralism.
63
+ *
64
+ * ### Channels
65
+ *
66
+ * {@link GraffitiObjectBase.channels | `channels`}
67
+ * are a way for the creators of social data to express the intended audience of their
68
+ * data. When a user creates data using the {@link put} method, they
69
+ * can place their data in one or more channels.
70
+ * Content consumers using the {@link discover} method will only see data
71
+ * contained in one of the channels they specify.
72
+ *
73
+ * While many channels may be public, they partition
74
+ * the public into different "contexts", mitigating the
75
+ * phenomenon of [context collapse](https://en.wikipedia.org/wiki/Context_collapse) or the "flattening of multiple audiences."
76
+ * Any [URI](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier) can be used as a channel, and so channels can represent people,
77
+ * comment threads, topics, places (real or virtual), pieces of media, and more.
78
+ *
79
+ * For example, consider a comment on a post. If we place that comment in the channel
80
+ * represented by the post's URI, then only people viewing the post will know to
81
+ * look in that channel, giving it visibility akin to a comment on a blog post
82
+ * or comment on Instagram ([since 2019](https://www.buzzfeednews.com/article/katienotopoulos/instagrams-following-activity-tab-is-going-away)).
83
+ * If we also place the comment in the channel represented by the commenter's URI (their
84
+ * {@link GraffitiObjectBase.actor | `actor` URI}), then people viewing the commenter's profile
85
+ * will also see the comment, giving it more visibility, like a reply on Twitter.
86
+ * If we *only* place the comment in the channel represented by the commenter's URI, then
87
+ * it becomes like a quote tweet ([prior to 2020](https://x.com/Support/status/1300555325750292480)),
88
+ * where the comment is only visible to the commenter's followers but not the audience
89
+ * of the original post.
90
+ *
91
+ * The channel model differs from other models of communication such as the
92
+ * [actor model](https://www.w3.org/TR/activitypub/#Overview) used by ActivityPub,
93
+ * the protocol underlying Mastodon, or the [firehose model](https://bsky.social/about/blog/5-5-2023-federation-architecture)
94
+ * used by the AT Protocol, the protocol underlying BlueSky.
95
+ * The actor model is a fusion of direct messaging (like Email) and broadcasting
96
+ * (like RSS) and works well for follow-based communication but struggles
97
+ * to pass information via other rendez-vous.
98
+ * In the actor model, even something as simple as comments can be
99
+ * [very tricky and require server "side effects"](https://seb.jambor.dev/posts/understanding-activitypub-part-3-the-state-of-mastodon/).
100
+ * The firehose model dumps all user data into one public database,
101
+ * which doesn't allow for the carving out of different contexts that we did in our comment
102
+ * example above. In the firehose model a comment will always be visible to *both* the original post's audience and
103
+ * the commenter's followers.
104
+ *
105
+ * In some sense, channels provide a sort of "social access control" by forming
106
+ * expectations about the audiences of different online spaces.
107
+ * As a real world analogy, oftentimes support groups, such as alcoholics
108
+ * anonymous, are open to the public but people in those spaces feel comfortable sharing intimate details
109
+ * because they have expectations about the other people attending.
110
+ * If someone malicious went to support groups just to spread people's secrets,
111
+ * they would be shamed for violating these norms.
112
+ * Similarly, in Graffiti, while you could spider public channels like a search engine
113
+ * to find content about a person, revealing that you've done such a thing
114
+ * would be shameful.
115
+ *
116
+ * Still, social access control is not perfect and so in situations where privacy is important,
117
+ * objects can also be given
118
+ * an {@link GraffitiObjectBase.allowed | `allowed`} list.
119
+ * For example, to send someone a direct message you should put an object representing
120
+ * that message in the channel that represents them (their {@link GraffitiObjectBase.actor | `actor` URI}),
121
+ * so they can find it, *and* set the `allowed` field to only include the recipient,
122
+ * so only they can read it.
123
+ *
124
+ * ### Interaction relativity
125
+ *
126
+ * Interaction relativity posits that "interaction between two individuals only
127
+ * exists relative to an observer," or equivalently, all interaction is [reified](https://en.wikipedia.org/wiki/Reification_(computer_science)).
128
+ * For example, if one user creates a post and another user wants to "like" that post,
129
+ * their like is not modifying the original post, it is simply another data object that points
130
+ * to the post being liked, via its {@link locationToUri | URI}.
131
+ *
132
+ * ```json
133
+ * {
134
+ * activity: 'like',
135
+ * target: 'uri-of-the-post-i-like',
136
+ * actor: 'my-user-id'
137
+ * }
138
+ * ```
139
+ *
140
+ * In Graffiti, all interactions including *moderation* and *collaboration* are relative.
141
+ * This means that applications can freely choose which interactions
142
+ * they want to express to their users and how.
143
+ * For example, one application could have a single fixed moderator,
144
+ * another could allow users to choose which moderators they would like filter their content
145
+ * like [Bluesky's stackable moderation](https://bsky.social/about/blog/03-12-2024-stackable-moderation),
146
+ * and another could implement a fully democratic system like [PolicyKit](https://policykit.org/).
147
+ * Each of these applications is one interpretation of the underlying refieid user interactions and
148
+ * users can freely switch between them.
149
+ *
150
+ * Interaction relativy also allows applications to introduce new sorts of interactions
151
+ * without having to coordinate with all the other existing applications,
152
+ * keeping the ecosystem flexible and interoperable.
153
+ * For example, an application could [add a "Trust" button to posts](https://social.cs.washington.edu/pub_details.html?id=trustnet)
154
+ * and use it assess the truthfulness of posts made on applications across Graffiti.
155
+ * New sorts of interactions like these can be smoothly absorbed by the broader ecosystem
156
+ * as a [folksonomy](https://en.wikipedia.org/wiki/Folksonomy).
157
+ *
158
+ * Interactivy relativity is realized in Graffiti through two design decisions:
159
+ * 1. The creators of objects can only modify their own objects. It is important for
160
+ * users to be able to change and delete their own content to respect their
161
+ * [right to be forgotten](https://en.wikipedia.org/wiki/Right_to_be_forgotten),
162
+ * but beyond self-correction and self-censorship all other interaction is reified.
163
+ * Many interactions can be reified via pointers, as in the "like" example above, and collaborative
164
+ * edits can be refieid via [CRDTs](https://en.wikipedia.org/wiki/Conflict-free_replicated_data_type).
165
+ * 2. No one owns channels. Unlike IRC/Slack channels or [Matrix rooms](https://matrix.org/docs/matrix-concepts/rooms_and_events/),
166
+ * anyone can post to any channel, so long as they know the URI of that channel.
167
+ * It is up to applications to hide content from channels either according to manual
168
+ * filters or in response to user action.
169
+ * For example, a user may create a post with the flag `disableReplies`.
170
+ * Applications could then filter out any content from the replies channel
171
+ * that the original poster has not specifically approved.
172
+ *
173
+ * ## Implementing the API
174
+ *
175
+ * To implement the API, first install it:
176
+ *
177
+ * ```bash
178
+ * npm install @graffiti-garden/api
179
+ * ```
180
+ *
181
+ * Then create a class that extends the `Graffiti` class and implement the abstract methods.
182
+ *
183
+ * ```typescript
184
+ * import { Graffiti } from "@graffiti-garden/api";
185
+ *
186
+ * class MyGraffitiImplementation extends Graffiti {
187
+ * // Implement the abstract methods here
188
+ * }
189
+ * ```
190
+ * ### Testing
191
+ *
192
+ * We have written a number of unit tests written with [vitest](https://vitest.dev/)
193
+ * that can be used to verify implementations of the API.
194
+ * To use them, create a test file in that ends in `*.spec.ts` and format it as follows:
195
+ *
196
+ * ```typescript
197
+ * import { graffitiCRUDTests } from "@graffiti-garden/api/tests";
198
+ *
199
+ * const useGraffiti = () => new MyGraffitiImplementation();
200
+ * // Fill in with implementation-specific information
201
+ * // to provide to valid actor sessions for the tests
202
+ * // to use as identities.
203
+ * const useSession1 = () => ({ actor: "someone" });
204
+ * const useSession2 = () => ({ actor: "someoneelse" });
205
+ *
206
+ * // Run the tests
207
+ * graffitiCRUDTests(useGraffiti, useSession1, useSession2);
208
+ * ```
209
+ *
210
+ * Then run the tests in the root of your directory with:
211
+ *
212
+ * ```bash
213
+ * npx vitest
214
+ * ```
215
+ *
216
+ * ## Building the Documentation
217
+ *
218
+ * To build the [TypeDoc](https://typedoc.org/) documentation, run the following commands:
219
+ *
220
+ * ```bash
221
+ * npm run install
222
+ * npm run docs
223
+ * ```
224
+ *
225
+ * Then run a local server to view the documentation:
226
+ *
227
+ * ```bash
228
+ * cd docs
229
+ * npx http-server
230
+ * ```
231
+ *
232
+ * ## TODO
233
+ *
234
+ * - Test for listChannels and listOrphans,
235
+ * - Implement scope.
57
236
  *
58
237
  * @groupDescription CRUD Methods
59
238
  * Methods for {@link put | creating}, {@link get | reading}, {@link patch | updating},
@@ -238,15 +417,46 @@ export abstract class Graffiti {
238
417
  * not specified by the `discover` method will not be revealed. This masking happens
239
418
  * before the supplied schema is applied.
240
419
  *
420
+ * {@link discover} can be used in conjunction with {@link synchronize}
421
+ * to provide a responsive and consistent user experience.
422
+ *
241
423
  * Since different implementations may fetch data from multiple sources there is
242
424
  * no guarentee on the order that objects are returned in. Additionally, the method
243
- * may return objects that have been deleted but with a
425
+ * will return objects that have been deleted but with a
244
426
  * {@link GraffitiObjectBase.tombstone | `tombstone`} field set to `true` for
245
- * cache invalidation purposes. Implementations must make aware when, if ever,
246
- * tombstoned objects are removed.
247
- *
248
- * {@link discover} can be used in conjunction with {@link synchronize}
249
- * to provide a responsive and consistent user experience.
427
+ * cache invalidation purposes.
428
+ * The final `return()` value of the stream includes a `tombstoneRetention`
429
+ * property that represents the minimum amount of time,
430
+ * in milliseconds, that an application will retain and return tombstones for objects that
431
+ * have been deleted.
432
+ *
433
+ * When repolling, the {@link GraffitiObjectBase.lastModified | `lastModified`}
434
+ * field can be queried via the schema to
435
+ * only fetch objects that have been modified since the last poll.
436
+ * Such queries should only be done if the time since the last poll
437
+ * is less than the `tombstoneRetention` value of that poll, otherwise the tombstones
438
+ * for objects that have been deleted may not be returned.
439
+ *
440
+ * ```json
441
+ * {
442
+ * "properties": {
443
+ * "lastModified": {
444
+ * "minimum": LAST_RETRIEVED_TIME
445
+ * }
446
+ * }
447
+ * }
448
+ * ```
449
+ *
450
+ * `discover` needs to be polled for new data because live updates to
451
+ * an application can be visually distracting or lead to toxic engagement.
452
+ * If and when an application wants real-time updates, such as in a chat
453
+ * application, application authors must be intentional about their polling.
454
+ *
455
+ * Implementers should be aware that some users may applications may try to poll
456
+ * {@link discover} repetitively. You can deal with this by rate limiting or
457
+ * preemptively fetching data via a bidirectional channel, like a WebSocket.
458
+ * Additionally, implementers should probably index the `lastModified` field
459
+ * to speed up responses to schemas like the one above.
250
460
  *
251
461
  * @returns A stream of objects that match the given {@link GraffitiObjectBase.channels | `channels`}
252
462
  * and [JSON Schema](https://json-schema.org).
@@ -269,7 +479,12 @@ export abstract class Graffiti {
269
479
  * property will be returned.
270
480
  */
271
481
  session?: GraffitiSession,
272
- ): GraffitiStream<GraffitiObject<Schema>>;
482
+ ): GraffitiStream<
483
+ GraffitiObject<Schema>,
484
+ {
485
+ tombstoneRetention: number;
486
+ }
487
+ >;
273
488
 
274
489
  /**
275
490
  * This method has the same signature as {@link discover} but listens for
@@ -336,7 +551,6 @@ export abstract class Graffiti {
336
551
  session: GraffitiSession,
337
552
  ): GraffitiStream<{
338
553
  channel: string;
339
- source: string;
340
554
  lastModified: number;
341
555
  count: number;
342
556
  }>;
@@ -355,15 +569,12 @@ export abstract class Graffiti {
355
569
  * and {@link GraffitiObjectBase.source | `source`} of the orphaned objects
356
570
  * that the {@link GraffitiObjectBase.actor | `actor`} has posted to.
357
571
  * The {@link GraffitiObjectBase.lastModified | lastModified} field is the
358
- * time that the user last modified the orphan and the
359
- * {@link GraffitiObjectBase.tombstone | `tombstone`} field is `true`
360
- * if the object has been deleted.
572
+ * time that the user last modified the orphan.
361
573
  */
362
574
  abstract listOrphans(session: GraffitiSession): GraffitiStream<{
363
575
  name: string;
364
576
  source: string;
365
577
  lastModified: string;
366
- tombstone: boolean;
367
578
  }>;
368
579
 
369
580
  /**
@@ -378,21 +589,40 @@ export abstract class Graffiti {
378
589
  *
379
590
  * The {@link GraffitiSession | session} object is returned
380
591
  * asynchronously via {@link Graffiti.sessionEvents | sessionEvents}
381
- * as a {@link GraffitiLoginEvent}.
592
+ * as a {@link GraffitiLoginEvent} with event type `login`.
382
593
  *
383
594
  * @group Session Management
384
595
  */
385
596
  abstract login(
386
597
  /**
387
- * An optional actor to prompt the user to login as. For example,
388
- * if a session expired and the user is trying to reauthenticate,
389
- * or if the user entered their username in an application-side login form.
390
- *
391
- * If not provided, the implementation should prompt the user to
392
- * supply an actor ID along with their other login information
393
- * (e.g. password).
598
+ * Suggestions for the permissions that the
599
+ * login process should grant. The login process may not
600
+ * provide the exact proposed permissions.
394
601
  */
395
- actor?: string,
602
+ proposal?: {
603
+ /**
604
+ * A suggested actor to login as. For example, if a user tries to
605
+ * edit a post but are not logged in, the interface can infer that
606
+ * they might want to log in as the actor who created the post
607
+ * they are attempting to edit.
608
+ *
609
+ * Even if provided, the implementation should allow the user
610
+ * to log in as a different actor if they choose.
611
+ */
612
+ actor?: string;
613
+ /**
614
+ * A yet to be defined permissions scope. An application may use
615
+ * this to indicate the minimum necessary scope needed to
616
+ * operate. For example, it may need to be able read private
617
+ * messages from a certain set of channels, or write messages that
618
+ * follow a particular schema.
619
+ *
620
+ * The login process should make it clear what scope an application
621
+ * is requesting and allow the user to enhance or reduce that
622
+ * scope as necessary.
623
+ */
624
+ scope?: {};
625
+ },
396
626
  /**
397
627
  * An arbitrary string that will be returned with the
398
628
  * {@link GraffitiSession | session} object
@@ -409,7 +639,7 @@ export abstract class Graffiti {
409
639
  *
410
640
  * A confirmation will be returned asynchronously via
411
641
  * {@link Graffiti.sessionEvents | sessionEvents}
412
- * as a {@link GraffitiLogoutEvent}.
642
+ * as a {@link GraffitiLogoutEvent} as event type `logout`.
413
643
  *
414
644
  * @group Session Management
415
645
  */
@@ -429,7 +659,7 @@ export abstract class Graffiti {
429
659
  /**
430
660
  * An event target that can be used to listen for `login`
431
661
  * and `logout` events. They are custom events of types
432
- * {@link GraffitiLoginEvent`} and {@link GraffitiLogoutEvent }
662
+ * {@link GraffitiLoginEvent} and {@link GraffitiLogoutEvent }
433
663
  * respectively.
434
664
  *
435
665
  * @group Session Management
package/src/2-types.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { type JTDDataType } from "ajv/dist/core";
1
+ import type { JTDDataType } from "ajv/dist/core";
2
2
  import type { Operation as JSONPatchOperation } from "fast-json-patch";
3
3
 
4
4
  /**
@@ -169,7 +169,7 @@ export type GraffitiPutObject<Schema> = Pick<
169
169
  * use to verify that a user has permission to operate a
170
170
  * particular {@link GraffitiObjectBase.actor | `actor`}.
171
171
  * This object is required of all {@link Graffiti} methods
172
- * that modify objects and optional for methods that read objects.
172
+ * that modify objects and is optional for methods that read objects.
173
173
  *
174
174
  * At a minimum the `session` object must contain the
175
175
  * {@link GraffitiSession.actor | `actor`} URI the user wants to authenticate with.
@@ -180,8 +180,20 @@ export type GraffitiPutObject<Schema> = Pick<
180
180
  * function. A distributed implementation may include
181
181
  * a cryptographic signature.
182
182
  *
183
- * It may also include other implementation specific properties
184
- * that provide hints for performance or security.
183
+ * As to why the `session` object is passed as an argument to every method
184
+ * rather than being an internal property of the {@link Graffiti} instance,
185
+ * this is primarily for type-checking to catch bugs related to login state.
186
+ * Graffiti applications can expose some functionality to users who are not logged in
187
+ * with {@link Graffiti.get} and {@link Graffiti.discover} but without type-checking
188
+ * the `session` it can be easy to forget to hide buttons that trigger
189
+ * other methods that require login.
190
+ * In the future, `session` object may be updated to include scope information
191
+ * and passing the `session` to each method can type-check whether the session provides the
192
+ * necessary permissions.
193
+ *
194
+ * Passing the `session` object per-method also allows for multiple sessions
195
+ * to be used within the same application, like an Email client fetching from
196
+ * multiple accounts.
185
197
  */
186
198
  export interface GraffitiSession {
187
199
  /**
@@ -189,9 +201,12 @@ export interface GraffitiSession {
189
201
  */
190
202
  actor: string;
191
203
  /**
192
- * Other implementation-specific properties go here.
204
+ * A yet undefined property detailing what operations the session
205
+ * grants the user to perform. For example, to allow a user to
206
+ * read private messages from a particular set of channels or
207
+ * to allow the user to write object matching a particular schema.
193
208
  */
194
- [key: string]: any;
209
+ scope?: {};
195
210
  }
196
211
 
197
212
  /**
@@ -243,16 +258,16 @@ export interface GraffitiPatch {
243
258
  * that can be iterated over using `for await` loops or calling `next` on the generator.
244
259
  * The stream can be terminated by breaking out of a loop calling `return` on the generator.
245
260
  */
246
- export type GraffitiStream<T> = AsyncGenerator<
261
+ export type GraffitiStream<TValue, TReturn = void> = AsyncGenerator<
247
262
  | {
248
263
  error?: undefined;
249
- value: T;
264
+ value: TValue;
250
265
  }
251
266
  | {
252
267
  error: Error;
253
268
  source: string;
254
269
  },
255
- void
270
+ TReturn
256
271
  >;
257
272
 
258
273
  /**
package/tests/discover.ts CHANGED
@@ -1,6 +1,11 @@
1
- import { it, expect, describe } from "vitest";
2
- import { type GraffitiFactory, type GraffitiSession } from "../src/index";
3
- import { randomString, randomValue, randomPutObject } from "./utils";
1
+ import { it, expect, describe, assert } from "vitest";
2
+ import {
3
+ type GraffitiFactory,
4
+ type GraffitiSession,
5
+ type GraffitiStream,
6
+ type JSONSchema4,
7
+ } from "../src/index";
8
+ import { randomString, nextStreamValue, randomPutObject } from "./utils";
4
9
 
5
10
  export const graffitiDiscoverTests = (
6
11
  useGraffiti: GraffitiFactory,
@@ -8,6 +13,12 @@ export const graffitiDiscoverTests = (
8
13
  useSession2: () => GraffitiSession,
9
14
  ) => {
10
15
  describe("discover", () => {
16
+ it("discover nothing", async () => {
17
+ const graffiti = useGraffiti();
18
+ const iterator = graffiti.discover([], {});
19
+ expect(await iterator.next()).toHaveProperty("done", true);
20
+ });
21
+
11
22
  it("discover single", async () => {
12
23
  const graffiti = useGraffiti();
13
24
  const session = useSession1();
@@ -17,16 +28,572 @@ export const graffitiDiscoverTests = (
17
28
 
18
29
  const queryChannels = [randomString(), object.channels[0]];
19
30
  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);
31
+ const value = await nextStreamValue(iterator);
32
+ expect(value.value).toEqual(object.value);
33
+ expect(value.channels).toEqual([object.channels[0]]);
34
+ expect(value.allowed).toBeUndefined();
35
+ expect(value.actor).toEqual(session.actor);
36
+ expect(value.tombstone).toBe(false);
37
+ expect(value.lastModified).toEqual(putted.lastModified);
28
38
  const result2 = await iterator.next();
29
39
  expect(result2.done).toBe(true);
30
40
  });
41
+
42
+ it("discover wrong channel", async () => {
43
+ const graffiti = useGraffiti();
44
+ const session = useSession1();
45
+ const object = randomPutObject();
46
+ await graffiti.put(object, session);
47
+ const iterator = graffiti.discover([randomString()], {});
48
+ await expect(iterator.next()).resolves.toHaveProperty("done", true);
49
+ });
50
+
51
+ it("discover not allowed", async () => {
52
+ const graffiti = useGraffiti();
53
+ const session1 = useSession1();
54
+ const session2 = useSession2();
55
+
56
+ const object = randomPutObject();
57
+ object.allowed = [randomString(), randomString()];
58
+ const putted = await graffiti.put(object, session1);
59
+
60
+ const iteratorSession1 = graffiti.discover(object.channels, {}, session1);
61
+ const value = await nextStreamValue(iteratorSession1);
62
+ expect(value.value).toEqual(object.value);
63
+ expect(value.channels).toEqual(object.channels);
64
+ expect(value.allowed).toEqual(object.allowed);
65
+ expect(value.actor).toEqual(session1.actor);
66
+ expect(value.tombstone).toBe(false);
67
+ expect(value.lastModified).toEqual(putted.lastModified);
68
+
69
+ const iteratorSession2 = graffiti.discover(object.channels, {}, session2);
70
+ expect(await iteratorSession2.next()).toHaveProperty("done", true);
71
+
72
+ const iteratorNoSession = graffiti.discover(object.channels, {});
73
+ expect(await iteratorNoSession.next()).toHaveProperty("done", true);
74
+ });
75
+
76
+ it("discover allowed", async () => {
77
+ const graffiti = useGraffiti();
78
+ const session1 = useSession1();
79
+ const session2 = useSession2();
80
+
81
+ const object = randomPutObject();
82
+ object.allowed = [randomString(), session2.actor, randomString()];
83
+ const putted = await graffiti.put(object, session1);
84
+
85
+ const iteratorSession2 = graffiti.discover(object.channels, {}, session2);
86
+ const value = await nextStreamValue(iteratorSession2);
87
+ expect(value.value).toEqual(object.value);
88
+ expect(value.allowed).toEqual([session2.actor]);
89
+ expect(value.channels).toEqual(object.channels);
90
+ expect(value.actor).toEqual(session1.actor);
91
+ expect(value.tombstone).toBe(false);
92
+ expect(value.lastModified).toEqual(putted.lastModified);
93
+ });
94
+
95
+ for (const prop of ["name", "actor", "lastModified"] as const) {
96
+ it(`discover for ${prop}`, async () => {
97
+ const graffiti = useGraffiti();
98
+ const session1 = useSession1();
99
+ const session2 = useSession2();
100
+
101
+ const object1 = randomPutObject();
102
+ const putted1 = await graffiti.put(object1, session1);
103
+
104
+ const object2 = randomPutObject();
105
+ object2.channels = object1.channels;
106
+ // Make sure the lastModified is different for the query
107
+ await new Promise((r) => setTimeout(r, 20));
108
+ const putted2 = await graffiti.put(object2, session2);
109
+
110
+ const iterator = graffiti.discover(object1.channels, {
111
+ properties: {
112
+ [prop]: {
113
+ enum: [putted1[prop]],
114
+ },
115
+ },
116
+ });
117
+
118
+ const value = await nextStreamValue(iterator);
119
+ expect(value.name).toEqual(putted1.name);
120
+ expect(value.name).not.toEqual(putted2.name);
121
+ expect(value.value).toEqual(object1.value);
122
+ await expect(iterator.next()).resolves.toHaveProperty("done", true);
123
+ });
124
+ }
125
+
126
+ it("discover with lastModified range", async () => {
127
+ const graffiti = useGraffiti();
128
+ const session = useSession1();
129
+
130
+ const object = randomPutObject();
131
+ const putted1 = await graffiti.put(object, session);
132
+ // Make sure the lastModified is different
133
+ await new Promise((r) => setTimeout(r, 20));
134
+ const putted2 = await graffiti.put(object, session);
135
+
136
+ expect(putted1.name).not.toEqual(putted2.name);
137
+ expect(putted1.lastModified).toBeLessThan(putted2.lastModified);
138
+
139
+ const gtIterator = graffiti.discover([object.channels[0]], {
140
+ properties: {
141
+ lastModified: {
142
+ minimum: putted2.lastModified,
143
+ exclusiveMinimum: true,
144
+ },
145
+ },
146
+ });
147
+ expect(await gtIterator.next()).toHaveProperty("done", true);
148
+ const gtIteratorEpsilon = graffiti.discover([object.channels[0]], {
149
+ properties: {
150
+ lastModified: {
151
+ minimum: putted2.lastModified - 0.1,
152
+ exclusiveMinimum: true,
153
+ },
154
+ },
155
+ });
156
+ const value1 = await nextStreamValue(gtIteratorEpsilon);
157
+ expect(value1.name).toEqual(putted2.name);
158
+ expect(await gtIteratorEpsilon.next()).toHaveProperty("done", true);
159
+ const gteIterator = graffiti.discover(object.channels, {
160
+ properties: {
161
+ value: {},
162
+ lastModified: {
163
+ minimum: putted2.lastModified,
164
+ },
165
+ },
166
+ });
167
+ const value = await nextStreamValue(gteIterator);
168
+ expect(value.name).toEqual(putted2.name);
169
+ expect(await gteIterator.next()).toHaveProperty("done", true);
170
+ const gteIteratorEpsilon = graffiti.discover(object.channels, {
171
+ properties: {
172
+ lastModified: {
173
+ minimum: putted2.lastModified + 0.1,
174
+ },
175
+ },
176
+ });
177
+ expect(await gteIteratorEpsilon.next()).toHaveProperty("done", true);
178
+
179
+ const ltIterator = graffiti.discover(object.channels, {
180
+ properties: {
181
+ lastModified: {
182
+ maximum: putted1.lastModified,
183
+ exclusiveMaximum: true,
184
+ },
185
+ },
186
+ });
187
+ expect(await ltIterator.next()).toHaveProperty("done", true);
188
+
189
+ const ltIteratorEpsilon = graffiti.discover(object.channels, {
190
+ properties: {
191
+ lastModified: {
192
+ maximum: putted1.lastModified + 0.1,
193
+ exclusiveMaximum: true,
194
+ },
195
+ },
196
+ });
197
+ const value3 = await nextStreamValue(ltIteratorEpsilon);
198
+ expect(value3.name).toEqual(putted1.name);
199
+ expect(await ltIteratorEpsilon.next()).toHaveProperty("done", true);
200
+
201
+ const lteIterator = graffiti.discover(object.channels, {
202
+ properties: {
203
+ lastModified: {
204
+ maximum: putted1.lastModified,
205
+ },
206
+ },
207
+ });
208
+ const value2 = await nextStreamValue(lteIterator);
209
+ expect(value2.name).toEqual(putted1.name);
210
+ expect(await lteIterator.next()).toHaveProperty("done", true);
211
+
212
+ const lteIteratorEpsilon = graffiti.discover(object.channels, {
213
+ properties: {
214
+ lastModified: {
215
+ maximum: putted1.lastModified - 0.1,
216
+ },
217
+ },
218
+ });
219
+ expect(await lteIteratorEpsilon.next()).toHaveProperty("done", true);
220
+ });
221
+
222
+ it("discover schema allowed, as and not as owner", async () => {
223
+ const graffiti = useGraffiti();
224
+ const session1 = useSession1();
225
+ const session2 = useSession2();
226
+
227
+ const object = randomPutObject();
228
+ object.allowed = [randomString(), session2.actor, randomString()];
229
+ await graffiti.put(object, session1);
230
+
231
+ const iteratorSession1 = graffiti.discover(
232
+ object.channels,
233
+ {
234
+ properties: {
235
+ allowed: {
236
+ minItems: 3,
237
+ // Make sure session2.actor is in the allow list
238
+ not: {
239
+ items: {
240
+ not: {
241
+ enum: [session2.actor],
242
+ },
243
+ },
244
+ },
245
+ },
246
+ },
247
+ },
248
+ session1,
249
+ );
250
+ const value = await nextStreamValue(iteratorSession1);
251
+ expect(value.value).toEqual(object.value);
252
+ await expect(iteratorSession1.next()).resolves.toHaveProperty(
253
+ "done",
254
+ true,
255
+ );
256
+
257
+ const iteratorSession2BigAllow = graffiti.discover(
258
+ object.channels,
259
+ {
260
+ properties: {
261
+ allowed: {
262
+ minItems: 3,
263
+ },
264
+ },
265
+ },
266
+ session2,
267
+ );
268
+ await expect(iteratorSession2BigAllow.next()).resolves.toHaveProperty(
269
+ "done",
270
+ true,
271
+ );
272
+ const iteratorSession2PeekOther = graffiti.discover(
273
+ object.channels,
274
+ {
275
+ properties: {
276
+ allowed: {
277
+ not: {
278
+ items: {
279
+ not: {
280
+ enum: [object.channels[0]],
281
+ },
282
+ },
283
+ },
284
+ },
285
+ },
286
+ },
287
+ session2,
288
+ );
289
+ await expect(iteratorSession2PeekOther.next()).resolves.toHaveProperty(
290
+ "done",
291
+ true,
292
+ );
293
+ const iteratorSession2SmallAllowPeekSelf = graffiti.discover(
294
+ object.channels,
295
+ {
296
+ properties: {
297
+ allowed: {
298
+ maxItems: 1,
299
+ not: {
300
+ items: {
301
+ not: {
302
+ enum: [session2.actor],
303
+ },
304
+ },
305
+ },
306
+ },
307
+ },
308
+ },
309
+ session2,
310
+ );
311
+ const value2 = await nextStreamValue(iteratorSession2SmallAllowPeekSelf);
312
+ expect(value2.value).toEqual(object.value);
313
+ await expect(
314
+ iteratorSession2SmallAllowPeekSelf.next(),
315
+ ).resolves.toHaveProperty("done", true);
316
+ });
317
+
318
+ it("discover schema channels, as and not as owner", async () => {
319
+ const graffiti = useGraffiti();
320
+ const session1 = useSession1();
321
+ const session2 = useSession2();
322
+
323
+ const object = randomPutObject();
324
+ object.channels = [randomString(), randomString(), randomString()];
325
+ await graffiti.put(object, session1);
326
+
327
+ const iteratorSession1 = graffiti.discover(
328
+ [object.channels[0], object.channels[2]],
329
+ {
330
+ properties: {
331
+ channels: {
332
+ minItems: 3,
333
+ // Make sure session2.actor is in the allow list
334
+ not: {
335
+ items: {
336
+ not: {
337
+ enum: [object.channels[1]],
338
+ },
339
+ },
340
+ },
341
+ },
342
+ },
343
+ },
344
+ session1,
345
+ );
346
+ const value = await nextStreamValue(iteratorSession1);
347
+ expect(value.value).toEqual(object.value);
348
+ await expect(iteratorSession1.next()).resolves.toHaveProperty(
349
+ "done",
350
+ true,
351
+ );
352
+
353
+ const iteratorSession2BigAllow = graffiti.discover(
354
+ [object.channels[0], object.channels[2]],
355
+ {
356
+ properties: {
357
+ channels: {
358
+ minItems: 3,
359
+ },
360
+ },
361
+ },
362
+ session2,
363
+ );
364
+ await expect(iteratorSession2BigAllow.next()).resolves.toHaveProperty(
365
+ "done",
366
+ true,
367
+ );
368
+ const iteratorSession2PeekOther = graffiti.discover(
369
+ [object.channels[0], object.channels[2]],
370
+ {
371
+ properties: {
372
+ channels: {
373
+ not: {
374
+ items: {
375
+ not: {
376
+ enum: [object.channels[1]],
377
+ },
378
+ },
379
+ },
380
+ },
381
+ },
382
+ },
383
+ session2,
384
+ );
385
+ await expect(iteratorSession2PeekOther.next()).resolves.toHaveProperty(
386
+ "done",
387
+ true,
388
+ );
389
+ const iteratorSession2SmallAllowPeekSelf = graffiti.discover(
390
+ [object.channels[0], object.channels[2]],
391
+ {
392
+ properties: {
393
+ allowed: {
394
+ maxItems: 2,
395
+ not: {
396
+ items: {
397
+ not: {
398
+ enum: [object.channels[2]],
399
+ },
400
+ },
401
+ },
402
+ },
403
+ },
404
+ },
405
+ session2,
406
+ );
407
+ const value2 = await nextStreamValue(iteratorSession2SmallAllowPeekSelf);
408
+ expect(value2.value).toEqual(object.value);
409
+ await expect(
410
+ iteratorSession2SmallAllowPeekSelf.next(),
411
+ ).resolves.toHaveProperty("done", true);
412
+ });
413
+
414
+ it("discover query for empty allowed", async () => {
415
+ const graffiti = useGraffiti();
416
+ const session1 = useSession1();
417
+
418
+ const publicO = randomPutObject();
419
+
420
+ const publicSchema = {
421
+ not: {
422
+ required: ["allowed"],
423
+ },
424
+ } satisfies JSONSchema4;
425
+
426
+ await graffiti.put(publicO, session1);
427
+ const iterator = graffiti.discover(
428
+ publicO.channels,
429
+ publicSchema,
430
+ session1,
431
+ );
432
+ const value = await nextStreamValue(iterator);
433
+ expect(value.value).toEqual(publicO.value);
434
+ expect(value.allowed).toBeUndefined();
435
+ await expect(iterator.next()).resolves.toHaveProperty("done", true);
436
+
437
+ const restricted = randomPutObject();
438
+ restricted.allowed = [];
439
+ await graffiti.put(restricted, session1);
440
+ const iterator2 = graffiti.discover(
441
+ restricted.channels,
442
+ publicSchema,
443
+ session1,
444
+ );
445
+ await expect(iterator2.next()).resolves.toHaveProperty("done", true);
446
+ });
447
+
448
+ it("discover query for values", async () => {
449
+ const graffiti = useGraffiti();
450
+ const session = useSession1();
451
+
452
+ const object1 = randomPutObject();
453
+ object1.value = { test: randomString() };
454
+ await graffiti.put(object1, session);
455
+
456
+ const object2 = randomPutObject();
457
+ object2.channels = object1.channels;
458
+ object2.value = { test: randomString(), something: randomString() };
459
+ await graffiti.put(object2, session);
460
+
461
+ const object3 = randomPutObject();
462
+ object3.channels = object1.channels;
463
+ object3.value = { other: randomString(), something: randomString() };
464
+ await graffiti.put(object3, session);
465
+
466
+ const counts = new Map<string, number>();
467
+ for (const property of ["test", "something", "other"] as const) {
468
+ let count = 0;
469
+ for await (const result of graffiti.discover(object1.channels, {
470
+ properties: {
471
+ value: {
472
+ required: [property],
473
+ },
474
+ },
475
+ })) {
476
+ assert(!result.error, "result has error");
477
+ if (property in result.value.value) {
478
+ count++;
479
+ }
480
+ }
481
+ counts.set(property, count);
482
+ }
483
+
484
+ expect(counts.get("test")).toBe(2);
485
+ expect(counts.get("something")).toBe(2);
486
+ expect(counts.get("other")).toBe(1);
487
+ });
488
+
489
+ it("discover for deleted content", async () => {
490
+ const graffiti = useGraffiti();
491
+ const session = useSession1();
492
+
493
+ const object = randomPutObject();
494
+ const putted = await graffiti.put(object, session);
495
+ const deleted = await graffiti.delete(putted, session);
496
+
497
+ const iterator = graffiti.discover(object.channels, {});
498
+ const value = await nextStreamValue(iterator);
499
+ expect(value.tombstone).toBe(true);
500
+ expect(value.value).toEqual(object.value);
501
+ expect(value.channels).toEqual(object.channels);
502
+ expect(value.actor).toEqual(session.actor);
503
+ expect(value.lastModified).toEqual(deleted.lastModified);
504
+ await expect(iterator.next()).resolves.toHaveProperty("done", true);
505
+ });
506
+
507
+ it("discover for replaced channels", async () => {
508
+ // Do this a bunch to check for concurrency issues
509
+ for (let i = 0; i < 10; i++) {
510
+ const graffiti = useGraffiti();
511
+ const session = useSession1();
512
+
513
+ const object1 = randomPutObject();
514
+ const putted = await graffiti.put(object1, session);
515
+ const object2 = randomPutObject();
516
+ const replaced = await graffiti.put(
517
+ {
518
+ ...putted,
519
+ ...object2,
520
+ },
521
+ session,
522
+ );
523
+
524
+ const iterator1 = graffiti.discover(object1.channels, {});
525
+ const value1 = await nextStreamValue(iterator1);
526
+ await expect(iterator1.next()).resolves.toHaveProperty("done", true);
527
+
528
+ const iterator2 = graffiti.discover(object2.channels, {});
529
+ const value2 = await nextStreamValue(iterator2);
530
+ await expect(iterator2.next()).resolves.toHaveProperty("done", true);
531
+
532
+ // If they have the same timestamp, except
533
+ // only one to have a tombstone
534
+ if (putted.lastModified === replaced.lastModified) {
535
+ expect(value1.tombstone || value2.tombstone).toBe(true);
536
+ expect(value1.tombstone && value2.tombstone).toBe(false);
537
+ } else {
538
+ expect(value1.tombstone).toBe(true);
539
+ expect(value1.value).toEqual(object1.value);
540
+ expect(value1.channels).toEqual(object1.channels);
541
+ expect(value1.lastModified).toEqual(replaced.lastModified);
542
+
543
+ expect(value2.tombstone).toBe(false);
544
+ expect(value2.value).toEqual(object2.value);
545
+ expect(value2.channels).toEqual(object2.channels);
546
+ expect(value2.lastModified).toEqual(replaced.lastModified);
547
+ }
548
+ }
549
+ });
550
+
551
+ it("discover for patched allowed", async () => {
552
+ const graffiti = useGraffiti();
553
+ const session = useSession1();
554
+ const object = randomPutObject();
555
+ const putted = await graffiti.put(object, session);
556
+ await graffiti.patch(
557
+ {
558
+ allowed: [{ op: "add", path: "", value: [] }],
559
+ },
560
+ putted,
561
+ session,
562
+ );
563
+ const iterator = graffiti.discover(object.channels, {});
564
+ const value = await nextStreamValue(iterator);
565
+ expect(value.tombstone).toBe(true);
566
+ expect(value.value).toEqual(object.value);
567
+ expect(value.channels).toEqual(object.channels);
568
+ expect(value.allowed).toBeUndefined();
569
+ await expect(iterator.next()).resolves.toHaveProperty("done", true);
570
+ });
571
+
572
+ it("put concurrently and discover one", async () => {
573
+ const graffiti = useGraffiti();
574
+ const session = useSession1();
575
+
576
+ const object = randomPutObject();
577
+ object.name = randomString();
578
+
579
+ const putPromises = Array(100)
580
+ .fill(0)
581
+ .map(() => graffiti.put(object, session));
582
+ await Promise.all(putPromises);
583
+
584
+ const iterator = graffiti.discover(object.channels, {});
585
+ let tombstoneCount = 0;
586
+ let valueCount = 0;
587
+ for await (const result of iterator) {
588
+ assert(!result.error, "result has error");
589
+ if (result.value.tombstone) {
590
+ tombstoneCount++;
591
+ } else {
592
+ valueCount++;
593
+ }
594
+ }
595
+ expect(tombstoneCount).toBe(99);
596
+ expect(valueCount).toBe(1);
597
+ });
31
598
  });
32
599
  };
@@ -1,7 +1,6 @@
1
1
  import { it, expect, describe } from "vitest";
2
2
  import { type GraffitiFactory, type GraffitiSession } from "../src/index";
3
3
  import { randomPutObject, randomString } from "./utils";
4
- import { randomInt } from "crypto";
5
4
 
6
5
  export const graffitiSynchronizeTests = (
7
6
  useGraffiti: GraffitiFactory,
package/tests/utils.ts CHANGED
@@ -1,4 +1,5 @@
1
- import type { GraffitiPutObject } from "../src";
1
+ import { assert } from "vitest";
2
+ import type { GraffitiPutObject, GraffitiStream } from "../src";
2
3
 
3
4
  export function randomString(): string {
4
5
  const array = new Uint8Array(16);
@@ -20,3 +21,9 @@ export function randomPutObject(): GraffitiPutObject<{}> {
20
21
  channels: [randomString(), randomString()],
21
22
  };
22
23
  }
24
+
25
+ export async function nextStreamValue<S, T>(iterator: GraffitiStream<S, T>) {
26
+ const result = await iterator.next();
27
+ assert(!result.done && !result.value.error, "result has no value");
28
+ return result.value.value;
29
+ }
package/tsconfig.json DELETED
@@ -1,12 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "esnext",
4
- "module": "esnext",
5
- "moduleResolution": "bundler",
6
- "strict": true,
7
- "esModuleInterop": true,
8
- "resolveJsonModule": true,
9
- "verbatimModuleSyntax": true
10
- },
11
- "include": ["src", "tests"]
12
- }