├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── data └── README.md ├── package.json ├── pnpm-lock.yaml ├── runtime ├── browser.js ├── bun.js ├── index.d.ts ├── node.js └── server.js ├── src ├── demo │ ├── +layout.ts │ ├── +page.svelte │ ├── app.html │ ├── bulk │ │ ├── +page.svelte │ │ └── Component.svelte │ ├── client.ts │ ├── frameworks │ │ ├── react │ │ │ ├── +page.svelte │ │ │ └── React.tsx │ │ ├── routes.ts │ │ ├── schema.ts │ │ ├── solid │ │ │ ├── +page.svelte │ │ │ ├── Solid.jsx │ │ │ └── tsconfig.json │ │ ├── svelte │ │ │ └── +page.svelte │ │ └── vanilla │ │ │ ├── +page.svelte │ │ │ └── vanilla.ts │ ├── library │ │ ├── +page.svelte │ │ ├── Tracks.svelte │ │ ├── library.ts │ │ ├── routes.ts │ │ └── schema.ts │ ├── server.ts │ ├── sortable │ │ ├── +page.svelte │ │ ├── components │ │ │ ├── Overlay.svelte │ │ │ ├── Sortable.svelte │ │ │ ├── Virtual.svelte │ │ │ ├── autoscroll.ts │ │ │ ├── draggable.ts │ │ │ ├── hold.ts │ │ │ ├── math.ts │ │ │ ├── object.ts │ │ │ ├── pointer.ts │ │ │ ├── throttle.ts │ │ │ └── touch.ts │ │ ├── routes.ts │ │ └── schema.ts │ ├── ssr │ │ ├── +page.server.ts │ │ ├── +page.svelte │ │ ├── routes.ts │ │ └── stores.ts │ ├── todo │ │ ├── +page.svelte │ │ ├── routes.ts │ │ └── schema.ts │ └── trpc.ts ├── lib │ ├── core │ │ ├── crstore.ts │ │ ├── reactive.ts │ │ └── types.ts │ ├── database │ │ ├── dialect.ts │ │ ├── encoder.ts │ │ ├── index.ts │ │ ├── json.ts │ │ ├── operations.ts │ │ ├── queue.ts │ │ └── schema.ts │ ├── index.ts │ ├── react.ts │ ├── solid.ts │ └── svelte.ts └── test │ ├── encoder.test.ts │ └── store.test.ts ├── static └── favicon.png ├── svelte.config.js ├── tsconfig.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /.svelte-kit 4 | /dist 5 | .env 6 | .env.* 7 | !.env.example 8 | vite.config.js.timestamp-* 9 | vite.config.ts.timestamp-* 10 | *.db* 11 | /data/* 12 | !/data/README.md -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /build 4 | /.svelte-kit 5 | /package 6 | .env 7 | .env.* 8 | !.env.example 9 | 10 | # Ignore files for PNPM, NPM and YARN 11 | pnpm-lock.yaml 12 | package-lock.json 13 | yarn.lock 14 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["prettier-plugin-svelte"], 3 | "overrides": [ 4 | { 5 | "files": "*.svelte", 6 | "options": { 7 | "parser": "svelte" 8 | } 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "Automagical", 4 | "Azarattum", 5 | "CRDT", 6 | "CRDTs", 7 | "crsql", 8 | "crsqlite", 9 | "crstore", 10 | "esbuild", 11 | "finitify", 12 | "fract", 13 | "fractindex", 14 | "froms", 15 | "IFNULL", 16 | "Introspector", 17 | "Kysely", 18 | "Lamport", 19 | "siteid", 20 | "Struct", 21 | "superstruct", 22 | "sveltekit", 23 | "todos", 24 | "trpc", 25 | "unsubbed", 26 | "vlcn" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Azarattum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CRStore 2 | 3 | Conflict-free replicated store. 4 | 5 | > WARNING: Still in development! Expect breaking changes! 6 | > 7 | > BREAKING (v0.20.0): Added support for React & Solid. For Svelte import `database` from `crstore/svelte`. Renamed `store` to `replicated`. 8 | > 9 | > BREAKING (v0.19.0): Updated `cr-sqlite` from v13 to v16. See [changelog](https://github.com/vlcn-io/cr-sqlite/releases) 10 | > 11 | > BREAKING (v0.18.0): If you want to support [older browsers](https://caniuse.com/mdn-api_navigator_locks) consider adding [navigator.locks polyfill](https://www.npmjs.com/package/navigator.locks) to your project. CRStore does **not** ship it since `0.18.0`! 12 | 13 | - ✨ Elegance of [Svelte](https://svelte.dev/) / [SolidJS](https://www.solidjs.com/) / [React](https://react.dev/) 14 | - 💪 Power of [SQLite](https://www.sqlite.org/index.html) 15 | - 🛡️ Safety with [Kysely](https://github.com/koskimas/kysely) 16 | - ⚡ CRDTs powered by [cr-sqlite](https://github.com/vlcn-io/cr-sqlite) 17 | - 🔮 Automagical schema using [superstruct](https://github.com/ianstormtaylor/superstruct) 18 | - 🤝 First class [tRPC](https://github.com/trpc/trpc) support 19 | - 🐇 Supports [bun:sqlite](https://bun.sh/docs/api/sqlite) (experimental) 20 | 21 | Install `crstore` and `superstruct` (for automatic schema): 22 | ```sh 23 | npm install crstore superstruct 24 | ``` 25 | 26 | ## Using CRStore 27 | 28 | To start using `CRStore` first you need to define a schema for your database. This is like a [Kysely schema](https://github.com/koskimas/kysely/blob/master/recipes/schemas.md), but defined with [superstruct](https://github.com/ianstormtaylor/superstruct), so we can have a runtime access to it. 29 | ```ts 30 | import { crr, primary } from "crstore"; 31 | 32 | // Struct that represents the table 33 | const todos = object({ 34 | id: string(), 35 | title: string(), 36 | text: string(), 37 | completed: boolean(), 38 | }); 39 | crr(todos); // Register table with conflict-free replicated relations 40 | primary(todos, "id"); // Define a primary key (can be multi-column) 41 | 42 | const schema = object({ todos }); 43 | ``` 44 | 45 | Now you can establish a database connection with your schema: 46 | ```ts 47 | import { database } from "crstore/svelte"; 48 | 49 | const { replicated } = database(schema); 50 | ``` 51 | > Note, that this example uses Svelte version (`replicated`). For React `database` function will return `useReplica` and `createReplica` for SolidJS. Learn more how to use `CRStore` with these frameworks [here](./src/demo/frameworks/). 52 | 53 | With the `replicated` function we can create arbitrary views to our database which are valid svelte stores. For example let's create a store that will have our entire `todos` table: 54 | ```ts 55 | const todos = replicated((db) => db.selectFrom("todos").selectAll()); 56 | ``` 57 | 58 | To mutate the data we can either call `.update` on the store or add built-in actions upon creation: 59 | ```ts 60 | const todos = replicated((db) => db.selectFrom("todos").selectAll(), { 61 | // Define actions for your store 62 | toggle(db, id: string) { 63 | return db 64 | .updateTable("todos") 65 | .set({ completed: sql`NOT(completed)` }) 66 | .where("id", "=", id) 67 | .execute(); 68 | }, 69 | remove(db, id: string) { 70 | return db.deleteFrom("todos").where("id", "=", id).execute(); 71 | }, 72 | }); 73 | 74 | // Call an update manually 75 | todos.update((db) => db.insertInto("todos").values({ ... }).execute()); 76 | // Call an action 77 | todos.toggle("id"); 78 | ``` 79 | 80 | We can simple iterate the store to render the results: 81 | > Note that the database loads asynchronously, so the store will contain an empty array util it loads. 82 | ```svelte 83 | {#each $todos as todo} 84 |

{todo.title}

85 |

{todo.text}

86 | {/each} 87 | ``` 88 | 89 | This we dynamically react to all the changes in our database even if we make them from a different store. Each store we create reacts only to changes in tables we have selected from. 90 | 91 | ## Connecting with tRPC 92 | 93 | You can provide custom handlers for your network layer upon initialization. `push` method is called when you make changes locally that need to be synchronized. `pull` is called when `crstore` wants to subscribe to any changes coming from the network. Let's say you have a `push` [tRPC mutation](https://trpc.io/docs/quickstart) and a `pull` [tRPC subscription](https://trpc.io/docs/subscriptions) then you can use them like so when connection to a database: 94 | ```ts 95 | const { replicated } = database(schema, { 96 | push: trpc.push.mutate, 97 | pull: trpc.pull.subscribe, 98 | }); 99 | ``` 100 | 101 | Then your server implementation would look something like this: 102 | ```ts 103 | import { database } from "crstore"; 104 | 105 | const { subscribe, merge } = database(schema); 106 | const { router, procedure } = initTRPC.create(); 107 | 108 | const app = router({ 109 | push: procedure.input(any()).mutation(({ input }) => merge(input)), 110 | pull: procedure 111 | .input(object({ version: number(), client: string() })) 112 | .subscription(({ input }) => 113 | observable(({ next }) => subscribe(["*"], next, input)) 114 | ), 115 | }); 116 | ``` 117 | 118 | > If you are using `vite-node` to run your server, you should add `define: { "import.meta.env.SSR": false }` to your vite config file. 119 | 120 | ## Advanced Usage 121 | 122 | ### Depend on other stores 123 | 124 | When creating a `crstore` you might want it to subscribe to some other stores. For example you can have a writable `query` store and a `search` crstore. Where `search` updates every time `query` updates. To do so you can use `.with(...stores)` syntax when creating a store. All the resolved dependencies will be passed to your SELECT callback. 125 | ```ts 126 | import { database } from "crstore/svelte"; 127 | import { writable } from "svelte/store"; 128 | 129 | const { replicated } = database(schema); 130 | 131 | const query = writable("hey"); 132 | const search = replicated.with(query)((db, query) => 133 | db.selectFrom("todos").where("text", "=", query).selectAll() 134 | ); 135 | ``` 136 | 137 | ### Specify custom paths 138 | 139 | If needed you can specify custom paths to `better-sqlite3` binding, `crsqlite` extension and `crsqlite-wasm` binary. To do so, provide `path` option upon `database` initialization: 140 | ```ts 141 | import { database } from "crstore/svelte"; 142 | 143 | const { replicated } = database(schema, { 144 | // These are the default values: 145 | paths: { 146 | wasm: "/sqlite.wasm", 147 | extension: "node_modules/@vlcn.io/crsqlite/build/Release/crsqlite.node", 148 | binding: undefined, 149 | } 150 | }); 151 | ``` 152 | 153 | ### Specify database name 154 | 155 | If you need to manage multiple databases you can specify `name` database option. This will be used as a filename on a server or a VFS path on a client. 156 | ```ts 157 | import { database } from "crstore/svelte"; 158 | 159 | const { replicated } = database(schema, { 160 | name: "data/example.db" 161 | }); 162 | ``` 163 | 164 | ### Specify a custom online checker 165 | 166 | `push` and `pull` capabilities rely on checking current online status. When available `navigator.onLine` is used by default. You have an option to override it by providing a custom online function. 167 | ```ts 168 | import { database } from "crstore/svelte"; 169 | 170 | const { replicated } = database(schema, { 171 | online: () => true // Always online 172 | }); 173 | ``` 174 | Note that this is only really needed if you use `pull` and `push` helpers. If your [server implementation](#connecting-with-trpc) uses `subscribe` and `merge` methods instead, the online checker is unnecessary (defaults to `false`). 175 | 176 | ### Apply updates without creating a store 177 | 178 | Use can apply any updates right after you have initialized your database connection by using the `update` function. If there are any stores initialized, they will also be updated if you change any tables they depend on. 179 | ```ts 180 | import { database } from "crstore"; 181 | 182 | const { update } = database(schema); 183 | update((db) => db.insertInto("todos").values({ ... })); 184 | ``` 185 | 186 | ### Access raw database connection 187 | 188 | Use can access the raw database connection. This can sometime be useful for debugging. Note that any mutations you do directly from the connection **will not trigger any reactive updates**! To mutate data safely please use [the `update` function](#apply-updates-without-creating-a-store) instead. 189 | 190 | ```ts 191 | import { database } from "crstore"; 192 | 193 | const { connection } = database(schema); 194 | const db = await connection; 195 | 196 | const data = await db.selectFrom("todos").selectAll().execute() 197 | console.log(data); 198 | ``` 199 | 200 | ### Nested JSON queries 201 | 202 | `crstore` provides support for nested JSON queries via it's own [JSON Kysely plugin](src/lib/database/json.ts). You can see how it's used in practice be looking at the [library demo](src/demo/library/library.ts). 203 | ```ts 204 | import { groupJSON } from "crstore"; 205 | 206 | const grouped = replicated((db) => 207 | db 208 | .selectFrom("tracks") 209 | .leftJoin("artists", "tracks.artist", "artists.id") 210 | .leftJoin("albums", "tracks.album", "albums.id") 211 | .select([ 212 | "albums.title as album", 213 | (qb) => 214 | // Here we aggregate all the tracks for the album 215 | groupJSON(qb, { 216 | id: "tracks.id", 217 | title: "tracks.title", 218 | artist: "artists.title", 219 | album: "albums.title", 220 | }).as("tracks"), 221 | ]) 222 | // `groupBy` is essential for the aggregation to work 223 | .groupBy("album") 224 | ); 225 | 226 | $grouped[0] // ↓ The type is inferred from `json` 227 | // { 228 | // album: string | null; 229 | // tracks: { 230 | // id: string; 231 | // title: string; 232 | // artist: string | null; 233 | // album: string | null; 234 | // }[] 235 | // } 236 | ``` 237 | 238 | ### Specify indexes in the schema 239 | You can specify one or more indexes for your tables. 240 | 241 | ```ts 242 | import { index } from "crstore"; 243 | 244 | const todos = object({ 245 | id: string(), 246 | title: string(), 247 | text: string(), 248 | completed: boolean(), 249 | }); 250 | index(todos, "title"); 251 | index(todos, "text", "completed"); // Multi-column index 252 | ``` 253 | 254 | ### Define a fractional index for a table 255 | `cr-sqlite` supports conflict free fractional indexing. To use them in `CRStore` first you should define table as ordered in your schema: 256 | 257 | ```ts 258 | import { ordered } from "crstore"; 259 | 260 | const todos = object({ 261 | id: string(), 262 | text: string(), 263 | completed: boolean(), 264 | collection: string(), 265 | order: string() 266 | }); 267 | // Sort by 'order' column in each 'collection' 268 | ordered(todos, "order", "collection"); 269 | ``` 270 | 271 | Then you can append or prepend items by putting the exported constants as your order value. 272 | ```ts 273 | import { APPEND, PREPEND } from "crstore"; 274 | 275 | db.insertInto("todos") 276 | .values({ 277 | id: "4321", 278 | text: "Hello", 279 | completed: false, 280 | collection: "1234", 281 | order: APPEND, 282 | }) 283 | .execute(); 284 | ``` 285 | 286 | To move an item you should update the `{you_table}_fractindex` virtual table with the `after_id` value. 287 | ```ts 288 | db 289 | .updateTable("todos_fractindex" as any) 290 | .set({ after_id: "2345" }) 291 | .where("id", "=", "4321") 292 | .execute(); 293 | ``` 294 | 295 | Check out the [sortable example](src/demo/sortable) for more details. 296 | 297 | ### Setup server side rendering 298 | When defining your database set `ssr` option to `true`: 299 | ```ts 300 | const { replicated, merge, subscribe } = database(schema, { 301 | ssr: true, 302 | }); 303 | ``` 304 | 305 | Add `+page.server.ts` file to preload your data with SvelteKit. You can call `.then` on a store to get a promise with its latest state (the `await` keyword would achieve the same effect). Pass down the value of your store to your page like this: 306 | ```ts 307 | import type { PageServerLoad } from "./$types"; 308 | import { items } from "./stores"; 309 | 310 | export const load: PageServerLoad = async () => ({ ssr: await items }); 311 | ``` 312 | 313 | In your `+page.svelte` you render the server-side data until client database is ready. 314 | ```svelte 315 | 322 | 323 | {#each ready($items) ? $items : data.ssr as item} 324 |
  • {item.data}
  • 325 | {/each} 326 | ``` 327 | 328 | Check out the [ssr example](src/demo/ssr/) for complete implementation. 329 | 330 | ### Error handling 331 | You can add an error handler to your database connection. 332 | ```ts 333 | const { replicated } = database(schema, { 334 | error: (reason) => console.log(reason), 335 | }); 336 | ``` 337 | 338 | It will handle all the errors that happen during subscriber callbacks. 339 | -------------------------------------------------------------------------------- /data/README.md: -------------------------------------------------------------------------------- 1 | # Data 2 | 3 | This is a directory for database files which are used by the sync server in the [demo app](../src/demo). 4 | 5 | This readme file is primarily used to commit this empty `data` directory to git. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crstore", 3 | "version": "0.24.0", 4 | "description": "Conflict-free replicated store.", 5 | "license": "MIT", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/Azarattum/CRStore" 9 | }, 10 | "author": { 11 | "name": "Azarattum", 12 | "homepage": "https://github.com/Azarattum" 13 | }, 14 | "keywords": [ 15 | "store", 16 | "replicated", 17 | "sync", 18 | "conflict free", 19 | "CRDT", 20 | "sqlite", 21 | "crsqlite", 22 | "kysely", 23 | "trpc", 24 | "svelte", 25 | "solid", 26 | "react" 27 | ], 28 | "scripts": { 29 | "dev": "vite dev", 30 | "build": "svelte-kit sync && svelte-package && tsc-esm-fix --target='dist'", 31 | "test": "vitest", 32 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 33 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 34 | "lint": "prettier --plugin-search-dir . --check .", 35 | "format": "prettier --plugin-search-dir . --write ." 36 | }, 37 | "devDependencies": { 38 | "@merged/react-solid": "^1.0.1", 39 | "@rollup/pluginutils": "^5.1.4", 40 | "@sveltejs/adapter-static": "^3.0.8", 41 | "@sveltejs/kit": "2.17.1", 42 | "@sveltejs/package": "2.3.10", 43 | "@sveltejs/vite-plugin-svelte": "^5.0.3", 44 | "@trpc/client": "^10.45.2", 45 | "@trpc/server": "^10.45.2", 46 | "@types/better-sqlite3": "^7.6.12", 47 | "@types/react": "^19.0.8", 48 | "@types/react-dom": "^19.0.3", 49 | "@types/ws": "^8.5.14", 50 | "prettier": "^3.5.0", 51 | "prettier-plugin-svelte": "^3.3.3", 52 | "react": "^19.0.0", 53 | "react-dom": "^19.0.0", 54 | "solid-js": "^1.9.4", 55 | "superstruct": "^2.0.2", 56 | "svelte": "^5.19.9", 57 | "svelte-check": "^4.1.4", 58 | "tsc-esm-fix": "^3.1.2", 59 | "tslib": "^2.8.1", 60 | "typescript": "^5.7.3", 61 | "vite": "^6.1.0", 62 | "vite-plugin-solid": "^2.11.1", 63 | "vitest": "^3.0.5", 64 | "ws": "^8.18.0" 65 | }, 66 | "type": "module", 67 | "dependencies": { 68 | "@types/bun": "^1.2.2", 69 | "@vlcn.io/crsqlite": "0.16.3", 70 | "@vlcn.io/crsqlite-wasm": "0.16.0", 71 | "better-sqlite3": "^11.8.1", 72 | "kysely": "^0.27.5" 73 | }, 74 | "files": [ 75 | "dist", 76 | "runtime" 77 | ], 78 | "types": "./dist/index.d.ts", 79 | "exports": { 80 | ".": { 81 | "types": "./dist/index.d.ts", 82 | "default": "./dist/index.js" 83 | }, 84 | "./svelte": { 85 | "types": "./dist/svelte.d.ts", 86 | "default": "./dist/svelte.js" 87 | }, 88 | "./react": { 89 | "types": "./dist/react.d.ts", 90 | "default": "./dist/react.js" 91 | }, 92 | "./solid": { 93 | "types": "./dist/solid.d.ts", 94 | "default": "./dist/solid.js" 95 | }, 96 | "./runtime": { 97 | "types": "./runtime/index.d.ts", 98 | "node": "./runtime/server.js", 99 | "default": "./runtime/browser.js" 100 | } 101 | }, 102 | "optionalDependencies": { 103 | "react": "^19.0.0", 104 | "solid-js": "^1.9.4", 105 | "svelte": "^5.19.9" 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /runtime/browser.js: -------------------------------------------------------------------------------- 1 | import wasmUrl from "@vlcn.io/crsqlite-wasm/crsqlite.wasm?url"; 2 | import wasmSqlite from "@vlcn.io/crsqlite-wasm"; 3 | 4 | /** 5 | * @param {string} file 6 | * @param {{ wasm?: string; }} paths 7 | * @returns {Promise<{ database: any, env: "browser" }>} 8 | */ 9 | export async function load(file, paths) { 10 | const sqlite = await wasmSqlite(() => paths.wasm || wasmUrl); 11 | const database = await sqlite.open(file); 12 | return { database, env: "browser" }; 13 | } 14 | -------------------------------------------------------------------------------- /runtime/bun.js: -------------------------------------------------------------------------------- 1 | import { extensionPath } from "@vlcn.io/crsqlite"; 2 | import { platform } from "os"; 3 | 4 | let isSQLiteUnchanged = true; 5 | 6 | /** 7 | * @param {string} file 8 | * @param {{ binding?: string; extension?: string; }} paths 9 | * @returns {Promise<{ database: any, env: "bun" }>} 10 | */ 11 | export async function load(file, paths) { 12 | const { Database: SQLite } = await import("bun:sqlite"); 13 | if (platform() === "darwin") { 14 | paths.binding ??= "/opt/homebrew/opt/sqlite/lib/libsqlite3.dylib"; 15 | } 16 | 17 | if (paths.binding && isSQLiteUnchanged) { 18 | SQLite.setCustomSQLite(paths.binding); 19 | isSQLiteUnchanged = false; 20 | } 21 | 22 | const database = new SQLite(file); 23 | database.run("PRAGMA journal_mode = wal"); 24 | database.loadExtension(paths.extension || extensionPath); 25 | 26 | const prepare = database.prepare.bind(database); 27 | database.prepare = (...args) => 28 | Object.assign(prepare(...args), { reader: true }); 29 | 30 | return { database, env: "bun" }; 31 | } 32 | -------------------------------------------------------------------------------- /runtime/index.d.ts: -------------------------------------------------------------------------------- 1 | export function load( 2 | file: string, 3 | paths: { 4 | extension?: string; 5 | binding?: string; 6 | wasm?: string; 7 | }, 8 | ): Promise<{ 9 | database: any; 10 | env: "browser" | "node" | "bun"; 11 | }>; 12 | -------------------------------------------------------------------------------- /runtime/node.js: -------------------------------------------------------------------------------- 1 | import SQLite from "better-sqlite3"; 2 | import { extensionPath } from "@vlcn.io/crsqlite"; 3 | 4 | /** 5 | * @param {string} file 6 | * @param {{ binding?: string; extension?: string; }} paths 7 | * @returns {Promise<{ database: any, env: "node" }>} 8 | */ 9 | export async function load(file, paths) { 10 | const database = new SQLite(file, { nativeBinding: paths.binding }); 11 | database.pragma("journal_mode = WAL"); 12 | database.loadExtension(paths.extension || extensionPath); 13 | return { database, env: "node" }; 14 | } 15 | -------------------------------------------------------------------------------- /runtime/server.js: -------------------------------------------------------------------------------- 1 | import { load as loadBun } from "./bun.js"; 2 | import { load as loadNode } from "./node.js"; 3 | 4 | export const load = process.versions.bun ? loadBun : loadNode; 5 | -------------------------------------------------------------------------------- /src/demo/+layout.ts: -------------------------------------------------------------------------------- 1 | export const prerender = true; 2 | export const trailingSlash = "always"; 3 | -------------------------------------------------------------------------------- /src/demo/+page.svelte: -------------------------------------------------------------------------------- 1 |

    Demos:

    2 | 14 | -------------------------------------------------------------------------------- /src/demo/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | %sveltekit.head% 8 | 9 | 10 |
    %sveltekit.body%
    11 | 12 | 13 | -------------------------------------------------------------------------------- /src/demo/bulk/+page.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 | {#each Array.from({ length: 100 }).map((_, i) => i) as _} 6 | 7 | {/each} 8 | -------------------------------------------------------------------------------- /src/demo/bulk/Component.svelte: -------------------------------------------------------------------------------- 1 | 18 | 19 | 28 | 29 | 30 | {#if $data[0]} 31 | {$data[0].id} 32 | {(i === 99 && console.timeEnd("Time to Render"), "")} 33 | {:else} 34 | ? 35 | {(i === 0 && console.time("Time to Render"), "")} 36 | {/if} 37 |   38 | 39 | -------------------------------------------------------------------------------- /src/demo/client.ts: -------------------------------------------------------------------------------- 1 | import { createTRPCProxyClient, createWSClient, wsLink } from "@trpc/client"; 2 | import type { App } from "./server"; 3 | 4 | const proxy = new Proxy(() => {}, { 5 | apply: () => proxy, 6 | get: () => proxy, 7 | set: () => proxy, 8 | }) as never; 9 | 10 | export const trpc = 11 | "window" in globalThis 12 | ? createTRPCProxyClient({ 13 | links: [ 14 | wsLink({ 15 | client: createWSClient({ 16 | url: "ws://localhost:5173/trpc", 17 | }), 18 | }), 19 | ], 20 | }) 21 | : proxy; 22 | -------------------------------------------------------------------------------- /src/demo/frameworks/react/+page.svelte: -------------------------------------------------------------------------------- 1 | 12 | 13 |
    14 | -------------------------------------------------------------------------------- /src/demo/frameworks/react/React.tsx: -------------------------------------------------------------------------------- 1 | import React, { useCallback, useState } from "react"; 2 | import { database } from "$lib/react"; 3 | import { trpc } from "../../client"; 4 | import { schema } from "../schema"; 5 | 6 | const { useReplica } = database(schema, { 7 | name: "frameworks.db", 8 | push: trpc.frameworks.push.mutate, 9 | pull: trpc.frameworks.pull.subscribe, 10 | }); 11 | 12 | export const Component: React.FC = () => { 13 | const [filter, setFilter] = useState(""); 14 | const items = useReplica( 15 | (db, filter) => 16 | db 17 | .selectFrom("items") 18 | .where("text", "like", filter + "%") 19 | .selectAll(), 20 | { 21 | create(db, text: string) { 22 | return db.insertInto("items").values({ text }).execute(); 23 | }, 24 | }, 25 | [filter], 26 | ); 27 | 28 | const handleCreate = useCallback( 29 | (e: React.KeyboardEvent) => { 30 | if (e.code === "Enter") items.create(e.currentTarget.value); 31 | }, 32 | [], 33 | ); 34 | 35 | return ( 36 | 37 |

    React

    38 |
      39 | {items.map((x) => ( 40 |
    1. {x.text}
    2. 41 | ))} 42 |
    43 | 44 | setFilter(e.currentTarget.value)} 49 | /> 50 |
    51 | ); 52 | }; 53 | -------------------------------------------------------------------------------- /src/demo/frameworks/routes.ts: -------------------------------------------------------------------------------- 1 | import { any, number, object, string } from "superstruct"; 2 | import { observable } from "@trpc/server/observable"; 3 | import { router, procedure } from "../trpc"; 4 | import { database } from "../../lib"; 5 | import { schema } from "./schema"; 6 | 7 | const { subscribe, merge, close } = database(schema, { 8 | name: "data/frameworks.db", 9 | }); 10 | 11 | const routes = router({ 12 | push: procedure.input(any()).mutation(({ input }) => merge(input)), 13 | pull: procedure 14 | .input(object({ version: number(), client: string() })) 15 | .subscription(({ input }) => 16 | observable(({ next }) => subscribe(["*"], next, input)), 17 | ), 18 | }); 19 | 20 | export { routes, close }; 21 | -------------------------------------------------------------------------------- /src/demo/frameworks/schema.ts: -------------------------------------------------------------------------------- 1 | import { object, string } from "superstruct"; 2 | import { crr, primary } from "../../lib"; 3 | 4 | const items = object({ text: string() }); 5 | primary(items, "text"); 6 | crr(items); 7 | 8 | export const schema = object({ items }); 9 | -------------------------------------------------------------------------------- /src/demo/frameworks/solid/+page.svelte: -------------------------------------------------------------------------------- 1 | 11 | 12 |
    13 | -------------------------------------------------------------------------------- /src/demo/frameworks/solid/Solid.jsx: -------------------------------------------------------------------------------- 1 | import { createSignal, Index } from "solid-js"; 2 | import { database } from "$lib/solid"; 3 | import { trpc } from "../../client"; 4 | import { schema } from "../schema"; 5 | 6 | const { createReplica } = database(schema, { 7 | name: "frameworks.db", 8 | push: trpc.frameworks.push.mutate, 9 | pull: trpc.frameworks.pull.subscribe, 10 | }); 11 | 12 | export const Component = () => { 13 | const [filter, setFilter] = createSignal(""); 14 | const items = createReplica( 15 | (db, filter) => 16 | db 17 | .selectFrom("items") 18 | .where("text", "like", filter + "%") 19 | .selectAll(), 20 | { 21 | create(db, text) { 22 | return db.insertInto("items").values({ text }).execute(); 23 | }, 24 | }, 25 | [filter], 26 | ); 27 | 28 | return ( 29 | <> 30 |

    Solid

    31 |
      32 | {(x) =>
    1. {x().text}
    2. }
      33 |
    34 | { 38 | if (e.code === "Enter") items.create(e.currentTarget.value); 39 | }} 40 | /> 41 | setFilter(e.currentTarget.value)} 45 | /> 46 | 47 | ); 48 | }; 49 | -------------------------------------------------------------------------------- /src/demo/frameworks/solid/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../../tsconfig.json", 3 | "compilerOptions": { 4 | "jsx": "preserve", 5 | "jsxImportSource": "solid-js" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/demo/frameworks/svelte/+page.svelte: -------------------------------------------------------------------------------- 1 | 33 | 34 |

    Svelte

    35 |
      36 | {#each $items as x} 37 |
    1. {x.text}
    2. 38 | {/each} 39 |
    40 | 41 | 42 | -------------------------------------------------------------------------------- /src/demo/frameworks/vanilla/+page.svelte: -------------------------------------------------------------------------------- 1 | 7 | 8 |

    Vanilla

    9 |
      10 | 11 | 12 | -------------------------------------------------------------------------------- /src/demo/frameworks/vanilla/vanilla.ts: -------------------------------------------------------------------------------- 1 | import { trpc } from "../../client"; 2 | import { schema } from "../schema"; 3 | import { database } from "$lib"; 4 | 5 | export function load() { 6 | const { replica } = database(schema, { 7 | name: "frameworks.db", 8 | push: trpc.frameworks.push.mutate, 9 | pull: trpc.frameworks.pull.subscribe, 10 | }); 11 | 12 | const items = replica( 13 | (db, filter) => 14 | db 15 | .selectFrom("items") 16 | .where("text", "like", filter + "%") 17 | .selectAll(), 18 | { 19 | create(db, text: string) { 20 | return db.insertInto("items").values({ text }).execute(); 21 | }, 22 | }, 23 | [""], 24 | ); 25 | 26 | const createElement = document.getElementById("create"); 27 | createElement?.addEventListener("keydown", (e) => { 28 | if (e.code === "Enter") items.create((e.currentTarget as any).value); 29 | }); 30 | 31 | const filterElement = document.getElementById("filter"); 32 | filterElement?.addEventListener("input", (e) => { 33 | items.bind([(e.currentTarget as any).value]); 34 | }); 35 | 36 | const listElement = document.getElementById("list")!; 37 | items.subscribe((items) => { 38 | listElement.innerHTML = ""; 39 | items.forEach((x) => { 40 | const liElement = document.createElement("li"); 41 | liElement.textContent = x.text; 42 | listElement.appendChild(liElement); 43 | }); 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /src/demo/library/+page.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 |
      23 | 24 | 25 |
      26 |
      27 | 28 | 29 |
      30 |
      31 | 32 | 33 |
      34 |
      35 | 36 | 41 | 46 | 47 |
      48 |
      49 | 54 | 59 | 60 |
      61 | 62 |

      Tracks:

      63 | 64 | 65 |

      Artists:

      66 |
        67 | {#each $artists as artist} 68 |
      • {artist.title}
      • 69 | {/each} 70 |
      71 | 72 |

      Tracks by album:

      73 | {#each $grouped as { album, tracks }} 74 |
      75 | {album} 76 | 77 |
      78 | {/each} 79 | 80 |

      Tracks by playlist:

      81 | {#each $organized as { playlist, tracks }} 82 |
      83 | {playlist} 84 | 85 |
      86 | {/each} 87 | 88 | 99 | -------------------------------------------------------------------------------- /src/demo/library/Tracks.svelte: -------------------------------------------------------------------------------- 1 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | {#each tracks as track (track.id)} 16 | 17 | 18 | 19 | 20 | 21 | {/each} 22 | 23 |
      ArtistTitleAlbum
      {track.artist || "-"}{track.title}{track.album || "-"}
      24 | 25 | 48 | -------------------------------------------------------------------------------- /src/demo/library/library.ts: -------------------------------------------------------------------------------- 1 | import { database } from "../../lib/svelte"; 2 | import { groupJSON } from "../../lib"; 3 | import { schema } from "./schema"; 4 | import { trpc } from "../client"; 5 | 6 | const { replicated } = database(schema, { 7 | name: "library.db", 8 | push: trpc.library.push.mutate, 9 | pull: trpc.library.pull.subscribe, 10 | }); 11 | 12 | const all = replicated( 13 | (db) => 14 | db 15 | .selectFrom("tracks") 16 | .leftJoin("artists", "tracks.artist", "artists.id") 17 | .leftJoin("albums", "tracks.album", "albums.id") 18 | .select([ 19 | "tracks.id as id", 20 | "tracks.title as title", 21 | "artists.title as artist", 22 | "albums.title as album", 23 | ]), 24 | { 25 | add(db, title: string, artistId: string, albumId: string) { 26 | const id = [...title, ...artistId, ...albumId] 27 | .map((x) => x.charCodeAt(0)) 28 | .join(""); 29 | return db 30 | .insertInto("tracks") 31 | .values({ id, title, artist: artistId, album: albumId }) 32 | .execute(); 33 | }, 34 | }, 35 | ); 36 | 37 | const artists = replicated((db) => db.selectFrom("artists").selectAll(), { 38 | add(db, title: string) { 39 | const id = [...title].map((x) => x.charCodeAt(0)).join(""); 40 | return db.insertInto("artists").values({ id, title }).execute(); 41 | }, 42 | }); 43 | 44 | const albums = replicated((db) => db.selectFrom("albums").selectAll(), { 45 | add(db, title: string) { 46 | const id = [...title].map((x) => x.charCodeAt(0)).join(""); 47 | return db.insertInto("albums").values({ id, title }).execute(); 48 | }, 49 | }); 50 | 51 | const playlists = replicated((db) => db.selectFrom("playlists").selectAll(), { 52 | add(db, title: string) { 53 | const id = [...title].map((x) => x.charCodeAt(0)).join(""); 54 | return db.insertInto("playlists").values({ id, title }).execute(); 55 | }, 56 | async link(db, track: string, playlist: string) { 57 | const id = Math.random().toString(36).slice(2); 58 | const max = await db 59 | .selectFrom("tracksByPlaylist") 60 | .where("playlist", "=", playlist) 61 | .select((db) => db.fn.max("order").as("order")) 62 | .executeTakeFirst(); 63 | // Append "|" to make the next item 64 | const order = max ? (max.order || "") + "|" : "|"; 65 | return db 66 | .insertInto("tracksByPlaylist") 67 | .values({ id, track, playlist, order }) 68 | .execute(); 69 | }, 70 | }); 71 | 72 | const grouped = replicated((db) => 73 | db 74 | .selectFrom("tracks") 75 | .leftJoin("artists", "tracks.artist", "artists.id") 76 | .leftJoin("albums", "tracks.album", "albums.id") 77 | .select([ 78 | "albums.title as album", 79 | (qb) => 80 | groupJSON(qb, { 81 | id: "tracks.id", 82 | title: "tracks.title", 83 | artist: "artists.title", 84 | album: "albums.title", 85 | }).as("tracks"), 86 | ]) 87 | .groupBy("album"), 88 | ); 89 | 90 | const organized = replicated((db) => 91 | db 92 | .selectFrom((db) => 93 | db 94 | .selectFrom("playlists") 95 | .innerJoin("tracksByPlaylist", "playlist", "playlists.id") 96 | .innerJoin("tracks", "track", "tracks.id") 97 | .leftJoin("artists", "tracks.artist", "artists.id") 98 | .leftJoin("albums", "tracks.album", "albums.id") 99 | .select([ 100 | "playlists.title as playlist", 101 | "tracksByPlaylist.id as id", 102 | "tracks.title as title", 103 | "artists.title as artist", 104 | "albums.title as album", 105 | ]) 106 | .orderBy("order") 107 | .as("data"), 108 | ) 109 | .select([ 110 | "playlist", 111 | (qb) => 112 | groupJSON(qb, { 113 | id: "id", 114 | title: "title", 115 | artist: "artist", 116 | album: "album", 117 | }).as("tracks"), 118 | ]) 119 | .groupBy("playlist"), 120 | ); 121 | 122 | export { all, artists, albums, playlists, grouped, organized }; 123 | -------------------------------------------------------------------------------- /src/demo/library/routes.ts: -------------------------------------------------------------------------------- 1 | import { any, array, number, object, string } from "superstruct"; 2 | import { observable } from "@trpc/server/observable"; 3 | import { router, procedure } from "../trpc"; 4 | import { database } from "../../lib"; 5 | import { schema } from "./schema"; 6 | 7 | const { subscribe, merge, close } = database(schema, { 8 | name: "data/library.db", 9 | }); 10 | 11 | const routes = router({ 12 | push: procedure.input(any()).mutation(({ input }) => merge(input)), 13 | pull: procedure 14 | .input(object({ version: number(), client: string() })) 15 | .subscription(({ input }) => 16 | observable(({ next }) => subscribe(["*"], next, input)), 17 | ), 18 | }); 19 | 20 | export { routes, close }; 21 | -------------------------------------------------------------------------------- /src/demo/library/schema.ts: -------------------------------------------------------------------------------- 1 | import { crr, primary, index } from "../../lib"; 2 | import { object, string } from "superstruct"; 3 | 4 | const tracks = object({ 5 | id: string(), 6 | title: string(), 7 | artist: string(), 8 | album: string(), 9 | }); 10 | crr(tracks); 11 | primary(tracks, "id"); 12 | 13 | const artists = object({ 14 | id: string(), 15 | title: string(), 16 | }); 17 | crr(artists); 18 | primary(artists, "id"); 19 | 20 | const albums = object({ 21 | id: string(), 22 | title: string(), 23 | }); 24 | crr(albums); 25 | primary(albums, "id"); 26 | 27 | const playlists = object({ 28 | id: string(), 29 | title: string(), 30 | }); 31 | crr(playlists); 32 | primary(playlists, "id"); 33 | 34 | const tracksByPlaylist = object({ 35 | id: string(), 36 | track: string(), 37 | playlist: string(), 38 | order: string(), 39 | }); 40 | crr(tracksByPlaylist); 41 | primary(tracksByPlaylist, "id"); 42 | index(tracksByPlaylist, "order"); 43 | index(tracksByPlaylist, "playlist"); 44 | 45 | const schema = object({ tracks, artists, albums, playlists, tracksByPlaylist }); 46 | 47 | export { schema }; 48 | -------------------------------------------------------------------------------- /src/demo/server.ts: -------------------------------------------------------------------------------- 1 | import { routes as frameworksRoutes } from "./frameworks/routes"; 2 | import { routes as sortableRoutes } from "./sortable/routes"; 3 | import { routes as libraryRoutes } from "./library/routes"; 4 | import { routes as todoRoutes } from "./todo/routes"; 5 | import { routes as ssrRoutes } from "./ssr/routes"; 6 | import { router } from "./trpc"; 7 | 8 | const app = router({ 9 | ssr: ssrRoutes, 10 | todo: todoRoutes, 11 | library: libraryRoutes, 12 | sortable: sortableRoutes, 13 | frameworks: frameworksRoutes, 14 | }); 15 | 16 | export { app as router }; 17 | export type App = typeof app; 18 | -------------------------------------------------------------------------------- /src/demo/sortable/+page.svelte: -------------------------------------------------------------------------------- 1 | 78 | 79 | 80 | {#if $lists} 81 |
      82 | 83 | 84 | 85 | 86 |
      87 | 88 | {#each $lists as list, i} 89 |

      {list.title}

      90 |
        91 | item.id} 94 | let:item 95 | on:sort={({ detail }) => lists.move(i, detail.from, detail.to)} 96 | animation={150} 97 | > 98 |
        99 | {item.data} 100 | {item.order} 101 |
        102 |
        103 |
      104 | {/each} 105 | {:else} 106 | Loading... 107 | {/if} 108 | 109 | 125 | -------------------------------------------------------------------------------- /src/demo/sortable/components/Overlay.svelte: -------------------------------------------------------------------------------- 1 | 69 | 70 | 89 | 90 | 94 | 95 | 125 | -------------------------------------------------------------------------------- /src/demo/sortable/components/Sortable.svelte: -------------------------------------------------------------------------------- 1 | 81 | 82 | 96 | 97 | 98 | -------------------------------------------------------------------------------- /src/demo/sortable/components/Virtual.svelte: -------------------------------------------------------------------------------- 1 | 115 | 116 |
      117 |
        118 | {#each slice as item, i (item.key)} 119 |
      • 120 | 121 |
      • 122 | {/each} 123 |
      124 |
      125 | 126 | 142 | -------------------------------------------------------------------------------- /src/demo/sortable/components/autoscroll.ts: -------------------------------------------------------------------------------- 1 | import { position, type Point } from "./pointer"; 2 | 3 | export default function autoscroll( 4 | node: HTMLElement, 5 | { 6 | axis = "both", 7 | threshold = 64, 8 | enabled = false, 9 | trigger = "auto", 10 | }: AutoScrollOptions = {}, 11 | ) { 12 | let bounds: DOMRect; 13 | const direction = { x: 0, y: 0 }; 14 | const enable = () => toggle(true); 15 | const disable = () => toggle(false); 16 | 17 | function update({ x, y }: Point) { 18 | if (!enabled) return; 19 | let dx = 0; 20 | let dy = 0; 21 | 22 | if (axis !== "x") { 23 | if (y - bounds.top < threshold) { 24 | dy = -(bounds.top - y + threshold); 25 | } else if (y > bounds.bottom - threshold) { 26 | dy = y - bounds.bottom + threshold; 27 | } 28 | } 29 | if (axis !== "y") { 30 | if (x - bounds.left < threshold) { 31 | dx = -(bounds.left - x + threshold); 32 | } else if (x > bounds.right - threshold) { 33 | dx = x - bounds.right + threshold; 34 | } 35 | } 36 | dy /= threshold; 37 | dx /= threshold; 38 | 39 | const wasZero = !direction.x && !direction.y; 40 | const isNotZero = dx || dy; 41 | direction.x = ease(dx); 42 | direction.y = ease(dy); 43 | 44 | if (wasZero && isNotZero) scroll(); 45 | } 46 | 47 | function ease(value: number) { 48 | if (!value) return 0; 49 | let temp = Math.abs(value); 50 | if (temp < 1) temp = (Math.cos(Math.PI * (temp + 1)) + 1) / 2; 51 | else temp = Math.log(temp) / 3 + 1; 52 | 53 | return temp * Math.sign(value); 54 | } 55 | 56 | function scroll() { 57 | let then = Date.now(); 58 | const scroll = () => { 59 | if (!enabled) return; 60 | if (!direction.x && !direction.y) return; 61 | const now = Date.now(); 62 | const elapsed = now - then; 63 | then = now; 64 | 65 | node.scrollBy(direction.x * elapsed, direction.y * elapsed); 66 | requestAnimationFrame(scroll); 67 | }; 68 | requestAnimationFrame(scroll); 69 | } 70 | 71 | let unsubscribe = () => {}; 72 | function toggle(state = enabled) { 73 | enabled = state || trigger === "always"; 74 | if (enabled) { 75 | bounds = node.getBoundingClientRect(); 76 | unsubscribe = position.subscribe(update); 77 | } else { 78 | direction.x = 0; 79 | direction.y = 0; 80 | unsubscribe(); 81 | } 82 | } 83 | 84 | if (trigger === "auto") { 85 | node.addEventListener("dragstart", enable); 86 | node.addEventListener("dragend", disable); 87 | } 88 | toggle(); 89 | 90 | return { 91 | update(config: AutoScrollOptions) { 92 | if (config.enabled != null) toggle(config.enabled); 93 | }, 94 | destroy() { 95 | node.removeEventListener("dragstart", enable); 96 | node.removeEventListener("dragend", disable); 97 | trigger = "none"; 98 | toggle(false); 99 | }, 100 | }; 101 | } 102 | 103 | interface AutoScrollOptions { 104 | enabled?: boolean; 105 | threshold?: number; 106 | axis?: "both" | "x" | "y"; 107 | trigger?: "auto" | "always" | "none"; 108 | } 109 | 110 | declare global { 111 | namespace svelte.JSX { 112 | // @ts-ignore 113 | interface HTMLAttributes { 114 | autoscroll?: boolean | "true" | "false" | null; 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/demo/sortable/components/draggable.ts: -------------------------------------------------------------------------------- 1 | import { position, type Point } from "./pointer"; 2 | import { createEffect } from "./Overlay.svelte"; 3 | import { lock, unlock } from "./touch"; 4 | 5 | export default function draggable( 6 | node: HTMLElement, 7 | { 8 | trigger = "touchstart", 9 | easing = "ease", 10 | duration = 300, 11 | mode = "self", 12 | axis = "both", 13 | }: DraggableOptions = {}, 14 | ) { 15 | const element = (event: Event) => { 16 | let target = ( 17 | mode === "children" ? event.target : node 18 | ) as HTMLElement | null; 19 | 20 | if (event instanceof DragEvent) return target; 21 | while (target && !target.draggable) { 22 | target = target?.parentElement || null; 23 | } 24 | 25 | return target; 26 | }; 27 | 28 | const simulate = (event: any) => { 29 | lock(); 30 | const { clientX, clientY } = event.changedTouches?.[0] || event; 31 | element(event)?.dispatchEvent( 32 | new DragEvent("dragstart", { 33 | clientX, 34 | clientY, 35 | bubbles: true, 36 | }), 37 | ); 38 | }; 39 | 40 | function grab(event: DragEvent) { 41 | const target = element(event); 42 | if (!target?.draggable) return; 43 | event.preventDefault(); 44 | 45 | const effect = createEffect(target); 46 | requestAnimationFrame(() => { 47 | effect.setAttribute("dragging", "true"); 48 | }); 49 | 50 | const initial = target.getBoundingClientRect(); 51 | const offset = { 52 | x: axis === "y" ? 0 : event.clientX || 0, 53 | y: axis === "x" ? 0 : event.clientY || 0, 54 | }; 55 | target.style.visibility = "hidden"; 56 | target.draggable = false; 57 | 58 | const unsubscribe = position.subscribe(drag(effect, offset)); 59 | const stopHandler = (event: Event) => { 60 | const callback = new Event("dragend", event) as DraggedEvent; 61 | callback.retract = (override) => { 62 | if (override === undefined) override = target; 63 | retract(override, effect, initial)(); 64 | }; 65 | 66 | node.dispatchEvent(callback); 67 | if (!callback.defaultPrevented) { 68 | requestAnimationFrame(() => { 69 | callback.retract(); 70 | }); 71 | } 72 | 73 | removeEventListener("pointercancel", stopHandler); 74 | removeEventListener("pointerup", stopHandler); 75 | unsubscribe(); 76 | }; 77 | 78 | addEventListener("pointercancel", stopHandler); 79 | addEventListener("pointerup", stopHandler); 80 | 81 | event.preventDefault = () => { 82 | stopHandler(event); 83 | }; 84 | } 85 | 86 | function drag(effect: HTMLElement, offset: Point) { 87 | return ({ x, y }: Point) => { 88 | x = (axis === "y" ? 0 : x) - offset.x; 89 | y = (axis === "x" ? 0 : y) - offset.y; 90 | effect.style.transform = `translate3d(${x}px, ${y}px, 0)`; 91 | }; 92 | } 93 | 94 | function retract( 95 | target: HTMLElement | null, 96 | effect: HTMLElement, 97 | initial: Point, 98 | ) { 99 | return () => { 100 | unlock(); 101 | let props: PropertyIndexedKeyframes = { opacity: "0" }; 102 | if (target && document.contains(target)) { 103 | const desired = target.getBoundingClientRect(); 104 | const dx = desired.x - initial.x; 105 | const dy = desired.y - initial.y; 106 | props = { transform: `translate3d(${dx}px, ${dy}px, 0)` }; 107 | } 108 | 109 | effect.removeAttribute("dragging"); 110 | const animation = effect.animate(props, { duration, easing }); 111 | const complete = () => { 112 | effect.remove(); 113 | if (target) { 114 | target.style.visibility = ""; 115 | target.draggable = true; 116 | } 117 | animation.removeEventListener("finish", complete); 118 | animation.removeEventListener("cancel", complete); 119 | animation.removeEventListener("remove", complete); 120 | }; 121 | 122 | animation.addEventListener("finish", complete); 123 | animation.addEventListener("cancel", complete); 124 | animation.addEventListener("remove", complete); 125 | }; 126 | } 127 | 128 | if (mode === "self") node.draggable = true; 129 | node.addEventListener(trigger, simulate); 130 | node.addEventListener("dragstart", grab); 131 | return { 132 | destroy() { 133 | node.removeEventListener(trigger, simulate); 134 | node.removeEventListener("dragstart", grab); 135 | }, 136 | }; 137 | } 138 | 139 | interface DraggableOptions { 140 | easing?: string; 141 | trigger?: string; 142 | duration?: number; 143 | axis?: "both" | "x" | "y"; 144 | mode?: "self" | "children"; 145 | } 146 | 147 | declare global { 148 | namespace svelte.JSX { 149 | // @ts-ignore 150 | interface HTMLAttributes { 151 | ondragend?: (event: DraggedEvent) => void; 152 | } 153 | } 154 | 155 | interface DraggedEvent extends Event { 156 | retract: (target?: HTMLElement | null) => void; 157 | canceled: boolean; 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /src/demo/sortable/components/hold.ts: -------------------------------------------------------------------------------- 1 | export default function hold( 2 | node: HTMLElement, 3 | { touch = true, mouse = false, duration = 300 }: HoldOptions = {}, 4 | ) { 5 | function detectHold(event: TouchEvent | MouseEvent) { 6 | const timeout = setTimeout(() => { 7 | cancel(); 8 | const Event = event instanceof TouchEvent ? TouchEvent : MouseEvent; 9 | event.target?.dispatchEvent(new Event("hold", event as EventInit)); 10 | }, duration); 11 | const cancel = () => { 12 | clearTimeout(timeout); 13 | if (touch) { 14 | node.removeEventListener("touchcancel", cancel); 15 | node.removeEventListener("touchmove", cancel); 16 | node.removeEventListener("touchend", cancel); 17 | } 18 | if (mouse) { 19 | node.removeEventListener("mousemove", cancel); 20 | node.removeEventListener("mouseup", cancel); 21 | } 22 | }; 23 | 24 | if (touch) { 25 | node.addEventListener("touchcancel", cancel, { once: true }); 26 | node.addEventListener("touchend", cancel, { once: true }); 27 | node.addEventListener("touchmove", cancel, { 28 | once: true, 29 | passive: true, 30 | }); 31 | } 32 | if (mouse) { 33 | node.addEventListener("mousemove", cancel, { once: true }); 34 | node.addEventListener("mouseup", cancel, { once: true }); 35 | } 36 | } 37 | 38 | if (touch) { 39 | node.addEventListener("touchstart", detectHold, { passive: true }); 40 | } 41 | if (mouse) { 42 | node.addEventListener("mousedown", detectHold); 43 | } 44 | 45 | return { 46 | destroy() { 47 | node.removeEventListener("touchstart", detectHold); 48 | node.removeEventListener("mousedown", detectHold); 49 | }, 50 | }; 51 | } 52 | 53 | interface HoldOptions { 54 | duration?: number; 55 | touch?: boolean; 56 | mouse?: boolean; 57 | } 58 | 59 | declare global { 60 | namespace svelte.JSX { 61 | // @ts-ignore 62 | interface HTMLAttributes { 63 | onhold?: (event: TouchEvent | MouseEvent) => void; 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/demo/sortable/components/math.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Returns a value within min & max boundary 3 | */ 4 | export function minmax(value: number, min: number, max: number) { 5 | if (value < min) return min; 6 | if (value > max) return max; 7 | return value; 8 | } 9 | 10 | /** 11 | * Checks whether the value is a finite number, 12 | * otherwise returns the fallback value 13 | */ 14 | export function finitify(value: number, fallback = 0) { 15 | return Number.isFinite(value) ? value : fallback; 16 | } 17 | -------------------------------------------------------------------------------- /src/demo/sortable/components/object.ts: -------------------------------------------------------------------------------- 1 | const derivatives = new WeakMap(); 2 | 3 | /** 4 | * Derives a weakly mapped object symbol from the given object 5 | * or a special string from other primitives. 6 | * Values derived the same number of times are guaranteed to be equal. 7 | */ 8 | export function derive(obj: any, times = 1): object | symbol { 9 | if (times <= 1) { 10 | if (typeof obj === "object" || typeof obj === "symbol") return obj; 11 | else return Symbol.for(String(obj)); 12 | } 13 | 14 | if (typeof obj !== "object") { 15 | if (typeof obj === "symbol") obj = Symbol.keyFor(obj); 16 | else obj = String(obj); 17 | 18 | return derive(Symbol.for(obj + "\0"), times - 1); 19 | } 20 | 21 | let symbol = derivatives.get(obj); 22 | if (!symbol) { 23 | symbol = Object(Symbol()) as object; 24 | derivatives.set(obj, symbol); 25 | } 26 | 27 | return derive(symbol, times - 1); 28 | } 29 | 30 | /** 31 | * Efficiently slices an iterator into an array 32 | * @param iterator Iterator object 33 | * @param start Start index 34 | * @param end End index 35 | */ 36 | export function slice(iterator: Iterator, start: number, end: number) { 37 | if (!Number.isFinite(start) || !Number.isFinite(end)) return []; 38 | if (Number.isNaN(start) || Number.isNaN(end)) return []; 39 | if (start >= end) return []; 40 | 41 | const slice = new Array(end - start); 42 | for (let i = 0; i < end; i++) { 43 | const { value, done } = iterator.next(); 44 | if (i < start) continue; 45 | if (done) return slice.slice(0, i); 46 | slice[i - start] = value; 47 | } 48 | 49 | return slice; 50 | } 51 | -------------------------------------------------------------------------------- /src/demo/sortable/components/pointer.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from "./throttle"; 2 | import { readable } from "svelte/store"; 3 | 4 | export type Point = { x: number; y: number }; 5 | 6 | /** 7 | * A readable store of the current pointer (mouse/touch) 8 | * position (clientX, clientY). 9 | */ 10 | export const position = readable({ x: NaN, y: NaN }, (set) => { 11 | const update = throttle(({ x, y }: Point) => set({ x, y })); 12 | globalThis.addEventListener?.("pointermove", update); 13 | globalThis.addEventListener?.("pointerdown", update); 14 | 15 | return function stop() { 16 | globalThis.removeEventListener?.("pointermove", update); 17 | globalThis.removeEventListener?.("pointerdown", update); 18 | }; 19 | }); 20 | -------------------------------------------------------------------------------- /src/demo/sortable/components/throttle.ts: -------------------------------------------------------------------------------- 1 | type Func = (..._: any) => any; 2 | 3 | /** 4 | * Returns a throttled function with respect to animation frames or a specified delay. 5 | * Typically used to throttle executions of input events handlers. 6 | * 7 | * Example: 8 | * ``` 9 | * addEventListener("pointermove", throttle(handler)); 10 | * ``` 11 | */ 12 | export function throttle(func: T, delay?: number) { 13 | let id: number; 14 | let context: any; 15 | let parameters: any; 16 | let time = Date.now() - (delay || 0 + 1); 17 | 18 | function callId() { 19 | func.apply(context, parameters); 20 | id = 0; 21 | } 22 | 23 | function callTime() { 24 | func.apply(context, parameters); 25 | time = Date.now(); 26 | } 27 | 28 | if (delay === undefined) { 29 | return function (this: any, ...args: any) { 30 | context = this; 31 | parameters = args; 32 | 33 | if (id) return; 34 | id = requestAnimationFrame(callId); 35 | } as T; 36 | } else { 37 | return function (this: any, ...args: any) { 38 | context = this; 39 | parameters = args; 40 | 41 | clearTimeout(id); 42 | id = +setTimeout(callTime, delay - (Date.now() - time)); 43 | } as T; 44 | } 45 | } 46 | 47 | /** 48 | * Returns a debounced function. 49 | * 50 | * Example: 51 | * ``` 52 | * addEventListener("input", debounce(handler, 200)); 53 | * ``` 54 | */ 55 | export function debounce(func: T, delay = 300) { 56 | let id: number; 57 | return function (this: any, ...args: any) { 58 | clearTimeout(id); 59 | id = +setTimeout(() => { 60 | func.apply(this, args); 61 | }, delay); 62 | } as T; 63 | } 64 | 65 | /** 66 | * Provides a promise-based delay using setTimeout function. 67 | * @param duration Delay duration 68 | */ 69 | export function delay(duration: number) { 70 | return new Promise((resolve) => setTimeout(resolve, duration)); 71 | } 72 | -------------------------------------------------------------------------------- /src/demo/sortable/components/touch.ts: -------------------------------------------------------------------------------- 1 | let locked = false; 2 | 3 | const prevent = (event: Event) => { 4 | if (locked) event.preventDefault(); 5 | }; 6 | 7 | const mitigate = ({ target }: { target: EventTarget | null }) => { 8 | if (!target) return; 9 | const cancel = () => { 10 | target.removeEventListener("touchend", cancel); 11 | target.removeEventListener("touchmove", prevent); 12 | target.removeEventListener("touchcancel", cancel); 13 | }; 14 | target.addEventListener("touchmove", prevent, { passive: false }); 15 | target.addEventListener("touchcancel", cancel, { once: true }); 16 | target.addEventListener("touchend", cancel, { once: true }); 17 | }; 18 | 19 | globalThis.addEventListener?.("touchmove", prevent, { passive: false }); 20 | globalThis.addEventListener?.("touchstart", mitigate, { passive: true }); 21 | 22 | /** 23 | * Immediately locks touch move events 24 | */ 25 | export function lock() { 26 | locked = true; 27 | } 28 | 29 | /** 30 | * Unlocks touch events locked with `lock()` 31 | */ 32 | export function unlock() { 33 | locked = false; 34 | } 35 | -------------------------------------------------------------------------------- /src/demo/sortable/routes.ts: -------------------------------------------------------------------------------- 1 | import { any, array, number, object, string } from "superstruct"; 2 | import { observable } from "@trpc/server/observable"; 3 | import { router, procedure } from "../trpc"; 4 | import { database } from "../../lib"; 5 | import { schema } from "./schema"; 6 | 7 | const { subscribe, merge, close } = database(schema, { 8 | name: "data/sortable.db", 9 | }); 10 | 11 | const routes = router({ 12 | push: procedure.input(any()).mutation(({ input }) => merge(input)), 13 | pull: procedure 14 | .input(object({ version: number(), client: string() })) 15 | .subscription(({ input }) => 16 | observable(({ next }) => subscribe(["*"], next, input)), 17 | ), 18 | }); 19 | 20 | export { routes, close }; 21 | -------------------------------------------------------------------------------- /src/demo/sortable/schema.ts: -------------------------------------------------------------------------------- 1 | import { number, object, string } from "superstruct"; 2 | import { crr, index, ordered, primary } from "../../lib"; 3 | 4 | const items = object({ 5 | id: number(), 6 | data: string(), 7 | list: number(), 8 | order: string(), 9 | }); 10 | crr(items); 11 | primary(items, "id"); 12 | index(items, "list"); 13 | index(items, "order", "id"); 14 | ordered(items, "order", "list"); 15 | 16 | const lists = object({ 17 | id: number(), 18 | title: string(), 19 | }); 20 | crr(lists); 21 | primary(lists, "id"); 22 | 23 | const schema = object({ items, lists }); 24 | 25 | export { schema }; 26 | -------------------------------------------------------------------------------- /src/demo/ssr/+page.server.ts: -------------------------------------------------------------------------------- 1 | import type { PageServerLoad } from "./$types"; 2 | import { items } from "./stores"; 3 | 4 | export const load: PageServerLoad = async () => ({ ssr: await items }); 5 | -------------------------------------------------------------------------------- /src/demo/ssr/+page.svelte: -------------------------------------------------------------------------------- 1 | 13 | 14 |
      15 | 16 |
      17 | 18 |
        19 | {#each ready($items) ? $items : data.ssr as item} 20 |
      • {item.data}
      • 21 | {/each} 22 |
      23 | -------------------------------------------------------------------------------- /src/demo/ssr/routes.ts: -------------------------------------------------------------------------------- 1 | import { any, array, number, object, string } from "superstruct"; 2 | import { observable } from "@trpc/server/observable"; 3 | import { router, procedure } from "../trpc"; 4 | import { merge, subscribe } from "./stores"; 5 | 6 | const routes = router({ 7 | push: procedure.input(any()).mutation(({ input }) => merge(input)), 8 | pull: procedure 9 | .input(object({ version: number(), client: string() })) 10 | .subscription(({ input }) => 11 | observable(({ next }) => subscribe(["*"], next, input)), 12 | ), 13 | }); 14 | 15 | export { routes }; 16 | -------------------------------------------------------------------------------- /src/demo/ssr/stores.ts: -------------------------------------------------------------------------------- 1 | import { object, string } from "superstruct"; 2 | import { database } from "../../lib/svelte"; 3 | import { crr, primary } from "../../lib"; 4 | import { trpc } from "../client"; 5 | 6 | const schema = object({ 7 | items: crr(primary(object({ id: string(), data: string() }), "id")), 8 | }); 9 | 10 | const client = trpc as any; // Fixes circular referencing 11 | const browser = "window" in globalThis; 12 | 13 | export const { replicated, merge, subscribe, close } = database(schema, { 14 | ssr: true, 15 | name: "data/ssr.db", 16 | push: browser ? client.ssr.push.mutate : undefined, 17 | pull: browser ? client.ssr.pull.subscribe : undefined, 18 | }); 19 | 20 | export const items = replicated((db) => db.selectFrom("items").selectAll(), { 21 | add(db, data: string) { 22 | const id = Math.random().toString(36).slice(2); 23 | return db.insertInto("items").values({ id, data }).execute(); 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/demo/todo/+page.svelte: -------------------------------------------------------------------------------- 1 | 42 | 43 |

      Top Item: {top}

      44 | {#if $todos} 45 |
      46 | 47 | 48 | 49 |
      50 |
        51 | {#each $todos as todo} 52 |
      • 53 | 61 | 62 |
      • 63 | {/each} 64 |
      65 | {:else} 66 | Loading... 67 | {/if} 68 | 69 | 83 | -------------------------------------------------------------------------------- /src/demo/todo/routes.ts: -------------------------------------------------------------------------------- 1 | import { any, number, object, string } from "superstruct"; 2 | import { observable } from "@trpc/server/observable"; 3 | import { router, procedure } from "../trpc"; 4 | import { database } from "../../lib"; 5 | import { schema } from "./schema"; 6 | 7 | const { subscribe, merge, close } = database(schema, { name: "data/todo.db" }); 8 | 9 | const routes = router({ 10 | push: procedure.input(any()).mutation(({ input }) => merge(input)), 11 | pull: procedure 12 | .input(object({ version: number(), client: string() })) 13 | .subscription(({ input }) => 14 | observable(({ next }) => subscribe(["*"], next, input)), 15 | ), 16 | }); 17 | 18 | export { routes, close }; 19 | -------------------------------------------------------------------------------- /src/demo/todo/schema.ts: -------------------------------------------------------------------------------- 1 | import { boolean, object, string } from "superstruct"; 2 | import { crr, primary } from "../../lib"; 3 | 4 | const todos = object({ 5 | id: string(), 6 | title: string(), 7 | text: string(), 8 | completed: boolean(), 9 | }); 10 | crr(todos); 11 | primary(todos, "id"); 12 | 13 | const schema = object({ todos }); 14 | 15 | export { schema }; 16 | -------------------------------------------------------------------------------- /src/demo/trpc.ts: -------------------------------------------------------------------------------- 1 | import { initTRPC } from "@trpc/server"; 2 | 3 | const { router, procedure } = initTRPC.create(); 4 | export { router, procedure }; 5 | -------------------------------------------------------------------------------- /src/lib/core/crstore.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | EncodedChanges, 3 | Operation, 4 | CoreDatabase, 5 | Actions, 6 | Context, 7 | Updater, 8 | QueryId, 9 | Schema, 10 | Bound, 11 | Error, 12 | Pull, 13 | Push, 14 | View, 15 | } from "./types"; 16 | import { affectedTables } from "../database/operations"; 17 | import type { CRSchema } from "../database/schema"; 18 | import { defaultPaths, init } from "../database"; 19 | import { reactive, ready } from "./reactive"; 20 | import type { CompiledQuery } from "kysely"; 21 | import { queue } from "../database/queue"; 22 | 23 | function database( 24 | schema: T, 25 | { 26 | ssr = false, 27 | name = "crstore.db", 28 | paths = defaultPaths, 29 | error = undefined as Error, 30 | push: remotePush = undefined as Push, 31 | pull: remotePull = undefined as Pull, 32 | online = () => !!(globalThis as any).navigator?.onLine, 33 | } = {}, 34 | ): CoreDatabase> { 35 | const dummy = !ssr && !!import.meta.env?.SSR; 36 | const connection = dummy 37 | ? new Promise(() => {}) 38 | : init(name, schema, paths); 39 | const channel = 40 | "BroadcastChannel" in globalThis 41 | ? new globalThis.BroadcastChannel(`${name}-sync`) 42 | : null; 43 | const tabUpdate = (event: MessageEvent) => trigger(event.data, event.data[0]); 44 | const write = queue(connection, trigger); 45 | const read = queue(connection); 46 | 47 | channel?.addEventListener("message", tabUpdate); 48 | globalThis.addEventListener?.("online", pull); 49 | 50 | const listeners = new Map>(); 51 | let hold = () => {}; 52 | pull(); 53 | 54 | async function refresh(query: CompiledQuery, id: QueryId) { 55 | return read 56 | .enqueue(id, (db) => db.getExecutor().executeQuery(query, id)) 57 | .then((x) => x.rows); 58 | } 59 | 60 | function subscribe( 61 | tables: string[], 62 | callback: Updater, 63 | options?: { client: string; version: number }, 64 | ) { 65 | const listener = async (changes: EncodedChanges, sender?: string) => { 66 | try { 67 | if (options && options.client === sender) return; 68 | await callback(changes, sender); 69 | } catch (reason) { 70 | error?.(reason); 71 | } 72 | }; 73 | 74 | tables.forEach((x) => { 75 | if (!listeners.has(x)) listeners.set(x, new Set()); 76 | listeners.get(x)?.add(listener); 77 | }); 78 | 79 | // Immediately call when have options 80 | if (options) { 81 | connection.then(async (db) => { 82 | const changes = await db 83 | .changesSince(options.version, { 84 | filter: options.client, 85 | chunk: true, 86 | }) 87 | .execute(); 88 | if (changes.length) changes.forEach((x) => listener(x)); 89 | }); 90 | } else listener(""); 91 | 92 | return () => tables.forEach((x) => listeners.get(x)?.delete(listener)); 93 | } 94 | 95 | async function push() { 96 | if (!remotePush || !online()) return; 97 | const db = await connection; 98 | 99 | const { current, synced } = await db.selectVersion().execute(); 100 | if (current <= synced) return; 101 | 102 | const changes = await db 103 | .changesSince(synced, { filter: null, chunk: true }) 104 | .execute(); 105 | 106 | await Promise.all(changes.map(remotePush)); 107 | await db.updateVersion(current).execute(); 108 | } 109 | 110 | async function pull() { 111 | globalThis.removeEventListener?.("offline", hold); 112 | hold(); 113 | 114 | if (!remotePull || !online()) return; 115 | const db = await connection; 116 | const { synced: version } = await db.selectVersion().execute(); 117 | const client = await db.selectClient().execute(); 118 | 119 | await push(); 120 | hold = remotePull( 121 | { version, client }, 122 | { 123 | async onData(changes) { 124 | if (!changes.length) return; 125 | await db.insertChanges(changes).execute(); 126 | await trigger(changes, changes[0]); 127 | }, 128 | }, 129 | ).unsubscribe; 130 | globalThis.addEventListener?.("offline", hold); 131 | } 132 | 133 | async function update( 134 | operation: Operation, 135 | ...args: T 136 | ) { 137 | return write.enqueue({}, operation, ...args); 138 | } 139 | 140 | async function merge(changes: EncodedChanges) { 141 | if (!changes.length) return; 142 | const db = await connection; 143 | await trigger(await db.resolveChanges(changes).execute(), changes[0]); 144 | } 145 | 146 | async function trigger(changes: EncodedChanges, sender?: string) { 147 | if (!changes.length) return; 148 | const callbacks = new Set(); 149 | const tables = affectedTables(changes); 150 | 151 | listeners.get("*")?.forEach((x) => callbacks.add(x)); 152 | tables.forEach((table) => 153 | listeners.get(table)?.forEach((x) => callbacks.add(x)), 154 | ); 155 | 156 | const promises = [...callbacks].map((x) => x(changes, sender)); 157 | if (!sender) { 158 | channel?.postMessage(changes); 159 | await push(); 160 | } 161 | 162 | await Promise.all(promises); 163 | } 164 | 165 | async function close() { 166 | hold(); 167 | channel?.close(); 168 | listeners.clear(); 169 | globalThis.removeEventListener?.("online", pull); 170 | globalThis.removeEventListener?.("offline", hold); 171 | channel?.removeEventListener("message", tabUpdate); 172 | await connection.then((x) => x.destroy()); 173 | } 174 | 175 | return { 176 | close, 177 | merge, 178 | update, 179 | subscribe, 180 | connection, 181 | replica: store.bind({ 182 | connection, 183 | subscribe, 184 | update, 185 | refresh, 186 | } as any) as any, 187 | }; 188 | } 189 | 190 | function store( 191 | this: Context, 192 | view: View, 193 | actions: Actions = {}, 194 | dependencies: unknown[] = [], 195 | ) { 196 | const { connection, update, refresh: read } = this; 197 | 198 | let query = null as CompiledQuery | null; 199 | let id = null as QueryId | null; 200 | 201 | const { subscribe, set, bind } = reactive( 202 | async (...values) => { 203 | const db = await connection; 204 | const node = view(db, ...(values as [])).toOperationNode(); 205 | const tables = affectedTables(node); 206 | const executor = db.getExecutor(); 207 | 208 | id = { queryId: Math.random().toString(36).slice(2) }; 209 | query = executor.compileQuery(executor.transformQuery(node, id), id); 210 | 211 | return this.subscribe(tables, refresh); 212 | }, 213 | dependencies, 214 | ); 215 | 216 | async function refresh() { 217 | await connection; 218 | if (!query || !id) return; 219 | set(await read(query, id)); 220 | } 221 | 222 | const bound: Bound> = {}; 223 | for (const name in actions) { 224 | bound[name] = (...args: any[]) => update(actions[name], ...args); 225 | } 226 | 227 | return { 228 | ...bound, 229 | subscribe, 230 | bind, 231 | update(operation?: Operation, ...args: T) { 232 | if (!operation) return refresh(); 233 | return update(operation, ...args); 234 | }, 235 | then(resolve: (x: Type[]) => any = (x) => x, reject?: (e: any) => any) { 236 | let data: Type[] = []; 237 | const done = subscribe((x) => (data = x)); 238 | // It is hard to know whether the current store's state is dirty, 239 | // therefore we have to explicitly refresh it 240 | return refresh().then(() => (done(), resolve?.(data)), reject); 241 | }, 242 | }; 243 | } 244 | 245 | export { database, ready }; 246 | -------------------------------------------------------------------------------- /src/lib/core/reactive.ts: -------------------------------------------------------------------------------- 1 | const empty: [] = []; 2 | const ready = (data: unknown[]) => data !== empty; 3 | 4 | function reactive( 5 | start: (...args: U) => (() => void) | Promise<() => void>, 6 | parameters: U = [] as unknown as U, 7 | ) { 8 | type Subscriber = (value: T) => void; 9 | const subscribers = new Set(); 10 | const invalidator = { 11 | stop: undefined as (() => void) | undefined, 12 | start: undefined as 13 | | ((set: typeof invalidate) => (() => void) | undefined) 14 | | undefined, 15 | }; 16 | 17 | let stop: (() => void) | Promise<() => void> | undefined; 18 | let value = empty as unknown as T; 19 | 20 | function set(updated: T) { 21 | value = updated; 22 | if (stop) subscribers.forEach((x) => x(value)); 23 | } 24 | 25 | function subscribe(fn: Subscriber) { 26 | subscribers.add(fn); 27 | if (subscribers.size === 1) { 28 | stop = start(...parameters); 29 | invalidator.stop = invalidator.start?.(invalidate); 30 | } 31 | fn(value); 32 | return () => { 33 | subscribers.delete(fn); 34 | if (subscribers.size === 0 && stop) { 35 | Promise.resolve(stop).then((x) => x()); 36 | stop = undefined; 37 | invalidator.stop?.(); 38 | invalidator.stop = undefined; 39 | } 40 | }; 41 | } 42 | 43 | function bind(fn: ((set: typeof invalidate) => () => void) | U) { 44 | if (Array.isArray(fn)) return invalidate(fn); 45 | invalidator.stop?.(); 46 | invalidator.start = fn; 47 | if (stop) invalidator.stop = invalidator.start(invalidate); 48 | } 49 | 50 | function invalidate(updated: U) { 51 | if (JSON.stringify(updated) === JSON.stringify(parameters)) return; 52 | parameters = updated.slice() as U; 53 | if (!stop) return; 54 | Promise.resolve(stop).then((x) => x()); 55 | stop = start(...parameters); 56 | } 57 | 58 | return { set, subscribe, bind }; 59 | } 60 | 61 | export { reactive, ready }; 62 | -------------------------------------------------------------------------------- /src/lib/core/types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | AggregateFunctionNode, 3 | SelectQueryNode, 4 | ReferenceNode, 5 | SelectAllNode, 6 | CompiledQuery, 7 | ColumnNode, 8 | TableNode, 9 | AliasNode, 10 | RawNode, 11 | Kysely, 12 | } from "kysely"; 13 | 14 | // === UTILITIES === 15 | 16 | /** A set of changes that is encoded to be used over the wire */ 17 | type EncodedChanges = string; 18 | type Change = { 19 | /** Client's unique identifier */ 20 | site_id: Uint8Array; 21 | /** Column name */ 22 | cid: string; 23 | /** Primary key */ 24 | pk: Uint8Array; 25 | /** Table name */ 26 | table: string; 27 | /** Value */ 28 | val: string | number | Uint8Array | bigint | null; 29 | /** Lamport clock of the database for this change (used to track whether or not a client has seen changes from another database) */ 30 | db_version: number; 31 | /** Lamport clock of the column for this change (used for merging) */ 32 | col_version: number; 33 | /** Causal length (used for delete/insert tracking) */ 34 | cl: number; 35 | /** Operation number in the current transaction */ 36 | seq: number; 37 | }; 38 | 39 | type QueryId = { queryId: string }; 40 | type Executable = { execute(): Promise }; 41 | type Updater = (changes: EncodedChanges, sender?: string) => any; 42 | type Schema = T extends { TYPE: infer U } ? U : unknown; 43 | 44 | type Operation = ( 45 | db: Kysely, 46 | ...args: T 47 | ) => Promise; 48 | 49 | type Selectable = { 50 | execute(): Promise; 51 | compile(): CompiledQuery; 52 | toOperationNode(): SelectQueryNode; 53 | }; 54 | 55 | type Actions = Record< 56 | string, 57 | (db: Kysely, ...args: any[]) => Promise 58 | >; 59 | 60 | type Bound = { 61 | [K in keyof Actions]: Actions[K] extends ( 62 | db: Kysely, 63 | ...args: infer A 64 | ) => infer R 65 | ? (...args: A) => Promise> 66 | : never; 67 | }; 68 | 69 | type Node = 70 | | SelectQueryNode 71 | | TableNode 72 | | ColumnNode 73 | | ReferenceNode 74 | | RawNode 75 | | AggregateFunctionNode 76 | | AliasNode 77 | | SelectAllNode; 78 | 79 | // === STORE === 80 | 81 | type View = ( 82 | db: Kysely, 83 | ..._: Deps 84 | ) => Selectable; 85 | 86 | type Update = { 87 | update( 88 | operation?: Operation, 89 | ...args: T 90 | ): Promise; 91 | }; 92 | 93 | type Context = { 94 | update( 95 | operation: Operation, 96 | ...args: T 97 | ): Promise; 98 | refresh( 99 | query: CompiledQuery, 100 | id: { queryId: string }, 101 | ): Promise; 102 | subscribe(tables: string[], callback: () => any): () => void; 103 | connection: Promise>; 104 | }; 105 | 106 | type CoreStore = , D extends any[]>( 107 | view: View, 108 | actions?: A, 109 | dependencies?: D, 110 | ) => PromiseLike & 111 | Bound & 112 | Update & { 113 | subscribe: (fn: (value: T[]) => void) => () => void; 114 | bind: ( 115 | parameters: D | ((set: (updated: D) => void) => (() => void) | void), 116 | ) => void; 117 | }; 118 | 119 | // === DATABASE === 120 | 121 | type Error = ((reason: unknown) => void) | undefined; 122 | type Push = ((changes: EncodedChanges) => any) | undefined; 123 | type Pull = 124 | | (( 125 | input: { version: number; client: string }, 126 | options: { onData: (changes: EncodedChanges) => any }, 127 | ) => { unsubscribe(): void }) 128 | | undefined; 129 | 130 | interface Connection extends Kysely { 131 | selectVersion(): Executable<{ current: number; synced: number }>; 132 | resolveChanges(changes: EncodedChanges): Executable; 133 | updateVersion(version?: number): Executable; 134 | insertChanges(changes: EncodedChanges): Executable; 135 | selectClient(): Executable; 136 | applyOperation( 137 | operation: Operation, 138 | ...args: T 139 | ): Executable<{ result: Awaited; changes: EncodedChanges }>; 140 | 141 | changesSince(since: number): Executable; 142 | changesSince( 143 | since: number, 144 | options: { filter?: string | null; chunk?: false }, 145 | ): Executable; 146 | changesSince( 147 | since: number, 148 | options: { filter?: string | null; chunk: true }, 149 | ): Executable; 150 | } 151 | 152 | interface CoreDatabase { 153 | connection: Promise>; 154 | replica: CoreStore; 155 | 156 | update( 157 | operation: Operation, 158 | ...args: T 159 | ): Promise; 160 | merge(changes: EncodedChanges): Promise; 161 | close(): Promise; 162 | subscribe( 163 | tables: string[], 164 | callback: Updater, 165 | options?: { 166 | client: string; 167 | version: number; 168 | }, 169 | ): () => void; 170 | } 171 | 172 | export type { 173 | EncodedChanges, 174 | CoreDatabase, 175 | Connection, 176 | CoreStore, 177 | Operation, 178 | QueryId, 179 | Actions, 180 | Context, 181 | Updater, 182 | Update, 183 | Change, 184 | Kysely, 185 | Schema, 186 | Bound, 187 | Error, 188 | View, 189 | Push, 190 | Pull, 191 | Node, 192 | }; 193 | -------------------------------------------------------------------------------- /src/lib/database/dialect.ts: -------------------------------------------------------------------------------- 1 | import { SqliteDialect, CompiledQuery, type DatabaseConnection } from "kysely"; 2 | 3 | class CRDialect extends SqliteDialect { 4 | database: () => Promise; 5 | 6 | constructor(config: CRDialectConfig) { 7 | super(config as any); 8 | this.database = async () => 9 | typeof config.database === "function" 10 | ? config.database() 11 | : config.database; 12 | } 13 | 14 | createDriver() { 15 | const load = this.database; 16 | const waiter = mutex(); 17 | 18 | let db: CRDatabase; 19 | let connection: DatabaseConnection; 20 | 21 | return { 22 | async init() { 23 | db = await load(); 24 | connection = { 25 | async executeQuery(query: CompiledQuery) { 26 | return { 27 | rows: (await db.execO(query.sql, query.parameters)) as O[], 28 | }; 29 | }, 30 | async *streamQuery() { 31 | throw new Error("Sqlite driver doesn't support streaming"); 32 | }, 33 | }; 34 | }, 35 | async acquireConnection() { 36 | await waiter.lock(); 37 | return connection; 38 | }, 39 | async beginTransaction(connection: DatabaseConnection) { 40 | await connection.executeQuery(CompiledQuery.raw("begin")); 41 | }, 42 | async commitTransaction(connection: DatabaseConnection) { 43 | await connection.executeQuery(CompiledQuery.raw("commit")); 44 | }, 45 | async rollbackTransaction(connection: DatabaseConnection) { 46 | await connection.executeQuery(CompiledQuery.raw("rollback")); 47 | }, 48 | async releaseConnection() { 49 | waiter.unlock(); 50 | }, 51 | async destroy() { 52 | db?.close(); 53 | }, 54 | }; 55 | } 56 | } 57 | 58 | function mutex() { 59 | let promise: Promise | undefined; 60 | let resolve: (() => void) | undefined; 61 | 62 | return { 63 | async lock() { 64 | while (promise) await promise; 65 | promise = new Promise((r) => (resolve = r)); 66 | }, 67 | unlock(): void { 68 | const unlock = resolve; 69 | promise = undefined; 70 | resolve = undefined; 71 | unlock?.(); 72 | }, 73 | }; 74 | } 75 | 76 | interface CRDialectConfig { 77 | database: CRDatabase | (() => Promise); 78 | } 79 | 80 | interface CRDatabase { 81 | close(): void; 82 | execO(sql: string, bind?: readonly unknown[]): Promise; 83 | } 84 | 85 | export { CRDialect }; 86 | -------------------------------------------------------------------------------- /src/lib/database/encoder.ts: -------------------------------------------------------------------------------- 1 | const types = { 2 | boolean: "?", 3 | number: "+", 4 | string: "'", 5 | object: "&", 6 | bigint: "^", 7 | null: "!", 8 | "?": "boolean", 9 | "+": "number", 10 | "'": "string", 11 | "^": "bigint", 12 | "&": "object", 13 | "!": "null", 14 | } as const; 15 | 16 | const encoders = { 17 | any: (x: any): string => 18 | (typeof x) in types && x != null 19 | ? types[typeof x as keyof typeof types] + 20 | encoders[typeof x as keyof typeof encoders]?.(x as never) || 21 | x.toString() 22 | : "!", 23 | object: (x: number[]) => btoa(String.fromCharCode.apply(null, x)), 24 | number: (x: number) => x.toString(), 25 | string: (x: string) => x.replaceAll(",", ",,").replaceAll("*", "**"), 26 | bigint: (x: BigInt) => x.toString(), 27 | boolean: (x: boolean) => (+x).toString(), 28 | } as const; 29 | 30 | const decoders = { 31 | any: (x: string): Uint8Array | null | number | string | boolean | bigint => 32 | decoders[types[x[0] as keyof typeof types] as keyof typeof decoders]?.( 33 | x.slice(1), 34 | ), 35 | object: (x: string) => 36 | Uint8Array.from([...atob(x)].map((x) => x.charCodeAt(0))), 37 | number: (x: string) => parseFloat(x), 38 | string: (x: string) => x.replaceAll(",,", ",").replaceAll("**", "*"), 39 | bigint: (x: string) => BigInt(x), 40 | boolean: (x: string) => !!+x, 41 | null: () => null, 42 | } as const; 43 | 44 | export function encode( 45 | data: FromSchema[], 46 | schema: TSchema, 47 | ) { 48 | const entries: [string, number][] = []; 49 | const last: Record = {}; 50 | for (const item of data) { 51 | for (const [id, type] of schema) { 52 | const encoded = encoders[type](item[id as keyof typeof item]); 53 | if (last[id]?.[0] !== encoded) { 54 | const data: [string, number] = [encoded, 1]; 55 | entries.push(data); 56 | last[id] = data; 57 | } else { 58 | last[id][1] += 1; 59 | } 60 | } 61 | } 62 | return entries 63 | .map( 64 | ([data, count]) => 65 | (count > 1 ? "*" + String.fromCharCode(count + 43) : ",") + data, 66 | ) 67 | .join(""); 68 | } 69 | 70 | export function decode(data: string, schema: TSchema) { 71 | const items: [string, number][] = []; 72 | 73 | for (let position = 0; position < data.length; ) { 74 | let next = position - 1; 75 | do { 76 | next = [",", "*"] 77 | .map((x) => data.indexOf(x, next + 2)) 78 | .filter((x) => ~x) 79 | .reduce((a, b) => Math.min(a, b), Infinity); 80 | } while (data[next + 1] === data[next] && next !== Infinity); 81 | 82 | let buffer = data.slice(position, next); 83 | const single = buffer[0] === ","; 84 | items.push([ 85 | buffer.slice(single ? 1 : 2), 86 | single ? 1 : buffer.charCodeAt(1) - 43, 87 | ]); 88 | position = next; 89 | } 90 | 91 | const entries = items.slice(0, schema.length); 92 | const decoded: FromSchema[] = []; 93 | let current = schema.length; 94 | 95 | outer: for (;;) { 96 | const item: any = {}; 97 | for (let i = 0; i < schema.length; i++) { 98 | if (!entries[i]) break outer; 99 | const [key, type] = schema[i]; 100 | item[key] = decoders[type](entries[i][0]); 101 | entries[i][1] -= 1; 102 | if (!entries[i][1]) entries[i] = items[current++]; 103 | } 104 | decoded.push(item); 105 | } 106 | 107 | return decoded; 108 | } 109 | 110 | export function chunk( 111 | items: T[], 112 | { size = 1000, strict = true } = {}, 113 | ) { 114 | if (items.length <= size) return [items]; 115 | const chunks: T[][] = []; 116 | 117 | for (let offset = 0; offset < items.length; ) { 118 | const firstVersion = items[offset].db_version; 119 | const edgeVersion = items[offset + size]?.db_version; 120 | const predicate = (x: T) => x.db_version === edgeVersion; 121 | 122 | let edgeIndex = Infinity; 123 | if (firstVersion === edgeVersion) { 124 | if (strict) edgeIndex = offset + size; 125 | else edgeIndex = items.findLastIndex(predicate, offset + size) + 1; 126 | } else if (edgeVersion != null) { 127 | edgeIndex = items.findIndex(predicate, offset); 128 | } 129 | 130 | chunks.push(items.slice(offset, edgeIndex)); 131 | offset = edgeIndex; 132 | } 133 | 134 | return chunks; 135 | } 136 | 137 | type Types = { 138 | object: Uint8Array; 139 | boolean: boolean; 140 | number: number; 141 | string: string; 142 | bigint: bigint; 143 | any: unknown; 144 | null: null; 145 | }; 146 | 147 | type Schema = readonly (readonly [string, keyof typeof encoders])[]; 148 | 149 | type FromSchema = { 150 | [K in TSchema[number][0]]: Types[Extract< 151 | TSchema[number], 152 | readonly [K, any] 153 | >[1]]; 154 | } & NonNullable; 155 | -------------------------------------------------------------------------------- /src/lib/database/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | applyOperation, 3 | resolveChanges, 4 | insertChanges, 5 | updateVersion, 6 | selectVersion, 7 | changesSince, 8 | selectClient, 9 | finalize, 10 | } from "./operations"; 11 | import type { Connection, Schema } from "../core/types"; 12 | import { Kysely, SqliteDialect } from "kysely"; 13 | import type { CRSchema } from "./schema"; 14 | import { load } from "crstore/runtime"; 15 | import { CRDialect } from "./dialect"; 16 | import { JSONPlugin } from "./json"; 17 | import { apply } from "./schema"; 18 | 19 | const connections = new Map(); 20 | const defaultPaths = {} as { 21 | wasm?: string; 22 | binding?: string; 23 | extension?: string; 24 | }; 25 | 26 | async function init( 27 | file: string, 28 | schema: T, 29 | paths = defaultPaths, 30 | ) { 31 | type DB = Schema; 32 | if (connections.has(file)) return connections.get(file) as Connection; 33 | 34 | const { database, env } = await load(file, paths); 35 | const Dialect = env === "browser" ? CRDialect : SqliteDialect; 36 | const kysely = new Kysely({ 37 | dialect: new Dialect({ database }), 38 | plugins: [new JSONPlugin()], 39 | }); 40 | 41 | const close = kysely.destroy.bind(kysely); 42 | await kysely.transaction().execute((db) => apply(db, schema)); 43 | 44 | const connection = Object.assign(kysely, { 45 | resolveChanges, 46 | applyOperation, 47 | insertChanges, 48 | updateVersion, 49 | selectVersion, 50 | selectClient, 51 | changesSince, 52 | async destroy() { 53 | connections.delete(file); 54 | await finalize.bind(kysely)().execute(); 55 | return close(); 56 | }, 57 | }) as Connection; 58 | 59 | connections.set(file, connection); 60 | return connection; 61 | } 62 | 63 | export { init, defaultPaths }; 64 | -------------------------------------------------------------------------------- /src/lib/database/json.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ExtractTypeFromReferenceExpression, 3 | PluginTransformResultArgs, 4 | PluginTransformQueryArgs, 5 | ExpressionBuilder, 6 | StringReference, 7 | KyselyPlugin, 8 | Expression, 9 | } from "kysely"; 10 | import { sql, AggregateFunctionNode, AggregateFunctionBuilder } from "kysely"; 11 | 12 | type Simplify = T extends any[] | Date 13 | ? T 14 | : { [K in keyof T]: T[K] } & NonNullable; 15 | 16 | type JSON = { 17 | [K in keyof OBJ]: NonNullable< 18 | ExtractTypeFromReferenceExpression 19 | > & 20 | NonNullable; 21 | }; 22 | 23 | function json< 24 | DB, 25 | TB extends keyof DB, 26 | OBJ extends Record | Expression>, 27 | >(kysely: ExpressionBuilder, obj: OBJ) { 28 | const entires = Object.entries(obj).flatMap(([key, value]) => [ 29 | sql.lit(key), 30 | typeof value === "string" ? kysely.ref(value) : value, 31 | ]); 32 | 33 | return sql`json_object(${sql.join(entires)})` 34 | .withPlugin({ 35 | transformQuery({ node }: PluginTransformQueryArgs) { 36 | return { ...node, json: true }; 37 | }, 38 | } as any) 39 | .$castTo>>(); 40 | } 41 | 42 | function group< 43 | DB, 44 | TB extends keyof DB, 45 | EXP extends StringReference | Expression, 46 | >(kysely: ExpressionBuilder, expr: EXP) { 47 | const reference = 48 | typeof expr === "string" 49 | ? kysely.ref(expr as StringReference).toOperationNode() 50 | : expr.toOperationNode(); 51 | 52 | type O = Simplify< 53 | NonNullable>[] 54 | >; 55 | return new AggregateFunctionBuilder({ 56 | aggregateFunctionNode: { 57 | ...AggregateFunctionNode.create("json_group_array", [reference]), 58 | json: true, 59 | } as any, 60 | }); 61 | } 62 | 63 | function groupJSON< 64 | DB, 65 | TB extends keyof DB, 66 | OBJ extends Record | Expression>, 67 | >(kysely: ExpressionBuilder, obj: OBJ) { 68 | return group(kysely, json(kysely, obj)); 69 | } 70 | 71 | class JSONPlugin implements KyselyPlugin { 72 | #jsonNodes = new WeakMap>(); 73 | 74 | transformQuery({ node, queryId }: PluginTransformQueryArgs) { 75 | if (node.kind !== "SelectQueryNode") return node; 76 | this.#jsonNodes.set(queryId, new Set(this.getColumns(node))); 77 | return node; 78 | } 79 | 80 | async transformResult({ result, queryId }: PluginTransformResultArgs) { 81 | if (!Array.isArray(result.rows)) return result; 82 | const mapped = this.#jsonNodes.get(queryId); 83 | if (!mapped) return result; 84 | result.rows.forEach((row) => this.parseObject(row, mapped)); 85 | return result; 86 | } 87 | 88 | private getColumns(node: Record) { 89 | const columns: string[] = []; 90 | for (const key in node) { 91 | if (node[key] && typeof node[key] === "object") { 92 | if (node[key]["json"] === true && typeof node.alias?.name == "string") { 93 | columns.push(node.alias?.name); 94 | } 95 | columns.push(...this.getColumns(node[key])); 96 | } 97 | } 98 | return columns; 99 | } 100 | 101 | private parseObject(object: Record, keys: Set) { 102 | for (const key in object) { 103 | if (keys.has(key)) object[key] = JSON.parse(String(object[key])); 104 | if (typeof object[key] === "object") this.parseObject(object[key], keys); 105 | } 106 | } 107 | } 108 | 109 | export { json, group, groupJSON, JSONPlugin }; 110 | -------------------------------------------------------------------------------- /src/lib/database/operations.ts: -------------------------------------------------------------------------------- 1 | import type { Operation, Node, Change, EncodedChanges } from "../core/types"; 2 | import { 3 | encode as genericEncode, 4 | decode as genericDecode, 5 | chunk, 6 | } from "./encoder"; 7 | import type { Kysely } from "kysely"; 8 | import { sql } from "kysely"; 9 | 10 | const schema = [ 11 | ["site_id", "object"], 12 | ["cid", "string"], 13 | ["pk", "object"], 14 | ["table", "string"], 15 | ["val", "any"], 16 | ["db_version", "number"], 17 | ["col_version", "number"], 18 | ["cl", "number"], 19 | ["seq", "number"], 20 | ] as const; 21 | 22 | function encode(changes: Change[]): EncodedChanges { 23 | return genericEncode(changes, schema); 24 | } 25 | 26 | function decode(encoded: EncodedChanges) { 27 | return genericDecode(encoded, schema); 28 | } 29 | 30 | function toBytes(data: string) { 31 | return Uint8Array.from([...data].map((x) => x.charCodeAt(0))); 32 | } 33 | 34 | function fromBytes(data: Uint8Array) { 35 | return String.fromCharCode(...data); 36 | } 37 | 38 | function selectVersion(this: Kysely) { 39 | type Version = { current: number; synced: number }; 40 | const query = sql`SELECT 41 | crsql_db_version() as current, 42 | IFNULL(MAX(version), 0) as synced 43 | FROM "__crstore_sync"`; 44 | 45 | return { 46 | execute: () => query.execute(this).then((x) => x.rows[0]), 47 | }; 48 | } 49 | 50 | function updateVersion(this: Kysely, version?: number) { 51 | return this.updateTable("__crstore_sync").set({ 52 | version: version != null ? version : sql`crsql_db_version()`, 53 | }); 54 | } 55 | 56 | function selectClient(this: Kysely) { 57 | type Client = { client: Uint8Array }; 58 | const query = sql`SELECT crsql_site_id() as client`; 59 | return { 60 | execute: () => query.execute(this).then((x) => fromBytes(x.rows[0].client)), 61 | }; 62 | } 63 | 64 | function changesSince( 65 | this: Kysely, 66 | since: number, 67 | { 68 | filter = undefined as string | number | undefined, 69 | chunk: areChunked = false, 70 | } = {}, 71 | ) { 72 | let query = this.selectFrom("crsql_changes") 73 | // Overwrite `site_id` with the local one 74 | .select(sql`crsql_site_id()`.as("site_id")) 75 | .select([ 76 | "cid", 77 | "pk", 78 | "table", 79 | "val", 80 | "db_version", 81 | "col_version", 82 | "cl", 83 | "seq", 84 | ]) 85 | .where("db_version", ">", since) 86 | // Don't return tombstones when requesting the entire db 87 | .$if(!since, (qb) => qb.where("cid", "!=", "__crsql_del")) 88 | .$if(filter === null, (qb) => 89 | qb.where("site_id", "is", sql`crsql_site_id()`), 90 | ) 91 | .$if(typeof filter === "string", (qb) => 92 | qb.where("site_id", "is not", toBytes(filter as any)), 93 | ) 94 | .$castTo(); 95 | 96 | return { 97 | execute: () => 98 | query 99 | .execute() 100 | .then((changes) => 101 | areChunked 102 | ? chunk(changes, { strict: false }).map(encode) 103 | : encode(changes), 104 | ), 105 | }; 106 | } 107 | 108 | function insertChanges(this: Kysely, changes: EncodedChanges) { 109 | const run = async (db: typeof this) => { 110 | if (!changes.length) return; 111 | const inserts = chunk(decode(changes)).map((changeset) => 112 | db.insertInto("crsql_changes").values(changeset).execute(), 113 | ); 114 | await Promise.all(inserts); 115 | await updateVersion.bind(db)().execute(); 116 | }; 117 | return { 118 | execute: () => 119 | this.isTransaction ? run(this) : this.transaction().execute(run), 120 | }; 121 | } 122 | 123 | function resolveChanges(this: Kysely, changes: EncodedChanges) { 124 | return { 125 | execute: () => 126 | applyOperation 127 | .bind(this)((db) => insertChanges.bind(db)(changes).execute()) 128 | .execute() 129 | .then((x) => x.changes), 130 | }; 131 | } 132 | 133 | function applyOperation( 134 | this: Kysely, 135 | operation: Operation, 136 | ...args: T 137 | ) { 138 | return { 139 | execute: () => 140 | this.transaction().execute(async (db) => { 141 | const { current } = await selectVersion.bind(db)().execute(); 142 | const result = await operation(db, ...args); 143 | const changes = await changesSince.bind(db)(current).execute(); 144 | return { result, changes }; 145 | }), 146 | }; 147 | } 148 | 149 | function finalize(this: Kysely) { 150 | const query = sql`select crsql_finalize();`; 151 | return { 152 | execute: () => query.execute(this), 153 | }; 154 | } 155 | 156 | function affectedTables(target: Node | EncodedChanges): string[] { 157 | if (typeof target === "string") { 158 | return [...new Set(decode(target).map((x) => x.table))]; 159 | } 160 | if (target.kind === "TableNode") { 161 | return [target.table.identifier.name]; 162 | } 163 | if (target.kind === "ReferenceNode" && target.table) { 164 | return [target.table.table.identifier.name]; 165 | } 166 | if (target.kind === "AliasNode") { 167 | return affectedTables(target.node as Node); 168 | } 169 | if (target.kind === "SelectQueryNode") { 170 | const tables = ( 171 | [ 172 | ...(target.from?.froms || []), 173 | ...(target.joins?.map((x) => x.table) || []), 174 | ...(target.selections?.map((x) => x.selection) || []), 175 | ...(target.with?.expressions.map((x) => x.expression) || []), 176 | ] as Node[] 177 | ).flatMap(affectedTables); 178 | return [...new Set(tables)]; 179 | } 180 | return []; 181 | } 182 | 183 | export { 184 | encode, 185 | decode, 186 | finalize, 187 | changesSince, 188 | selectClient, 189 | selectVersion, 190 | updateVersion, 191 | insertChanges, 192 | applyOperation, 193 | resolveChanges, 194 | affectedTables, 195 | }; 196 | -------------------------------------------------------------------------------- /src/lib/database/queue.ts: -------------------------------------------------------------------------------- 1 | import type { EncodedChanges, Operation } from "../core/types"; 2 | import { changesSince, selectVersion } from "./operations"; 3 | import type { Kysely } from "kysely"; 4 | 5 | const raf = globalThis.requestAnimationFrame || globalThis.setTimeout; 6 | const error = Symbol("error"); 7 | 8 | function queue( 9 | connection: Promise>, 10 | trigger?: (changes: EncodedChanges) => void, 11 | ) { 12 | const queue = new Map>(); 13 | let queueing: Promise> | undefined; 14 | 15 | async function dequeue() { 16 | if (queueing) return queueing; 17 | return (queueing = new Promise((resolve) => 18 | raf(async () => { 19 | const db = await connection; 20 | const result = new Map(); 21 | await db 22 | .transaction() 23 | .execute(async (trx: any) => { 24 | const current: any = 25 | trigger && (await selectVersion.bind(trx)().execute()).current; 26 | for (const [id, query] of queue.entries()) { 27 | const rows = await query(trx).catch((x) => ({ [error]: x })); 28 | result.set(id, rows); 29 | } 30 | trigger?.( 31 | (await changesSince.bind(trx)(current).execute()) as string, 32 | ); 33 | }) 34 | .catch((reason) => { 35 | if (String(reason).includes("driver has already been destroyed")) { 36 | return; 37 | } 38 | throw reason; 39 | }); 40 | queue.clear(); 41 | queueing = undefined; 42 | resolve(result); 43 | }), 44 | )); 45 | } 46 | 47 | return { 48 | enqueue( 49 | id: object, 50 | operation: Operation, 51 | ...args: T 52 | ) { 53 | queue.set(id, (db: Kysely) => operation(db, ...args)); 54 | return dequeue() 55 | .then((x) => x.get(id)) 56 | .then((x) => { 57 | if (x && typeof x === "object" && error in x) throw x[error]; 58 | else return x as R; 59 | }); 60 | }, 61 | }; 62 | } 63 | 64 | export { queue }; 65 | -------------------------------------------------------------------------------- /src/lib/database/schema.ts: -------------------------------------------------------------------------------- 1 | import { sql, type Transaction } from "kysely"; 2 | 3 | function covert(type: string) { 4 | const types = { 5 | any: "blob", 6 | string: "text", 7 | number: "real", 8 | unknown: "blob", 9 | instance: "blob", 10 | bigint: "integer", 11 | integer: "integer", 12 | boolean: "boolean", 13 | } as const; 14 | 15 | const mapped = types[type as keyof typeof types]; 16 | if (mapped) return mapped; 17 | throw new Error(`Type "${type}" is not allowed in the database schema!`); 18 | } 19 | 20 | async function apply(db: Transaction, { schema }: CRSchema) { 21 | for (const table in schema) { 22 | const current = schema[table]; 23 | // Create tables 24 | let query = db.schema.createTable(table).ifNotExists(); 25 | for (const column in current.schema) { 26 | const { type } = current.schema[column]; 27 | query = query.addColumn( 28 | column, 29 | current.ordered?.find(([x]) => x === column) ? "blob" : covert(type), 30 | (col) => (current.primary?.includes(column) ? col.notNull() : col), 31 | ); 32 | } 33 | // Add constrains 34 | if (current.primary) { 35 | query = query.addPrimaryKeyConstraint( 36 | "primary_key", 37 | current.primary as any, 38 | ); 39 | } 40 | await query.execute(); 41 | // Create indices 42 | for (const index of current.indices || []) { 43 | await db.schema 44 | .createIndex(`${table}_${index.join("_")}`) 45 | .ifNotExists() 46 | .on(table) 47 | .columns(index) 48 | .execute(); 49 | } 50 | // Register CRRs 51 | if (current.crr) { 52 | await sql`SELECT crsql_as_crr(${table})`.execute(db); 53 | } 54 | // Register fraction index 55 | for (const ordered of current.ordered || []) { 56 | await sql`SELECT crsql_fract_as_ordered(${table},${sql.join( 57 | ordered, 58 | )})`.execute(db); 59 | } 60 | // Create a special table for version sync 61 | await db.schema 62 | .createTable("__crstore_sync") 63 | .ifNotExists() 64 | .addColumn("version", "integer") 65 | .execute(); 66 | await sql`INSERT INTO __crstore_sync (version) SELECT 0 67 | WHERE NOT EXISTS (SELECT * FROM __crstore_sync) 68 | `.execute(db); 69 | } 70 | } 71 | 72 | function primary(table: T, ...keys: Keys[]) { 73 | table.primary = keys; 74 | return table; 75 | } 76 | 77 | function crr(table: T) { 78 | table.crr = true; 79 | return table; 80 | } 81 | 82 | function ordered( 83 | table: T, 84 | by: Keys, 85 | ...grouped: Keys[] 86 | ) { 87 | if (!table.ordered) table.ordered = []; 88 | table.ordered.push([by, ...grouped]); 89 | return table; 90 | } 91 | 92 | function index(table: T, ...keys: Keys[]) { 93 | if (!table.indices) table.indices = []; 94 | table.indices.push(keys); 95 | return table; 96 | } 97 | 98 | type Keys = Exclude; 99 | type CRColumn = { type: string }; 100 | type CRTable = { 101 | schema: Record; 102 | 103 | ordered?: string[][]; 104 | indices?: string[][]; 105 | primary?: string[]; 106 | crr?: boolean; 107 | }; 108 | type CRSchema = { schema: Record }; 109 | 110 | export { apply, primary, crr, index, ordered }; 111 | export type { CRSchema }; 112 | -------------------------------------------------------------------------------- /src/lib/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./core/crstore"; 2 | export { encode, decode } from "./database/operations"; 3 | export { group, json, groupJSON } from "./database/json"; 4 | export { primary, crr, index, ordered } from "./database/schema"; 5 | 6 | export const APPEND = 1 as any; 7 | export const PREPEND = -1 as any; 8 | 9 | export * from "kysely"; 10 | 11 | export * from "./core/types"; 12 | -------------------------------------------------------------------------------- /src/lib/react.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CoreDatabase, 3 | Actions, 4 | Schema, 5 | Update, 6 | Bound, 7 | View, 8 | } from "./core/types"; 9 | import { database as coreDatabase } from "./core/crstore"; 10 | import { useState, useMemo, useEffect } from "react"; 11 | import type { CRSchema } from "./database/schema"; 12 | 13 | function database( 14 | schema: S, 15 | params: Parameters[1] = {}, 16 | ): ReactDatabase> { 17 | const { replica, ...rest } = coreDatabase(schema, params); 18 | 19 | function useReplica>, D extends any[]>( 20 | view: View, T, D>, 21 | actions?: A, 22 | deps: D = [] as unknown as D, 23 | ) { 24 | const [data, setData] = useState([]); 25 | const { bind, subscribe, ...rest } = useMemo( 26 | () => replica(view, actions, deps), 27 | [], 28 | ); 29 | 30 | useEffect(() => subscribe(setData), []); 31 | useEffect(() => bind(deps), deps); 32 | 33 | const compound = useMemo(() => Object.assign(data, rest), [data]); 34 | return compound; 35 | } 36 | 37 | return { useReplica: useReplica as any as ReactStore>, ...rest }; 38 | } 39 | 40 | type ReactStore = , D extends any[] = []>( 41 | view: View, 42 | actions?: A, 43 | deps?: D, 44 | ) => T[] & Bound & Update; 45 | 46 | type ReactDatabase = Omit, "replica"> & { 47 | useReplica: ReactStore; 48 | }; 49 | 50 | export { database }; 51 | export type { ReactStore, ReactDatabase }; 52 | -------------------------------------------------------------------------------- /src/lib/solid.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CoreDatabase, 3 | Actions, 4 | Update, 5 | Schema, 6 | Bound, 7 | View, 8 | } from "./core/types"; 9 | import { createSignal, createEffect, onCleanup } from "solid-js"; 10 | import { database as coreDatabase } from "./core/crstore"; 11 | import type { CRSchema } from "./database/schema"; 12 | import type { Accessor } from "solid-js"; 13 | 14 | function database( 15 | schema: S, 16 | params: Parameters[1] = {}, 17 | ): SolidDatabase> { 18 | const { replica, ...rest } = coreDatabase(schema, params); 19 | 20 | function createReplica< 21 | T, 22 | A extends Actions>, 23 | D extends Accessor[], 24 | >( 25 | view: View, T, SignalValues>, 26 | actions?: A, 27 | deps: D = [] as unknown as D, 28 | ) { 29 | const [data, setData] = createSignal([]); 30 | const { bind, subscribe, ...rest } = replica( 31 | view, 32 | actions, 33 | deps.map((x) => x()) as SignalValues, 34 | ); 35 | 36 | createEffect(() => onCleanup(subscribe(setData))); 37 | createEffect(() => bind(deps.map((x) => x()) as SignalValues)); 38 | 39 | return Object.assign(data, rest); 40 | } 41 | 42 | return { 43 | createReplica: createReplica as any as SolidStore>, 44 | ...rest, 45 | }; 46 | } 47 | 48 | type SolidStore = , D extends Accessor[] = []>( 49 | view: View>, 50 | actions?: A, 51 | deps?: D, 52 | ) => Accessor & Bound & Update; 53 | 54 | type SolidDatabase = Omit, "replica"> & { 55 | createReplica: SolidStore; 56 | }; 57 | 58 | type SignalValues = { 59 | [K in keyof T]: T[K] extends Accessor ? U : never; 60 | }; 61 | 62 | export { database }; 63 | export type { SolidStore, SolidDatabase }; 64 | -------------------------------------------------------------------------------- /src/lib/svelte.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CoreDatabase, 3 | Actions, 4 | Schema, 5 | Update, 6 | Bound, 7 | View, 8 | } from "./core/types"; 9 | import { derived, get, type Readable } from "svelte/store"; 10 | import { database as coreDatabase } from "./core/crstore"; 11 | import type { CRSchema } from "./database/schema"; 12 | 13 | function database( 14 | schema: S, 15 | params: Parameters[1] = {}, 16 | ): SvelteDatabase> { 17 | const { replica, ...rest } = coreDatabase(schema, params); 18 | 19 | function storeWith[]>(...deps: D) { 20 | return function >>( 21 | view: View, T, StoresValues>, 22 | actions?: A, 23 | ) { 24 | const dependency = deps.length ? derived(deps, (x) => x) : undefined; 25 | const initial = dependency && get(dependency); 26 | const { bind, ...rest } = replica(view, actions, initial); 27 | bind((update) => dependency?.subscribe(update)); 28 | return rest; 29 | } as any as SvelteStore, StoresValues>; 30 | } 31 | 32 | return { 33 | replicated: Object.assign(storeWith(), { with: storeWith }), 34 | ...rest, 35 | }; 36 | } 37 | 38 | type StoresValues = 39 | T extends Readable 40 | ? U 41 | : { [K in keyof T]: T[K] extends Readable ? U : never }; 42 | 43 | type SvelteStore = >( 44 | view: View, 45 | actions?: A, 46 | ) => Readable & PromiseLike & Bound & Update; 47 | 48 | type SvelteDatabase = Omit, "replica"> & { 49 | replicated: SvelteStore & { 50 | with[]>( 51 | ...stores: D 52 | ): SvelteStore>; 53 | }; 54 | }; 55 | 56 | export { database }; 57 | export type { SvelteStore, SvelteDatabase }; 58 | -------------------------------------------------------------------------------- /src/test/encoder.test.ts: -------------------------------------------------------------------------------- 1 | import { chunk, decode, encode } from "../lib/database/encoder"; 2 | import { expect, it } from "vitest"; 3 | 4 | it("encodes and decodes", () => { 5 | const schema = [ 6 | ["site_id", "object"], 7 | ["cid", "string"], 8 | ["pk", "object"], 9 | ["table", "string"], 10 | ["val", "any"], 11 | ["db_version", "number"], 12 | ["col_version", "number"], 13 | ["cl", "number"], 14 | ["seq", "number"], 15 | ] as const; 16 | 17 | const samplePlain = [ 18 | { 19 | site_id: new Uint8Array([ 20 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 21 | ]), 22 | cid: "order", 23 | pk: new Uint8Array([1, 33, 76, 224, 139, 12]), 24 | table: "pla,yback", 25 | val: new Uint8Array([1, 2, 3]), 26 | db_version: 78, 27 | col_version: 2, 28 | cl: 1, 29 | seq: 0, 30 | }, 31 | { 32 | site_id: new Uint8Array([ 33 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 34 | ]), 35 | cid: "playlist", 36 | pk: new Uint8Array([1, 33, 66, 51, 49, 168]), 37 | table: "library", 38 | val: -1, 39 | db_version: 78, 40 | col_version: 1, 41 | cl: 1, 42 | seq: 3, 43 | }, 44 | { 45 | site_id: new Uint8Array([ 46 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 47 | ]), 48 | cid: "track", 49 | pk: new Uint8Array([1, 33, 66, 51, 49, 168]), 50 | table: "library", 51 | val: true, 52 | db_version: 78, 53 | col_version: 1, 54 | cl: 1, 55 | seq: 4, 56 | }, 57 | { 58 | site_id: new Uint8Array([ 59 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 60 | ]), 61 | cid: "date", 62 | pk: new Uint8Array([1, 33, 66, 51, 49, 168]), 63 | table: "library", 64 | val: 1704614751n, 65 | db_version: 78, 66 | col_version: 1, 67 | cl: 1, 68 | seq: 5, 69 | }, 70 | { 71 | site_id: new Uint8Array([ 72 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 73 | ]), 74 | cid: "order", 75 | pk: new Uint8Array([1, 33, 66, 51, 49, 168]), 76 | table: "library", 77 | val: "ZI", 78 | db_version: 78, 79 | col_version: 2, 80 | cl: 1, 81 | seq: 6, 82 | }, 83 | { 84 | site_id: new Uint8Array([ 85 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 86 | ]), 87 | cid: "playback", 88 | pk: new Uint8Array([ 89 | 1, 12, 16, 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 90 | 83, 247, 51, 91 | ]), 92 | table: "devices", 93 | val: null, 94 | db_version: 78, 95 | col_version: 4, 96 | cl: 1, 97 | seq: 7, 98 | }, 99 | { 100 | site_id: new Uint8Array([ 101 | 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 83, 247, 51, 102 | ]), 103 | cid: "progress", 104 | pk: new Uint8Array([ 105 | 1, 12, 16, 163, 103, 146, 69, 118, 255, 69, 92, 187, 16, 64, 79, 125, 106 | 83, 247, 51, 107 | ]), 108 | table: "devices", 109 | val: 0.00700311693548387, 110 | db_version: 79, 111 | col_version: 10, 112 | cl: 1, 113 | seq: 0, 114 | }, 115 | ]; 116 | 117 | const sampleEncoded = 118 | `*2o2eSRXb/RVy7EEBPfVP3Mw==,order,ASFM` + 119 | `4IsM,pla,,yback,&AQID*178,2*21,0,play` + 120 | `list*/ASFCMzGo*/library,+-1*.1,3,trac` + 121 | `k,?1,4,date,^1704614751,5,order,'ZI,2` + 122 | `,6,playback*-AQwQo2eSRXb/RVy7EEBPfVP3` + 123 | `Mw==*-devices,!,4,7,progress,+0.00700` + 124 | `311693548387,79,10,0`; 125 | 126 | expect(encode(samplePlain, schema)).toBe(sampleEncoded); 127 | expect(decode(sampleEncoded, schema)).toEqual(samplePlain); 128 | }); 129 | 130 | it("escapes split characters", () => { 131 | const schema = [ 132 | ["test", "string"], 133 | ["other", "string"], 134 | ] as const; 135 | expect(encode([{ test: ",,,", other: "***" }], schema)).toBe( 136 | ",,,,,,,,******", 137 | ); 138 | expect(decode(",,,,,,,,******", schema)).toEqual([ 139 | { test: ",,,", other: "***" }, 140 | ]); 141 | }); 142 | 143 | it("compacts repeating sequences", () => { 144 | const schema = [["repeated", "any"]] as const; 145 | const repeated = [ 146 | { repeated: 42 }, 147 | { repeated: 42 }, 148 | { repeated: 42 }, 149 | { repeated: "42" }, 150 | ]; 151 | 152 | expect(encode(repeated, schema)).toBe("*.+42,'42"); 153 | expect(decode("*.+42,'42", schema)).toEqual(repeated); 154 | }); 155 | 156 | it("handles empty data", () => { 157 | const schema = [["id", "number"]] as const; 158 | const encoded = encode([], schema); 159 | expect(encoded).toBe(""); 160 | const decoded = decode(encoded, schema); 161 | expect(decoded).toEqual([]); 162 | }); 163 | 164 | it("handles null, undefined and empty values", () => { 165 | const schema = [ 166 | ["id", "number"], 167 | ["name", "any"], 168 | ] as const; 169 | 170 | const encoded = encode( 171 | [ 172 | { id: 1, name: "" }, 173 | { id: 2, name: null }, 174 | { id: 3, name: undefined }, 175 | { id: 4, name: 0 }, 176 | { id: 5, name: false }, 177 | { id: 6, name: new Uint8Array([]) }, 178 | ], 179 | schema, 180 | ); 181 | expect(encoded).toBe(",1,',2*-!,3,4,+0,5,?0,6,&"); 182 | 183 | const decoded = decode(encoded, schema); 184 | expect(decoded).toEqual([ 185 | { id: 1, name: "" }, 186 | { id: 2, name: null }, 187 | { id: 3, name: null }, 188 | { id: 4, name: 0 }, 189 | { id: 5, name: false }, 190 | { id: 6, name: new Uint8Array([]) }, 191 | ]); 192 | }); 193 | 194 | it("chunks values", () => { 195 | const v = (version: number) => ({ db_version: version }); 196 | const vs = (version: number, length: number) => 197 | Array.from>({ length }).fill(v(version)); 198 | 199 | expect(chunk([v(1), v(2), v(3), v(4), v(5), v(6)], { size: 2 })).toEqual([ 200 | [v(1), v(2)], 201 | [v(3), v(4)], 202 | [v(5), v(6)], 203 | ]); 204 | 205 | expect( 206 | chunk([v(3), ...vs(5, 5), v(8), v(10), v(11), v(11)], { size: 5 }), 207 | ).toEqual([[v(3)], vs(5, 5), [v(8), v(10), v(11), v(11)]]); 208 | 209 | expect( 210 | chunk([v(3), v(3), v(8), ...vs(5, 5), v(10), v(11), v(11)], { size: 7 }), 211 | ).toEqual([ 212 | [v(3), v(3), v(8)], 213 | [...vs(5, 5), v(10)], 214 | [v(11), v(11)], 215 | ]); 216 | 217 | expect(chunk([v(1), v(2), v(3)], { size: 1 })).toEqual([ 218 | [v(1)], 219 | [v(2)], 220 | [v(3)], 221 | ]); 222 | 223 | expect( 224 | chunk([v(3), ...vs(5, 6), v(8), v(10), v(11), v(11)], { size: 5 }), 225 | ).toEqual([[v(3)], vs(5, 5), [v(5), v(8), v(10), v(11), v(11)]]); 226 | 227 | expect( 228 | chunk([v(3), ...vs(5, 6), v(8), v(10), v(11), v(11)], { 229 | strict: false, 230 | size: 5, 231 | }), 232 | ).toEqual([[v(3)], vs(5, 6), [v(8), v(10), v(11), v(11)]]); 233 | }); 234 | 235 | it("does not chunks small changesets", () => { 236 | const v = (version: number) => ({ db_version: version }); 237 | const changes = [v(1), v(2), v(3), v(4)]; 238 | expect(chunk(changes, { size: 5 })[0]).toBe(changes); 239 | }); 240 | -------------------------------------------------------------------------------- /src/test/store.test.ts: -------------------------------------------------------------------------------- 1 | import { afterAll, expect, it, vi } from "vitest"; 2 | import { object, string } from "superstruct"; 3 | import { get, writable } from "svelte/store"; 4 | import { crr, primary, encode } from "$lib"; 5 | import { database } from "$lib/svelte"; 6 | import { rm } from "fs/promises"; 7 | import { decode } from "$lib"; 8 | 9 | const delay = (ms = 1) => new Promise((r) => setTimeout(r, ms)); 10 | const errored = vi.fn(); 11 | const schema = object({ 12 | test: crr( 13 | primary( 14 | object({ 15 | id: string(), 16 | data: string(), 17 | }), 18 | "id", 19 | ), 20 | ), 21 | }); 22 | 23 | const { replicated, close, subscribe, update, merge } = database(schema, { 24 | name: "test.db", 25 | error: errored, 26 | ssr: true, 27 | }); 28 | 29 | it("stores data", async () => { 30 | const item = { id: "1", data: "data" }; 31 | const spy = vi.fn(); 32 | const table = replicated((db) => db.selectFrom("test").selectAll()); 33 | const unsubscribe = table.subscribe(spy); 34 | 35 | const spy2 = vi.fn(); 36 | const unsubscribe2 = subscribe(["*"], spy2, { client: "", version: 0 }); 37 | 38 | expect(spy).toHaveBeenCalledWith([]); 39 | await delay(10); 40 | expect(spy).toHaveBeenCalledWith([]); 41 | expect(spy).toHaveBeenCalledTimes(2); 42 | await table.update((db) => db.insertInto("test").values(item).execute()); 43 | expect(spy).toHaveBeenCalledTimes(2); 44 | await delay(50); 45 | expect(spy).toHaveBeenCalledWith([item]); 46 | expect(spy).toHaveBeenCalledTimes(3); 47 | await table.update(); 48 | expect(spy).toHaveBeenCalledTimes(4); 49 | expect(get(table)).toEqual([item]); 50 | unsubscribe(); 51 | 52 | expect(spy2).toHaveBeenCalledWith( 53 | expect.stringContaining(",data,AQsBMQ==,test,'data,1,1,1,0"), 54 | undefined, 55 | ); 56 | unsubscribe2(); 57 | }); 58 | 59 | it("handles errors", async () => { 60 | const table2 = replicated((db) => db.selectFrom("test2" as any).selectAll()); 61 | const table = replicated((db) => db.selectFrom("test").selectAll()); 62 | 63 | const rejection = expect(table2.then()).rejects.toThrowError(); 64 | const resolution = expect(table.then()).resolves.toBeTruthy(); 65 | await delay(100); 66 | expect(errored).toHaveBeenCalled(); 67 | await rejection; 68 | await resolution; 69 | }); 70 | 71 | it("merges changes", async () => { 72 | const spy = vi.fn(); 73 | const table = replicated((db) => db.selectFrom("test").selectAll()); 74 | const unsubscribe = table.subscribe(spy); 75 | 76 | expect(spy).toHaveBeenCalledWith([]); 77 | await delay(50); 78 | expect(spy).toHaveBeenCalledWith([{ id: "1", data: "data" }]); 79 | 80 | await merge( 81 | encode([ 82 | { 83 | site_id: new Uint8Array([1, 2]), 84 | cid: "data", 85 | pk: new Uint8Array([1, 11, 1, 49]), 86 | table: "test", 87 | val: "updated", 88 | db_version: 2, 89 | col_version: 2, 90 | cl: 1, 91 | seq: 0, 92 | }, 93 | ]), 94 | ); 95 | expect(spy).toHaveBeenCalledWith([{ id: "1", data: "updated" }]); 96 | expect(spy).toHaveBeenCalledTimes(3); 97 | unsubscribe(); 98 | }); 99 | 100 | it("works with stores", async () => { 101 | const select = vi.fn((db, query) => 102 | db.selectFrom("test").where("data", "=", query).selectAll(), 103 | ); 104 | const spy = vi.fn(); 105 | 106 | const query = writable("updated"); 107 | const searched = replicated.with(query)(select); 108 | const unsubscribe = searched.subscribe(spy); 109 | 110 | expect(spy).toHaveBeenCalledWith([]); 111 | await delay(50); 112 | expect(spy).toHaveBeenCalledWith([{ id: "1", data: "updated" }]); 113 | query.set("data"); 114 | await delay(50); 115 | expect(spy).toHaveBeenCalledWith([]); 116 | expect(spy).toHaveBeenCalledTimes(3); 117 | unsubscribe(); 118 | }); 119 | 120 | it("unsubscribes from stores", async () => { 121 | const [subbed, unsubbed, executed] = [vi.fn(), vi.fn(), vi.fn()]; 122 | const dependency = writable(1, () => (subbed(), unsubbed)); 123 | const target = replicated.with(dependency)((db, dep) => { 124 | executed(dep); 125 | return db.selectFrom("test").selectAll(); 126 | }); 127 | 128 | expect(executed).toHaveBeenCalledTimes(0); 129 | expect(unsubbed).toHaveBeenCalledTimes(1); 130 | expect(subbed).toHaveBeenCalledTimes(1); 131 | 132 | const stop = target.subscribe(() => {}); 133 | await delay(50); 134 | 135 | expect(executed).toHaveBeenCalledTimes(1); 136 | expect(unsubbed).toHaveBeenCalledTimes(1); 137 | expect(subbed).toHaveBeenCalledTimes(2); 138 | 139 | stop(); 140 | 141 | expect(executed).toHaveBeenCalledTimes(1); 142 | expect(unsubbed).toHaveBeenCalledTimes(2); 143 | expect(subbed).toHaveBeenCalledTimes(2); 144 | 145 | expect(executed).toHaveBeenCalledWith(1); 146 | }); 147 | 148 | it("merges large changesets", async () => { 149 | const sqliteMaxVariables = 32766; 150 | const length = Math.ceil(sqliteMaxVariables / 9); 151 | const changeset = Array.from({ length }).map((_, i) => ({ 152 | site_id: new Uint8Array([1, 2]), 153 | cid: "data", 154 | pk: new Uint8Array([1, 11, 1, 49]), 155 | table: "test", 156 | val: `value ${i}`, 157 | db_version: i, 158 | col_version: i, 159 | cl: 1, 160 | seq: 0, 161 | })); 162 | 163 | await merge(encode(changeset)); 164 | 165 | const table = replicated((db) => db.selectFrom("test").selectAll()); 166 | expect((await table)[0].data).toBe(`value ${length - 1}`); 167 | }); 168 | 169 | it("chunks changesets sent to network", async () => { 170 | for (let chunk = 0; chunk < 50; chunk++) { 171 | update((db) => 172 | db 173 | .insertInto("test") 174 | .values( 175 | Array.from({ length: 100 }).map((_, i) => ({ 176 | id: `#${chunk}-${i}`, 177 | data: `data ${i}`, 178 | })), 179 | ) 180 | .execute(), 181 | ); 182 | await delay(0); 183 | } 184 | 185 | const success = vi.fn(); 186 | const callback = vi.fn((changes) => { 187 | const count = decode(changes).length; 188 | expect(count).toBeLessThanOrEqual(1000); 189 | expect(count).toBeGreaterThan(0); 190 | success(); 191 | }); 192 | subscribe(["test"], callback, { client: "", version: 0 }); 193 | 194 | await delay(10); 195 | expect(callback).toHaveBeenCalledTimes(6); 196 | expect(success).toHaveBeenCalledTimes(6); 197 | }); 198 | 199 | afterAll(async () => { 200 | await close(); 201 | await rm("./test.db"); 202 | await rm("./test.db-shm", { force: true }); 203 | await rm("./test.db-wal", { force: true }); 204 | }); 205 | -------------------------------------------------------------------------------- /static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Azarattum/CRStore/cdbc1c8e79e536702b531d5866e8827ed82b205a/static/favicon.png -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; 2 | import adapter from "@sveltejs/adapter-static"; 3 | 4 | /** @type {import('@sveltejs/kit').Config} */ 5 | const config = { 6 | preprocess: vitePreprocess(), 7 | kit: { 8 | adapter: adapter(), 9 | files: { 10 | routes: "src/demo", 11 | appTemplate: "src/demo/app.html", 12 | }, 13 | }, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": false, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "jsx": "react" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | // @ts-nocheck 2 | import { narrowSolidPlugin } from "@merged/react-solid/plugin"; 3 | import { applyWSSHandler } from "@trpc/server/adapters/ws"; 4 | import { sveltekit } from "@sveltejs/kit/vite"; 5 | import type { UserConfig } from "vite"; 6 | import { WebSocketServer } from "ws"; 7 | import { resolve } from "path"; 8 | import { parse } from "url"; 9 | 10 | const config = { 11 | plugins: [ 12 | sveltekit(), 13 | narrowSolidPlugin({ 14 | include: /\/src\/demo\/frameworks\/solid/, 15 | hot: false, 16 | }), 17 | { 18 | name: "vite-trpc-ws", 19 | async configureServer(server) { 20 | const { router } = await import("./src/demo/server"); 21 | const wss = new WebSocketServer({ noServer: true }); 22 | server.httpServer?.on("upgrade", (request, socket, head) => { 23 | const { pathname } = parse(request.url); 24 | if (pathname === "/trpc") { 25 | wss.handleUpgrade(request, socket, head, (ws) => { 26 | wss.emit("connection", ws, request); 27 | }); 28 | } 29 | }); 30 | applyWSSHandler({ wss, router }); 31 | }, 32 | async closeBundle() { 33 | await (await import("./src/demo/ssr/stores")).close(); 34 | await (await import("./src/demo/todo/routes")).close(); 35 | await (await import("./src/demo/library/routes")).close(); 36 | await (await import("./src/demo/sortable/routes")).close(); 37 | await (await import("./src/demo/frameworks/routes")).close(); 38 | }, 39 | }, 40 | ], 41 | resolve: { 42 | alias: [ 43 | { 44 | find: "crstore/runtime", 45 | customResolver: (_0: any, _1: any, { ssr }: { ssr?: boolean }) => 46 | ssr 47 | ? resolve("./runtime/server.js") 48 | : resolve("./runtime/browser.js"), 49 | } as any, 50 | ], 51 | }, 52 | build: { 53 | target: "es2020", 54 | rollupOptions: { external: ["path", "url"] }, 55 | }, 56 | optimizeDeps: { 57 | esbuildOptions: { 58 | target: "es2020", 59 | }, 60 | }, 61 | server: { 62 | fs: { allow: ["runtime"] }, 63 | }, 64 | test: { env: { SSR: "" } }, 65 | } satisfies UserConfig; 66 | 67 | export default config; 68 | --------------------------------------------------------------------------------