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.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1260 -0
  3. package/dist/cjs/client/index.cjs +1252 -0
  4. package/dist/cjs/client/index.d.cts +629 -0
  5. package/dist/cjs/client/storage/indexeddb.cjs +253 -0
  6. package/dist/cjs/client/storage/indexeddb.d.cts +85 -0
  7. package/dist/cjs/core/index.cjs +202 -0
  8. package/dist/cjs/core/index.d.cts +473 -0
  9. package/dist/cjs/react/index.cjs +1512 -0
  10. package/dist/cjs/react/index.d.cts +748 -0
  11. package/dist/cjs/server/drizzle.cjs +413 -0
  12. package/dist/cjs/server/drizzle.d.cts +233 -0
  13. package/dist/cjs/server/index.cjs +4486 -0
  14. package/dist/cjs/server/index.d.cts +1988 -0
  15. package/dist/cjs/svelte/index.cjs +1414 -0
  16. package/dist/cjs/svelte/index.d.cts +645 -0
  17. package/dist/cjs/transport/bun-ws.cjs +244 -0
  18. package/dist/cjs/transport/bun-ws.d.cts +215 -0
  19. package/dist/cjs/transport/polling.cjs +285 -0
  20. package/dist/cjs/transport/polling.d.cts +210 -0
  21. package/dist/cjs/transport/sse.cjs +312 -0
  22. package/dist/cjs/transport/sse.d.cts +205 -0
  23. package/dist/cjs/transport/ws.cjs +330 -0
  24. package/dist/cjs/transport/ws.d.cts +236 -0
  25. package/dist/cjs/vanilla/index.cjs +1430 -0
  26. package/dist/cjs/vanilla/index.d.cts +657 -0
  27. package/dist/client/index.d.ts +629 -0
  28. package/dist/client/index.js +106 -0
  29. package/dist/client/storage/indexeddb.d.ts +85 -0
  30. package/dist/client/storage/indexeddb.js +213 -0
  31. package/dist/core/index.d.ts +473 -0
  32. package/dist/core/index.js +56 -0
  33. package/dist/react/index.d.ts +748 -0
  34. package/dist/react/index.js +366 -0
  35. package/dist/server/drizzle.d.ts +233 -0
  36. package/dist/server/drizzle.js +9 -0
  37. package/dist/server/index.d.ts +1988 -0
  38. package/dist/server/index.js +3959 -0
  39. package/dist/shared/esm-3tkwvysa.js +54 -0
  40. package/dist/shared/esm-b7xs9cde.js +4 -0
  41. package/dist/shared/esm-rw7jjtrv.js +58 -0
  42. package/dist/shared/esm-wkwx6bd9.js +25 -0
  43. package/dist/shared/esm-ytrd3hbq.js +1007 -0
  44. package/dist/shared/esm-z1xse19c.js +369 -0
  45. package/dist/svelte/index.d.ts +645 -0
  46. package/dist/svelte/index.js +262 -0
  47. package/dist/transport/bun-ws.d.ts +215 -0
  48. package/dist/transport/bun-ws.js +140 -0
  49. package/dist/transport/polling.d.ts +210 -0
  50. package/dist/transport/polling.js +181 -0
  51. package/dist/transport/sse.d.ts +205 -0
  52. package/dist/transport/sse.js +208 -0
  53. package/dist/transport/ws.d.ts +236 -0
  54. package/dist/transport/ws.js +226 -0
  55. package/dist/vanilla/index.d.ts +657 -0
  56. package/dist/vanilla/index.js +278 -0
  57. 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 };