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
|
@@ -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
|
+
};
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
> =
|
|
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 {
|
|
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 };
|