Basic storage backend with diff/patch synchronization

This commit is contained in:
wpetit 2020-05-03 18:34:44 +02:00
parent 1ac485abf3
commit a9c24051b0
20 changed files with 734 additions and 398 deletions

View File

@ -82,6 +82,11 @@
"integrity": "sha512-q95SP4FdkmF0CwO0F2q0H6ZgudsApaY/yCtAQNRn1gduef5fGpyEphzy0YCq/N0UFvDSnLg5V8jFK/YGXlDiCw==", "integrity": "sha512-q95SP4FdkmF0CwO0F2q0H6ZgudsApaY/yCtAQNRn1gduef5fGpyEphzy0YCq/N0UFvDSnLg5V8jFK/YGXlDiCw==",
"dev": true "dev": true
}, },
"@types/json-merge-patch": {
"version": "0.0.4",
"resolved": "https://registry.npmjs.org/@types/json-merge-patch/-/json-merge-patch-0.0.4.tgz",
"integrity": "sha1-pSgtqWkKgSpiEoo0cIr0dqMI2UE="
},
"@types/minimatch": { "@types/minimatch": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/minimatch/-/minimatch-3.0.3.tgz",
@ -1729,7 +1734,6 @@
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.1.tgz",
"integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==", "integrity": "sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g==",
"dev": true,
"requires": { "requires": {
"is-arguments": "^1.0.4", "is-arguments": "^1.0.4",
"is-date-object": "^1.0.1", "is-date-object": "^1.0.1",
@ -1753,7 +1757,6 @@
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz", "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.1.3.tgz",
"integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==", "integrity": "sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ==",
"dev": true,
"requires": { "requires": {
"object-keys": "^1.0.12" "object-keys": "^1.0.12"
} }
@ -2041,7 +2044,6 @@
"version": "1.17.5", "version": "1.17.5",
"resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.17.5.tgz",
"integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==", "integrity": "sha512-BR9auzDbySxOcfog0tLECW8l28eRGpDpU3Dm3Hp4q/N+VtLTmyj4EUN088XZWQDW/hzj6sYRDXeOFsaAODKvpg==",
"dev": true,
"requires": { "requires": {
"es-to-primitive": "^1.2.1", "es-to-primitive": "^1.2.1",
"function-bind": "^1.1.1", "function-bind": "^1.1.1",
@ -2060,7 +2062,6 @@
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.2.1.tgz",
"integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==", "integrity": "sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==",
"dev": true,
"requires": { "requires": {
"is-callable": "^1.1.4", "is-callable": "^1.1.4",
"is-date-object": "^1.0.1", "is-date-object": "^1.0.1",
@ -2392,11 +2393,6 @@
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.1.tgz",
"integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA==" "integrity": "sha512-8UEa58QDLauDNfpbrX55Q9jrGHThw2ZMdOky5Gl1CDtVeJDPVrG4Jxx1N8jw2gkWaff5UUuX1KJd+9zGe2B+ZA=="
}, },
"fast-json-patch": {
"version": "3.0.0-1",
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.0.0-1.tgz",
"integrity": "sha512-6pdFb07cknxvPzCeLsFHStEy+MysPJPgZQ9LbQ/2O67unQF93SNqfdSqnPPl71YMHX+AD8gbl7iuoGFzHEdDuw=="
},
"fast-json-stable-stringify": { "fast-json-stable-stringify": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -3356,8 +3352,7 @@
"function-bind": { "function-bind": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz",
"integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==", "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A=="
"dev": true
}, },
"get-caller-file": { "get-caller-file": {
"version": "1.0.3", "version": "1.0.3",
@ -3487,7 +3482,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz",
"integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==", "integrity": "sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw==",
"dev": true,
"requires": { "requires": {
"function-bind": "^1.1.1" "function-bind": "^1.1.1"
} }
@ -3510,8 +3504,7 @@
"has-symbols": { "has-symbols": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.1.tgz",
"integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg==", "integrity": "sha512-PLcsoqu++dmEIZB+6totNFKq/7Do+Z0u4oT0zKOJNl3lYK6vGwwu2hjHs+68OEZbTjiUE9bgOABXbP/GvrS0Kg=="
"dev": true
}, },
"has-value": { "has-value": {
"version": "1.0.0", "version": "1.0.0",
@ -4018,8 +4011,7 @@
"is-arguments": { "is-arguments": {
"version": "1.0.4", "version": "1.0.4",
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
"integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==", "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA=="
"dev": true
}, },
"is-binary-path": { "is-binary-path": {
"version": "1.0.1", "version": "1.0.1",
@ -4039,8 +4031,7 @@
"is-callable": { "is-callable": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz", "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.1.5.tgz",
"integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q==", "integrity": "sha512-ESKv5sMCJB2jnHTWZ3O5itG+O128Hsus4K4Qh1h2/cgn2vbgnLSVqfV46AeJA9D5EeeLa9w81KUXMtn34zhX+Q=="
"dev": true
}, },
"is-data-descriptor": { "is-data-descriptor": {
"version": "0.1.4", "version": "0.1.4",
@ -4065,8 +4056,7 @@
"is-date-object": { "is-date-object": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.2.tgz",
"integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g==", "integrity": "sha512-USlDT524woQ08aoZFzh3/Z6ch9Y/EWXEHQ/AaRN0SkKq4t2Jw2R2339tSXmwuVoY7LLlBCbOIlx2myP/L5zk0g=="
"dev": true
}, },
"is-descriptor": { "is-descriptor": {
"version": "0.1.6", "version": "0.1.6",
@ -4163,7 +4153,6 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.0.5.tgz",
"integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==", "integrity": "sha512-vlKW17SNq44owv5AQR3Cq0bQPEb8+kF3UKZ2fiZNOWtztYE5i0CzCZxFDwO58qAOWtxdBRVO/V5Qin1wjCqFYQ==",
"dev": true,
"requires": { "requires": {
"has": "^1.0.3" "has": "^1.0.3"
} }
@ -4178,7 +4167,6 @@
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.3.tgz",
"integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==", "integrity": "sha512-OwijhaRSgqvhm/0ZdAcXNZt9lYdKFpcRDT5ULUuYXPoT794UNOdU+gpT6Rzo7b4V2HUl/op6GqY894AZwv9faQ==",
"dev": true,
"requires": { "requires": {
"has-symbols": "^1.0.1" "has-symbols": "^1.0.1"
} }
@ -4219,6 +4207,14 @@
"integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=",
"dev": true "dev": true
}, },
"json-merge-patch": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/json-merge-patch/-/json-merge-patch-0.2.3.tgz",
"integrity": "sha1-+ixrWvh9p3uuKWalidUuI+2B/kA=",
"requires": {
"deep-equal": "^1.0.0"
}
},
"json-parse-better-errors": { "json-parse-better-errors": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz", "resolved": "https://registry.npmjs.org/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz",
@ -4777,14 +4773,12 @@
"object-inspect": { "object-inspect": {
"version": "1.7.0", "version": "1.7.0",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.7.0.tgz",
"integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw==", "integrity": "sha512-a7pEHdh1xKIAgTySUGgLMx/xwDZskN1Ud6egYYN3EdRW4ZMPNEDUTF+hwy2LUC+Bl+SyLXANnwz/jyh/qutKUw=="
"dev": true
}, },
"object-is": { "object-is": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz", "resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.2.tgz",
"integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==", "integrity": "sha512-5lHCz+0uufF6wZ7CRFWJN3hp8Jqblpgve06U5CMQ3f//6iDjPr2PEo9MWCjEssDsa+UZEL4PkFpr+BMop6aKzQ==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.3", "define-properties": "^1.1.3",
"es-abstract": "^1.17.5" "es-abstract": "^1.17.5"
@ -4793,8 +4787,7 @@
"object-keys": { "object-keys": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA=="
"dev": true
}, },
"object-path": { "object-path": {
"version": "0.11.4", "version": "0.11.4",
@ -4815,7 +4808,6 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz", "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.0.tgz",
"integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==", "integrity": "sha512-exHJeq6kBKj58mqGyTQ9DFvrZC/eR6OwxzoM9YRoGBqrXYonaFyGiFMuc9VZrXf7DarreEwMpurG3dd+CNyW5w==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.2", "define-properties": "^1.1.2",
"function-bind": "^1.1.1", "function-bind": "^1.1.1",
@ -5614,7 +5606,6 @@
"version": "1.3.0", "version": "1.3.0",
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.3.0.tgz",
"integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==", "integrity": "sha512-2+Q0C5g951OlYlJz6yu5/M33IcsESLlLfsyIaLJaG4FA2r4yP8MvVMJUUP/fVBkSpbbbZlS5gynbEWLipiiXiQ==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.3", "define-properties": "^1.1.3",
"es-abstract": "^1.17.0-next.1" "es-abstract": "^1.17.0-next.1"
@ -6466,7 +6457,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.1.tgz",
"integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==", "integrity": "sha512-LRPxFUaTtpqYsTeNKaFOw3R4bxIzWOnbQ837QfBylo8jIxtcbK/A/sMV7Q+OAV/vWo+7s25pOE10KYSjaSO06g==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.3", "define-properties": "^1.1.3",
"es-abstract": "^1.17.5" "es-abstract": "^1.17.5"
@ -6476,7 +6466,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz", "resolved": "https://registry.npmjs.org/string.prototype.trimleft/-/string.prototype.trimleft-2.1.2.tgz",
"integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==", "integrity": "sha512-gCA0tza1JBvqr3bfAIFJGqfdRTyPae82+KTnm3coDXkZN9wnuW3HjGgN386D7hfv5CHQYCI022/rJPVlqXyHSw==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.3", "define-properties": "^1.1.3",
"es-abstract": "^1.17.5", "es-abstract": "^1.17.5",
@ -6487,7 +6476,6 @@
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz", "resolved": "https://registry.npmjs.org/string.prototype.trimright/-/string.prototype.trimright-2.1.2.tgz",
"integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==", "integrity": "sha512-ZNRQ7sY3KroTaYjRS6EbNiiHrOkjihL9aQE/8gfQ4DtAC/aEBRHFJa44OmoWxGGqXuJlfKkZW4WcXErGr+9ZFg==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.3", "define-properties": "^1.1.3",
"es-abstract": "^1.17.5", "es-abstract": "^1.17.5",
@ -6498,7 +6486,6 @@
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz", "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.1.tgz",
"integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==", "integrity": "sha512-XxZn+QpvrBI1FOcg6dIpxUPgWCPuNXvMD72aaRaUQv1eD4e/Qy8i/hFTe0BUmD60p/QA6bh1avmuPTfNjqVWRw==",
"dev": true,
"requires": { "requires": {
"define-properties": "^1.1.3", "define-properties": "^1.1.3",
"es-abstract": "^1.17.5" "es-abstract": "^1.17.5"

View File

@ -5,10 +5,11 @@
"main": "src/index.js", "main": "src/index.js",
"dependencies": { "dependencies": {
"@types/bs58": "^4.0.1", "@types/bs58": "^4.0.1",
"@types/json-merge-patch": "0.0.4",
"bs58": "^4.0.1", "bs58": "^4.0.1",
"bulma": "^0.8.2", "bulma": "^0.8.2",
"bulma-switch": "^2.0.0", "bulma-switch": "^2.0.0",
"fast-json-patch": "^3.0.0-1", "json-merge-patch": "^0.2.3",
"preact": "^10.4.1", "preact": "^10.4.1",
"preact-markup": "^1.6.0", "preact-markup": "^1.6.0",
"preact-render-to-string": "^5.1.6", "preact-render-to-string": "^5.1.6",

View File

@ -15,9 +15,14 @@ const EditableText: FunctionalComponent<EditableTextProps> = ({ onChange, value,
const [ editMode, setEditMode ] = useState(false); const [ editMode, setEditMode ] = useState(false);
useEffect(() => { useEffect(() => {
if (internalValue === value) return;
if (onChange) onChange(internalValue); if (onChange) onChange(internalValue);
}, [internalValue]); }, [internalValue]);
useEffect(() => {
setInternalValue(value);
}, [value])
const onEditIconClick = () => { const onEditIconClick = () => {
setEditMode(true); setEditMode(true);
}; };

View File

@ -0,0 +1,19 @@
import { useMemo, useState } from "preact/hooks";
export default function useDebounce(func: Function, delay: number) {
const [id, setId] = useState<number|null>(null)
return useMemo(
(...args) => {
if (id) {
clearTimeout(id)
} else {
setId(
window.setTimeout(() => {
setId(null)
func(...args)
}, delay)
)
}
}, [func]
)
}

View File

@ -1,6 +1,9 @@
import { Project } from "../models/project"; import { Project } from "../models/project";
import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task"; import { Task, TaskID, EstimationConfidence, TaskCategoryID, TaskCategory } from "../models/task";
import { useReducer } from "preact/hooks"; import { useReducer } from "preact/hooks";
import { generate as diff } from "json-merge-patch";
import { applyPatch } from "../util/patch";
export interface Action { export interface Action {
type: string type: string
@ -16,7 +19,8 @@ export type ProjectReducerActions =
UpdateTaskCategoryLabel | UpdateTaskCategoryLabel |
UpdateTaskCategoryCost | UpdateTaskCategoryCost |
AddTaskCategory | AddTaskCategory |
RemoveTaskCategory RemoveTaskCategory |
PatchProject
export function useProjectReducer(project: Project) { export function useProjectReducer(project: Project) {
return useReducer(projectReducer, project); return useReducer(projectReducer, project);
@ -54,6 +58,9 @@ export function projectReducer(project: Project, action: ProjectReducerActions):
case UPDATE_TASK_CATEGORY_COST: case UPDATE_TASK_CATEGORY_COST:
return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost); return handleUpdateTaskCategoryCost(project, action as UpdateTaskCategoryCost);
case PATCH_PROJECT:
return handlePatchProject(project, action as PatchProject);
} }
return project; return project;
@ -299,3 +306,20 @@ export function handleRemoveTaskCategory(project: Project, action: RemoveTaskCat
} }
}; };
} }
export interface PatchProject extends Action {
from: Project
}
export const PATCH_PROJECT = "PATCH_PROJECT";
export function patchProject(from: Project): PatchProject {
return { type: PATCH_PROJECT, from };
}
export function handlePatchProject(project: Project, action: PatchProject): Project {
const p = diff(project, action.from);
console.log('patch to apply', p);
if (!p) return project;
return applyPatch(project, p) as Project;
}

View File

@ -1,57 +1,108 @@
import { Project } from "../models/project"; import { Project } from "../models/project";
import { usePrevious } from "./use-previous"; import { usePrevious } from "./use-previous";
import * as jsonpatch from 'fast-json-patch';
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Operation } from "fast-json-patch"; import { generate as diff } from "json-merge-patch";
import useDebounce from "./use-debounce";
export interface ServerSyncOptions { export interface ServerSyncOptions {
baseUrl: string projectURL: string
refreshInterval: number
syncDelay: number
} }
export const defaultOptions = { export const defaultOptions = {
baseUrl: `ws://${window.location.host}/ws`, refreshInterval: 10000,
projectURL: `//${window.location.host}/api/v1/projects`,
syncDelay: 5000,
} }
export function useServerSync(project: Project, options: ServerSyncOptions = defaultOptions) { export function useServerSync(project: Project, applyServerUpdate: (project: Project) => void, options: ServerSyncOptions = defaultOptions) {
options = Object.assign({}, defaultOptions, options); options = Object.assign({}, defaultOptions, options);
const [ conn, setConn ] = useState<WebSocket>(() => { const [ version, setVersion ] = useState(0);
const conn = new WebSocket(`${options.baseUrl}/${project.id}`);
conn.onerror = (evt: Event) => { const handleAPIResponse = (res: Response) => {
console.error(evt); // If the project does not yet exist, create it
}; if (res.status === 404) {
return createProject(project, options)
.then(res => res.json())
.then(result => {
setVersion(result.version);
})
;
}
conn.onopen = (evt: Event) => { // In case of conflict, notify of new server version
console.log('ws connection opened'); if (res.status === 409) {
}; return res.json().then((result: any) => {
applyServerUpdate(result.project);
return conn; setVersion(result.version);
}); });
}
const [ ops, setOps ] = useState<Operation[][]>([]); // If the server version is not modified, do nothing
if (res.status === 304) {
return;
}
useEffect(() => { return res.json().then((result: any) => {
return () => { applyServerUpdate(result.project);
if (!conn) return; setVersion(result.version);
console.log('closing ws'); });
conn.close();
conn.onerror = null;
conn.onopen = null;
}; };
}, []);
// Force refresh periodically
useEffect(() => {
const intervalId = window.setInterval(() => {
refreshProject(project, version, options).then(handleAPIResponse);
}, options.refreshInterval);
return () => clearInterval(intervalId);
}, [version]);
useEffect(() => { useEffect(() => {
conn.send(JSON.stringify(ops)); const timeoutId =window.setTimeout(() => {
setOps([]); console.log('executing debounced patch');
}, [ops.length > 0]);
let previousProject: Project|any = usePrevious(project); let previousProject: Project|any = usePrevious(project);
if (!previousProject) previousProject = {}; if (!previousProject) previousProject = {};
const newOps = jsonpatch.compare(previousProject, project); // Trigger patch if project has changed
if (ops.length === 0) return; const patch = diff(previousProject, project);
console.log('generated patch', patch);
if (!patch) return;
patchProject(project, patch, version, options).then(handleAPIResponse);
}, options.syncDelay);
return () => clearTimeout(timeoutId);
});
setOps(ops => [...ops, newOps]);
} }
function refreshProject(project: Project, version: number, options: ServerSyncOptions): Promise<Response> {
return fetch(`${options.projectURL}/${project.id}?version=${version}`, {
method: 'GET'
});
}
function createProject(project: Project, options: ServerSyncOptions): Promise<Response> {
return fetch(`${options.projectURL}/${project.id}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ project })
});
}
function patchProject(project: Project, patch: Object, version: number, options: ServerSyncOptions): Promise<any> {
return fetch(`${options.projectURL}/${project.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ version, patch })
});
};

View File

@ -1,8 +1,8 @@
import { FunctionalComponent, h } from "preact"; import { FunctionalComponent, h } from "preact";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import style from "./style.module.css"; import style from "./style.module.css";
import { newProject } from "../../models/project"; import { newProject, Project } from "../../models/project";
import { useProjectReducer, updateProjectLabel } from "../../hooks/use-project-reducer"; import { useProjectReducer, updateProjectLabel, patchProject } from "../../hooks/use-project-reducer";
import { getProjectStorageKey } from "../../util/storage"; import { getProjectStorageKey } from "../../util/storage";
import { useLocalStorage } from "../../hooks/use-local-storage"; import { useLocalStorage } from "../../hooks/use-local-storage";
import EditableText from "../../components/editable-text"; import EditableText from "../../components/editable-text";
@ -20,7 +20,10 @@ const Project: FunctionalComponent<ProjectProps> = ({ projectId }) => {
const projectStorageKey = getProjectStorageKey(projectId); const projectStorageKey = getProjectStorageKey(projectId);
const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId)); const [ storedProject, storeProject ] = useLocalStorage(projectStorageKey, newProject(projectId));
const [ project, dispatch ] = useProjectReducer(storedProject); const [ project, dispatch ] = useProjectReducer(storedProject);
useServerSync(project)
useServerSync(project, (project: Project) => {
dispatch(patchProject(project));
});
const onProjectLabelChange = (projectLabel: string) => { const onProjectLabelChange = (projectLabel: string) => {
dispatch(updateProjectLabel(projectLabel)); dispatch(updateProjectLabel(projectLabel));

45
client/src/util/patch.ts Normal file
View File

@ -0,0 +1,45 @@
// React/Redux compatible implementation of RFC 7396
// See https://tools.ietf.org/html/rfc7396
//
// Pseudo algorithm:
//
// define MergePatch(Target, Patch):
// if Patch is an Object:
// if Target is not an Object:
// Target = {} # Ignore the contents and set it to an empty Object
// for each Name/Value pair in Patch:
// if Value is null:
// if Name exists in Target:
// remove the Name/Value pair from Target
// else:
// Target[Name] = MergePatch(Target[Name], Value)
// return Target
// else:
// return Patch
export function applyPatch(target: any, patch: any): Object {
if (!isObject(patch)) {
return patch;
}
if (!isObject(target)) {
target = {};
}
Object.keys(patch).forEach((key: any) => {
const value = patch[key];
target = { ...target };
if (value === null) {
delete target[key];
} else {
target[key] = applyPatch(target[key], value);
}
});
return target;
}
function isObject(value: any): boolean {
return value === Object(value);
}

View File

@ -24,9 +24,8 @@ module.exports = {
historyApiFallback: true, historyApiFallback: true,
hot: env.NODE_ENV === 'development', hot: env.NODE_ENV === 'development',
proxy: { proxy: {
'/ws': { '/api': {
target: 'http://127.0.0.1:8081', target: 'http://127.0.0.1:8081',
ws: true
} }
} }
}, },

View File

@ -15,5 +15,5 @@ modd.conf {
server/**/*.go server/**/*.go
server/**/*_test.go { server/**/*_test.go {
prep: cd server && go clean -testcache && go test -v ./... prep: cd server && go clean -testcache && go test -race -v ./...
} }

View File

@ -2,6 +2,7 @@ package main
import ( import (
"forge.cadoles.com/wpetit/guesstimate/internal/config" "forge.cadoles.com/wpetit/guesstimate/internal/config"
"forge.cadoles.com/wpetit/guesstimate/internal/model"
"forge.cadoles.com/wpetit/guesstimate/internal/storm" "forge.cadoles.com/wpetit/guesstimate/internal/storm"
"gitlab.com/wpetit/goweb/service" "gitlab.com/wpetit/goweb/service"
"gitlab.com/wpetit/goweb/service/build" "gitlab.com/wpetit/goweb/service/build"
@ -18,6 +19,9 @@ func getServiceContainer(conf *config.Config) (*service.Container, error) {
ctn.Provide(storm.ServiceName, storm.ServiceProvider( ctn.Provide(storm.ServiceName, storm.ServiceProvider(
storm.WithPath(conf.Data.Path), storm.WithPath(conf.Data.Path),
storm.WithObjects(&model.ProjectEntry{}),
storm.WithInit(true),
storm.WithReIndex(true),
)) ))
return ctn, nil return ctn, nil

View File

@ -0,0 +1,29 @@
package model
type ProjectID string
type ProjectEntry struct {
ID ProjectID `storm:"id"`
Version uint64 `storm:"index"`
Project *Project
}
type Project struct {
ID ProjectID `json:"id" storm:"id"`
Label string `json:"label"`
Description string `json:"description"`
Tasks map[TaskID]Task `json:"tasks"`
Params Params `json:"params"`
}
type Params struct {
TaskCategories map[TaskCategoryID]TaskCategory `json:"taskCategories"`
TimeUnit TimeUnit `json:"timeUnit"`
Currency string `json:"currency"`
RoundUpEstimations bool `json:"roundUpEstimations"`
}
type TimeUnit struct {
Acronym string `json:"acronym"`
Label string `json:"label"`
}

View File

@ -0,0 +1,24 @@
package model
type TaskID string
type Task struct {
ID TaskID `json:"id"`
Label string `json:"label"`
Estimations TaskEstimations `json:"estimations"`
CategoryID TaskCategoryID `json:"category"`
}
type TaskEstimations struct {
Optimistic float64 `json:"optimistic"`
Likely float64 `json:"likely"`
Pessimistic float64 `json:"pessimistic"`
}
type TaskCategoryID string
type TaskCategory struct {
ID TaskCategoryID `json:"id"`
Label string `json:"label"`
CostPerTimeUnit float64 `json:"costPerTimeUnit"`
}

View File

@ -6,7 +6,12 @@ import (
) )
func Mount(r *chi.Mux, config *config.Config) error { func Mount(r *chi.Mux, config *config.Config) error {
r.Get("/ws/{projectId}", handleProjectWebsocket) r.Route("/api/v1", func(r chi.Router) {
r.Get("/projects/{projectID}", handleGetProject)
r.Post("/projects/{projectID}", handleCreateProject)
r.Patch("/projects/{projectID}", handlePatchProject)
r.Delete("/projects/{projectID}", handleDeleteProject)
})
return nil return nil
} }

View File

@ -1,109 +1,248 @@
package route package route
import ( import (
"encoding/json"
"log" "log"
"net/http" "net/http"
"strconv"
"github.com/davecgh/go-spew/spew" jsonpatch "gopkg.in/evanphx/json-patch.v4"
"github.com/gorilla/websocket"
"forge.cadoles.com/wpetit/guesstimate/internal/model"
"forge.cadoles.com/wpetit/guesstimate/internal/storm"
"github.com/go-chi/chi"
"github.com/pkg/errors" "github.com/pkg/errors"
"gitlab.com/wpetit/goweb/logger" "gitlab.com/wpetit/goweb/middleware/container"
) )
var upgrader = websocket.Upgrader{} // nolint: gochecknoglobals type projectResponse struct {
Version uint64 `json:"version"`
func handleProjectWebsocket(w http.ResponseWriter, r *http.Request) { Project *model.Project `json:"project"`
log.Println("websocket request")
c, err := upgrader.Upgrade(w, r, nil)
if err != nil {
panic(errors.Wrap(err, "could not upgrade connection"))
} }
defer c.Close()
// ctn := container.Must(r.Context()) func handleGetProject(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
db := storm.Must(ctn)
// ctx := r.Context() projectID := getProjectID(r)
// Loop over incoming websocket messages var (
for { version uint64
_, data, err := c.ReadMessage() err error
)
rawVersion := r.URL.Query().Get("version")
if rawVersion != "" {
version, err = strconv.ParseUint(rawVersion, 10, 64)
if err != nil { if err != nil {
cause := errors.Cause(err) http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
if websocket.IsCloseError(cause, 1001, 1005) { // Ignore "going away" and "no status" close errors
return
}
}
tx, err := db.Begin(false)
if err != nil {
panic(errors.Wrap(err, "could not start transaction"))
}
defer func() {
if err := tx.Rollback(); err != nil && err != storm.ErrNotInTransaction {
panic(errors.Wrap(err, "could not rollback transaction"))
}
}()
entry := &model.ProjectEntry{}
if err := tx.One("ID", projectID, entry); err != nil {
if err == storm.ErrNotFound {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return return
} }
logger.Error( panic(errors.Wrapf(err, "could not find project '%s'", projectID))
r.Context(),
"could not read message",
logger.E(err),
)
break
} }
spew.Dump(data) if rawVersion != "" && entry.Version == version {
http.Error(w, http.StatusText(http.StatusNotModified), http.StatusNotModified)
// message := &game.Message{} return
// if err := json.Unmarshal(data, message); err != nil { }
// logger.Error(
// r.Context(),
// "could not decode message",
// logger.E(err),
// )
// break if err := writeJSON(w, http.StatusOK, &projectResponse{entry.Version, entry.Project}); err != nil {
// } panic(errors.Wrap(err, "could not write json"))
// switch {
// case message.Type == game.MessageTypeInit:
// payload := &game.InitPayload{}
// if err := json.Unmarshal(message.Payload, payload); err != nil {
// logger.Error(
// r.Context(),
// "could not decode payload",
// logger.E(err),
// )
// break
// }
// setGameID(payload.GameID)
// evt := game.NewEventPlayerConnected(payload.GameID, user.ID)
// bus.Publish(game.EventNamespace, evt)
// case message.Type == game.MessageTypeGameEvent:
// gameID, ok := getGameID()
// if !ok {
// logger.Error(
// r.Context(),
// "game id not received yet",
// )
// break
// }
// payload := &game.EventPayload{}
// if err := json.Unmarshal(message.Payload, payload); err != nil {
// logger.Error(
// r.Context(),
// "could not decode payload",
// logger.E(err),
// )
// break
// }
// evt := game.NewEventPlayerMessage(gameID, user.ID, payload.Data)
// bus.Publish(game.EventNamespace, evt)
// default:
// logger.Error(
// r.Context(),
// "unsupported message type",
// logger.F("messageType", message.Type),
// )
// }
} }
} }
type createRequest struct {
Project *model.Project `json:"project"`
}
func handleCreateProject(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
db := storm.Must(ctn)
projectID := getProjectID(r)
log.Printf("handling create request for project %s", projectID)
createReq := &createRequest{}
if err := parseJSONBody(r, createReq); err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
panic(errors.Wrap(err, "could not parse create request"))
}
tx, err := db.Begin(true)
if err != nil {
panic(errors.Wrap(err, "could not start transaction"))
}
defer func() {
if err := tx.Rollback(); err != nil && err != storm.ErrNotInTransaction {
panic(errors.Wrap(err, "could not rollback transaction"))
}
}()
entry := &model.ProjectEntry{}
err = tx.One("ID", projectID, entry)
if err == nil {
http.Error(w, http.StatusText(http.StatusConflict), http.StatusConflict)
return
}
if err != storm.ErrNotFound {
panic(errors.Wrapf(err, "could not check project '%s'", projectID))
}
entry.ID = projectID
entry.Project = createReq.Project
entry.Version = 0
if err := tx.Save(entry); err != nil {
panic(errors.Wrap(err, "could not save project"))
}
if err := tx.Commit(); err != nil {
panic(errors.Wrap(err, "could not commit transaction"))
}
if err := writeJSON(w, http.StatusCreated, &projectResponse{entry.Version, entry.Project}); err != nil {
panic(errors.Wrap(err, "could not write json response"))
}
}
type patchRequest struct {
Version uint64 `json:"version"`
Patch json.RawMessage `json:"patch"`
}
func handlePatchProject(w http.ResponseWriter, r *http.Request) {
ctn := container.Must(r.Context())
db := storm.Must(ctn)
projectID := getProjectID(r)
log.Printf("handling patch request for project %s", projectID)
patchReq := &patchRequest{}
if err := parseJSONBody(r, patchReq); err != nil {
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
panic(errors.Wrap(err, "could not parse patch request"))
}
tx, err := db.Begin(true)
if err != nil {
panic(errors.Wrap(err, "could not start transaction"))
}
defer func() {
if err := tx.Rollback(); err != nil && err != storm.ErrNotInTransaction {
panic(errors.Wrap(err, "could not rollback transaction"))
}
}()
entry := &model.ProjectEntry{}
if err := tx.One("ID", projectID, entry); err != nil {
if err == storm.ErrNotFound {
http.Error(w, http.StatusText(http.StatusNotFound), http.StatusNotFound)
return
}
panic(errors.Wrapf(err, "could not find project '%s'", projectID))
}
if entry.Version != patchReq.Version {
if err := writeJSON(w, http.StatusConflict, &projectResponse{entry.Version, entry.Project}); err != nil {
panic(errors.Wrap(err, "could not write json response"))
}
return
}
projectData, err := json.Marshal(entry.Project)
if err != nil {
panic(errors.Wrap(err, "could not marshal project"))
}
projectData, err = jsonpatch.MergePatch(projectData, patchReq.Patch)
if err != nil {
panic(errors.Wrap(err, "could not merge project patch"))
}
newProject := &model.Project{}
if err := json.Unmarshal(projectData, newProject); err != nil {
panic(errors.Wrap(err, "could not merge project patch"))
}
entry.Version++
entry.Project = newProject
if err := tx.Save(entry); err != nil {
panic(errors.Wrap(err, "could not save project"))
}
if err := tx.Commit(); err != nil {
panic(errors.Wrap(err, "could not commit transaction"))
}
if err := writeJSON(w, http.StatusOK, &projectResponse{entry.Version, entry.Project}); err != nil {
panic(errors.Wrap(err, "could not write json response"))
}
}
func handleDeleteProject(w http.ResponseWriter, r *http.Request) {
}
func getProjectID(r *http.Request) model.ProjectID {
return model.ProjectID(chi.URLParam(r, "projectID"))
}
func parseJSONBody(r *http.Request, payload interface{}) (err error) {
decoder := json.NewDecoder(r.Body)
defer func() {
if err = r.Body.Close(); err != nil {
err = errors.Wrap(err, "could not close request body")
}
}()
if err := decoder.Decode(payload); err != nil {
return errors.Wrap(err, "could not decode request body")
}
return nil
}
func writeJSON(w http.ResponseWriter, statusCode int, data interface{}) error {
encoder := json.NewEncoder(w)
encoder.SetIndent("", " ")
w.WriteHeader(statusCode)
w.Header().Set("Content-Type", "application/json")
return encoder.Encode(data)
}

View File

@ -6,4 +6,5 @@ import (
var ( var (
ErrNotFound = storm.ErrNotFound ErrNotFound = storm.ErrNotFound
ErrNotInTransaction = storm.ErrNotInTransaction
) )