├── .eslintrc.cjs ├── .github └── workflows │ └── js.yml ├── .gitignore ├── .prettierrc.json ├── LICENSE ├── README.md ├── next-env.d.ts ├── package-lock.json ├── package.json ├── src ├── backend │ ├── data.test.ts │ ├── data.ts │ ├── index.ts │ ├── pg.ts │ ├── pgconfig │ │ ├── pgconfig.ts │ │ ├── pgmem.ts │ │ ├── postgres.ts │ │ └── supabase.ts │ ├── poke │ │ ├── poke.ts │ │ ├── sse.ts │ │ └── supabase.ts │ ├── postgres-storage.ts │ ├── pull.ts │ ├── push.ts │ ├── schema.ts │ └── supabase.ts ├── endpoints │ ├── handle-request.ts │ ├── replicache-poke-sse.ts │ ├── replicache-pull.ts │ └── replicache-push.ts └── frontend │ ├── index.ts │ ├── poke.ts │ └── use-replicache.ts └── tsconfig.json /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: "@typescript-eslint/parser", 4 | plugins: ["@typescript-eslint"], 5 | extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 6 | parserOptions: { 7 | project: "./tsconfig.json", 8 | }, 9 | rules: { 10 | "@typescript-eslint/no-floating-promises": "error", 11 | "@typescript-eslint/naming-convention": [ 12 | "error", 13 | { 14 | selector: "memberLike", 15 | modifiers: ["public"], 16 | format: ["camelCase"], 17 | leadingUnderscore: "forbid", 18 | }, 19 | ], 20 | eqeqeq: "error", 21 | "no-var": "error", 22 | "object-shorthand": "error", 23 | "prefer-arrow-callback": "error", 24 | "prefer-destructuring": [ 25 | "error", 26 | { 27 | VariableDeclarator: { 28 | object: true, 29 | }, 30 | }, 31 | { 32 | enforceForRenamedProperties: false, 33 | }, 34 | ], 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /.github/workflows/js.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [16.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v2 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: 'npm' 24 | - run: npm ci 25 | - run: npm test 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | *.orig 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "trailingComma": "es5", 4 | "arrowParens": "always", 5 | "bracketSpacing": true, 6 | "tabWidth": 2, 7 | "useTabs": false 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # replicache-nextjs 2 | 3 | This is a generic Replicache backend built on Next.js and Supabase. 4 | 5 | It's "generic" in the sense that it works with any Replicache mutators, and doesn't require app-specific code to sync. 6 | 7 | ## Usage 8 | 9 | See https://github.com/rocicorp/replicache-todo for example usage. 10 | 11 | This isn't very extensible yet. For example, there are no integration points for authentication, authorization, custom database schema,etc. 12 | 13 | We imagine over time, and with experience it will start to be clear what the extension points should be. 14 | 15 | For now, if you'd like to add features to this the best way is probably to fork it. 16 | 17 | ## Contributing 18 | 19 | PRs and feature requests are welcome! 20 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "replicache-nextjs", 3 | "version": "0.5.1", 4 | "description": "Generic Replicache backend on Next.js", 5 | "homepage": "https://github.com/rocicorp/replicache-nextjs", 6 | "repository": "github:rocicorp/replicache-nextjs", 7 | "license": "Apache-2.0", 8 | "scripts": { 9 | "format": "prettier --write 'src/**/*.{js,jsx,json,ts,tsx,html,css,md}'", 10 | "check-format": "prettier --check 'src/**/*.{js,jsx,json,ts,tsx,html,css,md}'", 11 | "lint": "eslint --ext .ts,.tsx,.js,.jsx src/", 12 | "check-types": "tsc --noEmit", 13 | "build": "rm -rf lib && tsc", 14 | "prepack": "npm run lint && npm run build", 15 | "pretest": "npm run build", 16 | "test": "mocha --ui=tdd 'lib/**/*.test.js'" 17 | }, 18 | "engines": { 19 | "//": "npm 7 not strictly *required* but without, you need to manually install the peerDependencies below", 20 | "npm": ">=7.0.0" 21 | }, 22 | "peerDependencies": { 23 | "@supabase/supabase-js": ">=2.0.0", 24 | "next": ">=12.1.6", 25 | "pg": ">=8.6.0", 26 | "pg-mem": ">=2.5.0", 27 | "react": ">=16.0 <19.0", 28 | "react-dom": ">=16.0 <19.0", 29 | "replicache": ">=12.0.1", 30 | "zod": ">=3.17.3", 31 | "replicache-transaction": ">=0.2.1" 32 | }, 33 | "devDependencies": { 34 | "@types/chai": "^4.3.0", 35 | "@types/mocha": "^9.1.0", 36 | "@types/node": "^14.14.37", 37 | "@types/pg": "^8.6.4", 38 | "@types/react": "^17.0.11", 39 | "@types/react-dom": "^18.0.5", 40 | "@typescript-eslint/eslint-plugin": "^5.3.1", 41 | "@typescript-eslint/parser": "^5.18.0", 42 | "chai": "^4.3.6", 43 | "eslint": "^8.2.0", 44 | "mocha": "^9.2.1", 45 | "prettier": "^2.2.1", 46 | "ts-node": "^10.7.0", 47 | "typescript": "^4.8.4" 48 | }, 49 | "type": "module", 50 | "files": [ 51 | "lib/*", 52 | "!lib/*.test.*" 53 | ], 54 | "exports": { 55 | "./lib/backend": { 56 | "import": { 57 | "types": "./lib/backend/index.d.ts", 58 | "default": "./lib/backend/index.js" 59 | } 60 | }, 61 | "./lib/frontend": { 62 | "import": { 63 | "types": "./lib/frontend/index.d.ts", 64 | "default": "./lib/frontend/index.js" 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/backend/data.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { test } from "mocha"; 3 | import type { JSONValue } from "replicache"; 4 | import { 5 | createSpace, 6 | delEntry, 7 | getCookie, 8 | getEntries, 9 | getEntry, 10 | putEntry, 11 | setCookie, 12 | } from "./data.js"; 13 | import { withExecutor } from "./pg.js"; 14 | 15 | test("getEntry", async () => { 16 | type Case = { 17 | name: string; 18 | exists: boolean; 19 | deleted: boolean; 20 | validJSON: boolean; 21 | }; 22 | const cases: Case[] = [ 23 | { 24 | name: "does not exist", 25 | exists: false, 26 | deleted: false, 27 | validJSON: false, 28 | }, 29 | { 30 | name: "exists, deleted", 31 | exists: true, 32 | deleted: true, 33 | validJSON: true, 34 | }, 35 | { 36 | name: "exists, not deleted, invalid JSON", 37 | exists: true, 38 | deleted: false, 39 | validJSON: false, 40 | }, 41 | { 42 | name: "exists, not deleted, valid JSON", 43 | exists: true, 44 | deleted: false, 45 | validJSON: true, 46 | }, 47 | ]; 48 | 49 | await withExecutor(async (executor) => { 50 | for (const c of cases) { 51 | await executor(`delete from entry where spaceid = 's1' and key = 'foo'`); 52 | if (c.exists) { 53 | await executor( 54 | `insert into entry (spaceid, key, value, deleted, version, lastmodified) values ('s1', 'foo', $1, $2, 1, now())`, 55 | [c.validJSON ? JSON.stringify(42) : "not json", c.deleted] 56 | ); 57 | } 58 | 59 | const promise = getEntry(executor, "s1", "foo"); 60 | let result: JSONValue | undefined; 61 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 62 | let error: any | undefined; 63 | await promise.then( 64 | (r) => (result = r), 65 | (e) => (error = String(e)) 66 | ); 67 | if (!c.exists) { 68 | expect(result, c.name).undefined; 69 | expect(error, c.name).undefined; 70 | } else if (c.deleted) { 71 | expect(result, c.name).undefined; 72 | expect(error, c.name).undefined; 73 | } else if (!c.validJSON) { 74 | expect(result, c.name).undefined; 75 | expect(error, c.name).contains("SyntaxError"); 76 | } else { 77 | expect(result, c.name).eq(42); 78 | expect(error, c.name).undefined; 79 | } 80 | } 81 | }); 82 | }); 83 | 84 | test("getEntry RoundTrip types", async () => { 85 | await withExecutor(async (executor) => { 86 | await putEntry(executor, "s1", "boolean", true, 1); 87 | await putEntry(executor, "s1", "number", 42, 1); 88 | await putEntry(executor, "s1", "string", "foo", 1); 89 | await putEntry(executor, "s1", "array", [1, 2, 3], 1); 90 | await putEntry(executor, "s1", "object", { a: 1, b: 2 }, 1); 91 | 92 | expect(await getEntry(executor, "s1", "boolean")).eq(true); 93 | expect(await getEntry(executor, "s1", "number")).eq(42); 94 | expect(await getEntry(executor, "s1", "string")).eq("foo"); 95 | expect(await getEntry(executor, "s1", "array")).deep.equal([1, 2, 3]); 96 | expect(await getEntry(executor, "s1", "object")).deep.equal({ a: 1, b: 2 }); 97 | }); 98 | }); 99 | 100 | test("getEntries", async () => { 101 | await withExecutor(async (executor) => { 102 | await executor(`delete from entry where spaceid = 's1'`); 103 | await putEntry(executor, "s1", "foo", "foo", 1); 104 | await putEntry(executor, "s1", "bar", "bar", 1); 105 | await putEntry(executor, "s1", "baz", "baz", 1); 106 | 107 | type Case = { 108 | name: string; 109 | fromKey: string; 110 | expect: string[]; 111 | }; 112 | const cases: Case[] = [ 113 | { 114 | name: "fromEmpty", 115 | fromKey: "", 116 | expect: ["bar", "baz", "foo"], 117 | }, 118 | { 119 | name: "fromB", 120 | fromKey: "b", 121 | expect: ["bar", "baz", "foo"], 122 | }, 123 | { 124 | name: "fromBar", 125 | fromKey: "bar", 126 | expect: ["bar", "baz", "foo"], 127 | }, 128 | { 129 | name: "fromBas", 130 | fromKey: "bas", 131 | expect: ["baz", "foo"], 132 | }, 133 | { 134 | name: "fromF", 135 | fromKey: "f", 136 | expect: ["foo"], 137 | }, 138 | { 139 | name: "fromFooa", 140 | fromKey: "fooa", 141 | expect: [], 142 | }, 143 | ]; 144 | 145 | for (const c of cases) { 146 | const entries = []; 147 | for await (const entry of getEntries(executor, "s1", c.fromKey)) { 148 | entries.push(entry); 149 | } 150 | expect(entries).deep.equal( 151 | c.expect.map((k) => [k, k]), 152 | c.name 153 | ); 154 | } 155 | }); 156 | }); 157 | 158 | test("putEntry", async () => { 159 | type Case = { 160 | name: string; 161 | duplicate: boolean; 162 | deleted: boolean; 163 | }; 164 | 165 | const cases: Case[] = [ 166 | { 167 | name: "not duplicate", 168 | duplicate: false, 169 | deleted: false, 170 | }, 171 | { 172 | name: "duplicate", 173 | duplicate: true, 174 | deleted: false, 175 | }, 176 | { 177 | name: "deleted", 178 | duplicate: true, 179 | deleted: true, 180 | }, 181 | ]; 182 | 183 | await withExecutor(async (executor) => { 184 | for (const c of cases) { 185 | await executor(`delete from entry where spaceid = 's1' and key = 'foo'`); 186 | 187 | let res: Promise; 188 | if (c.duplicate) { 189 | await putEntry(executor, "s1", "foo", 41, 1); 190 | if (c.deleted) { 191 | await delEntry(executor, "s1", "foo", 1); 192 | } 193 | } 194 | // eslint-disable-next-line prefer-const 195 | res = putEntry(executor, "s1", "foo", 42, 2); 196 | 197 | await res.catch(() => ({})); 198 | 199 | const qr = await executor( 200 | `select spaceid, key, value, deleted, version 201 | from entry where spaceid = 's1' and key = 'foo'` 202 | ); 203 | const [row] = qr.rows; 204 | 205 | expect(row, c.name).not.undefined; 206 | const { spaceid, key, value, deleted, version } = row; 207 | expect(spaceid, c.name).eq("s1"); 208 | expect(key, c.name).eq("foo"); 209 | expect(value, c.name).eq("42"); 210 | expect(deleted, c.name).false; 211 | expect(version, c.name).eq(2); 212 | } 213 | }); 214 | }); 215 | 216 | test("delEntry", async () => { 217 | type Case = { 218 | name: string; 219 | exists: boolean; 220 | }; 221 | const cases: Case[] = [ 222 | { 223 | name: "does not exist", 224 | exists: false, 225 | }, 226 | { 227 | name: "exists", 228 | exists: true, 229 | }, 230 | ]; 231 | for (const c of cases) { 232 | await withExecutor(async (executor) => { 233 | await executor(`delete from entry where spaceid = 's1' and key = 'foo'`); 234 | if (c.exists) { 235 | await executor( 236 | `insert into entry (spaceid, key, value, deleted, version, lastmodified) values ('s1', 'foo', '42', false, 1, now())` 237 | ); 238 | } 239 | 240 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 241 | let error: any | undefined; 242 | await delEntry(executor, "s1", "foo", 2).catch( 243 | (e) => (error = String(e)) 244 | ); 245 | 246 | const qr = await executor( 247 | `select spaceid, key, value, deleted, version from entry where spaceid = 's1' and key = 'foo'` 248 | ); 249 | const [row] = qr.rows; 250 | 251 | if (c.exists) { 252 | expect(row, c.name).not.undefined; 253 | const { spaceid, key, value, deleted, version } = row; 254 | expect(spaceid, c.name).eq("s1"); 255 | expect(key, c.name).eq("foo"); 256 | expect(value, c.name).eq("42"); 257 | expect(deleted, c.name).true; 258 | expect(version, c.name).eq(2); 259 | } else { 260 | expect(row, c.name).undefined; 261 | expect(error, c.name).undefined; 262 | } 263 | }); 264 | } 265 | }); 266 | 267 | test("createSpace", async () => { 268 | type Case = { 269 | name: string; 270 | exists: boolean; 271 | }; 272 | const cases: Case[] = [ 273 | { 274 | name: "does not exist", 275 | exists: false, 276 | }, 277 | { 278 | name: "exists", 279 | exists: true, 280 | }, 281 | ]; 282 | for (const c of cases) { 283 | await withExecutor(async (executor) => { 284 | await executor(`delete from space where id = 'foo'`); 285 | if (c.exists) { 286 | await createSpace(executor, "foo"); 287 | await setCookie(executor, "foo", 42); 288 | } 289 | 290 | try { 291 | await createSpace(executor, "foo"); 292 | expect(c.exists).false; 293 | } catch (e) { 294 | expect(String(e)).contains( 295 | `duplicate key value violates unique constraint "space_pkey` 296 | ); 297 | expect(c.exists).true; 298 | } 299 | 300 | const res = await executor(`select * from space where id = 'foo'`); 301 | expect(res.rowCount).eq(1); 302 | const [row] = res.rows; 303 | if (c.exists) { 304 | expect(row).deep.equal({ 305 | id: "foo", 306 | version: 42, 307 | lastmodified: row.lastmodified, 308 | }); 309 | } else { 310 | expect(row).deep.equal({ 311 | id: "foo", 312 | version: 0, 313 | lastmodified: row.lastmodified, 314 | }); 315 | } 316 | }); 317 | } 318 | }); 319 | 320 | test("getCookie", async () => { 321 | type Case = { 322 | name: string; 323 | exists: boolean; 324 | }; 325 | const cases: Case[] = [ 326 | { 327 | name: "does not exist", 328 | exists: false, 329 | }, 330 | { 331 | name: "exists", 332 | exists: true, 333 | }, 334 | ]; 335 | for (const c of cases) { 336 | await withExecutor(async (executor) => { 337 | await executor(`delete from space where id = 'foo'`); 338 | if (c.exists) { 339 | await createSpace(executor, "foo"); 340 | await setCookie(executor, "foo", 42); 341 | } 342 | 343 | const cookie = await getCookie(executor, "foo"); 344 | expect(cookie).eq(c.exists ? 42 : undefined); 345 | }); 346 | } 347 | }); 348 | -------------------------------------------------------------------------------- /src/backend/data.ts: -------------------------------------------------------------------------------- 1 | import type { JSONValue } from "replicache"; 2 | import { z } from "zod"; 3 | import type { Executor } from "./pg.js"; 4 | 5 | export async function getEntry( 6 | executor: Executor, 7 | spaceid: string, 8 | key: string 9 | ): Promise { 10 | const { rows } = await executor( 11 | "select value from entry where spaceid = $1 and key = $2 and deleted = false", 12 | [spaceid, key] 13 | ); 14 | const value = rows[0]?.value; 15 | if (value === undefined) { 16 | return undefined; 17 | } 18 | return JSON.parse(value); 19 | } 20 | 21 | export async function putEntry( 22 | executor: Executor, 23 | spaceID: string, 24 | key: string, 25 | value: JSONValue, 26 | version: number 27 | ): Promise { 28 | await executor( 29 | ` 30 | insert into entry (spaceid, key, value, deleted, version, lastmodified) 31 | values ($1, $2, $3, false, $4, now()) 32 | on conflict (spaceid, key) do update set 33 | value = $3, deleted = false, version = $4, lastmodified = now() 34 | `, 35 | [spaceID, key, JSON.stringify(value), version] 36 | ); 37 | } 38 | 39 | export async function delEntry( 40 | executor: Executor, 41 | spaceID: string, 42 | key: string, 43 | version: number 44 | ): Promise { 45 | await executor( 46 | `update entry set deleted = true, version = $3 where spaceid = $1 and key = $2`, 47 | [spaceID, key, version] 48 | ); 49 | } 50 | 51 | export async function* getEntries( 52 | executor: Executor, 53 | spaceID: string, 54 | fromKey: string 55 | ): AsyncIterable { 56 | const { rows } = await executor( 57 | `select key, value from entry where spaceid = $1 and key >= $2 and deleted = false order by key`, 58 | [spaceID, fromKey] 59 | ); 60 | for (const row of rows) { 61 | yield [row.key as string, JSON.parse(row.value) as JSONValue] as const; 62 | } 63 | } 64 | 65 | export async function getChangedEntries( 66 | executor: Executor, 67 | spaceID: string, 68 | prevVersion: number 69 | ): Promise<[key: string, value: JSONValue, deleted: boolean][]> { 70 | const { rows } = await executor( 71 | `select key, value, deleted from entry where spaceid = $1 and version > $2`, 72 | [spaceID, prevVersion] 73 | ); 74 | return rows.map((row) => [row.key, JSON.parse(row.value), row.deleted]); 75 | } 76 | 77 | export async function createSpace( 78 | executor: Executor, 79 | spaceID: string 80 | ): Promise { 81 | console.log("creating space", spaceID); 82 | await executor( 83 | `insert into space (id, version, lastmodified) values ($1, 0, now())`, 84 | [spaceID] 85 | ); 86 | } 87 | 88 | export async function getCookie( 89 | executor: Executor, 90 | spaceID: string 91 | ): Promise { 92 | const { rows } = await executor(`select version from space where id = $1`, [ 93 | spaceID, 94 | ]); 95 | const value = rows[0]?.version; 96 | if (value === undefined) { 97 | return undefined; 98 | } 99 | return z.number().parse(value); 100 | } 101 | 102 | export async function setCookie( 103 | executor: Executor, 104 | spaceID: string, 105 | version: number 106 | ): Promise { 107 | await executor( 108 | `update space set version = $2, lastmodified = now() where id = $1`, 109 | [spaceID, version] 110 | ); 111 | } 112 | 113 | export async function getLastMutationID( 114 | executor: Executor, 115 | clientID: string 116 | ): Promise { 117 | const { rows } = await executor( 118 | `select lastmutationid from client where id = $1`, 119 | [clientID] 120 | ); 121 | const value = rows[0]?.lastmutationid; 122 | if (value === undefined) { 123 | return undefined; 124 | } 125 | return z.number().parse(value); 126 | } 127 | 128 | export async function setLastMutationID( 129 | executor: Executor, 130 | clientID: string, 131 | lastMutationID: number 132 | ): Promise { 133 | await executor( 134 | ` 135 | insert into client (id, lastmutationid, lastmodified) 136 | values ($1, $2, now()) 137 | on conflict (id) do update set lastmutationid = $2, lastmodified = now() 138 | `, 139 | [clientID, lastMutationID] 140 | ); 141 | } 142 | -------------------------------------------------------------------------------- /src/backend/index.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { getCookie, createSpace as createSpaceImpl } from "../backend/data.js"; 3 | import { handleRequest as handleRequestImpl } from "../endpoints/handle-request.js"; 4 | import { transact } from "../backend/pg.js"; 5 | import type { MutatorDefs } from "replicache"; 6 | 7 | export async function spaceExists(spaceID: string) { 8 | const cookie = await transact(async (executor) => { 9 | return await getCookie(executor, spaceID); 10 | }); 11 | return cookie !== undefined; 12 | } 13 | 14 | export async function createSpace(spaceID: string) { 15 | await transact(async (executor) => { 16 | await createSpaceImpl(executor, spaceID); 17 | }); 18 | } 19 | 20 | export async function handleRequest( 21 | req: NextApiRequest, 22 | res: NextApiResponse, 23 | mutators: M 24 | ) { 25 | await handleRequestImpl(req, res, mutators); 26 | } 27 | -------------------------------------------------------------------------------- /src/backend/pg.ts: -------------------------------------------------------------------------------- 1 | // Low-level config and utilities for Postgres. 2 | 3 | import type { Pool, QueryResult } from "pg"; 4 | import { createDatabase } from "./schema.js"; 5 | import { getDBConfig } from "./pgconfig/pgconfig.js"; 6 | 7 | const pool = getPool(); 8 | 9 | async function getPool() { 10 | const global = globalThis as unknown as { 11 | // eslint-disable-next-line @typescript-eslint/naming-convention 12 | _pool: Pool; 13 | }; 14 | if (!global._pool) { 15 | global._pool = await initPool(); 16 | } 17 | return global._pool; 18 | } 19 | 20 | async function initPool() { 21 | console.log("creating global pool"); 22 | 23 | const dbConfig = getDBConfig(); 24 | const pool = dbConfig.initPool(); 25 | 26 | // the pool will emit an error on behalf of any idle clients 27 | // it contains if a backend error or network partition happens 28 | pool.on("error", (err) => { 29 | console.error("Unexpected error on idle client", err); 30 | process.exit(-1); 31 | }); 32 | pool.on("connect", async (client) => { 33 | // eslint-disable-next-line @typescript-eslint/no-floating-promises 34 | client.query( 35 | "SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE" 36 | ); 37 | }); 38 | 39 | await withExecutorAndPool(async (executor) => { 40 | await transactWithExecutor(executor, async (executor) => { 41 | await createDatabase(executor, dbConfig); 42 | }); 43 | }, pool); 44 | 45 | return pool; 46 | } 47 | 48 | export async function withExecutor(f: (executor: Executor) => R) { 49 | const p = await pool; 50 | return withExecutorAndPool(f, p); 51 | } 52 | 53 | async function withExecutorAndPool( 54 | f: (executor: Executor) => R, 55 | p: Pool 56 | ): Promise { 57 | const client = await p.connect(); 58 | 59 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 60 | const executor = async (sql: string, params?: any[]) => { 61 | try { 62 | return await client.query(sql, params); 63 | } catch (e) { 64 | throw new Error( 65 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 66 | `Error executing SQL: ${sql}: ${(e as unknown as any).toString()}` 67 | ); 68 | } 69 | }; 70 | 71 | try { 72 | return await f(executor); 73 | } finally { 74 | client.release(); 75 | } 76 | } 77 | 78 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 79 | export type Executor = (sql: string, params?: any[]) => Promise; 80 | export type TransactionBodyFn = (executor: Executor) => Promise; 81 | 82 | /** 83 | * Invokes a supplied function within a transaction. 84 | * @param body Function to invoke. If this throws, the transaction will be rolled 85 | * back. The thrown error will be re-thrown. 86 | */ 87 | export async function transact(body: TransactionBodyFn) { 88 | return await withExecutor(async (executor) => { 89 | return await transactWithExecutor(executor, body); 90 | }); 91 | } 92 | 93 | async function transactWithExecutor( 94 | executor: Executor, 95 | body: TransactionBodyFn 96 | ) { 97 | for (let i = 0; i < 10; i++) { 98 | try { 99 | await executor("begin"); 100 | try { 101 | const r = await body(executor); 102 | await executor("commit"); 103 | return r; 104 | } catch (e) { 105 | console.log("caught error", e, "rolling back"); 106 | await executor("rollback"); 107 | throw e; 108 | } 109 | } catch (e) { 110 | if (shouldRetryTransaction(e)) { 111 | console.log( 112 | `Retrying transaction due to error ${e} - attempt number ${i}` 113 | ); 114 | continue; 115 | } 116 | throw e; 117 | } 118 | } 119 | throw new Error("Tried to execute transacation too many times. Giving up."); 120 | } 121 | 122 | //stackoverflow.com/questions/60339223/node-js-transaction-coflicts-in-postgresql-optimistic-concurrency-control-and 123 | function shouldRetryTransaction(err: unknown) { 124 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 125 | const code = typeof err === "object" ? String((err as any).code) : null; 126 | return code === "40001" || code === "40P01"; 127 | } 128 | -------------------------------------------------------------------------------- /src/backend/pgconfig/pgconfig.ts: -------------------------------------------------------------------------------- 1 | import type { Pool } from "pg"; 2 | import type { Executor } from "../pg.js"; 3 | import { getSupabaseServerConfig } from "../supabase.js"; 4 | import { PGMemConfig } from "./pgmem.js"; 5 | import { PostgresDBConfig } from "./postgres.js"; 6 | import { supabaseDBConfig } from "./supabase.js"; 7 | 8 | /** 9 | * We use Postgres in a few different ways: directly, via supabase, 10 | * emulated with pg-mem. This interface abstracts their differences. 11 | */ 12 | export interface PGConfig { 13 | initPool(): Pool; 14 | getSchemaVersion(executor: Executor): Promise; 15 | } 16 | 17 | export function getDBConfig(): PGConfig { 18 | const dbURL = process.env.DATABASE_URL; 19 | if (dbURL) { 20 | return new PostgresDBConfig(dbURL); 21 | } 22 | const supabaseServerConfig = getSupabaseServerConfig(); 23 | if (supabaseServerConfig) { 24 | return supabaseDBConfig(supabaseServerConfig); 25 | } 26 | return new PGMemConfig(); 27 | } 28 | -------------------------------------------------------------------------------- /src/backend/pgconfig/pgmem.ts: -------------------------------------------------------------------------------- 1 | import type { Pool } from "pg"; 2 | import { newDb } from "pg-mem"; 3 | import type { PGConfig } from "./pgconfig.js"; 4 | 5 | export class PGMemConfig implements PGConfig { 6 | constructor() { 7 | console.log("Creating PGMemConfig"); 8 | } 9 | 10 | initPool(): Pool { 11 | return new (newDb().adapters.createPg().Pool)() as Pool; 12 | } 13 | 14 | async getSchemaVersion(): Promise { 15 | // pg-mem lacks the system tables we normally use to introspect our 16 | // version. Luckily since pg-mem is in memory, we know that everytime we 17 | // start, we're starting fresh :). 18 | return 0; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/backend/pgconfig/postgres.ts: -------------------------------------------------------------------------------- 1 | import pg from "pg"; 2 | import type { Executor } from "../pg.js"; 3 | import type { PGConfig } from "./pgconfig.js"; 4 | 5 | /** 6 | * Implements PGConfig over a basic Postgres connection. 7 | */ 8 | export class PostgresDBConfig implements PGConfig { 9 | private _url: string; 10 | 11 | constructor(url: string) { 12 | console.log("Creating PostgresDBConfig with url", url); 13 | this._url = url; 14 | } 15 | 16 | initPool(): pg.Pool { 17 | const ssl = 18 | process.env.NODE_ENV === "production" 19 | ? { 20 | rejectUnauthorized: false, 21 | } 22 | : undefined; 23 | return new pg.Pool({ 24 | connectionString: this._url, 25 | ssl, 26 | }); 27 | } 28 | 29 | async getSchemaVersion(executor: Executor): Promise { 30 | const metaExists = await executor(`select exists( 31 | select from pg_tables where schemaname = 'public' and tablename = 'meta')`); 32 | if (!metaExists.rows[0].exists) { 33 | return 0; 34 | } 35 | const qr = await executor( 36 | `select value from meta where key = 'schemaVersion'` 37 | ); 38 | return qr.rows[0].value; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/backend/pgconfig/supabase.ts: -------------------------------------------------------------------------------- 1 | import type { SupabaseServerConfig } from "../supabase.js"; 2 | import { PostgresDBConfig } from "./postgres.js"; 3 | 4 | /** 5 | * Gets a PGConfig for Supabase. Supabase is postgres, just the way to get the 6 | * Database URL is different. The reason to not just use DATABASE_URL is 7 | * because the Supabase integration for Vercel sets the NEXT_PUBLIC_SUPABASE_URL 8 | * env var automatically. We prefer to derive the database URL from that plus 9 | * the password to reduce setup work the user would have to do (going and 10 | * finding the config vars would otherwise be a minor pain). 11 | */ 12 | export function supabaseDBConfig(config: SupabaseServerConfig) { 13 | // The Supabase URL env var is the URL to access the Supabase REST API, 14 | // which looks like: https://pfdhjzsdkvlmuyvttfvt.supabase.co. 15 | // We need to convert it into the Postgres connection string. 16 | const { url, dbpass } = config; 17 | const host = new URL(url).hostname; 18 | const id = host.split(".")[0]; 19 | return new PostgresDBConfig( 20 | `postgresql://postgres:${dbpass}@db.${id}.supabase.co:5432/postgres` 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/backend/poke/poke.ts: -------------------------------------------------------------------------------- 1 | import type { Executor } from "../pg.js"; 2 | import { getSupabaseServerConfig } from "../supabase.js"; 3 | import { SSEPokeBackend } from "./sse.js"; 4 | import { SupabasePokeBackend } from "./supabase.js"; 5 | 6 | export interface PokeBackend { 7 | initSchema(executor: Executor): Promise; 8 | poke(spaceID: string): void; 9 | } 10 | 11 | export function getPokeBackend() { 12 | // The SSE impl has to keep process-wide state using the global object. 13 | // Otherwise the state is lost during hot reload in dev. 14 | const global = globalThis as unknown as { 15 | // eslint-disable-next-line @typescript-eslint/naming-convention 16 | _pokeBackend: PokeBackend | undefined; 17 | }; 18 | if (!global._pokeBackend) { 19 | global._pokeBackend = initPokeBackend(); 20 | } 21 | return global._pokeBackend; 22 | } 23 | 24 | function initPokeBackend() { 25 | const supabaseServerConfig = getSupabaseServerConfig(); 26 | if (supabaseServerConfig) { 27 | console.log("Creating SupabasePokeBackend"); 28 | return new SupabasePokeBackend(); 29 | } else { 30 | console.log("Creating SSEPokeBackend"); 31 | return new SSEPokeBackend(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/backend/poke/sse.ts: -------------------------------------------------------------------------------- 1 | import type { PokeBackend } from "./poke.js"; 2 | 3 | type Listener = () => void; 4 | type ListenerMap = Map>; 5 | 6 | // Implements the poke backend using server-sent events. 7 | export class SSEPokeBackend implements PokeBackend { 8 | private _listeners: ListenerMap; 9 | 10 | constructor() { 11 | this._listeners = new Map(); 12 | } 13 | 14 | async initSchema(): Promise { 15 | // No schema support necessary for SSE poke. 16 | } 17 | 18 | addListener(spaceID: string, listener: () => void) { 19 | let set = this._listeners.get(spaceID); 20 | if (!set) { 21 | set = new Set(); 22 | this._listeners.set(spaceID, set); 23 | } 24 | set.add(listener); 25 | return () => this._removeListener(spaceID, listener); 26 | } 27 | 28 | poke(spaceID: string) { 29 | const set = this._listeners.get(spaceID); 30 | if (!set) { 31 | return; 32 | } 33 | for (const listener of set) { 34 | try { 35 | listener(); 36 | } catch (e) { 37 | console.error(e); 38 | } 39 | } 40 | } 41 | 42 | private _removeListener(spaceID: string, listener: () => void) { 43 | const set = this._listeners.get(spaceID); 44 | if (!set) { 45 | return; 46 | } 47 | set.delete(listener); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/backend/poke/supabase.ts: -------------------------------------------------------------------------------- 1 | import type { Executor } from "../pg.js"; 2 | import type { PokeBackend } from "./poke.js"; 3 | 4 | // Implements the poke backend using Supabase's realtime features. 5 | export class SupabasePokeBackend implements PokeBackend { 6 | async initSchema(executor: Executor): Promise { 7 | await executor(`alter publication supabase_realtime add table space`); 8 | await executor(`alter publication supabase_realtime set 9 | (publish = 'insert, update, delete');`); 10 | } 11 | 12 | poke() { 13 | // No need to poke, this is handled internally by the supabase realtime stuff. 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/backend/postgres-storage.ts: -------------------------------------------------------------------------------- 1 | import type { JSONValue } from "replicache"; 2 | import type { Storage } from "replicache-transaction"; 3 | import { putEntry, getEntry, getEntries, delEntry } from "./data.js"; 4 | import type { Executor } from "./pg.js"; 5 | 6 | // Implements the Storage interface required by replicache-transaction in terms 7 | // of our Postgres database. 8 | export class PostgresStorage implements Storage { 9 | private _spaceID: string; 10 | private _version: number; 11 | private _executor: Executor; 12 | 13 | constructor(spaceID: string, version: number, executor: Executor) { 14 | this._spaceID = spaceID; 15 | this._version = version; 16 | this._executor = executor; 17 | } 18 | 19 | putEntry(key: string, value: JSONValue): Promise { 20 | return putEntry(this._executor, this._spaceID, key, value, this._version); 21 | } 22 | 23 | async hasEntry(key: string): Promise { 24 | const v = await this.getEntry(key); 25 | return v !== undefined; 26 | } 27 | 28 | getEntry(key: string): Promise { 29 | return getEntry(this._executor, this._spaceID, key); 30 | } 31 | 32 | getEntries(fromKey: string): AsyncIterable { 33 | return getEntries(this._executor, this._spaceID, fromKey); 34 | } 35 | 36 | delEntry(key: string): Promise { 37 | return delEntry(this._executor, this._spaceID, key, this._version); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/backend/pull.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest } from "next"; 2 | import { transact } from "./pg.js"; 3 | import { getChangedEntries, getCookie, getLastMutationID } from "./data.js"; 4 | import { z } from "zod"; 5 | import type { PullResponse } from "replicache"; 6 | 7 | const pullRequest = z.object({ 8 | clientID: z.string(), 9 | cookie: z.union([z.number(), z.null()]), 10 | }); 11 | 12 | export async function pull( 13 | spaceID: string, 14 | requestBody: NextApiRequest 15 | ): Promise { 16 | console.log(`Processing pull`, JSON.stringify(requestBody, null, "")); 17 | 18 | const pull = pullRequest.parse(requestBody); 19 | const requestCookie = pull.cookie; 20 | 21 | console.log("spaceID", spaceID); 22 | console.log("clientID", pull.clientID); 23 | 24 | const t0 = Date.now(); 25 | 26 | const [entries, lastMutationID, responseCookie] = await transact( 27 | async (executor) => { 28 | return Promise.all([ 29 | getChangedEntries(executor, spaceID, requestCookie ?? 0), 30 | getLastMutationID(executor, pull.clientID), 31 | getCookie(executor, spaceID), 32 | ]); 33 | } 34 | ); 35 | 36 | console.log("lastMutationID: ", lastMutationID); 37 | console.log("responseCookie: ", responseCookie); 38 | console.log("Read all objects in", Date.now() - t0); 39 | 40 | if (responseCookie === undefined) { 41 | throw new Error(`Unknown space ${spaceID}`); 42 | } 43 | 44 | const resp: PullResponse = { 45 | lastMutationID: lastMutationID ?? 0, 46 | cookie: responseCookie, 47 | patch: [], 48 | }; 49 | 50 | for (const [key, value, deleted] of entries) { 51 | if (deleted) { 52 | resp.patch.push({ 53 | op: "del", 54 | key, 55 | }); 56 | } else { 57 | resp.patch.push({ 58 | op: "put", 59 | key, 60 | value, 61 | }); 62 | } 63 | } 64 | 65 | console.log(`Returning`, JSON.stringify(resp, null, "")); 66 | return resp; 67 | } 68 | -------------------------------------------------------------------------------- /src/backend/push.ts: -------------------------------------------------------------------------------- 1 | import { transact } from "./pg.js"; 2 | import { 3 | getCookie, 4 | getLastMutationID, 5 | setCookie, 6 | setLastMutationID, 7 | } from "./data.js"; 8 | import { ReplicacheTransaction } from "replicache-transaction"; 9 | import { z, ZodType } from "zod"; 10 | import { getPokeBackend } from "./poke/poke.js"; 11 | import type { MutatorDefs, ReadonlyJSONValue } from "replicache"; 12 | import { PostgresStorage } from "./postgres-storage.js"; 13 | 14 | const mutationSchema = z.object({ 15 | id: z.number(), 16 | name: z.string(), 17 | args: z.any(), 18 | }); 19 | 20 | const pushRequestSchema = z.object({ 21 | clientID: z.string(), 22 | mutations: z.array(mutationSchema), 23 | }); 24 | 25 | export function parseIfDebug(schema: ZodType, val: ReadonlyJSONValue): T { 26 | if (globalThis.process?.env?.NODE_ENV !== "production") { 27 | return schema.parse(val); 28 | } 29 | return val as T; 30 | } 31 | 32 | export type Error = "SpaceNotFound"; 33 | 34 | export async function push( 35 | spaceID: string, 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | requestBody: any, 38 | mutators: M 39 | ) { 40 | console.log("Processing push", JSON.stringify(requestBody, null, "")); 41 | 42 | const push = parseIfDebug(pushRequestSchema, requestBody); 43 | 44 | const t0 = Date.now(); 45 | await transact(async (executor) => { 46 | const prevVersion = await getCookie(executor, spaceID); 47 | if (prevVersion === undefined) { 48 | throw new Error(`Unknown space ${spaceID}`); 49 | } 50 | 51 | const nextVersion = prevVersion + 1; 52 | let lastMutationID = 53 | (await getLastMutationID(executor, push.clientID)) ?? 0; 54 | 55 | console.log("prevVersion: ", prevVersion); 56 | console.log("lastMutationID:", lastMutationID); 57 | 58 | const storage = new PostgresStorage(spaceID, nextVersion, executor); 59 | const tx = new ReplicacheTransaction(storage, push.clientID); 60 | 61 | for (let i = 0; i < push.mutations.length; i++) { 62 | const mutation = push.mutations[i]; 63 | const expectedMutationID = lastMutationID + 1; 64 | 65 | if (mutation.id < expectedMutationID) { 66 | console.log( 67 | `Mutation ${mutation.id} has already been processed - skipping` 68 | ); 69 | continue; 70 | } 71 | if (mutation.id > expectedMutationID) { 72 | console.warn(`Mutation ${mutation.id} is from the future - aborting`); 73 | break; 74 | } 75 | 76 | console.log("Processing mutation:", JSON.stringify(mutation, null, "")); 77 | 78 | const t1 = Date.now(); 79 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 80 | const mutator = (mutators as any)[mutation.name]; 81 | if (!mutator) { 82 | console.error(`Unknown mutator: ${mutation.name} - skipping`); 83 | } 84 | 85 | try { 86 | await mutator(tx, mutation.args); 87 | } catch (e) { 88 | console.error( 89 | `Error executing mutator: ${JSON.stringify(mutator)}: ${e}` 90 | ); 91 | } 92 | 93 | lastMutationID = expectedMutationID; 94 | console.log("Processed mutation in", Date.now() - t1); 95 | } 96 | 97 | await Promise.all([ 98 | setLastMutationID(executor, push.clientID, lastMutationID), 99 | setCookie(executor, spaceID, nextVersion), 100 | tx.flush(), 101 | ]); 102 | 103 | const pokeBackend = getPokeBackend(); 104 | await pokeBackend.poke(spaceID); 105 | }); 106 | 107 | console.log("Processed all mutations in", Date.now() - t0); 108 | } 109 | -------------------------------------------------------------------------------- /src/backend/schema.ts: -------------------------------------------------------------------------------- 1 | import type { PGConfig } from "./pgconfig/pgconfig.js"; 2 | import type { Executor } from "./pg.js"; 3 | import { getPokeBackend } from "./poke/poke.js"; 4 | 5 | export async function createDatabase(executor: Executor, dbConfig: PGConfig) { 6 | console.log("creating database"); 7 | const schemaVersion = await dbConfig.getSchemaVersion(executor); 8 | if (schemaVersion < 0 || schemaVersion > 1) { 9 | throw new Error("Unexpected schema version: " + schemaVersion); 10 | } 11 | if (schemaVersion === 0) { 12 | await createSchemaVersion1(executor); 13 | } 14 | } 15 | 16 | export async function createSchemaVersion1(executor: Executor) { 17 | await executor("create table meta (key text primary key, value json)"); 18 | await executor("insert into meta (key, value) values ('schemaVersion', '1')"); 19 | 20 | await executor(`create table space ( 21 | id text primary key not null, 22 | version integer not null, 23 | lastmodified timestamp(6) not null 24 | )`); 25 | 26 | await executor(`create table client ( 27 | id text primary key not null, 28 | lastmutationid integer not null, 29 | lastmodified timestamp(6) not null 30 | )`); 31 | 32 | await executor(`create table entry ( 33 | spaceid text not null, 34 | key text not null, 35 | value text not null, 36 | deleted boolean not null, 37 | version integer not null, 38 | lastmodified timestamp(6) not null 39 | )`); 40 | 41 | await executor(`create unique index on entry (spaceid, key)`); 42 | await executor(`create index on entry (spaceid)`); 43 | await executor(`create index on entry (deleted)`); 44 | await executor(`create index on entry (version)`); 45 | 46 | const pokeBackend = getPokeBackend(); 47 | await pokeBackend.initSchema(executor); 48 | } 49 | -------------------------------------------------------------------------------- /src/backend/supabase.ts: -------------------------------------------------------------------------------- 1 | const clientEnvVars = { 2 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 3 | url: process.env.NEXT_PUBLIC_SUPABASE_URL!, 4 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 5 | key: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 6 | }; 7 | const serverEnvVars = { 8 | ...clientEnvVars, 9 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 10 | dbpass: process.env.SUPABASE_DATABASE_PASSWORD!, 11 | }; 12 | 13 | export type SupabaseClientConfig = typeof clientEnvVars; 14 | export type SupabaseServerConfig = typeof serverEnvVars; 15 | 16 | export function getSupabaseClientConfig() { 17 | return validate(clientEnvVars); 18 | } 19 | 20 | export function getSupabaseServerConfig() { 21 | return validate(serverEnvVars); 22 | } 23 | 24 | function validate>(vars: T) { 25 | const enabled = Object.values(vars).some((v) => v); 26 | if (!enabled) { 27 | return undefined; 28 | } 29 | for (const [k, v] of Object.entries(vars)) { 30 | if (!v) { 31 | throw new Error( 32 | `Invalid Supabase config: NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_DATABASE_PASSWORD must be set (${k} was not)` 33 | ); 34 | } 35 | } 36 | return vars; 37 | } 38 | -------------------------------------------------------------------------------- /src/endpoints/handle-request.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import type { MutatorDefs } from "replicache"; 3 | import { handlePokeSSE } from "./replicache-poke-sse.js"; 4 | import { handlePull } from "./replicache-pull.js"; 5 | import { handlePush } from "./replicache-push.js"; 6 | 7 | export async function handleRequest( 8 | req: NextApiRequest, 9 | res: NextApiResponse, 10 | mutators: M 11 | ) { 12 | if (req.query === undefined) { 13 | res.status(400).send("Missing query"); 14 | return; 15 | } 16 | const op = req.query["op"] as string; 17 | console.log(`Handling request ${req.url}, op: ${op}`); 18 | 19 | switch (op) { 20 | case "push": 21 | return await handlePush(req, res, mutators); 22 | case "pull": 23 | return await handlePull(req, res); 24 | case "poke-sse": 25 | return await handlePokeSSE(req, res); 26 | } 27 | 28 | res.status(404).send("route not found"); 29 | } 30 | -------------------------------------------------------------------------------- /src/endpoints/replicache-poke-sse.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { getPokeBackend } from "../backend/poke/poke.js"; 3 | import type { SSEPokeBackend } from "../backend/poke/sse.js"; 4 | 5 | export async function handlePokeSSE(req: NextApiRequest, res: NextApiResponse) { 6 | if (req.query["spaceID"] === undefined) { 7 | res.status(400).send("Missing spaceID"); 8 | return; 9 | } 10 | const spaceID = req.query["spaceID"].toString() as string; 11 | 12 | res.setHeader("Access-Control-Allow-Origin", "*"); 13 | res.setHeader("Content-Type", "text/event-stream;charset=utf-8"); 14 | res.setHeader("Cache-Control", "no-cache, no-transform"); 15 | res.setHeader("X-Accel-Buffering", "no"); 16 | 17 | res.write(`id: ${Date.now()}\n`); 18 | res.write(`data: hello\n\n`); 19 | 20 | const pokeBackend = getPokeBackend() as SSEPokeBackend; 21 | if (!pokeBackend.addListener) { 22 | throw new Error( 23 | "Unsupported configuration. Expected to be configured using server-sent events for poke." 24 | ); 25 | } 26 | 27 | const unlisten = pokeBackend.addListener(spaceID, () => { 28 | res.write(`id: ${Date.now()}\n`); 29 | res.write(`data: poke\n\n`); 30 | }); 31 | 32 | setInterval(() => { 33 | res.write(`id: ${Date.now()}\n`); 34 | res.write(`data: beat\n\n`); 35 | }, 30 * 1000); 36 | 37 | res.on("close", () => { 38 | unlisten(); 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /src/endpoints/replicache-pull.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { pull } from "../backend/pull.js"; 3 | 4 | export async function handlePull(req: NextApiRequest, res: NextApiResponse) { 5 | if (req.query["spaceID"] === undefined) { 6 | res.status(400).send("Missing spaceID"); 7 | return; 8 | } 9 | const spaceID = req.query["spaceID"].toString() as string; 10 | const resp = await pull(spaceID, req.body); 11 | res.json(resp); 12 | res.end(); 13 | } 14 | -------------------------------------------------------------------------------- /src/endpoints/replicache-push.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import type { MutatorDefs } from "replicache"; 3 | import { push } from "../backend/push.js"; 4 | 5 | export async function handlePush( 6 | req: NextApiRequest, 7 | res: NextApiResponse, 8 | mutators: M 9 | ) { 10 | if (req.query["spaceID"] === undefined) { 11 | res.status(400).send("Missing spaceID"); 12 | return; 13 | } 14 | const spaceID = req.query["spaceID"].toString() as string; 15 | await push(spaceID, req.body, mutators); 16 | 17 | res.status(200).json({}); 18 | } 19 | -------------------------------------------------------------------------------- /src/frontend/index.ts: -------------------------------------------------------------------------------- 1 | export { useReplicache } from "./use-replicache.js"; 2 | -------------------------------------------------------------------------------- /src/frontend/poke.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getSupabaseClientConfig, 3 | SupabaseClientConfig, 4 | } from "../backend/supabase.js"; 5 | import { createClient } from "@supabase/supabase-js"; 6 | 7 | const supabaseClientConfig = getSupabaseClientConfig(); 8 | 9 | export type Receiver = (spaceID: string, onPoke: OnPoke) => Cancel; 10 | export type OnPoke = () => Promise; 11 | export type Cancel = () => void; 12 | 13 | // Returns a function that can be used to listen for pokes from the backend. 14 | // This sample supports two different ways to do it. 15 | export function getPokeReceiver(): Receiver { 16 | if (supabaseClientConfig) { 17 | return supabaseReceiver.bind(null, supabaseClientConfig); 18 | } else { 19 | return sseReceiver; 20 | } 21 | } 22 | 23 | // Implements a Replicache poke using Supabase's realtime functionality. 24 | // See: backend/poke/supabase.ts. 25 | function supabaseReceiver( 26 | supabaseClientConfig: SupabaseClientConfig, 27 | spaceID: string, 28 | onPoke: () => Promise 29 | ) { 30 | if (!supabaseClientConfig) { 31 | // eslint-disable-next-line @typescript-eslint/no-empty-function 32 | return () => {}; 33 | } 34 | const { url, key } = supabaseClientConfig; 35 | const supabase = createClient(url, key); 36 | const subscriptionChannel = supabase.channel("public:space"); 37 | subscriptionChannel 38 | .on( 39 | "postgres_changes", 40 | { 41 | event: "*", 42 | schema: "public", 43 | table: "space", 44 | filter: `id=eq.${spaceID}`, 45 | }, 46 | () => { 47 | void onPoke(); 48 | } 49 | ) 50 | .subscribe(); 51 | return () => { 52 | void supabase.removeChannel(subscriptionChannel); 53 | }; 54 | } 55 | 56 | // Implements a Replicache poke using Server-Sent Events. 57 | // See: backend/poke/sse.ts. 58 | function sseReceiver(spaceID: string, onPoke: () => Promise) { 59 | const ev = new EventSource(`/api/replicache/poke-sse?spaceID=${spaceID}`, { 60 | withCredentials: true, 61 | }); 62 | ev.onmessage = async (event) => { 63 | if (event.data === "poke") { 64 | await onPoke(); 65 | } 66 | }; 67 | const close = () => { 68 | ev.close(); 69 | }; 70 | // See https://bugzilla.mozilla.org/show_bug.cgi?id=833462 71 | window.addEventListener("beforeunload", close); 72 | return () => { 73 | close(); 74 | window.removeEventListener("beforeunload", close); 75 | }; 76 | } 77 | -------------------------------------------------------------------------------- /src/frontend/use-replicache.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { MutatorDefs, Replicache, ReplicacheOptions } from "replicache"; 3 | import { getPokeReceiver } from "./poke.js"; 4 | 5 | export interface UseReplicacheOptions 6 | extends Omit, "licenseKey" | "name"> { 7 | name?: string; 8 | } 9 | 10 | /** 11 | * Returns a Replicache instance with the given configuration. 12 | * If name is undefined, returns null. 13 | * If any of the values of the options change (by way of JS equals), a new 14 | * Replicache instance is created and the old one is closed. 15 | * Thus it is fine to say `useReplicache({name, mutators})`, as long as name 16 | * and mutators are stable. 17 | */ 18 | export function useReplicache({ 19 | name, 20 | ...options 21 | }: UseReplicacheOptions) { 22 | const [rep, setRep] = useState | null>(null); 23 | 24 | useEffect(() => { 25 | if (!name) { 26 | setRep(null); 27 | // eslint-disable-next-line @typescript-eslint/no-empty-function 28 | return () => {}; 29 | } 30 | 31 | const r = new Replicache({ 32 | // See https://doc.replicache.dev/licensing for how to get a license key. 33 | // eslint-disable-next-line @typescript-eslint/no-non-null-assertion 34 | licenseKey: process.env.NEXT_PUBLIC_REPLICACHE_LICENSE_KEY!, 35 | pushURL: `/api/replicache/push?spaceID=${name}`, 36 | pullURL: `/api/replicache/pull?spaceID=${name}`, 37 | name, 38 | ...options, 39 | }); 40 | 41 | // Replicache uses an empty "poke" message sent over some pubsub channel 42 | // to know when to pull changes from the server. There are many ways to 43 | // implement pokes. This sample app implements two different ways. 44 | // By default, we use Server-Sent Events. This is simple, cheap, and fast, 45 | // but requires a stateful server to keep the SSE channels open. For 46 | // serverless platforms we also support pokes via Supabase. See: 47 | // - https://doc.replicache.dev/deploy 48 | // - https://doc.replicache.dev/how-it-works#poke-optional 49 | // - https://github.com/supabase/realtime 50 | // - https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events 51 | const cancelReceiver = getPokeReceiver()(name, async () => r.pull()); 52 | setRep(r); 53 | 54 | return () => { 55 | cancelReceiver(); 56 | void r.close(); 57 | }; 58 | }, [name, ...Object.values(options)]); 59 | 60 | if (!rep) { 61 | return null; 62 | } 63 | 64 | return rep; 65 | } 66 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": true, 6 | "noUnusedLocals": true, 7 | "noUnusedParameters": true, 8 | "noImplicitReturns": true, 9 | "noFallthroughCasesInSwitch": true, 10 | "esModuleInterop": true, 11 | "importsNotUsedAsValues": "error", 12 | "declaration": true, 13 | "forceConsistentCasingInFileNames": true, 14 | "moduleResolution": "node", 15 | "outDir": "lib", 16 | "allowJs": true, 17 | // esnext for Object.fromEntries 18 | "lib": ["dom", "esnext"], 19 | "preserveSymlinks": true 20 | }, 21 | "include": ["src/**/*.ts"], 22 | "exclude": ["node_modules", "lib/**/*"] 23 | } 24 | --------------------------------------------------------------------------------