@graffiti-garden/api 0.0.2 → 0.0.4
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/package.json +4 -2
- package/src/{api.ts → 1-api.ts} +28 -6
- package/src/3-errors.ts +7 -0
- package/src/index.ts +3 -2
- package/tests/crud.ts +250 -0
- package/tests/index.ts +2 -0
- package/tests/{location.spec.ts → location.ts} +2 -2
- package/src/errors.ts +0 -14
- /package/src/{types.ts → 2-types.ts} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@graffiti-garden/api",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4",
|
|
4
4
|
"description": "The heart of Graffiti",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"author": "Theia Henderson",
|
|
18
18
|
"license": "GPL-3.0-or-later",
|
|
19
19
|
"scripts": {
|
|
20
|
-
"docs": "typedoc --options typedoc.json"
|
|
20
|
+
"docs": "typedoc --options typedoc.json",
|
|
21
|
+
"prepublishOnly": "npm install"
|
|
21
22
|
},
|
|
22
23
|
"repository": {
|
|
23
24
|
"type": "git",
|
|
@@ -29,6 +30,7 @@
|
|
|
29
30
|
"homepage": "https://api.graffiti.garden/classes/Graffiti.html",
|
|
30
31
|
"devDependencies": {
|
|
31
32
|
"@types/json-schema": "^7.0.15",
|
|
33
|
+
"@types/node": "^22.10.5",
|
|
32
34
|
"ajv": "^8.17.1",
|
|
33
35
|
"fast-json-patch": "^3.1.1",
|
|
34
36
|
"tslib": "^2.8.1",
|
package/src/{api.ts → 1-api.ts}
RENAMED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
GraffitiSessionBase,
|
|
7
7
|
GraffitiPutObject,
|
|
8
8
|
GraffitiStream,
|
|
9
|
-
} from "./types";
|
|
9
|
+
} from "./2-types";
|
|
10
10
|
import type { JSONSchema4 } from "json-schema";
|
|
11
11
|
|
|
12
12
|
/**
|
|
@@ -131,7 +131,7 @@ export abstract class Graffiti {
|
|
|
131
131
|
* If no object exists at that location or if the retrieving
|
|
132
132
|
* {@link GraffitiObjectBase.actor | `actor`} is not the creator or included in
|
|
133
133
|
* the object's {@link GraffitiObjectBase.allowed | `allowed`} property,
|
|
134
|
-
*
|
|
134
|
+
* a {@link GraffitiErrorNotFound} is thrown.
|
|
135
135
|
*
|
|
136
136
|
* The retrieved object is also type-checked against the provided [JSON schema](https://json-schema.org/)
|
|
137
137
|
* otherwise an error is thrown.
|
|
@@ -191,6 +191,9 @@ export abstract class Graffiti {
|
|
|
191
191
|
* The deleting {@link GraffitiObjectBase.actor | `actor`} must be the same as the
|
|
192
192
|
* `actor` that created the object.
|
|
193
193
|
*
|
|
194
|
+
* If the object does not exist or has already been deleted, a
|
|
195
|
+
* {@link GraffitiErrorNotFound} is thrown.
|
|
196
|
+
*
|
|
194
197
|
* @returns The object that was deleted if one exists or an object with
|
|
195
198
|
* with a `null` {@link GraffitiObjectBase.value | `value`} otherwise.
|
|
196
199
|
* The object will have a {@link GraffitiObjectBase.tombstone | `tombstone`}
|
|
@@ -212,17 +215,36 @@ export abstract class Graffiti {
|
|
|
212
215
|
): Promise<GraffitiObjectBase>;
|
|
213
216
|
|
|
214
217
|
/**
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
* and match the given [JSON Schema](https://json-schema.org)
|
|
218
|
+
* Discovers objects created by any user that are contained
|
|
219
|
+
* in at least one of the given {@link GraffitiObjectBase.channels | `channels`}
|
|
220
|
+
* and match the given [JSON Schema](https://json-schema.org).
|
|
218
221
|
*
|
|
219
222
|
* Objects are returned asynchronously as they are discovered but the stream
|
|
220
223
|
* will end once all leads have been exhausted.
|
|
221
224
|
* The method must be polled again for new objects.
|
|
222
225
|
*
|
|
226
|
+
* `discover` will not return objects that the {@link GraffitiObjectBase.actor | `actor`}
|
|
227
|
+
* is not {@link GraffitiObjectBase.allowed | `allowed`} to access.
|
|
228
|
+
* If the actor is not the creator of a discovered object,
|
|
229
|
+
* the allowed list will be masked to only contain the querying actor if the
|
|
230
|
+
* allowed list is not `undefined` (public). Additionally, if the actor is not the
|
|
231
|
+
* creator of a discovered object, any {@link GraffitiObjectBase.channels | `channels`}
|
|
232
|
+
* not specified by the `discover` operation will not be revealed. This masking happens
|
|
233
|
+
* before the supplied schema is applied.
|
|
234
|
+
*
|
|
235
|
+
* Since different implementations may fetch data from multiple sources there is
|
|
236
|
+
* no guarentee on the order that objects are returned in. Additionally, the operation
|
|
237
|
+
* may return objects that have been deleted but with a
|
|
238
|
+
* {@link GraffitiObjectBase.tombstone | `tombstone`} field set to `true` for
|
|
239
|
+
* cache invalidation purposes. Implementations must make aware when, if ever,
|
|
240
|
+
* tombstoned objects are removed.
|
|
241
|
+
*
|
|
223
242
|
* {@link discover} can be used in conjunction with {@link synchronize}
|
|
224
243
|
* to provide a responsive and consistent user experience.
|
|
225
244
|
*
|
|
245
|
+
* @returns A stream of objects that match the given {@link GraffitiObjectBase.channels | `channels`}
|
|
246
|
+
* and [JSON Schema](https://json-schema.org).
|
|
247
|
+
*
|
|
226
248
|
* @group Query Operations
|
|
227
249
|
*/
|
|
228
250
|
abstract discover<Schema extends JSONSchema4>(
|
|
@@ -345,4 +367,4 @@ export abstract class Graffiti {
|
|
|
345
367
|
* abstract, factory functions provide an easy way to
|
|
346
368
|
* swap out different implementations.
|
|
347
369
|
*/
|
|
348
|
-
export type
|
|
370
|
+
export type GraffitiFactory = () => Graffiti;
|
package/src/3-errors.ts
ADDED
package/src/index.ts
CHANGED
package/tests/crud.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { it, expect } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
GraffitiFactory,
|
|
4
|
+
GraffitiSessionBase,
|
|
5
|
+
GraffitiPatch,
|
|
6
|
+
} from "../src/index";
|
|
7
|
+
|
|
8
|
+
export const graffitiCRUDTests = (
|
|
9
|
+
useGraffiti: GraffitiFactory,
|
|
10
|
+
useSession1: () => GraffitiSessionBase,
|
|
11
|
+
useSession2: () => GraffitiSessionBase,
|
|
12
|
+
) => {
|
|
13
|
+
it("put, get, delete", async () => {
|
|
14
|
+
const graffiti = useGraffiti();
|
|
15
|
+
const session = useSession1();
|
|
16
|
+
const value = {
|
|
17
|
+
something: "hello, world~ c:",
|
|
18
|
+
};
|
|
19
|
+
const channels = ["world"];
|
|
20
|
+
|
|
21
|
+
// Put the object
|
|
22
|
+
const previous = await graffiti.put({ value, channels }, session);
|
|
23
|
+
expect(previous.value).toEqual({});
|
|
24
|
+
expect(previous.channels).toEqual([]);
|
|
25
|
+
expect(previous.allowed).toBeUndefined();
|
|
26
|
+
expect(previous.actor).toEqual(session.actor);
|
|
27
|
+
|
|
28
|
+
// Get it back
|
|
29
|
+
const gotten = await graffiti.get(previous, {});
|
|
30
|
+
expect(gotten.value).toEqual(value);
|
|
31
|
+
expect(gotten.channels).toEqual([]);
|
|
32
|
+
expect(gotten.allowed).toBeUndefined();
|
|
33
|
+
expect(gotten.name).toEqual(previous.name);
|
|
34
|
+
expect(gotten.actor).toEqual(previous.actor);
|
|
35
|
+
expect(gotten.source).toEqual(previous.source);
|
|
36
|
+
expect(gotten.lastModified.getTime()).toEqual(
|
|
37
|
+
previous.lastModified.getTime(),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
// Replace it
|
|
41
|
+
const newValue = {
|
|
42
|
+
something: "goodbye, world~ :c",
|
|
43
|
+
};
|
|
44
|
+
const beforeReplaced = await graffiti.put(
|
|
45
|
+
{ ...previous, value: newValue, channels: [] },
|
|
46
|
+
session,
|
|
47
|
+
);
|
|
48
|
+
expect(beforeReplaced.value).toEqual(value);
|
|
49
|
+
expect(beforeReplaced.tombstone).toEqual(true);
|
|
50
|
+
expect(beforeReplaced.name).toEqual(previous.name);
|
|
51
|
+
expect(beforeReplaced.actor).toEqual(previous.actor);
|
|
52
|
+
expect(beforeReplaced.source).toEqual(previous.source);
|
|
53
|
+
expect(beforeReplaced.lastModified.getTime()).toBeGreaterThan(
|
|
54
|
+
gotten.lastModified.getTime(),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Get it again
|
|
58
|
+
const afterReplaced = await graffiti.get(previous, {});
|
|
59
|
+
expect(afterReplaced.value).toEqual(newValue);
|
|
60
|
+
expect(afterReplaced.lastModified.getTime()).toEqual(
|
|
61
|
+
beforeReplaced.lastModified.getTime(),
|
|
62
|
+
);
|
|
63
|
+
expect(afterReplaced.tombstone).toEqual(false);
|
|
64
|
+
|
|
65
|
+
// Delete it
|
|
66
|
+
const beforeDeleted = await graffiti.delete(afterReplaced, session);
|
|
67
|
+
expect(beforeDeleted.tombstone).toEqual(true);
|
|
68
|
+
expect(beforeDeleted.value).toEqual(newValue);
|
|
69
|
+
expect(beforeDeleted.lastModified.getTime()).toBeGreaterThan(
|
|
70
|
+
beforeReplaced.lastModified.getTime(),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
// Try to get it and fail
|
|
74
|
+
await expect(graffiti.get(afterReplaced, {})).rejects.toThrow();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("put and get with schema", async () => {
|
|
78
|
+
const graffiti = useGraffiti();
|
|
79
|
+
const session = useSession1();
|
|
80
|
+
|
|
81
|
+
const schema = {
|
|
82
|
+
properties: {
|
|
83
|
+
value: {
|
|
84
|
+
properties: {
|
|
85
|
+
something: {
|
|
86
|
+
type: "string",
|
|
87
|
+
},
|
|
88
|
+
another: {
|
|
89
|
+
type: "integer",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
} as const;
|
|
95
|
+
|
|
96
|
+
const goodValue = {
|
|
97
|
+
something: "hello",
|
|
98
|
+
another: 42,
|
|
99
|
+
} as const;
|
|
100
|
+
|
|
101
|
+
const putted = await graffiti.put<typeof schema>(
|
|
102
|
+
{
|
|
103
|
+
value: goodValue,
|
|
104
|
+
channels: [],
|
|
105
|
+
},
|
|
106
|
+
session,
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const gotten = await graffiti.get(putted, schema);
|
|
110
|
+
expect(gotten.value.something).toEqual(goodValue.something);
|
|
111
|
+
expect(gotten.value.another).toEqual(goodValue.another);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("put and get with bad schema", async () => {
|
|
115
|
+
const graffiti = useGraffiti();
|
|
116
|
+
const session = useSession1();
|
|
117
|
+
|
|
118
|
+
const putted = await graffiti.put(
|
|
119
|
+
{
|
|
120
|
+
value: {
|
|
121
|
+
hello: "world",
|
|
122
|
+
},
|
|
123
|
+
channels: [],
|
|
124
|
+
},
|
|
125
|
+
session,
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
await expect(
|
|
129
|
+
graffiti.get(putted, {
|
|
130
|
+
properties: {
|
|
131
|
+
value: {
|
|
132
|
+
properties: {
|
|
133
|
+
hello: {
|
|
134
|
+
type: "number",
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
).rejects.toThrow();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("put and get with empty access control", async () => {
|
|
144
|
+
const graffiti = useGraffiti();
|
|
145
|
+
const session1 = useSession1();
|
|
146
|
+
const session2 = useSession2();
|
|
147
|
+
|
|
148
|
+
const value = {
|
|
149
|
+
um: "hi",
|
|
150
|
+
};
|
|
151
|
+
const allowed = ["asdf"];
|
|
152
|
+
const channels = ["helloooo"];
|
|
153
|
+
const putted = await graffiti.put({ value, allowed, channels }, session1);
|
|
154
|
+
|
|
155
|
+
// Get it with authenticated session
|
|
156
|
+
const gotten = await graffiti.get(putted, {}, session1);
|
|
157
|
+
expect(gotten.value).toEqual(value);
|
|
158
|
+
expect(gotten.allowed).toEqual(allowed);
|
|
159
|
+
expect(gotten.channels).toEqual(channels);
|
|
160
|
+
|
|
161
|
+
// But not without session
|
|
162
|
+
await expect(graffiti.get(putted, {})).rejects.toThrow();
|
|
163
|
+
|
|
164
|
+
// Or the wrong session
|
|
165
|
+
await expect(graffiti.get(putted, {}, session2)).rejects.toThrow();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("put and get with specific access control", async () => {
|
|
169
|
+
const graffiti = useGraffiti();
|
|
170
|
+
const session1 = useSession1();
|
|
171
|
+
const session2 = useSession2();
|
|
172
|
+
|
|
173
|
+
const value = {
|
|
174
|
+
um: "hi",
|
|
175
|
+
};
|
|
176
|
+
const allowed = ["asdf", session2.actor, "1234"];
|
|
177
|
+
const channels = ["helloooo"];
|
|
178
|
+
const putted = await graffiti.put(
|
|
179
|
+
{
|
|
180
|
+
value,
|
|
181
|
+
allowed,
|
|
182
|
+
channels,
|
|
183
|
+
},
|
|
184
|
+
session1,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Get it with authenticated session
|
|
188
|
+
const gotten = await graffiti.get(putted, {}, session1);
|
|
189
|
+
expect(gotten.value).toEqual(value);
|
|
190
|
+
expect(gotten.allowed).toEqual(allowed);
|
|
191
|
+
expect(gotten.channels).toEqual(channels);
|
|
192
|
+
|
|
193
|
+
// But not without session
|
|
194
|
+
await expect(graffiti.get(putted, {})).rejects.toThrow();
|
|
195
|
+
|
|
196
|
+
const gotten2 = await graffiti.get(putted, {}, session2);
|
|
197
|
+
expect(gotten2.value).toEqual(value);
|
|
198
|
+
// They should only see that is is private to them
|
|
199
|
+
expect(gotten2.allowed).toEqual([session2.actor]);
|
|
200
|
+
// And not see any channels
|
|
201
|
+
expect(gotten2.channels).toEqual([]);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("patch value", async () => {
|
|
205
|
+
const graffiti = useGraffiti();
|
|
206
|
+
const session = useSession1();
|
|
207
|
+
|
|
208
|
+
const value = {
|
|
209
|
+
something: "hello, world~ c:",
|
|
210
|
+
};
|
|
211
|
+
const putted = await graffiti.put({ value, channels: [] }, session);
|
|
212
|
+
|
|
213
|
+
const patch: GraffitiPatch = {
|
|
214
|
+
value: [
|
|
215
|
+
{ op: "replace", path: "/something", value: "goodbye, world~ :c" },
|
|
216
|
+
],
|
|
217
|
+
};
|
|
218
|
+
const beforePatched = await graffiti.patch(patch, putted, session);
|
|
219
|
+
expect(beforePatched.value).toEqual(value);
|
|
220
|
+
expect(beforePatched.tombstone).toBe(true);
|
|
221
|
+
|
|
222
|
+
const gotten = await graffiti.get(putted, {});
|
|
223
|
+
expect(gotten.value).toEqual({
|
|
224
|
+
something: "goodbye, world~ :c",
|
|
225
|
+
});
|
|
226
|
+
expect(beforePatched.lastModified.getTime()).toBe(
|
|
227
|
+
gotten.lastModified.getTime(),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
await graffiti.delete(putted, session);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("patch channels", async () => {
|
|
234
|
+
const graffiti = useGraffiti();
|
|
235
|
+
const session = useSession1();
|
|
236
|
+
|
|
237
|
+
const putted = await graffiti.put(
|
|
238
|
+
{ value: {}, channels: ["helloooo"] },
|
|
239
|
+
session,
|
|
240
|
+
);
|
|
241
|
+
|
|
242
|
+
const patch: GraffitiPatch = {
|
|
243
|
+
channels: [{ op: "replace", path: "/0", value: "goodbye" }],
|
|
244
|
+
};
|
|
245
|
+
await graffiti.patch(patch, putted, session);
|
|
246
|
+
const gotten = await graffiti.get(putted, {}, session);
|
|
247
|
+
expect(gotten.channels).toEqual(["goodbye"]);
|
|
248
|
+
await graffiti.delete(putted, session);
|
|
249
|
+
});
|
|
250
|
+
};
|
package/tests/index.ts
ADDED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { it, expect } from "vitest";
|
|
2
|
-
import type {
|
|
2
|
+
import type { GraffitiFactory } from "../src/index";
|
|
3
3
|
|
|
4
|
-
export const
|
|
4
|
+
export const graffitiLocationTests = (useGraffiti: GraffitiFactory) => {
|
|
5
5
|
it("url and location", async () => {
|
|
6
6
|
const graffiti = useGraffiti();
|
|
7
7
|
const location = {
|
package/src/errors.ts
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
class GraffitiUnauthorizedError extends Error {
|
|
2
|
-
constructor(message: string) {
|
|
3
|
-
super(message);
|
|
4
|
-
this.name = "UnauthorizedError";
|
|
5
|
-
Object.setPrototypeOf(this, GraffitiUnauthorizedError.prototype);
|
|
6
|
-
}
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
class ForbiddenError extends Error {
|
|
10
|
-
constructor(message: string) {
|
|
11
|
-
super(message);
|
|
12
|
-
this.name = "ForbiddenError";
|
|
13
|
-
}
|
|
14
|
-
}
|
|
File without changes
|