reflectdb 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +1260 -0
- package/dist/cjs/client/index.cjs +1252 -0
- package/dist/cjs/client/index.d.cts +629 -0
- package/dist/cjs/client/storage/indexeddb.cjs +253 -0
- package/dist/cjs/client/storage/indexeddb.d.cts +85 -0
- package/dist/cjs/core/index.cjs +202 -0
- package/dist/cjs/core/index.d.cts +473 -0
- package/dist/cjs/react/index.cjs +1512 -0
- package/dist/cjs/react/index.d.cts +748 -0
- package/dist/cjs/server/drizzle.cjs +413 -0
- package/dist/cjs/server/drizzle.d.cts +233 -0
- package/dist/cjs/server/index.cjs +4486 -0
- package/dist/cjs/server/index.d.cts +1988 -0
- package/dist/cjs/svelte/index.cjs +1414 -0
- package/dist/cjs/svelte/index.d.cts +645 -0
- package/dist/cjs/transport/bun-ws.cjs +244 -0
- package/dist/cjs/transport/bun-ws.d.cts +215 -0
- package/dist/cjs/transport/polling.cjs +285 -0
- package/dist/cjs/transport/polling.d.cts +210 -0
- package/dist/cjs/transport/sse.cjs +312 -0
- package/dist/cjs/transport/sse.d.cts +205 -0
- package/dist/cjs/transport/ws.cjs +330 -0
- package/dist/cjs/transport/ws.d.cts +236 -0
- package/dist/cjs/vanilla/index.cjs +1430 -0
- package/dist/cjs/vanilla/index.d.cts +657 -0
- package/dist/client/index.d.ts +629 -0
- package/dist/client/index.js +106 -0
- package/dist/client/storage/indexeddb.d.ts +85 -0
- package/dist/client/storage/indexeddb.js +213 -0
- package/dist/core/index.d.ts +473 -0
- package/dist/core/index.js +56 -0
- package/dist/react/index.d.ts +748 -0
- package/dist/react/index.js +366 -0
- package/dist/server/drizzle.d.ts +233 -0
- package/dist/server/drizzle.js +9 -0
- package/dist/server/index.d.ts +1988 -0
- package/dist/server/index.js +3959 -0
- package/dist/shared/esm-3tkwvysa.js +54 -0
- package/dist/shared/esm-b7xs9cde.js +4 -0
- package/dist/shared/esm-rw7jjtrv.js +58 -0
- package/dist/shared/esm-wkwx6bd9.js +25 -0
- package/dist/shared/esm-ytrd3hbq.js +1007 -0
- package/dist/shared/esm-z1xse19c.js +369 -0
- package/dist/svelte/index.d.ts +645 -0
- package/dist/svelte/index.js +262 -0
- package/dist/transport/bun-ws.d.ts +215 -0
- package/dist/transport/bun-ws.js +140 -0
- package/dist/transport/polling.d.ts +210 -0
- package/dist/transport/polling.js +181 -0
- package/dist/transport/sse.d.ts +205 -0
- package/dist/transport/sse.js +208 -0
- package/dist/transport/ws.d.ts +236 -0
- package/dist/transport/ws.js +226 -0
- package/dist/vanilla/index.d.ts +657 -0
- package/dist/vanilla/index.js +278 -0
- package/package.json +253 -0
|
@@ -0,0 +1,1988 @@
|
|
|
1
|
+
type OpType = "insert" | "update" | "delete";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side conflict policy for concurrent writes to the same row.
|
|
4
|
+
*
|
|
5
|
+
* Scope note for `"merge"`: the server resolves per column using the **client
|
|
6
|
+
* op HLCs** recorded in reflectdb's mirror, so two clients editing different
|
|
7
|
+
* fields both land. The per-column clocks the client sees on a broadcast are a
|
|
8
|
+
* different domain — a diff-driven broadcast can't attribute a column to the
|
|
9
|
+
* op that produced it, so every column changed in one broadcast carries that
|
|
10
|
+
* broadcast's HLC. Client-side merge therefore orders *broadcasts* against each
|
|
11
|
+
* other and against local optimistic state; it does not reconstruct per-column
|
|
12
|
+
* causality between clients. Column-level convergence is a server guarantee.
|
|
13
|
+
*/
|
|
14
|
+
type ConflictPolicy = "lww" | "merge" | "server" | CustomConflictPolicy;
|
|
15
|
+
interface CustomConflictPolicy {
|
|
16
|
+
policy: "custom";
|
|
17
|
+
resolve: ConflictResolver;
|
|
18
|
+
}
|
|
19
|
+
interface ConflictResolver {
|
|
20
|
+
(incoming: {
|
|
21
|
+
op: OpType;
|
|
22
|
+
payload: Record<string, unknown> | null;
|
|
23
|
+
hlc: string;
|
|
24
|
+
}, existing: {
|
|
25
|
+
row: Record<string, unknown>;
|
|
26
|
+
colClocks: Record<string, string>;
|
|
27
|
+
}, meta: {
|
|
28
|
+
table: string;
|
|
29
|
+
rowId: string;
|
|
30
|
+
userId: string;
|
|
31
|
+
}): {
|
|
32
|
+
row: Record<string, unknown>;
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
type ErrorReason = "outside_shape" | "readonly_query" | "rate_limited" | "buffer_full" | "server_conflict" | "custom_conflict" | "merge_stale" | "clock_drift" | "replay" | "batch_too_large" | "schema_outdated" | "auth_revoked" | "compacted" | "mutation_rejected" | "server_error" | "unknown_query";
|
|
36
|
+
interface HelloMessage {
|
|
37
|
+
type: "hello";
|
|
38
|
+
/** Highest protocol version the client prefers. Retained for back-compat. */
|
|
39
|
+
protocolVersion: number;
|
|
40
|
+
/** All versions the client can speak. Omit for legacy single-version clients. */
|
|
41
|
+
supportedVersions?: readonly number[];
|
|
42
|
+
clientId: string;
|
|
43
|
+
token: string;
|
|
44
|
+
}
|
|
45
|
+
interface BootstrapMessage {
|
|
46
|
+
type: "bootstrap";
|
|
47
|
+
syncParams?: Record<string, Record<string, unknown>>;
|
|
48
|
+
}
|
|
49
|
+
interface ResumeMessage {
|
|
50
|
+
type: "resume";
|
|
51
|
+
since: string;
|
|
52
|
+
}
|
|
53
|
+
interface OpsMessage {
|
|
54
|
+
type: "ops";
|
|
55
|
+
ops: ClientOp[];
|
|
56
|
+
token: string;
|
|
57
|
+
}
|
|
58
|
+
interface ClientOp {
|
|
59
|
+
id: string;
|
|
60
|
+
table: string;
|
|
61
|
+
op: OpType;
|
|
62
|
+
rowId: string;
|
|
63
|
+
payload: Record<string, unknown> | null;
|
|
64
|
+
hlc: string;
|
|
65
|
+
batchId?: string;
|
|
66
|
+
batchSize?: number;
|
|
67
|
+
batchSeq?: number;
|
|
68
|
+
}
|
|
69
|
+
interface SyncDeclareMessage {
|
|
70
|
+
type: "sync_declare";
|
|
71
|
+
table: string;
|
|
72
|
+
params?: Record<string, unknown>;
|
|
73
|
+
window?: number;
|
|
74
|
+
}
|
|
75
|
+
interface LoadMoreMessage {
|
|
76
|
+
type: "load_more";
|
|
77
|
+
table: string;
|
|
78
|
+
count: number;
|
|
79
|
+
}
|
|
80
|
+
interface UnsyncMessage {
|
|
81
|
+
type: "unsync";
|
|
82
|
+
table: string;
|
|
83
|
+
}
|
|
84
|
+
interface AuthMessage {
|
|
85
|
+
type: "auth";
|
|
86
|
+
token: string;
|
|
87
|
+
}
|
|
88
|
+
interface EphemeralMessage {
|
|
89
|
+
type: "ephemeral";
|
|
90
|
+
key: string;
|
|
91
|
+
userId: string;
|
|
92
|
+
data: Record<string, unknown>;
|
|
93
|
+
ttlMs?: number;
|
|
94
|
+
}
|
|
95
|
+
interface HelloAckMessage {
|
|
96
|
+
type: "hello_ack";
|
|
97
|
+
protocolVersion: number;
|
|
98
|
+
serverId: string;
|
|
99
|
+
}
|
|
100
|
+
interface HelloRejectMessage {
|
|
101
|
+
type: "hello_reject";
|
|
102
|
+
reason: string;
|
|
103
|
+
supported: number[];
|
|
104
|
+
}
|
|
105
|
+
interface SnapshotMessage {
|
|
106
|
+
type: "snapshot";
|
|
107
|
+
table: string;
|
|
108
|
+
rows: Record<string, unknown>[];
|
|
109
|
+
colClocks: Record<string, Record<string, string>>;
|
|
110
|
+
append?: boolean;
|
|
111
|
+
totalCount?: number;
|
|
112
|
+
/**
|
|
113
|
+
* Primary key column name for this table. Self-contained per message so the
|
|
114
|
+
* client doesn't depend on `bootstrap_complete.tableMeta` arriving first.
|
|
115
|
+
* When omitted (older servers), the client falls back to tableMeta, then "id".
|
|
116
|
+
*/
|
|
117
|
+
pk?: string;
|
|
118
|
+
}
|
|
119
|
+
interface BootstrapCompleteMessage {
|
|
120
|
+
type: "bootstrap_complete";
|
|
121
|
+
serverHlc: string;
|
|
122
|
+
tableMeta: Record<string, TableMeta>;
|
|
123
|
+
}
|
|
124
|
+
interface TableMeta {
|
|
125
|
+
serverSet: string[];
|
|
126
|
+
readonly: string[];
|
|
127
|
+
broadcast: "consistent" | "eager" | "eager-durable";
|
|
128
|
+
/** Primary key column name. Defaults to "id" when omitted (older servers). */
|
|
129
|
+
pk?: string;
|
|
130
|
+
}
|
|
131
|
+
interface DeltaMessage {
|
|
132
|
+
type: "delta";
|
|
133
|
+
table: string;
|
|
134
|
+
op: OpType;
|
|
135
|
+
rowId: string;
|
|
136
|
+
payload: Record<string, unknown> | null;
|
|
137
|
+
hlc: string;
|
|
138
|
+
colClocks?: Record<string, string>;
|
|
139
|
+
}
|
|
140
|
+
interface AckMessage {
|
|
141
|
+
type: "ack";
|
|
142
|
+
opIds: string[];
|
|
143
|
+
}
|
|
144
|
+
interface RejectMessage {
|
|
145
|
+
type: "reject";
|
|
146
|
+
opId?: string;
|
|
147
|
+
batchId?: string;
|
|
148
|
+
reason: ErrorReason;
|
|
149
|
+
serverRow?: Record<string, unknown>;
|
|
150
|
+
}
|
|
151
|
+
interface ResumeCompleteMessage {
|
|
152
|
+
type: "resume_complete";
|
|
153
|
+
serverHlc: string;
|
|
154
|
+
}
|
|
155
|
+
interface ResumeRejectedMessage {
|
|
156
|
+
type: "resume_rejected";
|
|
157
|
+
reason: string;
|
|
158
|
+
serverHlc: string;
|
|
159
|
+
}
|
|
160
|
+
interface ShapeChangedMessage {
|
|
161
|
+
type: "shape_changed";
|
|
162
|
+
table: string;
|
|
163
|
+
reason: "auth_changed" | "params_changed" | "server_policy";
|
|
164
|
+
}
|
|
165
|
+
interface DisconnectMessage {
|
|
166
|
+
type: "disconnect";
|
|
167
|
+
reason: string;
|
|
168
|
+
}
|
|
169
|
+
interface ReauthMessage {
|
|
170
|
+
type: "reauth";
|
|
171
|
+
}
|
|
172
|
+
interface CountChangedMessage {
|
|
173
|
+
type: "count_changed";
|
|
174
|
+
table: string;
|
|
175
|
+
totalCount: number;
|
|
176
|
+
}
|
|
177
|
+
interface EphemeralEvent {
|
|
178
|
+
type: "ephemeral";
|
|
179
|
+
key: string;
|
|
180
|
+
clientId: string;
|
|
181
|
+
userId: string;
|
|
182
|
+
data: Record<string, unknown>;
|
|
183
|
+
ttlMs?: number;
|
|
184
|
+
}
|
|
185
|
+
type ClientMessage = HelloMessage | BootstrapMessage | ResumeMessage | OpsMessage | SyncDeclareMessage | LoadMoreMessage | UnsyncMessage | AuthMessage | EphemeralMessage;
|
|
186
|
+
type ServerMessage = HelloAckMessage | HelloRejectMessage | SnapshotMessage | BootstrapCompleteMessage | DeltaMessage | AckMessage | RejectMessage | ResumeCompleteMessage | ResumeRejectedMessage | ShapeChangedMessage | DisconnectMessage | ReauthMessage | EphemeralEvent | CountChangedMessage;
|
|
187
|
+
interface ServerTransport {
|
|
188
|
+
/**
|
|
189
|
+
* Deliver a message to one client.
|
|
190
|
+
*
|
|
191
|
+
* MUST reject (ideally with `TransportSendError`) when the frame could not
|
|
192
|
+
* be handed to the peer — unknown/closed socket, full outbound queue, or a
|
|
193
|
+
* throw from the underlying socket. Resolving on a dropped frame makes the
|
|
194
|
+
* broadcast engine commit result-cache state the client never received.
|
|
195
|
+
*/
|
|
196
|
+
send(clientId: string, message: ServerMessage): Promise<void>;
|
|
197
|
+
broadcast(roomId: string, message: ServerMessage, exclude?: string): Promise<void>;
|
|
198
|
+
onMessage(handler: (clientId: string, message: ClientMessage) => void): void;
|
|
199
|
+
onConnect(handler: (clientId: string, req: Request) => void): void;
|
|
200
|
+
onDisconnect(handler: (clientId: string) => void): void;
|
|
201
|
+
close(): Promise<void>;
|
|
202
|
+
/**
|
|
203
|
+
* Force-disconnect a single client. Optional — if absent, the handler relies
|
|
204
|
+
* on the client honoring a `disconnect` server-message. Required for any
|
|
205
|
+
* transport that carries authentication state, since an adversarial client
|
|
206
|
+
* can otherwise ignore the message and continue using a revoked session.
|
|
207
|
+
*/
|
|
208
|
+
disconnect?(clientId: string): Promise<void> | void;
|
|
209
|
+
}
|
|
210
|
+
interface RateLimitConfig {
|
|
211
|
+
opsPerSecond?: number;
|
|
212
|
+
opsPerMinute?: number;
|
|
213
|
+
batchesPerMinute?: number;
|
|
214
|
+
/**
|
|
215
|
+
* Per-table overrides. Applied in addition to the global limit: a write
|
|
216
|
+
* must pass both. Tables missing from this map inherit only the global
|
|
217
|
+
* bucket. Use to tighten write pressure on hot tables (e.g. ephemeral
|
|
218
|
+
* chat) without punishing cold ones.
|
|
219
|
+
*/
|
|
220
|
+
perTable?: Record<string, {
|
|
221
|
+
opsPerSecond?: number;
|
|
222
|
+
opsPerMinute?: number;
|
|
223
|
+
}>;
|
|
224
|
+
/**
|
|
225
|
+
* Ceiling on ephemeral (presence/cursor) messages per client per second.
|
|
226
|
+
* Metered separately from writes so presence traffic and the write budget
|
|
227
|
+
* don't starve each other. Defaults to 60; 0 disables metering entirely
|
|
228
|
+
* (each ephemeral message fans out to every room subscriber, so an
|
|
229
|
+
* unmetered channel is an amplification vector).
|
|
230
|
+
*/
|
|
231
|
+
ephemeralPerSecond?: number;
|
|
232
|
+
}
|
|
233
|
+
interface CompactionConfig {
|
|
234
|
+
clientInactivityTimeout: string;
|
|
235
|
+
interval: string;
|
|
236
|
+
minOpAge: string;
|
|
237
|
+
}
|
|
238
|
+
interface AuthContext {
|
|
239
|
+
userId: string;
|
|
240
|
+
[key: string]: unknown;
|
|
241
|
+
}
|
|
242
|
+
declare class MutationError extends Error {
|
|
243
|
+
reason: ErrorReason;
|
|
244
|
+
constructor(reason: ErrorReason, message?: string);
|
|
245
|
+
}
|
|
246
|
+
interface DrizzleTableLike {
|
|
247
|
+
$inferSelect: Record<string, unknown>;
|
|
248
|
+
$inferInsert: Record<string, unknown>;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Value supplied per serverSet field in the schema's object form.
|
|
252
|
+
* Either a static value or a function that receives the request context.
|
|
253
|
+
*/
|
|
254
|
+
type ServerSetSchemaValue = unknown | ((ctx: {
|
|
255
|
+
auth: unknown;
|
|
256
|
+
params: unknown;
|
|
257
|
+
}) => unknown);
|
|
258
|
+
/**
|
|
259
|
+
* Schema-side `serverSet` declaration. Two shapes:
|
|
260
|
+
* - `string[]` — keys only; values are supplied at `implement(...)` time.
|
|
261
|
+
* - `Record<string, ServerSetSchemaValue>` — keys + values collocated in schema.
|
|
262
|
+
*/
|
|
263
|
+
type SchemaServerSet = readonly string[] | Readonly<Record<string, ServerSetSchemaValue>>;
|
|
264
|
+
interface SyncQueryDef {
|
|
265
|
+
/** Drizzle table — row type derived from $inferSelect */
|
|
266
|
+
table?: DrizzleTableLike;
|
|
267
|
+
/** Phantom row type for non-drizzle usage: `{} as MyRow` */
|
|
268
|
+
row?: Record<string, unknown>;
|
|
269
|
+
/** Database tables for change detection. Falls back to query key name. */
|
|
270
|
+
tables?: string[];
|
|
271
|
+
/** Phantom value for params type: `{} as { orgId: string }` */
|
|
272
|
+
params?: Record<string, unknown>;
|
|
273
|
+
/** Primary key column name. Defaults to "id". Single-column only. */
|
|
274
|
+
pk?: string;
|
|
275
|
+
conflict?: ConflictPolicy;
|
|
276
|
+
readonly?: readonly string[];
|
|
277
|
+
serverSet?: SchemaServerSet;
|
|
278
|
+
countHints?: boolean;
|
|
279
|
+
}
|
|
280
|
+
/**
|
|
281
|
+
* Read-only computed query. Compiles down to `server.query(...)` with a
|
|
282
|
+
* throwing mutate so the type system blocks `useSync(...).insert/update/remove`
|
|
283
|
+
* and the runtime rejects any direct write attempt.
|
|
284
|
+
*/
|
|
285
|
+
interface SyncViewDef {
|
|
286
|
+
__view: true;
|
|
287
|
+
row?: Record<string, unknown>;
|
|
288
|
+
params?: Record<string, unknown>;
|
|
289
|
+
tables?: string[];
|
|
290
|
+
/** Tables to watch for change-detection. Defaults to `tables` or query key. */
|
|
291
|
+
deps?: string[];
|
|
292
|
+
}
|
|
293
|
+
/**
|
|
294
|
+
* Typed ephemeral channel. Sugar over the runtime `useEphemeral` hook —
|
|
295
|
+
* derives a stable key from the schema name + serialized params and gives
|
|
296
|
+
* `peers`/`set` a typed `state`.
|
|
297
|
+
*/
|
|
298
|
+
interface SyncPresenceDef {
|
|
299
|
+
__presence: true;
|
|
300
|
+
state?: Record<string, unknown>;
|
|
301
|
+
params?: Record<string, unknown>;
|
|
302
|
+
ttlMs?: number;
|
|
303
|
+
}
|
|
304
|
+
type SyncQueryEntry = SyncQueryDef | SyncViewDef | SyncPresenceDef;
|
|
305
|
+
type SyncQueryMap = Record<string, SyncQueryEntry>;
|
|
306
|
+
/** Row type: from `table.$inferSelect` if drizzle, otherwise from `row` phantom */
|
|
307
|
+
type InferRow<
|
|
308
|
+
TQueries extends SyncQueryMap,
|
|
309
|
+
K extends keyof TQueries
|
|
310
|
+
> = TQueries[K] extends {
|
|
311
|
+
table: DrizzleTableLike;
|
|
312
|
+
} ? TQueries[K]["table"]["$inferSelect"] : TQueries[K] extends {
|
|
313
|
+
row: infer R extends Record<string, unknown>;
|
|
314
|
+
} ? R : Record<string, unknown>;
|
|
315
|
+
/** Extract serverSet field names from either array form or object form. */
|
|
316
|
+
type InferServerSetKeys<TDef> = TDef extends {
|
|
317
|
+
serverSet: readonly (infer S extends string)[];
|
|
318
|
+
} ? S : TDef extends {
|
|
319
|
+
serverSet: infer O extends Readonly<Record<string, unknown>>;
|
|
320
|
+
} ? keyof O & string : never;
|
|
321
|
+
/**
|
|
322
|
+
* Adapter contract for `server.tx({ atomic: true })` — pluggable so non-drizzle
|
|
323
|
+
* data layers (kysely, prisma, raw SQL) can wrap their own BEGIN/COMMIT/ROLLBACK
|
|
324
|
+
* without reflectdb hard-coding a drizzle dependency.
|
|
325
|
+
*
|
|
326
|
+
* Pass an adapter via:
|
|
327
|
+
* - `server.tx({ atomic: <adapter> }, fn)` — per-call override.
|
|
328
|
+
* - `ServerConfig.txAtomic` — server-wide default for `atomic: true`.
|
|
329
|
+
*
|
|
330
|
+
* If neither is supplied and `atomic: true` is requested, reflectdb falls back to
|
|
331
|
+
* the bundled drizzle adapter via dynamic import (no top-level dep). When
|
|
332
|
+
* drizzle isn't installed the fallback throws a clear error directing users to
|
|
333
|
+
* supply a `txAtomic` adapter.
|
|
334
|
+
*/
|
|
335
|
+
interface TxAtomicAdapter {
|
|
336
|
+
/** Begin a transaction on the given db handle. */
|
|
337
|
+
begin(db: unknown): Promise<void>;
|
|
338
|
+
/** Commit the in-flight transaction. */
|
|
339
|
+
commit(db: unknown): Promise<void>;
|
|
340
|
+
/** Roll back the in-flight transaction. */
|
|
341
|
+
rollback(db: unknown): Promise<void>;
|
|
342
|
+
}
|
|
343
|
+
interface ConflictInput {
|
|
344
|
+
op: OpType;
|
|
345
|
+
payload: Record<string, unknown> | null;
|
|
346
|
+
hlc: string;
|
|
347
|
+
rowId: string;
|
|
348
|
+
tableName: string;
|
|
349
|
+
userId: string;
|
|
350
|
+
}
|
|
351
|
+
interface ExistingRow {
|
|
352
|
+
row: Record<string, unknown> | null;
|
|
353
|
+
rowHlc: string | null;
|
|
354
|
+
colClocks: Record<string, string>;
|
|
355
|
+
}
|
|
356
|
+
interface ConflictResult {
|
|
357
|
+
accepted: boolean;
|
|
358
|
+
resolvedRow: Record<string, unknown> | null;
|
|
359
|
+
updatedColClocks: Record<string, string>;
|
|
360
|
+
reason?: string;
|
|
361
|
+
}
|
|
362
|
+
declare function resolveConflict(policy: ConflictPolicy, incoming: ConflictInput, existing: ExistingRow): ConflictResult;
|
|
363
|
+
interface EnforcementResult {
|
|
364
|
+
ok: boolean;
|
|
365
|
+
reason?: ErrorReason;
|
|
366
|
+
op?: ClientOp;
|
|
367
|
+
}
|
|
368
|
+
interface EnforcementContext {
|
|
369
|
+
userId: string;
|
|
370
|
+
nodeId: string;
|
|
371
|
+
}
|
|
372
|
+
declare function enforceClockDrift(hlc: string): EnforcementResult;
|
|
373
|
+
declare function enforceReadonly(op: ClientOp, options: QueryOptions): ClientOp;
|
|
374
|
+
declare function enforceServerSet(op: ClientOp, options: QueryOptions): ClientOp;
|
|
375
|
+
declare function enforceBatchSize(batchSize: number | undefined, maxBatchSize: number): EnforcementResult;
|
|
376
|
+
interface RateLimiter {
|
|
377
|
+
check(userId: string, clientId: string, table?: string): EnforcementResult;
|
|
378
|
+
record(userId: string, clientId: string, table?: string): void;
|
|
379
|
+
}
|
|
380
|
+
declare function createRateLimiter(config: {
|
|
381
|
+
opsPerSecond?: number;
|
|
382
|
+
opsPerMinute?: number;
|
|
383
|
+
perTable?: Record<string, {
|
|
384
|
+
opsPerSecond?: number;
|
|
385
|
+
opsPerMinute?: number;
|
|
386
|
+
}>;
|
|
387
|
+
}): RateLimiter;
|
|
388
|
+
interface ClientSession<TAuth extends AuthContext = AuthContext> {
|
|
389
|
+
clientId: string;
|
|
390
|
+
auth: TAuth | null;
|
|
391
|
+
subscriptions: Map<string, QuerySubscription>;
|
|
392
|
+
watermark: string | null;
|
|
393
|
+
connectedAt: number;
|
|
394
|
+
}
|
|
395
|
+
interface QuerySubscription {
|
|
396
|
+
queryName: string;
|
|
397
|
+
params: Record<string, unknown>;
|
|
398
|
+
options: QueryOptions;
|
|
399
|
+
roomKey: string | null;
|
|
400
|
+
/** Window the client asked for at subscribe time. Null = unwindowed. */
|
|
401
|
+
windowSize: number | null;
|
|
402
|
+
/**
|
|
403
|
+
* How many rows the client is currently entitled to: `windowSize` plus every
|
|
404
|
+
* `load_more` since. Distinct from `loadedCount`, which is how many rows it
|
|
405
|
+
* actually holds — a client whose query matched 3 rows for a window of 50 is
|
|
406
|
+
* still entitled to 50, so the next 47 inserts must reach it.
|
|
407
|
+
*/
|
|
408
|
+
windowLimit: number;
|
|
409
|
+
loadedCount: number;
|
|
410
|
+
lastTotalCount: number | null;
|
|
411
|
+
bootstrapped: boolean;
|
|
412
|
+
}
|
|
413
|
+
declare class SessionManager<TAuth extends AuthContext = AuthContext> {
|
|
414
|
+
private sessions;
|
|
415
|
+
private querySubscribers;
|
|
416
|
+
private userSessions;
|
|
417
|
+
connect(clientId: string): ClientSession<TAuth>;
|
|
418
|
+
disconnect(clientId: string): void;
|
|
419
|
+
/** Count of active sessions for a given userId. */
|
|
420
|
+
countForUser(userId: string): number;
|
|
421
|
+
/** Oldest (by connectedAt) session for a given userId. */
|
|
422
|
+
oldestForUser(userId: string): ClientSession<TAuth> | null;
|
|
423
|
+
get(clientId: string): ClientSession<TAuth> | undefined;
|
|
424
|
+
setAuth(clientId: string, auth: TAuth): void;
|
|
425
|
+
subscribe(clientId: string, queryName: string, params: Record<string, unknown>, options: QueryOptions, roomKey?: string | null, windowSize?: number | null): void;
|
|
426
|
+
unsubscribe(clientId: string, queryName: string): void;
|
|
427
|
+
updateWatermark(clientId: string, hlc: string): void;
|
|
428
|
+
/**
|
|
429
|
+
* Get all subscribers for a specific query name.
|
|
430
|
+
* Uses reverse index for O(subscribers) lookup instead of O(all sessions).
|
|
431
|
+
*/
|
|
432
|
+
getSubscribersForQuery(queryName: string, roomKey?: string | null): ClientSession<TAuth>[];
|
|
433
|
+
/**
|
|
434
|
+
* Get all subscribers that have a query depending on a given table.
|
|
435
|
+
* Uses reverse index for efficient lookup.
|
|
436
|
+
*/
|
|
437
|
+
getSubscribersForTableDependency(tableDependencyIndex: Map<string, Set<string>>, tableName: string): Array<{
|
|
438
|
+
session: ClientSession<TAuth>;
|
|
439
|
+
queryName: string;
|
|
440
|
+
}>;
|
|
441
|
+
getAll(): ClientSession<TAuth>[];
|
|
442
|
+
size(): number;
|
|
443
|
+
}
|
|
444
|
+
interface DiffResult {
|
|
445
|
+
inserted: Array<{
|
|
446
|
+
rowId: string;
|
|
447
|
+
data: Record<string, unknown>;
|
|
448
|
+
}>;
|
|
449
|
+
updated: Array<{
|
|
450
|
+
rowId: string;
|
|
451
|
+
changed: Record<string, unknown>;
|
|
452
|
+
}>;
|
|
453
|
+
deleted: string[];
|
|
454
|
+
}
|
|
455
|
+
declare class ResultCache {
|
|
456
|
+
private cache;
|
|
457
|
+
/**
|
|
458
|
+
* Update the cached result set and return the diff.
|
|
459
|
+
* @param idField - The field name used as the row identifier (default: "id")
|
|
460
|
+
*/
|
|
461
|
+
set(clientId: string, queryName: string, rows: Record<string, unknown>[], idField?: string): DiffResult;
|
|
462
|
+
get(clientId: string, queryName: string): Map<string, Record<string, unknown>>;
|
|
463
|
+
/**
|
|
464
|
+
* Compute the diff WITHOUT committing the new rows to the cache. Use when
|
|
465
|
+
* the broadcast may fail and we need to defer the cache update until sends
|
|
466
|
+
* succeed — otherwise a dropped send leaves the cache claiming the client
|
|
467
|
+
* already has rows it never received.
|
|
468
|
+
*/
|
|
469
|
+
diffOnly(clientId: string, queryName: string, rows: Record<string, unknown>[], idField?: string): DiffResult;
|
|
470
|
+
clear(clientId: string, queryName: string): void;
|
|
471
|
+
clearClient(clientId: string): void;
|
|
472
|
+
private getOrCreateClientCache;
|
|
473
|
+
private diff;
|
|
474
|
+
/**
|
|
475
|
+
* Returns only the changed fields, or null if nothing changed.
|
|
476
|
+
*/
|
|
477
|
+
private fieldDiff;
|
|
478
|
+
}
|
|
479
|
+
interface EagerBufferConfig {
|
|
480
|
+
flushInterval?: number;
|
|
481
|
+
maxBufferSize?: number;
|
|
482
|
+
/**
|
|
483
|
+
* Called when an op fails to persist. Distinct from console.error so the
|
|
484
|
+
* application can surface flush failures (DLQ, alerting, metrics) instead
|
|
485
|
+
* of relying on log scraping. The op stays re-queued at the buffer head
|
|
486
|
+
* for retry; this hook fires per failed attempt.
|
|
487
|
+
*/
|
|
488
|
+
onFlushError?: (err: unknown, op: BufferedOp) => void;
|
|
489
|
+
}
|
|
490
|
+
interface BufferedOp {
|
|
491
|
+
entry: OpLogEntry;
|
|
492
|
+
/**
|
|
493
|
+
* Resolved row state to write to the row store. May differ from
|
|
494
|
+
* entry.payload when the op is an UPDATE: payload carries the delta
|
|
495
|
+
* for the oplog, but row carries the merged result so putRow doesn't
|
|
496
|
+
* clobber untouched columns. If undefined, defaults to entry.payload.
|
|
497
|
+
*/
|
|
498
|
+
row?: Record<string, unknown> | null;
|
|
499
|
+
}
|
|
500
|
+
declare class EagerBuffer {
|
|
501
|
+
private buffers;
|
|
502
|
+
private flushInterval;
|
|
503
|
+
private maxBufferSize;
|
|
504
|
+
private timer;
|
|
505
|
+
private storage;
|
|
506
|
+
private flushPromise;
|
|
507
|
+
private onFlushError;
|
|
508
|
+
constructor(config?: EagerBufferConfig);
|
|
509
|
+
setOnFlushError(handler: ((err: unknown, op: BufferedOp) => void) | null): void;
|
|
510
|
+
setStorage(storage: StorageAdapter): void;
|
|
511
|
+
start(interval?: number): void;
|
|
512
|
+
/**
|
|
513
|
+
* Check if the buffer has capacity for one more op.
|
|
514
|
+
*/
|
|
515
|
+
hasCapacity(queryName: string, maxSize?: number): boolean;
|
|
516
|
+
/**
|
|
517
|
+
* Push an op into the buffer. Returns false if the buffer is full (backpressure).
|
|
518
|
+
* Synchronous — the periodic flush timer handles draining.
|
|
519
|
+
*/
|
|
520
|
+
push(queryName: string, op: BufferedOp, maxSize?: number): boolean;
|
|
521
|
+
/**
|
|
522
|
+
* Flush all buffered ops to storage.
|
|
523
|
+
* If a flush is already in progress, waits for it to complete.
|
|
524
|
+
*/
|
|
525
|
+
flush(): Promise<number>;
|
|
526
|
+
private doFlush;
|
|
527
|
+
/** Number of buffered ops across all queries. */
|
|
528
|
+
get pendingCount(): number;
|
|
529
|
+
/** Number of buffered ops for a specific query. */
|
|
530
|
+
queryPendingCount(queryName: string): number;
|
|
531
|
+
close(): Promise<void>;
|
|
532
|
+
}
|
|
533
|
+
interface HandlerConfig {
|
|
534
|
+
transport: ServerTransport;
|
|
535
|
+
serverId: string;
|
|
536
|
+
db?: unknown;
|
|
537
|
+
onEvent?: (event: SyncEvent) => void;
|
|
538
|
+
/**
|
|
539
|
+
* Max concurrent sessions for a single userId. Beyond the cap the
|
|
540
|
+
* oldest session is evicted so a misbehaving client can't exhaust
|
|
541
|
+
* server memory by opening unbounded connections.
|
|
542
|
+
*/
|
|
543
|
+
maxConnectionsPerUser?: number;
|
|
544
|
+
/**
|
|
545
|
+
* Accept connections when no `auth()` callback is registered, assigning each
|
|
546
|
+
* session a synthetic `anon:<clientId>` identity. Without this opt-in, a
|
|
547
|
+
* server with no auth callback rejects the handshake instead of acking
|
|
548
|
+
* `hello` and then failing every later message as unauthenticated.
|
|
549
|
+
*/
|
|
550
|
+
allowAnonymous?: boolean;
|
|
551
|
+
/**
|
|
552
|
+
* Abort a query execution that exceeds this many ms during broadcast. One
|
|
553
|
+
* slow query would otherwise stall fanout to every subscriber. 0 disables.
|
|
554
|
+
* Default: 0 (disabled).
|
|
555
|
+
*/
|
|
556
|
+
queryTimeoutMs?: number;
|
|
557
|
+
/** Max subscriber groups whose queries run concurrently per broadcast. Default: 8. */
|
|
558
|
+
maxBroadcastConcurrency?: number;
|
|
559
|
+
}
|
|
560
|
+
interface QueryRegistration {
|
|
561
|
+
name: string;
|
|
562
|
+
callback: QueryCallback;
|
|
563
|
+
tableDependencies: Set<string>;
|
|
564
|
+
options: QueryOptions;
|
|
565
|
+
}
|
|
566
|
+
interface RoomRegistration {
|
|
567
|
+
pattern: string;
|
|
568
|
+
regex: RegExp;
|
|
569
|
+
paramNames: string[];
|
|
570
|
+
callback: RoomCallback;
|
|
571
|
+
}
|
|
572
|
+
interface OpLogEntry {
|
|
573
|
+
table: string;
|
|
574
|
+
op: string;
|
|
575
|
+
rowId: string;
|
|
576
|
+
payload: Record<string, unknown> | null;
|
|
577
|
+
hlc: string;
|
|
578
|
+
colClocks: Record<string, string>;
|
|
579
|
+
}
|
|
580
|
+
interface StorageAdapter {
|
|
581
|
+
getRow(table: string, rowId: string): Promise<ExistingRow>;
|
|
582
|
+
/**
|
|
583
|
+
* Read many rows of one table in a single round trip, keyed by rowId.
|
|
584
|
+
* Missing rows are simply absent from the result.
|
|
585
|
+
*
|
|
586
|
+
* A 100-op batch otherwise issues 100 sequential `getRow` calls before it
|
|
587
|
+
* can even start writing. Optional — the handler falls back to `getRow`.
|
|
588
|
+
*/
|
|
589
|
+
getRowsByIds?(table: string, rowIds: string[]): Promise<Record<string, ExistingRow>>;
|
|
590
|
+
putRow(table: string, rowId: string, row: Record<string, unknown> | null, colClocks: Record<string, string>, hlc: string): Promise<void>;
|
|
591
|
+
getRows(table: string, filter?: Record<string, unknown>): Promise<{
|
|
592
|
+
rows: Record<string, unknown>[];
|
|
593
|
+
colClocks: Record<string, Record<string, string>>;
|
|
594
|
+
}>;
|
|
595
|
+
appendOp(entry: OpLogEntry): Promise<void>;
|
|
596
|
+
/**
|
|
597
|
+
* Atomic write: putRow + appendOp in a single transaction.
|
|
598
|
+
* Prevents divergence between the row store and op log on crash.
|
|
599
|
+
* Optional — handler falls back to sequential putRow+appendOp if absent.
|
|
600
|
+
*/
|
|
601
|
+
applyOp?(table: string, rowId: string, row: Record<string, unknown> | null, colClocks: Record<string, string>, hlc: string, opType: string, payload: Record<string, unknown> | null): Promise<void>;
|
|
602
|
+
getOpsSince(since: string, tables: string[]): Promise<OpLogEntry[]>;
|
|
603
|
+
/**
|
|
604
|
+
* Distinct table names with ops newer than `since`, restricted to `tables`.
|
|
605
|
+
*
|
|
606
|
+
* Resume only needs the set of changed tables — it re-executes each affected
|
|
607
|
+
* query and sends a snapshot rather than replaying ops. Without this,
|
|
608
|
+
* resume loads every op row since the watermark into memory just to collect
|
|
609
|
+
* distinct names, which is unbounded for a long-offline client.
|
|
610
|
+
*
|
|
611
|
+
* Optional — the handler falls back to `getOpsSince` when absent.
|
|
612
|
+
*/
|
|
613
|
+
getChangedTablesSince?(since: string, tables: string[]): Promise<string[]>;
|
|
614
|
+
/**
|
|
615
|
+
* Highest op-log HLC across `tables`, or null when the log is empty.
|
|
616
|
+
*
|
|
617
|
+
* HA polling uses it as a cheap "did anything change at all" probe: without
|
|
618
|
+
* it, every poll tick re-executes every query for every subscriber group
|
|
619
|
+
* even when there were zero writes. Optional.
|
|
620
|
+
*/
|
|
621
|
+
getOplogHead?(tables: string[]): Promise<string | null>;
|
|
622
|
+
deleteOpsBefore(hlc: string): Promise<number>;
|
|
623
|
+
/**
|
|
624
|
+
* Atomic reserve-or-detect-replay: returns true if this opId was fresh
|
|
625
|
+
* (inserted), false if it was already reserved. MUST be implemented as a
|
|
626
|
+
* single atomic operation (e.g. INSERT ... ON CONFLICT DO NOTHING) — a
|
|
627
|
+
* non-atomic check-then-write race-conditions in HA setups where multiple
|
|
628
|
+
* instances share storage and double-applies the op.
|
|
629
|
+
*/
|
|
630
|
+
reserveOp(opId: string): Promise<boolean>;
|
|
631
|
+
/**
|
|
632
|
+
* Batch form of `reserveOp`: returns the subset of `opIds` that were fresh.
|
|
633
|
+
* Must be atomic per id, like `reserveOp`. Optional — the handler falls
|
|
634
|
+
* back to one `reserveOp` per op.
|
|
635
|
+
*/
|
|
636
|
+
reserveOps?(opIds: string[]): Promise<string[]>;
|
|
637
|
+
getMeta(key: string): Promise<string | null>;
|
|
638
|
+
setMeta(key: string, value: string): Promise<void>;
|
|
639
|
+
/**
|
|
640
|
+
* Acquire a cross-instance lock (e.g. Postgres pg_advisory_lock).
|
|
641
|
+
* Returns true if acquired, false if another instance holds it.
|
|
642
|
+
* Optional — handler runs best-effort if absent.
|
|
643
|
+
*/
|
|
644
|
+
tryLock?(key: string): Promise<boolean>;
|
|
645
|
+
unlock?(key: string): Promise<void>;
|
|
646
|
+
}
|
|
647
|
+
declare class MessageHandler<TAuth extends AuthContext = AuthContext> {
|
|
648
|
+
private transport;
|
|
649
|
+
private serverId;
|
|
650
|
+
private db;
|
|
651
|
+
private clock;
|
|
652
|
+
private sessions;
|
|
653
|
+
private authCallback;
|
|
654
|
+
private queries;
|
|
655
|
+
private rooms;
|
|
656
|
+
private rateLimiter;
|
|
657
|
+
private maxBatchSize;
|
|
658
|
+
private storage;
|
|
659
|
+
private minSchemaVersion;
|
|
660
|
+
private messageQueues;
|
|
661
|
+
private resultCache;
|
|
662
|
+
private broadcast;
|
|
663
|
+
private ops;
|
|
664
|
+
private ephemeralManager;
|
|
665
|
+
private ephemeralCleanupTimer;
|
|
666
|
+
private eagerBuffer;
|
|
667
|
+
private replay;
|
|
668
|
+
private static readonly MAX_EPHEMERAL_TTL_MS;
|
|
669
|
+
private static readonly AUTH_CACHE_TTL_MS;
|
|
670
|
+
private authCache;
|
|
671
|
+
private compaction;
|
|
672
|
+
private messageQueueDepths;
|
|
673
|
+
private static readonly MAX_QUEUE_DEPTH;
|
|
674
|
+
private static eagerWarned;
|
|
675
|
+
private static missingAuthWarned;
|
|
676
|
+
/** Highest op-log HLC observed by the HA poll loop. */
|
|
677
|
+
private pollWatermark;
|
|
678
|
+
private onEvent;
|
|
679
|
+
private maxConnectionsPerUser;
|
|
680
|
+
private allowAnonymous;
|
|
681
|
+
private queryTimeoutMs;
|
|
682
|
+
private ephemeralPerSecond;
|
|
683
|
+
private ephemeralBuckets;
|
|
684
|
+
private tableDependencyIndex;
|
|
685
|
+
private emit;
|
|
686
|
+
/**
|
|
687
|
+
* Send that never throws. Transports now REPORT delivery failure (so the
|
|
688
|
+
* broadcast engine can withhold its result-cache commit), which means every
|
|
689
|
+
* send site that must keep running afterwards — disconnect handshakes, acks,
|
|
690
|
+
* per-client fanout — has to opt out of the throw explicitly.
|
|
691
|
+
*
|
|
692
|
+
* Returns false when the frame did not reach the client.
|
|
693
|
+
*/
|
|
694
|
+
private trySend;
|
|
695
|
+
constructor(config: HandlerConfig);
|
|
696
|
+
setAuth(callback: AuthCallback): void;
|
|
697
|
+
setQuery(name: string, registration: QueryRegistration): void;
|
|
698
|
+
setRoom(registration: RoomRegistration): void;
|
|
699
|
+
setRateLimiter(limiter: RateLimiter): void;
|
|
700
|
+
/** Per-client ephemeral message ceiling per second. 0 disables metering. */
|
|
701
|
+
setEphemeralRateLimit(perSecond: number): void;
|
|
702
|
+
setMaxBatchSize(size: number): void;
|
|
703
|
+
setMaxEphemeralEntries(max: number): void;
|
|
704
|
+
setMinSchemaVersion(version: number): void;
|
|
705
|
+
setMaxConnectionsPerUser(max: number | null): void;
|
|
706
|
+
private enforceConnectionCap;
|
|
707
|
+
setStorage(adapter: StorageAdapter): void;
|
|
708
|
+
/**
|
|
709
|
+
* Re-read the shared HLC watermark and merge it into this instance's clock.
|
|
710
|
+
* The HA poll loop calls this each tick; expose it so deployments driving
|
|
711
|
+
* their own loop can keep instances converged too.
|
|
712
|
+
*/
|
|
713
|
+
syncClockWatermark(): Promise<void>;
|
|
714
|
+
getSessions(): SessionManager<TAuth>;
|
|
715
|
+
getResultCache(): ResultCache;
|
|
716
|
+
private handleMessage;
|
|
717
|
+
private handleHello;
|
|
718
|
+
private handleAuth;
|
|
719
|
+
private handleBootstrap;
|
|
720
|
+
/**
|
|
721
|
+
* Execute a query registration's callback for a given client session.
|
|
722
|
+
* Falls back to storage when no application DB is configured.
|
|
723
|
+
*
|
|
724
|
+
* @param limit — max rows the caller needs. Passed to the callback as
|
|
725
|
+
* `ctx.limit` so it can push the cap into SQL (`.limit(ctx.limit)`), and
|
|
726
|
+
* ALSO applied as a JS slice afterwards. The slice is the safety net for
|
|
727
|
+
* callbacks that ignore `ctx.limit` — without it, the server would have
|
|
728
|
+
* to choose between clobbering a user-defined `.limit()`/`.offset()` and
|
|
729
|
+
* returning more rows than the window holds.
|
|
730
|
+
*/
|
|
731
|
+
private executeQuery;
|
|
732
|
+
/**
|
|
733
|
+
* Total row count for count hints, using the query's `count` callback.
|
|
734
|
+
* Returns null when the query has none — callers then fall back to the
|
|
735
|
+
* length of a full result set.
|
|
736
|
+
*/
|
|
737
|
+
private countQuery;
|
|
738
|
+
/**
|
|
739
|
+
* Fetch one window of a query plus the total row count.
|
|
740
|
+
*
|
|
741
|
+
* With a `count` callback the fetch is capped at the window size and the
|
|
742
|
+
* total comes from `count` — a 50-row window over a million-row table reads
|
|
743
|
+
* 50 rows. Without one, the server must materialize the full result set to
|
|
744
|
+
* know the total, so windowing only saves bytes on the wire.
|
|
745
|
+
*/
|
|
746
|
+
private fetchWindow;
|
|
747
|
+
private handleResume;
|
|
748
|
+
private handleOps;
|
|
749
|
+
/**
|
|
750
|
+
* After a write to a table, find all queries that depend on that table,
|
|
751
|
+
* re-execute them per subscriber group, and send deltas for any changes.
|
|
752
|
+
* See `BroadcastEngine` for grouping, locking and windowing behavior.
|
|
753
|
+
*/
|
|
754
|
+
private broadcastChanges;
|
|
755
|
+
private handleSyncDeclare;
|
|
756
|
+
private handleLoadMore;
|
|
757
|
+
private handleUnsync;
|
|
758
|
+
/**
|
|
759
|
+
* Fixed-window bucket for ephemeral traffic, keyed per client. Separate from
|
|
760
|
+
* the op-path limiter so presence and writes don't starve each other.
|
|
761
|
+
*/
|
|
762
|
+
private allowEphemeral;
|
|
763
|
+
private handleEphemeral;
|
|
764
|
+
/** Public reserve-or-replay gate for REST idempotency. Returns true when fresh. */
|
|
765
|
+
reserveOpId(opId: string): Promise<boolean>;
|
|
766
|
+
runCompaction(minOpAge: number): Promise<number>;
|
|
767
|
+
getEagerBuffer(): EagerBuffer;
|
|
768
|
+
/**
|
|
769
|
+
* Returns all table names that have registered query dependencies.
|
|
770
|
+
* Used by the poll feature to know which tables to check for remote changes.
|
|
771
|
+
*/
|
|
772
|
+
getTrackedTables(): string[];
|
|
773
|
+
/**
|
|
774
|
+
* One HA poll tick: pick up writes made by other instances sharing storage.
|
|
775
|
+
*
|
|
776
|
+
* On an adapter that can report the op-log head, an idle tick costs a single
|
|
777
|
+
* `MAX(hlc)` query and broadcasts nothing. Re-running every query for every
|
|
778
|
+
* subscriber group on every tick — the naive version — is ~5k queries/sec at
|
|
779
|
+
* idle for 500 clients on a 100ms poll.
|
|
780
|
+
*
|
|
781
|
+
* Also re-merges the shared clock watermark, so an instance whose wall clock
|
|
782
|
+
* lags its peers stops stamping writes below HLCs clients have already seen.
|
|
783
|
+
*/
|
|
784
|
+
pollRemoteChanges(): Promise<void>;
|
|
785
|
+
/** Broadcast several tables, isolating per-table failures from each other. */
|
|
786
|
+
private notifyAll;
|
|
787
|
+
/**
|
|
788
|
+
* Notify that a table changed from an external source (e.g. REST).
|
|
789
|
+
* Triggers delta broadcasting to connected sync clients.
|
|
790
|
+
*
|
|
791
|
+
* @param roomKey — restrict broadcast to subscribers in this room. Pass
|
|
792
|
+
* null to broadcast cross-room (the default; legacy behavior). For
|
|
793
|
+
* multi-tenant apps, pass the tenant's room key to avoid leaking
|
|
794
|
+
* row-level changes across tenants.
|
|
795
|
+
*/
|
|
796
|
+
notifyChange(tableName: string, roomKey?: string | null): Promise<void>;
|
|
797
|
+
/**
|
|
798
|
+
* Apply a server-originated write (e.g. REST endpoint) through the same
|
|
799
|
+
* HLC stamping + op log + broadcast path as sync ops. Previously REST
|
|
800
|
+
* wrote with `hlc: ""`, bypassing the op log entirely — resume would
|
|
801
|
+
* miss the write and divergence would accumulate.
|
|
802
|
+
*
|
|
803
|
+
* Ordering matches the sync-consistent pipeline: stamp HLC, run user
|
|
804
|
+
* authorize/mutate (via `execute`), then atomic sync-storage write and
|
|
805
|
+
* broadcast. If `execute` throws, sync storage is untouched.
|
|
806
|
+
*/
|
|
807
|
+
applyServerOp(op: {
|
|
808
|
+
type: OpType;
|
|
809
|
+
table: string;
|
|
810
|
+
rowId: string;
|
|
811
|
+
payload: Record<string, unknown> | null;
|
|
812
|
+
}, execute?: (stamped: {
|
|
813
|
+
type: OpType;
|
|
814
|
+
table: string;
|
|
815
|
+
rowId: string;
|
|
816
|
+
payload: Record<string, unknown> | null;
|
|
817
|
+
hlc: string;
|
|
818
|
+
}) => Promise<void>, options?: {
|
|
819
|
+
/**
|
|
820
|
+
* Restrict change broadcast to subscribers in this room. Without
|
|
821
|
+
* this, the broadcast goes to ALL subscribers of the affected
|
|
822
|
+
* query — leaking writes across tenant boundaries. Pass null to
|
|
823
|
+
* intentionally broadcast cross-room (legacy behavior).
|
|
824
|
+
*/
|
|
825
|
+
roomKey?: string | null;
|
|
826
|
+
}): Promise<{
|
|
827
|
+
hlc: string;
|
|
828
|
+
resolvedRow: Record<string, unknown> | null;
|
|
829
|
+
}>;
|
|
830
|
+
close(): Promise<void>;
|
|
831
|
+
}
|
|
832
|
+
type SyncEvent = {
|
|
833
|
+
type: "client_connected";
|
|
834
|
+
clientId: string;
|
|
835
|
+
} | {
|
|
836
|
+
type: "client_disconnected";
|
|
837
|
+
clientId: string;
|
|
838
|
+
} | {
|
|
839
|
+
type: "auth_failed";
|
|
840
|
+
clientId: string;
|
|
841
|
+
error: unknown;
|
|
842
|
+
} | {
|
|
843
|
+
type: "message_invalid";
|
|
844
|
+
clientId: string;
|
|
845
|
+
error: string;
|
|
846
|
+
} | {
|
|
847
|
+
type: "ops_processed";
|
|
848
|
+
clientId: string;
|
|
849
|
+
accepted: number;
|
|
850
|
+
rejected: number;
|
|
851
|
+
} | {
|
|
852
|
+
type: "bootstrap";
|
|
853
|
+
clientId: string;
|
|
854
|
+
queries: number;
|
|
855
|
+
} | {
|
|
856
|
+
type: "resume";
|
|
857
|
+
clientId: string;
|
|
858
|
+
queries: number;
|
|
859
|
+
tablesChanged: number;
|
|
860
|
+
} | {
|
|
861
|
+
type: "compaction";
|
|
862
|
+
deletedOps: number;
|
|
863
|
+
} | {
|
|
864
|
+
type: "ephemeral_full";
|
|
865
|
+
currentSize: number;
|
|
866
|
+
} | {
|
|
867
|
+
type: "ephemeral_rate_limited";
|
|
868
|
+
clientId: string;
|
|
869
|
+
} | {
|
|
870
|
+
type: "query_error";
|
|
871
|
+
clientId: string;
|
|
872
|
+
queryName: string;
|
|
873
|
+
phase: string;
|
|
874
|
+
error: unknown;
|
|
875
|
+
} | {
|
|
876
|
+
type: "queue_overflow";
|
|
877
|
+
clientId: string;
|
|
878
|
+
depth: number;
|
|
879
|
+
} | {
|
|
880
|
+
type: "eager_flush_failed";
|
|
881
|
+
table: string;
|
|
882
|
+
rowId: string;
|
|
883
|
+
error: unknown;
|
|
884
|
+
};
|
|
885
|
+
interface ServerConfig<TDb = unknown> {
|
|
886
|
+
db?: TDb;
|
|
887
|
+
transport: ServerTransport;
|
|
888
|
+
serverId?: string;
|
|
889
|
+
storage?: StorageAdapter;
|
|
890
|
+
onEvent?: (event: SyncEvent) => void;
|
|
891
|
+
/** Poll interval in ms for detecting changes from other server instances sharing the same storage. Enables HA mode. 0 or undefined to disable. */
|
|
892
|
+
poll?: number;
|
|
893
|
+
/** Max concurrent sessions per userId. Oldest is evicted on overflow. */
|
|
894
|
+
maxConnectionsPerUser?: number;
|
|
895
|
+
/**
|
|
896
|
+
* Serve connections without an `auth()` callback, giving each session an
|
|
897
|
+
* `anon:<clientId>` identity. Without it a server that never calls `auth()`
|
|
898
|
+
* rejects the handshake rather than acking `hello` and then failing every
|
|
899
|
+
* subsequent message as unauthenticated.
|
|
900
|
+
*/
|
|
901
|
+
allowAnonymous?: boolean;
|
|
902
|
+
/**
|
|
903
|
+
* Abort a broadcast query that runs longer than this many ms, so one slow
|
|
904
|
+
* query can't stall fanout to every other subscriber. 0 disables.
|
|
905
|
+
* Note: this abandons the promise, it does not cancel the underlying
|
|
906
|
+
* database work.
|
|
907
|
+
*/
|
|
908
|
+
queryTimeoutMs?: number;
|
|
909
|
+
/** Max subscriber groups whose queries run concurrently per broadcast. Default: 8. */
|
|
910
|
+
maxBroadcastConcurrency?: number;
|
|
911
|
+
/**
|
|
912
|
+
* Adapter that wraps `server.tx({ atomic: true })` writes in a transaction.
|
|
913
|
+
* Required when using `atomic: true` against a non-drizzle handle (kysely,
|
|
914
|
+
* prisma, raw SQL). Drizzle handles auto-fall-back via dynamic import when
|
|
915
|
+
* this is absent. See `TxAtomicAdapter`.
|
|
916
|
+
*/
|
|
917
|
+
txAtomic?: TxAtomicAdapter;
|
|
918
|
+
}
|
|
919
|
+
interface QueryContext<TAuth extends AuthContext = AuthContext> {
|
|
920
|
+
auth: TAuth;
|
|
921
|
+
params: Record<string, unknown>;
|
|
922
|
+
/**
|
|
923
|
+
* Row cap the server needs for this execution, or undefined for "all rows".
|
|
924
|
+
*
|
|
925
|
+
* Set for windowed subscriptions. Applying it as `.limit(ctx.limit)` pushes
|
|
926
|
+
* windowing into SQL; ignore it and the server still slices in JS, which is
|
|
927
|
+
* correct but fetches the whole table on every broadcast. Pair with
|
|
928
|
+
* `QueryOptions.count` so count hints don't force a full fetch either.
|
|
929
|
+
*/
|
|
930
|
+
limit?: number;
|
|
931
|
+
}
|
|
932
|
+
interface ResolvedOp {
|
|
933
|
+
type: OpType;
|
|
934
|
+
table: string;
|
|
935
|
+
rowId: string;
|
|
936
|
+
payload: Record<string, unknown> | null;
|
|
937
|
+
hlc: string;
|
|
938
|
+
}
|
|
939
|
+
interface MutateResult {
|
|
940
|
+
affected?: string[];
|
|
941
|
+
}
|
|
942
|
+
interface MutationContext<TAuth extends AuthContext = AuthContext> {
|
|
943
|
+
auth: TAuth;
|
|
944
|
+
params: Record<string, unknown>;
|
|
945
|
+
}
|
|
946
|
+
type AuthorizeAction = {
|
|
947
|
+
type: "read";
|
|
948
|
+
table: string;
|
|
949
|
+
params: Record<string, unknown>;
|
|
950
|
+
} | {
|
|
951
|
+
type: "write";
|
|
952
|
+
table: string;
|
|
953
|
+
op: ResolvedOp;
|
|
954
|
+
};
|
|
955
|
+
interface QueryOptions<
|
|
956
|
+
TAuth extends AuthContext = AuthContext,
|
|
957
|
+
TDb = unknown
|
|
958
|
+
> {
|
|
959
|
+
tables: string[];
|
|
960
|
+
/** Primary key column name. Defaults to "id". Single-column only. */
|
|
961
|
+
pk?: string;
|
|
962
|
+
/**
|
|
963
|
+
* How concurrent writes to the same row are resolved.
|
|
964
|
+
*
|
|
965
|
+
* Resolution compares the incoming op against **reflectdb's mirror** (the
|
|
966
|
+
* JSONB row store + per-column clocks it maintains), not against your
|
|
967
|
+
* database. The two agree as long as every write to the table goes through
|
|
968
|
+
* reflectdb and `mutate` persists the resolved payload verbatim. They diverge
|
|
969
|
+
* when `mutate` transforms the payload, when database defaults/triggers
|
|
970
|
+
* rewrite it, or when something writes the table out of band — and then
|
|
971
|
+
* conflicts are decided against state clients never observed, because
|
|
972
|
+
* snapshots and deltas are read from your database.
|
|
973
|
+
*
|
|
974
|
+
* Ignored by both `eager` broadcast modes.
|
|
975
|
+
*/
|
|
976
|
+
conflict?: ConflictPolicy;
|
|
977
|
+
serverSet?: Record<string, unknown>;
|
|
978
|
+
readonly?: string[];
|
|
979
|
+
countHints?: boolean;
|
|
980
|
+
/**
|
|
981
|
+
* Require this query's subscriptions to resolve the named room pattern
|
|
982
|
+
* (e.g. `"org/:orgId"`). A `sync_declare` whose params don't produce a
|
|
983
|
+
* valid key for it is rejected instead of falling back to an unscoped,
|
|
984
|
+
* cross-room subscription. Use for any query carrying tenant data.
|
|
985
|
+
*/
|
|
986
|
+
room?: string;
|
|
987
|
+
/**
|
|
988
|
+
* Total row count for `countHints`, independent of the window.
|
|
989
|
+
*
|
|
990
|
+
* Without it, count hints force the broadcast engine to fetch every row of
|
|
991
|
+
* every windowed query on every write just to call `.length` on the result.
|
|
992
|
+
* Implement it as a `SELECT count(*)` and windowing becomes a real limit.
|
|
993
|
+
*/
|
|
994
|
+
count?: (ctx: QueryContext<TAuth>, db: TDb) => Promise<number> | number;
|
|
995
|
+
/**
|
|
996
|
+
* Collapse subscribers into a shared query execution.
|
|
997
|
+
*
|
|
998
|
+
* By default the broadcast engine groups subscribers by their full auth
|
|
999
|
+
* context, so a write costs one query execution per connected client. When
|
|
1000
|
+
* a query's results depend only on a coarser key — a tenant id, a room, or
|
|
1001
|
+
* nothing at all — return that key here and every client sharing it is
|
|
1002
|
+
* served by ONE execution and one diff.
|
|
1003
|
+
*
|
|
1004
|
+
* Return a key that captures every input the query reads from `auth`. Two
|
|
1005
|
+
* clients sharing a key MUST be entitled to byte-identical rows; collapsing
|
|
1006
|
+
* clients that aren't leaks rows across the boundary.
|
|
1007
|
+
*
|
|
1008
|
+
* ```ts
|
|
1009
|
+
* // Query filters only on orgId — all members of an org share a result set.
|
|
1010
|
+
* groupBy: ({ auth }) => String(auth.orgId)
|
|
1011
|
+
* ```
|
|
1012
|
+
*/
|
|
1013
|
+
groupBy?: (ctx: {
|
|
1014
|
+
auth: TAuth;
|
|
1015
|
+
params: Record<string, unknown>;
|
|
1016
|
+
}) => string;
|
|
1017
|
+
/**
|
|
1018
|
+
* Broadcast strategy.
|
|
1019
|
+
*
|
|
1020
|
+
* - "consistent" (default): run the conflict pipeline, persist, then
|
|
1021
|
+
* broadcast by diffing each subscriber's re-executed query result.
|
|
1022
|
+
* - "eager-durable": skip conflict resolution, persist to reflectdb's mirror
|
|
1023
|
+
* atomically, then broadcast the delta directly. Low latency, and the
|
|
1024
|
+
* mirror write can't be lost to a crash after the broadcast.
|
|
1025
|
+
* - "eager": broadcast immediately, batch-persist to the mirror in the
|
|
1026
|
+
* background. AT-MOST-ONCE for the mirror across a crash: subscribers
|
|
1027
|
+
* may hold deltas the server forgets after restart. Only safe when
|
|
1028
|
+
* \`mutate\` is durable to your own database (the mirror becomes a
|
|
1029
|
+
* delivery hint, not a source of truth) AND clients tolerate
|
|
1030
|
+
* resume-via-snapshot. Prefer "eager-durable" unless you have measured
|
|
1031
|
+
* persistence latency as a real bottleneck.
|
|
1032
|
+
*
|
|
1033
|
+
* Both eager modes DO enforce \`readonly\`, \`serverSet\`, clock drift, the
|
|
1034
|
+
* batch cap and rate limits. What they skip is conflict resolution: a
|
|
1035
|
+
* declared \`conflict\` policy does not apply and writes land last-writer-wins.
|
|
1036
|
+
*
|
|
1037
|
+
* Durability caveat that applies to EVERY mode: \`mutate\` writes your
|
|
1038
|
+
* database and reflectdb writes its own mirror + op log in a separate commit.
|
|
1039
|
+
* "Atomic" here means the mirror row and its op-log entry commit together,
|
|
1040
|
+
* not that they commit with your database write. A crash between the two
|
|
1041
|
+
* leaves your database ahead of the mirror.
|
|
1042
|
+
*/
|
|
1043
|
+
broadcast?: "consistent" | "eager" | "eager-durable";
|
|
1044
|
+
/** Flush interval in ms for eager broadcast buffer. Default: 200. */
|
|
1045
|
+
flushInterval?: number;
|
|
1046
|
+
/** Max buffered ops per query before forcing a flush. Default: 1000. */
|
|
1047
|
+
maxBufferSize?: number;
|
|
1048
|
+
authorize?: (action: AuthorizeAction, ctx: MutationContext<TAuth>, db: TDb) => Promise<void>;
|
|
1049
|
+
mutate?: (op: ResolvedOp, ctx: MutationContext<TAuth>, db: TDb) => Promise<void | MutateResult>;
|
|
1050
|
+
}
|
|
1051
|
+
type QueryCallback<
|
|
1052
|
+
TAuth extends AuthContext = AuthContext,
|
|
1053
|
+
TDb = unknown
|
|
1054
|
+
> = (ctx: QueryContext<TAuth>, db: TDb) => unknown;
|
|
1055
|
+
type AuthCallback = (req: Request) => Promise<AuthContext>;
|
|
1056
|
+
/**
|
|
1057
|
+
* Proxy passed to `server.tx(fn)`. Forwards all calls through to the
|
|
1058
|
+
* underlying db handle; for drizzle handles, `insert/update/delete` are
|
|
1059
|
+
* intercepted to auto-record touched tables. `select` is NOT a touch.
|
|
1060
|
+
*
|
|
1061
|
+
* Non-drizzle data layers (kysely, prisma, raw SQL) call `tx.touch(name)`
|
|
1062
|
+
* after each write to flag the table for the trailing `notifyChange` pass.
|
|
1063
|
+
* Drizzle users may also call `tx.touch` to add a table the proxy didn't
|
|
1064
|
+
* detect (e.g. raw SQL escape hatch through `tx.run(...)`).
|
|
1065
|
+
*
|
|
1066
|
+
* Typed loosely as the underlying handle to avoid coupling to a specific
|
|
1067
|
+
* dialect; `touch` is appended explicitly for IDE discoverability.
|
|
1068
|
+
*/
|
|
1069
|
+
type TxProxy = any & {
|
|
1070
|
+
touch(table: string): void;
|
|
1071
|
+
};
|
|
1072
|
+
type TxFn<T> = (tx: TxProxy) => Promise<T>;
|
|
1073
|
+
interface TxOptions {
|
|
1074
|
+
/**
|
|
1075
|
+
* Wrap the work in BEGIN/COMMIT for SQL atomicity. Defaults to `true` —
|
|
1076
|
+
* partial writes on throw are usually a footgun, so opt OUT explicitly
|
|
1077
|
+
* (`atomic: false`) only when you've measured it as a perf bottleneck.
|
|
1078
|
+
* Either way, notifies fire only on full success.
|
|
1079
|
+
*
|
|
1080
|
+
* Three accepted forms:
|
|
1081
|
+
* - `true` (default): use `ServerConfig.txAtomic` if set, otherwise fall
|
|
1082
|
+
* back to the bundled drizzle adapter (lazy-loaded — no top-level
|
|
1083
|
+
* `drizzle-orm` dep). Throws if drizzle isn't installed and no adapter
|
|
1084
|
+
* is configured.
|
|
1085
|
+
* - `false`: skip BEGIN/COMMIT entirely. Notifies still fire only on
|
|
1086
|
+
* full success.
|
|
1087
|
+
* - `<TxAtomicAdapter>`: per-call override — use this adapter for the
|
|
1088
|
+
* one-off write, ignoring `ServerConfig.txAtomic`.
|
|
1089
|
+
*
|
|
1090
|
+
* Note: with multi-connection adapters (postgres pool) atomicity requires
|
|
1091
|
+
* that BEGIN/COMMIT and the writes share a connection. Pass a single-connection
|
|
1092
|
+
* handle when atomicity is load-bearing.
|
|
1093
|
+
*/
|
|
1094
|
+
atomic?: boolean | TxAtomicAdapter;
|
|
1095
|
+
}
|
|
1096
|
+
interface RoomCallbackResult {
|
|
1097
|
+
ok: boolean;
|
|
1098
|
+
reason?: string;
|
|
1099
|
+
}
|
|
1100
|
+
interface RoomCallback<TAuth extends AuthContext = AuthContext> {
|
|
1101
|
+
(ctx: {
|
|
1102
|
+
params: Record<string, string>;
|
|
1103
|
+
auth: TAuth;
|
|
1104
|
+
}): void | RoomCallbackResult | Promise<void | RoomCallbackResult>;
|
|
1105
|
+
}
|
|
1106
|
+
interface SyncServer<
|
|
1107
|
+
TAuth extends AuthContext = AuthContext,
|
|
1108
|
+
TDb = unknown
|
|
1109
|
+
> {
|
|
1110
|
+
auth(callback: AuthCallback): void;
|
|
1111
|
+
query(name: string, callback: QueryCallback<TAuth, TDb>, options: QueryOptions<TAuth, TDb>): void;
|
|
1112
|
+
room(pattern: string, callback: RoomCallback<TAuth>): void;
|
|
1113
|
+
rateLimit(config: RateLimitConfig): void;
|
|
1114
|
+
compaction(config: CompactionConfig): void;
|
|
1115
|
+
minSchemaVersion(version: number): void;
|
|
1116
|
+
runCompaction(): Promise<void>;
|
|
1117
|
+
notifyChange(tableName: string, roomKey?: string | null): Promise<void>;
|
|
1118
|
+
/**
|
|
1119
|
+
* Idempotency gate — returns true if this opId is fresh. Callers use
|
|
1120
|
+
* this to dedupe REST retries (e.g. Idempotency-Key header).
|
|
1121
|
+
*/
|
|
1122
|
+
reserveOpId(opId: string): Promise<boolean>;
|
|
1123
|
+
/**
|
|
1124
|
+
* Route a server-originated op (e.g. REST write) through the sync
|
|
1125
|
+
* pipeline: assign server HLC, persist atomically, broadcast.
|
|
1126
|
+
* Call after authorize + mutate to keep the op log consistent.
|
|
1127
|
+
*
|
|
1128
|
+
* Pass `options.roomKey` to scope the broadcast to a tenant/room. Without
|
|
1129
|
+
* it the broadcast goes to all subscribers of the affected query —
|
|
1130
|
+
* leaking writes across tenants in multi-tenant apps.
|
|
1131
|
+
*/
|
|
1132
|
+
applyServerOp(op: {
|
|
1133
|
+
type: OpType;
|
|
1134
|
+
table: string;
|
|
1135
|
+
rowId: string;
|
|
1136
|
+
payload: Record<string, unknown> | null;
|
|
1137
|
+
}, execute?: (stamped: {
|
|
1138
|
+
type: OpType;
|
|
1139
|
+
table: string;
|
|
1140
|
+
rowId: string;
|
|
1141
|
+
payload: Record<string, unknown> | null;
|
|
1142
|
+
hlc: string;
|
|
1143
|
+
}) => Promise<void>, options?: {
|
|
1144
|
+
roomKey?: string | null;
|
|
1145
|
+
}): Promise<{
|
|
1146
|
+
hlc: string;
|
|
1147
|
+
resolvedRow: Record<string, unknown> | null;
|
|
1148
|
+
}>;
|
|
1149
|
+
/**
|
|
1150
|
+
* Server-origin row write. Sugar over `applyServerOp` — generates rowId
|
|
1151
|
+
* (uses `payload.id` if present, else uuid), routes through HLC + op log
|
|
1152
|
+
* + broadcast pipeline. Does not apply schema serverSet/readonly stripping;
|
|
1153
|
+
* server is the trusted writer.
|
|
1154
|
+
*/
|
|
1155
|
+
emit(table: string, payload: Record<string, unknown>, options?: {
|
|
1156
|
+
rowId?: string;
|
|
1157
|
+
type?: OpType;
|
|
1158
|
+
roomKey?: string | null;
|
|
1159
|
+
}): Promise<{
|
|
1160
|
+
hlc: string;
|
|
1161
|
+
rowId: string;
|
|
1162
|
+
}>;
|
|
1163
|
+
/**
|
|
1164
|
+
* Atomic-ish write group with auto-notify. Wraps the server's underlying
|
|
1165
|
+
* drizzle `db` in a proxy that tracks tables touched by
|
|
1166
|
+
* `insert/update/delete`; on success, fires one `notifyChange` per touched
|
|
1167
|
+
* table. With `atomic: true`, runs inside `db.transaction(...)`; without
|
|
1168
|
+
* it, writes are not transactional but notifies are still skipped on
|
|
1169
|
+
* throw. Throws if the server has no `db` configured.
|
|
1170
|
+
*/
|
|
1171
|
+
tx<T>(fn: TxFn<T>): Promise<T>;
|
|
1172
|
+
tx<T>(opts: TxOptions, fn: TxFn<T>): Promise<T>;
|
|
1173
|
+
lock<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
1174
|
+
tryLock<T>(key: string, fn: () => Promise<T>): Promise<T | null>;
|
|
1175
|
+
/**
|
|
1176
|
+
* `setInterval` wrapper that auto-disposes on `bun --hot` reload and on
|
|
1177
|
+
* `server.close()`. Errors from `fn` are logged, not thrown.
|
|
1178
|
+
*/
|
|
1179
|
+
interval(ms: number, fn: () => void | Promise<void>): {
|
|
1180
|
+
clear(): void;
|
|
1181
|
+
};
|
|
1182
|
+
/**
|
|
1183
|
+
* `setTimeout` wrapper that auto-disposes on `bun --hot` reload and on
|
|
1184
|
+
* `server.close()`. Errors from `fn` are logged, not thrown.
|
|
1185
|
+
*/
|
|
1186
|
+
timeout(ms: number, fn: () => void | Promise<void>): {
|
|
1187
|
+
clear(): void;
|
|
1188
|
+
};
|
|
1189
|
+
close(): Promise<void>;
|
|
1190
|
+
}
|
|
1191
|
+
type Ctx<
|
|
1192
|
+
TAuth extends AuthContext,
|
|
1193
|
+
TParams extends Record<string, unknown>
|
|
1194
|
+
> = {
|
|
1195
|
+
auth: TAuth;
|
|
1196
|
+
params: TParams;
|
|
1197
|
+
};
|
|
1198
|
+
/**
|
|
1199
|
+
* Function-form scope returns an adapter-interpreted SQL fragment (e.g. a
|
|
1200
|
+
* drizzle `SQL`, a kysely `Expression<boolean>`, or a raw string). The generic
|
|
1201
|
+
* factory treats it as opaque (`unknown`) — ORM-specific helpers like
|
|
1202
|
+
* `drizzleTable` narrow the return type at their call site.
|
|
1203
|
+
*/
|
|
1204
|
+
type ScopeFn<
|
|
1205
|
+
TAuth extends AuthContext,
|
|
1206
|
+
TParams extends Record<string, unknown>
|
|
1207
|
+
> = (ctx: Ctx<TAuth, TParams>) => unknown;
|
|
1208
|
+
type ScopeOpt<
|
|
1209
|
+
TRowKey extends string,
|
|
1210
|
+
TAuth extends AuthContext,
|
|
1211
|
+
TParams extends Record<string, unknown>
|
|
1212
|
+
> = TRowKey | {
|
|
1213
|
+
auth: keyof TAuth & string;
|
|
1214
|
+
column: TRowKey;
|
|
1215
|
+
} | ScopeFn<TAuth, TParams>;
|
|
1216
|
+
type StampSource<
|
|
1217
|
+
TAuth extends AuthContext,
|
|
1218
|
+
TParams extends Record<string, unknown>
|
|
1219
|
+
> = "auth.userId" | "auth.name" | string | ((ctx: Ctx<TAuth, TParams>) => unknown);
|
|
1220
|
+
type ServerSetSource<
|
|
1221
|
+
TAuth extends AuthContext,
|
|
1222
|
+
TParams extends Record<string, unknown>
|
|
1223
|
+
> = unknown | ((ctx: Ctx<TAuth, TParams>) => unknown);
|
|
1224
|
+
type FieldPolicy<
|
|
1225
|
+
TAuth extends AuthContext,
|
|
1226
|
+
TParams extends Record<string, unknown>
|
|
1227
|
+
> = "server-only" | "insert-only" | {
|
|
1228
|
+
write: (input: {
|
|
1229
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1230
|
+
existing?: Record<string, unknown> | null;
|
|
1231
|
+
payload: Record<string, unknown>;
|
|
1232
|
+
}) => boolean | Promise<boolean>;
|
|
1233
|
+
};
|
|
1234
|
+
/**
|
|
1235
|
+
* Built-in scope shorthand resolves to one of these. Function-form scope
|
|
1236
|
+
* (returning raw SQL) bypasses the adapter — it must be paired with an
|
|
1237
|
+
* `opts.query` override since the adapter has no SQL fragment to consume.
|
|
1238
|
+
*/
|
|
1239
|
+
interface ScopeFilter {
|
|
1240
|
+
kind: "params" | "auth";
|
|
1241
|
+
column: string;
|
|
1242
|
+
value: unknown;
|
|
1243
|
+
}
|
|
1244
|
+
/**
|
|
1245
|
+
* Minimal contract a data layer must implement to plug into `defineTable`.
|
|
1246
|
+
* Per-row I/O only — bulk paths intentionally absent so adapters stay short.
|
|
1247
|
+
*/
|
|
1248
|
+
interface TableAdapter<TRow extends Record<string, unknown> = Record<string, unknown>> {
|
|
1249
|
+
/** Logical table name — used for op log + change detection. */
|
|
1250
|
+
name: string;
|
|
1251
|
+
/** Primary key column. Default "id". */
|
|
1252
|
+
pk?: string;
|
|
1253
|
+
/**
|
|
1254
|
+
* Fetch rows, optionally narrowed by a scope filter the factory passes
|
|
1255
|
+
* for built-in scope shorthand. Implement using the adapter's native
|
|
1256
|
+
* query builder. Return rows or a thenable resolving to rows.
|
|
1257
|
+
*/
|
|
1258
|
+
list(input: {
|
|
1259
|
+
db: any;
|
|
1260
|
+
scope?: ScopeFilter;
|
|
1261
|
+
}): Promise<TRow[]> | TRow[];
|
|
1262
|
+
/** Fetch a single row by pk. Return null if absent. */
|
|
1263
|
+
find(input: {
|
|
1264
|
+
db: any;
|
|
1265
|
+
rowId: string;
|
|
1266
|
+
}): Promise<TRow | null> | TRow | null;
|
|
1267
|
+
/** Insert (or upsert) a row. payload does NOT include the pk; rowId is authoritative. */
|
|
1268
|
+
insert(input: {
|
|
1269
|
+
db: any;
|
|
1270
|
+
rowId: string;
|
|
1271
|
+
payload: Record<string, unknown>;
|
|
1272
|
+
}): Promise<void> | void;
|
|
1273
|
+
/** Update an existing row. payload may be partial. */
|
|
1274
|
+
update(input: {
|
|
1275
|
+
db: any;
|
|
1276
|
+
rowId: string;
|
|
1277
|
+
payload: Record<string, unknown>;
|
|
1278
|
+
}): Promise<void> | void;
|
|
1279
|
+
/** Delete a row by pk. */
|
|
1280
|
+
delete(input: {
|
|
1281
|
+
db: any;
|
|
1282
|
+
rowId: string;
|
|
1283
|
+
}): Promise<void> | void;
|
|
1284
|
+
}
|
|
1285
|
+
interface DefineTableOpts<
|
|
1286
|
+
TRow extends Record<string, unknown> = Record<string, unknown>,
|
|
1287
|
+
TAuth extends AuthContext = AuthContext,
|
|
1288
|
+
TParams extends Record<string, unknown> = Record<string, unknown>
|
|
1289
|
+
> {
|
|
1290
|
+
scope?: ScopeOpt<keyof TRow & string, TAuth, TParams>;
|
|
1291
|
+
rowId?: string;
|
|
1292
|
+
ownerStamp?: Partial<Record<keyof TRow & string, StampSource<TAuth, TParams>>>;
|
|
1293
|
+
serverSet?: Partial<Record<keyof TRow & string, ServerSetSource<TAuth, TParams>>>;
|
|
1294
|
+
policy?: {
|
|
1295
|
+
read?: (input: {
|
|
1296
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1297
|
+
existing?: Record<string, unknown> | null;
|
|
1298
|
+
}) => boolean | Promise<boolean>;
|
|
1299
|
+
insert?: (input: {
|
|
1300
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1301
|
+
payload: Record<string, unknown>;
|
|
1302
|
+
}) => boolean | Promise<boolean>;
|
|
1303
|
+
update?: (input: {
|
|
1304
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1305
|
+
existing: Record<string, unknown>;
|
|
1306
|
+
payload: Record<string, unknown>;
|
|
1307
|
+
}) => boolean | Promise<boolean>;
|
|
1308
|
+
delete?: (input: {
|
|
1309
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1310
|
+
existing: Record<string, unknown>;
|
|
1311
|
+
}) => boolean | Promise<boolean>;
|
|
1312
|
+
fields?: Partial<Record<keyof TRow & string, FieldPolicy<TAuth, TParams>>>;
|
|
1313
|
+
};
|
|
1314
|
+
hooks?: {
|
|
1315
|
+
beforeInsert?: (input: {
|
|
1316
|
+
payload: Record<string, unknown>;
|
|
1317
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1318
|
+
db: any;
|
|
1319
|
+
}) => Promise<void> | void;
|
|
1320
|
+
afterInsert?: (input: {
|
|
1321
|
+
row: Record<string, unknown>;
|
|
1322
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1323
|
+
db: any;
|
|
1324
|
+
}) => Promise<void> | void;
|
|
1325
|
+
beforeUpdate?: (input: {
|
|
1326
|
+
payload: Record<string, unknown>;
|
|
1327
|
+
existing: Record<string, unknown>;
|
|
1328
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1329
|
+
db: any;
|
|
1330
|
+
}) => Promise<void> | void;
|
|
1331
|
+
afterUpdate?: (input: {
|
|
1332
|
+
row: Record<string, unknown>;
|
|
1333
|
+
previous: Record<string, unknown>;
|
|
1334
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1335
|
+
db: any;
|
|
1336
|
+
}) => Promise<void> | void;
|
|
1337
|
+
beforeDelete?: (input: {
|
|
1338
|
+
existing: Record<string, unknown>;
|
|
1339
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1340
|
+
db: any;
|
|
1341
|
+
}) => Promise<void> | void;
|
|
1342
|
+
afterDelete?: (input: {
|
|
1343
|
+
rowId: string;
|
|
1344
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1345
|
+
db: any;
|
|
1346
|
+
}) => Promise<void> | void;
|
|
1347
|
+
};
|
|
1348
|
+
query?: (ctx: Ctx<TAuth, TParams>, db: any) => unknown;
|
|
1349
|
+
insert?: (input: {
|
|
1350
|
+
payload: Record<string, unknown>;
|
|
1351
|
+
existing?: Record<string, unknown> | null;
|
|
1352
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1353
|
+
db: any;
|
|
1354
|
+
}) => Promise<void> | void;
|
|
1355
|
+
update?: (input: {
|
|
1356
|
+
payload: Record<string, unknown>;
|
|
1357
|
+
existing: Record<string, unknown>;
|
|
1358
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1359
|
+
db: any;
|
|
1360
|
+
}) => Promise<void> | void;
|
|
1361
|
+
delete?: (input: {
|
|
1362
|
+
rowId: string;
|
|
1363
|
+
existing: Record<string, unknown> | null;
|
|
1364
|
+
ctx: Ctx<TAuth, TParams>;
|
|
1365
|
+
db: any;
|
|
1366
|
+
}) => Promise<void> | void;
|
|
1367
|
+
pk?: keyof TRow & string;
|
|
1368
|
+
}
|
|
1369
|
+
declare function defineTable<
|
|
1370
|
+
TRow extends Record<string, unknown> = Record<string, unknown>,
|
|
1371
|
+
TAuth extends AuthContext = AuthContext,
|
|
1372
|
+
TParams extends Record<string, unknown> = Record<string, unknown>
|
|
1373
|
+
>(adapter: TableAdapter<TRow>, opts?: DefineTableOpts<TRow, TAuth, TParams>): {
|
|
1374
|
+
query: (ctx: Ctx<TAuth, TParams>, db: any) => unknown;
|
|
1375
|
+
mutate: (op: ResolvedOp, ctx: MutationContext<TAuth>, db: any) => Promise<void>;
|
|
1376
|
+
authorize: (action: AuthorizeAction, ctx: MutationContext<TAuth>, db: any) => Promise<void>;
|
|
1377
|
+
};
|
|
1378
|
+
import { SQL as SQL_1rji } from "drizzle-orm";
|
|
1379
|
+
type SQL = SQL_1rji;
|
|
1380
|
+
type Ctx2<
|
|
1381
|
+
TAuth extends AuthContext,
|
|
1382
|
+
TParams extends Record<string, unknown>
|
|
1383
|
+
> = {
|
|
1384
|
+
auth: TAuth;
|
|
1385
|
+
params: TParams;
|
|
1386
|
+
};
|
|
1387
|
+
type DrizzleScopeFn<
|
|
1388
|
+
TAuth extends AuthContext,
|
|
1389
|
+
TParams extends Record<string, unknown>
|
|
1390
|
+
> = (ctx: Ctx2<TAuth, TParams>) => SQL;
|
|
1391
|
+
type DrizzleScopeOpt<
|
|
1392
|
+
TRowKey extends string,
|
|
1393
|
+
TAuth extends AuthContext,
|
|
1394
|
+
TParams extends Record<string, unknown>
|
|
1395
|
+
> = TRowKey | {
|
|
1396
|
+
auth: keyof TAuth & string;
|
|
1397
|
+
column: TRowKey;
|
|
1398
|
+
} | DrizzleScopeFn<TAuth, TParams>;
|
|
1399
|
+
/**
|
|
1400
|
+
* Drizzle-flavored options. Identical surface to `DefineTableOpts` except the
|
|
1401
|
+
* row type is derived from `$inferSelect` and function-form scope returns
|
|
1402
|
+
* drizzle's `SQL` (vs. the generic factory's `unknown`). Kept for backward
|
|
1403
|
+
* compat with the existing public `drizzleTable<TTable, ...>(opts)` call shape.
|
|
1404
|
+
*/
|
|
1405
|
+
interface DrizzleTableOpts<
|
|
1406
|
+
TTable extends DrizzleTableLike,
|
|
1407
|
+
TAuth extends AuthContext = AuthContext,
|
|
1408
|
+
TParams extends Record<string, unknown> = Record<string, unknown>
|
|
1409
|
+
> extends Omit<DefineTableOpts<TTable["$inferSelect"], TAuth, TParams>, "scope"> {
|
|
1410
|
+
scope?: DrizzleScopeOpt<keyof TTable["$inferSelect"] & string, TAuth, TParams>;
|
|
1411
|
+
}
|
|
1412
|
+
declare function drizzleTable<
|
|
1413
|
+
TTable extends DrizzleTableLike,
|
|
1414
|
+
TAuth extends AuthContext = AuthContext,
|
|
1415
|
+
TParams extends Record<string, unknown> = Record<string, unknown>
|
|
1416
|
+
>(table: TTable, opts?: DrizzleTableOpts<TTable, TAuth, TParams>): {
|
|
1417
|
+
query: (ctx: Ctx2<TAuth, TParams>, db: any) => unknown;
|
|
1418
|
+
mutate: (op: ResolvedOp, ctx: MutationContext<TAuth>, db: any) => Promise<void>;
|
|
1419
|
+
authorize: (action: AuthorizeAction, ctx: MutationContext<TAuth>, db: any) => Promise<void>;
|
|
1420
|
+
};
|
|
1421
|
+
/**
|
|
1422
|
+
* `TxAtomicAdapter` for drizzle handles. Uses raw SQL via `db.run(sql\`BEGIN\`)`
|
|
1423
|
+
* — drizzle's async `db.transaction(...)` is unsafe on the bun-sqlite dialect
|
|
1424
|
+
* (sync transaction commits before the async body finishes), so we route
|
|
1425
|
+
* through `db.run(...)` which is portable across sqlite and postgres adapters.
|
|
1426
|
+
*
|
|
1427
|
+
* Exported so users can pass it explicitly to `tx({ atomic: drizzleTxAtomic })`
|
|
1428
|
+
* or to `ServerConfig.txAtomic` — and so the server's lazy-loaded fallback
|
|
1429
|
+
* has a single source of truth.
|
|
1430
|
+
*/
|
|
1431
|
+
declare const drizzleTxAtomic: TxAtomicAdapter;
|
|
1432
|
+
declare function createServer<
|
|
1433
|
+
TDb = unknown,
|
|
1434
|
+
TAuth extends AuthContext = AuthContext
|
|
1435
|
+
>(config: ServerConfig<TDb>): SyncServer<TAuth, TDb>;
|
|
1436
|
+
interface PipelineContext {
|
|
1437
|
+
userId: string;
|
|
1438
|
+
nodeId: string;
|
|
1439
|
+
options: QueryOptions;
|
|
1440
|
+
conflict: ConflictPolicy;
|
|
1441
|
+
rateLimiter?: RateLimiter;
|
|
1442
|
+
maxBatchSize?: number;
|
|
1443
|
+
}
|
|
1444
|
+
interface PipelineResult {
|
|
1445
|
+
accepted: boolean;
|
|
1446
|
+
op: ClientOp;
|
|
1447
|
+
resolvedRow: Record<string, unknown> | null;
|
|
1448
|
+
updatedColClocks: Record<string, string>;
|
|
1449
|
+
reason?: ErrorReason;
|
|
1450
|
+
}
|
|
1451
|
+
declare function processOp(op: ClientOp, existing: ExistingRow, ctx: PipelineContext): PipelineResult;
|
|
1452
|
+
/**
|
|
1453
|
+
* Structural stand-in for `bun:sqlite`'s `Database`, covering only the surface
|
|
1454
|
+
* this adapter uses. A real `bun:sqlite` Database satisfies it structurally.
|
|
1455
|
+
*
|
|
1456
|
+
* Deliberately NOT `import("bun:sqlite").Database`: that type reference survives
|
|
1457
|
+
* into the emitted `dist/server/index.d.ts`, and the specifier does not resolve
|
|
1458
|
+
* for consumers who type-check under Node without `@types/bun` — every such
|
|
1459
|
+
* consumer gets "Cannot find module 'bun:sqlite'" from a declaration file they
|
|
1460
|
+
* never asked for. `bun run verify:exports` guards the regression.
|
|
1461
|
+
*/
|
|
1462
|
+
type SqlValue = string | number | bigint | boolean | null | Uint8Array;
|
|
1463
|
+
/**
|
|
1464
|
+
* Bind parameters stay `SqlValue[]` rather than a per-statement tuple: bun's own
|
|
1465
|
+
* `Statement` takes a union of tuple shapes, and a generic tuple here makes a
|
|
1466
|
+
* real `Database` fail to satisfy `BunDatabase` — which would break every Bun
|
|
1467
|
+
* caller passing `{ db }`. Row types, the part worth keeping, are still exact.
|
|
1468
|
+
*/
|
|
1469
|
+
interface BunStatement<Row> {
|
|
1470
|
+
all(...params: SqlValue[]): Row[];
|
|
1471
|
+
get(...params: SqlValue[]): Row | null;
|
|
1472
|
+
run(...params: SqlValue[]): {
|
|
1473
|
+
changes: number;
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
interface BunDatabase {
|
|
1477
|
+
run(sql: string, ...params: SqlValue[][]): {
|
|
1478
|
+
changes: number;
|
|
1479
|
+
};
|
|
1480
|
+
prepare<Row = unknown>(sql: string): BunStatement<Row>;
|
|
1481
|
+
transaction<T>(fn: () => T): () => T;
|
|
1482
|
+
close(): void;
|
|
1483
|
+
}
|
|
1484
|
+
type Database = BunDatabase;
|
|
1485
|
+
interface SqliteStorageConfig {
|
|
1486
|
+
/** Path to SQLite database file, or ":memory:" for in-memory */
|
|
1487
|
+
path?: string;
|
|
1488
|
+
/** Existing Database instance to use instead of creating one */
|
|
1489
|
+
db?: Database;
|
|
1490
|
+
}
|
|
1491
|
+
declare function createSqliteStorage(config?: SqliteStorageConfig): StorageAdapter & {
|
|
1492
|
+
close(): void;
|
|
1493
|
+
};
|
|
1494
|
+
/**
|
|
1495
|
+
* Minimal Postgres client interface.
|
|
1496
|
+
* Compatible with pg.Pool, pg.Client, @neondatabase/serverless, etc.
|
|
1497
|
+
*/
|
|
1498
|
+
interface PostgresClient {
|
|
1499
|
+
query<T extends Record<string, unknown> = Record<string, unknown>>(text: string, values?: unknown[]): Promise<{
|
|
1500
|
+
rows: T[];
|
|
1501
|
+
}>;
|
|
1502
|
+
}
|
|
1503
|
+
interface PostgresStorageConfig {
|
|
1504
|
+
/** Postgres client or pool implementing the query interface */
|
|
1505
|
+
client: PostgresClient;
|
|
1506
|
+
/** Table name prefix. Default: "_reflectdb" */
|
|
1507
|
+
tablePrefix?: string;
|
|
1508
|
+
}
|
|
1509
|
+
declare function createPostgresStorage(clientOrConfig: PostgresClient | PostgresStorageConfig): StorageAdapter & {
|
|
1510
|
+
close(): void;
|
|
1511
|
+
ensureSchema(): Promise<void>;
|
|
1512
|
+
};
|
|
1513
|
+
/**
|
|
1514
|
+
* Deterministic JSON: object keys are emitted in sorted order at every depth.
|
|
1515
|
+
*
|
|
1516
|
+
* `JSON.stringify` is insertion-order sensitive, so two auth contexts holding
|
|
1517
|
+
* the same fields in a different order would hash to different group keys and
|
|
1518
|
+
* the broadcast engine would execute the identical query twice.
|
|
1519
|
+
*/
|
|
1520
|
+
declare function stableStringify(value: unknown): string;
|
|
1521
|
+
interface BroadcastEngineDeps<TAuth extends AuthContext> {
|
|
1522
|
+
sessions: SessionManager<TAuth>;
|
|
1523
|
+
queries: Map<string, QueryRegistration>;
|
|
1524
|
+
tableDependencyIndex: Map<string, Set<string>>;
|
|
1525
|
+
resultCache: ResultCache;
|
|
1526
|
+
/** Raw transport send — MUST reject when the frame did not reach the client. */
|
|
1527
|
+
send(clientId: string, message: ServerMessage): Promise<void>;
|
|
1528
|
+
/** Execute a query for a session, optionally capped to `limit` rows. */
|
|
1529
|
+
executeQuery(reg: QueryRegistration, session: ClientSession<TAuth>, limit?: number): Promise<Record<string, unknown>[]>;
|
|
1530
|
+
/** Total row count for count hints, or null when the query has no `count` callback. */
|
|
1531
|
+
countQuery(reg: QueryRegistration, session: ClientSession<TAuth>): Promise<number | null>;
|
|
1532
|
+
/** Bump and pack the server HLC. One call per broadcast tick. */
|
|
1533
|
+
nextHlc(): string;
|
|
1534
|
+
emit(event: SyncEvent): void;
|
|
1535
|
+
/** Reject a query execution that exceeds this many ms. 0 disables. */
|
|
1536
|
+
queryTimeoutMs: number;
|
|
1537
|
+
/** Max query groups executed concurrently within one broadcast. */
|
|
1538
|
+
maxGroupConcurrency: number;
|
|
1539
|
+
}
|
|
1540
|
+
/**
|
|
1541
|
+
* Turns "table X changed" into per-subscriber deltas.
|
|
1542
|
+
*
|
|
1543
|
+
* Subscribers are grouped by everything that can change query results
|
|
1544
|
+
* (auth + params + roomKey, or a query-supplied `groupBy`), the query runs once
|
|
1545
|
+
* per group, and each client's result cache is diffed against the shared result.
|
|
1546
|
+
*
|
|
1547
|
+
* Scaling note: one write still costs one query execution per distinct group.
|
|
1548
|
+
* Because `auth` is part of the default group key, groups collapse to roughly
|
|
1549
|
+
* one per connected client. Queries whose results depend only on a tenant/room
|
|
1550
|
+
* rather than the full auth object should set `groupBy` to collapse those
|
|
1551
|
+
* clients into a single execution.
|
|
1552
|
+
*/
|
|
1553
|
+
declare class BroadcastEngine<TAuth extends AuthContext = AuthContext> {
|
|
1554
|
+
private deps;
|
|
1555
|
+
/**
|
|
1556
|
+
* Per-(client, query) critical section. Inbound messages are serialized per
|
|
1557
|
+
* client, but broadcasts are not: two concurrent writers would otherwise
|
|
1558
|
+
* both diff against the same stale cache, both send, and both commit — last
|
|
1559
|
+
* commit wins, and any row only the loser knew about never gets its delete
|
|
1560
|
+
* emitted again. The client would keep a phantom row until reconnect.
|
|
1561
|
+
*/
|
|
1562
|
+
private locks;
|
|
1563
|
+
constructor(deps: BroadcastEngineDeps<TAuth>);
|
|
1564
|
+
private withLock;
|
|
1565
|
+
private executeWithTimeout;
|
|
1566
|
+
/**
|
|
1567
|
+
* After a write to `tableName`, re-run every dependent query and send each
|
|
1568
|
+
* subscriber the rows that changed for them.
|
|
1569
|
+
*
|
|
1570
|
+
* @param excludeClientId — the writer, who already applied optimistically.
|
|
1571
|
+
* Pass "" for server-originated writes with no client to exclude.
|
|
1572
|
+
* @param overrideRoomKey — restrict fanout to one room. `undefined` falls
|
|
1573
|
+
* back to the writer's own room; `null` broadcasts cross-room.
|
|
1574
|
+
*/
|
|
1575
|
+
broadcastChanges(tableName: string, excludeClientId: string, overrideRoomKey?: string | null): Promise<void>;
|
|
1576
|
+
private buildGroups;
|
|
1577
|
+
private groupKeyFor;
|
|
1578
|
+
/**
|
|
1579
|
+
* Row cap this group actually needs.
|
|
1580
|
+
*
|
|
1581
|
+
* Windowed subscribers only render up to their window, so fetching beyond
|
|
1582
|
+
* the largest window in the group is pure waste. Returns undefined when the
|
|
1583
|
+
* full result set is required — a non-windowed subscriber, or count hints
|
|
1584
|
+
* without a `count` callback to compute the total cheaply.
|
|
1585
|
+
*/
|
|
1586
|
+
private limitFor;
|
|
1587
|
+
private broadcastGroup;
|
|
1588
|
+
private sendToSubscriber;
|
|
1589
|
+
/**
|
|
1590
|
+
* Commit a client's cached result set — unless it disconnected, or
|
|
1591
|
+
* reconnected under the same clientId, while the sends were in flight.
|
|
1592
|
+
* Writing then would resurrect the cache entry that `onDisconnect` just
|
|
1593
|
+
* dropped, permanently (no further disconnect event arrives for that
|
|
1594
|
+
* session), and would describe the old connection's state to a new one.
|
|
1595
|
+
*/
|
|
1596
|
+
private commit;
|
|
1597
|
+
/** Run `fn` over items with at most `maxGroupConcurrency` in flight. */
|
|
1598
|
+
private runBounded;
|
|
1599
|
+
}
|
|
1600
|
+
/**
|
|
1601
|
+
* Atomic replay-detection gate. With storage, delegates to `storage.reserveOp`
|
|
1602
|
+
* (HA-safe). Without storage, uses a TTL-bounded in-memory map (single-instance).
|
|
1603
|
+
*
|
|
1604
|
+
* Returns true when the opId is fresh (first sighting), false on replay.
|
|
1605
|
+
*/
|
|
1606
|
+
declare class ReplayDetector {
|
|
1607
|
+
private storage;
|
|
1608
|
+
private static readonly TTL_MS;
|
|
1609
|
+
private static readonly MAX_ENTRIES;
|
|
1610
|
+
private inMemory;
|
|
1611
|
+
constructor(storage: StorageAdapter | null);
|
|
1612
|
+
setStorage(storage: StorageAdapter): void;
|
|
1613
|
+
reserve(opId: string): Promise<boolean>;
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* Per-`ops`-message state shared by every op in the batch: which op ids were
|
|
1617
|
+
* fresh (one replay reservation for the whole batch) and the rows those ops
|
|
1618
|
+
* touch (one read per table). Kept write-through so an op later in the batch
|
|
1619
|
+
* still sees the state an earlier op in the same batch wrote.
|
|
1620
|
+
*/
|
|
1621
|
+
interface BatchContext {
|
|
1622
|
+
freshOpIds: Set<string>;
|
|
1623
|
+
rows: Map<string, ExistingRow>;
|
|
1624
|
+
}
|
|
1625
|
+
interface OpResult {
|
|
1626
|
+
accepted: boolean;
|
|
1627
|
+
reason?: ErrorReason;
|
|
1628
|
+
serverRow?: Record<string, unknown> | null;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* Collaborators the op pipeline needs from the handler. Mutable settings are
|
|
1632
|
+
* read through getters because `setStorage`/`setRateLimiter`/`setMaxBatchSize`
|
|
1633
|
+
* can land after the processor is constructed.
|
|
1634
|
+
*/
|
|
1635
|
+
interface OpProcessorDeps<TAuth extends AuthContext> {
|
|
1636
|
+
db: unknown;
|
|
1637
|
+
queries: Map<string, QueryRegistration>;
|
|
1638
|
+
sessions: SessionManager<TAuth>;
|
|
1639
|
+
eagerBuffer: EagerBuffer;
|
|
1640
|
+
replay: ReplayDetector;
|
|
1641
|
+
getStorage(): StorageAdapter | null;
|
|
1642
|
+
getRateLimiter(): RateLimiter | null;
|
|
1643
|
+
getMaxBatchSize(): number;
|
|
1644
|
+
/** Bump the server clock and return the packed HLC. */
|
|
1645
|
+
stampHlc(): string;
|
|
1646
|
+
/** Advance the server clock past an accepted client op's HLC. */
|
|
1647
|
+
receiveClientHlc(hlc: string): void;
|
|
1648
|
+
send(clientId: string, message: ServerMessage): Promise<void>;
|
|
1649
|
+
broadcastChanges(tableName: string, excludeClientId: string): Promise<void>;
|
|
1650
|
+
}
|
|
1651
|
+
/**
|
|
1652
|
+
* Applies client ops: replay reservation, enforcement, conflict resolution,
|
|
1653
|
+
* persistence and the eager-path delta fanout.
|
|
1654
|
+
*
|
|
1655
|
+
* Two pipelines live here. The **consistent** path runs the full conflict
|
|
1656
|
+
* pipeline, persists, then lets the broadcast engine diff each subscriber's
|
|
1657
|
+
* query result. The **eager** paths skip conflict resolution and send the delta
|
|
1658
|
+
* straight to subscribers — they still run every enforcement gate.
|
|
1659
|
+
*/
|
|
1660
|
+
declare class OpProcessor<TAuth extends AuthContext = AuthContext> {
|
|
1661
|
+
private deps;
|
|
1662
|
+
constructor(deps: OpProcessorDeps<TAuth>);
|
|
1663
|
+
private get storage();
|
|
1664
|
+
private get rateLimiter();
|
|
1665
|
+
private get maxBatchSize();
|
|
1666
|
+
private get db();
|
|
1667
|
+
private get queries();
|
|
1668
|
+
private get sessions();
|
|
1669
|
+
private get eagerBuffer();
|
|
1670
|
+
private get replay();
|
|
1671
|
+
private static rowKey;
|
|
1672
|
+
/**
|
|
1673
|
+
* Reserve every op id and read every touched row in as few round trips as
|
|
1674
|
+
* the adapter allows. Falls back to per-op calls on adapters without the
|
|
1675
|
+
* batch methods, in which case the returned context only carries the
|
|
1676
|
+
* reservation result.
|
|
1677
|
+
*/
|
|
1678
|
+
prefetchBatch(ops: ClientOp[]): Promise<BatchContext>;
|
|
1679
|
+
/** Read a row, preferring the batch prefetch cache. */
|
|
1680
|
+
private readRow;
|
|
1681
|
+
/**
|
|
1682
|
+
* Write-through the batch cache after a successful apply, so a later op in
|
|
1683
|
+
* the same batch touching the same row conflict-resolves against what was
|
|
1684
|
+
* just written rather than the pre-batch snapshot.
|
|
1685
|
+
*/
|
|
1686
|
+
private recordBatchWrite;
|
|
1687
|
+
processClientOp(op: ClientOp, session: ClientSession<TAuth>, deferredBroadcastTables?: Set<string>, batchCtx?: BatchContext): Promise<OpResult>;
|
|
1688
|
+
private processEagerOp;
|
|
1689
|
+
private processConsistentOp;
|
|
1690
|
+
}
|
|
1691
|
+
interface HLC {
|
|
1692
|
+
ms: number;
|
|
1693
|
+
counter: number;
|
|
1694
|
+
nodeId: string;
|
|
1695
|
+
}
|
|
1696
|
+
/**
|
|
1697
|
+
* Meta key holding the highest server HLC this deployment has stamped.
|
|
1698
|
+
* Shared across instances that share storage.
|
|
1699
|
+
*/
|
|
1700
|
+
declare const SERVER_HLC_META_KEY = "serverHlcWatermark";
|
|
1701
|
+
/**
|
|
1702
|
+
* The server's hybrid logical clock, made durable.
|
|
1703
|
+
*
|
|
1704
|
+
* An in-memory-only clock resets to zero on restart and recovers only via
|
|
1705
|
+
* `Date.now()`. A backwards NTP step then makes broadcast HLCs regress and
|
|
1706
|
+
* clients drop legitimate deltas; across instances, one lagging wall clock does
|
|
1707
|
+
* the same thing continuously. This persists a watermark (throttled, with a
|
|
1708
|
+
* lease ahead of now) and merges it on boot — and on demand, so HA deployments
|
|
1709
|
+
* can converge peers that drift apart.
|
|
1710
|
+
*/
|
|
1711
|
+
declare class ServerClock {
|
|
1712
|
+
private hlc;
|
|
1713
|
+
private storage;
|
|
1714
|
+
private load;
|
|
1715
|
+
private persistTimer;
|
|
1716
|
+
private persistPending;
|
|
1717
|
+
constructor(nodeId: string);
|
|
1718
|
+
setStorage(storage: StorageAdapter): void;
|
|
1719
|
+
/** Current clock value. Read-only snapshot — use `stamp`/`receive` to advance. */
|
|
1720
|
+
current(): HLC;
|
|
1721
|
+
/** Merge the persisted watermark into this instance's clock, once. */
|
|
1722
|
+
ensureLoaded(): Promise<void>;
|
|
1723
|
+
/**
|
|
1724
|
+
* Re-read the shared watermark and merge it in. Multi-instance deployments
|
|
1725
|
+
* call this periodically (the HA poll loop does) so a lagging instance can't
|
|
1726
|
+
* stamp writes below what its peers already broadcast.
|
|
1727
|
+
*/
|
|
1728
|
+
refresh(): Promise<void>;
|
|
1729
|
+
/** Bump the clock, schedule a watermark write, return the packed HLC. */
|
|
1730
|
+
stamp(): string;
|
|
1731
|
+
/** Peek at the next HLC without advancing the stored clock. */
|
|
1732
|
+
peekNext(): string;
|
|
1733
|
+
/** Current clock, packed. */
|
|
1734
|
+
packed(): string;
|
|
1735
|
+
/**
|
|
1736
|
+
* Advance past a remote HLC (an accepted client op, or a row written by
|
|
1737
|
+
* another instance). Malformed input is ignored — callers reject it through
|
|
1738
|
+
* their own clock-drift gate.
|
|
1739
|
+
*/
|
|
1740
|
+
receive(packedRemote: string): void;
|
|
1741
|
+
/** Flush any pending watermark write and stop the timer. */
|
|
1742
|
+
close(): Promise<void>;
|
|
1743
|
+
private schedulePersist;
|
|
1744
|
+
private flush;
|
|
1745
|
+
}
|
|
1746
|
+
/** Outcome of mapping subscription params onto a registered room pattern. */
|
|
1747
|
+
type RoomResolution = {
|
|
1748
|
+
ok: true;
|
|
1749
|
+
roomKey: string | null;
|
|
1750
|
+
} | {
|
|
1751
|
+
ok: false;
|
|
1752
|
+
reason: string;
|
|
1753
|
+
};
|
|
1754
|
+
/**
|
|
1755
|
+
* Map subscription params onto a registered room pattern.
|
|
1756
|
+
*
|
|
1757
|
+
* FAIL CLOSED. Room keys come from client-supplied params, so silently
|
|
1758
|
+
* returning `null` for a half-resolved or malformed key hands the client a
|
|
1759
|
+
* room-ACL bypass: no room callback runs, and the broadcast falls back to
|
|
1760
|
+
* cross-room fanout. Two shapes are rejected outright:
|
|
1761
|
+
*
|
|
1762
|
+
* - a pattern is partly addressed (`org/:orgId/team/:teamId` with `orgId`
|
|
1763
|
+
* but no `teamId`) — omitting a param must not widen the scope;
|
|
1764
|
+
* - every param is present but the substituted key fails the pattern
|
|
1765
|
+
* (e.g. a value containing `/`, which breaks the `([^/]+)` capture).
|
|
1766
|
+
*
|
|
1767
|
+
* A subscription that addresses no room pattern at all is still fine —
|
|
1768
|
+
* that's a legitimately global query. Set `QueryOptions.room` to require a
|
|
1769
|
+
* specific pattern for a query.
|
|
1770
|
+
*/
|
|
1771
|
+
declare function resolveRoomKey(rooms: readonly RoomRegistration[], params: Record<string, unknown>, requiredPattern?: string): RoomResolution;
|
|
1772
|
+
interface TypedServerConfig<
|
|
1773
|
+
TQueries extends SyncQueryMap,
|
|
1774
|
+
TDb = unknown
|
|
1775
|
+
> {
|
|
1776
|
+
queries: TQueries;
|
|
1777
|
+
db?: TDb;
|
|
1778
|
+
transport: ServerTransport;
|
|
1779
|
+
serverId?: string;
|
|
1780
|
+
storage?: StorageAdapter;
|
|
1781
|
+
onEvent?: (event: SyncEvent) => void;
|
|
1782
|
+
/** Poll interval in ms for detecting changes from other server instances sharing the same storage. Enables HA mode. 0 or undefined to disable. */
|
|
1783
|
+
poll?: number;
|
|
1784
|
+
maxConnectionsPerUser?: number;
|
|
1785
|
+
/**
|
|
1786
|
+
* Serve connections without an `auth()` callback, giving each session an
|
|
1787
|
+
* `anon:<clientId>` identity. Without it a server that never calls `auth()`
|
|
1788
|
+
* rejects the handshake instead of acking `hello` and then failing every
|
|
1789
|
+
* later message as unauthenticated.
|
|
1790
|
+
*/
|
|
1791
|
+
allowAnonymous?: boolean;
|
|
1792
|
+
/** Abort a broadcast query that runs longer than this many ms. 0 disables. */
|
|
1793
|
+
queryTimeoutMs?: number;
|
|
1794
|
+
/** Max subscriber groups whose queries run concurrently per broadcast. Default: 8. */
|
|
1795
|
+
maxBroadcastConcurrency?: number;
|
|
1796
|
+
/** Adapter that wraps `server.tx({ atomic: true })` writes in a transaction. */
|
|
1797
|
+
txAtomic?: TxAtomicAdapter;
|
|
1798
|
+
}
|
|
1799
|
+
type ImplementParams<
|
|
1800
|
+
TQueries extends SyncQueryMap,
|
|
1801
|
+
K extends keyof TQueries
|
|
1802
|
+
> = TQueries[K] extends {
|
|
1803
|
+
params: infer P extends Record<string, unknown>;
|
|
1804
|
+
} ? P : Record<string, unknown>;
|
|
1805
|
+
/** Keys whose value is a `SyncViewDef` (read-only computed query). */
|
|
1806
|
+
type ViewKeys<TQueries extends SyncQueryMap> = { [K in keyof TQueries] : TQueries[K] extends SyncViewDef ? K : never }[keyof TQueries];
|
|
1807
|
+
/** Keys whose value is a regular query (not view, not presence). */
|
|
1808
|
+
type RegularKeys<TQueries extends SyncQueryMap> = { [K in keyof TQueries] : TQueries[K] extends SyncViewDef | SyncPresenceDef ? never : K }[keyof TQueries];
|
|
1809
|
+
type ServerSetKeys<
|
|
1810
|
+
TQueries extends SyncQueryMap,
|
|
1811
|
+
K extends keyof TQueries
|
|
1812
|
+
> = InferServerSetKeys<TQueries[K]>;
|
|
1813
|
+
/** True when schema's `serverSet` is the array form (keys only) */
|
|
1814
|
+
type IsServerSetArrayForm<
|
|
1815
|
+
TQueries extends SyncQueryMap,
|
|
1816
|
+
K extends keyof TQueries
|
|
1817
|
+
> = TQueries[K] extends {
|
|
1818
|
+
serverSet: readonly string[];
|
|
1819
|
+
} ? true : false;
|
|
1820
|
+
/** Value: static or a function receiving auth/params context */
|
|
1821
|
+
type ServerSetValue<
|
|
1822
|
+
TAuth extends AuthContext,
|
|
1823
|
+
TQueries extends SyncQueryMap,
|
|
1824
|
+
K extends keyof TQueries
|
|
1825
|
+
> = unknown | ((ctx: {
|
|
1826
|
+
auth: TAuth;
|
|
1827
|
+
params: ImplementParams<TQueries, K>;
|
|
1828
|
+
}) => unknown);
|
|
1829
|
+
/**
|
|
1830
|
+
* `serverSet` shape on `implement(...)`:
|
|
1831
|
+
* - schema declares no `serverSet` → field disallowed.
|
|
1832
|
+
* - schema declares array form → field REQUIRED (values must be supplied).
|
|
1833
|
+
* - schema declares object form → field OPTIONAL (overrides only).
|
|
1834
|
+
*/
|
|
1835
|
+
type ServerSetOption<
|
|
1836
|
+
TQueries extends SyncQueryMap,
|
|
1837
|
+
K extends keyof TQueries,
|
|
1838
|
+
TAuth extends AuthContext
|
|
1839
|
+
> = [ServerSetKeys<TQueries, K>] extends [never] ? {
|
|
1840
|
+
serverSet?: undefined;
|
|
1841
|
+
} : IsServerSetArrayForm<TQueries, K> extends true ? {
|
|
1842
|
+
serverSet: { [F in ServerSetKeys<TQueries, K>] : ServerSetValue<TAuth, TQueries, K> };
|
|
1843
|
+
} : {
|
|
1844
|
+
serverSet?: Partial<{ [F in ServerSetKeys<TQueries, K>] : ServerSetValue<TAuth, TQueries, K> }>;
|
|
1845
|
+
};
|
|
1846
|
+
/**
|
|
1847
|
+
* Options for `server.implement(...)`. Collapses to `never` for view/presence
|
|
1848
|
+
* entries — those have dedicated `server.view(...)` / `usePresence(...)` paths
|
|
1849
|
+
* and aren't writable through the implement surface.
|
|
1850
|
+
*/
|
|
1851
|
+
type ImplementOptions<
|
|
1852
|
+
TQueries extends SyncQueryMap,
|
|
1853
|
+
K extends keyof TQueries,
|
|
1854
|
+
TAuth extends AuthContext,
|
|
1855
|
+
TDb
|
|
1856
|
+
> = TQueries[K] extends SyncViewDef | SyncPresenceDef ? never : {
|
|
1857
|
+
query: (ctx: {
|
|
1858
|
+
auth: TAuth;
|
|
1859
|
+
params: ImplementParams<TQueries, K>;
|
|
1860
|
+
}, db: TDb) => unknown;
|
|
1861
|
+
mutate?: (op: ResolvedOp, ctx: {
|
|
1862
|
+
auth: TAuth;
|
|
1863
|
+
params: ImplementParams<TQueries, K>;
|
|
1864
|
+
}, db: TDb) => Promise<void | MutateResult>;
|
|
1865
|
+
authorize?: (action: AuthorizeAction, ctx: {
|
|
1866
|
+
auth: TAuth;
|
|
1867
|
+
params: ImplementParams<TQueries, K>;
|
|
1868
|
+
}, db: TDb) => Promise<void>;
|
|
1869
|
+
broadcast?: "consistent" | "eager" | "eager-durable";
|
|
1870
|
+
flushInterval?: number;
|
|
1871
|
+
maxBufferSize?: number;
|
|
1872
|
+
/** Override change-detection tables (defaults to schema.tables or query key name) */
|
|
1873
|
+
tables?: string[];
|
|
1874
|
+
/**
|
|
1875
|
+
* Total row count for `countHints`, independent of the window. Without
|
|
1876
|
+
* it every broadcast fetches the full result set just to count it.
|
|
1877
|
+
*/
|
|
1878
|
+
count?: (ctx: {
|
|
1879
|
+
auth: TAuth;
|
|
1880
|
+
params: ImplementParams<TQueries, K>;
|
|
1881
|
+
}, db: TDb) => Promise<number> | number;
|
|
1882
|
+
/**
|
|
1883
|
+
* Collapse subscribers whose results are identical into one query
|
|
1884
|
+
* execution per broadcast. See `QueryOptions.groupBy`.
|
|
1885
|
+
*/
|
|
1886
|
+
groupBy?: (ctx: {
|
|
1887
|
+
auth: TAuth;
|
|
1888
|
+
params: ImplementParams<TQueries, K>;
|
|
1889
|
+
}) => string;
|
|
1890
|
+
/**
|
|
1891
|
+
* Require subscriptions to resolve this room pattern (e.g.
|
|
1892
|
+
* `"org/:orgId"`). Subscriptions that don't are rejected instead of
|
|
1893
|
+
* silently becoming unscoped. See `QueryOptions.room`.
|
|
1894
|
+
*/
|
|
1895
|
+
room?: string;
|
|
1896
|
+
} & ServerSetOption<TQueries, K, TAuth>;
|
|
1897
|
+
/** Function signature for a view's read-only query callback. */
|
|
1898
|
+
type ViewFn<
|
|
1899
|
+
TQueries extends SyncQueryMap,
|
|
1900
|
+
K extends keyof TQueries,
|
|
1901
|
+
TAuth extends AuthContext,
|
|
1902
|
+
TDb
|
|
1903
|
+
> = (ctx: {
|
|
1904
|
+
auth: TAuth;
|
|
1905
|
+
params: ImplementParams<TQueries, K>;
|
|
1906
|
+
}, db: TDb) => Promise<InferRow<TQueries, K>[]> | InferRow<TQueries, K>[];
|
|
1907
|
+
interface RestConfig {
|
|
1908
|
+
/** URL prefix (default: "/api") */
|
|
1909
|
+
prefix?: string;
|
|
1910
|
+
}
|
|
1911
|
+
interface TypedSyncServer<
|
|
1912
|
+
TQueries extends SyncQueryMap,
|
|
1913
|
+
TAuth extends AuthContext = AuthContext,
|
|
1914
|
+
TDb = unknown
|
|
1915
|
+
> {
|
|
1916
|
+
auth(callback: AuthCallback): void;
|
|
1917
|
+
implement<K extends RegularKeys<TQueries> & string>(name: K, options: ImplementOptions<TQueries, K, TAuth, TDb>): void;
|
|
1918
|
+
/**
|
|
1919
|
+
* Register a read-only computed query. Compiles to `server.query(...)` with a
|
|
1920
|
+
* mutate that throws `readonly_query`, so direct writes are blocked at runtime
|
|
1921
|
+
* and `useSync(...).insert/update/remove` is blocked at the type level.
|
|
1922
|
+
*/
|
|
1923
|
+
view<K extends ViewKeys<TQueries> & string>(name: K, fn: ViewFn<TQueries, K, TAuth, TDb>): void;
|
|
1924
|
+
room(pattern: string, callback: RoomCallback<TAuth>): void;
|
|
1925
|
+
rateLimit(config: RateLimitConfig): void;
|
|
1926
|
+
compaction(config: CompactionConfig): void;
|
|
1927
|
+
minSchemaVersion(version: number): void;
|
|
1928
|
+
runCompaction(): Promise<void>;
|
|
1929
|
+
notifyChange(tableName: string, roomKey?: string | null): Promise<void>;
|
|
1930
|
+
reserveOpId(opId: string): Promise<boolean>;
|
|
1931
|
+
applyServerOp(op: {
|
|
1932
|
+
type: OpType;
|
|
1933
|
+
table: string;
|
|
1934
|
+
rowId: string;
|
|
1935
|
+
payload: Record<string, unknown> | null;
|
|
1936
|
+
}, execute?: (stamped: {
|
|
1937
|
+
type: OpType;
|
|
1938
|
+
table: string;
|
|
1939
|
+
rowId: string;
|
|
1940
|
+
payload: Record<string, unknown> | null;
|
|
1941
|
+
hlc: string;
|
|
1942
|
+
}) => Promise<void>, options?: {
|
|
1943
|
+
roomKey?: string | null;
|
|
1944
|
+
}): Promise<{
|
|
1945
|
+
hlc: string;
|
|
1946
|
+
resolvedRow: Record<string, unknown> | null;
|
|
1947
|
+
}>;
|
|
1948
|
+
/**
|
|
1949
|
+
* Server-origin row write. Sugar over `applyServerOp` — generates rowId,
|
|
1950
|
+
* routes through HLC + op log + broadcast pipeline.
|
|
1951
|
+
*/
|
|
1952
|
+
emit(table: string, payload: Record<string, unknown>, options?: {
|
|
1953
|
+
rowId?: string;
|
|
1954
|
+
type?: OpType;
|
|
1955
|
+
roomKey?: string | null;
|
|
1956
|
+
}): Promise<{
|
|
1957
|
+
hlc: string;
|
|
1958
|
+
rowId: string;
|
|
1959
|
+
}>;
|
|
1960
|
+
/**
|
|
1961
|
+
* Atomic-ish write group with auto-notify. Tracks tables touched by drizzle
|
|
1962
|
+
* `insert/update/delete` calls inside `fn` and fires one batched
|
|
1963
|
+
* `notifyChange` per table on success. With `atomic: true` the work runs
|
|
1964
|
+
* inside `db.transaction(...)` and rolls back on throw; without it, writes
|
|
1965
|
+
* are not transactional but notifies are still skipped on throw.
|
|
1966
|
+
*/
|
|
1967
|
+
tx<T>(fn: TxFn<T>): Promise<T>;
|
|
1968
|
+
tx<T>(opts: TxOptions, fn: TxFn<T>): Promise<T>;
|
|
1969
|
+
lock<T>(key: string, fn: () => Promise<T>): Promise<T>;
|
|
1970
|
+
tryLock<T>(key: string, fn: () => Promise<T>): Promise<T | null>;
|
|
1971
|
+
/** `setInterval` wrapper — auto-disposes on `bun --hot` reload and on `close()`. */
|
|
1972
|
+
interval(ms: number, fn: () => void | Promise<void>): {
|
|
1973
|
+
clear(): void;
|
|
1974
|
+
};
|
|
1975
|
+
/** `setTimeout` wrapper — auto-disposes on `bun --hot` reload and on `close()`. */
|
|
1976
|
+
timeout(ms: number, fn: () => void | Promise<void>): {
|
|
1977
|
+
clear(): void;
|
|
1978
|
+
};
|
|
1979
|
+
close(): Promise<void>;
|
|
1980
|
+
/** Generate a REST fetch handler from registered implementations */
|
|
1981
|
+
rest(config?: RestConfig): (req: Request) => Promise<Response>;
|
|
1982
|
+
}
|
|
1983
|
+
declare function createSyncServer<
|
|
1984
|
+
TQueries extends SyncQueryMap,
|
|
1985
|
+
TDb = unknown,
|
|
1986
|
+
TAuth extends AuthContext = AuthContext
|
|
1987
|
+
>(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 };
|