@graffiti-garden/api 0.0.8 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@graffiti-garden/api",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "description": "The heart of Graffiti",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/1-api.ts CHANGED
@@ -10,46 +10,224 @@ 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
- * including a decentralized implementation and a local implementation
23
- * that can be used for testing. In the design of Graffiti we prioritized
24
- * the design of this API first as it is the layer that shapes the experience
25
- * of developing applications. While different implementations provide tradeoffs between
25
+ * including a [decentralized implementation](https://github.com/graffiti-garden/client-core),
26
+ * and a [local implementation](https://github.com/graffiti-garden/implementation-pouchdb)
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
26
30
  * other important properties (e.g. privacy, security, scalability), those properties
27
- * are useless if the system as a whole is unusable. Build APIs before protocols!
28
- *
29
- * The first group of methods are like standard CRUD methods that
30
- * allow applications to {@link put}, {@link get}, {@link patch}, and {@link delete}
31
- * {@link GraffitiObjectBase} objects. The main difference between these
32
- * methods and standard database methods is that an {@link GraffitiObjectBase.actor | `actor`}
33
- * (essentially a user) can only modify objects that they created.
34
- * Applications may also specify an an array of actors that are {@link GraffitiObjectBase.allowed | `allowed`}
35
- * to access the object and an array of {@link GraffitiObjectBase.channels | `channels`}
36
- * that the object is associated with.
37
- *
38
- * The "social" part of the API is the {@link discover} method, which allows
39
- * an application to query for objects made by other users.
40
- * This function only returns objects that are associated with one or more
41
- * of the {@link GraffitiObjectBase.channels | `channels`}
42
- * provided by a querying application. This helps to prevent
43
- * [context collapse](https://en.wikipedia.org/wiki/Context_collapse) and
44
- * allows users to express their intended audience, even in an interoperable
45
- * environment.
46
- *
47
- * Additionally, {@link synchronize} keeps track of changes to data
48
- * from any of the aforementioned methods and routes these changes internally
49
- * to provide a consistent user experience.
50
- *
51
- * Finally, other utility functions provide simple type conversions and
52
- * 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
+ * ```
53
231
  *
54
232
  * @groupDescription CRUD Methods
55
233
  * Methods for {@link put | creating}, {@link get | reading}, {@link patch | updating},
@@ -234,15 +412,46 @@ export abstract class Graffiti {
234
412
  * not specified by the `discover` method will not be revealed. This masking happens
235
413
  * before the supplied schema is applied.
236
414
  *
415
+ * {@link discover} can be used in conjunction with {@link synchronize}
416
+ * to provide a responsive and consistent user experience.
417
+ *
237
418
  * Since different implementations may fetch data from multiple sources there is
238
419
  * no guarentee on the order that objects are returned in. Additionally, the method
239
- * may return objects that have been deleted but with a
420
+ * will return objects that have been deleted but with a
240
421
  * {@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
- *
244
- * {@link discover} can be used in conjunction with {@link synchronize}
245
- * to provide a responsive and consistent user experience.
422
+ * cache invalidation purposes.
423
+ * The final `return()` value of the stream includes a `tombstoneRetention`
424
+ * property that represents the minimum amount of time,
425
+ * in milliseconds, that an application will retain and return tombstones for objects that
426
+ * have been deleted.
427
+ *
428
+ * When repolling, the {@link GraffitiObjectBase.lastModified | `lastModified`}
429
+ * field can be queried via the schema to
430
+ * only fetch objects that have been modified since the last poll.
431
+ * Such queries should only be done if the time since the last poll
432
+ * is less than the `tombstoneRetention` value of that poll, otherwise the tombstones
433
+ * for objects that have been deleted may not be returned.
434
+ *
435
+ * ```json
436
+ * {
437
+ * "properties": {
438
+ * "lastModified": {
439
+ * "minimum": LAST_RETRIEVED_TIME
440
+ * }
441
+ * }
442
+ * }
443
+ * ```
444
+ *
445
+ * `discover` needs to be polled for new data because live updates to
446
+ * an application can be visually distracting or lead to toxic engagement.
447
+ * If and when an application wants real-time updates, such as in a chat
448
+ * application, application authors must be intentional about their polling.
449
+ *
450
+ * Implementers should be aware that some users may applications may try to poll
451
+ * {@link discover} repetitively. You can deal with this by rate limiting or
452
+ * preemptively fetching data via a bidirectional channel, like a WebSocket.
453
+ * Additionally, implementers should probably index the `lastModified` field
454
+ * to speed up responses to schemas like the one above.
246
455
  *
247
456
  * @returns A stream of objects that match the given {@link GraffitiObjectBase.channels | `channels`}
248
457
  * and [JSON Schema](https://json-schema.org).
@@ -265,7 +474,12 @@ export abstract class Graffiti {
265
474
  * property will be returned.
266
475
  */
267
476
  session?: GraffitiSession,
268
- ): GraffitiStream<GraffitiObject<Schema>>;
477
+ ): GraffitiStream<
478
+ GraffitiObject<Schema>,
479
+ {
480
+ tombstoneRetention: number;
481
+ }
482
+ >;
269
483
 
270
484
  /**
271
485
  * This method has the same signature as {@link discover} but listens for
@@ -332,8 +546,7 @@ export abstract class Graffiti {
332
546
  session: GraffitiSession,
333
547
  ): GraffitiStream<{
334
548
  channel: string;
335
- source: string;
336
- lastModified: string;
549
+ lastModified: number;
337
550
  count: number;
338
551
  }>;
339
552
 
@@ -351,15 +564,12 @@ export abstract class Graffiti {
351
564
  * and {@link GraffitiObjectBase.source | `source`} of the orphaned objects
352
565
  * that the {@link GraffitiObjectBase.actor | `actor`} has posted to.
353
566
  * The {@link GraffitiObjectBase.lastModified | lastModified} field is the
354
- * time that the user last modified the orphan and the
355
- * {@link GraffitiObjectBase.tombstone | `tombstone`} field is `true`
356
- * if the object has been deleted.
567
+ * time that the user last modified the orphan.
357
568
  */
358
569
  abstract listOrphans(session: GraffitiSession): GraffitiStream<{
359
570
  name: string;
360
571
  source: string;
361
572
  lastModified: string;
362
- tombstone: boolean;
363
573
  }>;
364
574
 
365
575
  /**
@@ -374,21 +584,40 @@ export abstract class Graffiti {
374
584
  *
375
585
  * The {@link GraffitiSession | session} object is returned
376
586
  * asynchronously via {@link Graffiti.sessionEvents | sessionEvents}
377
- * as a {@link GraffitiLoginEvent}.
587
+ * as a {@link GraffitiLoginEvent} with event type `login`.
378
588
  *
379
589
  * @group Session Management
380
590
  */
381
591
  abstract login(
382
592
  /**
383
- * An optional actor to prompt the user to login as. For example,
384
- * if a session expired and the user is trying to reauthenticate,
385
- * or if the user entered their username in an application-side login form.
386
- *
387
- * If not provided, the implementation should prompt the user to
388
- * supply an actor ID along with their other login information
389
- * (e.g. password).
593
+ * Suggestions for the permissions that the
594
+ * login process should grant. The login process may not
595
+ * provide the exact proposed permissions.
390
596
  */
391
- actor?: string,
597
+ proposal?: {
598
+ /**
599
+ * A suggested actor to login as. For example, if a user tries to
600
+ * edit a post but are not logged in, the interface can infer that
601
+ * they might want to log in as the actor who created the post
602
+ * they are attempting to edit.
603
+ *
604
+ * Even if provided, the implementation should allow the user
605
+ * to log in as a different actor if they choose.
606
+ */
607
+ actor?: string;
608
+ /**
609
+ * A yet to be defined permissions scope. An application may use
610
+ * this to indicate the minimum necessary scope needed to
611
+ * operate. For example, it may need to be able read private
612
+ * messages from a certain set of channels, or write messages that
613
+ * follow a particular schema.
614
+ *
615
+ * The login process should make it clear what scope an application
616
+ * is requesting and allow the user to enhance or reduce that
617
+ * scope as necessary.
618
+ */
619
+ scope?: {};
620
+ },
392
621
  /**
393
622
  * An arbitrary string that will be returned with the
394
623
  * {@link GraffitiSession | session} object
@@ -405,7 +634,7 @@ export abstract class Graffiti {
405
634
  *
406
635
  * A confirmation will be returned asynchronously via
407
636
  * {@link Graffiti.sessionEvents | sessionEvents}
408
- * as a {@link GraffitiLogoutEvent}.
637
+ * as a {@link GraffitiLogoutEvent} as event type `logout`.
409
638
  *
410
639
  * @group Session Management
411
640
  */
package/src/2-types.ts CHANGED
@@ -95,12 +95,16 @@ export interface GraffitiObjectBase {
95
95
  source: string;
96
96
 
97
97
  /**
98
- * The time the object was last modified in [ISO format](https://fits.gsfc.nasa.gov/iso-time.html). This is used for caching and synchronization.
99
- * It can also be used to sort objects in a user interface but in many cases it would be better to
98
+ * The time the object was last modified, measured in milliseconds since January 1, 1970.
99
+ * This is used for caching and synchronization.
100
+ * A number, rather than an ISO string or Date object, is used for easy comparison, sorting,
101
+ * and JSON Schema [range queries](https://json-schema.org/understanding-json-schema/reference/numeric#range).
102
+ *
103
+ * It is possible to use this value to sort objects in a user's interface but in many cases it would be better to
100
104
  * use a `createdAt` property in the object's {@link value | `value`} to indicate when the object was created
101
105
  * rather than when it was modified.
102
106
  */
103
- lastModified: string;
107
+ lastModified: number;
104
108
 
105
109
  /**
106
110
  * A boolean indicating whether the object has been deleted.
@@ -165,7 +169,7 @@ export type GraffitiPutObject<Schema> = Pick<
165
169
  * use to verify that a user has permission to operate a
166
170
  * particular {@link GraffitiObjectBase.actor | `actor`}.
167
171
  * This object is required of all {@link Graffiti} methods
168
- * that modify objects and optional for methods that read objects.
172
+ * that modify objects and is optional for methods that read objects.
169
173
  *
170
174
  * At a minimum the `session` object must contain the
171
175
  * {@link GraffitiSession.actor | `actor`} URI the user wants to authenticate with.
@@ -176,8 +180,20 @@ export type GraffitiPutObject<Schema> = Pick<
176
180
  * function. A distributed implementation may include
177
181
  * a cryptographic signature.
178
182
  *
179
- * It may also include other implementation specific properties
180
- * 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.
181
197
  */
182
198
  export interface GraffitiSession {
183
199
  /**
@@ -185,9 +201,12 @@ export interface GraffitiSession {
185
201
  */
186
202
  actor: string;
187
203
  /**
188
- * 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.
189
208
  */
190
- [key: string]: any;
209
+ scope?: {};
191
210
  }
192
211
 
193
212
  /**
@@ -239,16 +258,16 @@ export interface GraffitiPatch {
239
258
  * that can be iterated over using `for await` loops or calling `next` on the generator.
240
259
  * The stream can be terminated by breaking out of a loop calling `return` on the generator.
241
260
  */
242
- export type GraffitiStream<T> = AsyncGenerator<
261
+ export type GraffitiStream<TValue, TReturn = void> = AsyncGenerator<
243
262
  | {
244
263
  error?: undefined;
245
- value: T;
264
+ value: TValue;
246
265
  }
247
266
  | {
248
267
  error: Error;
249
268
  source: string;
250
269
  },
251
- void
270
+ TReturn
252
271
  >;
253
272
 
254
273
  /**
package/tests/crud.ts CHANGED
@@ -56,9 +56,7 @@ export const graffitiCRUDTests = (
56
56
  expect(beforeReplaced.name).toEqual(previous.name);
57
57
  expect(beforeReplaced.actor).toEqual(previous.actor);
58
58
  expect(beforeReplaced.source).toEqual(previous.source);
59
- expect(new Date(beforeReplaced.lastModified).getTime()).toBeGreaterThan(
60
- new Date(gotten.lastModified).getTime(),
61
- );
59
+ expect(beforeReplaced.lastModified).toBeGreaterThan(gotten.lastModified);
62
60
 
63
61
  // Get it again
64
62
  const afterReplaced = await graffiti.get(previous, {});
@@ -70,8 +68,8 @@ export const graffitiCRUDTests = (
70
68
  const beforeDeleted = await graffiti.delete(afterReplaced, session);
71
69
  expect(beforeDeleted.tombstone).toEqual(true);
72
70
  expect(beforeDeleted.value).toEqual(newValue);
73
- expect(new Date(beforeDeleted.lastModified).getTime()).toBeGreaterThan(
74
- new Date(beforeReplaced.lastModified).getTime(),
71
+ expect(beforeDeleted.lastModified).toBeGreaterThan(
72
+ beforeReplaced.lastModified,
75
73
  );
76
74
 
77
75
  // Try to get it and fail
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
  };
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
+ }