├── .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 | {x.text}
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) => {x().text} }
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 | {x.text}
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 | artists.add(artist)}>+
25 |
26 |
27 |
28 | albums.add(album)}>+
29 |
30 |
31 |
32 | playlists.add(playlist)}>+
33 |
34 |
35 |
36 |
37 | {#each $artists as artist}
38 | {artist.title}
39 | {/each}
40 |
41 |
42 | {#each $albums as album, i}
43 | {album.title}
44 | {/each}
45 |
46 | all.add(title, artistId, albumId)}>+
47 |
48 |
49 |
50 | {#each $all as track}
51 | {track.title}
52 | {/each}
53 |
54 |
55 | {#each $playlists as playlist}
56 | {playlist.title}
57 | {/each}
58 |
59 | playlists.link(trackLink, playlistLink)}>+
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 | Artist Title Album
14 |
15 | {#each tracks as track (track.id)}
16 |
17 | {track.artist || "-"}
18 | {track.title}
19 | {track.album || "-"}
20 |
21 | {/each}
22 |
23 |
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 | lists.add(list, item)}>Append
85 | lists.add(list, item, false)}>Prepend
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 |
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 | todos.create(title, text)}>+
49 |
50 |
51 | {#each $todos as todo}
52 |
53 | todos.toggle(todo.id)}
55 | class="todo"
56 | class:done={todo.completed}
57 | >
58 | {todo.title}
59 | {todo.text}
60 |
61 | todos.remove(todo.id)}>x
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 |
--------------------------------------------------------------------------------