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.
Files changed (61) hide show
  1. package/README.md +500 -40
  2. package/dist/cjs/client/index.cjs +32 -17
  3. package/dist/cjs/client/index.d.cts +18 -3
  4. package/dist/cjs/client/storage/indexeddb.d.cts +1 -1
  5. package/dist/cjs/core/index.cjs +28 -28
  6. package/dist/cjs/core/index.d.cts +21 -9
  7. package/dist/cjs/react/index.cjs +48 -33
  8. package/dist/cjs/react/index.d.cts +23 -5
  9. package/dist/cjs/server/drizzle.cjs +2 -2
  10. package/dist/cjs/server/drizzle.d.cts +1 -1
  11. package/dist/cjs/server/ephemeral/index.cjs +180 -0
  12. package/dist/cjs/server/ephemeral/index.d.cts +102 -0
  13. package/dist/cjs/server/ephemeral/redis.cjs +226 -0
  14. package/dist/cjs/server/ephemeral/redis.d.cts +136 -0
  15. package/dist/cjs/server/index.cjs +238 -87
  16. package/dist/cjs/server/index.d.cts +196 -5
  17. package/dist/cjs/svelte/index.cjs +34 -19
  18. package/dist/cjs/svelte/index.d.cts +18 -3
  19. package/dist/cjs/transport/bun-ws.cjs +9 -9
  20. package/dist/cjs/transport/bun-ws.d.cts +1 -1
  21. package/dist/cjs/transport/polling.cjs +11 -11
  22. package/dist/cjs/transport/polling.d.cts +1 -1
  23. package/dist/cjs/transport/sse.cjs +11 -11
  24. package/dist/cjs/transport/sse.d.cts +1 -1
  25. package/dist/cjs/transport/ws.cjs +11 -11
  26. package/dist/cjs/transport/ws.d.cts +1 -1
  27. package/dist/cjs/vanilla/index.cjs +34 -19
  28. package/dist/cjs/vanilla/index.d.cts +18 -3
  29. package/dist/client/index.d.ts +18 -3
  30. package/dist/client/index.js +7 -7
  31. package/dist/client/storage/indexeddb.d.ts +1 -1
  32. package/dist/client/storage/indexeddb.js +1 -1
  33. package/dist/core/index.d.ts +21 -9
  34. package/dist/core/index.js +20 -20
  35. package/dist/react/index.d.ts +23 -5
  36. package/dist/react/index.js +23 -23
  37. package/dist/server/drizzle.d.ts +1 -1
  38. package/dist/server/drizzle.js +3 -3
  39. package/dist/server/ephemeral/index.d.ts +102 -0
  40. package/dist/server/ephemeral/index.js +9 -0
  41. package/dist/server/ephemeral/redis.d.ts +136 -0
  42. package/dist/server/ephemeral/redis.js +186 -0
  43. package/dist/server/index.d.ts +196 -5
  44. package/dist/server/index.js +184 -163
  45. package/dist/shared/{esm-ytrd3hbq.js → esm-8qbr4y0d.js} +18 -3
  46. package/dist/shared/{esm-wkwx6bd9.js → esm-ck88h30s.js} +10 -10
  47. package/dist/shared/esm-g0marxk7.js +134 -0
  48. package/dist/svelte/index.d.ts +18 -3
  49. package/dist/svelte/index.js +9 -9
  50. package/dist/transport/bun-ws.d.ts +1 -1
  51. package/dist/transport/bun-ws.js +1 -1
  52. package/dist/transport/polling.d.ts +1 -1
  53. package/dist/transport/polling.js +3 -3
  54. package/dist/transport/sse.d.ts +1 -1
  55. package/dist/transport/sse.js +3 -3
  56. package/dist/transport/ws.d.ts +1 -1
  57. package/dist/transport/ws.js +3 -3
  58. package/dist/vanilla/index.d.ts +18 -3
  59. package/dist/vanilla/index.js +9 -9
  60. package/package.json +66 -44
  61. /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 one end-to-end example [`examples/whiteboard/`](./examples/whiteboard/) a collaborative drawing app with two modes (freeform and **Pictionary**). It exercises the patterns below in one place. The snippets here are minimal, copy-paste-friendly references; see the example for how they fit together.
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
- throw new Error("not a member of this org");
388
+ return { ok: false, reason: "not a member of this org" };
368
389
  }
369
- return { scope: { orgId: params.orgId } };
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 a `setInterval` with `notifyChange` and the server stays the single source of truth.
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
- setInterval(async () => {
525
- const now = Date.now();
526
- const active = await db.select().from(games).where(eq(games.mode, "pictionary"));
527
- for (const g of active) {
528
- if (g.state === "drawing" && now >= g.roundEndsAt) {
529
- await endRound(g.id); // raw SQL writes
530
- await server.notifyChange("games"); // fan-out to subscribers
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
- }, 500);
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
- Pair with a per-row mutex if a tick can outrun its interval. The whiteboard example does both — see [`examples/whiteboard/server.tsx`](./examples/whiteboard/server.tsx).
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 place: [`examples/whiteboard/`](./examples/whiteboard/).
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
- | Authentication via better-auth (email/password + anonymous) | [`auth.ts`](./examples/whiteboard/auth.ts) |
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
- │ ├─ .auth(token → AuthContext) │ │ ├─ .insert/.update/.delete │
688
- │ ├─ .room(pattern, cb) │ │ ├─ .subscribe / .subscribeTable │
689
- │ ├─ .rateLimit({...}) │ │ ├─ .getRows / .getRow / .getState │
690
- │ ├─ .compaction({...}) │ │ ├─ .loadMore / .getTotalCount │
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(table) │ │ │
693
- └─ .close() │ │ Internal: │
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 → bootstrapping synced
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) server ──▶ missed deltas
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, and the server replays only what the client missed. This makes cross-server failover automatic when the log is shared (Postgres).
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
- Old ops are compacted on a schedule based on client inactivity and minimum op age. Reconnecting clients whose watermark has been compacted receive a fresh bootstrap.
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 types inferred from your schema.
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 + Windows)
1716
+ - CI: GitHub Actions (Ubuntu + macOS)
1257
1717
 
1258
1718
  ## License
1259
1719