@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.
Files changed (50) hide show
  1. package/README.md +78 -0
  2. package/dist/database.browser.js +27 -0
  3. package/dist/database.browser.js.map +1 -0
  4. package/dist/database.cjs.js +2 -0
  5. package/dist/database.cjs.js.map +1 -0
  6. package/dist/database.js +2 -0
  7. package/dist/database.js.map +1 -0
  8. package/dist/index.browser.js +32 -0
  9. package/dist/index.browser.js.map +1 -0
  10. package/dist/index.cjs.js +2 -0
  11. package/dist/index.cjs.js.map +1 -0
  12. package/dist/index.js +2 -0
  13. package/dist/index.js.map +1 -0
  14. package/dist/session-manager.browser.js +2 -0
  15. package/dist/session-manager.browser.js.map +1 -0
  16. package/dist/session-manager.cjs.js +2 -0
  17. package/dist/session-manager.cjs.js.map +1 -0
  18. package/dist/session-manager.js +2 -0
  19. package/dist/session-manager.js.map +1 -0
  20. package/dist/src/database.d.ts +57 -0
  21. package/dist/src/database.d.ts.map +1 -0
  22. package/dist/src/index.d.ts +26 -0
  23. package/dist/src/index.d.ts.map +1 -0
  24. package/dist/src/session-manager.d.ts +22 -0
  25. package/dist/src/session-manager.d.ts.map +1 -0
  26. package/dist/src/synchronize.d.ts +25 -0
  27. package/dist/src/synchronize.d.ts.map +1 -0
  28. package/dist/src/tests.spec.d.ts +2 -0
  29. package/dist/src/tests.spec.d.ts.map +1 -0
  30. package/dist/src/utilities.d.ts +15 -0
  31. package/dist/src/utilities.d.ts.map +1 -0
  32. package/dist/synchronize.browser.js +18 -0
  33. package/dist/synchronize.browser.js.map +1 -0
  34. package/dist/synchronize.cjs.js +2 -0
  35. package/dist/synchronize.cjs.js.map +1 -0
  36. package/dist/synchronize.js +2 -0
  37. package/dist/synchronize.js.map +1 -0
  38. package/dist/utilities.browser.js +2 -0
  39. package/dist/utilities.browser.js.map +1 -0
  40. package/dist/utilities.cjs.js +2 -0
  41. package/dist/utilities.cjs.js.map +1 -0
  42. package/dist/utilities.js +2 -0
  43. package/dist/utilities.js.map +1 -0
  44. package/package.json +110 -0
  45. package/src/database.ts +450 -0
  46. package/src/index.ts +58 -0
  47. package/src/session-manager.ts +122 -0
  48. package/src/synchronize.ts +154 -0
  49. package/src/tests.spec.ts +16 -0
  50. 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);
@@ -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
+ }