@sveltebase/sync 1.2.0 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -451
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/live-query.svelte.d.ts.map +1 -1
- package/dist/client/status.svelte.d.ts.map +1 -1
- package/dist/cloudflare/index.d.ts +17 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +116 -0
- package/dist/index.d.ts +1 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +0 -1
- package/dist/protocol.d.ts.map +1 -1
- package/dist/server/broker.d.ts +5 -4
- package/dist/server/broker.d.ts.map +1 -1
- package/dist/server/broker.js +3 -5
- package/dist/server/engine.d.ts +5 -2
- package/dist/server/engine.d.ts.map +1 -1
- package/dist/server/engine.js +26 -5
- package/dist/server/handler.d.ts +15 -16
- package/dist/server/handler.d.ts.map +1 -1
- package/dist/server/handler.js +67 -98
- package/dist/server/index.d.ts +29 -15
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -0
- package/dist/sveltekit/index.d.ts +10 -0
- package/dist/sveltekit/index.d.ts.map +1 -0
- package/dist/sveltekit/index.js +29 -0
- package/package.json +9 -9
- package/dist/global.d.ts +0 -25
- package/dist/server/dev-engine.d.ts +0 -15
- package/dist/server/dev-engine.d.ts.map +0 -1
- package/dist/server/dev-engine.js +0 -121
- package/dist/vite.d.ts +0 -6
- package/dist/vite.d.ts.map +0 -1
- package/dist/vite.js +0 -55
package/README.md
CHANGED
|
@@ -1,525 +1,203 @@
|
|
|
1
1
|
# @sveltebase/sync
|
|
2
2
|
|
|
3
|
-
Reactive, local-first database synchronization
|
|
3
|
+
Reactive, local-first database synchronization for Svelte 5 using a separate Cloudflare Worker for realtime sync.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Architecture
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
- **Optimistic Updates**: Client mutations update the local database instantly (~1ms), sync with the server in the background, and roll back automatically on failures.
|
|
9
|
-
- **Real-Time Sync**: Single multiplexed WebSocket connection fanning out updates to all active subscribers.
|
|
10
|
-
- **Last-Write-Wins (LWW)**: Timestamps prevent out-of-order write conflicts.
|
|
11
|
-
- **Delta Syncing (Incremental Load)**: Automatically pulls only modified records since the last sync time to conserve network bandwidth.
|
|
12
|
-
- **Hibernate Friendly**: Client-initiated heartbeats allow Cloudflare Durable Objects to sleep when idle, cutting active execution costs down to near zero.
|
|
13
|
-
- **Vite Integration**: Custom dev plugin simulating Durable Objects and bindings proxy locally without full worker compilation loops.
|
|
14
|
-
- **Database Agnostic**: Completely decoupled from the underlying storage. You are not locked into Cloudflare D1; the sync handler hooks (`fetch`, `create`, `update`, `delete`) are simple, asynchronous JavaScript callbacks where you can connect to PostgreSQL, MySQL, Supabase, Neon, MongoDB, or any database of your choice.
|
|
7
|
+
`@sveltebase/sync` now uses two Workers:
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
The **Josh Thomas fork** introduces a platform entrypoint (`src/platform.cloudflare.ts`) which SvelteKit bundles into the worker wrapper, allowing you to export Durable Objects while SvelteKit continues to manage Svelte routing and page rendering.
|
|
28
|
-
|
|
29
|
-
---
|
|
30
|
-
|
|
31
|
-
## 1. Cloudflare Configuration (`wrangler.jsonc`)
|
|
32
|
-
|
|
33
|
-
Define D1 database and Durable Object namespace configurations in your `wrangler.jsonc` (or `wrangler.toml`):
|
|
34
|
-
|
|
35
|
-
```json
|
|
36
|
-
{
|
|
37
|
-
"compatibility_date": "2026-06-07",
|
|
38
|
-
"compatibility_flags": ["nodejs_compat"],
|
|
39
|
-
"main": ".svelte-kit/cloudflare/_worker.js",
|
|
40
|
-
"d1_databases": [
|
|
41
|
-
{
|
|
42
|
-
"binding": "DB",
|
|
43
|
-
"database_name": "sveltebase-sync",
|
|
44
|
-
"database_id": "YOUR_DATABASE_ID",
|
|
45
|
-
"migrations_dir": "drizzle/migrations"
|
|
46
|
-
}
|
|
47
|
-
],
|
|
48
|
-
"durable_objects": {
|
|
49
|
-
"bindings": [
|
|
50
|
-
{
|
|
51
|
-
"name": "SYNC_ENGINE",
|
|
52
|
-
"class_name": "SyncEngine"
|
|
53
|
-
}
|
|
54
|
-
]
|
|
55
|
-
},
|
|
56
|
-
"migrations": [
|
|
57
|
-
{
|
|
58
|
-
"tag": "v1",
|
|
59
|
-
"new_sqlite_classes": ["SyncEngine"]
|
|
60
|
-
}
|
|
61
|
-
]
|
|
62
|
-
}
|
|
9
|
+
```txt
|
|
10
|
+
browser
|
|
11
|
+
-> SvelteKit app Worker
|
|
12
|
+
/api/sync -> env.SYNC_WORKER.fetch(request)
|
|
13
|
+
-> sync Worker
|
|
14
|
+
owns SyncEngine Durable Object
|
|
15
|
+
owns websocket upgrades
|
|
16
|
+
owns broadcasts
|
|
17
|
+
owns sync database/runtime bindings
|
|
18
|
+
owns sync auth verification
|
|
63
19
|
```
|
|
64
20
|
|
|
65
|
-
|
|
21
|
+
The SvelteKit app Worker does not export sync Durable Objects. This lets apps use the official `@sveltejs/adapter-cloudflare`.
|
|
66
22
|
|
|
67
|
-
##
|
|
23
|
+
## Imports
|
|
68
24
|
|
|
69
|
-
|
|
25
|
+
```ts
|
|
26
|
+
import { SyncClient, createLiveQuery } from "@sveltebase/sync/client";
|
|
27
|
+
import { defineSync, createPublisher } from "@sveltebase/sync/server";
|
|
28
|
+
import { syncProxy } from "@sveltebase/sync/sveltekit";
|
|
29
|
+
import { defineSyncWorker, SyncEngine } from "@sveltebase/sync/cloudflare";
|
|
30
|
+
```
|
|
70
31
|
|
|
71
|
-
|
|
32
|
+
## Client
|
|
72
33
|
|
|
73
|
-
```
|
|
34
|
+
```ts
|
|
74
35
|
// src/lib/sync-client.ts
|
|
75
36
|
import { SyncClient } from "@sveltebase/sync/client";
|
|
76
|
-
import type { Todo } from "$lib/server/db/schema";
|
|
77
37
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
38
|
+
type AppSchema = {
|
|
39
|
+
todos: {
|
|
40
|
+
id: string;
|
|
41
|
+
title: string;
|
|
42
|
+
completed: boolean;
|
|
43
|
+
updatedAt: string;
|
|
44
|
+
};
|
|
81
45
|
};
|
|
82
46
|
|
|
83
|
-
export const sync = new SyncClient<
|
|
84
|
-
name: "
|
|
85
|
-
url: "/api/sync",
|
|
47
|
+
export const sync = new SyncClient<AppSchema>({
|
|
48
|
+
name: "app-sync",
|
|
49
|
+
url: "/api/sync",
|
|
86
50
|
tables: {
|
|
87
51
|
todos: {
|
|
88
|
-
indexes: "id, completed,
|
|
89
|
-
channel: "todos",
|
|
52
|
+
indexes: "id, completed, updatedAt",
|
|
53
|
+
channel: "todos",
|
|
90
54
|
},
|
|
91
55
|
},
|
|
92
56
|
});
|
|
93
57
|
```
|
|
94
58
|
|
|
95
|
-
|
|
96
|
-
```svelte
|
|
97
|
-
<script lang="ts">
|
|
98
|
-
import { sync } from "$lib/sync-client";
|
|
99
|
-
import { liveQuery } from "dexie";
|
|
100
|
-
import { Check, Trash } from "lucide-svelte";
|
|
101
|
-
|
|
102
|
-
// Standard Dexie liveQuery updates instantly on mutations & remote syncs
|
|
103
|
-
const todos = liveQuery(() => sync.todos.orderBy("createdAt").reverse().toArray());
|
|
104
|
-
|
|
105
|
-
let title = "";
|
|
106
|
-
|
|
107
|
-
async function addTodo() {
|
|
108
|
-
if (!title.trim()) return;
|
|
109
|
-
await sync.todos.add({
|
|
110
|
-
id: crypto.randomUUID(),
|
|
111
|
-
title,
|
|
112
|
-
completed: false,
|
|
113
|
-
createdAt: new Date().toISOString(),
|
|
114
|
-
updatedAt: new Date().toISOString(),
|
|
115
|
-
});
|
|
116
|
-
title = "";
|
|
117
|
-
}
|
|
118
|
-
</script>
|
|
119
|
-
|
|
120
|
-
<input bind:value={title} onkeydown={(e) => e.key === 'Enter' && addTodo()} />
|
|
121
|
-
|
|
122
|
-
{#each ($todos || []) as todo (todo.id)}
|
|
123
|
-
<div>
|
|
124
|
-
<button onclick={() => sync.todos.update(todo.id, { completed: !todo.completed })}>
|
|
125
|
-
<Check class={todo.completed ? "text-emerald-500" : ""} />
|
|
126
|
-
</button>
|
|
127
|
-
<span>{todo.title}</span>
|
|
128
|
-
<button onclick={() => sync.todos.delete(todo.id)}><Trash /></button>
|
|
129
|
-
</div>
|
|
130
|
-
{/each}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### Synced Database Operations
|
|
134
|
-
|
|
135
|
-
Under the hood, `@sveltebase/sync` intercepts native Dexie table writes to capture and propagate mutations to the backend. The following methods automatically sync with the server:
|
|
59
|
+
## Sync Handlers
|
|
136
60
|
|
|
137
|
-
|
|
138
|
-
* **`.put(row)` or `.put(id, changes)`**: Computes a diff of changed properties (for updates) or initiates a `"create"` mutation (for new rows) and sends it to the server.
|
|
139
|
-
* **`.update(id, changes)`**: Performs a local partial update and propagates the changes to the server as an `"update"` mutation.
|
|
140
|
-
* **`.delete(id)`**: Locally deletes the row and propagates a `"delete"` mutation to the server.
|
|
61
|
+
Handlers run in the sync Worker. Use `ctx.platform.env` for Cloudflare bindings, `ctx.auth` for verified auth data, and `ctx.identity` for ownership/scoped fanout.
|
|
141
62
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
---
|
|
146
|
-
|
|
147
|
-
### Step 2: Define Sync Handlers (Server)
|
|
148
|
-
|
|
149
|
-
> [!NOTE]
|
|
150
|
-
> **Database Agnostic (No Lock-In):**
|
|
151
|
-
> While the examples below connect to **Cloudflare D1 SQLite** (using Drizzle ORM), `@sveltebase/sync` is completely database-agnostic. The `fetch`, `create`, `update`, and `delete` handlers are standard asynchronous functions. You can fetch, save, or delete data using **any database** of your choice (PostgreSQL, MySQL, Supabase, Neon, MongoDB, etc.) by writing the appropriate database connection logic inside these hooks.
|
|
152
|
-
|
|
153
|
-
Define the handlers that translate IndexedDB operations (fetch, create, update, delete) to database queries:
|
|
154
|
-
|
|
155
|
-
```typescript
|
|
156
|
-
// src/lib/server/sync-todos.ts
|
|
157
|
-
import { defineSync } from "@sveltebase/sync";
|
|
158
|
-
import { getDB } from "$lib/server/db/index.js";
|
|
159
|
-
import { todos } from "$lib/server/db/schema";
|
|
160
|
-
import { desc, eq, gt } from "drizzle-orm";
|
|
161
|
-
import type { Todo } from "$lib/server/db/schema";
|
|
63
|
+
```ts
|
|
64
|
+
// src/lib/server/sync-handlers.ts
|
|
65
|
+
import { defineSync } from "@sveltebase/sync/server";
|
|
162
66
|
|
|
163
|
-
export const todoSync = defineSync
|
|
67
|
+
export const todoSync = defineSync({
|
|
164
68
|
channel: "todos",
|
|
165
69
|
|
|
166
70
|
fetch: async (ctx, since) => {
|
|
167
|
-
const db =
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
.select()
|
|
171
|
-
.from(todos)
|
|
172
|
-
.where(gt(todos.updatedAt, since))
|
|
173
|
-
.orderBy(desc(todos.createdAt));
|
|
174
|
-
}
|
|
175
|
-
return await db.select().from(todos).orderBy(desc(todos.createdAt));
|
|
176
|
-
},
|
|
177
|
-
|
|
178
|
-
create: async (ctx, data) => {
|
|
179
|
-
const db = getDB(ctx.platform);
|
|
180
|
-
const [created] = await db
|
|
181
|
-
.insert(todos)
|
|
182
|
-
.values(data)
|
|
183
|
-
.onConflictDoUpdate({
|
|
184
|
-
target: todos.id,
|
|
185
|
-
set: {
|
|
186
|
-
title: data.title,
|
|
187
|
-
completed: data.completed,
|
|
188
|
-
updatedAt: new Date().toISOString(),
|
|
189
|
-
},
|
|
190
|
-
})
|
|
191
|
-
.returning();
|
|
192
|
-
return created;
|
|
71
|
+
const db = ctx.platform.env.DB;
|
|
72
|
+
// Query any database here.
|
|
73
|
+
return [];
|
|
193
74
|
},
|
|
194
75
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
.set({ ...changes, updatedAt: new Date().toISOString() })
|
|
200
|
-
.where(eq(todos.id, key))
|
|
201
|
-
.returning();
|
|
202
|
-
return updated;
|
|
76
|
+
authorize: async (ctx) => {
|
|
77
|
+
if (!ctx.auth) {
|
|
78
|
+
throw new Error("Unauthorized");
|
|
79
|
+
}
|
|
203
80
|
},
|
|
204
81
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await db.delete(todos).where(eq(todos.id, key));
|
|
82
|
+
scope: (ctx) => {
|
|
83
|
+
return ctx.identity ? [ctx.identity] : [];
|
|
208
84
|
},
|
|
209
85
|
});
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
Export handlers from a single list:
|
|
213
|
-
```typescript
|
|
214
|
-
// src/lib/server/sync-handlers.ts
|
|
215
|
-
import { todoSync } from "./sync-todos.js";
|
|
216
86
|
|
|
217
87
|
export const handlers = [todoSync];
|
|
218
88
|
```
|
|
219
89
|
|
|
220
|
-
|
|
90
|
+
## Sync Worker
|
|
221
91
|
|
|
222
|
-
|
|
92
|
+
Create a standalone Worker entrypoint that owns the Durable Object:
|
|
223
93
|
|
|
224
|
-
|
|
94
|
+
```ts
|
|
95
|
+
// src/worker/sync.ts
|
|
96
|
+
import { jwtCookieAuth } from "@sveltebase/auth/sync";
|
|
97
|
+
import { defineSyncWorker, SyncEngine } from "@sveltebase/sync/cloudflare";
|
|
98
|
+
import { handlers } from "$lib/server/sync-handlers";
|
|
225
99
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
export const GET: RequestHandler = (event: RequestEvent) => {
|
|
232
|
-
return handleUpgrade(event.request, event.platform);
|
|
233
|
-
};
|
|
234
|
-
```
|
|
235
|
-
|
|
236
|
-
---
|
|
237
|
-
|
|
238
|
-
### Step 4: Svelte Config & Cloudflare Platform Entrypoint
|
|
239
|
-
|
|
240
|
-
Configure `@joshthomas/sveltekit-adapter-cloudflare` in your `svelte.config.js`:
|
|
241
|
-
|
|
242
|
-
```javascript
|
|
243
|
-
// svelte.config.js
|
|
244
|
-
import adapter from "@joshthomas/sveltekit-adapter-cloudflare";
|
|
100
|
+
export default defineSyncWorker({
|
|
101
|
+
handlers,
|
|
102
|
+
auth: jwtCookieAuth(),
|
|
103
|
+
});
|
|
245
104
|
|
|
246
|
-
export
|
|
247
|
-
kit: {
|
|
248
|
-
adapter: adapter({
|
|
249
|
-
platform: "src/platform.cloudflare.ts" // Platform config file
|
|
250
|
-
})
|
|
251
|
-
}
|
|
252
|
-
};
|
|
105
|
+
export { SyncEngine };
|
|
253
106
|
```
|
|
254
107
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
```typescript
|
|
258
|
-
// src/platform.cloudflare.ts
|
|
259
|
-
import { SyncEngineBase } from "@sveltebase/sync/server";
|
|
260
|
-
import { handlers } from "./lib/server/sync-handlers.js";
|
|
261
|
-
|
|
262
|
-
// Export the Durable Object class compiled into the worker
|
|
263
|
-
export class SyncEngine extends SyncEngineBase {
|
|
264
|
-
constructor(ctx: DurableObjectState, env: Env) {
|
|
265
|
-
super(ctx, env, handlers);
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
```
|
|
108
|
+
`defineSyncWorker()` handles:
|
|
269
109
|
|
|
270
|
-
|
|
110
|
+
- `GET /api/sync`: public websocket upgrade endpoint
|
|
111
|
+
- `POST /broadcast`: publish one external change
|
|
112
|
+
- `POST /broadcast-batch`: publish a batch of external changes
|
|
271
113
|
|
|
272
|
-
|
|
114
|
+
`GET /websocket` is internal to the sync Worker and Durable Object.
|
|
273
115
|
|
|
274
|
-
|
|
116
|
+
## SvelteKit Proxy Route
|
|
275
117
|
|
|
276
|
-
|
|
118
|
+
Keep browsers connecting to the app origin so existing cookies are sent:
|
|
277
119
|
|
|
278
|
-
```
|
|
279
|
-
//
|
|
280
|
-
import {
|
|
281
|
-
import {
|
|
282
|
-
import { syncDevPlugin } from "@sveltebase/sync/vite";
|
|
120
|
+
```ts
|
|
121
|
+
// src/routes/api/sync/+server.ts
|
|
122
|
+
import { SYNC_WORKER_URL } from "$env/static/private";
|
|
123
|
+
import { syncProxy } from "@sveltebase/sync/sveltekit";
|
|
283
124
|
|
|
284
|
-
export
|
|
285
|
-
|
|
286
|
-
syncDevPlugin({
|
|
287
|
-
// Path to your sync handlers. Uses ssrLoadModule so SvelteKit
|
|
288
|
-
// path aliases (like $lib) resolve perfectly at runtime.
|
|
289
|
-
handlersPath: "$lib/server/sync-handlers"
|
|
290
|
-
}),
|
|
291
|
-
sveltekit()
|
|
292
|
-
]
|
|
125
|
+
export const { GET, POST } = syncProxy({
|
|
126
|
+
fallbackUrl: SYNC_WORKER_URL,
|
|
293
127
|
});
|
|
294
128
|
```
|
|
295
129
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
## 3. Local Development Features
|
|
299
|
-
|
|
300
|
-
### Automatic Bindings Proxy
|
|
301
|
-
During development (`vite dev`), the dev engine uses Wrangler's programmatic Node API `getPlatformProxy()` under the hood. It caches the proxy on `globalThis` to survive Vite HMR reloads.
|
|
302
|
-
|
|
303
|
-
Both SvelteKit and the dev WebSocket server share the **exact same emulated D1 database instance** automatically.
|
|
304
|
-
|
|
305
|
-
### Message Buffering
|
|
306
|
-
Vite's module loading is asynchronous. When upgrading WebSocket connections, the plugin buffers incoming WebSocket frames during the module import phase. Once modules have fully loaded and handlers are registered, it replays the buffered messages to avoid connection race conditions.
|
|
130
|
+
In production, configure a Cloudflare service binding named `SYNC_WORKER`. In local development, use `fallbackUrl` such as `http://localhost:8788/api/sync`.
|
|
307
131
|
|
|
308
|
-
|
|
132
|
+
## Publishing Server Events
|
|
309
133
|
|
|
310
|
-
|
|
134
|
+
Publishing is explicit. It never reads SvelteKit request context implicitly.
|
|
311
135
|
|
|
312
|
-
|
|
313
|
-
|
|
136
|
+
```ts
|
|
137
|
+
import { createPublisher } from "@sveltebase/sync/server";
|
|
314
138
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
// Helper to extract session profile from handshake request
|
|
319
|
-
async function getSession(ctx: SyncContext) {
|
|
320
|
-
const cookie = ctx.request.headers.get("Cookie");
|
|
321
|
-
const db = getDB(ctx.platform);
|
|
322
|
-
// Perform session verification/DB lookup...
|
|
323
|
-
return { userId: "usr_123", role: "admin" };
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
---
|
|
139
|
+
type AppSchema = {
|
|
140
|
+
todos: { id: string; title: string; updatedAt: string };
|
|
141
|
+
};
|
|
328
142
|
|
|
329
|
-
|
|
330
|
-
|
|
143
|
+
const publish = createPublisher<AppSchema>({
|
|
144
|
+
platform: ctx.platform,
|
|
145
|
+
binding: "SYNC_WORKER",
|
|
146
|
+
fallbackUrl: env.SYNC_WORKER_URL,
|
|
147
|
+
});
|
|
331
148
|
|
|
332
|
-
|
|
333
|
-
authorize: async (ctx) => {
|
|
334
|
-
const user = await getSession(ctx);
|
|
335
|
-
if (!user) {
|
|
336
|
-
throw new Error("Unauthorized access to channel");
|
|
337
|
-
}
|
|
338
|
-
}
|
|
149
|
+
await publish("todos", "update", todo.id, todo);
|
|
339
150
|
```
|
|
340
151
|
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
### Throwing & Filtering in Handlers (CRUD Operations)
|
|
344
|
-
|
|
345
|
-
Beyond the global `authorize` hook, you can enforce security directly inside your query (`fetch`) and mutation (`create`, `update`, `delete`) handlers:
|
|
152
|
+
Inside the sync Worker, `createPublisher()` publishes directly to `platform.env.SYNC_ENGINE`. Inside the app Worker, it publishes through `platform.env.SYNC_WORKER.fetch()`. Without a binding, it uses `fallbackUrl`.
|
|
346
153
|
|
|
347
|
-
|
|
348
|
-
Use the handshake HTTP request (`ctx.request`) to dynamically filter the records fetched from the database, preventing users from pulling unauthorized rows.
|
|
154
|
+
## Cloudflare Configuration
|
|
349
155
|
|
|
350
|
-
|
|
351
|
-
fetch: async (ctx, since) => {
|
|
352
|
-
const db = getDB(ctx.platform);
|
|
353
|
-
const user = await getSession(ctx);
|
|
156
|
+
App Worker:
|
|
354
157
|
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
query = query.where(and(...conditions));
|
|
368
|
-
}
|
|
369
|
-
return await query;
|
|
370
|
-
}
|
|
371
|
-
```
|
|
372
|
-
|
|
373
|
-
#### 2. Write & Delete Handlers (Optional)
|
|
374
|
-
The `create`, `update`, and `delete` handlers are optional. If you omit any of these handlers, Sveltebase Sync treats the channel as read-only for that operation and will automatically reject any incoming client mutations.
|
|
375
|
-
|
|
376
|
-
If you *do* define them, you can throw regular JavaScript/TypeScript errors inside your mutation handlers. When an error is thrown:
|
|
377
|
-
1. The server catches the error and rejects the mutation.
|
|
378
|
-
2. The server sends a rejection response back to the client.
|
|
379
|
-
3. The client receives the rejection, triggers the `rollback` function, and reverts the optimistic UI change in IndexedDB.
|
|
380
|
-
|
|
381
|
-
```typescript
|
|
382
|
-
create: async (ctx, data) => {
|
|
383
|
-
const user = await getSession(ctx);
|
|
384
|
-
|
|
385
|
-
// Guard write action
|
|
386
|
-
if (user.role !== "editor" && user.role !== "admin") {
|
|
387
|
-
throw new Error("You do not have permission to create items.");
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const db = getDB(ctx.platform);
|
|
391
|
-
const [created] = await db.insert(todos).values(data).returning();
|
|
392
|
-
return created;
|
|
393
|
-
},
|
|
394
|
-
|
|
395
|
-
update: async (ctx, key, changes) => {
|
|
396
|
-
const user = await getSession(ctx);
|
|
397
|
-
const db = getDB(ctx.platform);
|
|
398
|
-
|
|
399
|
-
// Fetch target record to verify ownership
|
|
400
|
-
const [record] = await db.select().from(todos).where(eq(todos.id, key));
|
|
401
|
-
if (record.ownerId !== user.userId && user.role !== "admin") {
|
|
402
|
-
throw new Error("You cannot update a record owned by someone else.");
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
const [updated] = await db.update(todos).set(changes).where(eq(todos.id, key)).returning();
|
|
406
|
-
return updated;
|
|
407
|
-
},
|
|
408
|
-
|
|
409
|
-
delete: async (ctx, key) => {
|
|
410
|
-
const user = await getSession(ctx);
|
|
411
|
-
|
|
412
|
-
// Guard delete action
|
|
413
|
-
if (user.role !== "admin") {
|
|
414
|
-
throw new Error("Only admins can delete items.");
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
const db = getDB(ctx.platform);
|
|
418
|
-
await db.delete(todos).where(eq(todos.id, key));
|
|
158
|
+
```jsonc
|
|
159
|
+
{
|
|
160
|
+
"name": "my-app",
|
|
161
|
+
"main": ".svelte-kit/cloudflare/_worker.js",
|
|
162
|
+
"compatibility_date": "2026-06-07",
|
|
163
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
164
|
+
"services": [
|
|
165
|
+
{
|
|
166
|
+
"binding": "SYNC_WORKER",
|
|
167
|
+
"service": "my-app-sync"
|
|
168
|
+
}
|
|
169
|
+
]
|
|
419
170
|
}
|
|
420
171
|
```
|
|
421
172
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
### The `scope` Hook (Row-Level Broadcast Filtering)
|
|
425
|
-
The `scope` hook determines which of the connected and subscribed clients should receive real-time notifications when a database record is modified. It runs asynchronously after a mutation succeeds on the database.
|
|
426
|
-
|
|
427
|
-
> [!CAUTION]
|
|
428
|
-
> **Security Warning:** If you omit the `scope` hook, Sveltebase Sync defaults to broadcasting mutations to `"all"` subscribed connections.
|
|
429
|
-
> If your channel contains user-private data (meaning you filter by user ID inside the `fetch` handler), you **must** also define a `scope` hook that returns the user ID of the owner: `scope: (ctx, action, data) => [data.userId]`. Otherwise, a user's private updates will be broadcast to all connected users in real time.
|
|
430
|
-
|
|
431
|
-
* Return **`"all"`** to broadcast the change to every client subscribed to the channel.
|
|
432
|
-
* Return an **array of user IDs** (`string[]`) to restrict the broadcast. The broker will match these IDs against the connection's registered identity and skip broadcasting to everyone else.
|
|
433
|
-
|
|
434
|
-
```typescript
|
|
435
|
-
export const todoSync = defineSync<Todo>({
|
|
436
|
-
channel: "todos",
|
|
173
|
+
Sync Worker:
|
|
437
174
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
175
|
+
```jsonc
|
|
176
|
+
{
|
|
177
|
+
"name": "my-app-sync",
|
|
178
|
+
"main": "./src/worker/sync.ts",
|
|
179
|
+
"compatibility_date": "2026-06-07",
|
|
180
|
+
"compatibility_flags": ["nodejs_compat"],
|
|
181
|
+
"durable_objects": {
|
|
182
|
+
"bindings": [
|
|
183
|
+
{
|
|
184
|
+
"name": "SYNC_ENGINE",
|
|
185
|
+
"class_name": "SyncEngine"
|
|
186
|
+
}
|
|
187
|
+
]
|
|
188
|
+
},
|
|
189
|
+
"migrations": [
|
|
190
|
+
{
|
|
191
|
+
"tag": "v1",
|
|
192
|
+
"new_sqlite_classes": ["SyncEngine"]
|
|
445
193
|
}
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
const admins = await db
|
|
449
|
-
.select({ id: users.id })
|
|
450
|
-
.from(users)
|
|
451
|
-
.where(eq(users.role, "admin"));
|
|
452
|
-
|
|
453
|
-
return admins.map((admin) => admin.id);
|
|
454
|
-
}
|
|
455
|
-
});
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
#### How Connection Identities are Registered
|
|
459
|
-
The broker matches the user IDs returned by `scope` to each active client's socket state. The server registers the connection's identity during the handshake using query parameters or the `x-user-id` header:
|
|
460
|
-
```typescript
|
|
461
|
-
const userId = url.searchParams.get("userId") || request.headers.get("x-user-id");
|
|
462
|
-
```
|
|
463
|
-
|
|
464
|
-
##### Authenticating using SvelteKit Sessions & Cookies (Recommended)
|
|
465
|
-
Instead of exposing user IDs in client-side WebSocket URLs, you can resolve the user session on the server inside your SvelteKit route (`+server.ts`) and inject the verified `x-user-id` header before calling `handleUpgrade()`:
|
|
466
|
-
|
|
467
|
-
```typescript
|
|
468
|
-
// src/routes/api/sync/+server.ts
|
|
469
|
-
import { handleUpgrade } from "@sveltebase/sync";
|
|
470
|
-
import type { RequestEvent, RequestHandler } from "@sveltejs/kit";
|
|
471
|
-
|
|
472
|
-
export const GET: RequestHandler = (event: RequestEvent) => {
|
|
473
|
-
// 1. Get user identity from your custom server-side session/cookies
|
|
474
|
-
const user = event.locals.user; // e.g., set by your auth hook middleware
|
|
475
|
-
if (!user) {
|
|
476
|
-
return new Response("Unauthorized", { status: 401 });
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
// 2. Clone the request and inject the verified user ID header
|
|
480
|
-
const request = new Request(event.request);
|
|
481
|
-
request.headers.set("x-user-id", user.id);
|
|
482
|
-
|
|
483
|
-
// 3. Hand off to the sync engine
|
|
484
|
-
return handleUpgrade(request, event.platform);
|
|
485
|
-
};
|
|
194
|
+
]
|
|
195
|
+
}
|
|
486
196
|
```
|
|
487
|
-
This approach keeps WebSocket URLs clean of private IDs and ensures all active sockets are automatically authenticated with their verified session roles/IDs.
|
|
488
|
-
|
|
489
|
-
---
|
|
490
|
-
|
|
491
|
-
## 5. Type-Safe Backend Event Publishing (`createPublisher`)
|
|
492
|
-
|
|
493
|
-
When publishing backend events (e.g. from standard API routes, message queues, or cron triggers) to push updates to connected clients, you can create a type-safe publisher matched to your application's database schema. This checks channels (including dynamic channel patterns like `"todos:user_123"`), actions, and payloads at compile-time:
|
|
494
|
-
|
|
495
|
-
```typescript
|
|
496
|
-
import { createPublisher } from "@sveltebase/sync";
|
|
497
|
-
import type { Todo } from "$lib/server/db/schema";
|
|
498
|
-
|
|
499
|
-
// Define schema matching channel names to model types
|
|
500
|
-
type AppSyncSchema = {
|
|
501
|
-
todos: Todo;
|
|
502
|
-
};
|
|
503
|
-
|
|
504
|
-
// Create typed publish function (Option A: Explicit Schema)
|
|
505
|
-
const publish = createPublisher<AppSyncSchema>();
|
|
506
|
-
|
|
507
|
-
// Create typed publish function (Option B: Automatically inferred from Sync Handlers)
|
|
508
|
-
import { handlers } from "./lib/server/sync-handlers.js";
|
|
509
|
-
const publish = createPublisher(handlers);
|
|
510
|
-
|
|
511
|
-
// 1. Publish a create event (expects full Todo payload)
|
|
512
|
-
await publish("todos", "create", todo.id, todo);
|
|
513
197
|
|
|
514
|
-
|
|
515
|
-
await publish("todos", "update", todo.id, { completed: true });
|
|
198
|
+
Both Workers need the same session secret when using `@sveltebase/auth/sync`:
|
|
516
199
|
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
// 4. Supports scoped/dynamic channels (e.g. "channelName:scopeId")
|
|
521
|
-
await publish("todos:user_123", "update", todo.id, { title: "New Title" });
|
|
200
|
+
```bash
|
|
201
|
+
wrangler secret put JWT_SECRET --config wrangler.jsonc
|
|
202
|
+
wrangler secret put JWT_SECRET --config wrangler.sync.jsonc
|
|
522
203
|
```
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,EAAE,KAAK,KAAK,EAAE,MAAM,OAAO,CAAC;AAI1C,OAAO,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AACzD,YAAY,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAE7D,MAAM,MAAM,WAAW,GAAG;IACxB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;CACrC,CAAC;AAaF,cAAM,eAAe,CACnB,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CACzD,SAAQ,KAAK;IACb,OAAO,CAAC,KAAK,CAA4C;IACzD,OAAO,CAAC,MAAM,CAAwB;IACtC,OAAO,CAAC,YAAY,CAA8B;IAClD,OAAO,CAAC,cAAc,CAA4C;IAClE,OAAO,CAAC,YAAY,CAA6C;IACjE,OAAO,CAAC,cAAc,CAAS;IAC/B,OAAO,CAAC,cAAc,CAAqB;IAG3C,OAAO,CAAC,YAAY,CAA0B;IAG9C,OAAO,CAAC,gBAAgB,CAAsC;IAE9D,OAAO,CAAC,aAAa,CAMb;gBAEI,OAAO,EAAE;QACnB,IAAI,EAAE,MAAM,CAAC;QACb,GAAG,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;QAC/C,MAAM,EAAE,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,EAAE,WAAW,CAAC,CAAC;KACrD;IAoBD,IAAW,MAAM,gDAEhB;IAED,OAAO,CAAC,cAAc;YAiJR,OAAO;IAuFd,SAAS;IAgBhB,OAAO,CAAC,cAAc;IAStB,OAAO,CAAC,aAAa;YAOP,kBAAkB;IAoBhC,OAAO,CAAC,kBAAkB;YAkBZ,UAAU;YAiBV,aAAa;YAqBb,mBAAmB;IAyFjC,OAAO,CAAC,kBAAkB;IAQ1B,OAAO,CAAC,eAAe;IAqChB,UAAU;CAelB;AAED,MAAM,MAAM,UAAU,CAAC,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,IAAI,eAAe,CAAC,OAAO,CAAC,GAAG;KAC5G,CAAC,IAAI,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;CACxC,GAAG;IACF,CAAC,SAAS,EAAE,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC;CACjC,CAAC;AAEF,eAAO,MAAM,UAAU,EAAE,KACvB,OAAO,SAAS,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,GAAG,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,EACzD,OAAO,EAAE;IACT,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,GAAG,CAAC,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;IAC/C,MAAM,EAAE,MAAM,CAAC,MAAM,OAAO,GAAG,MAAM,EAAE,WAAW,CAAC,CAAC;CACrD,KAAK,UAAU,CAAC,OAAO,CAA0B,CAAC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"live-query.svelte.d.ts","sourceRoot":"","sources":["
|
|
1
|
+
{"version":3,"file":"live-query.svelte.d.ts","sourceRoot":"","sources":["live-query.svelte.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,cAAc,CAAC,CAAC,IAAI;IAC9B,IAAI,CAAC,EAAE,CAAC,CAAC;IACT,SAAS,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,GAAG,CAAC;CACb,CAAC;AAEF,wBAAgB,eAAe,CAAC,CAAC,EAC/B,OAAO,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,CAAC,EAC7B,YAAY,CAAC,EAAE,MAAM,OAAO,EAAE,GAC7B,cAAc,CAAC,CAAC,CAAC,CA6BnB"}
|