@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,2 @@
1
+ "use strict";var e=require("ajv-draft-04"),t=require("@repeaterjs/repeater"),n=require("fast-json-patch"),a=require("@graffiti-garden/api");function i(e,t,n,i){const r=n[t];if(r&&r.length)try{i[t]=e(i[t],r,!0,!1).newDocument}catch(e){throw"object"==typeof e&&e&&"name"in e&&"string"==typeof e.name&&"message"in e&&"string"==typeof e.message?"TEST_OPERATION_FAILED"===e.name?new a.GraffitiErrorPatchTestFailed(e.message):new a.GraffitiErrorPatchError(e.name+": "+e.message):e}}function r(e,t,n){e.actor!==n?.actor&&(e.allowed=e.allowed&&n?[n.actor]:void 0,e.channels=e.channels.filter((e=>t.includes(e))))}function s(e,t){return void 0===e.allowed||!!t?.actor&&(e.actor===t.actor||e.allowed.includes(t.actor))}exports.GraffitiSynchronize=class{synchronizeEvents=new EventTarget;ajv;graffiti;constructor(t,n){this.ajv=n??new e({strict:!1}),this.graffiti=t}synchronizeDispatch(e,t){const n=new CustomEvent("change",{detail:{oldObject:e,newObject:t}});this.synchronizeEvents.dispatchEvent(n)}get=async(...e)=>{const t=await this.graffiti.get(...e);return this.synchronizeDispatch(t),t};put=async(...e)=>{const t=await this.graffiti.put(...e),n=e[0],a={...t,value:n.value,channels:n.channels,allowed:n.allowed,tombstone:!1};return this.synchronizeDispatch(t,a),t};patch=async(...e)=>{const t=await this.graffiti.patch(...e),a={...t};a.tombstone=!1;for(const t of["value","channels","allowed"])i(n.applyPatch,t,e[0],a);return this.synchronizeDispatch(t,a),t};delete=async(...e)=>{const t=await this.graffiti.delete(...e);return this.synchronizeDispatch(t),t};discover=(...e)=>{const t=this.graffiti.discover(...e),n=this.synchronizeDispatch.bind(this);return async function*(){let e=await t.next();for(;!e.done;)e.value.error||n(e.value.value),yield e.value,e=await t.next();return e.value}()};synchronize=(...e)=>{const[n,i,c]=e,o=function(e,t){try{return e.compile(t)}catch(e){throw new a.GraffitiErrorInvalidSchema(e instanceof Error?e.message:void 0)}}(this.ajv,i);return new t.Repeater((async(e,t)=>{const a=t=>{const{oldObject:a,newObject:i}=t.detail;for(const t of[i,a])if(t&&t.channels.some((e=>n.includes(e)))&&s(t,c)){const a={...t};if(r(a,n,c),o(a)){e({value:a});break}}};this.synchronizeEvents.addEventListener("change",a),await t,this.synchronizeEvents.removeEventListener("change",a)}))}};
2
+ //# sourceMappingURL=synchronize.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synchronize.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ import t from"ajv-draft-04";import{Repeater as e}from"@repeaterjs/repeater";import{applyPatch as n}from"fast-json-patch";import{GraffitiErrorPatchTestFailed as a,GraffitiErrorPatchError as s,GraffitiErrorInvalidSchema as i}from"@graffiti-garden/api";function o(t,e,n,i){const o=n[e];if(o&&o.length)try{i[e]=t(i[e],o,!0,!1).newDocument}catch(t){throw"object"==typeof t&&t&&"name"in t&&"string"==typeof t.name&&"message"in t&&"string"==typeof t.message?"TEST_OPERATION_FAILED"===t.name?new a(t.message):new s(t.name+": "+t.message):t}}function r(t,e,n){t.actor!==n?.actor&&(t.allowed=t.allowed&&n?[n.actor]:void 0,t.channels=t.channels.filter((t=>e.includes(t))))}function c(t,e){return void 0===t.allowed||!!e?.actor&&(t.actor===e.actor||t.allowed.includes(e.actor))}class h{synchronizeEvents=new EventTarget;ajv;graffiti;constructor(e,n){this.ajv=n??new t({strict:!1}),this.graffiti=e}synchronizeDispatch(t,e){const n=new CustomEvent("change",{detail:{oldObject:t,newObject:e}});this.synchronizeEvents.dispatchEvent(n)}get=async(...t)=>{const e=await this.graffiti.get(...t);return this.synchronizeDispatch(e),e};put=async(...t)=>{const e=await this.graffiti.put(...t),n=t[0],a={...e,value:n.value,channels:n.channels,allowed:n.allowed,tombstone:!1};return this.synchronizeDispatch(e,a),e};patch=async(...t)=>{const e=await this.graffiti.patch(...t),a={...e};a.tombstone=!1;for(const e of["value","channels","allowed"])o(n,e,t[0],a);return this.synchronizeDispatch(e,a),e};delete=async(...t)=>{const e=await this.graffiti.delete(...t);return this.synchronizeDispatch(e),e};discover=(...t)=>{const e=this.graffiti.discover(...t),n=this.synchronizeDispatch.bind(this);return async function*(){let t=await e.next();for(;!t.done;)t.value.error||n(t.value.value),yield t.value,t=await e.next();return t.value}()};synchronize=(...t)=>{const[n,a,s]=t,o=function(t,e){try{return t.compile(e)}catch(t){throw new i(t instanceof Error?t.message:void 0)}}(this.ajv,a);return new e((async(t,e)=>{const a=e=>{const{oldObject:a,newObject:i}=e.detail;for(const e of[i,a])if(e&&e.channels.some((t=>n.includes(t)))&&c(e,s)){const a={...e};if(r(a,n,s),o(a)){t({value:a});break}}};this.synchronizeEvents.addEventListener("change",a),await e,this.synchronizeEvents.removeEventListener("change",a)}))}}export{h as GraffitiSynchronize};
2
+ //# sourceMappingURL=synchronize.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synchronize.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ class t extends Error{constructor(e){super(e),this.name="GraffitiErrorInvalidSchema",Object.setPrototypeOf(this,t.prototype)}}class e extends Error{constructor(t){super(t),this.name="GraffitiErrorPatchTestFailed",Object.setPrototypeOf(this,e.prototype)}}class o extends Error{constructor(t){super(t),this.name="GraffitiErrorPatchError",Object.setPrototypeOf(this,o.prototype)}}class r extends Error{constructor(t){super(t),this.name="GraffitiErrorInvalidUri",Object.setPrototypeOf(this,r.prototype)}}const n=t=>`${t.source}/${encodeURIComponent(t.actor)}/${encodeURIComponent(t.name)}`,c=t=>{const e=t.split("/"),o=e.pop(),n=e.pop();if(!o||!n||!e.length)throw new r;return{name:decodeURIComponent(o),actor:decodeURIComponent(n),source:e.join("/")}};function s(t=16){const e=new Uint8Array(t);crypto.getRandomValues(e);return btoa(String.fromCodePoint(...e)).replace(/\+/g,"-").replace(/\//g,"_").replace(/\=+$/,"")}function a(t){return"string"==typeof t?{location:c(t),uri:t}:{location:{name:t.name,actor:t.actor,source:t.source},uri:n(t)}}function i(t,r,n,c){const s=n[r];if(s&&s.length)try{c[r]=t(c[r],s,!0,!1).newDocument}catch(t){throw"object"==typeof t&&t&&"name"in t&&"string"==typeof t.name&&"message"in t&&"string"==typeof t.message?"TEST_OPERATION_FAILED"===t.name?new e(t.message):new o(t.name+": "+t.message):t}}function p(e,o){try{return e.compile(o)}catch(e){throw new t(e instanceof Error?e.message:void 0)}}function l(t,e,o){t.actor!==o?.actor&&(t.allowed=t.allowed&&o?[o.actor]:void 0,t.channels=t.channels.filter((t=>e.includes(t))))}function u(t,e){return void 0===t.allowed||!!e?.actor&&(t.actor===e.actor||t.allowed.includes(e.actor))}export{i as applyGraffitiPatch,p as attemptAjvCompile,u as isActorAllowedGraffitiObject,n as locationToUri,l as maskGraffitiObject,s as randomBase64,a as unpackLocationOrUri,c as uriToLocation};
2
+ //# sourceMappingURL=utilities.browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utilities.browser.js","sources":["../node_modules/@graffiti-garden/api/dist/index.js"],"sourcesContent":["class r{objectToUri(r){return this.locationToUri(r)}}class t extends Error{constructor(r){super(r),this.name=\"GraffitiErrorUnauthorized\",Object.setPrototypeOf(this,t.prototype)}}class o extends Error{constructor(r){super(r),this.name=\"GraffitiErrorForbidden\",Object.setPrototypeOf(this,o.prototype)}}class e extends Error{constructor(r){super(r),this.name=\"GraffitiErrorNotFound\",Object.setPrototypeOf(this,e.prototype)}}class s extends Error{constructor(r){super(r),this.name=\"GraffitiErrorInvalidSchema\",Object.setPrototypeOf(this,s.prototype)}}class i extends Error{constructor(r){super(r),this.name=\"GraffitiErrorSchemaMismatch\",Object.setPrototypeOf(this,i.prototype)}}class c extends Error{constructor(r){super(r),this.name=\"GraffitiErrorPatchTestFailed\",Object.setPrototypeOf(this,c.prototype)}}class a extends Error{constructor(r){super(r),this.name=\"GraffitiErrorPatchError\",Object.setPrototypeOf(this,a.prototype)}}class p extends Error{constructor(r){super(r),this.name=\"GraffitiErrorInvalidUri\",Object.setPrototypeOf(this,p.prototype)}}export{r as Graffiti,o as GraffitiErrorForbidden,s as GraffitiErrorInvalidSchema,p as GraffitiErrorInvalidUri,e as GraffitiErrorNotFound,a as GraffitiErrorPatchError,c as GraffitiErrorPatchTestFailed,i as GraffitiErrorSchemaMismatch,t as GraffitiErrorUnauthorized};\n//# sourceMappingURL=index.js.map\n"],"names":["s","Error","constructor","r","super","this","name","Object","setPrototypeOf","prototype","c","a","p"],"mappings":"AAAqa,MAAMA,UAAUC,MAAM,WAAAC,CAAYC,GAAGC,MAAMD,GAAGE,KAAKC,KAAK,6BAA6BC,OAAOC,eAAeH,KAAKL,EAAES,UAAU,EAAiI,MAAMC,UAAUT,MAAM,WAAAC,CAAYC,GAAGC,MAAMD,GAAGE,KAAKC,KAAK,+BAA+BC,OAAOC,eAAeH,KAAKK,EAAED,UAAU,EAAE,MAAME,UAAUV,MAAM,WAAAC,CAAYC,GAAGC,MAAMD,GAAGE,KAAKC,KAAK,0BAA0BC,OAAOC,eAAeH,KAAKM,EAAEF,UAAU,EAAE,MAAMG,UAAUX,MAAM,WAAAC,CAAYC,GAAGC,MAAMD,GAAGE,KAAKC,KAAK,0BAA0BC,OAAOC,eAAeH,KAAKO,EAAEH,UAAU","x_google_ignoreList":[0]}
@@ -0,0 +1,2 @@
1
+ "use strict";var e=require("@graffiti-garden/api");const o=e=>`${e.source}/${encodeURIComponent(e.actor)}/${encodeURIComponent(e.name)}`,r=o=>{const r=o.split("/"),t=r.pop(),n=r.pop();if(!t||!n||!r.length)throw new e.GraffitiErrorInvalidUri;return{name:decodeURIComponent(t),actor:decodeURIComponent(n),source:r.join("/")}};exports.applyGraffitiPatch=function(o,r,t,n){const a=t[r];if(a&&a.length)try{n[r]=o(n[r],a,!0,!1).newDocument}catch(o){throw"object"==typeof o&&o&&"name"in o&&"string"==typeof o.name&&"message"in o&&"string"==typeof o.message?"TEST_OPERATION_FAILED"===o.name?new e.GraffitiErrorPatchTestFailed(o.message):new e.GraffitiErrorPatchError(o.name+": "+o.message):o}},exports.attemptAjvCompile=function(o,r){try{return o.compile(r)}catch(o){throw new e.GraffitiErrorInvalidSchema(o instanceof Error?o.message:void 0)}},exports.isActorAllowedGraffitiObject=function(e,o){return void 0===e.allowed||!!o?.actor&&(e.actor===o.actor||e.allowed.includes(o.actor))},exports.locationToUri=o,exports.maskGraffitiObject=function(e,o,r){e.actor!==r?.actor&&(e.allowed=e.allowed&&r?[r.actor]:void 0,e.channels=e.channels.filter((e=>o.includes(e))))},exports.randomBase64=function(e=16){const o=new Uint8Array(e);return crypto.getRandomValues(o),btoa(String.fromCodePoint(...o)).replace(/\+/g,"-").replace(/\//g,"_").replace(/\=+$/,"")},exports.unpackLocationOrUri=function(e){return"string"==typeof e?{location:r(e),uri:e}:{location:{name:e.name,actor:e.actor,source:e.source},uri:o(e)}},exports.uriToLocation=r;
2
+ //# sourceMappingURL=utilities.cjs.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utilities.cjs.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ import{GraffitiErrorInvalidUri as e,GraffitiErrorPatchTestFailed as o,GraffitiErrorPatchError as n,GraffitiErrorInvalidSchema as t}from"@graffiti-garden/api";const r=e=>`${e.source}/${encodeURIComponent(e.actor)}/${encodeURIComponent(e.name)}`,c=o=>{const n=o.split("/"),t=n.pop(),r=n.pop();if(!t||!r||!n.length)throw new e;return{name:decodeURIComponent(t),actor:decodeURIComponent(r),source:n.join("/")}};function a(e=16){const o=new Uint8Array(e);crypto.getRandomValues(o);return btoa(String.fromCodePoint(...o)).replace(/\+/g,"-").replace(/\//g,"_").replace(/\=+$/,"")}function i(e){return"string"==typeof e?{location:c(e),uri:e}:{location:{name:e.name,actor:e.actor,source:e.source},uri:r(e)}}function s(e,t,r,c){const a=r[t];if(a&&a.length)try{c[t]=e(c[t],a,!0,!1).newDocument}catch(e){throw"object"==typeof e&&e&&"name"in e&&"string"==typeof e.name&&"message"in e&&"string"==typeof e.message?"TEST_OPERATION_FAILED"===e.name?new o(e.message):new n(e.name+": "+e.message):e}}function l(e,o){try{return e.compile(o)}catch(e){throw new t(e instanceof Error?e.message:void 0)}}function m(e,o,n){e.actor!==n?.actor&&(e.allowed=e.allowed&&n?[n.actor]:void 0,e.channels=e.channels.filter((e=>o.includes(e))))}function p(e,o){return void 0===e.allowed||!!o?.actor&&(e.actor===o.actor||e.allowed.includes(o.actor))}export{s as applyGraffitiPatch,l as attemptAjvCompile,p as isActorAllowedGraffitiObject,r as locationToUri,m as maskGraffitiObject,a as randomBase64,i as unpackLocationOrUri,c as uriToLocation};
2
+ //# sourceMappingURL=utilities.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utilities.js","sources":[],"sourcesContent":[],"names":[],"mappings":""}
package/package.json ADDED
@@ -0,0 +1,110 @@
1
+ {
2
+ "name": "@graffiti-garden/implementation-local",
3
+ "version": "0.2.0",
4
+ "description": "A local-only implementation of the Graffiti API using PouchDB",
5
+ "types": "./dist/src/index.d.ts",
6
+ "module": "./dist/index.js",
7
+ "browser": "./dist/index.browser.js",
8
+ "main": "./dist/index.cjs.js",
9
+ "exports": {
10
+ ".": {
11
+ "import": {
12
+ "types": "./dist/src/index.d.ts",
13
+ "node": "./dist/index.cjs.js",
14
+ "default": "./dist/index.js"
15
+ },
16
+ "require": {
17
+ "types": "./dist/src/index.d.ts",
18
+ "default": "./dist/index.cjs.js"
19
+ }
20
+ },
21
+ "./database": {
22
+ "import": {
23
+ "types": "./dist/src/database.d.ts",
24
+ "node": "./dist/database.cjs.js",
25
+ "default": "./dist/database.js"
26
+ },
27
+ "require": {
28
+ "types": "./dist/src/database.d.ts",
29
+ "default": "./dist/database.cjs.js"
30
+ }
31
+ },
32
+ "./session-manager": {
33
+ "import": {
34
+ "types": "./dist/src/session-manager-local.d.ts",
35
+ "node": "./dist/session-manager-local.cjs.js",
36
+ "default": "./dist/session-manager-local.js"
37
+ },
38
+ "require": {
39
+ "types": "./dist/src/session-manager-local.d.ts",
40
+ "default": "./dist/session-manager-local.cjs.js"
41
+ }
42
+ },
43
+ "./synchronize": {
44
+ "import": {
45
+ "types": "./dist/src/synchronize.d.ts",
46
+ "node": "./dist/synchronize.cjs.js",
47
+ "default": "./dist/synchronize.js"
48
+ },
49
+ "require": {
50
+ "types": "./dist/src/synchronize.d.ts",
51
+ "default": "./dist/synchronize.cjs.js"
52
+ }
53
+ },
54
+ "./utilities": {
55
+ "import": {
56
+ "types": "./dist/src/utilities.d.ts",
57
+ "node": "./dist/utilities.cjs.js",
58
+ "default": "./dist/utilities.js"
59
+ },
60
+ "require": {
61
+ "types": "./dist/src/utilities.d.ts",
62
+ "default": "./dist/utilities.cjs.js"
63
+ }
64
+ }
65
+ },
66
+ "scripts": {
67
+ "test": "vitest run",
68
+ "test:watch": "vitest",
69
+ "test:coverage": "vitest --coverage",
70
+ "build": "rollup -c rollup.config.ts --configPlugin rollup-plugin-typescript2",
71
+ "prepublishOnly": "npm update && npm test && npm run build"
72
+ },
73
+ "files": [
74
+ "src",
75
+ "dist",
76
+ "package.json",
77
+ "README.md"
78
+ ],
79
+ "author": "Theia Henderson",
80
+ "license": "GPL-3.0-or-later",
81
+ "repository": {
82
+ "type": "git",
83
+ "url": "git+https://github.com/graffiti-garden/implementation-pouchdb.git"
84
+ },
85
+ "bugs": {
86
+ "url": "https://github.com/graffiti-garden/implementation-pouchdb/issues"
87
+ },
88
+ "devDependencies": {
89
+ "@rollup/plugin-commonjs": "^28.0.2",
90
+ "@rollup/plugin-json": "^6.1.0",
91
+ "@rollup/plugin-node-resolve": "^16.0.0",
92
+ "@rollup/plugin-terser": "^0.4.4",
93
+ "@vitest/coverage-v8": "^2.1.8",
94
+ "rollup": "^4.30.1",
95
+ "rollup-plugin-node-polyfills": "^0.2.1",
96
+ "rollup-plugin-typescript2": "^0.36.0",
97
+ "rollup-plugin-visualizer": "^5.14.0",
98
+ "tslib": "^2.8.1",
99
+ "vitest": "^2.1.8"
100
+ },
101
+ "dependencies": {
102
+ "@graffiti-garden/api": "^0.1.10",
103
+ "@repeaterjs/repeater": "^3.0.6",
104
+ "@types/pouchdb": "^6.4.2",
105
+ "ajv": "^8.17.1",
106
+ "ajv-draft-04": "^1.0.0",
107
+ "fast-json-patch": "^3.1.1",
108
+ "pouchdb": "^9.0.0"
109
+ }
110
+ }
@@ -0,0 +1,450 @@
1
+ import type {
2
+ Graffiti,
3
+ GraffitiObjectBase,
4
+ GraffitiLocation,
5
+ } from "@graffiti-garden/api";
6
+ import {
7
+ GraffitiErrorNotFound,
8
+ GraffitiErrorSchemaMismatch,
9
+ GraffitiErrorForbidden,
10
+ GraffitiErrorPatchError,
11
+ } from "@graffiti-garden/api";
12
+ import PouchDB from "pouchdb";
13
+ import {
14
+ locationToUri,
15
+ unpackLocationOrUri,
16
+ randomBase64,
17
+ applyGraffitiPatch,
18
+ attemptAjvCompile,
19
+ maskGraffitiObject,
20
+ isActorAllowedGraffitiObject,
21
+ } from "./utilities";
22
+ import { Repeater } from "@repeaterjs/repeater";
23
+ import Ajv from "ajv-draft-04";
24
+ import { applyPatch } from "fast-json-patch";
25
+
26
+ /**
27
+ * Constructor options for the GraffitiPoubchDB class.
28
+ */
29
+ export interface GraffitiLocalOptions {
30
+ /**
31
+ * Options to pass to the PouchDB constructor.
32
+ * Defaults to `{ name: "graffitiDb" }`.
33
+ *
34
+ * See the [PouchDB documentation](https://pouchdb.com/api.html#create_database)
35
+ * for available options.
36
+ */
37
+ pouchDBOptions?: PouchDB.Configuration.DatabaseConfiguration;
38
+ /**
39
+ * Defines the name of the {@link https://api.graffiti.garden/interfaces/GraffitiObjectBase.html#source | `source` }
40
+ * under which to store objects.
41
+ * Defaults to `"local"`.
42
+ */
43
+ sourceName?: string;
44
+ /**
45
+ * The time in milliseconds to keep tombstones before deleting them.
46
+ * See the {@link https://api.graffiti.garden/classes/Graffiti.html#discover | `discover` }
47
+ * documentation for more information.
48
+ */
49
+ tombstoneRetention?: number;
50
+ }
51
+
52
+ /**
53
+ * An implementation of only the database operations of the
54
+ * GraffitiAPI without synchronization or session management.
55
+ */
56
+ export class GraffitiLocalDatabase
57
+ implements
58
+ Pick<
59
+ Graffiti,
60
+ | "get"
61
+ | "put"
62
+ | "patch"
63
+ | "delete"
64
+ | "discover"
65
+ | "listChannels"
66
+ | "listOrphans"
67
+ >
68
+ {
69
+ protected readonly db: PouchDB.Database<GraffitiObjectBase>;
70
+ protected readonly source: string = "local";
71
+ protected readonly tombstoneRetention: number = 86400000; // 1 day in ms
72
+ protected readonly ajv: Ajv;
73
+
74
+ constructor(options?: GraffitiLocalOptions, ajv?: Ajv) {
75
+ this.ajv = ajv ?? new Ajv({ strict: false });
76
+ this.source = options?.sourceName ?? this.source;
77
+ this.tombstoneRetention =
78
+ options?.tombstoneRetention ?? this.tombstoneRetention;
79
+ const pouchDbOptions = {
80
+ name: "graffitiDb",
81
+ ...options?.pouchDBOptions,
82
+ };
83
+ this.db = new PouchDB<GraffitiObjectBase>(
84
+ pouchDbOptions.name,
85
+ pouchDbOptions,
86
+ );
87
+
88
+ this.db
89
+ //@ts-ignore
90
+ .put({
91
+ _id: "_design/index3",
92
+ views: {
93
+ byChannelAndLastModified: {
94
+ map: function (object: GraffitiObjectBase) {
95
+ const paddedLastModified = object.lastModified
96
+ .toString()
97
+ .padStart(15, "0");
98
+ object.channels.forEach(function (channel) {
99
+ const id =
100
+ encodeURIComponent(channel) + "/" + paddedLastModified;
101
+ //@ts-ignore
102
+ emit(id);
103
+ });
104
+ }.toString(),
105
+ },
106
+ },
107
+ })
108
+ //@ts-ignore
109
+ .catch((error) => {
110
+ if (
111
+ error &&
112
+ typeof error === "object" &&
113
+ "name" in error &&
114
+ error.name === "conflict"
115
+ ) {
116
+ // Design document already exists
117
+ return;
118
+ } else {
119
+ throw error;
120
+ }
121
+ });
122
+ }
123
+
124
+ protected async queryByLocation(location: GraffitiLocation) {
125
+ const uri = locationToUri(location) + "/";
126
+ const results = await this.db.allDocs({
127
+ startkey: uri,
128
+ endkey: uri + "\uffff", // \uffff is the last unicode character
129
+ include_docs: true,
130
+ });
131
+ const docs = results.rows
132
+ .map((row) => row.doc)
133
+ // Remove undefined docs
134
+ .reduce<
135
+ PouchDB.Core.ExistingDocument<
136
+ GraffitiObjectBase & PouchDB.Core.AllDocsMeta
137
+ >[]
138
+ >((acc, doc) => {
139
+ if (doc) acc.push(doc);
140
+ return acc;
141
+ }, [])
142
+ // Remove tombstones
143
+ .filter((doc) => !doc.tombstone);
144
+ return docs;
145
+ }
146
+
147
+ protected docId(location: GraffitiLocation) {
148
+ return locationToUri(location) + "/" + randomBase64();
149
+ }
150
+
151
+ get: Graffiti["get"] = async (...args) => {
152
+ const [locationOrUri, schema, session] = args;
153
+ const { location } = unpackLocationOrUri(locationOrUri);
154
+
155
+ const docs = await this.queryByLocation(location);
156
+ if (!docs.length) throw new GraffitiErrorNotFound();
157
+
158
+ // Get the most recent document
159
+ const doc = docs.reduce((a, b) =>
160
+ a.lastModified > b.lastModified ? a : b,
161
+ );
162
+
163
+ // Strip out the _id and _rev
164
+ const { _id, _rev, ...object } = doc;
165
+
166
+ // Make sure the user is allowed to see it
167
+ if (!isActorAllowedGraffitiObject(doc, session))
168
+ throw new GraffitiErrorNotFound();
169
+
170
+ // Mask out the allowed list and channels
171
+ // if the user is not the owner
172
+ maskGraffitiObject(object, [], session);
173
+
174
+ const validate = attemptAjvCompile(this.ajv, schema);
175
+ if (!validate(object)) {
176
+ throw new GraffitiErrorSchemaMismatch();
177
+ }
178
+ return object;
179
+ };
180
+
181
+ /**
182
+ * Deletes all docs at a particular location.
183
+ * If the `keepLatest` flag is set to true,
184
+ * the doc with the most recent timestamp will be
185
+ * spared. If there are multiple docs with the same
186
+ * timestamp, the one with the highest `_id` will be
187
+ * spared.
188
+ */
189
+ protected async deleteAtLocation(
190
+ location: GraffitiLocation,
191
+ keepLatest: boolean = false,
192
+ ) {
193
+ const docsAtLocation = await this.queryByLocation(location);
194
+ if (!docsAtLocation.length) return undefined;
195
+
196
+ // Get the most recent lastModified timestamp.
197
+ const latestModified = docsAtLocation
198
+ .map((doc) => doc.lastModified)
199
+ .reduce((a, b) => (a > b ? a : b));
200
+
201
+ // Delete all old docs
202
+ const docsToDelete = docsAtLocation.filter(
203
+ (doc) => !keepLatest || doc.lastModified < latestModified,
204
+ );
205
+
206
+ // For docs with the same timestamp,
207
+ // keep the one with the highest _id
208
+ // to break concurrency ties
209
+ const concurrentDocsAll = docsAtLocation.filter(
210
+ (doc) => keepLatest && doc.lastModified === latestModified,
211
+ );
212
+ if (concurrentDocsAll.length) {
213
+ const keepDocId = concurrentDocsAll
214
+ .map((doc) => doc._id)
215
+ .reduce((a, b) => (a > b ? a : b));
216
+ const concurrentDocsToDelete = concurrentDocsAll.filter(
217
+ (doc) => doc._id !== keepDocId,
218
+ );
219
+ docsToDelete.push(...concurrentDocsToDelete);
220
+ }
221
+
222
+ const lastModified = keepLatest ? latestModified : new Date().getTime();
223
+
224
+ let deletedObject: GraffitiObjectBase | undefined = undefined;
225
+ // Go through documents oldest to newest
226
+ for (const doc of docsToDelete.sort(
227
+ (a, b) => a.lastModified - b.lastModified,
228
+ )) {
229
+ // Change it's tombstone to true
230
+ // and update it's timestamp
231
+ const deletedDoc = {
232
+ ...doc,
233
+ tombstone: true,
234
+ lastModified,
235
+ };
236
+ try {
237
+ await this.db.put(deletedDoc);
238
+ } catch (error) {
239
+ if (
240
+ error &&
241
+ typeof error === "object" &&
242
+ "name" in error &&
243
+ error.name === "conflict"
244
+ ) {
245
+ // Document was already deleted
246
+ continue;
247
+ }
248
+ }
249
+ const { _id, _rev, ...object } = deletedDoc;
250
+ deletedObject = object;
251
+ }
252
+
253
+ return deletedObject;
254
+ }
255
+
256
+ delete: Graffiti["delete"] = async (...args) => {
257
+ const [locationOrUri, session] = args;
258
+ const { location } = unpackLocationOrUri(locationOrUri);
259
+ if (location.actor !== session.actor) {
260
+ throw new GraffitiErrorForbidden();
261
+ }
262
+
263
+ const deletedObject = await this.deleteAtLocation(location);
264
+ if (!deletedObject) {
265
+ throw new GraffitiErrorNotFound();
266
+ }
267
+ return deletedObject;
268
+ };
269
+
270
+ put: Graffiti["put"] = async (...args) => {
271
+ const [objectPartial, session] = args;
272
+ if (objectPartial.actor && objectPartial.actor !== session.actor) {
273
+ throw new GraffitiErrorForbidden();
274
+ }
275
+
276
+ const object: GraffitiObjectBase = {
277
+ value: objectPartial.value,
278
+ channels: objectPartial.channels,
279
+ allowed: objectPartial.allowed,
280
+ name: objectPartial.name ?? randomBase64(),
281
+ source: objectPartial.source ?? this.source,
282
+ actor: session.actor,
283
+ tombstone: false,
284
+ lastModified: new Date().getTime(),
285
+ };
286
+
287
+ await this.db.put({
288
+ _id: this.docId(object),
289
+ ...object,
290
+ });
291
+
292
+ // Delete the old object
293
+ const previousObject = await this.deleteAtLocation(object, true);
294
+ if (previousObject) {
295
+ return previousObject;
296
+ } else {
297
+ return {
298
+ ...object,
299
+ value: {},
300
+ channels: [],
301
+ allowed: undefined,
302
+ tombstone: true,
303
+ };
304
+ }
305
+ };
306
+
307
+ patch: Graffiti["patch"] = async (...args) => {
308
+ const [patch, locationOrUri, session] = args;
309
+ const { location } = unpackLocationOrUri(locationOrUri);
310
+ if (location.actor !== session.actor) {
311
+ throw new GraffitiErrorForbidden();
312
+ }
313
+ const originalObject = await this.get(locationOrUri, {}, session);
314
+
315
+ // Patch it outside of the database
316
+ const patchObject: GraffitiObjectBase = { ...originalObject };
317
+ for (const prop of ["value", "channels", "allowed"] as const) {
318
+ applyGraffitiPatch(applyPatch, prop, patch, patchObject);
319
+ }
320
+
321
+ // Make sure the value is an object
322
+ if (
323
+ typeof patchObject.value !== "object" ||
324
+ Array.isArray(patchObject.value) ||
325
+ !patchObject.value
326
+ ) {
327
+ throw new GraffitiErrorPatchError("value is no longer an object");
328
+ }
329
+
330
+ // Make sure the channels are an array of strings
331
+ if (
332
+ !Array.isArray(patchObject.channels) ||
333
+ !patchObject.channels.every((channel) => typeof channel === "string")
334
+ ) {
335
+ throw new GraffitiErrorPatchError(
336
+ "channels are no longer an array of strings",
337
+ );
338
+ }
339
+
340
+ // Make sure the allowed list is an array of strings or undefined
341
+ if (
342
+ patchObject.allowed &&
343
+ (!Array.isArray(patchObject.allowed) ||
344
+ !patchObject.allowed.every((allowed) => typeof allowed === "string"))
345
+ ) {
346
+ throw new GraffitiErrorPatchError(
347
+ "allowed list is not an array of strings",
348
+ );
349
+ }
350
+
351
+ patchObject.lastModified = new Date().getTime();
352
+ await this.db.put({
353
+ ...patchObject,
354
+ _id: this.docId(patchObject),
355
+ });
356
+
357
+ // Delete the old object
358
+ await this.deleteAtLocation(patchObject, true);
359
+
360
+ return {
361
+ ...originalObject,
362
+ tombstone: true,
363
+ lastModified: patchObject.lastModified,
364
+ };
365
+ };
366
+
367
+ discover: Graffiti["discover"] = (...args) => {
368
+ const [channels, schema, session] = args;
369
+
370
+ const validate = attemptAjvCompile(this.ajv, schema);
371
+
372
+ // Use the index for queries over ranges of lastModified
373
+ let startKeyAppend = "";
374
+ let endKeyAppend = "\uffff";
375
+ const lastModifiedSchema = schema.properties?.lastModified;
376
+ if (lastModifiedSchema?.minimum) {
377
+ let minimum = Math.ceil(lastModifiedSchema.minimum);
378
+ minimum === lastModifiedSchema.minimum &&
379
+ lastModifiedSchema.exclusiveMinimum &&
380
+ minimum++;
381
+ startKeyAppend = minimum.toString().padStart(15, "0");
382
+ }
383
+ if (lastModifiedSchema?.maximum) {
384
+ let maximum = Math.floor(lastModifiedSchema.maximum);
385
+ maximum === lastModifiedSchema.maximum &&
386
+ lastModifiedSchema.exclusiveMaximum &&
387
+ maximum--;
388
+ endKeyAppend = maximum.toString().padStart(15, "0");
389
+ }
390
+
391
+ const repeater: ReturnType<
392
+ typeof Graffiti.prototype.discover<typeof schema>
393
+ > = new Repeater(async (push, stop) => {
394
+ const processedIds = new Set<string>();
395
+
396
+ for (const channel of channels) {
397
+ const encodedChannel = encodeURIComponent(channel);
398
+ const startkey = encodedChannel + "/" + startKeyAppend;
399
+ const endkey = encodedChannel + "/" + endKeyAppend;
400
+
401
+ const result = await this.db.query<GraffitiObjectBase>(
402
+ "index3/byChannelAndLastModified",
403
+ { startkey, endkey, include_docs: true },
404
+ );
405
+
406
+ for (const row of result.rows) {
407
+ const doc = row.doc;
408
+ if (!doc) continue;
409
+
410
+ const { _id, _rev, ...object } = doc;
411
+
412
+ // Don't double return the same object
413
+ // (which can happen if it's in multiple channels)
414
+ if (processedIds.has(_id)) continue;
415
+ processedIds.add(_id);
416
+
417
+ // Make sure the user is allowed to see it
418
+ if (!isActorAllowedGraffitiObject(doc, session)) continue;
419
+
420
+ // Mask out the allowed list and channels
421
+ // if the user is not the owner
422
+ maskGraffitiObject(object, channels, session);
423
+
424
+ // Check that it matches the schema
425
+ if (validate(object)) {
426
+ push({
427
+ value: object,
428
+ });
429
+ }
430
+ }
431
+ }
432
+ stop();
433
+ return {
434
+ tombstoneRetention: this.tombstoneRetention,
435
+ };
436
+ });
437
+
438
+ return repeater;
439
+ };
440
+
441
+ listChannels: Graffiti["listChannels"] = (...args) => {
442
+ // TODO
443
+ return (async function* () {})();
444
+ };
445
+
446
+ listOrphans: Graffiti["listOrphans"] = (...args) => {
447
+ // TODO
448
+ return (async function* () {})();
449
+ };
450
+ }
package/src/index.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { Graffiti } from "@graffiti-garden/api";
2
+ import Ajv from "ajv-draft-04";
3
+ import { GraffitiLocalSessionManager } from "./session-manager";
4
+ import { GraffitiLocalDatabase, type GraffitiLocalOptions } from "./database";
5
+ import { GraffitiSynchronize } from "./synchronize";
6
+ import { locationToUri, uriToLocation } from "./utilities";
7
+
8
+ /**
9
+ * A local implementation of the [Graffiti API](https://api.graffiti.garden/classes/Graffiti.html)
10
+ * based on [PouchDB](https://pouchdb.com/). PouchDb will automatically persist data in a local
11
+ * database, either in the browser or in Node.js.
12
+ * It can also be configured to work with an external [CouchDB](https://couchdb.apache.org/) server,
13
+ * although using it with a remote server will not be secure.
14
+ */
15
+ export class GraffitiLocal extends Graffiti {
16
+ locationToUri = locationToUri;
17
+ uriToLocation = uriToLocation;
18
+
19
+ login: Graffiti["login"];
20
+ logout: Graffiti["logout"];
21
+ sessionEvents: Graffiti["sessionEvents"];
22
+ put: Graffiti["put"];
23
+ get: Graffiti["get"];
24
+ patch: Graffiti["patch"];
25
+ delete: Graffiti["delete"];
26
+ discover: Graffiti["discover"];
27
+ synchronize: Graffiti["synchronize"];
28
+ listChannels: Graffiti["listChannels"];
29
+ listOrphans: Graffiti["listOrphans"];
30
+
31
+ constructor(options?: GraffitiLocalOptions) {
32
+ super();
33
+
34
+ const sessionManagerLocal = new GraffitiLocalSessionManager();
35
+ this.login = sessionManagerLocal.login.bind(sessionManagerLocal);
36
+ this.logout = sessionManagerLocal.logout.bind(sessionManagerLocal);
37
+ this.sessionEvents = sessionManagerLocal.sessionEvents;
38
+
39
+ const ajv = new Ajv({ strict: false });
40
+ const graffitiPouchDbBase = new GraffitiLocalDatabase(options, ajv);
41
+ const graffitiSynchronize = new GraffitiSynchronize(
42
+ graffitiPouchDbBase,
43
+ ajv,
44
+ );
45
+
46
+ this.put = graffitiSynchronize.put.bind(graffitiSynchronize);
47
+ this.get = graffitiSynchronize.get.bind(graffitiSynchronize);
48
+ this.patch = graffitiSynchronize.patch.bind(graffitiSynchronize);
49
+ this.delete = graffitiSynchronize.delete.bind(graffitiSynchronize);
50
+ this.discover = graffitiSynchronize.discover.bind(graffitiSynchronize);
51
+ this.synchronize =
52
+ graffitiSynchronize.synchronize.bind(graffitiSynchronize);
53
+ this.listChannels =
54
+ graffitiPouchDbBase.listChannels.bind(graffitiPouchDbBase);
55
+ this.listOrphans =
56
+ graffitiPouchDbBase.listOrphans.bind(graffitiPouchDbBase);
57
+ }
58
+ }