@graffiti-garden/implementation-local 0.2.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 +78 -0
- package/dist/database.browser.js +27 -0
- package/dist/database.browser.js.map +1 -0
- package/dist/database.cjs.js +2 -0
- package/dist/database.cjs.js.map +1 -0
- package/dist/database.js +2 -0
- package/dist/database.js.map +1 -0
- package/dist/index.browser.js +32 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.cjs.js +2 -0
- package/dist/index.cjs.js.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/session-manager.browser.js +2 -0
- package/dist/session-manager.browser.js.map +1 -0
- package/dist/session-manager.cjs.js +2 -0
- package/dist/session-manager.cjs.js.map +1 -0
- package/dist/session-manager.js +2 -0
- package/dist/session-manager.js.map +1 -0
- package/dist/src/database.d.ts +57 -0
- package/dist/src/database.d.ts.map +1 -0
- package/dist/src/index.d.ts +26 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/session-manager.d.ts +22 -0
- package/dist/src/session-manager.d.ts.map +1 -0
- package/dist/src/synchronize.d.ts +25 -0
- package/dist/src/synchronize.d.ts.map +1 -0
- package/dist/src/tests.spec.d.ts +2 -0
- package/dist/src/tests.spec.d.ts.map +1 -0
- package/dist/src/utilities.d.ts +15 -0
- package/dist/src/utilities.d.ts.map +1 -0
- package/dist/synchronize.browser.js +18 -0
- package/dist/synchronize.browser.js.map +1 -0
- package/dist/synchronize.cjs.js +2 -0
- package/dist/synchronize.cjs.js.map +1 -0
- package/dist/synchronize.js +2 -0
- package/dist/synchronize.js.map +1 -0
- package/dist/utilities.browser.js +2 -0
- package/dist/utilities.browser.js.map +1 -0
- package/dist/utilities.cjs.js +2 -0
- package/dist/utilities.cjs.js.map +1 -0
- package/dist/utilities.js +2 -0
- package/dist/utilities.js.map +1 -0
- package/package.json +110 -0
- package/src/database.ts +450 -0
- package/src/index.ts +58 -0
- package/src/session-manager.ts +122 -0
- package/src/synchronize.ts +154 -0
- package/src/tests.spec.ts +16 -0
- package/src/utilities.ts +128 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Graffiti,
|
|
3
|
+
GraffitiLoginEvent,
|
|
4
|
+
GraffitiLogoutEvent,
|
|
5
|
+
GraffitiSessionInitializedEvent,
|
|
6
|
+
} from "@graffiti-garden/api";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A class that implements the login methods
|
|
10
|
+
* of the [Graffiti API]() for use in the browser.
|
|
11
|
+
* It is completely insecure and should only be used
|
|
12
|
+
* for testing and demonstrations.
|
|
13
|
+
*
|
|
14
|
+
* It uses `localStorage` to store login state and
|
|
15
|
+
* window prompts rather than an oauth flow for log in.
|
|
16
|
+
* It can be used in node.js but will not persist
|
|
17
|
+
* login state and a proposed username must be provided.
|
|
18
|
+
*/
|
|
19
|
+
export class GraffitiLocalSessionManager {
|
|
20
|
+
sessionEvents: Graffiti["sessionEvents"] = new EventTarget();
|
|
21
|
+
|
|
22
|
+
constructor() {
|
|
23
|
+
// Look for any existing sessions
|
|
24
|
+
const sessionRestorer = async () => {
|
|
25
|
+
// Allow listeners to be added first
|
|
26
|
+
await Promise.resolve();
|
|
27
|
+
|
|
28
|
+
// Restore previous sessions
|
|
29
|
+
for (const actor of this.getLoggedInActors()) {
|
|
30
|
+
const event: GraffitiLoginEvent = new CustomEvent("login", {
|
|
31
|
+
detail: { session: { actor } },
|
|
32
|
+
});
|
|
33
|
+
this.sessionEvents.dispatchEvent(event);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const event: GraffitiSessionInitializedEvent = new CustomEvent(
|
|
37
|
+
"initialized",
|
|
38
|
+
);
|
|
39
|
+
this.sessionEvents.dispatchEvent(event);
|
|
40
|
+
};
|
|
41
|
+
sessionRestorer();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
loggedInActors: string[] = [];
|
|
45
|
+
|
|
46
|
+
protected getLoggedInActors(): string[] {
|
|
47
|
+
if (typeof window !== "undefined") {
|
|
48
|
+
const actorsString = window.localStorage.getItem("graffiti-actor");
|
|
49
|
+
return actorsString
|
|
50
|
+
? actorsString.split(",").map(decodeURIComponent)
|
|
51
|
+
: [];
|
|
52
|
+
} else {
|
|
53
|
+
return this.loggedInActors;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
protected setLoggedInActors(actors: string[]) {
|
|
58
|
+
if (typeof window !== "undefined") {
|
|
59
|
+
window.localStorage.setItem(
|
|
60
|
+
"graffiti-actor",
|
|
61
|
+
actors.map(encodeURIComponent).join(","),
|
|
62
|
+
);
|
|
63
|
+
} else {
|
|
64
|
+
this.loggedInActors = actors;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
login: Graffiti["login"] = async (proposal) => {
|
|
69
|
+
let actor = proposal?.actor;
|
|
70
|
+
if (!actor && typeof window !== "undefined") {
|
|
71
|
+
const response = window.prompt(
|
|
72
|
+
`This is an insecure implementation of the Graffiti API \
|
|
73
|
+
for *demo purposes only*. Do not store any sensitive information \
|
|
74
|
+
here.\
|
|
75
|
+
\n\n\
|
|
76
|
+
Simply choose a username to log in.`,
|
|
77
|
+
);
|
|
78
|
+
if (response) actor = response;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let detail: GraffitiLoginEvent["detail"];
|
|
82
|
+
if (!actor) {
|
|
83
|
+
detail = {
|
|
84
|
+
error: new Error("No actor ID provided to login"),
|
|
85
|
+
};
|
|
86
|
+
} else {
|
|
87
|
+
const existingActors = this.getLoggedInActors();
|
|
88
|
+
if (!existingActors.includes(actor)) {
|
|
89
|
+
this.setLoggedInActors([...existingActors, actor]);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
detail = {
|
|
93
|
+
session: { actor },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const event: GraffitiLoginEvent = new CustomEvent("login", { detail });
|
|
98
|
+
this.sessionEvents.dispatchEvent(event);
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
logout: Graffiti["logout"] = async (session) => {
|
|
102
|
+
const existingActors = this.getLoggedInActors();
|
|
103
|
+
const exists = existingActors.includes(session.actor);
|
|
104
|
+
if (exists) {
|
|
105
|
+
this.setLoggedInActors(
|
|
106
|
+
existingActors.filter((actor) => actor !== session.actor),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const detail: GraffitiLogoutEvent["detail"] = exists
|
|
111
|
+
? {
|
|
112
|
+
actor: session.actor,
|
|
113
|
+
}
|
|
114
|
+
: {
|
|
115
|
+
actor: session.actor,
|
|
116
|
+
error: new Error("Not logged in with that actor"),
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const event: GraffitiLogoutEvent = new CustomEvent("logout", { detail });
|
|
120
|
+
this.sessionEvents.dispatchEvent(event);
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import Ajv from "ajv-draft-04";
|
|
2
|
+
import type { Graffiti } from "@graffiti-garden/api";
|
|
3
|
+
import type { GraffitiObjectBase } from "@graffiti-garden/api";
|
|
4
|
+
import { Repeater } from "@repeaterjs/repeater";
|
|
5
|
+
import { applyPatch } from "fast-json-patch";
|
|
6
|
+
import {
|
|
7
|
+
applyGraffitiPatch,
|
|
8
|
+
attemptAjvCompile,
|
|
9
|
+
isActorAllowedGraffitiObject,
|
|
10
|
+
maskGraffitiObject,
|
|
11
|
+
} from "./utilities";
|
|
12
|
+
|
|
13
|
+
type SynchronizeEvent = CustomEvent<{
|
|
14
|
+
oldObject: GraffitiObjectBase;
|
|
15
|
+
newObject?: GraffitiObjectBase;
|
|
16
|
+
}>;
|
|
17
|
+
|
|
18
|
+
type GraffitiDatabaseMethods = Pick<
|
|
19
|
+
Graffiti,
|
|
20
|
+
"get" | "put" | "patch" | "delete" | "discover"
|
|
21
|
+
>;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Wraps a partial implementation of the [Graffiti API](https://api.graffiti.garden/classes/Graffiti.html)
|
|
25
|
+
* to provide the [`synchronize`](https://api.graffiti.garden/classes/Graffiti.html#synchronize) method.
|
|
26
|
+
* The partial implementation must include the primary database methods:
|
|
27
|
+
* `get`, `put`, `patch`, `delete`, and `discover`.
|
|
28
|
+
*/
|
|
29
|
+
export class GraffitiSynchronize
|
|
30
|
+
implements
|
|
31
|
+
Pick<
|
|
32
|
+
Graffiti,
|
|
33
|
+
"put" | "get" | "patch" | "delete" | "discover" | "synchronize"
|
|
34
|
+
>
|
|
35
|
+
{
|
|
36
|
+
protected readonly synchronizeEvents = new EventTarget();
|
|
37
|
+
protected readonly ajv: Ajv;
|
|
38
|
+
protected readonly graffiti: GraffitiDatabaseMethods;
|
|
39
|
+
|
|
40
|
+
// Pass in the ajv instance
|
|
41
|
+
// and database methods to wrap
|
|
42
|
+
constructor(graffiti: GraffitiDatabaseMethods, ajv?: Ajv) {
|
|
43
|
+
this.ajv = ajv ?? new Ajv({ strict: false });
|
|
44
|
+
this.graffiti = graffiti;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
protected synchronizeDispatch(
|
|
48
|
+
oldObject: GraffitiObjectBase,
|
|
49
|
+
newObject?: GraffitiObjectBase,
|
|
50
|
+
) {
|
|
51
|
+
const event: SynchronizeEvent = new CustomEvent("change", {
|
|
52
|
+
detail: {
|
|
53
|
+
oldObject,
|
|
54
|
+
newObject,
|
|
55
|
+
},
|
|
56
|
+
});
|
|
57
|
+
this.synchronizeEvents.dispatchEvent(event);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get: Graffiti["get"] = async (...args) => {
|
|
61
|
+
const object = await this.graffiti.get(...args);
|
|
62
|
+
this.synchronizeDispatch(object);
|
|
63
|
+
return object;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
put: Graffiti["put"] = async (...args) => {
|
|
67
|
+
const oldObject = await this.graffiti.put(...args);
|
|
68
|
+
const partialObject = args[0];
|
|
69
|
+
const newObject: GraffitiObjectBase = {
|
|
70
|
+
...oldObject,
|
|
71
|
+
value: partialObject.value,
|
|
72
|
+
channels: partialObject.channels,
|
|
73
|
+
allowed: partialObject.allowed,
|
|
74
|
+
tombstone: false,
|
|
75
|
+
};
|
|
76
|
+
this.synchronizeDispatch(oldObject, newObject);
|
|
77
|
+
return oldObject;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
patch: Graffiti["patch"] = async (...args) => {
|
|
81
|
+
const oldObject = await this.graffiti.patch(...args);
|
|
82
|
+
const newObject: GraffitiObjectBase = { ...oldObject };
|
|
83
|
+
newObject.tombstone = false;
|
|
84
|
+
for (const prop of ["value", "channels", "allowed"] as const) {
|
|
85
|
+
applyGraffitiPatch(applyPatch, prop, args[0], newObject);
|
|
86
|
+
}
|
|
87
|
+
this.synchronizeDispatch(oldObject, newObject);
|
|
88
|
+
return oldObject;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
delete: Graffiti["delete"] = async (...args) => {
|
|
92
|
+
const oldObject = await this.graffiti.delete(...args);
|
|
93
|
+
this.synchronizeDispatch(oldObject);
|
|
94
|
+
return oldObject;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
discover: Graffiti["discover"] = (...args) => {
|
|
98
|
+
const iterator = this.graffiti.discover(...args);
|
|
99
|
+
const dispatch = this.synchronizeDispatch.bind(this);
|
|
100
|
+
const wrapper = async function* () {
|
|
101
|
+
let result = await iterator.next();
|
|
102
|
+
while (!result.done) {
|
|
103
|
+
if (!result.value.error) {
|
|
104
|
+
dispatch(result.value.value);
|
|
105
|
+
}
|
|
106
|
+
yield result.value;
|
|
107
|
+
result = await iterator.next();
|
|
108
|
+
}
|
|
109
|
+
return result.value;
|
|
110
|
+
};
|
|
111
|
+
return wrapper();
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
synchronize: Graffiti["synchronize"] = (...args) => {
|
|
115
|
+
const [channels, schema, session] = args;
|
|
116
|
+
const validate = attemptAjvCompile(this.ajv, schema);
|
|
117
|
+
|
|
118
|
+
const repeater: ReturnType<
|
|
119
|
+
typeof Graffiti.prototype.synchronize<typeof schema>
|
|
120
|
+
> = new Repeater(async (push, stop) => {
|
|
121
|
+
const callback = (event: SynchronizeEvent) => {
|
|
122
|
+
const { oldObject: oldObjectRaw, newObject: newObjectRaw } =
|
|
123
|
+
event.detail;
|
|
124
|
+
|
|
125
|
+
for (const objectRaw of [newObjectRaw, oldObjectRaw]) {
|
|
126
|
+
if (
|
|
127
|
+
objectRaw &&
|
|
128
|
+
objectRaw.channels.some((channel) => channels.includes(channel)) &&
|
|
129
|
+
isActorAllowedGraffitiObject(objectRaw, session)
|
|
130
|
+
) {
|
|
131
|
+
const object = { ...objectRaw };
|
|
132
|
+
maskGraffitiObject(object, channels, session);
|
|
133
|
+
if (validate(object)) {
|
|
134
|
+
push({ value: object });
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
this.synchronizeEvents.addEventListener(
|
|
142
|
+
"change",
|
|
143
|
+
callback as EventListener,
|
|
144
|
+
);
|
|
145
|
+
await stop;
|
|
146
|
+
this.synchronizeEvents.removeEventListener(
|
|
147
|
+
"change",
|
|
148
|
+
callback as EventListener,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return repeater;
|
|
153
|
+
};
|
|
154
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import {
|
|
2
|
+
graffitiLocationTests,
|
|
3
|
+
graffitiCRUDTests,
|
|
4
|
+
graffitiSynchronizeTests,
|
|
5
|
+
graffitiDiscoverTests,
|
|
6
|
+
} from "@graffiti-garden/api/tests";
|
|
7
|
+
import { GraffitiLocal } from "./index";
|
|
8
|
+
|
|
9
|
+
const useGraffiti = () => new GraffitiLocal();
|
|
10
|
+
const useSession1 = () => ({ actor: "someone" });
|
|
11
|
+
const useSession2 = () => ({ actor: "someoneelse" });
|
|
12
|
+
|
|
13
|
+
graffitiLocationTests(useGraffiti);
|
|
14
|
+
graffitiCRUDTests(useGraffiti, useSession1, useSession2);
|
|
15
|
+
graffitiSynchronizeTests(useGraffiti, useSession1, useSession2);
|
|
16
|
+
graffitiDiscoverTests(useGraffiti, useSession1, useSession2);
|
package/src/utilities.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import {
|
|
2
|
+
GraffitiErrorInvalidSchema,
|
|
3
|
+
GraffitiErrorInvalidUri,
|
|
4
|
+
GraffitiErrorPatchError,
|
|
5
|
+
GraffitiErrorPatchTestFailed,
|
|
6
|
+
} from "@graffiti-garden/api";
|
|
7
|
+
import type {
|
|
8
|
+
Graffiti,
|
|
9
|
+
GraffitiObjectBase,
|
|
10
|
+
GraffitiLocation,
|
|
11
|
+
GraffitiPatch,
|
|
12
|
+
JSONSchema4,
|
|
13
|
+
GraffitiSession,
|
|
14
|
+
} from "@graffiti-garden/api";
|
|
15
|
+
import type { Ajv } from "ajv";
|
|
16
|
+
import type { applyPatch } from "fast-json-patch";
|
|
17
|
+
|
|
18
|
+
export const locationToUri: Graffiti["locationToUri"] = (location) => {
|
|
19
|
+
return `${location.source}/${encodeURIComponent(location.actor)}/${encodeURIComponent(location.name)}`;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const uriToLocation: Graffiti["uriToLocation"] = (uri) => {
|
|
23
|
+
const parts = uri.split("/");
|
|
24
|
+
const nameEncoded = parts.pop();
|
|
25
|
+
const webIdEncoded = parts.pop();
|
|
26
|
+
if (!nameEncoded || !webIdEncoded || !parts.length) {
|
|
27
|
+
throw new GraffitiErrorInvalidUri();
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
name: decodeURIComponent(nameEncoded),
|
|
31
|
+
actor: decodeURIComponent(webIdEncoded),
|
|
32
|
+
source: parts.join("/"),
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export function randomBase64(numBytes: number = 16) {
|
|
37
|
+
const bytes = new Uint8Array(numBytes);
|
|
38
|
+
crypto.getRandomValues(bytes);
|
|
39
|
+
// Convert it to base64
|
|
40
|
+
const base64 = btoa(String.fromCodePoint(...bytes));
|
|
41
|
+
// Make sure it is url safe
|
|
42
|
+
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/\=+$/, "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function unpackLocationOrUri(locationOrUri: GraffitiLocation | string) {
|
|
46
|
+
if (typeof locationOrUri === "string") {
|
|
47
|
+
return {
|
|
48
|
+
location: uriToLocation(locationOrUri),
|
|
49
|
+
uri: locationOrUri,
|
|
50
|
+
};
|
|
51
|
+
} else {
|
|
52
|
+
return {
|
|
53
|
+
location: {
|
|
54
|
+
name: locationOrUri.name,
|
|
55
|
+
actor: locationOrUri.actor,
|
|
56
|
+
source: locationOrUri.source,
|
|
57
|
+
},
|
|
58
|
+
uri: locationToUri(locationOrUri),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function applyGraffitiPatch<Prop extends keyof GraffitiPatch>(
|
|
64
|
+
apply: typeof applyPatch,
|
|
65
|
+
prop: Prop,
|
|
66
|
+
patch: GraffitiPatch,
|
|
67
|
+
object: GraffitiObjectBase,
|
|
68
|
+
): void {
|
|
69
|
+
const ops = patch[prop];
|
|
70
|
+
if (!ops || !ops.length) return;
|
|
71
|
+
try {
|
|
72
|
+
object[prop] = apply(object[prop], ops, true, false).newDocument;
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (
|
|
75
|
+
typeof e === "object" &&
|
|
76
|
+
e &&
|
|
77
|
+
"name" in e &&
|
|
78
|
+
typeof e.name === "string" &&
|
|
79
|
+
"message" in e &&
|
|
80
|
+
typeof e.message === "string"
|
|
81
|
+
) {
|
|
82
|
+
if (e.name === "TEST_OPERATION_FAILED") {
|
|
83
|
+
throw new GraffitiErrorPatchTestFailed(e.message);
|
|
84
|
+
} else {
|
|
85
|
+
throw new GraffitiErrorPatchError(e.name + ": " + e.message);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
throw e;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function attemptAjvCompile<Schema extends JSONSchema4>(
|
|
94
|
+
ajv: Ajv,
|
|
95
|
+
schema: Schema,
|
|
96
|
+
) {
|
|
97
|
+
try {
|
|
98
|
+
return ajv.compile(schema);
|
|
99
|
+
} catch (error) {
|
|
100
|
+
throw new GraffitiErrorInvalidSchema(
|
|
101
|
+
error instanceof Error ? error.message : undefined,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function maskGraffitiObject(
|
|
107
|
+
object: GraffitiObjectBase,
|
|
108
|
+
channels: string[],
|
|
109
|
+
session?: GraffitiSession,
|
|
110
|
+
): void {
|
|
111
|
+
if (object.actor !== session?.actor) {
|
|
112
|
+
object.allowed = object.allowed && session ? [session.actor] : undefined;
|
|
113
|
+
object.channels = object.channels.filter((channel) =>
|
|
114
|
+
channels.includes(channel),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function isActorAllowedGraffitiObject(
|
|
119
|
+
object: GraffitiObjectBase,
|
|
120
|
+
session?: GraffitiSession,
|
|
121
|
+
) {
|
|
122
|
+
return (
|
|
123
|
+
object.allowed === undefined ||
|
|
124
|
+
(!!session?.actor &&
|
|
125
|
+
(object.actor === session.actor ||
|
|
126
|
+
object.allowed.includes(session.actor)))
|
|
127
|
+
);
|
|
128
|
+
}
|