@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 +4 -44
- package/dist/index.cjs.js +2 -0
- package/package.json +21 -6
- package/src/1-api.ts +287 -57
- package/src/2-types.ts +24 -9
- package/tests/discover.ts +578 -11
- package/tests/synchronize.ts +0 -1
- package/tests/utils.ts +8 -1
- package/tsconfig.json +0 -12
package/README.md
CHANGED
|
@@ -1,47 +1,7 @@
|
|
|
1
1
|
# Graffiti API
|
|
2
2
|
|
|
3
|
-
The Graffiti API makes it possible to build
|
|
4
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "The heart of Graffiti",
|
|
5
|
-
"
|
|
6
|
-
"
|
|
5
|
+
"types": "src/index.ts",
|
|
6
|
+
"module": "dist/index.js",
|
|
7
|
+
"main": "dist/index.cjs.js",
|
|
7
8
|
"exports": {
|
|
8
|
-
".":
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
|
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
|
|
25
|
-
*
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* that
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* of the {@link
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
* to
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
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
|
-
*
|
|
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.
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
*
|
|
249
|
-
*
|
|
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<
|
|
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
|
|
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
|
-
*
|
|
388
|
-
*
|
|
389
|
-
*
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
184
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
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<
|
|
261
|
+
export type GraffitiStream<TValue, TReturn = void> = AsyncGenerator<
|
|
247
262
|
| {
|
|
248
263
|
error?: undefined;
|
|
249
|
-
value:
|
|
264
|
+
value: TValue;
|
|
250
265
|
}
|
|
251
266
|
| {
|
|
252
267
|
error: Error;
|
|
253
268
|
source: string;
|
|
254
269
|
},
|
|
255
|
-
|
|
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 {
|
|
3
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
expect(
|
|
23
|
-
expect(
|
|
24
|
-
expect(
|
|
25
|
-
expect(
|
|
26
|
-
expect(
|
|
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/synchronize.ts
CHANGED
|
@@ -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
|
|
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