reflectdb 0.1.0 → 0.1.2
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 +500 -40
- package/dist/cjs/client/index.cjs +32 -17
- package/dist/cjs/client/index.d.cts +18 -3
- package/dist/cjs/client/storage/indexeddb.d.cts +1 -1
- package/dist/cjs/core/index.cjs +28 -28
- package/dist/cjs/core/index.d.cts +21 -9
- package/dist/cjs/react/index.cjs +48 -33
- package/dist/cjs/react/index.d.cts +23 -5
- package/dist/cjs/server/drizzle.cjs +2 -2
- package/dist/cjs/server/drizzle.d.cts +1 -1
- package/dist/cjs/server/ephemeral/index.cjs +180 -0
- package/dist/cjs/server/ephemeral/index.d.cts +102 -0
- package/dist/cjs/server/ephemeral/redis.cjs +226 -0
- package/dist/cjs/server/ephemeral/redis.d.cts +136 -0
- package/dist/cjs/server/index.cjs +238 -87
- package/dist/cjs/server/index.d.cts +196 -5
- package/dist/cjs/svelte/index.cjs +34 -19
- package/dist/cjs/svelte/index.d.cts +18 -3
- package/dist/cjs/transport/bun-ws.cjs +9 -9
- package/dist/cjs/transport/bun-ws.d.cts +1 -1
- package/dist/cjs/transport/polling.cjs +11 -11
- package/dist/cjs/transport/polling.d.cts +1 -1
- package/dist/cjs/transport/sse.cjs +11 -11
- package/dist/cjs/transport/sse.d.cts +1 -1
- package/dist/cjs/transport/ws.cjs +11 -11
- package/dist/cjs/transport/ws.d.cts +1 -1
- package/dist/cjs/vanilla/index.cjs +34 -19
- package/dist/cjs/vanilla/index.d.cts +18 -3
- package/dist/client/index.d.ts +18 -3
- package/dist/client/index.js +7 -7
- package/dist/client/storage/indexeddb.d.ts +1 -1
- package/dist/client/storage/indexeddb.js +1 -1
- package/dist/core/index.d.ts +21 -9
- package/dist/core/index.js +20 -20
- package/dist/react/index.d.ts +23 -5
- package/dist/react/index.js +23 -23
- package/dist/server/drizzle.d.ts +1 -1
- package/dist/server/drizzle.js +3 -3
- package/dist/server/ephemeral/index.d.ts +102 -0
- package/dist/server/ephemeral/index.js +9 -0
- package/dist/server/ephemeral/redis.d.ts +136 -0
- package/dist/server/ephemeral/redis.js +186 -0
- package/dist/server/index.d.ts +196 -5
- package/dist/server/index.js +184 -163
- package/dist/shared/{esm-ytrd3hbq.js → esm-8qbr4y0d.js} +18 -3
- package/dist/shared/{esm-wkwx6bd9.js → esm-ck88h30s.js} +10 -10
- package/dist/shared/esm-g0marxk7.js +134 -0
- package/dist/svelte/index.d.ts +18 -3
- package/dist/svelte/index.js +9 -9
- package/dist/transport/bun-ws.d.ts +1 -1
- package/dist/transport/bun-ws.js +1 -1
- package/dist/transport/polling.d.ts +1 -1
- package/dist/transport/polling.js +3 -3
- package/dist/transport/sse.d.ts +1 -1
- package/dist/transport/sse.js +3 -3
- package/dist/transport/ws.d.ts +1 -1
- package/dist/transport/ws.js +3 -3
- package/dist/vanilla/index.d.ts +18 -3
- package/dist/vanilla/index.js +9 -9
- package/package.json +66 -44
- /package/dist/shared/{esm-b7xs9cde.js → esm-k7kedp3y.js} +0 -0
package/README.md
CHANGED
|
@@ -23,6 +23,7 @@ You bring your own types and your own database. reflectdb handles the protocol,
|
|
|
23
23
|
|
|
24
24
|
## Table of Contents
|
|
25
25
|
|
|
26
|
+
- [Demos](#demos)
|
|
26
27
|
- [Why reflectdb](#why-reflectdb)
|
|
27
28
|
- [Features](#features)
|
|
28
29
|
- [Use Cases](#use-cases)
|
|
@@ -36,12 +37,16 @@ You bring your own types and your own database. reflectdb handles the protocol,
|
|
|
36
37
|
- [Custom conflict resolvers](#custom-conflict-resolvers)
|
|
37
38
|
- [Validating client payloads](#validating-client-payloads)
|
|
38
39
|
- [Ephemeral messages (cursors, presence, typing)](#ephemeral-messages-cursors-presence-typing)
|
|
40
|
+
- [Typed presence](#typed-presence)
|
|
39
41
|
- [Per-user query results](#per-user-query-results)
|
|
42
|
+
- [Read-only views](#read-only-views)
|
|
40
43
|
- [Server-driven game loops](#server-driven-game-loops)
|
|
44
|
+
- [Transactional writes with `server.tx`](#transactional-writes-with-servertx)
|
|
41
45
|
- [Windowed sync and pagination](#windowed-sync-and-pagination)
|
|
42
46
|
- [Auto-generated REST API](#auto-generated-rest-api)
|
|
43
47
|
- [High availability with Postgres](#high-availability-with-postgres)
|
|
44
48
|
- [Whiteboard + Pictionary example](#whiteboard--pictionary-example)
|
|
49
|
+
- [Infinite Tetris example](#infinite-tetris-example)
|
|
45
50
|
- [Architecture](#architecture)
|
|
46
51
|
- [Core Concepts](#core-concepts)
|
|
47
52
|
- [Hybrid Logical Clocks](#hybrid-logical-clocks)
|
|
@@ -59,6 +64,8 @@ You bring your own types and your own database. reflectdb handles the protocol,
|
|
|
59
64
|
- [`reflectdb/transport/*`](#reflectdbtransport)
|
|
60
65
|
- [Configuration Reference](#configuration-reference)
|
|
61
66
|
- [Query definition](#query-definition)
|
|
67
|
+
- [View definition](#view-definition)
|
|
68
|
+
- [Presence definition](#presence-definition)
|
|
62
69
|
- [Server configuration](#server-configuration)
|
|
63
70
|
- [`implement()` options](#implement-options)
|
|
64
71
|
- [Rate limiting](#rate-limiting)
|
|
@@ -69,6 +76,17 @@ You bring your own types and your own database. reflectdb handles the protocol,
|
|
|
69
76
|
- [Development](#development)
|
|
70
77
|
- [License](#license)
|
|
71
78
|
|
|
79
|
+
## Demos
|
|
80
|
+
|
|
81
|
+
| Demo | Try it | What it demonstrates |
|
|
82
|
+
|------|--------|----------------------|
|
|
83
|
+
| **Infinite multiplayer Tetris** | [Play live](https://reflectdb-tetris.fly.dev/) · [source](./examples/tetris/) | Optimistic input prediction, server reconciliation and gravity, a live leaderboard, per-player progression, and Bun SQLite persistence in one perpetual game. Open two tabs to add another player. |
|
|
84
|
+
| **Collaborative whiteboard** | [Draw live](https://reflectdb-whiteboard.fly.dev/) · [source](./examples/whiteboard/) | Freeform drawing by default, optional Pictionary rounds, guest-authenticated rooms, ephemeral cursors, chat, presence, and per-user query results. Rooms and everything in them are deleted 30 minutes after they are created. Open two tabs to draw with yourself. |
|
|
85
|
+
|
|
86
|
+
Both demos run on one auto-stopping Fly Machine with no volume, so the first load
|
|
87
|
+
after an idle period may take a moment. Their data is intentionally ephemeral
|
|
88
|
+
across deployments and Machine replacement.
|
|
89
|
+
|
|
72
90
|
## Why reflectdb
|
|
73
91
|
|
|
74
92
|
Most real-time sync libraries force you to choose: CRDTs (powerful but opaque), or simple pub/sub (fast but brittle). reflectdb sits in the middle — **per-row operations** with **hybrid logical clocks** for causal ordering, validated through a server-side pipeline so your database stays authoritative.
|
|
@@ -108,8 +126,11 @@ Optional bits (use what you want):
|
|
|
108
126
|
- **Op log compaction** — configurable retention for old accepted ops
|
|
109
127
|
- **High availability** — shared Postgres + optional cross-instance polling
|
|
110
128
|
- **Framework bindings** — React hooks, Svelte stores, and a vanilla-JS helper; the core client works anywhere
|
|
111
|
-
- **Ephemeral channels** — presence, cursors, typing indicators that never touch the op log
|
|
129
|
+
- **Ephemeral channels** — presence, cursors, typing indicators that never touch the op log, with a room snapshot on join and a pluggable adapter (Redis included) so presence spans a fleet
|
|
130
|
+
- **Typed presence** — `presence()` in the schema, `usePresence()` in the component, key derived for you
|
|
131
|
+
- **Read-only views** — `view()` entries that recompute on their dependencies and reject writes at both levels
|
|
112
132
|
- **Windowed sync** — paginate large tables with `loadMore` + `useTotalCount`
|
|
133
|
+
- **Server-side toolkit** — `tx` (transaction + auto-notify), `lock` / `tryLock`, and self-disposing `interval` / `timeout`
|
|
113
134
|
|
|
114
135
|
## Use Cases
|
|
115
136
|
|
|
@@ -268,7 +289,7 @@ Open two tabs — edits in one appear in the other within a round-trip. Close th
|
|
|
268
289
|
|
|
269
290
|
## Recipes
|
|
270
291
|
|
|
271
|
-
The repo ships
|
|
292
|
+
The repo ships two end-to-end examples: [`examples/whiteboard/`](./examples/whiteboard/), a collaborative drawing app with two modes (freeform and **Pictionary**), and [`examples/tetris/`](./examples/tetris/), one perpetual Tetris game with no player cap. Between them they exercise the patterns below in one place. The snippets here are minimal, copy-paste-friendly references; see the examples for how they fit together.
|
|
272
293
|
|
|
273
294
|
### WebSocket sync with SQLite + Drizzle
|
|
274
295
|
|
|
@@ -364,12 +385,17 @@ For multi-tenant apps, use `room()` to pin a client to a subset of data:
|
|
|
364
385
|
```ts
|
|
365
386
|
server.room("org/:orgId", async ({ params, auth }) => {
|
|
366
387
|
if (!auth.memberships.includes(params.orgId)) {
|
|
367
|
-
|
|
388
|
+
return { ok: false, reason: "not a member of this org" };
|
|
368
389
|
}
|
|
369
|
-
return
|
|
390
|
+
// return nothing (or `{ ok: true }`) to allow the subscription
|
|
370
391
|
});
|
|
371
392
|
```
|
|
372
393
|
|
|
394
|
+
Room keys are resolved from the subscription's params and fail closed: params that
|
|
395
|
+
address a pattern only partially, or that produce a key the pattern can't match, are
|
|
396
|
+
rejected rather than falling back to an unscoped, cross-room subscription. Set
|
|
397
|
+
`room` in `implement()` to require a specific pattern for a query.
|
|
398
|
+
|
|
373
399
|
The whiteboard example wires this up with [better-auth](https://better-auth.com) — see [`examples/whiteboard/auth.ts`](./examples/whiteboard/auth.ts).
|
|
374
400
|
|
|
375
401
|
### Per-column merge for collaborative editing
|
|
@@ -483,8 +509,84 @@ broadcast({ x: e.clientX, y: e.clientY });
|
|
|
483
509
|
Object.values(events).map((c) => <Cursor x={c.x} y={c.y} />);
|
|
484
510
|
```
|
|
485
511
|
|
|
512
|
+
Fan-out follows the sender's **query subscriptions**: recipients are the clients
|
|
513
|
+
subscribed to the same queries, narrowed to the sender's room when one is resolved. A
|
|
514
|
+
client that has called no `sync()` yet has no audience, so its ephemeral messages reach
|
|
515
|
+
nobody. The `userId` on the wire is always the authenticated one — the client-supplied
|
|
516
|
+
value is ignored — and a client-supplied `ttlMs` is clamped server-side.
|
|
517
|
+
|
|
518
|
+
Subscribing to a room also delivers a **snapshot** of that room's live ephemeral
|
|
519
|
+
state, so a client that joins mid-session sees the peers already there instead of
|
|
520
|
+
waiting for each one to move again. Snapshots arrive as ordinary `ephemeral` events
|
|
521
|
+
and exclude the joiner's own entries.
|
|
522
|
+
|
|
523
|
+
By default this state lives in the server process, which is correct on one node
|
|
524
|
+
and invisible across a fleet — two clients on different instances never see each
|
|
525
|
+
other. Point `ephemeral.adapter` at shared infrastructure to fix both halves; see
|
|
526
|
+
[Ephemeral (presence)](#ephemeral-presence).
|
|
527
|
+
|
|
486
528
|
The whiteboard renders peer cursors this way — see [`examples/whiteboard/app.tsx`](./examples/whiteboard/app.tsx).
|
|
487
529
|
|
|
530
|
+
### Typed presence
|
|
531
|
+
|
|
532
|
+
`presence()` is `useEphemeral` with the shape declared in the schema instead of at the
|
|
533
|
+
call site. The channel key is derived from the entry name plus its serialized params, so
|
|
534
|
+
two components watching the same presence entry always agree on the key.
|
|
535
|
+
|
|
536
|
+
```ts
|
|
537
|
+
// schema.ts
|
|
538
|
+
import { defineSyncQueries, presence, t } from "reflectdb/core";
|
|
539
|
+
|
|
540
|
+
export const queries = defineSyncQueries({
|
|
541
|
+
cursor: presence({
|
|
542
|
+
state: t<{ x: number; y: number; name: string }>(),
|
|
543
|
+
params: t<{ gameId: string }>(), // part of the derived key
|
|
544
|
+
ttlMs: 10_000,
|
|
545
|
+
}),
|
|
546
|
+
});
|
|
547
|
+
```
|
|
548
|
+
|
|
549
|
+
```tsx
|
|
550
|
+
// app.tsx — usePresence comes from the typed factory, not the bare import
|
|
551
|
+
import { createSyncReact } from "reflectdb/react";
|
|
552
|
+
import { queries } from "./schema";
|
|
553
|
+
|
|
554
|
+
export const { SyncProvider, useSync, usePresence } = createSyncReact(queries);
|
|
555
|
+
|
|
556
|
+
function Cursors({ gameId }: { gameId: string }) {
|
|
557
|
+
const { peers, set } = usePresence("cursor", { gameId });
|
|
558
|
+
// ^? { userId: string; state: { x, y, name } }[]
|
|
559
|
+
|
|
560
|
+
useEffect(() => {
|
|
561
|
+
const onMove = (e: PointerEvent) =>
|
|
562
|
+
set({ x: e.clientX, y: e.clientY, name: myName });
|
|
563
|
+
window.addEventListener("pointermove", onMove);
|
|
564
|
+
return () => window.removeEventListener("pointermove", onMove);
|
|
565
|
+
}, [set]);
|
|
566
|
+
|
|
567
|
+
return peers.map((p) => <Cursor key={p.userId} {...p.state} />);
|
|
568
|
+
}
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
Details worth knowing:
|
|
572
|
+
|
|
573
|
+
- **No server registration.** Presence entries are not queries — there is no
|
|
574
|
+
`server.implement`/`server.view` for them. They ride the same ephemeral channel and
|
|
575
|
+
are room-scoped by the sender's active subscriptions.
|
|
576
|
+
- **`peers` excludes you.** Ephemeral events are only delivered to *other* clients, so
|
|
577
|
+
render your own cursor from local state.
|
|
578
|
+
- **Peers are keyed by connection, not by account.** Presence entries are keyed by
|
|
579
|
+
`clientId` end to end — on the wire, in the server's store, and in `peers` — so two
|
|
580
|
+
tabs from one login are two peers with two cursors. Put the display identity in
|
|
581
|
+
`state` (as `name` above) if you need it; the authenticated `userId` rides along on
|
|
582
|
+
every event for authorization and display.
|
|
583
|
+
- **Params are required when declared**, exactly like `useSync` — `usePresence("cursor")`
|
|
584
|
+
fails to compile if the entry declares params.
|
|
585
|
+
- **React only.** `createSyncSvelte` / `createSyncVanilla` have no presence helper; use
|
|
586
|
+
`sync.sendEphemeral` / `sync.onEphemeral` (or the store's `ephemeral()`) with your own
|
|
587
|
+
key there. `derivePresenceKey(name, params)` is exported from `reflectdb/react` if you
|
|
588
|
+
want to interoperate with the same channel by hand.
|
|
589
|
+
|
|
488
590
|
### Per-user query results
|
|
489
591
|
|
|
490
592
|
A `query` callback is just a function — it can return different rows depending on the caller's `auth`. reflectdb re-runs it whenever the listed `tables` change, so each subscriber gets a personalized view that stays live.
|
|
@@ -516,24 +618,152 @@ server.implement("roundWord", {
|
|
|
516
618
|
|
|
517
619
|
The `game_secrets` table isn't registered in `defineSyncQueries`, so it's never broadcast directly. Calling `server.notifyChange("game_secrets")` from the engine fans out the recomputed `roundWord` result to whichever client is now the drawer.
|
|
518
620
|
|
|
621
|
+
### Read-only views
|
|
622
|
+
|
|
623
|
+
The recipe above is a query that happens to reject writes. `view()` makes that the
|
|
624
|
+
declaration: the entry has no `mutate`, `useSync(...)` returns only `{ rows, loading }`,
|
|
625
|
+
and a write that reaches the server anyway is rejected with `readonly_query`.
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
// schema.ts
|
|
629
|
+
import { defineSyncQueries, view, t } from "reflectdb/core";
|
|
630
|
+
|
|
631
|
+
export const queries = defineSyncQueries({
|
|
632
|
+
leaderboard: view({
|
|
633
|
+
row: t<{ id: string; name: string; points: number }>(),
|
|
634
|
+
params: t<{ gameId: string }>(),
|
|
635
|
+
deps: ["games", "scores"], // re-run when either table changes
|
|
636
|
+
}),
|
|
637
|
+
});
|
|
638
|
+
```
|
|
639
|
+
|
|
640
|
+
```ts
|
|
641
|
+
// server.ts — server.view, not server.implement
|
|
642
|
+
server.view("leaderboard", (ctx, db) =>
|
|
643
|
+
db.select().from(scores)
|
|
644
|
+
.where(eq(scores.gameId, ctx.params.gameId))
|
|
645
|
+
.orderBy(desc(scores.points))
|
|
646
|
+
.limit(10));
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
```tsx
|
|
650
|
+
// app.tsx
|
|
651
|
+
const { rows } = useSync("leaderboard", { params: { gameId } });
|
|
652
|
+
// rows: { id, name, points }[] — there is no .insert / .update / .remove here
|
|
653
|
+
```
|
|
654
|
+
|
|
655
|
+
Notes:
|
|
656
|
+
|
|
657
|
+
- **`deps` drives change detection**, falling back to `tables` and then to the entry
|
|
658
|
+
name. A view over tables it doesn't share a name with must declare them, or it never
|
|
659
|
+
re-broadcasts.
|
|
660
|
+
- **`implement()` and `view()` are not interchangeable.** Calling `server.implement` on a
|
|
661
|
+
name declared as a view throws, and so does `server.view` on a name that isn't one.
|
|
662
|
+
- **`server.view(name, fn)` takes no options** — only the callback and the schema's
|
|
663
|
+
dependency list. There is no `authorize`, `room`, `groupBy`, `count`/`countHints` or
|
|
664
|
+
`pk` on a view. Do access control inside the callback (it gets `ctx.auth` and
|
|
665
|
+
`ctx.params`), and fall back to a regular `implement()` with a throwing `mutate` when
|
|
666
|
+
you need those knobs.
|
|
667
|
+
- **Rows need an `id`.** The primary key isn't configurable for views, so give each row a
|
|
668
|
+
stable `id` — that's what delta diffing keys on. Computed rows can synthesize one.
|
|
669
|
+
- **The type-level block is React-only.** `createSyncSvelte` / `createSyncVanilla` don't
|
|
670
|
+
narrow view entries, so a write there compiles and is refused at runtime instead.
|
|
671
|
+
|
|
519
672
|
### Server-driven game loops
|
|
520
673
|
|
|
521
|
-
Some apps need state that advances on a clock, not on user input — round timers, expiring claims, scheduled rotations. Pair
|
|
674
|
+
Some apps need state that advances on a clock, not on user input — round timers, expiring claims, scheduled rotations. Pair `server.interval` with `notifyChange` and the server stays the single source of truth.
|
|
522
675
|
|
|
523
676
|
```ts
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
677
|
+
server.interval(500, () =>
|
|
678
|
+
server.lock("tick", async () => { // a tick must never outrun itself
|
|
679
|
+
const now = Date.now();
|
|
680
|
+
const active = await db.select().from(games).where(eq(games.mode, "pictionary"));
|
|
681
|
+
for (const g of active) {
|
|
682
|
+
if (g.state === "drawing" && now >= g.roundEndsAt) {
|
|
683
|
+
await endRound(g.id); // raw SQL writes
|
|
684
|
+
await server.notifyChange("games"); // fan-out to subscribers
|
|
685
|
+
}
|
|
531
686
|
}
|
|
532
|
-
}
|
|
533
|
-
|
|
687
|
+
}),
|
|
688
|
+
);
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
`server.interval(ms, fn)` and `server.timeout(ms, fn)` wrap the globals with three
|
|
692
|
+
differences worth having: a throw or a rejected promise inside `fn` is caught and logged
|
|
693
|
+
instead of taking the process down, the handle is cleared by `server.close()`, and it is
|
|
694
|
+
disposed on `bun --hot` reload — so an edit-save loop doesn't leave a fleet of orphaned
|
|
695
|
+
timers ticking against the same rows. Both return `{ clear() }`.
|
|
696
|
+
|
|
697
|
+
`server.lock(key, fn)` serializes async work per key: calls queue and run one at a time,
|
|
698
|
+
and a failure in one doesn't poison the queue behind it. `server.tryLock(key, fn)` is the
|
|
699
|
+
skip-if-busy variant — it returns `null` immediately when the key is held, which is
|
|
700
|
+
usually what you want for a tick that would otherwise pile up.
|
|
701
|
+
|
|
702
|
+
```ts
|
|
703
|
+
const result = await server.tryLock(`game:${gameId}`, () => scoreRound(gameId));
|
|
704
|
+
if (result === null) return; // another call is already scoring this game
|
|
534
705
|
```
|
|
535
706
|
|
|
536
|
-
|
|
707
|
+
Both are in-process only. Across instances, keep the guard in the database (a
|
|
708
|
+
conditional `UPDATE … WHERE state = 'drawing'` that returns rows-affected) — the lock
|
|
709
|
+
protects a single Node/Bun process, not a cluster. The whiteboard example uses both —
|
|
710
|
+
see [`examples/whiteboard/server.tsx`](./examples/whiteboard/server.tsx).
|
|
711
|
+
|
|
712
|
+
### Transactional writes with `server.tx`
|
|
713
|
+
|
|
714
|
+
`notifyChange` per table gets tedious the moment one logical action touches three of
|
|
715
|
+
them. `server.tx` runs the work, tracks which tables it wrote, and fires one
|
|
716
|
+
`notifyChange` per touched table — only if the whole function succeeded.
|
|
717
|
+
|
|
718
|
+
```ts
|
|
719
|
+
await server.tx(async (tx) => {
|
|
720
|
+
await tx.update(games).set({ state: "scoring" }).where(eq(games.id, gameId));
|
|
721
|
+
await tx.insert(scores).values(rows);
|
|
722
|
+
await tx.delete(guesses).where(eq(guesses.gameId, gameId));
|
|
723
|
+
});
|
|
724
|
+
// → games, scores and guesses each broadcast once, after COMMIT
|
|
725
|
+
```
|
|
726
|
+
|
|
727
|
+
- **Atomic by default.** `atomic: true` is the default: the body runs inside
|
|
728
|
+
`BEGIN`/`COMMIT` and rolls back on throw. It resolves an adapter from
|
|
729
|
+
`ServerConfig.txAtomic`, falling back to a bundled Drizzle adapter (lazy-loaded, so
|
|
730
|
+
there's no top-level `drizzle-orm` dependency). With neither available it throws —
|
|
731
|
+
pass `atomic: false` for a non-transactional group, or supply your own adapter with
|
|
732
|
+
`server.tx({ atomic: myAdapter }, fn)`.
|
|
733
|
+
- **Table tracking is automatic for Drizzle only.** The proxy watches
|
|
734
|
+
`insert` / `update` / `delete` (`select` is not a write, so it doesn't count). On
|
|
735
|
+
Kysely, Prisma or raw SQL, call `tx.touch("games")` after each write.
|
|
736
|
+
- **Notifies never fire on a throw**, transactional or not.
|
|
737
|
+
- **Pooled connections need care.** `BEGIN`/`COMMIT` and the writes must share one
|
|
738
|
+
connection, so pass a single-connection handle when atomicity is load-bearing rather
|
|
739
|
+
than a pool.
|
|
740
|
+
|
|
741
|
+
For a single row there is `server.emit(table, payload)`. It generates a rowId, stamps an
|
|
742
|
+
HLC, writes reflectdb's mirror plus the op-log entry, and broadcasts:
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
const { rowId, hlc } = await server.emit("todos", { title: "filed by a cron", done: false });
|
|
746
|
+
await server.emit("todos", { done: true }, { rowId, type: "update" });
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
It does **not** call your `implement`'s `mutate`, so it does not write your database.
|
|
750
|
+
That makes it the right tool when reflectdb's own store is what your `query` reads, and
|
|
751
|
+
the wrong one when your database is — a broadcast re-runs the query, so a row your
|
|
752
|
+
database never received simply won't appear. When your write has to happen under the
|
|
753
|
+
same stamp, use the primitive `emit` and `server.rest()` are both built on:
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
await server.applyServerOp(
|
|
757
|
+
{ type: "insert", table: "todos", rowId, payload },
|
|
758
|
+
async (stamped) => { // runs before the mirror write
|
|
759
|
+
await db.insert(todos).values({ id: stamped.rowId, ...stamped.payload });
|
|
760
|
+
},
|
|
761
|
+
{ roomKey: `org/${orgId}` }, // keep the fanout inside the tenant
|
|
762
|
+
);
|
|
763
|
+
```
|
|
764
|
+
|
|
765
|
+
A throw inside `execute` aborts before anything touches the mirror or the op log. Omit
|
|
766
|
+
`roomKey` and the broadcast reaches every subscriber of the affected query, across rooms.
|
|
537
767
|
|
|
538
768
|
### Windowed sync and pagination
|
|
539
769
|
|
|
@@ -632,7 +862,9 @@ Each poll tick first probes the shared op log's head HLC; an idle tick costs one
|
|
|
632
862
|
|
|
633
863
|
## Whiteboard + Pictionary example
|
|
634
864
|
|
|
635
|
-
A complete React + Bun + Drizzle app that exercises most of reflectdb in one
|
|
865
|
+
A complete React + Bun + Drizzle app that exercises most of reflectdb in one
|
|
866
|
+
place: [`examples/whiteboard/`](./examples/whiteboard/). It is deployed at
|
|
867
|
+
[reflectdb-whiteboard.fly.dev](https://reflectdb-whiteboard.fly.dev/).
|
|
636
868
|
|
|
637
869
|
```bash
|
|
638
870
|
cd examples/whiteboard
|
|
@@ -646,13 +878,17 @@ Two modes:
|
|
|
646
878
|
- **Freeform draw** — every player can draw on a shared canvas. Strokes are LWW per row.
|
|
647
879
|
- **Pictionary** — players take turns drawing while the others guess in chat. The server picks a word, runs a per-round timer, awards points based on remaining time, advances the drawer, and ends the game after N full rotations.
|
|
648
880
|
|
|
881
|
+
Rooms are ephemeral: 30 minutes after a room is created, a server-side sweep
|
|
882
|
+
deletes it together with every stroke, chat line, player row and round secret
|
|
883
|
+
belonging to it. Both tabs bounce back to the lobby when it happens.
|
|
884
|
+
|
|
649
885
|
What it demonstrates:
|
|
650
886
|
|
|
651
887
|
| Pattern | Where |
|
|
652
888
|
|---------|-------|
|
|
653
889
|
| Drizzle-typed schema, SQLite op log | [`schema.ts`](./examples/whiteboard/schema.ts) |
|
|
654
890
|
| WebSocket transport on Bun | [`server.tsx`](./examples/whiteboard/server.tsx) |
|
|
655
|
-
|
|
|
891
|
+
| Guest-only authentication via better-auth's anonymous plugin | [`auth.ts`](./examples/whiteboard/auth.ts) |
|
|
656
892
|
| `params`-scoped queries (`strokes`, `messages` per game) | [`server.tsx`](./examples/whiteboard/server.tsx) |
|
|
657
893
|
| Per-user query results — only the drawer receives the secret word | `roundWord` in [`server.tsx`](./examples/whiteboard/server.tsx) |
|
|
658
894
|
| Server-side game loop with a mutex + `notifyChange` | `tick`, `withLock` in [`server.tsx`](./examples/whiteboard/server.tsx) |
|
|
@@ -660,6 +896,40 @@ What it demonstrates:
|
|
|
660
896
|
| `readonly` field enforcement to keep the engine state out of client hands | [`schema.ts`](./examples/whiteboard/schema.ts) |
|
|
661
897
|
| Ephemeral cursors per game, scoped via `key: \`cursor:${gameId}\`` | [`app.tsx`](./examples/whiteboard/app.tsx) |
|
|
662
898
|
| Per-table rate limiting (loose for strokes, tight for chat) | `server.rateLimit` in [`server.tsx`](./examples/whiteboard/server.tsx) |
|
|
899
|
+
| TTL sweep deleting whole rooms and their content out of band, with `notifyChange` turning it into client deletes | `sweepExpiredRooms` in [`schema.ts`](./examples/whiteboard/schema.ts) |
|
|
900
|
+
| One-Machine Fly.io deployment, prebuilt bundle and env-driven config | [`Dockerfile`](./examples/whiteboard/Dockerfile) / [`fly.toml`](./examples/whiteboard/fly.toml) / [`config.ts`](./examples/whiteboard/config.ts) |
|
|
901
|
+
|
|
902
|
+
The included Fly.io config runs on one auto-stopping 512 MB Machine; deploying
|
|
903
|
+
your own copy takes two commands, both covered in
|
|
904
|
+
[`examples/whiteboard/README.md`](./examples/whiteboard/README.md).
|
|
905
|
+
|
|
906
|
+
## Infinite Tetris example
|
|
907
|
+
|
|
908
|
+
One ongoing Tetris game with no player cap: [`examples/tetris/`](./examples/tetris/).
|
|
909
|
+
Every visitor gets a live 10×20 well and a random server-assigned name. Players join
|
|
910
|
+
and leave without rounds or rooms; top out and that player's score resets to zero
|
|
911
|
+
before a fresh run begins immediately.
|
|
912
|
+
|
|
913
|
+
```bash
|
|
914
|
+
cd examples/tetris
|
|
915
|
+
bun install
|
|
916
|
+
bun dev
|
|
917
|
+
# open http://localhost:3004 in two tabs — each tab is a player
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
Bun SQLite stores the authoritative wells and reflectdb sync log in one WAL
|
|
921
|
+
database. The included Fly.io config runs on one auto-stopping 256 MB Machine.
|
|
922
|
+
|
|
923
|
+
| Pattern | Where |
|
|
924
|
+
|---------|-------|
|
|
925
|
+
| Server-authoritative gravity — `server.interval` + `server.tryLock` | [`server.tsx`](./examples/tetris/server.tsx) |
|
|
926
|
+
| `groupBy` — one query execution for the global game instead of one per player | `players` in [`server.tsx`](./examples/tetris/server.tsx) |
|
|
927
|
+
| `serverSet` refreshing the player heartbeat | `players` in [`server.tsx`](./examples/tetris/server.tsx) |
|
|
928
|
+
| Read-only board, piece, random name, and score fields | [`schema.ts`](./examples/tetris/schema.ts) |
|
|
929
|
+
| Row ownership enforced with `MutationError` | `players.mutate` in [`server.tsx`](./examples/tetris/server.tsx) |
|
|
930
|
+
| `view()` leaderboard, recomputed from `players` | `standings` in [`server.tsx`](./examples/tetris/server.tsx) |
|
|
931
|
+
| Headless game rules and top-out reset tests | [`game.ts`](./examples/tetris/game.ts) / [`game.test.ts`](./examples/tetris/game.test.ts) |
|
|
932
|
+
| Bun SQLite persistence and restart tests | [`database.ts`](./examples/tetris/database.ts) / [`database.test.ts`](./examples/tetris/database.test.ts) |
|
|
663
933
|
|
|
664
934
|
## Architecture
|
|
665
935
|
|
|
@@ -684,21 +954,22 @@ What it demonstrates:
|
|
|
684
954
|
│ │ │ │
|
|
685
955
|
│ createSyncServer<TQueries>() │ │ createSyncClient<TQueries>() │
|
|
686
956
|
│ ├─ .implement(name, opts) │ │ ├─ .sync(name, params?) │
|
|
687
|
-
│ ├─ .
|
|
688
|
-
│ ├─ .
|
|
689
|
-
│ ├─ .
|
|
690
|
-
│ ├─ .compaction
|
|
957
|
+
│ ├─ .view(name, fn) │ │ ├─ .insert/.update/.delete │
|
|
958
|
+
│ ├─ .auth(token → AuthContext) │ │ ├─ .subscribe / .subscribeTable │
|
|
959
|
+
│ ├─ .room(pattern, cb) │ │ ├─ .getRows / .getRow / .getState │
|
|
960
|
+
│ ├─ .rateLimit / .compaction │ │ ├─ .loadMore / .getTotalCount │
|
|
691
961
|
│ ├─ .rest({ prefix }) │ │ └─ .sendEphemeral / .subscribeEph. │
|
|
692
|
-
│ ├─ .notifyChange
|
|
693
|
-
│
|
|
962
|
+
│ ├─ .notifyChange / .emit / .tx │ │ │
|
|
963
|
+
│ ├─ .lock / .interval / .timeout │ │ Internal: │
|
|
964
|
+
│ └─ .close() │ │ │
|
|
694
965
|
│ │ │ • SyncClient (state machine) │
|
|
695
966
|
│ Pipeline (per op): │ │ • ClientStore (row cache + queue) │
|
|
696
967
|
│ 1. clock drift check │ │ • OpCreator (HLC stamping) │
|
|
697
968
|
│ 2. rate limit (fail-open) │ │ │
|
|
698
969
|
│ 3. batch-size check │ │ State machine: │
|
|
699
|
-
│ 4. readonly enforcement │ │ hydrating → disconnected →
|
|
700
|
-
│ 5. serverSet injection │ │ connecting →
|
|
701
|
-
│ 6. conflict resolution* │ │
|
|
970
|
+
│ 4. readonly enforcement │ │ hydrating → disconnected → │
|
|
971
|
+
│ 5. serverSet injection │ │ connecting → connected → │
|
|
972
|
+
│ 6. conflict resolution* │ │ bootstrapping → synced │
|
|
702
973
|
│ │ │ Storage adapters: │
|
|
703
974
|
│ (* skipped by eager modes) │ │ • memory (ephemeral) │
|
|
704
975
|
│ │ │ • indexeddb (persistent) │
|
|
@@ -789,7 +1060,9 @@ Both eager broadcast modes skip conflict resolution entirely — writes land las
|
|
|
789
1060
|
3. client ──▶ ops (optimistic) server runs pipeline
|
|
790
1061
|
4. server ──▶ ack / reject
|
|
791
1062
|
5. server ──▶ delta (broadcast to subscribers)
|
|
792
|
-
6. client reconnects ──▶ resume (watermark HLC)
|
|
1063
|
+
6. client reconnects ──▶ resume (watermark HLC)
|
|
1064
|
+
7. server ──▶ snapshot per changed query,
|
|
1065
|
+
then resume_complete
|
|
793
1066
|
```
|
|
794
1067
|
|
|
795
1068
|
All messages are JSON; the transport is just a pipe. WebSocket gives bi-directional real-time; SSE gives server-push with POST for upstream; polling is stateless HTTP for constrained environments.
|
|
@@ -812,9 +1085,20 @@ A write therefore lands in two places: your `mutate` callback commits to your da
|
|
|
812
1085
|
|
|
813
1086
|
### The Op Log and Resume
|
|
814
1087
|
|
|
815
|
-
Every accepted mutation is appended to the server's op log with its HLC. On reconnect, the client sends its last seen HLC as a watermark
|
|
1088
|
+
Every accepted mutation is appended to the server's op log with its HLC. On reconnect, the client sends its last seen HLC as a watermark.
|
|
1089
|
+
|
|
1090
|
+
The server does **not** replay those ops to the client. It asks the log a single question — *which tables changed since this HLC* — and then re-executes the subscribed queries that depend on those tables, sending each result as a fresh `snapshot`, followed by `resume_complete`.
|
|
1091
|
+
|
|
1092
|
+
Re-running the query is what keeps resume honest: the client's own `auth`, params and room scoping are applied to what comes back, exactly as they were at bootstrap. A raw op replay would hand back rows the query itself would have filtered out.
|
|
816
1093
|
|
|
817
|
-
|
|
1094
|
+
Two details worth knowing:
|
|
1095
|
+
|
|
1096
|
+
- When at least one table has moved, queries depending on none of them are skipped. With no op log configured — or if the log lookup fails — every subscribed query re-runs instead.
|
|
1097
|
+
- Adapters that implement `getChangedTablesSince` answer with just the distinct table names. Without it, the handler falls back to `getOpsSince` and reads the op rows only to collect those names, which is unbounded work for a client that has been away a long time.
|
|
1098
|
+
|
|
1099
|
+
Because the watermark is only an HLC and the log is the only shared state, cross-server failover is automatic when that log is shared (Postgres) — any instance can answer the question.
|
|
1100
|
+
|
|
1101
|
+
Old ops are compacted on a schedule based on client inactivity and minimum op age. A client whose watermark is older than the compaction cutoff gets `resume_rejected` with `reason: "compacted"` and falls back to a fresh bootstrap.
|
|
818
1102
|
|
|
819
1103
|
## API Reference
|
|
820
1104
|
|
|
@@ -822,7 +1106,7 @@ Old ops are compacted on a schedule based on client inactivity and minimum op ag
|
|
|
822
1106
|
|
|
823
1107
|
```ts
|
|
824
1108
|
import {
|
|
825
|
-
defineSyncQueries, t,
|
|
1109
|
+
defineSyncQueries, t, view, presence,
|
|
826
1110
|
createHlc, sendHlc, receiveHlc, packHlc, unpackHlc, compareHlc,
|
|
827
1111
|
MutationError, TransportSendError, isErrorReason, reasonFromError,
|
|
828
1112
|
PROTOCOL_VERSION, MAX_CLOCK_DRIFT_MS, MAX_BATCH_SIZE,
|
|
@@ -834,13 +1118,15 @@ import {
|
|
|
834
1118
|
|--------|-------------|
|
|
835
1119
|
| `defineSyncQueries(map)` | Identity function that pins your schema's literal types. Feed its result to both server and client. |
|
|
836
1120
|
| `t<T>()` | Phantom helper to declare a row or params type. Returns `undefined as T`. |
|
|
1121
|
+
| `view({ row?, params?, deps?, tables? })` | Declare a read-only computed query. Registered with `server.view()`; writes are blocked at the type level and rejected at runtime. See [Read-only views](#read-only-views). |
|
|
1122
|
+
| `presence({ state?, params?, ttlMs? })` | Declare a typed ephemeral channel. Read with `usePresence()` from `createSyncReact`. See [Typed presence](#typed-presence). |
|
|
837
1123
|
| `createHlc(nodeId)` / `sendHlc` / `receiveHlc` | HLC constructors and transitions. |
|
|
838
1124
|
| `packHlc` / `unpackHlc` / `compareHlc` | Serialize, deserialize, compare HLC values. |
|
|
839
1125
|
| `MutationError(reason, message?)` | Throw from `mutate`/`authorize` to reject a write with a specific `ErrorReason`. |
|
|
840
1126
|
| `TransportSendError(clientId, message)` | Throw from a custom `ServerTransport.send` when a frame did not reach the peer. |
|
|
841
1127
|
| `isErrorReason(v)` / `reasonFromError(e)` | Validate / extract an `ErrorReason`. |
|
|
842
1128
|
|
|
843
|
-
Types: `HLC`, `SyncOp`, `OpType`, `OpStatus`, `ClientMessage`, `ServerMessage`, `ErrorReason`, `ConflictPolicy`, `ConflictResolver`, `SyncQueryDef`, `InferRow`, `InferParams`, `InferWritableRow`, `RequiresParams`, `RateLimitConfig`, `CompactionConfig`, `ShapeConfig`, `AuthContext`, `DrizzleTableLike`.
|
|
1129
|
+
Types: `HLC`, `SyncOp`, `OpType`, `OpStatus`, `ClientMessage`, `ServerMessage`, `ErrorReason`, `ConflictPolicy`, `ConflictResolver`, `SyncQueryDef`, `SyncViewDef`, `SyncPresenceDef`, `SyncQueryEntry`, `SyncQueryMap`, `InferRow`, `InferState`, `InferParams`, `InferWritableRow`, `RequiresParams`, `RateLimitConfig`, `CompactionConfig`, `ShapeConfig`, `AuthContext`, `DrizzleTableLike`.
|
|
844
1130
|
|
|
845
1131
|
### `reflectdb/server`
|
|
846
1132
|
|
|
@@ -858,16 +1144,27 @@ import {
|
|
|
858
1144
|
|
|
859
1145
|
| Method | Purpose |
|
|
860
1146
|
|--------|---------|
|
|
861
|
-
| `.implement(name, options)` | Register a query handler (required for every query in the schema). |
|
|
1147
|
+
| `.implement(name, options)` | Register a query handler (required for every regular query in the schema). |
|
|
1148
|
+
| `.view(name, fn)` | Register a read-only query declared with `view()`. No `mutate`; writes reject with `readonly_query`. |
|
|
862
1149
|
| `.auth(callback)` | Validate the connection request and return an `AuthContext`. |
|
|
863
|
-
| `.room(pattern, callback)` | Scope clients to a subset of data, matched against URL-style patterns (`org/:orgId`). |
|
|
1150
|
+
| `.room(pattern, callback)` | Scope clients to a subset of data, matched against URL-style patterns (`org/:orgId`). Return `{ ok: false, reason }` to deny. |
|
|
864
1151
|
| `.rateLimit(config)` | Set per-user/per-table limits. Fail-open on limiter errors. |
|
|
865
1152
|
| `.compaction(config)` | Configure op-log compaction. |
|
|
866
1153
|
| `.rest({ prefix })` | Generate a CRUD fetch handler. |
|
|
867
1154
|
| `.minSchemaVersion(n)` | Reject clients on older schema versions. |
|
|
868
|
-
| `.notifyChange(table)` | Manually trigger a broadcast (for external writes). |
|
|
1155
|
+
| `.notifyChange(table, roomKey?)` | Manually trigger a broadcast (for external writes). |
|
|
1156
|
+
| `.emit(table, payload, opts?)` | Server-origin row write: stamps an HLC, writes the mirror + op log, broadcasts. Does not call your `mutate`. Returns `{ hlc, rowId }`. |
|
|
1157
|
+
| `.applyServerOp(op, execute?, opts?)` | The primitive behind `emit` and `rest`. Hands the stamped HLC to `execute` before the mirror write; a throw aborts both. |
|
|
1158
|
+
| `.tx(fn)` / `.tx(opts, fn)` | Run a write group in a transaction (`atomic: true` by default), tracking touched tables and firing one `notifyChange` each on success. |
|
|
1159
|
+
| `.lock(key, fn)` | Serialize async work per key. `.tryLock(key, fn)` returns `null` instead of queueing when the key is held. |
|
|
1160
|
+
| `.interval(ms, fn)` / `.timeout(ms, fn)` | Timers that log instead of crashing on a throw, and auto-dispose on `close()` and `bun --hot` reload. Return `{ clear() }`. |
|
|
1161
|
+
| `.reserveOpId(id)` | Idempotency gate — `true` when the id is fresh. Used to dedupe REST retries. |
|
|
869
1162
|
| `.runCompaction()` | Manually run one compaction pass. |
|
|
870
|
-
| `.close()` | Shut down, disconnect clients, close storage. |
|
|
1163
|
+
| `.close()` | Shut down, disconnect clients, clear timers, close storage. |
|
|
1164
|
+
|
|
1165
|
+
See [Server-driven game loops](#server-driven-game-loops) for `interval` / `lock`, and
|
|
1166
|
+
[Transactional writes with `server.tx`](#transactional-writes-with-servertx) for `tx`,
|
|
1167
|
+
`emit` and `applyServerOp`.
|
|
871
1168
|
|
|
872
1169
|
`createServer()` is the lower-level untyped variant — use it only if you need to register queries dynamically or don't have a `defineSyncQueries` map.
|
|
873
1170
|
|
|
@@ -897,7 +1194,7 @@ import { createIndexedDBStorage } from "reflectdb/client/storage/indexeddb";
|
|
|
897
1194
|
| Windowing | `loadMore(name, count)`, `getTotalCount(name)` |
|
|
898
1195
|
| Ephemeral | `sendEphemeral({ key, userId, data, ttlMs? })`, `subscribeEphemeral(key, listener)` |
|
|
899
1196
|
|
|
900
|
-
State machine: `hydrating → disconnected → connecting → bootstrapping → synced`. Reconnects with exponential backoff (capped by `maxReconnectDelayMs`, default 30s).
|
|
1197
|
+
State machine: `hydrating → disconnected → connecting → connected → bootstrapping → synced`. Reconnects with exponential backoff (capped by `maxReconnectDelayMs`, default 30s).
|
|
901
1198
|
|
|
902
1199
|
### `reflectdb/react`
|
|
903
1200
|
|
|
@@ -907,7 +1204,7 @@ import {
|
|
|
907
1204
|
useSync, useSyncStatus, useRow,
|
|
908
1205
|
usePendingCount, useEphemeral,
|
|
909
1206
|
useTotalCount, useLoadMore,
|
|
910
|
-
createSyncReact,
|
|
1207
|
+
createSyncReact, derivePresenceKey,
|
|
911
1208
|
} from "reflectdb/react";
|
|
912
1209
|
```
|
|
913
1210
|
|
|
@@ -928,14 +1225,23 @@ import {
|
|
|
928
1225
|
| Hook | Returns |
|
|
929
1226
|
|------|---------|
|
|
930
1227
|
| `useSync(table, options?)` | `{ rows, insert, update, remove, loading }` — options: `{ params?, includeDeleted?, window? }` |
|
|
931
|
-
| `useSyncStatus()` | `"hydrating" \| "disconnected" \| "connecting" \| "bootstrapping" \| "synced"` |
|
|
1228
|
+
| `useSyncStatus()` | `"hydrating" \| "disconnected" \| "connecting" \| "connected" \| "bootstrapping" \| "synced"` |
|
|
932
1229
|
| `useRow(table, id)` | Single row or `null` |
|
|
933
1230
|
| `usePendingCount()` | Total unsynced op count |
|
|
934
1231
|
| `useEphemeral({ key, userId, ttlMs? })` | `{ events, broadcast }` |
|
|
935
1232
|
| `useTotalCount(table)` | Server-side count (requires `countHints: true`) |
|
|
936
1233
|
| `useLoadMore(table)` | Function to expand the sync window |
|
|
937
1234
|
|
|
938
|
-
**`createSyncReact<TQueries>()`** returns the same hook set with row and param
|
|
1235
|
+
**`createSyncReact<TQueries>(queries)`** returns the same hook set with row and param
|
|
1236
|
+
types inferred from your schema, plus two things the bare hooks can't provide:
|
|
1237
|
+
|
|
1238
|
+
| Hook | Returns |
|
|
1239
|
+
|------|---------|
|
|
1240
|
+
| `usePresence(name, params?)` | `{ peers, set }` for a `presence()` entry — `peers` is `{ userId, state }[]`, typed by the schema, and excludes you. Params are required when the entry declares them. |
|
|
1241
|
+
| `useSync(viewName)` | `{ rows, loading }` for a `view()` entry — the mutators are absent from the type *and* stripped at runtime. |
|
|
1242
|
+
|
|
1243
|
+
`derivePresenceKey(name, params)` produces the same channel key `usePresence` uses, for
|
|
1244
|
+
interoperating with `useEphemeral` or a non-React binding by hand.
|
|
939
1245
|
|
|
940
1246
|
### `reflectdb/svelte`
|
|
941
1247
|
|
|
@@ -1021,6 +1327,50 @@ Every entry in `defineSyncQueries({ ... })`:
|
|
|
1021
1327
|
|
|
1022
1328
|
`conflict` resolves the incoming op against **reflectdb's mirror** (its own JSONB row store and per-column clocks), not against your database. The two agree as long as every write goes through reflectdb and `mutate` persists the resolved payload verbatim — see [Two stores, one sync](#two-stores-one-sync).
|
|
1023
1329
|
|
|
1330
|
+
A schema entry can also be a **view** or a **presence** channel instead of a regular
|
|
1331
|
+
query. All three live in the same `defineSyncQueries({ ... })` map:
|
|
1332
|
+
|
|
1333
|
+
```ts
|
|
1334
|
+
import { defineSyncQueries, t, view, presence } from "reflectdb/core";
|
|
1335
|
+
|
|
1336
|
+
export const queries = defineSyncQueries({
|
|
1337
|
+
todos: { row: t<Todo>(), conflict: "lww" }, // regular → server.implement()
|
|
1338
|
+
leaderboard: view({ row: t<Score>(), deps: ["scores"] }), // → server.view()
|
|
1339
|
+
cursor: presence({ state: t<{ x: number; y: number }>() }), // → no server call
|
|
1340
|
+
});
|
|
1341
|
+
```
|
|
1342
|
+
|
|
1343
|
+
### View definition
|
|
1344
|
+
|
|
1345
|
+
```ts
|
|
1346
|
+
view({
|
|
1347
|
+
row: t<MyRow>(), // row type (or omit for Record<string, unknown>)
|
|
1348
|
+
params: t<{ gameId: string }>(),// typed params, same rules as a query
|
|
1349
|
+
deps: ["games", "scores"], // change-detection tables
|
|
1350
|
+
tables: ["scores"], // fallback when `deps` is absent
|
|
1351
|
+
})
|
|
1352
|
+
```
|
|
1353
|
+
|
|
1354
|
+
`deps` → `tables` → the entry name, in that order, decides what re-runs the view. Views
|
|
1355
|
+
have no `conflict`, `readonly`, `serverSet` or `pk` — they never accept a write.
|
|
1356
|
+
Register with [`server.view(name, fn)`](#read-only-views); `server.implement` on a view
|
|
1357
|
+
name throws.
|
|
1358
|
+
|
|
1359
|
+
### Presence definition
|
|
1360
|
+
|
|
1361
|
+
```ts
|
|
1362
|
+
presence({
|
|
1363
|
+
state: t<{ x: number; y: number; name: string }>(), // payload shape
|
|
1364
|
+
params: t<{ gameId: string }>(), // folded into the channel key
|
|
1365
|
+
ttlMs: 10_000, // entry expiry; omit for none
|
|
1366
|
+
})
|
|
1367
|
+
```
|
|
1368
|
+
|
|
1369
|
+
Presence entries are ephemeral channels, not queries: there is nothing to register
|
|
1370
|
+
server-side, nothing lands in the op log, and the fan-out is scoped by the sender's
|
|
1371
|
+
active room. Read them with `usePresence` from `createSyncReact(queries)` — see
|
|
1372
|
+
[Typed presence](#typed-presence).
|
|
1373
|
+
|
|
1024
1374
|
### Server configuration
|
|
1025
1375
|
|
|
1026
1376
|
```ts
|
|
@@ -1062,6 +1412,13 @@ server.implement("todos", {
|
|
|
1062
1412
|
});
|
|
1063
1413
|
```
|
|
1064
1414
|
|
|
1415
|
+
`query` may return whatever your data layer hands back, including live objects
|
|
1416
|
+
your own code mutates later — an in-memory store, a game loop, an ORM's tracked
|
|
1417
|
+
entities. Change detection snapshots each row when it caches it, so mutating the
|
|
1418
|
+
same object in place is still seen as a change. The snapshot is shallow: mutating
|
|
1419
|
+
a *nested* object inside a row is not, so treat nested values as immutable
|
|
1420
|
+
(replace them rather than editing in place).
|
|
1421
|
+
|
|
1065
1422
|
`broadcast` modes:
|
|
1066
1423
|
|
|
1067
1424
|
- **`consistent`** (default): run the conflict pipeline, persist, then broadcast by diffing each subscriber's re-executed query result.
|
|
@@ -1157,6 +1514,64 @@ At boot the client restores its persisted subscriptions first and hydrates only
|
|
|
1157
1514
|
| `createMemoryStorage()` | `reflectdb/client` | Testing, SSR, short sessions |
|
|
1158
1515
|
| `createIndexedDBStorage({ dbName, version?, migrate? })` | `reflectdb/client/storage/indexeddb` | Production browser apps |
|
|
1159
1516
|
|
|
1517
|
+
#### Ephemeral (presence)
|
|
1518
|
+
|
|
1519
|
+
Presence, cursors and typing indicators are stored separately from the op log —
|
|
1520
|
+
they never durably persist, and they have their own adapter.
|
|
1521
|
+
|
|
1522
|
+
| Adapter | Import | Best for |
|
|
1523
|
+
|---------|--------|----------|
|
|
1524
|
+
| _(none)_ | omit `ephemeral` | In-process store; single node |
|
|
1525
|
+
| `createRedisEphemeral({ client, subscriber?, prefix? })` | `reflectdb/server/ephemeral/redis` | Multiple instances behind a load balancer |
|
|
1526
|
+
|
|
1527
|
+
```ts
|
|
1528
|
+
import { createRedisEphemeral } from "reflectdb/server/ephemeral/redis";
|
|
1529
|
+
import Redis from "ioredis";
|
|
1530
|
+
|
|
1531
|
+
const commands = new Redis(process.env.REDIS_URL!);
|
|
1532
|
+
// Subscribe mode blocks ordinary commands, so the bus needs its own connection.
|
|
1533
|
+
const bus = new Redis(process.env.REDIS_URL!);
|
|
1534
|
+
|
|
1535
|
+
const server = createSyncServer({
|
|
1536
|
+
queries,
|
|
1537
|
+
db,
|
|
1538
|
+
transport,
|
|
1539
|
+
ephemeral: {
|
|
1540
|
+
adapter: createRedisEphemeral({
|
|
1541
|
+
client: commands,
|
|
1542
|
+
subscriber: {
|
|
1543
|
+
subscribe: (channel, onMessage) => {
|
|
1544
|
+
bus.on("message", (c, m) => { if (c === channel) onMessage(m); });
|
|
1545
|
+
return bus.subscribe(channel);
|
|
1546
|
+
},
|
|
1547
|
+
},
|
|
1548
|
+
}),
|
|
1549
|
+
},
|
|
1550
|
+
});
|
|
1551
|
+
```
|
|
1552
|
+
|
|
1553
|
+
`client` needs one method — `call(command, ...args)`, which `ioredis` has natively.
|
|
1554
|
+
For node-redis or Bun, wrap it:
|
|
1555
|
+
|
|
1556
|
+
```ts
|
|
1557
|
+
// node-redis
|
|
1558
|
+
{ call: (cmd, ...args) => client.sendCommand([cmd, ...args.map(String)]) }
|
|
1559
|
+
// Bun
|
|
1560
|
+
{ call: (cmd, ...args) => client.send(cmd, args.map(String)) }
|
|
1561
|
+
```
|
|
1562
|
+
|
|
1563
|
+
Options: `prefix` (default `reflectdb:eph`), `maxEntries` (default `100_000`,
|
|
1564
|
+
fleet-wide), `hashTtlSeconds` (default 24h — a safety net so a crashed instance
|
|
1565
|
+
can't strand entries forever). Omit `subscriber` to share state without a live
|
|
1566
|
+
bus: peers then appear on join and after a sweep, but not as they move.
|
|
1567
|
+
|
|
1568
|
+
`ephemeral.maxEntries` on its own tunes the in-process store's ceiling
|
|
1569
|
+
(default `10_000`) without swapping the adapter.
|
|
1570
|
+
|
|
1571
|
+
Implement `EphemeralAdapter` (from `reflectdb/server/ephemeral`) to back
|
|
1572
|
+
presence with something else. `publish`/`subscribe` are optional — an adapter
|
|
1573
|
+
without them is a shared store with no live bus.
|
|
1574
|
+
|
|
1160
1575
|
### Transport configuration
|
|
1161
1576
|
|
|
1162
1577
|
#### WebSocket
|
|
@@ -1228,6 +1643,51 @@ bun install
|
|
|
1228
1643
|
| `bun run verify:exports` | Check the `exports` map against `dist/`, and type-check the emitted declarations without ambient Bun/React globals (run after `build`) |
|
|
1229
1644
|
| `bun run verify:node` | Install the packed tarball into a throwaway Node project and check every subpath imports, requires, and type-checks there under both export conditions (run after `build`; needs `node` + `npm`) |
|
|
1230
1645
|
|
|
1646
|
+
### Landing page and social cards
|
|
1647
|
+
|
|
1648
|
+
The site at [reflectdb.dev](https://reflectdb.dev) lives in `landing/`:
|
|
1649
|
+
|
|
1650
|
+
```bash
|
|
1651
|
+
cd landing
|
|
1652
|
+
bun install
|
|
1653
|
+
bun run dev # vite dev server
|
|
1654
|
+
bun run og # regenerate the social cards in landing/public
|
|
1655
|
+
```
|
|
1656
|
+
|
|
1657
|
+
`bun run og` renders every card in `landing/og/` with your local Chrome (set
|
|
1658
|
+
`CHROME_PATH` if it lives somewhere unusual) and writes the PNGs the pages
|
|
1659
|
+
reference:
|
|
1660
|
+
|
|
1661
|
+
| Card | Output | Size | Used by |
|
|
1662
|
+
|------|--------|------|---------|
|
|
1663
|
+
| `og/index.html` | `public/og.png` | 1200x630 at 2x | reflectdb.dev |
|
|
1664
|
+
| `og/tetris.html` | `public/og-tetris.png` | 1200x630 at 2x | the Tetris demo — served from reflectdb.dev, since the Fly Machine sleeps |
|
|
1665
|
+
| `og/whiteboard.html` | `public/og-whiteboard.png` | 1200x630 at 2x | the whiteboard demo, served from reflectdb.dev for the same reason |
|
|
1666
|
+
| `og/github.html` | `public/og-github.png` | 1280x640 at 2x | this repository's social preview, uploaded by hand under Settings → Social preview |
|
|
1667
|
+
|
|
1668
|
+
Edit the HTML, not the PNGs. 1200x630 is the one ratio X, Facebook, LinkedIn,
|
|
1669
|
+
Slack, Discord, Telegram, Mastodon and iMessage all unfurl without cropping, and
|
|
1670
|
+
each card has to stay under 300 kB or WhatsApp silently downgrades it to a small
|
|
1671
|
+
thumbnail — the script renders at the largest scale factor that fits, and fails
|
|
1672
|
+
if a card is oversized or the wrong shape.
|
|
1673
|
+
|
|
1674
|
+
#### Analytics
|
|
1675
|
+
|
|
1676
|
+
The site reports page views to a self-hosted [Umami](https://umami.is) instance,
|
|
1677
|
+
and does so only when it is configured to. `landing/vite.config.ts` injects the
|
|
1678
|
+
tag into `index.html` at build time from these variables — with the website id
|
|
1679
|
+
unset, nothing is injected and the page makes no third-party request, so a local
|
|
1680
|
+
dev server or a fork builds and runs untouched:
|
|
1681
|
+
|
|
1682
|
+
| Variable | Required | Default | Purpose |
|
|
1683
|
+
|----------|----------|---------|---------|
|
|
1684
|
+
| `VITE_UMAMI_WEBSITE_ID` | yes, to enable | — | The site's id in Umami. Unset disables analytics entirely. |
|
|
1685
|
+
| `VITE_UMAMI_SCRIPT_URL` | no | `https://linesofcode-umami.vercel.app/script.js` | The tracker script, if you host Umami elsewhere. |
|
|
1686
|
+
| `VITE_UMAMI_DOMAINS` | no | — | Comma-separated hostnames to count. Set it to `reflectdb.dev` to keep preview deployments and localhost out of the numbers. |
|
|
1687
|
+
|
|
1688
|
+
They are read at build time, so changing one in the Vercel project takes effect
|
|
1689
|
+
on the next deployment rather than the next request.
|
|
1690
|
+
|
|
1231
1691
|
### Project Structure
|
|
1232
1692
|
|
|
1233
1693
|
```
|
|
@@ -1253,7 +1713,7 @@ src/
|
|
|
1253
1713
|
- Test: `bun:test`
|
|
1254
1714
|
- Lint: oxlint
|
|
1255
1715
|
- Format: oxfmt
|
|
1256
|
-
- CI: GitHub Actions (Ubuntu + macOS
|
|
1716
|
+
- CI: GitHub Actions (Ubuntu + macOS)
|
|
1257
1717
|
|
|
1258
1718
|
## License
|
|
1259
1719
|
|