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
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Ephemeral (presence, cursors, typing) storage and fan-out seam.
3
+ *
4
+ * The default adapter keeps state in the server process, which is correct for a
5
+ * single node and invisible across a fleet: two clients on different instances
6
+ * never see each other. An adapter backed by shared infrastructure (Redis, or a
7
+ * hosted service) fixes both halves — the shared store answers "who is here"
8
+ * for a client that just joined, and the bus carries each event to the peers
9
+ * holding the other sockets.
10
+ */
11
+ type MaybePromise<T> = T | Promise<T>;
12
+ interface EphemeralState {
13
+ clientId: string;
14
+ userId: string;
15
+ key: string;
16
+ data: Record<string, unknown>;
17
+ updatedAt: number;
18
+ ttlMs?: number;
19
+ }
20
+ /** One fan-out target: subscribers of `query`, narrowed to `room` when set. */
21
+ interface EphemeralTarget {
22
+ query: string;
23
+ room: string | null;
24
+ }
25
+ /**
26
+ * An ephemeral event as it crosses the bus between server instances.
27
+ *
28
+ * Recipients are resolved on the origin instance and carried here as `targets`,
29
+ * because the receiving instance holds no session for the sender and so cannot
30
+ * re-derive them. `serverId` lets an instance drop its own echo on buses that
31
+ * deliver published messages back to the publisher.
32
+ */
33
+ interface EphemeralBroadcast {
34
+ serverId: string;
35
+ key: string;
36
+ clientId: string;
37
+ userId: string;
38
+ data: Record<string, unknown>;
39
+ ttlMs?: number;
40
+ targets: EphemeralTarget[];
41
+ }
42
+ /**
43
+ * Backing store and fan-out bus for ephemeral state.
44
+ *
45
+ * `publish`/`subscribe` are optional: an adapter that omits them is a
46
+ * single-process store, and the handler still fans out to its own sockets.
47
+ */
48
+ interface EphemeralAdapter {
49
+ /**
50
+ * Record one entry, keyed by `clientId`.
51
+ *
52
+ * Peer identity is the connection, not the account: two tabs from one login
53
+ * are two cursors, and the client bindings key peers the same way. `userId`
54
+ * rides along for display and authorization, not as the entry's identity.
55
+ *
56
+ * Returns false when the adapter is at capacity, which the handler surfaces
57
+ * as `ephemeral_full` rather than evicting silently.
58
+ */
59
+ set(room: string, key: string, clientId: string, userId: string, data: Record<string, unknown>, ttlMs?: number): MaybePromise<boolean>;
60
+ /** Live entries for one channel, keyed by clientId. */
61
+ get(room: string, key: string): MaybePromise<Record<string, EphemeralState>>;
62
+ /**
63
+ * Live entries for every channel in a room, keyed by channel key then
64
+ * clientId. Backs the snapshot a client receives when it joins.
65
+ */
66
+ getRoom(room: string): MaybePromise<Record<string, Record<string, EphemeralState>>>;
67
+ remove(room: string, key: string, clientId: string): MaybePromise<void>;
68
+ /** Drop everything a disconnecting client published. */
69
+ removeClient(clientId: string): MaybePromise<void>;
70
+ /** Sweep entries past their TTL. Called on a timer by the handler. */
71
+ cleanupExpired(): MaybePromise<void>;
72
+ /** Current entry count, for the capacity gate and `ephemeral_full`. */
73
+ size(): MaybePromise<number>;
74
+ destroy(): MaybePromise<void>;
75
+ /** Hand an event to peer instances. Absent on single-process adapters. */
76
+ publish?(event: EphemeralBroadcast): MaybePromise<void>;
77
+ /**
78
+ * Register the handler's delivery callback for events published by peers.
79
+ * Called once during wiring. Absent on single-process adapters.
80
+ */
81
+ subscribe?(onEvent: (event: EphemeralBroadcast) => void): MaybePromise<void>;
82
+ }
83
+ /**
84
+ * Minimal Redis client interface — one raw-command entrypoint.
85
+ *
86
+ * Compatible with ioredis (`client.call`) directly. For node-redis, wrap it:
87
+ *
88
+ * ```ts
89
+ * const shim = { call: (cmd, ...args) => client.sendCommand([cmd, ...args.map(String)]) };
90
+ * ```
91
+ */
92
+ interface RedisLike {
93
+ call(command: string, ...args: (string | number)[]): Promise<unknown>;
94
+ }
95
+ /**
96
+ * Pub/sub seam — one function, because no two Redis clients agree on the shape
97
+ * of theirs (argument order, `on("message")` vs a per-channel listener). Wire
98
+ * yours in a two-line shim rather than have this module guess:
99
+ *
100
+ * ```ts
101
+ * // ioredis (separate connection — subscribe mode blocks other commands)
102
+ * { subscribe: (ch, onMessage) => {
103
+ * sub.on("message", (c, m) => { if (c === ch) onMessage(m); });
104
+ * return sub.subscribe(ch);
105
+ * } }
106
+ *
107
+ * // Bun
108
+ * { subscribe: (ch, onMessage) => sub.subscribe(ch, (msg) => onMessage(msg)) }
109
+ * ```
110
+ */
111
+ interface RedisSubscriberLike {
112
+ subscribe(channel: string, onMessage: (payload: string) => void): Promise<unknown> | unknown;
113
+ }
114
+ interface RedisEphemeralConfig {
115
+ /** Command connection. */
116
+ client: RedisLike;
117
+ /**
118
+ * Separate connection used for pub/sub. Omit to run shared-state-only —
119
+ * peers then see each other's presence on join and on sweep, but not live.
120
+ */
121
+ subscriber?: RedisSubscriberLike;
122
+ /** Key prefix. Default: "reflectdb:eph" */
123
+ prefix?: string;
124
+ /** Global entry ceiling across the fleet. Default: 100_000 */
125
+ maxEntries?: number;
126
+ /**
127
+ * Safety-net expiry on state hashes, in seconds. Guards against a crashed
128
+ * instance leaving entries behind when its clients never disconnect
129
+ * cleanly. Default: 86400 (24h), matching the handler's TTL clamp.
130
+ */
131
+ hashTtlSeconds?: number;
132
+ }
133
+ declare function createRedisEphemeral(config: RedisEphemeralConfig): EphemeralAdapter & {
134
+ ready(): Promise<void>;
135
+ };
136
+ export { RedisEphemeralConfig, RedisLike, RedisSubscriberLike, createRedisEphemeral };
@@ -0,0 +1,186 @@
1
+ import"../../shared/esm-k7kedp3y.js";
2
+
3
+ // src/server/ephemeral/redis.ts
4
+ var DEFAULT_PREFIX = "reflectdb:eph";
5
+ var DEFAULT_MAX_ENTRIES = 1e5;
6
+ var DEFAULT_HASH_TTL_SECONDS = 24 * 60 * 60;
7
+ var SWEEP_BATCH = 1000;
8
+ function packed(room, key, clientId) {
9
+ return `${room}\x00${key}\x00${clientId}`;
10
+ }
11
+ function unpack(value) {
12
+ const [room, key, clientId] = value.split("\x00");
13
+ return { room, key, clientId };
14
+ }
15
+ function hashEntries(reply) {
16
+ if (Array.isArray(reply)) {
17
+ const out = [];
18
+ for (let i = 0;i + 1 < reply.length; i += 2) {
19
+ out.push([String(reply[i]), String(reply[i + 1])]);
20
+ }
21
+ return out;
22
+ }
23
+ if (reply && typeof reply === "object") {
24
+ return Object.entries(reply).map(([k, v]) => [k, String(v)]);
25
+ }
26
+ return [];
27
+ }
28
+ function toStringArray(reply) {
29
+ return Array.isArray(reply) ? reply.map((v) => String(v)) : [];
30
+ }
31
+ var SET_SCRIPT = `
32
+ local isNew = 0
33
+ if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then
34
+ local count = tonumber(redis.call('GET', KEYS[5]) or '0')
35
+ if count >= tonumber(ARGV[6]) then return 0 end
36
+ isNew = 1
37
+ end
38
+ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
39
+ redis.call('EXPIRE', KEYS[1], ARGV[7])
40
+ redis.call('SADD', KEYS[2], ARGV[4])
41
+ redis.call('EXPIRE', KEYS[2], ARGV[7])
42
+ redis.call('SADD', KEYS[3], ARGV[3])
43
+ redis.call('EXPIRE', KEYS[3], ARGV[7])
44
+ if ARGV[5] ~= '' then
45
+ redis.call('ZADD', KEYS[4], ARGV[5], ARGV[3])
46
+ else
47
+ redis.call('ZREM', KEYS[4], ARGV[3])
48
+ end
49
+ if isNew == 1 then redis.call('INCR', KEYS[5]) end
50
+ return 1
51
+ `;
52
+ var REMOVE_SCRIPT = `
53
+ if redis.call('HDEL', KEYS[1], ARGV[1]) == 0 then return 0 end
54
+ redis.call('ZREM', KEYS[3], ARGV[2])
55
+ redis.call('SREM', KEYS[5], ARGV[2])
56
+ if redis.call('HLEN', KEYS[1]) == 0 then
57
+ redis.call('SREM', KEYS[2], ARGV[3])
58
+ end
59
+ local count = tonumber(redis.call('GET', KEYS[4]) or '0')
60
+ if count > 0 then redis.call('DECR', KEYS[4]) end
61
+ return 1
62
+ `;
63
+ function createRedisEphemeral(config) {
64
+ const client = config.client;
65
+ const p = config.prefix ?? DEFAULT_PREFIX;
66
+ const maxEntries = config.maxEntries ?? DEFAULT_MAX_ENTRIES;
67
+ const hashTtl = config.hashTtlSeconds ?? DEFAULT_HASH_TTL_SECONDS;
68
+ const stateKey = (room, key) => `${p}:s:${room}:${key}`;
69
+ const roomKeysKey = (room) => `${p}:rk:${room}`;
70
+ const clientKey = (clientId) => `${p}:c:${clientId}`;
71
+ const expKey = `${p}:exp`;
72
+ const countKey = `${p}:n`;
73
+ const busChannel = `${p}:bus`;
74
+ let readyPromise = null;
75
+ let onEventCallback = null;
76
+ function parseState(raw) {
77
+ try {
78
+ return JSON.parse(raw);
79
+ } catch {
80
+ return null;
81
+ }
82
+ }
83
+ function isLive(state, now) {
84
+ return !state.ttlMs || now - state.updatedAt <= state.ttlMs;
85
+ }
86
+ async function removeEntry(room, key, clientId) {
87
+ await client.call("EVAL", REMOVE_SCRIPT, 5, stateKey(room, key), roomKeysKey(room), expKey, countKey, clientKey(clientId), clientId, packed(room, key, clientId), key);
88
+ }
89
+ async function ready() {
90
+ if (readyPromise)
91
+ return readyPromise;
92
+ const subscriber = config.subscriber;
93
+ if (!subscriber)
94
+ return;
95
+ readyPromise = (async () => {
96
+ await subscriber.subscribe(busChannel, (payload) => {
97
+ if (!onEventCallback)
98
+ return;
99
+ try {
100
+ onEventCallback(JSON.parse(payload));
101
+ } catch {}
102
+ });
103
+ })();
104
+ return readyPromise;
105
+ }
106
+ return {
107
+ ready,
108
+ async set(room, key, clientId, userId, data, ttlMs) {
109
+ const now = Date.now();
110
+ const state = {
111
+ clientId,
112
+ userId,
113
+ key,
114
+ data,
115
+ updatedAt: now,
116
+ ttlMs
117
+ };
118
+ const entry = packed(room, key, clientId);
119
+ const result = await client.call("EVAL", SET_SCRIPT, 5, stateKey(room, key), roomKeysKey(room), clientKey(clientId), expKey, countKey, clientId, JSON.stringify(state), entry, key, ttlMs ? String(now + ttlMs) : "", String(maxEntries), String(hashTtl));
120
+ return Number(result) === 1;
121
+ },
122
+ async get(room, key) {
123
+ const reply = await client.call("HGETALL", stateKey(room, key));
124
+ const now = Date.now();
125
+ const out = {};
126
+ for (const [entryClientId, raw] of hashEntries(reply)) {
127
+ const state = parseState(raw);
128
+ if (state && isLive(state, now))
129
+ out[entryClientId] = state;
130
+ }
131
+ return out;
132
+ },
133
+ async getRoom(room) {
134
+ const keys = toStringArray(await client.call("SMEMBERS", roomKeysKey(room)));
135
+ const now = Date.now();
136
+ const out = {};
137
+ for (const key of keys) {
138
+ const reply = await client.call("HGETALL", stateKey(room, key));
139
+ const channel = {};
140
+ for (const [entryClientId, raw] of hashEntries(reply)) {
141
+ const state = parseState(raw);
142
+ if (state && isLive(state, now))
143
+ channel[entryClientId] = state;
144
+ }
145
+ if (Object.keys(channel).length > 0)
146
+ out[key] = channel;
147
+ }
148
+ return out;
149
+ },
150
+ async remove(room, key, clientId) {
151
+ await removeEntry(room, key, clientId);
152
+ },
153
+ async removeClient(clientId) {
154
+ const entries = toStringArray(await client.call("SMEMBERS", clientKey(clientId)));
155
+ for (const value of entries) {
156
+ const { room, key, clientId: owner } = unpack(value);
157
+ await removeEntry(room, key, owner);
158
+ }
159
+ await client.call("DEL", clientKey(clientId));
160
+ },
161
+ async cleanupExpired() {
162
+ const due = toStringArray(await client.call("ZRANGEBYSCORE", expKey, "-inf", String(Date.now()), "LIMIT", 0, SWEEP_BATCH));
163
+ for (const value of due) {
164
+ const { room, key, clientId } = unpack(value);
165
+ await removeEntry(room, key, clientId);
166
+ }
167
+ },
168
+ async size() {
169
+ const reply = await client.call("GET", countKey);
170
+ return reply == null ? 0 : Number(reply);
171
+ },
172
+ async destroy() {
173
+ onEventCallback = null;
174
+ },
175
+ async publish(event) {
176
+ await client.call("PUBLISH", busChannel, JSON.stringify(event));
177
+ },
178
+ async subscribe(onEvent) {
179
+ onEventCallback = onEvent;
180
+ await ready();
181
+ }
182
+ };
183
+ }
184
+ export {
185
+ createRedisEphemeral
186
+ };
@@ -250,11 +250,15 @@ interface DrizzleTableLike {
250
250
  /**
251
251
  * Value supplied per serverSet field in the schema's object form.
252
252
  * Either a static value or a function that receives the request context.
253
+ *
254
+ * The static arm is enumerated rather than written as `unknown`: a union with
255
+ * `unknown` collapses to `unknown`, which strips the contextual type from the
256
+ * callback arm and makes every `(ctx) => …` an implicit `any` under `strict`.
253
257
  */
254
- type ServerSetSchemaValue = unknown | ((ctx: {
258
+ type ServerSetSchemaValue = ((ctx: {
255
259
  auth: unknown;
256
260
  params: unknown;
257
- }) => unknown);
261
+ }) => unknown) | string | number | bigint | boolean | symbol | null | undefined | Date | readonly unknown[] | Record<string, unknown>;
258
262
  /**
259
263
  * Schema-side `serverSet` declaration. Two shapes:
260
264
  * - `string[]` — keys only; values are supplied at `implement(...)` time.
@@ -319,6 +323,88 @@ type InferServerSetKeys<TDef> = TDef extends {
319
323
  serverSet: infer O extends Readonly<Record<string, unknown>>;
320
324
  } ? keyof O & string : never;
321
325
  /**
326
+ * Ephemeral (presence, cursors, typing) storage and fan-out seam.
327
+ *
328
+ * The default adapter keeps state in the server process, which is correct for a
329
+ * single node and invisible across a fleet: two clients on different instances
330
+ * never see each other. An adapter backed by shared infrastructure (Redis, or a
331
+ * hosted service) fixes both halves — the shared store answers "who is here"
332
+ * for a client that just joined, and the bus carries each event to the peers
333
+ * holding the other sockets.
334
+ */
335
+ type MaybePromise<T> = T | Promise<T>;
336
+ interface EphemeralState {
337
+ clientId: string;
338
+ userId: string;
339
+ key: string;
340
+ data: Record<string, unknown>;
341
+ updatedAt: number;
342
+ ttlMs?: number;
343
+ }
344
+ /** One fan-out target: subscribers of `query`, narrowed to `room` when set. */
345
+ interface EphemeralTarget {
346
+ query: string;
347
+ room: string | null;
348
+ }
349
+ /**
350
+ * An ephemeral event as it crosses the bus between server instances.
351
+ *
352
+ * Recipients are resolved on the origin instance and carried here as `targets`,
353
+ * because the receiving instance holds no session for the sender and so cannot
354
+ * re-derive them. `serverId` lets an instance drop its own echo on buses that
355
+ * deliver published messages back to the publisher.
356
+ */
357
+ interface EphemeralBroadcast {
358
+ serverId: string;
359
+ key: string;
360
+ clientId: string;
361
+ userId: string;
362
+ data: Record<string, unknown>;
363
+ ttlMs?: number;
364
+ targets: EphemeralTarget[];
365
+ }
366
+ /**
367
+ * Backing store and fan-out bus for ephemeral state.
368
+ *
369
+ * `publish`/`subscribe` are optional: an adapter that omits them is a
370
+ * single-process store, and the handler still fans out to its own sockets.
371
+ */
372
+ interface EphemeralAdapter {
373
+ /**
374
+ * Record one entry, keyed by `clientId`.
375
+ *
376
+ * Peer identity is the connection, not the account: two tabs from one login
377
+ * are two cursors, and the client bindings key peers the same way. `userId`
378
+ * rides along for display and authorization, not as the entry's identity.
379
+ *
380
+ * Returns false when the adapter is at capacity, which the handler surfaces
381
+ * as `ephemeral_full` rather than evicting silently.
382
+ */
383
+ set(room: string, key: string, clientId: string, userId: string, data: Record<string, unknown>, ttlMs?: number): MaybePromise<boolean>;
384
+ /** Live entries for one channel, keyed by clientId. */
385
+ get(room: string, key: string): MaybePromise<Record<string, EphemeralState>>;
386
+ /**
387
+ * Live entries for every channel in a room, keyed by channel key then
388
+ * clientId. Backs the snapshot a client receives when it joins.
389
+ */
390
+ getRoom(room: string): MaybePromise<Record<string, Record<string, EphemeralState>>>;
391
+ remove(room: string, key: string, clientId: string): MaybePromise<void>;
392
+ /** Drop everything a disconnecting client published. */
393
+ removeClient(clientId: string): MaybePromise<void>;
394
+ /** Sweep entries past their TTL. Called on a timer by the handler. */
395
+ cleanupExpired(): MaybePromise<void>;
396
+ /** Current entry count, for the capacity gate and `ephemeral_full`. */
397
+ size(): MaybePromise<number>;
398
+ destroy(): MaybePromise<void>;
399
+ /** Hand an event to peer instances. Absent on single-process adapters. */
400
+ publish?(event: EphemeralBroadcast): MaybePromise<void>;
401
+ /**
402
+ * Register the handler's delivery callback for events published by peers.
403
+ * Called once during wiring. Absent on single-process adapters.
404
+ */
405
+ subscribe?(onEvent: (event: EphemeralBroadcast) => void): MaybePromise<void>;
406
+ }
407
+ /**
322
408
  * Adapter contract for `server.tx({ atomic: true })` — pluggable so non-drizzle
323
409
  * data layers (kysely, prisma, raw SQL) can wrap their own BEGIN/COMMIT/ROLLBACK
324
410
  * without reflectdb hard-coding a drizzle dependency.
@@ -456,6 +542,16 @@ declare class ResultCache {
456
542
  private cache;
457
543
  /**
458
544
  * Update the cached result set and return the diff.
545
+ *
546
+ * Rows are snapshotted, not referenced. A query that hands back live objects
547
+ * — an in-memory store, a game loop mutating rows in place, an ORM returning
548
+ * tracked entities — would otherwise have the cache holding the very objects
549
+ * the next write mutates, so every later diff compares a row against itself,
550
+ * finds nothing changed, and the client silently stops receiving updates.
551
+ *
552
+ * The copy is shallow: mutating a nested object inside a row in place is
553
+ * still invisible to change detection.
554
+ *
459
555
  * @param idField - The field name used as the row identifier (default: "id")
460
556
  */
461
557
  set(clientId: string, queryName: string, rows: Record<string, unknown>[], idField?: string): DiffResult;
@@ -467,6 +563,21 @@ declare class ResultCache {
467
563
  * already has rows it never received.
468
564
  */
469
565
  diffOnly(clientId: string, queryName: string, rows: Record<string, unknown>[], idField?: string): DiffResult;
566
+ /**
567
+ * Forget one row of one client's cached result.
568
+ *
569
+ * The cache mirrors what a client is believed to hold, and a writer is
570
+ * excluded from the broadcast of its own write because it already applied
571
+ * that write optimistically. For a delete that means the row is gone on the
572
+ * client while the cache still holds it — and if the same rowId is later
573
+ * re-created, the next diff reports it as an *update* against the dead row
574
+ * and sends only the fields that happen to differ. The client, whose local
575
+ * row is just its own optimistic payload, would silently never receive the
576
+ * unchanged server-owned columns. Dropping the row here keeps the mirror
577
+ * honest, so a re-created rowId diffs as a fresh insert carrying every
578
+ * column.
579
+ */
580
+ evictRow(clientId: string, queryName: string, rowId: string): void;
470
581
  clear(clientId: string, queryName: string): void;
471
582
  clearClient(clientId: string): void;
472
583
  private getOrCreateClientCache;
@@ -662,6 +773,8 @@ declare class MessageHandler<TAuth extends AuthContext = AuthContext> {
662
773
  private broadcast;
663
774
  private ops;
664
775
  private ephemeralManager;
776
+ /** Bus subscription is wired once, on the first adapter that offers one. */
777
+ private ephemeralBusReady;
665
778
  private ephemeralCleanupTimer;
666
779
  private eagerBuffer;
667
780
  private replay;
@@ -701,6 +814,13 @@ declare class MessageHandler<TAuth extends AuthContext = AuthContext> {
701
814
  setEphemeralRateLimit(perSecond: number): void;
702
815
  setMaxBatchSize(size: number): void;
703
816
  setMaxEphemeralEntries(max: number): void;
817
+ /**
818
+ * Swap the ephemeral store. An adapter that implements `subscribe` also
819
+ * makes this instance a bus participant, so presence spans the fleet.
820
+ */
821
+ setEphemeralAdapter(adapter: EphemeralAdapter): void;
822
+ /** Drop everything a client published, whatever the adapter costs. */
823
+ private forgetEphemeralClient;
704
824
  setMinSchemaVersion(version: number): void;
705
825
  setMaxConnectionsPerUser(max: number | null): void;
706
826
  private enforceConnectionCap;
@@ -761,6 +881,23 @@ declare class MessageHandler<TAuth extends AuthContext = AuthContext> {
761
881
  */
762
882
  private allowEphemeral;
763
883
  private handleEphemeral;
884
+ /**
885
+ * Deliver one ephemeral event to this instance's sockets.
886
+ *
887
+ * `exclude` is the sender when the event originated here, and undefined
888
+ * when it arrived over the bus — the sender's socket lives elsewhere.
889
+ */
890
+ private fanOutEphemeral;
891
+ /** An event published by a peer instance. State is already in the shared store. */
892
+ private deliverRemoteEphemeral;
893
+ /**
894
+ * Replay a room's live presence to a client that just subscribed.
895
+ *
896
+ * Without this a joiner sees nobody until each peer happens to move again —
897
+ * the stored state existed but was never served. Replayed as ordinary
898
+ * `ephemeral` events, so clients need no new message type to benefit.
899
+ */
900
+ private sendPresenceSnapshot;
764
901
  /** Public reserve-or-replay gate for REST idempotency. Returns true when fresh. */
765
902
  reserveOpId(opId: string): Promise<boolean>;
766
903
  runCompaction(minOpAge: number): Promise<number>;
@@ -915,6 +1052,18 @@ interface ServerConfig<TDb = unknown> {
915
1052
  * this is absent. See `TxAtomicAdapter`.
916
1053
  */
917
1054
  txAtomic?: TxAtomicAdapter;
1055
+ /**
1056
+ * Ephemeral (presence, cursors, typing) storage and fan-out.
1057
+ *
1058
+ * The default is in-process: correct on one node, invisible across a fleet,
1059
+ * since each instance holds its own map and its own sockets. Supply an
1060
+ * adapter backed by shared infrastructure to make presence span instances.
1061
+ */
1062
+ ephemeral?: {
1063
+ adapter?: EphemeralAdapter;
1064
+ /** Entry ceiling for the default in-process store. Default: 10_000. */
1065
+ maxEntries?: number;
1066
+ };
918
1067
  }
919
1068
  interface QueryContext<TAuth extends AuthContext = AuthContext> {
920
1069
  auth: TAuth;
@@ -1472,6 +1621,11 @@ interface BunStatement<Row> {
1472
1621
  run(...params: SqlValue[]): {
1473
1622
  changes: number;
1474
1623
  };
1624
+ /**
1625
+ * Releases the statement's sqlite handle. Optional so a hand-rolled stand-in
1626
+ * need not implement it; `bun:sqlite` always does.
1627
+ */
1628
+ finalize?(): void;
1475
1629
  }
1476
1630
  interface BunDatabase {
1477
1631
  run(sql: string, ...params: SqlValue[][]): {
@@ -1564,6 +1718,18 @@ declare class BroadcastEngine<TAuth extends AuthContext = AuthContext> {
1564
1718
  private withLock;
1565
1719
  private executeWithTimeout;
1566
1720
  /**
1721
+ * Forget a row the writer itself removed, in every query that depends on
1722
+ * `tableName`.
1723
+ *
1724
+ * The writer is skipped by `broadcastChanges` because it applied its own op
1725
+ * optimistically, which leaves its cached result claiming a row the client
1726
+ * has already dropped. Re-creating that same rowId would then diff as an
1727
+ * update against the dead row and send only the columns that differ, so the
1728
+ * writer would never receive the server-owned columns that happen to match
1729
+ * the row it deleted.
1730
+ */
1731
+ forgetWriterRow(tableName: string, clientId: string, rowId: string): void;
1732
+ /**
1567
1733
  * After a write to `tableName`, re-run every dependent query and send each
1568
1734
  * subscriber the rows that changed for them.
1569
1735
  *
@@ -1647,6 +1813,13 @@ interface OpProcessorDeps<TAuth extends AuthContext> {
1647
1813
  receiveClientHlc(hlc: string): void;
1648
1814
  send(clientId: string, message: ServerMessage): Promise<void>;
1649
1815
  broadcastChanges(tableName: string, excludeClientId: string): Promise<void>;
1816
+ /**
1817
+ * Drop a row the writer deleted from the writer's own cached result set.
1818
+ * The writer is excluded from the broadcast of its own op, so without this
1819
+ * the cache keeps a row the client no longer holds — and a later re-insert
1820
+ * of the same rowId diffs as a partial update against that dead row.
1821
+ */
1822
+ forgetWriterRow(tableName: string, clientId: string, rowId: string): void;
1650
1823
  }
1651
1824
  /**
1652
1825
  * Applies client ops: replay reservation, enforcement, conflict resolution,
@@ -1795,6 +1968,16 @@ interface TypedServerConfig<
1795
1968
  maxBroadcastConcurrency?: number;
1796
1969
  /** Adapter that wraps `server.tx({ atomic: true })` writes in a transaction. */
1797
1970
  txAtomic?: TxAtomicAdapter;
1971
+ /**
1972
+ * Ephemeral (presence, cursors, typing) storage and fan-out. Defaults to an
1973
+ * in-process store, which is invisible across a fleet — supply an adapter
1974
+ * backed by shared infrastructure to make presence span instances.
1975
+ */
1976
+ ephemeral?: {
1977
+ adapter?: EphemeralAdapter;
1978
+ /** Entry ceiling for the default in-process store. Default: 10_000. */
1979
+ maxEntries?: number;
1980
+ };
1798
1981
  }
1799
1982
  type ImplementParams<
1800
1983
  TQueries extends SyncQueryMap,
@@ -1818,14 +2001,22 @@ type IsServerSetArrayForm<
1818
2001
  serverSet: readonly string[];
1819
2002
  } ? true : false;
1820
2003
  /** Value: static or a function receiving auth/params context */
2004
+ /**
2005
+ * A serverSet entry: a static value, or a function of the request context.
2006
+ *
2007
+ * The static arm is spelled out rather than written as `unknown`, because
2008
+ * `unknown | ((ctx) => …)` collapses to `unknown` and the callback's parameter
2009
+ * then has no contextual type — every `(ctx) => …` would be an implicit `any`
2010
+ * under `strict`.
2011
+ */
1821
2012
  type ServerSetValue<
1822
2013
  TAuth extends AuthContext,
1823
2014
  TQueries extends SyncQueryMap,
1824
2015
  K extends keyof TQueries
1825
- > = unknown | ((ctx: {
2016
+ > = ((ctx: {
1826
2017
  auth: TAuth;
1827
2018
  params: ImplementParams<TQueries, K>;
1828
- }) => unknown);
2019
+ }) => unknown) | string | number | bigint | boolean | symbol | null | undefined | Date | readonly unknown[] | Record<string, unknown>;
1829
2020
  /**
1830
2021
  * `serverSet` shape on `implement(...)`:
1831
2022
  * - schema declares no `serverSet` → field disallowed.
@@ -1985,4 +2176,4 @@ declare function createSyncServer<
1985
2176
  TDb = unknown,
1986
2177
  TAuth extends AuthContext = AuthContext
1987
2178
  >(config: TypedServerConfig<TQueries, TDb>): TypedSyncServer<TQueries, TAuth, TDb>;
1988
- export { stableStringify, resolveRoomKey, resolveConflict, processOp, enforceServerSet, enforceReadonly, enforceClockDrift, enforceBatchSize, drizzleTxAtomic, drizzleTable, defineTable, createSyncServer, createSqliteStorage, createServer, createRateLimiter, createPostgresStorage, TypedSyncServer, TypedServerConfig, TxProxy, TxOptions, TxFn, TxAtomicAdapter, TableAdapter, SyncServer, SyncEvent, StorageAdapter, SqliteStorageConfig, SessionManager, ServerConfig, ServerClock, ScopeFilter, SERVER_HLC_META_KEY, RoomResolution, RoomCallback, ResultCache, RestConfig, ResolvedOp, RateLimiter, QuerySubscription, QueryRegistration, QueryOptions, QueryContext, QueryCallback, PostgresStorageConfig, PostgresClient, PipelineResult, PipelineContext, OpResult, OpProcessorDeps, OpProcessor, OpLogEntry, MutationError, MutationContext, MutateResult, MessageHandler, ImplementOptions, HandlerConfig, ExistingRow, EnforcementResult, EnforcementContext, DrizzleTableOpts, DiffResult, DefineTableOpts, ConflictResult, ConflictInput, ClientSession, BroadcastEngineDeps, BroadcastEngine, BatchContext, AuthorizeAction, AuthCallback };
2179
+ export { AuthCallback, AuthorizeAction, BatchContext, BroadcastEngine, BroadcastEngineDeps, ClientSession, ConflictInput, ConflictResult, DefineTableOpts, DiffResult, DrizzleTableOpts, EnforcementContext, EnforcementResult, ExistingRow, HandlerConfig, ImplementOptions, MessageHandler, MutateResult, MutationContext, MutationError, OpLogEntry, OpProcessor, OpProcessorDeps, OpResult, PipelineContext, PipelineResult, PostgresClient, PostgresStorageConfig, QueryCallback, QueryContext, QueryOptions, QueryRegistration, QuerySubscription, RateLimiter, ResolvedOp, RestConfig, ResultCache, RoomCallback, RoomResolution, SERVER_HLC_META_KEY, ScopeFilter, ServerClock, ServerConfig, SessionManager, SqliteStorageConfig, StorageAdapter, SyncEvent, SyncServer, TableAdapter, TxAtomicAdapter, TxFn, TxOptions, TxProxy, TypedServerConfig, TypedSyncServer, createPostgresStorage, createRateLimiter, createServer, createSqliteStorage, createSyncServer, defineTable, drizzleTable, drizzleTxAtomic, enforceBatchSize, enforceClockDrift, enforceReadonly, enforceServerSet, processOp, resolveConflict, resolveRoomKey, stableStringify };