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,4486 @@
1
+ var import_node_module = require("node:module");
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropNames = Object.getOwnPropertyNames;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ function __accessProp(key) {
7
+ return this[key];
8
+ }
9
+ var __toCommonJS = (from) => {
10
+ var entry = (__moduleCache ??= new WeakMap).get(from), desc;
11
+ if (entry)
12
+ return entry;
13
+ entry = __defProp({}, "__esModule", { value: true });
14
+ if (from && typeof from === "object" || typeof from === "function") {
15
+ for (var key of __getOwnPropNames(from))
16
+ if (!__hasOwnProp.call(entry, key))
17
+ __defProp(entry, key, {
18
+ get: __accessProp.bind(from, key),
19
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
20
+ });
21
+ }
22
+ __moduleCache.set(from, entry);
23
+ return entry;
24
+ };
25
+ var __moduleCache;
26
+ var __returnValue = (v) => v;
27
+ function __exportSetter(name, newValue) {
28
+ this[name] = __returnValue.bind(null, newValue);
29
+ }
30
+ var __export = (target, all) => {
31
+ for (var name in all)
32
+ __defProp(target, name, {
33
+ get: all[name],
34
+ enumerable: true,
35
+ configurable: true,
36
+ set: __exportSetter.bind(all, name)
37
+ });
38
+ };
39
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
40
+
41
+ // src/core/types.ts
42
+ var exports_types = {};
43
+ __export(exports_types, {
44
+ reasonFromError: () => reasonFromError,
45
+ isErrorReason: () => isErrorReason,
46
+ TransportSendError: () => TransportSendError,
47
+ TOMBSTONE_RETENTION_MS: () => TOMBSTONE_RETENTION_MS,
48
+ SUPPORTED_PROTOCOL_VERSIONS: () => SUPPORTED_PROTOCOL_VERSIONS,
49
+ SERVER_TOMBSTONE_RETENTION_MS: () => SERVER_TOMBSTONE_RETENTION_MS,
50
+ PROTOCOL_VERSION: () => PROTOCOL_VERSION,
51
+ MutationError: () => MutationError,
52
+ MAX_CLOCK_DRIFT_MS: () => MAX_CLOCK_DRIFT_MS,
53
+ MAX_BATCH_SIZE: () => MAX_BATCH_SIZE
54
+ });
55
+ function isErrorReason(value) {
56
+ return typeof value === "string" && VALID_ERROR_REASONS.has(value);
57
+ }
58
+ function reasonFromError(err) {
59
+ if (err instanceof MutationError && isErrorReason(err.reason)) {
60
+ return err.reason;
61
+ }
62
+ return "server_error";
63
+ }
64
+ var TransportSendError, PROTOCOL_VERSION = 1, SUPPORTED_PROTOCOL_VERSIONS, MAX_CLOCK_DRIFT_MS, MAX_BATCH_SIZE = 100, TOMBSTONE_RETENTION_MS, SERVER_TOMBSTONE_RETENTION_MS, MutationError, VALID_ERROR_REASONS;
65
+ var init_types = __esm(() => {
66
+ TransportSendError = class TransportSendError extends Error {
67
+ clientId;
68
+ cause;
69
+ constructor(clientId, message, cause) {
70
+ super(message);
71
+ this.clientId = clientId;
72
+ this.cause = cause;
73
+ this.name = "TransportSendError";
74
+ }
75
+ };
76
+ SUPPORTED_PROTOCOL_VERSIONS = [1];
77
+ MAX_CLOCK_DRIFT_MS = 5 * 60 * 1000;
78
+ TOMBSTONE_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
79
+ SERVER_TOMBSTONE_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
80
+ MutationError = class MutationError extends Error {
81
+ reason;
82
+ constructor(reason, message) {
83
+ super(message ?? reason);
84
+ this.reason = reason;
85
+ }
86
+ };
87
+ VALID_ERROR_REASONS = new Set([
88
+ "outside_shape",
89
+ "readonly_query",
90
+ "rate_limited",
91
+ "buffer_full",
92
+ "server_conflict",
93
+ "custom_conflict",
94
+ "merge_stale",
95
+ "clock_drift",
96
+ "replay",
97
+ "batch_too_large",
98
+ "schema_outdated",
99
+ "auth_revoked",
100
+ "compacted",
101
+ "mutation_rejected",
102
+ "server_error",
103
+ "unknown_query"
104
+ ]);
105
+ });
106
+
107
+ // src/server/drizzle.ts
108
+ var exports_drizzle = {};
109
+ __export(exports_drizzle, {
110
+ drizzleTxAtomic: () => drizzleTxAtomic,
111
+ drizzleTable: () => drizzleTable
112
+ });
113
+ module.exports = __toCommonJS(exports_drizzle);
114
+ var import_node_module2 = require("node:module");
115
+
116
+ // src/server/factory.ts
117
+ function getByDottedPath(root, path) {
118
+ const parts = path.split(".");
119
+ let cur = root;
120
+ for (const p of parts) {
121
+ if (cur == null || typeof cur !== "object")
122
+ return;
123
+ cur = cur[p];
124
+ }
125
+ return cur;
126
+ }
127
+ function renderTemplate(template, ctx) {
128
+ return template.replace(/\$\{([^}]+)\}/g, (_m, expr) => {
129
+ const trimmed = expr.trim();
130
+ const path = trimmed.includes(".") ? trimmed : `params.${trimmed}`;
131
+ const v = getByDottedPath(ctx, path);
132
+ return v == null ? "" : String(v);
133
+ });
134
+ }
135
+ function resolveStamp(source, ctx) {
136
+ if (typeof source === "function")
137
+ return source(ctx);
138
+ if (source === "auth.userId")
139
+ return ctx.auth.userId;
140
+ if (source === "auth.name")
141
+ return ctx.auth.name;
142
+ if (typeof source === "string" && source.includes(".")) {
143
+ return getByDottedPath(ctx, source);
144
+ }
145
+ return source;
146
+ }
147
+ function buildScopeFilter(scope, ctx) {
148
+ if (!scope)
149
+ return;
150
+ if (typeof scope === "function")
151
+ return;
152
+ if (typeof scope === "string") {
153
+ const val2 = ctx.params[scope];
154
+ if (val2 === undefined) {
155
+ throw new Error(`params.${scope} required for scoped query`);
156
+ }
157
+ return { kind: "params", column: scope, value: val2 };
158
+ }
159
+ const { auth: authKey, column } = scope;
160
+ const val = ctx.auth[authKey];
161
+ if (val === undefined) {
162
+ throw new Error(`auth.${authKey} required for scoped query`);
163
+ }
164
+ return { kind: "auth", column, value: val };
165
+ }
166
+ function defineTable(adapter, opts = {}) {
167
+ const pkName = opts.pk ?? adapter.pk ?? "id";
168
+ const query = opts.query ? opts.query : (ctx, db) => {
169
+ if (typeof opts.scope === "function") {
170
+ throw new Error("function-form scope requires opts.query override on defineTable (no SQL fragment to pass to adapter)");
171
+ }
172
+ const scope = buildScopeFilter(opts.scope, ctx);
173
+ return adapter.list({ db, scope });
174
+ };
175
+ const authorize = async (action, ctx, _db) => {
176
+ if (action.type === "read" && opts.policy?.read) {
177
+ const ok = await opts.policy.read({ ctx });
178
+ if (!ok)
179
+ throw new Error("policy denied read");
180
+ }
181
+ };
182
+ const mutate = async (op, rawCtx, db) => {
183
+ const ctx = rawCtx;
184
+ const payload = op.payload ? { ...op.payload } : {};
185
+ if (op.type !== "delete")
186
+ delete payload[pkName];
187
+ if (opts.rowId) {
188
+ const expected = renderTemplate(opts.rowId, {
189
+ auth: ctx.auth,
190
+ params: ctx.params
191
+ });
192
+ if (op.rowId !== expected) {
193
+ throw new Error("rowId mismatch");
194
+ }
195
+ }
196
+ if (opts.scope && typeof opts.scope === "string" && op.type === "insert") {
197
+ const col = opts.scope;
198
+ const paramVal = ctx.params[col];
199
+ if (paramVal === undefined) {
200
+ throw new Error(`params.${col} required for scoped mutation`);
201
+ }
202
+ if (payload[col] !== paramVal) {
203
+ throw new Error(`${col} mismatch`);
204
+ }
205
+ }
206
+ if (opts.scope && typeof opts.scope === "object" && !Array.isArray(opts.scope) && "auth" in opts.scope && op.type === "insert") {
207
+ const { auth: authKey, column } = opts.scope;
208
+ const authVal = ctx.auth[authKey];
209
+ if (authVal === undefined) {
210
+ throw new Error(`auth.${authKey} required for scoped mutation`);
211
+ }
212
+ if (payload[column] !== authVal) {
213
+ throw new Error(`${column} mismatch`);
214
+ }
215
+ }
216
+ const existing = op.type !== "insert" ? await Promise.resolve(adapter.find({ db, rowId: op.rowId })) : null;
217
+ if (op.type !== "insert" && existing) {
218
+ if (opts.scope && typeof opts.scope === "string") {
219
+ const col = opts.scope;
220
+ const paramVal = ctx.params[col];
221
+ if (paramVal === undefined) {
222
+ throw new Error(`params.${col} required for scoped mutation`);
223
+ }
224
+ if (existing[col] !== paramVal) {
225
+ throw new Error(`${col} mismatch`);
226
+ }
227
+ }
228
+ if (opts.scope && typeof opts.scope === "object" && !Array.isArray(opts.scope) && "auth" in opts.scope) {
229
+ const { auth: authKey, column } = opts.scope;
230
+ const authVal = ctx.auth[authKey];
231
+ if (authVal === undefined) {
232
+ throw new Error(`auth.${authKey} required for scoped mutation`);
233
+ }
234
+ if (existing[column] !== authVal) {
235
+ throw new Error(`${column} mismatch`);
236
+ }
237
+ }
238
+ }
239
+ if (op.type === "insert" && opts.policy?.insert) {
240
+ const ok = await opts.policy.insert({ ctx, payload });
241
+ if (!ok)
242
+ throw new Error("policy denied insert");
243
+ } else if (op.type === "update" && opts.policy?.update) {
244
+ const ok = await opts.policy.update({
245
+ ctx,
246
+ existing: existing ?? {},
247
+ payload
248
+ });
249
+ if (!ok)
250
+ throw new Error("policy denied update");
251
+ } else if (op.type === "delete" && opts.policy?.delete) {
252
+ const ok = await opts.policy.delete({ ctx, existing: existing ?? {} });
253
+ if (!ok)
254
+ throw new Error("policy denied delete");
255
+ }
256
+ if (opts.policy?.fields && op.type !== "delete") {
257
+ for (const [field, rule] of Object.entries(opts.policy.fields)) {
258
+ if (rule === "server-only") {
259
+ delete payload[field];
260
+ } else if (rule === "insert-only") {
261
+ if (op.type === "update")
262
+ delete payload[field];
263
+ } else if (typeof rule === "object" && rule && "write" in rule) {
264
+ const allow = await rule.write({
265
+ ctx,
266
+ existing: existing ?? null,
267
+ payload
268
+ });
269
+ if (!allow)
270
+ delete payload[field];
271
+ }
272
+ }
273
+ }
274
+ if (op.type === "insert" && opts.ownerStamp) {
275
+ for (const [field, source] of Object.entries(opts.ownerStamp)) {
276
+ if (source === undefined)
277
+ continue;
278
+ payload[field] = resolveStamp(source, ctx);
279
+ }
280
+ }
281
+ if (op.type !== "delete" && opts.serverSet) {
282
+ for (const [field, val] of Object.entries(opts.serverSet)) {
283
+ if (val === undefined)
284
+ continue;
285
+ payload[field] = typeof val === "function" ? val(ctx) : val;
286
+ }
287
+ }
288
+ if (op.type === "insert") {
289
+ await opts.hooks?.beforeInsert?.({ payload, ctx, db });
290
+ } else if (op.type === "update") {
291
+ await opts.hooks?.beforeUpdate?.({
292
+ payload,
293
+ existing: existing ?? {},
294
+ ctx,
295
+ db
296
+ });
297
+ } else if (op.type === "delete") {
298
+ await opts.hooks?.beforeDelete?.({ existing: existing ?? {}, ctx, db });
299
+ }
300
+ if (op.type === "delete") {
301
+ if (opts.delete) {
302
+ await opts.delete({ rowId: op.rowId, existing, ctx, db });
303
+ } else {
304
+ await Promise.resolve(adapter.delete({ db, rowId: op.rowId }));
305
+ }
306
+ } else if (op.type === "insert" && opts.insert) {
307
+ await opts.insert({ payload, existing, ctx, db });
308
+ } else if (op.type === "update" && opts.update) {
309
+ await opts.update({ payload, existing: existing ?? {}, ctx, db });
310
+ } else if (op.type === "update") {
311
+ if (Object.keys(payload).length > 0) {
312
+ await Promise.resolve(adapter.update({ db, rowId: op.rowId, payload }));
313
+ }
314
+ } else {
315
+ await Promise.resolve(adapter.insert({ db, rowId: op.rowId, payload }));
316
+ }
317
+ if (op.type === "delete") {
318
+ await opts.hooks?.afterDelete?.({ rowId: op.rowId, ctx, db });
319
+ } else {
320
+ const hookName = op.type === "insert" ? "afterInsert" : "afterUpdate";
321
+ const after = opts.hooks?.[hookName];
322
+ if (after) {
323
+ const row = await Promise.resolve(adapter.find({ db, rowId: op.rowId })) ?? {};
324
+ if (op.type === "insert") {
325
+ await opts.hooks?.afterInsert?.({ row, ctx, db });
326
+ } else {
327
+ await opts.hooks?.afterUpdate?.({
328
+ row,
329
+ previous: existing ?? {},
330
+ ctx,
331
+ db
332
+ });
333
+ }
334
+ }
335
+ }
336
+ };
337
+ return { query, mutate, authorize };
338
+ }
339
+
340
+ // src/server/drizzle.ts
341
+ var requireFn = import_node_module2.createRequire(module.filename);
342
+ var cachedDrizzle;
343
+ function loadDrizzleSync() {
344
+ if (cachedDrizzle === undefined) {
345
+ try {
346
+ const mod = requireFn("drizzle-orm");
347
+ cachedDrizzle = { eq: mod.eq, sql: mod.sql, getTableName: mod.getTableName };
348
+ } catch {
349
+ cachedDrizzle = null;
350
+ }
351
+ }
352
+ if (!cachedDrizzle) {
353
+ throw new Error("drizzleTable / drizzleTxAtomic require drizzle-orm to be installed. Add it to your dependencies, or use defineTable with a custom TableAdapter.");
354
+ }
355
+ return cachedDrizzle;
356
+ }
357
+ async function execAll(query) {
358
+ const q = query;
359
+ if (q && typeof q.all === "function") {
360
+ return await Promise.resolve(q.all());
361
+ }
362
+ const res = await Promise.resolve(query);
363
+ return Array.isArray(res) ? res : [];
364
+ }
365
+ function drizzleAdapter(table, pk) {
366
+ const pkName = pk ?? "id";
367
+ const pkCol = table[pkName];
368
+ let cachedName = "";
369
+ const adapter = {
370
+ get name() {
371
+ return cachedName;
372
+ },
373
+ pk: pkName,
374
+ async list({ db, scope }) {
375
+ const { eq, getTableName } = loadDrizzleSync();
376
+ if (!cachedName)
377
+ cachedName = getTableName(table);
378
+ if (!scope) {
379
+ return await execAll(db.select().from(table));
380
+ }
381
+ const col = table[scope.column];
382
+ return await execAll(db.select().from(table).where(eq(col, scope.value)));
383
+ },
384
+ async find({ db, rowId }) {
385
+ const { eq, getTableName } = loadDrizzleSync();
386
+ if (!cachedName)
387
+ cachedName = getTableName(table);
388
+ const rows = await execAll(db.select().from(table).where(eq(pkCol, rowId)));
389
+ return rows[0] ?? null;
390
+ },
391
+ async insert({ db, rowId, payload }) {
392
+ const { getTableName } = loadDrizzleSync();
393
+ if (!cachedName)
394
+ cachedName = getTableName(table);
395
+ const values = { ...payload, [pkName]: rowId };
396
+ if (Object.keys(payload).length > 0) {
397
+ await Promise.resolve(db.insert(table).values(values).onConflictDoUpdate({ target: pkCol, set: payload }));
398
+ } else {
399
+ await Promise.resolve(db.insert(table).values(values).onConflictDoNothing());
400
+ }
401
+ },
402
+ async update({ db, rowId, payload }) {
403
+ const { eq, getTableName } = loadDrizzleSync();
404
+ if (!cachedName)
405
+ cachedName = getTableName(table);
406
+ await Promise.resolve(db.update(table).set(payload).where(eq(pkCol, rowId)));
407
+ },
408
+ async delete({ db, rowId }) {
409
+ const { eq, getTableName } = loadDrizzleSync();
410
+ if (!cachedName)
411
+ cachedName = getTableName(table);
412
+ await Promise.resolve(db.delete(table).where(eq(pkCol, rowId)));
413
+ }
414
+ };
415
+ return adapter;
416
+ }
417
+ function drizzleQueryFn(table, opts) {
418
+ if (opts.query)
419
+ return opts.query;
420
+ return (ctx, db) => {
421
+ const where = buildDrizzleScopeWhere(opts.scope, ctx, table);
422
+ return where ? db.select().from(table).where(where) : db.select().from(table);
423
+ };
424
+ }
425
+ function buildDrizzleScopeWhere(scope, ctx, table) {
426
+ if (!scope)
427
+ return;
428
+ if (typeof scope === "function")
429
+ return scope(ctx);
430
+ const { eq } = loadDrizzleSync();
431
+ if (typeof scope === "string") {
432
+ const col2 = table[scope];
433
+ const val2 = ctx.params[scope];
434
+ if (val2 === undefined) {
435
+ throw new Error(`params.${String(scope)} required for scoped query`);
436
+ }
437
+ return eq(col2, val2);
438
+ }
439
+ const col = table[scope.column];
440
+ const val = ctx.auth[scope.auth];
441
+ if (val === undefined) {
442
+ throw new Error(`auth.${String(scope.auth)} required for scoped query`);
443
+ }
444
+ return eq(col, val);
445
+ }
446
+ function drizzleTable(table, opts = {}) {
447
+ const adapter = drizzleAdapter(table, opts.pk);
448
+ const query = drizzleQueryFn(table, opts);
449
+ return defineTable(adapter, {
450
+ ...opts,
451
+ query
452
+ });
453
+ }
454
+ var drizzleTxAtomic = {
455
+ async begin(db) {
456
+ const { sql } = loadDrizzleSync();
457
+ const handle = db;
458
+ if (typeof handle?.run !== "function") {
459
+ throw new Error("drizzleTxAtomic.begin requires a drizzle db with a run() method");
460
+ }
461
+ await Promise.resolve(handle.run(sql`BEGIN`));
462
+ },
463
+ async commit(db) {
464
+ const { sql } = loadDrizzleSync();
465
+ const handle = db;
466
+ if (typeof handle?.run !== "function") {
467
+ throw new Error("drizzleTxAtomic.commit requires a drizzle db with a run() method");
468
+ }
469
+ await Promise.resolve(handle.run(sql`COMMIT`));
470
+ },
471
+ async rollback(db) {
472
+ const { sql } = loadDrizzleSync();
473
+ const handle = db;
474
+ if (typeof handle?.run !== "function") {
475
+ throw new Error("drizzleTxAtomic.rollback requires a drizzle db with a run() method");
476
+ }
477
+ await Promise.resolve(handle.run(sql`ROLLBACK`));
478
+ }
479
+ };
480
+
481
+ // src/server/index.ts
482
+ var exports_server = {};
483
+ __export(exports_server, {
484
+ stableStringify: () => stableStringify,
485
+ resolveRoomKey: () => resolveRoomKey,
486
+ resolveConflict: () => resolveConflict,
487
+ processOp: () => processOp,
488
+ enforceServerSet: () => enforceServerSet,
489
+ enforceReadonly: () => enforceReadonly,
490
+ enforceClockDrift: () => enforceClockDrift,
491
+ enforceBatchSize: () => enforceBatchSize,
492
+ drizzleTxAtomic: () => drizzleTxAtomic,
493
+ drizzleTable: () => drizzleTable,
494
+ defineTable: () => defineTable,
495
+ createSyncServer: () => createSyncServer,
496
+ createSqliteStorage: () => createSqliteStorage,
497
+ createServer: () => createServer,
498
+ createRateLimiter: () => createRateLimiter,
499
+ createPostgresStorage: () => createPostgresStorage,
500
+ SessionManager: () => SessionManager,
501
+ ServerClock: () => ServerClock,
502
+ SERVER_HLC_META_KEY: () => SERVER_HLC_META_KEY,
503
+ ResultCache: () => ResultCache,
504
+ OpProcessor: () => OpProcessor,
505
+ MutationError: () => MutationError,
506
+ MessageHandler: () => MessageHandler,
507
+ BroadcastEngine: () => BroadcastEngine
508
+ });
509
+ module.exports = __toCommonJS(exports_server);
510
+
511
+ // src/server/enforcement.ts
512
+ init_types();
513
+
514
+ // src/core/hlc.ts
515
+ init_types();
516
+ var MS_PAD = 19;
517
+ var COUNTER_PAD = 4;
518
+ var COUNTER_MAX = 9999;
519
+ function createHlc(nodeId) {
520
+ return { ms: 0, counter: 0, nodeId };
521
+ }
522
+ function normalize(ms, counter, nodeId) {
523
+ if (counter > COUNTER_MAX) {
524
+ return { ms: ms + 1, counter: 0, nodeId };
525
+ }
526
+ return { ms, counter, nodeId };
527
+ }
528
+ function sendHlc(local) {
529
+ const wall = Date.now();
530
+ const ms = Math.max(wall, local.ms);
531
+ const counter = ms === local.ms ? local.counter + 1 : 0;
532
+ return normalize(ms, counter, local.nodeId);
533
+ }
534
+ function receiveHlc(local, remote) {
535
+ const wall = Date.now();
536
+ const clampedRemoteMs = Math.min(remote.ms, wall + MAX_CLOCK_DRIFT_MS);
537
+ const ms = Math.max(wall, local.ms, clampedRemoteMs);
538
+ const counter = ms === local.ms && ms === clampedRemoteMs ? Math.max(local.counter, remote.counter) + 1 : ms === local.ms ? local.counter + 1 : ms === clampedRemoteMs ? remote.counter + 1 : 0;
539
+ return normalize(ms, counter, local.nodeId);
540
+ }
541
+ function packHlc(hlc) {
542
+ const ms = String(hlc.ms).padStart(MS_PAD, "0");
543
+ const counter = String(hlc.counter).padStart(COUNTER_PAD, "0");
544
+ return `${ms}.${counter}.${hlc.nodeId}`;
545
+ }
546
+ function unpackHlc(packed) {
547
+ const firstDot = packed.indexOf(".");
548
+ const secondDot = firstDot < 0 ? -1 : packed.indexOf(".", firstDot + 1);
549
+ if (firstDot < 0 || secondDot < 0) {
550
+ throw new Error(`Malformed HLC: ${packed}`);
551
+ }
552
+ const ms = Number(packed.slice(0, firstDot));
553
+ const counter = Number(packed.slice(firstDot + 1, secondDot));
554
+ const nodeId = packed.slice(secondDot + 1);
555
+ if (!Number.isFinite(ms) || !Number.isFinite(counter) || nodeId.length === 0) {
556
+ throw new Error(`Malformed HLC: ${packed}`);
557
+ }
558
+ return { ms, counter, nodeId };
559
+ }
560
+ function compareHlc(a, b) {
561
+ if (a < b)
562
+ return -1;
563
+ if (a > b)
564
+ return 1;
565
+ return 0;
566
+ }
567
+
568
+ // src/server/enforcement.ts
569
+ function enforceClockDrift(hlc) {
570
+ let parsed;
571
+ try {
572
+ parsed = unpackHlc(hlc);
573
+ } catch {
574
+ return { ok: false, reason: "clock_drift" };
575
+ }
576
+ const drift = parsed.ms - Date.now();
577
+ if (drift > MAX_CLOCK_DRIFT_MS) {
578
+ return { ok: false, reason: "clock_drift" };
579
+ }
580
+ return { ok: true };
581
+ }
582
+ function enforceReadonly(op, options) {
583
+ if (op.op === "delete" || op.op === "insert" || !op.payload || !options.readonly?.length) {
584
+ return op;
585
+ }
586
+ const cleaned = { ...op.payload };
587
+ for (const field of options.readonly) {
588
+ delete cleaned[field];
589
+ }
590
+ return { ...op, payload: cleaned };
591
+ }
592
+ function enforceServerSet(op, options) {
593
+ if (op.op === "delete" || !options.serverSet) {
594
+ return op;
595
+ }
596
+ const payload = { ...op.payload, ...options.serverSet };
597
+ return { ...op, payload };
598
+ }
599
+ function enforceBatchSize(batchSize, maxBatchSize) {
600
+ if (batchSize !== undefined && batchSize > maxBatchSize) {
601
+ return { ok: false, reason: "batch_too_large" };
602
+ }
603
+ return { ok: true };
604
+ }
605
+ function createRateLimiter(config) {
606
+ const secondBuckets = new Map;
607
+ const minuteBuckets = new Map;
608
+ function globalKey(userId, clientId) {
609
+ return `${userId}:${clientId}`;
610
+ }
611
+ function tableKey(userId, clientId, table) {
612
+ return `${userId}:${clientId}:${table}`;
613
+ }
614
+ let lastCleanup = Date.now();
615
+ function cleanupStale(now) {
616
+ if (now - lastCleanup < 60000)
617
+ return;
618
+ lastCleanup = now;
619
+ for (const [k, bucket] of secondBuckets) {
620
+ if (now >= bucket.resetAt)
621
+ secondBuckets.delete(k);
622
+ }
623
+ for (const [k, bucket] of minuteBuckets) {
624
+ if (now >= bucket.resetAt)
625
+ minuteBuckets.delete(k);
626
+ }
627
+ }
628
+ function checkBucket(buckets, k, limit, now) {
629
+ if (!limit)
630
+ return true;
631
+ const bucket = buckets.get(k);
632
+ return !(bucket && now < bucket.resetAt && bucket.count >= limit);
633
+ }
634
+ function recordBucket(buckets, k, limit, windowMs, now) {
635
+ if (!limit)
636
+ return;
637
+ const bucket = buckets.get(k);
638
+ if (!bucket || now >= bucket.resetAt) {
639
+ buckets.set(k, { count: 1, resetAt: now + windowMs });
640
+ } else {
641
+ bucket.count++;
642
+ }
643
+ }
644
+ return {
645
+ check(userId, clientId, table) {
646
+ const now = Date.now();
647
+ cleanupStale(now);
648
+ const gk = globalKey(userId, clientId);
649
+ if (!checkBucket(secondBuckets, gk, config.opsPerSecond, now)) {
650
+ return { ok: false, reason: "rate_limited" };
651
+ }
652
+ if (!checkBucket(minuteBuckets, gk, config.opsPerMinute, now)) {
653
+ return { ok: false, reason: "rate_limited" };
654
+ }
655
+ const tableCfg = table ? config.perTable?.[table] : undefined;
656
+ if (tableCfg && table) {
657
+ const tk = tableKey(userId, clientId, table);
658
+ if (!checkBucket(secondBuckets, tk, tableCfg.opsPerSecond, now)) {
659
+ return { ok: false, reason: "rate_limited" };
660
+ }
661
+ if (!checkBucket(minuteBuckets, tk, tableCfg.opsPerMinute, now)) {
662
+ return { ok: false, reason: "rate_limited" };
663
+ }
664
+ }
665
+ return { ok: true };
666
+ },
667
+ record(userId, clientId, table) {
668
+ const now = Date.now();
669
+ const gk = globalKey(userId, clientId);
670
+ recordBucket(secondBuckets, gk, config.opsPerSecond, 1000, now);
671
+ recordBucket(minuteBuckets, gk, config.opsPerMinute, 60000, now);
672
+ const tableCfg = table ? config.perTable?.[table] : undefined;
673
+ if (tableCfg && table) {
674
+ const tk = tableKey(userId, clientId, table);
675
+ recordBucket(secondBuckets, tk, tableCfg.opsPerSecond, 1000, now);
676
+ recordBucket(minuteBuckets, tk, tableCfg.opsPerMinute, 60000, now);
677
+ }
678
+ cleanupStale(now);
679
+ }
680
+ };
681
+ }
682
+
683
+ // src/server/handler.ts
684
+ init_types();
685
+
686
+ // src/server/server-clock.ts
687
+ var SERVER_HLC_META_KEY = "serverHlcWatermark";
688
+ var HLC_PERSIST_INTERVAL_MS = 1000;
689
+ var HLC_PERSIST_LEASE_MS = 5000;
690
+
691
+ class ServerClock {
692
+ hlc;
693
+ storage = null;
694
+ load = null;
695
+ persistTimer = null;
696
+ persistPending = false;
697
+ constructor(nodeId) {
698
+ this.hlc = createHlc(nodeId);
699
+ }
700
+ setStorage(storage) {
701
+ this.storage = storage;
702
+ this.load = null;
703
+ this.ensureLoaded();
704
+ }
705
+ current() {
706
+ return this.hlc;
707
+ }
708
+ ensureLoaded() {
709
+ if (!this.storage)
710
+ return Promise.resolve();
711
+ if (this.load)
712
+ return this.load;
713
+ const storage = this.storage;
714
+ this.load = (async () => {
715
+ try {
716
+ const stored = await storage.getMeta(SERVER_HLC_META_KEY);
717
+ if (stored)
718
+ this.hlc = receiveHlc(this.hlc, unpackHlc(stored));
719
+ } catch (err) {
720
+ console.error("[reflectdb] Failed to load persisted server HLC watermark:", err);
721
+ }
722
+ })();
723
+ return this.load;
724
+ }
725
+ async refresh() {
726
+ if (!this.storage)
727
+ return;
728
+ try {
729
+ const stored = await this.storage.getMeta(SERVER_HLC_META_KEY);
730
+ if (stored)
731
+ this.hlc = receiveHlc(this.hlc, unpackHlc(stored));
732
+ } catch (err) {
733
+ console.error("[reflectdb] Failed to refresh server HLC watermark:", err);
734
+ }
735
+ }
736
+ stamp() {
737
+ this.hlc = sendHlc(this.hlc);
738
+ this.schedulePersist();
739
+ return packHlc(this.hlc);
740
+ }
741
+ peekNext() {
742
+ return packHlc(sendHlc(this.hlc));
743
+ }
744
+ packed() {
745
+ return packHlc(this.hlc);
746
+ }
747
+ receive(packedRemote) {
748
+ try {
749
+ this.hlc = receiveHlc(this.hlc, unpackHlc(packedRemote));
750
+ this.schedulePersist();
751
+ } catch {}
752
+ }
753
+ async close() {
754
+ if (this.persistTimer) {
755
+ clearTimeout(this.persistTimer);
756
+ this.persistTimer = null;
757
+ }
758
+ await this.flush();
759
+ }
760
+ schedulePersist() {
761
+ if (!this.storage)
762
+ return;
763
+ this.persistPending = true;
764
+ if (this.persistTimer)
765
+ return;
766
+ this.persistTimer = setTimeout(() => {
767
+ this.persistTimer = null;
768
+ this.flush();
769
+ }, HLC_PERSIST_INTERVAL_MS);
770
+ if (typeof this.persistTimer.unref === "function") {
771
+ this.persistTimer.unref();
772
+ }
773
+ }
774
+ async flush() {
775
+ if (!this.storage || !this.persistPending)
776
+ return;
777
+ this.persistPending = false;
778
+ await this.refresh();
779
+ const watermark = packHlc({
780
+ ms: this.hlc.ms + HLC_PERSIST_LEASE_MS,
781
+ counter: 0,
782
+ nodeId: this.hlc.nodeId
783
+ });
784
+ try {
785
+ await this.storage.setMeta(SERVER_HLC_META_KEY, watermark);
786
+ } catch (err) {
787
+ console.error("[reflectdb] Failed to persist server HLC watermark:", err);
788
+ }
789
+ }
790
+ }
791
+
792
+ // src/server/session.ts
793
+ class SessionManager {
794
+ sessions = new Map;
795
+ querySubscribers = new Map;
796
+ userSessions = new Map;
797
+ connect(clientId) {
798
+ const session = {
799
+ clientId,
800
+ auth: null,
801
+ subscriptions: new Map,
802
+ watermark: null,
803
+ connectedAt: Date.now()
804
+ };
805
+ this.sessions.set(clientId, session);
806
+ return session;
807
+ }
808
+ disconnect(clientId) {
809
+ const session = this.sessions.get(clientId);
810
+ if (session) {
811
+ for (const queryName of session.subscriptions.keys()) {
812
+ const subs = this.querySubscribers.get(queryName);
813
+ if (subs) {
814
+ subs.delete(clientId);
815
+ if (subs.size === 0)
816
+ this.querySubscribers.delete(queryName);
817
+ }
818
+ }
819
+ if (session.auth) {
820
+ const userId = session.auth.userId;
821
+ const set = this.userSessions.get(userId);
822
+ if (set) {
823
+ set.delete(clientId);
824
+ if (set.size === 0)
825
+ this.userSessions.delete(userId);
826
+ }
827
+ }
828
+ }
829
+ this.sessions.delete(clientId);
830
+ }
831
+ countForUser(userId) {
832
+ return this.userSessions.get(userId)?.size ?? 0;
833
+ }
834
+ oldestForUser(userId) {
835
+ const ids = this.userSessions.get(userId);
836
+ if (!ids || ids.size === 0)
837
+ return null;
838
+ let oldest = null;
839
+ for (const id of ids) {
840
+ const s = this.sessions.get(id);
841
+ if (!s)
842
+ continue;
843
+ if (!oldest || s.connectedAt < oldest.connectedAt)
844
+ oldest = s;
845
+ }
846
+ return oldest;
847
+ }
848
+ get(clientId) {
849
+ return this.sessions.get(clientId);
850
+ }
851
+ setAuth(clientId, auth) {
852
+ const session = this.sessions.get(clientId);
853
+ if (session) {
854
+ if (session.auth && session.auth.userId !== auth.userId) {
855
+ const prev = this.userSessions.get(session.auth.userId);
856
+ if (prev) {
857
+ prev.delete(clientId);
858
+ if (prev.size === 0)
859
+ this.userSessions.delete(session.auth.userId);
860
+ }
861
+ }
862
+ session.auth = auth;
863
+ let set = this.userSessions.get(auth.userId);
864
+ if (!set) {
865
+ set = new Set;
866
+ this.userSessions.set(auth.userId, set);
867
+ }
868
+ set.add(clientId);
869
+ }
870
+ }
871
+ subscribe(clientId, queryName, params, options, roomKey = null, windowSize = null) {
872
+ const session = this.sessions.get(clientId);
873
+ if (session) {
874
+ const effectiveWindow = windowSize != null && windowSize > 0 ? windowSize : null;
875
+ session.subscriptions.set(queryName, {
876
+ queryName,
877
+ params,
878
+ options,
879
+ roomKey,
880
+ windowSize: effectiveWindow,
881
+ windowLimit: effectiveWindow ?? 0,
882
+ loadedCount: 0,
883
+ lastTotalCount: null,
884
+ bootstrapped: false
885
+ });
886
+ let subs = this.querySubscribers.get(queryName);
887
+ if (!subs) {
888
+ subs = new Set;
889
+ this.querySubscribers.set(queryName, subs);
890
+ }
891
+ subs.add(clientId);
892
+ }
893
+ }
894
+ unsubscribe(clientId, queryName) {
895
+ const session = this.sessions.get(clientId);
896
+ if (session) {
897
+ session.subscriptions.delete(queryName);
898
+ const subs = this.querySubscribers.get(queryName);
899
+ if (subs) {
900
+ subs.delete(clientId);
901
+ if (subs.size === 0)
902
+ this.querySubscribers.delete(queryName);
903
+ }
904
+ }
905
+ }
906
+ updateWatermark(clientId, hlc) {
907
+ const session = this.sessions.get(clientId);
908
+ if (session) {
909
+ if (!session.watermark || hlc > session.watermark) {
910
+ session.watermark = hlc;
911
+ }
912
+ }
913
+ }
914
+ getSubscribersForQuery(queryName, roomKey) {
915
+ const clientIds = this.querySubscribers.get(queryName);
916
+ if (!clientIds)
917
+ return [];
918
+ const result = [];
919
+ for (const clientId of clientIds) {
920
+ const session = this.sessions.get(clientId);
921
+ if (!session)
922
+ continue;
923
+ if (roomKey !== undefined && roomKey !== null) {
924
+ const sub = session.subscriptions.get(queryName);
925
+ if (!sub || sub.roomKey !== roomKey)
926
+ continue;
927
+ }
928
+ result.push(session);
929
+ }
930
+ return result;
931
+ }
932
+ getSubscribersForTableDependency(tableDependencyIndex, tableName) {
933
+ const affectedQueries = tableDependencyIndex.get(tableName);
934
+ if (!affectedQueries)
935
+ return [];
936
+ const result = [];
937
+ for (const queryName of affectedQueries) {
938
+ const clientIds = this.querySubscribers.get(queryName);
939
+ if (!clientIds)
940
+ continue;
941
+ for (const clientId of clientIds) {
942
+ const session = this.sessions.get(clientId);
943
+ if (session) {
944
+ result.push({ session, queryName });
945
+ }
946
+ }
947
+ }
948
+ return result;
949
+ }
950
+ getAll() {
951
+ return Array.from(this.sessions.values());
952
+ }
953
+ size() {
954
+ return this.sessions.size;
955
+ }
956
+ }
957
+
958
+ // src/server/result-cache.ts
959
+ function deepEqual(a, b) {
960
+ if (a === b)
961
+ return true;
962
+ if (a === null || b === null)
963
+ return false;
964
+ if (typeof a !== typeof b)
965
+ return false;
966
+ if (typeof a !== "object")
967
+ return false;
968
+ if (a instanceof Date || b instanceof Date) {
969
+ return a instanceof Date && b instanceof Date && a.getTime() === b.getTime();
970
+ }
971
+ const aIsArray = Array.isArray(a);
972
+ const bIsArray = Array.isArray(b);
973
+ if (aIsArray !== bIsArray)
974
+ return false;
975
+ if (aIsArray) {
976
+ const aArr = a;
977
+ const bArr = b;
978
+ if (aArr.length !== bArr.length)
979
+ return false;
980
+ for (let i = 0;i < aArr.length; i++) {
981
+ if (!deepEqual(aArr[i], bArr[i]))
982
+ return false;
983
+ }
984
+ return true;
985
+ }
986
+ const aObj = a;
987
+ const bObj = b;
988
+ const aKeys = Object.keys(aObj);
989
+ const bKeys = Object.keys(bObj);
990
+ if (aKeys.length !== bKeys.length)
991
+ return false;
992
+ for (const key of aKeys) {
993
+ if (!Object.prototype.hasOwnProperty.call(bObj, key))
994
+ return false;
995
+ if (!deepEqual(aObj[key], bObj[key]))
996
+ return false;
997
+ }
998
+ return true;
999
+ }
1000
+
1001
+ class ResultCache {
1002
+ cache = new Map;
1003
+ set(clientId, queryName, rows, idField = "id") {
1004
+ const clientCache = this.getOrCreateClientCache(clientId);
1005
+ const oldRows = clientCache.get(queryName) ?? new Map;
1006
+ const newRows = new Map;
1007
+ for (const row of rows) {
1008
+ const rowId = String(row[idField] ?? "");
1009
+ if (rowId) {
1010
+ newRows.set(rowId, row);
1011
+ }
1012
+ }
1013
+ const diff = this.diff(oldRows, newRows);
1014
+ clientCache.set(queryName, newRows);
1015
+ return diff;
1016
+ }
1017
+ get(clientId, queryName) {
1018
+ return this.cache.get(clientId)?.get(queryName) ?? new Map;
1019
+ }
1020
+ diffOnly(clientId, queryName, rows, idField = "id") {
1021
+ const oldRows = this.cache.get(clientId)?.get(queryName) ?? new Map;
1022
+ const newRows = new Map;
1023
+ for (const row of rows) {
1024
+ const rowId = String(row[idField] ?? "");
1025
+ if (rowId)
1026
+ newRows.set(rowId, row);
1027
+ }
1028
+ return this.diff(oldRows, newRows);
1029
+ }
1030
+ clear(clientId, queryName) {
1031
+ this.cache.get(clientId)?.delete(queryName);
1032
+ }
1033
+ clearClient(clientId) {
1034
+ this.cache.delete(clientId);
1035
+ }
1036
+ getOrCreateClientCache(clientId) {
1037
+ let clientCache = this.cache.get(clientId);
1038
+ if (!clientCache) {
1039
+ clientCache = new Map;
1040
+ this.cache.set(clientId, clientCache);
1041
+ }
1042
+ return clientCache;
1043
+ }
1044
+ diff(oldRows, newRows) {
1045
+ const inserted = [];
1046
+ const updated = [];
1047
+ const deleted = [];
1048
+ for (const [rowId, newRow] of newRows) {
1049
+ const oldRow = oldRows.get(rowId);
1050
+ if (!oldRow) {
1051
+ inserted.push({ rowId, data: newRow });
1052
+ } else {
1053
+ const changed = this.fieldDiff(oldRow, newRow);
1054
+ if (changed) {
1055
+ updated.push({ rowId, changed });
1056
+ }
1057
+ }
1058
+ }
1059
+ for (const rowId of oldRows.keys()) {
1060
+ if (!newRows.has(rowId)) {
1061
+ deleted.push(rowId);
1062
+ }
1063
+ }
1064
+ return { inserted, updated, deleted };
1065
+ }
1066
+ fieldDiff(oldRow, newRow) {
1067
+ const changed = {};
1068
+ let hasChanges = false;
1069
+ for (const [key, newValue] of Object.entries(newRow)) {
1070
+ const oldValue = oldRow[key];
1071
+ const equal = deepEqual(oldValue, newValue);
1072
+ if (!equal) {
1073
+ changed[key] = newValue;
1074
+ hasChanges = true;
1075
+ }
1076
+ }
1077
+ for (const key of Object.keys(oldRow)) {
1078
+ if (!(key in newRow)) {
1079
+ changed[key] = null;
1080
+ hasChanges = true;
1081
+ }
1082
+ }
1083
+ return hasChanges ? changed : null;
1084
+ }
1085
+ }
1086
+
1087
+ // src/server/broadcast-engine.ts
1088
+ function stableStringify(value) {
1089
+ if (value === null || typeof value !== "object")
1090
+ return JSON.stringify(value) ?? "null";
1091
+ if (Array.isArray(value))
1092
+ return `[${value.map(stableStringify).join(",")}]`;
1093
+ if (value instanceof Date)
1094
+ return JSON.stringify(value.toISOString());
1095
+ const obj = value;
1096
+ const keys = Object.keys(obj).sort();
1097
+ const parts = [];
1098
+ for (const key of keys) {
1099
+ if (obj[key] === undefined)
1100
+ continue;
1101
+ parts.push(`${JSON.stringify(key)}:${stableStringify(obj[key])}`);
1102
+ }
1103
+ return `{${parts.join(",")}}`;
1104
+ }
1105
+
1106
+ class BroadcastEngine {
1107
+ deps;
1108
+ locks = new Map;
1109
+ constructor(deps) {
1110
+ this.deps = deps;
1111
+ }
1112
+ withLock(clientId, queryName, fn) {
1113
+ const key = `${clientId.length}:${clientId}:${queryName}`;
1114
+ const prev = this.locks.get(key) ?? Promise.resolve();
1115
+ const next = prev.then(fn, fn);
1116
+ const stored = next.then(() => {
1117
+ if (this.locks.get(key) === stored)
1118
+ this.locks.delete(key);
1119
+ }, () => {
1120
+ if (this.locks.get(key) === stored)
1121
+ this.locks.delete(key);
1122
+ });
1123
+ this.locks.set(key, stored);
1124
+ return next;
1125
+ }
1126
+ async executeWithTimeout(reg, session, limit) {
1127
+ const timeoutMs = this.deps.queryTimeoutMs;
1128
+ if (timeoutMs <= 0)
1129
+ return this.deps.executeQuery(reg, session, limit);
1130
+ let timer;
1131
+ try {
1132
+ return await Promise.race([
1133
+ this.deps.executeQuery(reg, session, limit),
1134
+ new Promise((_, reject) => {
1135
+ timer = setTimeout(() => reject(new Error(`[reflectdb] Query "${reg.name}" exceeded queryTimeoutMs (${timeoutMs}ms)`)), timeoutMs);
1136
+ })
1137
+ ]);
1138
+ } finally {
1139
+ if (timer)
1140
+ clearTimeout(timer);
1141
+ }
1142
+ }
1143
+ async broadcastChanges(tableName, excludeClientId, overrideRoomKey) {
1144
+ const affectedQueries = this.deps.tableDependencyIndex.get(tableName);
1145
+ if (!affectedQueries)
1146
+ return;
1147
+ const hlcStr = this.deps.nextHlc();
1148
+ for (const queryName of affectedQueries) {
1149
+ const queryReg = this.deps.queries.get(queryName);
1150
+ if (!queryReg)
1151
+ continue;
1152
+ const groups = this.buildGroups(queryReg, excludeClientId, overrideRoomKey);
1153
+ if (groups.length === 0)
1154
+ continue;
1155
+ await this.runBounded(groups, (group) => this.broadcastGroup(queryReg, group, hlcStr));
1156
+ }
1157
+ }
1158
+ buildGroups(queryReg, excludeClientId, overrideRoomKey) {
1159
+ const queryName = queryReg.name;
1160
+ let senderRoomKey;
1161
+ if (overrideRoomKey !== undefined) {
1162
+ senderRoomKey = overrideRoomKey;
1163
+ } else {
1164
+ const senderSession = this.deps.sessions.get(excludeClientId);
1165
+ const senderSub = senderSession?.subscriptions.get(queryName);
1166
+ senderRoomKey = senderSub ? senderSub.roomKey : undefined;
1167
+ }
1168
+ const subscribers = this.deps.sessions.getSubscribersForQuery(queryName, senderRoomKey);
1169
+ const groups = new Map;
1170
+ for (const session of subscribers) {
1171
+ if (session.clientId === excludeClientId)
1172
+ continue;
1173
+ const sub = session.subscriptions.get(queryName);
1174
+ if (!sub)
1175
+ continue;
1176
+ if (sub.windowSize != null && !sub.bootstrapped)
1177
+ continue;
1178
+ const key = this.groupKeyFor(queryReg, session, sub);
1179
+ let group = groups.get(key);
1180
+ if (!group) {
1181
+ group = [];
1182
+ groups.set(key, group);
1183
+ }
1184
+ group.push({ session, sub });
1185
+ }
1186
+ return [...groups.values()];
1187
+ }
1188
+ groupKeyFor(queryReg, session, sub) {
1189
+ const custom = queryReg.options.groupBy;
1190
+ if (custom) {
1191
+ try {
1192
+ return `custom:${custom({ auth: session.auth, params: sub.params })}|${sub.roomKey ?? ""}`;
1193
+ } catch (err) {
1194
+ console.error(`[reflectdb] Query "${queryReg.name}" groupBy threw; falling back to per-identity grouping:`, err);
1195
+ }
1196
+ }
1197
+ return stableStringify({
1198
+ auth: session.auth,
1199
+ params: sub.params,
1200
+ roomKey: sub.roomKey
1201
+ });
1202
+ }
1203
+ limitFor(queryReg, group) {
1204
+ let maxWindow = 0;
1205
+ for (const { sub } of group) {
1206
+ if (sub.windowSize == null)
1207
+ return;
1208
+ maxWindow = Math.max(maxWindow, sub.windowLimit);
1209
+ }
1210
+ if (queryReg.options.countHints && !queryReg.options.count)
1211
+ return;
1212
+ return maxWindow > 0 ? maxWindow : undefined;
1213
+ }
1214
+ async broadcastGroup(queryReg, group, hlcStr) {
1215
+ const queryName = queryReg.name;
1216
+ const representative = group[0];
1217
+ const limit = this.limitFor(queryReg, group);
1218
+ let fullRows;
1219
+ try {
1220
+ fullRows = await this.executeWithTimeout(queryReg, representative.session, limit);
1221
+ } catch (err) {
1222
+ console.error(`[reflectdb] Query "${queryName}" failed for group (client ${representative.session.clientId}), skipping broadcast:`, err);
1223
+ this.deps.emit({
1224
+ type: "query_error",
1225
+ clientId: representative.session.clientId,
1226
+ queryName,
1227
+ phase: "broadcast",
1228
+ error: err
1229
+ });
1230
+ return;
1231
+ }
1232
+ let totalCount = null;
1233
+ if (queryReg.options.countHints) {
1234
+ try {
1235
+ totalCount = queryReg.options.count ? await this.deps.countQuery(queryReg, representative.session) : fullRows.length;
1236
+ } catch (err) {
1237
+ console.error(`[reflectdb] Query "${queryName}" count callback failed:`, err);
1238
+ }
1239
+ }
1240
+ const results = await Promise.allSettled(group.map(({ session, sub }) => this.sendToSubscriber(queryReg, session, sub, fullRows, totalCount, hlcStr)));
1241
+ for (let i = 0;i < results.length; i++) {
1242
+ const r = results[i];
1243
+ if (r.status === "rejected") {
1244
+ console.error(`[reflectdb] Broadcast to client ${group[i].session.clientId} for query "${queryName}" failed, skipping:`, r.reason);
1245
+ }
1246
+ }
1247
+ }
1248
+ sendToSubscriber(queryReg, session, sub, fullRows, totalCount, hlcStr) {
1249
+ const queryName = queryReg.name;
1250
+ const pk = queryReg.options.pk ?? "id";
1251
+ const isWindowed = sub.windowSize != null;
1252
+ return this.withLock(session.clientId, queryName, async () => {
1253
+ const newRows = isWindowed ? fullRows.slice(0, sub.windowLimit) : fullRows;
1254
+ const diff = this.deps.resultCache.diffOnly(session.clientId, queryName, newRows, pk);
1255
+ const sendPromises = [];
1256
+ for (const ins of diff.inserted) {
1257
+ sendPromises.push(this.deps.send(session.clientId, {
1258
+ type: "delta",
1259
+ table: queryName,
1260
+ op: "insert",
1261
+ rowId: ins.rowId,
1262
+ payload: ins.data,
1263
+ hlc: hlcStr
1264
+ }));
1265
+ }
1266
+ for (const upd of diff.updated) {
1267
+ sendPromises.push(this.deps.send(session.clientId, {
1268
+ type: "delta",
1269
+ table: queryName,
1270
+ op: "update",
1271
+ rowId: upd.rowId,
1272
+ payload: upd.changed,
1273
+ hlc: hlcStr,
1274
+ colClocks: Object.fromEntries(Object.keys(upd.changed).map((k) => [k, hlcStr]))
1275
+ }));
1276
+ }
1277
+ for (const deletedRowId of diff.deleted) {
1278
+ sendPromises.push(this.deps.send(session.clientId, {
1279
+ type: "delta",
1280
+ table: queryName,
1281
+ op: "delete",
1282
+ rowId: deletedRowId,
1283
+ payload: null,
1284
+ hlc: hlcStr
1285
+ }));
1286
+ }
1287
+ let countToCommit = null;
1288
+ if (isWindowed && queryReg.options.countHints && totalCount != null) {
1289
+ if (totalCount !== sub.lastTotalCount) {
1290
+ countToCommit = totalCount;
1291
+ sendPromises.push(this.deps.send(session.clientId, {
1292
+ type: "count_changed",
1293
+ table: queryName,
1294
+ totalCount
1295
+ }));
1296
+ }
1297
+ }
1298
+ if (sendPromises.length === 0) {
1299
+ this.commit(session, queryName, newRows, pk);
1300
+ return;
1301
+ }
1302
+ await Promise.all(sendPromises);
1303
+ if (this.commit(session, queryName, newRows, pk) && countToCommit != null) {
1304
+ sub.lastTotalCount = countToCommit;
1305
+ }
1306
+ });
1307
+ }
1308
+ commit(session, queryName, rows, pk) {
1309
+ if (this.deps.sessions.get(session.clientId) !== session)
1310
+ return false;
1311
+ this.deps.resultCache.set(session.clientId, queryName, rows, pk);
1312
+ return true;
1313
+ }
1314
+ async runBounded(items, fn) {
1315
+ const limit = Math.max(1, this.deps.maxGroupConcurrency);
1316
+ if (items.length === 1 || limit === 1) {
1317
+ for (const item of items) {
1318
+ try {
1319
+ await fn(item);
1320
+ } catch (err) {
1321
+ console.error("[reflectdb] broadcast group failed:", err);
1322
+ }
1323
+ }
1324
+ return;
1325
+ }
1326
+ let cursor = 0;
1327
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
1328
+ while (cursor < items.length) {
1329
+ const item = items[cursor++];
1330
+ try {
1331
+ await fn(item);
1332
+ } catch (err) {
1333
+ console.error("[reflectdb] broadcast group failed:", err);
1334
+ }
1335
+ }
1336
+ });
1337
+ await Promise.all(workers);
1338
+ }
1339
+ }
1340
+
1341
+ // src/server/op-processor.ts
1342
+ init_types();
1343
+
1344
+ // src/server/conflict.ts
1345
+ init_types();
1346
+ function resolveConflict(policy, incoming, existing) {
1347
+ if (typeof policy === "object" && policy.policy === "custom") {
1348
+ return resolveCustom(policy.resolve, incoming, existing);
1349
+ }
1350
+ switch (policy) {
1351
+ case "lww":
1352
+ return resolveLww(incoming, existing);
1353
+ case "merge":
1354
+ return resolveMerge(incoming, existing);
1355
+ case "server":
1356
+ return resolveServer(incoming, existing);
1357
+ default:
1358
+ return resolveLww(incoming, existing);
1359
+ }
1360
+ }
1361
+ function resolveLww(incoming, existing) {
1362
+ if (!existing.row || !existing.rowHlc) {
1363
+ return {
1364
+ accepted: true,
1365
+ resolvedRow: incoming.op === "delete" ? null : incoming.payload,
1366
+ updatedColClocks: buildColClocks(incoming)
1367
+ };
1368
+ }
1369
+ if (compareHlc(incoming.hlc, existing.rowHlc) > 0) {
1370
+ if (incoming.op === "delete") {
1371
+ return {
1372
+ accepted: true,
1373
+ resolvedRow: null,
1374
+ updatedColClocks: { ...existing.colClocks, _row: incoming.hlc }
1375
+ };
1376
+ }
1377
+ const resolvedRow = incoming.op === "update" ? { ...existing.row, ...incoming.payload } : incoming.payload;
1378
+ const clocks = { ...existing.colClocks, _row: incoming.hlc };
1379
+ if (incoming.payload) {
1380
+ for (const col of Object.keys(incoming.payload)) {
1381
+ clocks[col] = incoming.hlc;
1382
+ }
1383
+ }
1384
+ if (incoming.op === "insert" && incoming.payload) {
1385
+ for (const col of Object.keys(incoming.payload)) {
1386
+ clocks[col] = incoming.hlc;
1387
+ }
1388
+ }
1389
+ return {
1390
+ accepted: true,
1391
+ resolvedRow,
1392
+ updatedColClocks: clocks
1393
+ };
1394
+ }
1395
+ return {
1396
+ accepted: false,
1397
+ resolvedRow: existing.row,
1398
+ updatedColClocks: existing.colClocks
1399
+ };
1400
+ }
1401
+ function resolveMerge(incoming, existing) {
1402
+ if (!existing.row || !existing.rowHlc) {
1403
+ return {
1404
+ accepted: true,
1405
+ resolvedRow: incoming.op === "delete" ? null : incoming.payload,
1406
+ updatedColClocks: buildColClocks(incoming)
1407
+ };
1408
+ }
1409
+ if (incoming.op === "delete") {
1410
+ if (compareHlc(incoming.hlc, existing.rowHlc) > 0) {
1411
+ return {
1412
+ accepted: true,
1413
+ resolvedRow: null,
1414
+ updatedColClocks: { ...existing.colClocks, _row: incoming.hlc }
1415
+ };
1416
+ }
1417
+ return {
1418
+ accepted: false,
1419
+ resolvedRow: existing.row,
1420
+ updatedColClocks: existing.colClocks,
1421
+ reason: "merge_stale"
1422
+ };
1423
+ }
1424
+ const payload = incoming.payload ?? {};
1425
+ const merged = { ...existing.row };
1426
+ const clocks = { ...existing.colClocks };
1427
+ let anyAccepted = false;
1428
+ for (const [col, value] of Object.entries(payload)) {
1429
+ const existingClock = clocks[col];
1430
+ if (!existingClock || compareHlc(incoming.hlc, existingClock) > 0) {
1431
+ merged[col] = value;
1432
+ clocks[col] = incoming.hlc;
1433
+ anyAccepted = true;
1434
+ }
1435
+ }
1436
+ if (anyAccepted && (!clocks["_row"] || compareHlc(incoming.hlc, clocks["_row"]) > 0)) {
1437
+ clocks["_row"] = incoming.hlc;
1438
+ }
1439
+ if (!anyAccepted) {
1440
+ return {
1441
+ accepted: false,
1442
+ resolvedRow: existing.row,
1443
+ updatedColClocks: existing.colClocks,
1444
+ reason: "merge_stale"
1445
+ };
1446
+ }
1447
+ return {
1448
+ accepted: true,
1449
+ resolvedRow: merged,
1450
+ updatedColClocks: clocks
1451
+ };
1452
+ }
1453
+ function resolveServer(incoming, existing) {
1454
+ if (!existing.row) {
1455
+ return {
1456
+ accepted: true,
1457
+ resolvedRow: incoming.op === "delete" ? null : incoming.payload,
1458
+ updatedColClocks: buildColClocks(incoming)
1459
+ };
1460
+ }
1461
+ return {
1462
+ accepted: false,
1463
+ resolvedRow: existing.row,
1464
+ updatedColClocks: existing.colClocks,
1465
+ reason: "server_conflict"
1466
+ };
1467
+ }
1468
+ function resolveCustom(resolve, incoming, existing) {
1469
+ if (!existing.row) {
1470
+ return {
1471
+ accepted: true,
1472
+ resolvedRow: incoming.op === "delete" ? null : incoming.payload,
1473
+ updatedColClocks: buildColClocks(incoming)
1474
+ };
1475
+ }
1476
+ let result;
1477
+ try {
1478
+ result = resolve({ op: incoming.op, payload: incoming.payload, hlc: incoming.hlc }, { row: existing.row, colClocks: existing.colClocks }, {
1479
+ table: incoming.tableName,
1480
+ rowId: incoming.rowId,
1481
+ userId: incoming.userId
1482
+ });
1483
+ } catch (err) {
1484
+ if (err instanceof MutationError) {
1485
+ return {
1486
+ accepted: false,
1487
+ resolvedRow: existing.row,
1488
+ updatedColClocks: existing.colClocks,
1489
+ reason: err.reason
1490
+ };
1491
+ }
1492
+ console.error(`[reflectdb] Custom conflict resolver threw for ${incoming.tableName}/${incoming.rowId}:`, err);
1493
+ return {
1494
+ accepted: false,
1495
+ resolvedRow: existing.row,
1496
+ updatedColClocks: existing.colClocks,
1497
+ reason: "server_error"
1498
+ };
1499
+ }
1500
+ const clocks = { ...existing.colClocks };
1501
+ let anyChanged = false;
1502
+ for (const [col, value] of Object.entries(result.row)) {
1503
+ if (existing.row[col] !== value) {
1504
+ clocks[col] = incoming.hlc;
1505
+ anyChanged = true;
1506
+ }
1507
+ }
1508
+ if (anyChanged) {
1509
+ clocks["_row"] = incoming.hlc;
1510
+ }
1511
+ return {
1512
+ accepted: true,
1513
+ resolvedRow: result.row,
1514
+ updatedColClocks: clocks
1515
+ };
1516
+ }
1517
+ function buildColClocks(incoming) {
1518
+ const clocks = { _row: incoming.hlc };
1519
+ if (incoming.payload) {
1520
+ for (const col of Object.keys(incoming.payload)) {
1521
+ clocks[col] = incoming.hlc;
1522
+ }
1523
+ }
1524
+ return clocks;
1525
+ }
1526
+
1527
+ // src/server/pipeline.ts
1528
+ function processOp(op, existing, ctx) {
1529
+ const drift = enforceClockDrift(op.hlc);
1530
+ if (!drift.ok) {
1531
+ return reject(op, existing, drift.reason);
1532
+ }
1533
+ if (ctx.rateLimiter) {
1534
+ try {
1535
+ const rate = ctx.rateLimiter.check(ctx.userId, ctx.nodeId, op.table);
1536
+ if (!rate.ok) {
1537
+ return reject(op, existing, rate.reason);
1538
+ }
1539
+ ctx.rateLimiter.record(ctx.userId, ctx.nodeId, op.table);
1540
+ } catch (err) {
1541
+ console.error("[reflectdb] Rate limiter error (fail-open):", err);
1542
+ }
1543
+ }
1544
+ if (ctx.maxBatchSize !== undefined) {
1545
+ const batch = enforceBatchSize(op.batchSize, ctx.maxBatchSize);
1546
+ if (!batch.ok) {
1547
+ return reject(op, existing, batch.reason);
1548
+ }
1549
+ }
1550
+ op = enforceReadonly(op, ctx.options);
1551
+ op = enforceServerSet(op, ctx.options);
1552
+ const conflict = resolveConflict(ctx.conflict, {
1553
+ op: op.op,
1554
+ payload: op.payload,
1555
+ hlc: op.hlc,
1556
+ rowId: op.rowId,
1557
+ tableName: op.table,
1558
+ userId: ctx.userId
1559
+ }, existing);
1560
+ return {
1561
+ accepted: conflict.accepted,
1562
+ op,
1563
+ resolvedRow: conflict.resolvedRow,
1564
+ updatedColClocks: conflict.updatedColClocks,
1565
+ reason: conflict.reason
1566
+ };
1567
+ }
1568
+ function reject(op, existing, reason) {
1569
+ return {
1570
+ accepted: false,
1571
+ op,
1572
+ resolvedRow: existing.row,
1573
+ updatedColClocks: existing.colClocks,
1574
+ reason
1575
+ };
1576
+ }
1577
+
1578
+ // src/server/op-processor.ts
1579
+ class OpProcessor {
1580
+ deps;
1581
+ constructor(deps) {
1582
+ this.deps = deps;
1583
+ }
1584
+ get storage() {
1585
+ return this.deps.getStorage();
1586
+ }
1587
+ get rateLimiter() {
1588
+ return this.deps.getRateLimiter();
1589
+ }
1590
+ get maxBatchSize() {
1591
+ return this.deps.getMaxBatchSize();
1592
+ }
1593
+ get db() {
1594
+ return this.deps.db;
1595
+ }
1596
+ get queries() {
1597
+ return this.deps.queries;
1598
+ }
1599
+ get sessions() {
1600
+ return this.deps.sessions;
1601
+ }
1602
+ get eagerBuffer() {
1603
+ return this.deps.eagerBuffer;
1604
+ }
1605
+ get replay() {
1606
+ return this.deps.replay;
1607
+ }
1608
+ static rowKey(table, rowId) {
1609
+ return `${table.length}:${table}:${rowId}`;
1610
+ }
1611
+ async prefetchBatch(ops) {
1612
+ const freshOpIds = new Set;
1613
+ const rows = new Map;
1614
+ const reservable = ops.filter((op) => {
1615
+ const reg = this.queries.get(op.table);
1616
+ return reg != null && reg.options.mutate != null;
1617
+ });
1618
+ if (this.storage?.reserveOps) {
1619
+ for (const id of await this.storage.reserveOps(reservable.map((o) => o.id))) {
1620
+ freshOpIds.add(id);
1621
+ }
1622
+ } else {
1623
+ for (const op of reservable) {
1624
+ if (await this.replay.reserve(op.id))
1625
+ freshOpIds.add(op.id);
1626
+ }
1627
+ }
1628
+ if (this.storage?.getRowsByIds) {
1629
+ const byTable = new Map;
1630
+ for (const op of ops) {
1631
+ if (!freshOpIds.has(op.id))
1632
+ continue;
1633
+ let ids = byTable.get(op.table);
1634
+ if (!ids) {
1635
+ ids = new Set;
1636
+ byTable.set(op.table, ids);
1637
+ }
1638
+ ids.add(op.rowId);
1639
+ }
1640
+ const reads = [...byTable].map(async ([table, ids]) => {
1641
+ const found = await this.storage.getRowsByIds(table, [...ids]);
1642
+ for (const rowId of ids) {
1643
+ rows.set(OpProcessor.rowKey(table, rowId), found[rowId] ?? { row: null, rowHlc: null, colClocks: {} });
1644
+ }
1645
+ });
1646
+ const results = await Promise.allSettled(reads);
1647
+ for (const r of results) {
1648
+ if (r.status === "rejected") {
1649
+ console.warn("[reflectdb] batch row prefetch failed, falling back to getRow:", r.reason);
1650
+ }
1651
+ }
1652
+ }
1653
+ return { freshOpIds, rows };
1654
+ }
1655
+ async readRow(table, rowId, batchCtx) {
1656
+ const cached = batchCtx?.rows.get(OpProcessor.rowKey(table, rowId));
1657
+ if (cached)
1658
+ return cached;
1659
+ if (!this.storage)
1660
+ return { row: null, rowHlc: null, colClocks: {} };
1661
+ const existing = await this.storage.getRow(table, rowId);
1662
+ batchCtx?.rows.set(OpProcessor.rowKey(table, rowId), existing);
1663
+ return existing;
1664
+ }
1665
+ recordBatchWrite(batchCtx, table, rowId, row, colClocks, hlc) {
1666
+ batchCtx?.rows.set(OpProcessor.rowKey(table, rowId), {
1667
+ row,
1668
+ rowHlc: hlc,
1669
+ colClocks
1670
+ });
1671
+ }
1672
+ async processClientOp(op, session, deferredBroadcastTables, batchCtx) {
1673
+ const queryReg = this.queries.get(op.table);
1674
+ if (!queryReg) {
1675
+ return { accepted: false, reason: "outside_shape" };
1676
+ }
1677
+ if (!queryReg.options.mutate) {
1678
+ return { accepted: false, reason: "readonly_query" };
1679
+ }
1680
+ const fresh = batchCtx ? batchCtx.freshOpIds.delete(op.id) : await this.replay.reserve(op.id);
1681
+ if (!fresh) {
1682
+ return { accepted: true };
1683
+ }
1684
+ this.deps.receiveClientHlc(op.hlc);
1685
+ if (queryReg.options.broadcast === "eager" || queryReg.options.broadcast === "eager-durable") {
1686
+ if (deferredBroadcastTables && deferredBroadcastTables.size > 0) {
1687
+ const pending = [...deferredBroadcastTables];
1688
+ deferredBroadcastTables.clear();
1689
+ for (const table of pending) {
1690
+ await this.deps.broadcastChanges(table, session.clientId);
1691
+ }
1692
+ }
1693
+ return this.processEagerOp(op, queryReg, session, batchCtx);
1694
+ }
1695
+ return this.processConsistentOp(op, queryReg, session, deferredBroadcastTables, batchCtx);
1696
+ }
1697
+ async processEagerOp(op, queryReg, session, batchCtx) {
1698
+ const drift = enforceClockDrift(op.hlc);
1699
+ if (!drift.ok) {
1700
+ return { accepted: false, reason: drift.reason };
1701
+ }
1702
+ const batch = enforceBatchSize(op.batchSize, this.maxBatchSize);
1703
+ if (!batch.ok) {
1704
+ return { accepted: false, reason: batch.reason };
1705
+ }
1706
+ if (this.rateLimiter) {
1707
+ try {
1708
+ const result = this.rateLimiter.check(session.auth.userId, session.clientId, op.table);
1709
+ if (!result.ok) {
1710
+ return { accepted: false, reason: "rate_limited" };
1711
+ }
1712
+ this.rateLimiter.record(session.auth.userId, session.clientId, op.table);
1713
+ } catch (err) {
1714
+ console.error("[reflectdb] Rate limiter error (fail-open):", err);
1715
+ }
1716
+ }
1717
+ const enforceSub = session.subscriptions.get(op.table);
1718
+ let enforcedOptions = queryReg.options;
1719
+ if (enforcedOptions.serverSet && Object.keys(enforcedOptions.serverSet).length > 0) {
1720
+ const serverSetCtx = { auth: session.auth, params: enforceSub?.params ?? {} };
1721
+ const resolvedServerSet = {};
1722
+ for (const [key, value] of Object.entries(enforcedOptions.serverSet)) {
1723
+ resolvedServerSet[key] = typeof value === "function" ? value(serverSetCtx) : value;
1724
+ }
1725
+ enforcedOptions = { ...enforcedOptions, serverSet: resolvedServerSet };
1726
+ }
1727
+ op = enforceServerSet(enforceReadonly(op, enforcedOptions), enforcedOptions);
1728
+ const hlcStr = this.deps.stampHlc();
1729
+ const payload = op.payload;
1730
+ let mergedRow = payload;
1731
+ const colClocks = {};
1732
+ if (payload) {
1733
+ for (const key of Object.keys(payload)) {
1734
+ colClocks[key] = hlcStr;
1735
+ }
1736
+ }
1737
+ colClocks._row = hlcStr;
1738
+ if (op.op === "update" && payload && this.storage) {
1739
+ try {
1740
+ const existing = await this.readRow(op.table, op.rowId, batchCtx);
1741
+ if (existing.row) {
1742
+ mergedRow = { ...existing.row, ...payload };
1743
+ const merged = { ...existing.colClocks };
1744
+ for (const k of Object.keys(payload))
1745
+ merged[k] = hlcStr;
1746
+ merged._row = hlcStr;
1747
+ Object.assign(colClocks, merged);
1748
+ }
1749
+ } catch (err) {
1750
+ console.error("[reflectdb] eager update: getRow failed, applying payload as-is:", err);
1751
+ }
1752
+ }
1753
+ const resolvedOp = {
1754
+ type: op.op,
1755
+ table: op.table,
1756
+ rowId: op.rowId,
1757
+ payload,
1758
+ hlc: hlcStr
1759
+ };
1760
+ const sub = session.subscriptions.get(op.table);
1761
+ const mutCtx = {
1762
+ auth: session.auth,
1763
+ params: sub?.params ?? {}
1764
+ };
1765
+ if (queryReg.options.authorize) {
1766
+ try {
1767
+ await queryReg.options.authorize({ type: "write", table: op.table, op: resolvedOp }, mutCtx, this.db);
1768
+ } catch (err) {
1769
+ const reason = reasonFromError(err);
1770
+ return { accepted: false, reason };
1771
+ }
1772
+ }
1773
+ const bufferEntry = {
1774
+ entry: {
1775
+ table: op.table,
1776
+ op: op.op,
1777
+ rowId: op.rowId,
1778
+ payload,
1779
+ hlc: hlcStr,
1780
+ colClocks
1781
+ },
1782
+ row: mergedRow
1783
+ };
1784
+ if (!this.eagerBuffer.hasCapacity(op.table, queryReg.options.maxBufferSize)) {
1785
+ return { accepted: false, reason: "buffer_full" };
1786
+ }
1787
+ let mutateResult;
1788
+ if (queryReg.options.mutate) {
1789
+ try {
1790
+ const result = await queryReg.options.mutate(resolvedOp, mutCtx, this.db);
1791
+ if (result)
1792
+ mutateResult = result;
1793
+ } catch (err) {
1794
+ const reason = reasonFromError(err);
1795
+ return { accepted: false, reason };
1796
+ }
1797
+ }
1798
+ if (queryReg.options.broadcast === "eager-durable" && this.storage) {
1799
+ await this.storage.applyOp(op.table, op.rowId, mergedRow, colClocks, hlcStr, op.op, payload);
1800
+ this.recordBatchWrite(batchCtx, op.table, op.rowId, mergedRow, colClocks, hlcStr);
1801
+ } else {
1802
+ const pushed = this.eagerBuffer.push(op.table, bufferEntry, queryReg.options.maxBufferSize);
1803
+ this.recordBatchWrite(batchCtx, op.table, op.rowId, mergedRow, colClocks, hlcStr);
1804
+ if (!pushed && this.storage) {
1805
+ try {
1806
+ await this.storage.applyOp(op.table, op.rowId, mergedRow, colClocks, hlcStr, op.op, payload);
1807
+ } catch (err) {
1808
+ console.error("[reflectdb] eager fallback applyOp after buffer overflow failed:", err);
1809
+ }
1810
+ }
1811
+ }
1812
+ const senderSession = this.sessions.get(session.clientId);
1813
+ const senderSub = senderSession?.subscriptions.get(op.table);
1814
+ const senderRoomKey = senderSub ? senderSub.roomKey : undefined;
1815
+ const subscribers = this.sessions.getSubscribersForQuery(op.table, senderRoomKey);
1816
+ const eagerSendPromises = [];
1817
+ for (const sub2 of subscribers) {
1818
+ if (sub2.clientId === session.clientId)
1819
+ continue;
1820
+ eagerSendPromises.push(this.deps.send(sub2.clientId, {
1821
+ type: "delta",
1822
+ table: op.table,
1823
+ op: op.op,
1824
+ rowId: op.rowId,
1825
+ payload,
1826
+ hlc: hlcStr,
1827
+ colClocks
1828
+ }));
1829
+ }
1830
+ const eagerResults = await Promise.allSettled(eagerSendPromises);
1831
+ for (const r of eagerResults) {
1832
+ if (r.status === "rejected") {
1833
+ console.error("[reflectdb] eager broadcast send failed:", r.reason);
1834
+ }
1835
+ }
1836
+ if (mutateResult?.affected) {
1837
+ for (const affectedTable of mutateResult.affected) {
1838
+ await this.deps.broadcastChanges(affectedTable, session.clientId);
1839
+ }
1840
+ }
1841
+ return { accepted: true };
1842
+ }
1843
+ async processConsistentOp(op, queryReg, session, deferredBroadcastTables, batchCtx) {
1844
+ let options = queryReg.options;
1845
+ if (options.serverSet && Object.keys(options.serverSet).length > 0) {
1846
+ const sub = session.subscriptions.get(op.table);
1847
+ const mutCtx = { auth: session.auth, params: sub?.params ?? {} };
1848
+ const resolvedServerSet = {};
1849
+ for (const [key, value] of Object.entries(options.serverSet)) {
1850
+ resolvedServerSet[key] = typeof value === "function" ? value(mutCtx) : value;
1851
+ }
1852
+ options = { ...options, serverSet: resolvedServerSet };
1853
+ }
1854
+ let existing = { row: null, rowHlc: null, colClocks: {} };
1855
+ if (this.storage) {
1856
+ existing = await this.readRow(op.table, op.rowId, batchCtx);
1857
+ }
1858
+ const ctx = {
1859
+ userId: session.auth.userId,
1860
+ nodeId: session.clientId,
1861
+ options,
1862
+ conflict: options.conflict ?? "lww",
1863
+ rateLimiter: this.rateLimiter ?? undefined,
1864
+ maxBatchSize: this.maxBatchSize
1865
+ };
1866
+ const result = processOp(op, existing, ctx);
1867
+ if (result.accepted) {
1868
+ const resolvedOp = {
1869
+ type: op.op,
1870
+ table: op.table,
1871
+ rowId: op.rowId,
1872
+ payload: result.resolvedRow,
1873
+ hlc: op.hlc
1874
+ };
1875
+ const sub = session.subscriptions.get(op.table);
1876
+ const mutCtx = {
1877
+ auth: session.auth,
1878
+ params: sub?.params ?? {}
1879
+ };
1880
+ if (queryReg.options.authorize) {
1881
+ try {
1882
+ await queryReg.options.authorize({ type: "write", table: op.table, op: resolvedOp }, mutCtx, this.db);
1883
+ } catch (err) {
1884
+ const reason = reasonFromError(err);
1885
+ return { accepted: false, reason, serverRow: existing.row };
1886
+ }
1887
+ }
1888
+ let mutateResult = undefined;
1889
+ if (queryReg.options.mutate) {
1890
+ try {
1891
+ mutateResult = await queryReg.options.mutate(resolvedOp, mutCtx, this.db);
1892
+ } catch (err) {
1893
+ const reason = reasonFromError(err);
1894
+ return { accepted: false, reason, serverRow: existing.row };
1895
+ }
1896
+ }
1897
+ if (this.storage) {
1898
+ await this.storage.applyOp(op.table, op.rowId, result.resolvedRow, result.updatedColClocks, op.hlc, op.op, result.resolvedRow);
1899
+ this.recordBatchWrite(batchCtx, op.table, op.rowId, result.resolvedRow, result.updatedColClocks, op.hlc);
1900
+ }
1901
+ if (deferredBroadcastTables) {
1902
+ deferredBroadcastTables.add(op.table);
1903
+ if (mutateResult?.affected) {
1904
+ for (const affectedTable of mutateResult.affected) {
1905
+ deferredBroadcastTables.add(affectedTable);
1906
+ }
1907
+ }
1908
+ } else {
1909
+ const tablesToBroadcast = new Set([op.table]);
1910
+ if (mutateResult?.affected) {
1911
+ for (const affectedTable of mutateResult.affected) {
1912
+ tablesToBroadcast.add(affectedTable);
1913
+ }
1914
+ }
1915
+ for (const table of tablesToBroadcast) {
1916
+ await this.deps.broadcastChanges(table, session.clientId);
1917
+ }
1918
+ }
1919
+ }
1920
+ return {
1921
+ accepted: result.accepted,
1922
+ reason: result.reason,
1923
+ serverRow: result.accepted ? undefined : existing.row
1924
+ };
1925
+ }
1926
+ }
1927
+
1928
+ // src/server/ephemeral-manager.ts
1929
+ var DEFAULT_MAX_ENTRIES = 1e4;
1930
+ function indexKey(room, key, userId) {
1931
+ return `${room}\x00${key}\x00${userId}`;
1932
+ }
1933
+ function parseIndexKey(packed) {
1934
+ const [room, key, userId] = packed.split("\x00");
1935
+ return { room, key, userId };
1936
+ }
1937
+
1938
+ class EphemeralManager {
1939
+ store = new Map;
1940
+ clientIndex = new Map;
1941
+ entryCount = 0;
1942
+ maxEntries;
1943
+ constructor(maxEntries) {
1944
+ this.maxEntries = maxEntries ?? DEFAULT_MAX_ENTRIES;
1945
+ }
1946
+ set(room, key, clientId, userId, data, ttlMs) {
1947
+ const roomMap = this.store.get(room) ?? new Map;
1948
+ const keyMap = roomMap.get(key) ?? new Map;
1949
+ const isUpdate = keyMap.has(userId);
1950
+ if (!isUpdate && this.entryCount >= this.maxEntries) {
1951
+ return false;
1952
+ }
1953
+ const state = {
1954
+ clientId,
1955
+ userId,
1956
+ key,
1957
+ data,
1958
+ updatedAt: Date.now(),
1959
+ ttlMs
1960
+ };
1961
+ keyMap.set(userId, state);
1962
+ roomMap.set(key, keyMap);
1963
+ this.store.set(room, roomMap);
1964
+ if (!isUpdate) {
1965
+ this.entryCount++;
1966
+ const clientSet = this.clientIndex.get(clientId) ?? new Set;
1967
+ clientSet.add(indexKey(room, key, userId));
1968
+ this.clientIndex.set(clientId, clientSet);
1969
+ }
1970
+ return true;
1971
+ }
1972
+ get(room, key) {
1973
+ const roomMap = this.store.get(room);
1974
+ if (!roomMap)
1975
+ return {};
1976
+ const keyMap = roomMap.get(key);
1977
+ if (!keyMap)
1978
+ return {};
1979
+ return Object.fromEntries(keyMap.entries());
1980
+ }
1981
+ remove(room, key, userId) {
1982
+ const roomMap = this.store.get(room);
1983
+ if (!roomMap)
1984
+ return;
1985
+ const keyMap = roomMap.get(key);
1986
+ if (!keyMap)
1987
+ return;
1988
+ if (keyMap.has(userId)) {
1989
+ keyMap.delete(userId);
1990
+ this.entryCount--;
1991
+ }
1992
+ if (keyMap.size === 0) {
1993
+ roomMap.delete(key);
1994
+ }
1995
+ if (roomMap.size === 0) {
1996
+ this.store.delete(room);
1997
+ }
1998
+ }
1999
+ removeClient(clientId) {
2000
+ const indices = this.clientIndex.get(clientId);
2001
+ if (!indices)
2002
+ return;
2003
+ const entries = [...indices];
2004
+ for (const packed of entries) {
2005
+ const { room, key, userId } = parseIndexKey(packed);
2006
+ this.remove(room, key, userId);
2007
+ }
2008
+ this.clientIndex.delete(clientId);
2009
+ }
2010
+ cleanupExpired() {
2011
+ const now = Date.now();
2012
+ const toRemove = [];
2013
+ for (const [room, roomMap] of this.store) {
2014
+ for (const [key, keyMap] of roomMap) {
2015
+ for (const [userId, state] of keyMap) {
2016
+ if (state.ttlMs && now - state.updatedAt > state.ttlMs) {
2017
+ toRemove.push({ room, key, userId, clientId: state.clientId });
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ for (const { room, key, userId, clientId } of toRemove) {
2023
+ this.remove(room, key, userId);
2024
+ const clientSet = this.clientIndex.get(clientId);
2025
+ if (clientSet) {
2026
+ clientSet.delete(indexKey(room, key, userId));
2027
+ if (clientSet.size === 0) {
2028
+ this.clientIndex.delete(clientId);
2029
+ }
2030
+ }
2031
+ }
2032
+ }
2033
+ get size() {
2034
+ return this.entryCount;
2035
+ }
2036
+ destroy() {
2037
+ this.store.clear();
2038
+ this.clientIndex.clear();
2039
+ this.entryCount = 0;
2040
+ }
2041
+ }
2042
+
2043
+ // src/server/eager-buffer.ts
2044
+ var DEFAULT_FLUSH_INTERVAL = 200;
2045
+ var DEFAULT_MAX_BUFFER_SIZE = 1000;
2046
+
2047
+ class EagerBuffer {
2048
+ buffers = new Map;
2049
+ flushInterval;
2050
+ maxBufferSize;
2051
+ timer = null;
2052
+ storage = null;
2053
+ flushPromise = null;
2054
+ onFlushError = null;
2055
+ constructor(config) {
2056
+ this.flushInterval = config?.flushInterval ?? DEFAULT_FLUSH_INTERVAL;
2057
+ this.maxBufferSize = config?.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE;
2058
+ this.onFlushError = config?.onFlushError ?? null;
2059
+ }
2060
+ setOnFlushError(handler) {
2061
+ this.onFlushError = handler;
2062
+ }
2063
+ setStorage(storage) {
2064
+ this.storage = ensureAtomicApplyOp(storage);
2065
+ }
2066
+ start(interval) {
2067
+ const requested = interval ?? this.flushInterval;
2068
+ const newInterval = Math.max(1, Math.min(this.flushInterval, requested));
2069
+ if (this.timer) {
2070
+ if (newInterval < this.flushInterval) {
2071
+ clearInterval(this.timer);
2072
+ this.flushInterval = newInterval;
2073
+ this.timer = setInterval(() => {
2074
+ this.flush();
2075
+ }, this.flushInterval);
2076
+ }
2077
+ return;
2078
+ }
2079
+ this.flushInterval = newInterval;
2080
+ this.timer = setInterval(() => {
2081
+ this.flush();
2082
+ }, this.flushInterval);
2083
+ }
2084
+ hasCapacity(queryName, maxSize) {
2085
+ const buffer = this.buffers.get(queryName);
2086
+ if (!buffer)
2087
+ return true;
2088
+ return buffer.length < (maxSize ?? this.maxBufferSize);
2089
+ }
2090
+ push(queryName, op, maxSize) {
2091
+ let buffer = this.buffers.get(queryName);
2092
+ if (!buffer) {
2093
+ buffer = [];
2094
+ this.buffers.set(queryName, buffer);
2095
+ }
2096
+ if (buffer.length >= (maxSize ?? this.maxBufferSize)) {
2097
+ return false;
2098
+ }
2099
+ buffer.push(op);
2100
+ return true;
2101
+ }
2102
+ async flush() {
2103
+ if (this.flushPromise) {
2104
+ return this.flushPromise;
2105
+ }
2106
+ this.flushPromise = this.doFlush();
2107
+ try {
2108
+ return await this.flushPromise;
2109
+ } finally {
2110
+ this.flushPromise = null;
2111
+ }
2112
+ }
2113
+ async doFlush() {
2114
+ let flushed = 0;
2115
+ for (const [name, buffer] of this.buffers) {
2116
+ if (buffer.length === 0)
2117
+ continue;
2118
+ const ops = buffer.splice(0, buffer.length);
2119
+ if (this.storage) {
2120
+ const storage = this.storage;
2121
+ const results = await Promise.allSettled(ops.map((op) => storage.applyOp(op.entry.table, op.entry.rowId, op.row !== undefined ? op.row : op.entry.payload, op.entry.colClocks, op.entry.hlc, op.entry.op, op.entry.payload)));
2122
+ const failedOps = [];
2123
+ for (let i = 0;i < results.length; i++) {
2124
+ const r = results[i];
2125
+ if (r.status === "fulfilled") {
2126
+ flushed++;
2127
+ } else {
2128
+ console.error("[reflectdb] EagerBuffer: failed to persist op:", r.reason);
2129
+ failedOps.push(ops[i]);
2130
+ if (this.onFlushError) {
2131
+ try {
2132
+ this.onFlushError(r.reason, ops[i]);
2133
+ } catch (handlerErr) {
2134
+ console.error("[reflectdb] EagerBuffer: onFlushError threw:", handlerErr);
2135
+ }
2136
+ }
2137
+ }
2138
+ }
2139
+ if (failedOps.length > 0) {
2140
+ buffer.unshift(...failedOps);
2141
+ }
2142
+ } else {
2143
+ flushed += ops.length;
2144
+ }
2145
+ }
2146
+ return flushed;
2147
+ }
2148
+ get pendingCount() {
2149
+ let count = 0;
2150
+ for (const buffer of this.buffers.values()) {
2151
+ count += buffer.length;
2152
+ }
2153
+ return count;
2154
+ }
2155
+ queryPendingCount(queryName) {
2156
+ return this.buffers.get(queryName)?.length ?? 0;
2157
+ }
2158
+ async close() {
2159
+ if (this.timer) {
2160
+ clearInterval(this.timer);
2161
+ this.timer = null;
2162
+ }
2163
+ if (this.flushPromise) {
2164
+ await this.flushPromise;
2165
+ }
2166
+ await this.flush();
2167
+ }
2168
+ }
2169
+
2170
+ // src/server/message-validator.ts
2171
+ var VALID_CLIENT_MESSAGE_TYPES = new Set([
2172
+ "hello",
2173
+ "bootstrap",
2174
+ "resume",
2175
+ "ops",
2176
+ "sync_declare",
2177
+ "load_more",
2178
+ "unsync",
2179
+ "auth",
2180
+ "ephemeral"
2181
+ ]);
2182
+ var VALID_OP_TYPES = new Set(["insert", "update", "delete"]);
2183
+ var MAX_OPS_ARRAY = 1e4;
2184
+ var MAX_LOAD_MORE_COUNT = 1e5;
2185
+ function validateClientMessage(msg) {
2186
+ if (msg == null || typeof msg !== "object") {
2187
+ return { valid: false, error: "message is not an object" };
2188
+ }
2189
+ const m = msg;
2190
+ if (typeof m.type !== "string" || !VALID_CLIENT_MESSAGE_TYPES.has(m.type)) {
2191
+ return { valid: false, error: `invalid message type: ${JSON.stringify(m.type)}` };
2192
+ }
2193
+ switch (m.type) {
2194
+ case "hello":
2195
+ if (typeof m.protocolVersion !== "number")
2196
+ return { valid: false, error: "hello: missing protocolVersion" };
2197
+ if (typeof m.clientId !== "string")
2198
+ return { valid: false, error: "hello: missing clientId" };
2199
+ if (m.clientId.length === 0 || m.clientId.length > 256)
2200
+ return { valid: false, error: "hello: clientId length out of range" };
2201
+ if (typeof m.token !== "string")
2202
+ return { valid: false, error: "hello: missing token" };
2203
+ if (m.token.length > 16384)
2204
+ return { valid: false, error: "hello: token too long" };
2205
+ if (m.supportedVersions !== undefined) {
2206
+ if (!Array.isArray(m.supportedVersions))
2207
+ return { valid: false, error: "hello: supportedVersions must be array" };
2208
+ if (m.supportedVersions.length > 32)
2209
+ return { valid: false, error: "hello: supportedVersions too long" };
2210
+ }
2211
+ break;
2212
+ case "resume":
2213
+ if (typeof m.since !== "string")
2214
+ return { valid: false, error: "resume: missing since" };
2215
+ break;
2216
+ case "ops":
2217
+ if (!Array.isArray(m.ops))
2218
+ return { valid: false, error: "ops: missing ops array" };
2219
+ if (typeof m.token !== "string")
2220
+ return { valid: false, error: "ops: missing token" };
2221
+ if (m.ops.length > MAX_OPS_ARRAY)
2222
+ return { valid: false, error: `ops: array length ${m.ops.length} exceeds ${MAX_OPS_ARRAY}` };
2223
+ for (let i = 0;i < m.ops.length; i++) {
2224
+ const op = m.ops[i];
2225
+ if (typeof op?.id !== "string")
2226
+ return { valid: false, error: `ops[${i}]: missing id` };
2227
+ if (typeof op.table !== "string")
2228
+ return { valid: false, error: `ops[${i}]: missing table` };
2229
+ if (typeof op.op !== "string" || !VALID_OP_TYPES.has(op.op))
2230
+ return { valid: false, error: `ops[${i}]: invalid op type` };
2231
+ if (typeof op.rowId !== "string")
2232
+ return { valid: false, error: `ops[${i}]: missing rowId` };
2233
+ if (typeof op.hlc !== "string")
2234
+ return { valid: false, error: `ops[${i}]: missing hlc` };
2235
+ if (op.payload !== null && op.payload !== undefined) {
2236
+ if (typeof op.payload !== "object" || Array.isArray(op.payload)) {
2237
+ return { valid: false, error: `ops[${i}]: payload must be object or null` };
2238
+ }
2239
+ }
2240
+ }
2241
+ break;
2242
+ case "sync_declare":
2243
+ if (typeof m.table !== "string")
2244
+ return { valid: false, error: "sync_declare: missing table" };
2245
+ break;
2246
+ case "load_more":
2247
+ if (typeof m.table !== "string")
2248
+ return { valid: false, error: "load_more: missing table" };
2249
+ if (typeof m.count !== "number" || m.count <= 0 || m.count > MAX_LOAD_MORE_COUNT)
2250
+ return { valid: false, error: "load_more: invalid count" };
2251
+ break;
2252
+ case "unsync":
2253
+ if (typeof m.table !== "string")
2254
+ return { valid: false, error: "unsync: missing table" };
2255
+ break;
2256
+ case "auth":
2257
+ if (typeof m.token !== "string")
2258
+ return { valid: false, error: "auth: missing token" };
2259
+ break;
2260
+ case "ephemeral":
2261
+ if (typeof m.key !== "string")
2262
+ return { valid: false, error: "ephemeral: missing key" };
2263
+ if (typeof m.data !== "object" || m.data == null)
2264
+ return { valid: false, error: "ephemeral: missing data" };
2265
+ break;
2266
+ case "bootstrap":
2267
+ break;
2268
+ }
2269
+ return { valid: true, message: m };
2270
+ }
2271
+
2272
+ // src/server/compaction-manager.ts
2273
+ class CompactionManager {
2274
+ storage;
2275
+ emit;
2276
+ static MIN_COMPACTION_AGE_MS = 30000;
2277
+ cutoffHlc = null;
2278
+ cutoffLoaded = false;
2279
+ constructor(storage, emit) {
2280
+ this.storage = storage;
2281
+ this.emit = emit;
2282
+ }
2283
+ setStorage(storage) {
2284
+ this.storage = storage;
2285
+ }
2286
+ getCutoff() {
2287
+ return this.cutoffHlc;
2288
+ }
2289
+ async ensureCutoffLoaded() {
2290
+ if (this.cutoffLoaded || !this.storage)
2291
+ return;
2292
+ this.cutoffLoaded = true;
2293
+ const cutoff = await this.storage.getMeta("compactionCutoff");
2294
+ if (cutoff) {
2295
+ this.cutoffHlc = cutoff;
2296
+ }
2297
+ }
2298
+ async runCompaction(minOpAge) {
2299
+ if (!this.storage)
2300
+ return 0;
2301
+ const safeMinAge = Math.max(minOpAge, CompactionManager.MIN_COMPACTION_AGE_MS);
2302
+ const lockKey = "compaction";
2303
+ let acquired = true;
2304
+ if (this.storage.tryLock) {
2305
+ acquired = await this.storage.tryLock(lockKey);
2306
+ if (!acquired) {
2307
+ this.emit({ type: "compaction", deletedOps: 0 });
2308
+ return 0;
2309
+ }
2310
+ }
2311
+ try {
2312
+ await this.ensureCutoffLoaded();
2313
+ const cutoffMs = Date.now() - safeMinAge;
2314
+ const cutoffHlc = String(cutoffMs).padStart(19, "0") + ".9999.compaction";
2315
+ if (this.cutoffHlc && cutoffHlc <= this.cutoffHlc) {
2316
+ this.emit({ type: "compaction", deletedOps: 0 });
2317
+ return 0;
2318
+ }
2319
+ this.cutoffHlc = cutoffHlc;
2320
+ await this.storage.setMeta("compactionCutoff", cutoffHlc);
2321
+ const deleted = await this.storage.deleteOpsBefore(cutoffHlc);
2322
+ if (deleted > 0) {
2323
+ this.emit({ type: "compaction", deletedOps: deleted });
2324
+ }
2325
+ return deleted;
2326
+ } finally {
2327
+ if (acquired && this.storage.unlock) {
2328
+ await this.storage.unlock(lockKey);
2329
+ }
2330
+ }
2331
+ }
2332
+ }
2333
+
2334
+ // src/server/replay-detector.ts
2335
+ class ReplayDetector {
2336
+ storage;
2337
+ static TTL_MS = 5 * 60000;
2338
+ static MAX_ENTRIES = 1e4;
2339
+ inMemory = new Map;
2340
+ constructor(storage) {
2341
+ this.storage = storage;
2342
+ }
2343
+ setStorage(storage) {
2344
+ this.storage = storage;
2345
+ }
2346
+ async reserve(opId) {
2347
+ if (this.storage) {
2348
+ return this.storage.reserveOp(opId);
2349
+ }
2350
+ const now = Date.now();
2351
+ const cutoff = now - ReplayDetector.TTL_MS;
2352
+ if (this.inMemory.has(opId)) {
2353
+ this.inMemory.set(opId, now);
2354
+ return false;
2355
+ }
2356
+ if (this.inMemory.size >= ReplayDetector.MAX_ENTRIES) {
2357
+ for (const [k, ts] of this.inMemory) {
2358
+ if (ts < cutoff)
2359
+ this.inMemory.delete(k);
2360
+ }
2361
+ if (this.inMemory.size >= ReplayDetector.MAX_ENTRIES) {
2362
+ const iter = this.inMemory.keys();
2363
+ const drop = Math.ceil(ReplayDetector.MAX_ENTRIES / 2);
2364
+ for (let i = 0;i < drop; i++) {
2365
+ const k = iter.next().value;
2366
+ if (k === undefined)
2367
+ break;
2368
+ this.inMemory.delete(k);
2369
+ }
2370
+ }
2371
+ }
2372
+ this.inMemory.set(opId, now);
2373
+ return true;
2374
+ }
2375
+ }
2376
+
2377
+ // src/server/rooms.ts
2378
+ function resolveRoomKey(rooms, params, requiredPattern) {
2379
+ if (rooms.length === 0) {
2380
+ if (requiredPattern) {
2381
+ return {
2382
+ ok: false,
2383
+ reason: `Query requires room "${requiredPattern}" but no rooms are registered`
2384
+ };
2385
+ }
2386
+ return { ok: true, roomKey: null };
2387
+ }
2388
+ for (const room of rooms) {
2389
+ if (requiredPattern && room.pattern !== requiredPattern)
2390
+ continue;
2391
+ if (room.paramNames.length === 0) {
2392
+ return { ok: true, roomKey: room.pattern };
2393
+ }
2394
+ const provided = room.paramNames.filter((name) => params[name] !== undefined && params[name] !== null);
2395
+ if (provided.length === 0)
2396
+ continue;
2397
+ if (provided.length < room.paramNames.length) {
2398
+ const missing = room.paramNames.filter((n) => !provided.includes(n));
2399
+ return {
2400
+ ok: false,
2401
+ reason: `Room "${room.pattern}" is missing param(s): ${missing.join(", ")}`
2402
+ };
2403
+ }
2404
+ const key = room.pattern.replace(/:(\w+)/g, (_, name) => String(params[name]));
2405
+ if (!room.regex.test(key)) {
2406
+ return {
2407
+ ok: false,
2408
+ reason: `Room "${room.pattern}" params do not form a valid room key`
2409
+ };
2410
+ }
2411
+ return { ok: true, roomKey: key };
2412
+ }
2413
+ if (requiredPattern) {
2414
+ return {
2415
+ ok: false,
2416
+ reason: `Query requires room "${requiredPattern}" but params do not resolve it`
2417
+ };
2418
+ }
2419
+ return { ok: true, roomKey: null };
2420
+ }
2421
+
2422
+ // src/server/handler.ts
2423
+ var DEFAULT_EPHEMERAL_PER_SECOND = 60;
2424
+ var applyOpShimWarned = new WeakSet;
2425
+ function ensureAtomicApplyOp(adapter) {
2426
+ if (adapter.applyOp)
2427
+ return adapter;
2428
+ if (!applyOpShimWarned.has(adapter)) {
2429
+ applyOpShimWarned.add(adapter);
2430
+ console.warn("[reflectdb] StorageAdapter lacks atomic applyOp; using non-atomic shim. " + "Crash between putRow and appendOp can diverge. " + "Implement StorageAdapter.applyOp for crash safety.");
2431
+ }
2432
+ const wrapper = {
2433
+ getRow: (...args) => adapter.getRow(...args),
2434
+ putRow: (...args) => adapter.putRow(...args),
2435
+ getRows: (...args) => adapter.getRows(...args),
2436
+ appendOp: (...args) => adapter.appendOp(...args),
2437
+ getOpsSince: (...args) => adapter.getOpsSince(...args),
2438
+ deleteOpsBefore: (...args) => adapter.deleteOpsBefore(...args),
2439
+ reserveOp: (...args) => adapter.reserveOp(...args),
2440
+ getMeta: (...args) => adapter.getMeta(...args),
2441
+ setMeta: (...args) => adapter.setMeta(...args),
2442
+ applyOp: async (table, rowId, row, colClocks, hlc, opType, payload) => {
2443
+ await adapter.putRow(table, rowId, row, colClocks, hlc);
2444
+ await adapter.appendOp({ table, op: opType, rowId, payload, hlc, colClocks });
2445
+ }
2446
+ };
2447
+ if (adapter.getRowsByIds) {
2448
+ wrapper.getRowsByIds = (...args) => adapter.getRowsByIds(...args);
2449
+ }
2450
+ if (adapter.getChangedTablesSince) {
2451
+ wrapper.getChangedTablesSince = (...args) => adapter.getChangedTablesSince(...args);
2452
+ }
2453
+ if (adapter.getOplogHead) {
2454
+ wrapper.getOplogHead = (...args) => adapter.getOplogHead(...args);
2455
+ }
2456
+ if (adapter.reserveOps) {
2457
+ wrapper.reserveOps = (...args) => adapter.reserveOps(...args);
2458
+ }
2459
+ if (adapter.tryLock) {
2460
+ wrapper.tryLock = (...args) => adapter.tryLock(...args);
2461
+ }
2462
+ if (adapter.unlock) {
2463
+ wrapper.unlock = (...args) => adapter.unlock(...args);
2464
+ }
2465
+ return wrapper;
2466
+ }
2467
+
2468
+ class MessageHandler {
2469
+ transport;
2470
+ serverId;
2471
+ db;
2472
+ clock;
2473
+ sessions;
2474
+ authCallback = null;
2475
+ queries = new Map;
2476
+ rooms = [];
2477
+ rateLimiter = null;
2478
+ maxBatchSize = MAX_BATCH_SIZE;
2479
+ storage = null;
2480
+ minSchemaVersion = 0;
2481
+ messageQueues = new Map;
2482
+ resultCache = new ResultCache;
2483
+ broadcast;
2484
+ ops;
2485
+ ephemeralManager = new EphemeralManager;
2486
+ ephemeralCleanupTimer = null;
2487
+ eagerBuffer = new EagerBuffer;
2488
+ replay;
2489
+ static MAX_EPHEMERAL_TTL_MS = 24 * 60 * 60 * 1000;
2490
+ static AUTH_CACHE_TTL_MS = 5000;
2491
+ authCache = new Map;
2492
+ compaction;
2493
+ messageQueueDepths = new Map;
2494
+ static MAX_QUEUE_DEPTH = 100;
2495
+ static eagerWarned = false;
2496
+ static missingAuthWarned = false;
2497
+ pollWatermark = null;
2498
+ onEvent = null;
2499
+ maxConnectionsPerUser = null;
2500
+ allowAnonymous = false;
2501
+ queryTimeoutMs = 0;
2502
+ ephemeralPerSecond = DEFAULT_EPHEMERAL_PER_SECOND;
2503
+ ephemeralBuckets = new Map;
2504
+ tableDependencyIndex = new Map;
2505
+ emit(event) {
2506
+ try {
2507
+ this.onEvent?.(event);
2508
+ } catch {}
2509
+ }
2510
+ async trySend(clientId, message) {
2511
+ try {
2512
+ await this.transport.send(clientId, message);
2513
+ return true;
2514
+ } catch (err) {
2515
+ console.warn(`[reflectdb] send to ${clientId} failed (${message.type}):`, err instanceof Error ? err.message : err);
2516
+ return false;
2517
+ }
2518
+ }
2519
+ constructor(config) {
2520
+ this.transport = config.transport;
2521
+ this.serverId = config.serverId;
2522
+ this.db = config.db;
2523
+ this.onEvent = config.onEvent ?? null;
2524
+ this.maxConnectionsPerUser = config.maxConnectionsPerUser ?? null;
2525
+ this.allowAnonymous = config.allowAnonymous ?? false;
2526
+ this.queryTimeoutMs = config.queryTimeoutMs ?? 0;
2527
+ this.clock = new ServerClock(`server:${config.serverId}`);
2528
+ this.sessions = new SessionManager;
2529
+ this.compaction = new CompactionManager(null, (e) => this.emit(e));
2530
+ this.replay = new ReplayDetector(null);
2531
+ this.broadcast = new BroadcastEngine({
2532
+ sessions: this.sessions,
2533
+ queries: this.queries,
2534
+ tableDependencyIndex: this.tableDependencyIndex,
2535
+ resultCache: this.resultCache,
2536
+ send: (clientId, message) => this.transport.send(clientId, message),
2537
+ executeQuery: (reg, session, limit) => this.executeQuery(reg, session, limit),
2538
+ countQuery: (reg, session) => this.countQuery(reg, session),
2539
+ nextHlc: () => this.clock.stamp(),
2540
+ emit: (event) => this.emit(event),
2541
+ queryTimeoutMs: this.queryTimeoutMs,
2542
+ maxGroupConcurrency: config.maxBroadcastConcurrency ?? 8
2543
+ });
2544
+ this.ops = new OpProcessor({
2545
+ db: this.db,
2546
+ queries: this.queries,
2547
+ sessions: this.sessions,
2548
+ eagerBuffer: this.eagerBuffer,
2549
+ replay: this.replay,
2550
+ getStorage: () => this.storage,
2551
+ getRateLimiter: () => this.rateLimiter,
2552
+ getMaxBatchSize: () => this.maxBatchSize,
2553
+ stampHlc: () => this.clock.stamp(),
2554
+ receiveClientHlc: (hlc) => this.clock.receive(hlc),
2555
+ send: (clientId, message) => this.transport.send(clientId, message),
2556
+ broadcastChanges: (table, excludeClientId) => this.broadcastChanges(table, excludeClientId)
2557
+ });
2558
+ this.transport.onConnect((clientId) => {
2559
+ this.sessions.connect(clientId);
2560
+ this.emit({ type: "client_connected", clientId });
2561
+ });
2562
+ this.transport.onDisconnect((clientId) => {
2563
+ this.sessions.disconnect(clientId);
2564
+ this.messageQueues.delete(clientId);
2565
+ this.messageQueueDepths.delete(clientId);
2566
+ this.resultCache.clearClient(clientId);
2567
+ this.ephemeralManager.removeClient(clientId);
2568
+ this.ephemeralBuckets.delete(clientId);
2569
+ this.emit({ type: "client_disconnected", clientId });
2570
+ });
2571
+ this.transport.onMessage((clientId, rawMessage) => {
2572
+ const validation = validateClientMessage(rawMessage);
2573
+ if (!validation.valid) {
2574
+ console.warn(`[reflectdb] Invalid message from ${clientId}: ${validation.error}`);
2575
+ this.emit({ type: "message_invalid", clientId, error: validation.error });
2576
+ return;
2577
+ }
2578
+ const message = validation.message;
2579
+ const depth = this.messageQueueDepths.get(clientId) ?? 0;
2580
+ if (depth >= MessageHandler.MAX_QUEUE_DEPTH) {
2581
+ this.emit({ type: "queue_overflow", clientId, depth });
2582
+ const inflight = this.messageQueues.get(clientId) ?? Promise.resolve();
2583
+ inflight.catch(() => {}).then(() => this.transport.send(clientId, {
2584
+ type: "disconnect",
2585
+ reason: "Message queue overflow"
2586
+ }).catch(() => {})).then(() => this.transport.disconnect?.(clientId)).finally(() => {
2587
+ this.sessions.disconnect(clientId);
2588
+ this.messageQueues.delete(clientId);
2589
+ this.messageQueueDepths.delete(clientId);
2590
+ this.resultCache.clearClient(clientId);
2591
+ this.ephemeralBuckets.delete(clientId);
2592
+ });
2593
+ return;
2594
+ }
2595
+ this.messageQueueDepths.set(clientId, depth + 1);
2596
+ const prev = this.messageQueues.get(clientId) ?? Promise.resolve();
2597
+ const next = prev.then(() => this.handleMessage(clientId, message).catch((err) => {
2598
+ console.error(`Error handling message from ${clientId}:`, err);
2599
+ })).finally(() => {
2600
+ const current = this.messageQueueDepths.get(clientId);
2601
+ if (current !== undefined) {
2602
+ this.messageQueueDepths.set(clientId, current - 1);
2603
+ }
2604
+ });
2605
+ this.messageQueues.set(clientId, next);
2606
+ });
2607
+ this.ephemeralCleanupTimer = setInterval(() => {
2608
+ this.ephemeralManager.cleanupExpired();
2609
+ }, 5000);
2610
+ this.eagerBuffer.setOnFlushError((err, op) => {
2611
+ this.emit({
2612
+ type: "eager_flush_failed",
2613
+ table: op.entry.table,
2614
+ rowId: op.entry.rowId,
2615
+ error: err
2616
+ });
2617
+ });
2618
+ }
2619
+ setAuth(callback) {
2620
+ this.authCallback = callback;
2621
+ }
2622
+ setQuery(name, registration) {
2623
+ if (typeof registration.callback !== "function") {
2624
+ throw new TypeError(`[reflectdb] Query "${name}": callback must be a function`);
2625
+ }
2626
+ const opts = registration.options;
2627
+ if (opts.mutate !== undefined && typeof opts.mutate !== "function") {
2628
+ throw new TypeError(`[reflectdb] Query "${name}": options.mutate must be a function or undefined`);
2629
+ }
2630
+ if (opts.authorize !== undefined && typeof opts.authorize !== "function") {
2631
+ throw new TypeError(`[reflectdb] Query "${name}": options.authorize must be a function or undefined`);
2632
+ }
2633
+ this.queries.set(name, registration);
2634
+ for (const tableName of registration.tableDependencies) {
2635
+ let queryNames = this.tableDependencyIndex.get(tableName);
2636
+ if (!queryNames) {
2637
+ queryNames = new Set;
2638
+ this.tableDependencyIndex.set(tableName, queryNames);
2639
+ }
2640
+ queryNames.add(name);
2641
+ }
2642
+ if (registration.options.broadcast === "eager") {
2643
+ this.eagerBuffer.start(registration.options.flushInterval);
2644
+ if (!MessageHandler.eagerWarned) {
2645
+ MessageHandler.eagerWarned = true;
2646
+ console.warn(`[reflectdb] Query "${name}" uses broadcast: "eager" — at-most-once across server crash for the oplog. ` + `Use "eager-durable" unless your mutate hook writes durably to the user DB and you can tolerate resume-via-snapshot.`);
2647
+ }
2648
+ }
2649
+ }
2650
+ setRoom(registration) {
2651
+ this.rooms.push(registration);
2652
+ }
2653
+ setRateLimiter(limiter) {
2654
+ this.rateLimiter = limiter;
2655
+ }
2656
+ setEphemeralRateLimit(perSecond) {
2657
+ this.ephemeralPerSecond = perSecond;
2658
+ }
2659
+ setMaxBatchSize(size) {
2660
+ this.maxBatchSize = size;
2661
+ }
2662
+ setMaxEphemeralEntries(max) {
2663
+ this.ephemeralManager = new EphemeralManager(max);
2664
+ }
2665
+ setMinSchemaVersion(version) {
2666
+ this.minSchemaVersion = version;
2667
+ }
2668
+ setMaxConnectionsPerUser(max) {
2669
+ this.maxConnectionsPerUser = max;
2670
+ }
2671
+ async enforceConnectionCap(userId, newClientId) {
2672
+ if (!this.maxConnectionsPerUser || this.maxConnectionsPerUser <= 0)
2673
+ return;
2674
+ while (this.sessions.countForUser(userId) > this.maxConnectionsPerUser) {
2675
+ const oldest = this.sessions.oldestForUser(userId);
2676
+ if (!oldest || oldest.clientId === newClientId)
2677
+ break;
2678
+ await this.trySend(oldest.clientId, {
2679
+ type: "disconnect",
2680
+ reason: "Connection limit exceeded"
2681
+ });
2682
+ try {
2683
+ await this.transport.disconnect?.(oldest.clientId);
2684
+ } catch {}
2685
+ this.sessions.disconnect(oldest.clientId);
2686
+ this.messageQueues.delete(oldest.clientId);
2687
+ this.messageQueueDepths.delete(oldest.clientId);
2688
+ this.resultCache.clearClient(oldest.clientId);
2689
+ this.ephemeralManager.removeClient(oldest.clientId);
2690
+ this.ephemeralBuckets.delete(oldest.clientId);
2691
+ }
2692
+ }
2693
+ setStorage(adapter) {
2694
+ this.storage = ensureAtomicApplyOp(adapter);
2695
+ this.eagerBuffer.setStorage(this.storage);
2696
+ this.compaction.setStorage(this.storage);
2697
+ this.replay.setStorage(this.storage);
2698
+ this.clock.setStorage(this.storage);
2699
+ }
2700
+ async syncClockWatermark() {
2701
+ await this.clock.refresh();
2702
+ }
2703
+ getSessions() {
2704
+ return this.sessions;
2705
+ }
2706
+ getResultCache() {
2707
+ return this.resultCache;
2708
+ }
2709
+ async handleMessage(clientId, message) {
2710
+ await this.clock.ensureLoaded();
2711
+ if (message.type !== "hello" && message.type !== "auth") {
2712
+ const session = this.sessions.get(clientId);
2713
+ if (!session || !session.auth) {
2714
+ await this.trySend(clientId, {
2715
+ type: "disconnect",
2716
+ reason: "Not authenticated"
2717
+ });
2718
+ await this.transport.disconnect?.(clientId);
2719
+ return;
2720
+ }
2721
+ }
2722
+ switch (message.type) {
2723
+ case "hello":
2724
+ await this.handleHello(clientId, message);
2725
+ break;
2726
+ case "auth":
2727
+ await this.handleAuth(clientId, message);
2728
+ break;
2729
+ case "bootstrap":
2730
+ await this.handleBootstrap(clientId, message);
2731
+ break;
2732
+ case "resume":
2733
+ await this.handleResume(clientId, message);
2734
+ break;
2735
+ case "ops":
2736
+ await this.handleOps(clientId, message);
2737
+ break;
2738
+ case "sync_declare":
2739
+ await this.handleSyncDeclare(clientId, message);
2740
+ break;
2741
+ case "load_more":
2742
+ await this.handleLoadMore(clientId, message);
2743
+ break;
2744
+ case "unsync":
2745
+ await this.handleUnsync(clientId, message);
2746
+ break;
2747
+ case "ephemeral":
2748
+ await this.handleEphemeral(clientId, message);
2749
+ break;
2750
+ }
2751
+ }
2752
+ async handleHello(clientId, message) {
2753
+ const clientVersions = message.supportedVersions ?? [message.protocolVersion];
2754
+ const negotiated = [...SUPPORTED_PROTOCOL_VERSIONS].sort((a, b) => b - a).find((v) => clientVersions.includes(v));
2755
+ if (negotiated == null) {
2756
+ await this.trySend(clientId, {
2757
+ type: "hello_reject",
2758
+ reason: `Unsupported protocol version: ${message.protocolVersion}`,
2759
+ supported: [...SUPPORTED_PROTOCOL_VERSIONS]
2760
+ });
2761
+ return;
2762
+ }
2763
+ if (!this.authCallback && !this.allowAnonymous) {
2764
+ if (!MessageHandler.missingAuthWarned) {
2765
+ MessageHandler.missingAuthWarned = true;
2766
+ console.error("[reflectdb] No auth() callback registered. Every message after `hello` " + "would be rejected as unauthenticated. Call server.auth(fn), or pass " + "`allowAnonymous: true` to createServer for an unauthenticated server.");
2767
+ }
2768
+ await this.trySend(clientId, {
2769
+ type: "hello_reject",
2770
+ reason: "Server has no auth() configured",
2771
+ supported: [...SUPPORTED_PROTOCOL_VERSIONS]
2772
+ });
2773
+ await this.transport.disconnect?.(clientId);
2774
+ return;
2775
+ }
2776
+ if (this.authCallback) {
2777
+ try {
2778
+ const req = new Request("https://sync/hello", {
2779
+ headers: { authorization: `Bearer ${message.token}` }
2780
+ });
2781
+ const auth = await this.authCallback(req);
2782
+ this.sessions.setAuth(clientId, auth);
2783
+ await this.enforceConnectionCap(auth.userId, clientId);
2784
+ } catch (err) {
2785
+ console.error(`[reflectdb] Auth callback failed for client ${clientId}:`, err);
2786
+ this.emit({ type: "auth_failed", clientId, error: err });
2787
+ await this.trySend(clientId, {
2788
+ type: "hello_reject",
2789
+ reason: "Authentication failed",
2790
+ supported: [PROTOCOL_VERSION]
2791
+ });
2792
+ return;
2793
+ }
2794
+ } else {
2795
+ this.sessions.setAuth(clientId, { userId: `anon:${clientId}` });
2796
+ }
2797
+ await this.trySend(clientId, {
2798
+ type: "hello_ack",
2799
+ protocolVersion: negotiated,
2800
+ serverId: this.serverId
2801
+ });
2802
+ }
2803
+ async handleAuth(clientId, message) {
2804
+ if (!this.authCallback)
2805
+ return;
2806
+ try {
2807
+ const req = new Request("https://sync/auth", {
2808
+ headers: { authorization: `Bearer ${message.token}` }
2809
+ });
2810
+ const auth = await this.authCallback(req);
2811
+ this.sessions.setAuth(clientId, auth);
2812
+ await this.enforceConnectionCap(auth.userId, clientId);
2813
+ } catch (err) {
2814
+ console.error(`[reflectdb] Auth callback failed for client ${clientId} (re-auth):`, err);
2815
+ this.emit({ type: "auth_failed", clientId, error: err });
2816
+ await this.trySend(clientId, {
2817
+ type: "disconnect",
2818
+ reason: "Authentication failed"
2819
+ });
2820
+ await this.transport.disconnect?.(clientId);
2821
+ }
2822
+ }
2823
+ async handleBootstrap(clientId, _message) {
2824
+ const session = this.sessions.get(clientId);
2825
+ if (!session)
2826
+ return;
2827
+ await this.eagerBuffer.flush();
2828
+ for (const [queryName, sub] of session.subscriptions) {
2829
+ const queryReg = this.queries.get(queryName);
2830
+ if (!queryReg)
2831
+ continue;
2832
+ const isWindowed = sub.windowSize != null && sub.windowSize > 0;
2833
+ let rows;
2834
+ let totalCount;
2835
+ try {
2836
+ if (isWindowed) {
2837
+ const windowed = await this.fetchWindow(queryReg, session, sub.windowLimit);
2838
+ rows = windowed.rows;
2839
+ totalCount = windowed.totalCount;
2840
+ sub.loadedCount = rows.length;
2841
+ sub.lastTotalCount = totalCount;
2842
+ } else {
2843
+ rows = await this.executeQuery(queryReg, session);
2844
+ }
2845
+ } catch (err) {
2846
+ console.error(`[reflectdb] Query "${queryName}" failed during bootstrap for client ${clientId}:`, err);
2847
+ rows = [];
2848
+ }
2849
+ const pk = queryReg.options.pk ?? "id";
2850
+ const colClocks = {};
2851
+ const hlcStr2 = this.clock.packed();
2852
+ for (const row of rows) {
2853
+ const rowId = String(row[pk] ?? "");
2854
+ if (rowId) {
2855
+ colClocks[rowId] = { _row: hlcStr2 };
2856
+ }
2857
+ }
2858
+ const delivered = await this.trySend(clientId, {
2859
+ type: "snapshot",
2860
+ table: queryName,
2861
+ rows,
2862
+ colClocks,
2863
+ totalCount,
2864
+ pk
2865
+ });
2866
+ if (delivered) {
2867
+ this.resultCache.set(clientId, queryName, rows, pk);
2868
+ sub.bootstrapped = true;
2869
+ }
2870
+ }
2871
+ const tableMeta = {};
2872
+ for (const [queryName] of session.subscriptions) {
2873
+ const queryReg = this.queries.get(queryName);
2874
+ tableMeta[queryName] = {
2875
+ serverSet: queryReg?.options.serverSet ? Object.keys(queryReg.options.serverSet) : [],
2876
+ readonly: queryReg?.options.readonly ?? [],
2877
+ broadcast: queryReg?.options.broadcast ?? "consistent",
2878
+ pk: queryReg?.options.pk ?? "id"
2879
+ };
2880
+ }
2881
+ const hlcStr = this.clock.stamp();
2882
+ this.sessions.updateWatermark(clientId, hlcStr);
2883
+ this.emit({ type: "bootstrap", clientId, queries: session.subscriptions.size });
2884
+ await this.trySend(clientId, {
2885
+ type: "bootstrap_complete",
2886
+ serverHlc: hlcStr,
2887
+ tableMeta
2888
+ });
2889
+ }
2890
+ async executeQuery(queryReg, session, limit) {
2891
+ let rows;
2892
+ if (this.db) {
2893
+ const sub = session.subscriptions.get(queryReg.name);
2894
+ const ctx = { auth: session.auth, params: sub?.params ?? {}, limit };
2895
+ const query = queryReg.callback(ctx, this.db);
2896
+ const result = await query;
2897
+ rows = Array.isArray(result) ? result : [];
2898
+ } else {
2899
+ rows = [];
2900
+ }
2901
+ if (limit != null && rows.length > limit) {
2902
+ return rows.slice(0, limit);
2903
+ }
2904
+ return rows;
2905
+ }
2906
+ async countQuery(queryReg, session) {
2907
+ const countFn = queryReg.options.count;
2908
+ if (!countFn || !this.db)
2909
+ return null;
2910
+ const sub = session.subscriptions.get(queryReg.name);
2911
+ const ctx = { auth: session.auth, params: sub?.params ?? {} };
2912
+ return await countFn(ctx, this.db);
2913
+ }
2914
+ async fetchWindow(queryReg, session, windowSize) {
2915
+ if (queryReg.options.count) {
2916
+ const [rows, counted] = await Promise.all([
2917
+ this.executeQuery(queryReg, session, windowSize),
2918
+ this.countQuery(queryReg, session)
2919
+ ]);
2920
+ return { rows, totalCount: counted ?? rows.length };
2921
+ }
2922
+ const allRows = await this.executeQuery(queryReg, session);
2923
+ return { rows: allRows.slice(0, windowSize), totalCount: allRows.length };
2924
+ }
2925
+ async handleResume(clientId, message) {
2926
+ const session = this.sessions.get(clientId);
2927
+ if (!session)
2928
+ return;
2929
+ await this.eagerBuffer.flush();
2930
+ await this.compaction.ensureCutoffLoaded();
2931
+ const cutoffHlc = this.compaction.getCutoff();
2932
+ if (cutoffHlc && message.since < cutoffHlc) {
2933
+ await this.trySend(clientId, {
2934
+ type: "resume_rejected",
2935
+ reason: "compacted",
2936
+ serverHlc: this.clock.peekNext()
2937
+ });
2938
+ return;
2939
+ }
2940
+ const changedDbTables = new Set;
2941
+ if (this.storage) {
2942
+ const allDepTablesSet = new Set;
2943
+ for (const [queryName] of session.subscriptions) {
2944
+ const reg = this.queries.get(queryName);
2945
+ if (reg) {
2946
+ for (const dep of reg.tableDependencies) {
2947
+ allDepTablesSet.add(dep);
2948
+ }
2949
+ }
2950
+ }
2951
+ const allDepTables = [...allDepTablesSet];
2952
+ try {
2953
+ if (this.storage.getChangedTablesSince) {
2954
+ const tables = await this.storage.getChangedTablesSince(message.since, allDepTables);
2955
+ for (const t of tables)
2956
+ changedDbTables.add(t);
2957
+ } else {
2958
+ const ops = await this.storage.getOpsSince(message.since, allDepTables);
2959
+ for (const op of ops)
2960
+ changedDbTables.add(op.table);
2961
+ }
2962
+ } catch {
2963
+ for (const t of allDepTables)
2964
+ changedDbTables.add(t);
2965
+ }
2966
+ }
2967
+ const hlcStr = this.clock.packed();
2968
+ for (const [queryName, sub] of session.subscriptions) {
2969
+ const queryReg = this.queries.get(queryName);
2970
+ if (!queryReg)
2971
+ continue;
2972
+ const hasChanges = changedDbTables.size === 0 || [...queryReg.tableDependencies].some((t) => changedDbTables.has(t));
2973
+ if (!hasChanges)
2974
+ continue;
2975
+ const isWindowed = sub.windowSize != null && sub.windowSize > 0;
2976
+ try {
2977
+ let rows;
2978
+ let totalCount;
2979
+ if (isWindowed) {
2980
+ const windowed = await this.fetchWindow(queryReg, session, sub.windowLimit);
2981
+ rows = windowed.rows;
2982
+ totalCount = windowed.totalCount;
2983
+ sub.loadedCount = rows.length;
2984
+ sub.lastTotalCount = totalCount;
2985
+ } else {
2986
+ rows = await this.executeQuery(queryReg, session);
2987
+ }
2988
+ const pk = queryReg.options.pk ?? "id";
2989
+ const colClocks = {};
2990
+ for (const row of rows) {
2991
+ const rowId = String(row[pk] ?? "");
2992
+ if (rowId)
2993
+ colClocks[rowId] = { _row: hlcStr };
2994
+ }
2995
+ const delivered = await this.trySend(clientId, {
2996
+ type: "snapshot",
2997
+ table: queryName,
2998
+ rows,
2999
+ colClocks,
3000
+ totalCount,
3001
+ pk
3002
+ });
3003
+ if (delivered) {
3004
+ this.resultCache.set(clientId, queryName, rows, pk);
3005
+ sub.bootstrapped = true;
3006
+ }
3007
+ } catch (err) {
3008
+ console.error(`[reflectdb] Query "${queryName}" failed during resume for client ${clientId}:`, err);
3009
+ }
3010
+ }
3011
+ const resumeHlc = this.clock.stamp();
3012
+ this.sessions.updateWatermark(clientId, resumeHlc);
3013
+ this.emit({
3014
+ type: "resume",
3015
+ clientId,
3016
+ queries: session.subscriptions.size,
3017
+ tablesChanged: changedDbTables.size
3018
+ });
3019
+ await this.trySend(clientId, {
3020
+ type: "resume_complete",
3021
+ serverHlc: resumeHlc
3022
+ });
3023
+ }
3024
+ async handleOps(clientId, message) {
3025
+ const session = this.sessions.get(clientId);
3026
+ if (!session)
3027
+ return;
3028
+ if (this.authCallback) {
3029
+ try {
3030
+ const cached = this.authCache.get(message.token);
3031
+ let auth;
3032
+ const now = Date.now();
3033
+ if (cached && cached.expiresAt > now) {
3034
+ auth = cached.auth;
3035
+ } else {
3036
+ const req = new Request("https://sync/ops", {
3037
+ headers: { authorization: `Bearer ${message.token}` }
3038
+ });
3039
+ auth = await this.authCallback(req);
3040
+ this.authCache.set(message.token, {
3041
+ auth,
3042
+ expiresAt: now + MessageHandler.AUTH_CACHE_TTL_MS
3043
+ });
3044
+ if (this.authCache.size > 1000) {
3045
+ for (const [k, v] of this.authCache) {
3046
+ if (v.expiresAt <= now)
3047
+ this.authCache.delete(k);
3048
+ }
3049
+ if (this.authCache.size > 1000) {
3050
+ const drop = this.authCache.size - 1000;
3051
+ const iter = this.authCache.keys();
3052
+ for (let n = 0;n < drop; n++) {
3053
+ const k = iter.next().value;
3054
+ if (k === undefined)
3055
+ break;
3056
+ this.authCache.delete(k);
3057
+ }
3058
+ }
3059
+ }
3060
+ }
3061
+ this.sessions.setAuth(clientId, auth);
3062
+ } catch (err) {
3063
+ console.error(`[reflectdb] Auth callback failed for client ${clientId} (ops push):`, err);
3064
+ this.emit({ type: "auth_failed", clientId, error: err });
3065
+ await this.trySend(clientId, {
3066
+ type: "reauth"
3067
+ });
3068
+ return;
3069
+ }
3070
+ }
3071
+ const accepted = [];
3072
+ const rejected = [];
3073
+ const failedBatchIds = new Map;
3074
+ const deferredBroadcastTables = new Set;
3075
+ const batch = await this.ops.prefetchBatch(message.ops);
3076
+ for (const op of message.ops) {
3077
+ if (op.batchId && failedBatchIds.has(op.batchId)) {
3078
+ rejected.push({
3079
+ opId: op.id,
3080
+ batchId: op.batchId,
3081
+ reason: failedBatchIds.get(op.batchId)
3082
+ });
3083
+ continue;
3084
+ }
3085
+ const result = await this.ops.processClientOp(op, session, deferredBroadcastTables, batch);
3086
+ if (result.accepted) {
3087
+ accepted.push(op.id);
3088
+ } else {
3089
+ const reason = result.reason ?? "server_error";
3090
+ rejected.push({
3091
+ opId: op.id,
3092
+ batchId: op.batchId,
3093
+ reason,
3094
+ serverRow: result.serverRow
3095
+ });
3096
+ if (op.batchId) {
3097
+ failedBatchIds.set(op.batchId, reason);
3098
+ }
3099
+ }
3100
+ }
3101
+ for (const table of deferredBroadcastTables) {
3102
+ await this.broadcastChanges(table, session.clientId);
3103
+ }
3104
+ this.emit({
3105
+ type: "ops_processed",
3106
+ clientId,
3107
+ accepted: accepted.length,
3108
+ rejected: rejected.length
3109
+ });
3110
+ if (accepted.length > 0) {
3111
+ await this.trySend(clientId, {
3112
+ type: "ack",
3113
+ opIds: accepted
3114
+ });
3115
+ }
3116
+ if (rejected.length > 0) {
3117
+ await Promise.all(rejected.map((rej) => this.trySend(clientId, {
3118
+ type: "reject",
3119
+ opId: rej.opId,
3120
+ batchId: rej.batchId,
3121
+ reason: rej.reason,
3122
+ serverRow: rej.serverRow ?? undefined
3123
+ })));
3124
+ }
3125
+ }
3126
+ async broadcastChanges(tableName, excludeClientId, overrideRoomKey) {
3127
+ await this.broadcast.broadcastChanges(tableName, excludeClientId, overrideRoomKey);
3128
+ }
3129
+ async handleSyncDeclare(clientId, message) {
3130
+ const session = this.sessions.get(clientId);
3131
+ if (!session)
3132
+ return;
3133
+ const queryReg = this.queries.get(message.table);
3134
+ if (!queryReg) {
3135
+ await this.trySend(clientId, {
3136
+ type: "reject",
3137
+ opId: `sync_declare:${message.table}`,
3138
+ reason: "unknown_query"
3139
+ });
3140
+ return;
3141
+ }
3142
+ const resolved = resolveRoomKey(this.rooms, message.params ?? {}, queryReg.options.room);
3143
+ if (!resolved.ok) {
3144
+ console.warn(`[reflectdb] sync_declare for "${message.table}" from ${clientId} rejected: ${resolved.reason}`);
3145
+ await this.trySend(clientId, {
3146
+ type: "reject",
3147
+ opId: `sync_declare:${message.table}`,
3148
+ reason: "outside_shape"
3149
+ });
3150
+ return;
3151
+ }
3152
+ const roomKey = resolved.roomKey;
3153
+ if (roomKey) {
3154
+ for (const room of this.rooms) {
3155
+ const match = room.regex.exec(roomKey);
3156
+ if (match) {
3157
+ const params = {};
3158
+ for (let i = 0;i < room.paramNames.length; i++) {
3159
+ params[room.paramNames[i]] = match[i + 1];
3160
+ }
3161
+ const cbResult = await room.callback({ params, auth: session.auth });
3162
+ if (cbResult && !cbResult.ok) {
3163
+ await this.trySend(clientId, {
3164
+ type: "disconnect",
3165
+ reason: cbResult.reason ?? "Room access denied"
3166
+ });
3167
+ await this.transport.disconnect?.(clientId);
3168
+ return;
3169
+ }
3170
+ break;
3171
+ }
3172
+ }
3173
+ }
3174
+ if (queryReg.options.authorize) {
3175
+ const ctx = {
3176
+ auth: session.auth,
3177
+ params: message.params ?? {}
3178
+ };
3179
+ try {
3180
+ await queryReg.options.authorize({ type: "read", table: message.table, params: message.params ?? {} }, ctx, this.db);
3181
+ } catch (err) {
3182
+ const reason = reasonFromError(err);
3183
+ await this.trySend(clientId, {
3184
+ type: "reject",
3185
+ opId: `sync_declare:${message.table}`,
3186
+ reason
3187
+ });
3188
+ return;
3189
+ }
3190
+ }
3191
+ this.sessions.subscribe(clientId, message.table, message.params ?? {}, queryReg.options, roomKey, message.window ?? null);
3192
+ }
3193
+ async handleLoadMore(clientId, message) {
3194
+ const session = this.sessions.get(clientId);
3195
+ if (!session)
3196
+ return;
3197
+ const sub = session.subscriptions.get(message.table);
3198
+ if (!sub || sub.windowSize == null)
3199
+ return;
3200
+ const queryReg = this.queries.get(message.table);
3201
+ if (!queryReg)
3202
+ return;
3203
+ const prevLoaded = sub.loadedCount;
3204
+ const newLimit = sub.windowLimit + message.count;
3205
+ let totalCount = 0;
3206
+ let rows;
3207
+ try {
3208
+ if (queryReg.options.countHints && !queryReg.options.count) {
3209
+ const allRows = await this.executeQuery(queryReg, session);
3210
+ rows = allRows.slice(0, newLimit);
3211
+ totalCount = allRows.length;
3212
+ } else {
3213
+ rows = await this.executeQuery(queryReg, session, newLimit);
3214
+ if (queryReg.options.countHints) {
3215
+ totalCount = await this.countQuery(queryReg, session) ?? rows.length;
3216
+ }
3217
+ }
3218
+ } catch (err) {
3219
+ console.error(`[reflectdb] Query "${message.table}" failed during loadMore for client ${clientId}:`, err);
3220
+ return;
3221
+ }
3222
+ const newRows = rows.slice(prevLoaded);
3223
+ const pk = queryReg.options.pk ?? "id";
3224
+ let delivered = true;
3225
+ if (newRows.length > 0) {
3226
+ const colClocks = {};
3227
+ const hlcStr = this.clock.packed();
3228
+ for (const row of newRows) {
3229
+ const rowId = String(row[pk] ?? "");
3230
+ if (rowId) {
3231
+ colClocks[rowId] = { _row: hlcStr };
3232
+ }
3233
+ }
3234
+ delivered = await this.trySend(clientId, {
3235
+ type: "snapshot",
3236
+ table: message.table,
3237
+ rows: newRows,
3238
+ colClocks,
3239
+ append: true,
3240
+ pk
3241
+ });
3242
+ }
3243
+ if (delivered) {
3244
+ this.resultCache.set(clientId, message.table, rows, pk);
3245
+ sub.loadedCount = rows.length;
3246
+ sub.windowLimit = newLimit;
3247
+ }
3248
+ if (queryReg.options.countHints) {
3249
+ if (totalCount !== sub.lastTotalCount) {
3250
+ const countDelivered = await this.trySend(clientId, {
3251
+ type: "count_changed",
3252
+ table: message.table,
3253
+ totalCount
3254
+ });
3255
+ if (countDelivered)
3256
+ sub.lastTotalCount = totalCount;
3257
+ }
3258
+ }
3259
+ }
3260
+ async handleUnsync(clientId, message) {
3261
+ this.sessions.unsubscribe(clientId, message.table);
3262
+ this.resultCache.clear(clientId, message.table);
3263
+ }
3264
+ allowEphemeral(clientId) {
3265
+ if (this.ephemeralPerSecond <= 0)
3266
+ return true;
3267
+ const now = Date.now();
3268
+ const bucket = this.ephemeralBuckets.get(clientId);
3269
+ if (!bucket || now >= bucket.resetAt) {
3270
+ this.ephemeralBuckets.set(clientId, { count: 1, resetAt: now + 1000 });
3271
+ return true;
3272
+ }
3273
+ if (bucket.count >= this.ephemeralPerSecond)
3274
+ return false;
3275
+ bucket.count++;
3276
+ return true;
3277
+ }
3278
+ async handleEphemeral(clientId, message) {
3279
+ const session = this.sessions.get(clientId);
3280
+ if (!session)
3281
+ return;
3282
+ if (!this.allowEphemeral(clientId)) {
3283
+ this.emit({ type: "ephemeral_rate_limited", clientId });
3284
+ return;
3285
+ }
3286
+ const { key, data } = message;
3287
+ const ttlMs = typeof message.ttlMs === "number" && message.ttlMs > 0 ? Math.min(message.ttlMs, MessageHandler.MAX_EPHEMERAL_TTL_MS) : undefined;
3288
+ const userId = session.auth.userId;
3289
+ const roomKeys = new Set;
3290
+ const queryNames = new Set;
3291
+ for (const [queryName, sub] of session.subscriptions) {
3292
+ queryNames.add(queryName);
3293
+ if (sub.roomKey) {
3294
+ roomKeys.add(sub.roomKey);
3295
+ }
3296
+ }
3297
+ const event = {
3298
+ type: "ephemeral",
3299
+ key,
3300
+ clientId,
3301
+ userId,
3302
+ data,
3303
+ ttlMs
3304
+ };
3305
+ const sent = new Set;
3306
+ const recipients = [];
3307
+ for (const roomKey of roomKeys) {
3308
+ const accepted = this.ephemeralManager.set(roomKey, key, clientId, userId, data, ttlMs);
3309
+ if (!accepted) {
3310
+ this.emit({ type: "ephemeral_full", currentSize: this.ephemeralManager.size });
3311
+ return;
3312
+ }
3313
+ }
3314
+ for (const queryName of queryNames) {
3315
+ const senderSub = session.subscriptions.get(queryName);
3316
+ const senderRoomKey = senderSub?.roomKey ?? null;
3317
+ const subscribers = this.sessions.getSubscribersForQuery(queryName, senderRoomKey);
3318
+ for (const sub of subscribers) {
3319
+ if (sub.clientId !== clientId && !sent.has(sub.clientId)) {
3320
+ sent.add(sub.clientId);
3321
+ recipients.push(sub.clientId);
3322
+ }
3323
+ }
3324
+ }
3325
+ await Promise.all(recipients.map((recipient) => this.trySend(recipient, event)));
3326
+ }
3327
+ async reserveOpId(opId) {
3328
+ return this.replay.reserve(opId);
3329
+ }
3330
+ async runCompaction(minOpAge) {
3331
+ return this.compaction.runCompaction(minOpAge);
3332
+ }
3333
+ getEagerBuffer() {
3334
+ return this.eagerBuffer;
3335
+ }
3336
+ getTrackedTables() {
3337
+ return [...this.tableDependencyIndex.keys()];
3338
+ }
3339
+ async pollRemoteChanges() {
3340
+ await this.clock.refresh();
3341
+ const tables = this.getTrackedTables();
3342
+ if (tables.length === 0)
3343
+ return;
3344
+ const storage = this.storage;
3345
+ if (!storage?.getOplogHead || !storage.getChangedTablesSince) {
3346
+ await this.notifyAll(tables);
3347
+ return;
3348
+ }
3349
+ const head = await storage.getOplogHead(tables);
3350
+ if (this.pollWatermark === null) {
3351
+ this.pollWatermark = head ?? "";
3352
+ return;
3353
+ }
3354
+ if (head === null || head <= this.pollWatermark)
3355
+ return;
3356
+ const changed = await storage.getChangedTablesSince(this.pollWatermark, tables);
3357
+ this.pollWatermark = head;
3358
+ await this.notifyAll(changed);
3359
+ }
3360
+ async notifyAll(tables) {
3361
+ const results = await Promise.allSettled(tables.map((table) => this.notifyChange(table)));
3362
+ for (let i = 0;i < results.length; i++) {
3363
+ const r = results[i];
3364
+ if (r.status === "rejected") {
3365
+ console.error(`[reflectdb] poll notifyChange for "${tables[i]}" failed:`, r.reason);
3366
+ }
3367
+ }
3368
+ }
3369
+ async notifyChange(tableName, roomKey) {
3370
+ await this.clock.ensureLoaded();
3371
+ await this.broadcastChanges(tableName, "", roomKey);
3372
+ }
3373
+ async applyServerOp(op, execute, options) {
3374
+ await this.clock.ensureLoaded();
3375
+ let hlcStr = this.clock.stamp();
3376
+ let resolvedRow;
3377
+ const colClocks = {};
3378
+ let existingRow = null;
3379
+ let existingColClocks = {};
3380
+ if (this.storage) {
3381
+ const existing = await this.storage.getRow(op.table, op.rowId);
3382
+ existingRow = existing.row;
3383
+ existingColClocks = existing.colClocks;
3384
+ if (existing.rowHlc && hlcStr <= existing.rowHlc) {
3385
+ try {
3386
+ this.clock.receive(existing.rowHlc);
3387
+ hlcStr = this.clock.packed();
3388
+ } catch {}
3389
+ }
3390
+ }
3391
+ if (op.type === "delete" || op.payload === null) {
3392
+ resolvedRow = null;
3393
+ for (const key of Object.keys(existingColClocks)) {
3394
+ colClocks[key] = hlcStr;
3395
+ }
3396
+ } else if (op.type === "insert") {
3397
+ resolvedRow = { ...op.payload };
3398
+ for (const key of Object.keys(op.payload)) {
3399
+ colClocks[key] = hlcStr;
3400
+ }
3401
+ } else {
3402
+ const base = existingRow ?? {};
3403
+ resolvedRow = { ...base, ...op.payload };
3404
+ for (const [k, v] of Object.entries(existingColClocks)) {
3405
+ if (k !== "_row")
3406
+ colClocks[k] = v;
3407
+ }
3408
+ for (const key of Object.keys(op.payload)) {
3409
+ colClocks[key] = hlcStr;
3410
+ }
3411
+ }
3412
+ colClocks._row = hlcStr;
3413
+ if (execute) {
3414
+ await execute({ ...op, hlc: hlcStr });
3415
+ }
3416
+ if (this.storage) {
3417
+ await this.storage.applyOp(op.table, op.rowId, resolvedRow, colClocks, hlcStr, op.type, op.payload);
3418
+ }
3419
+ await this.broadcastChanges(op.table, "", options?.roomKey);
3420
+ return { hlc: hlcStr, resolvedRow };
3421
+ }
3422
+ async close() {
3423
+ if (this.ephemeralCleanupTimer) {
3424
+ clearInterval(this.ephemeralCleanupTimer);
3425
+ }
3426
+ await this.clock.close();
3427
+ const inflight = [...this.messageQueues.values()];
3428
+ if (inflight.length > 0) {
3429
+ await Promise.allSettled(inflight);
3430
+ }
3431
+ this.messageQueues.clear();
3432
+ this.messageQueueDepths.clear();
3433
+ this.ephemeralManager.destroy();
3434
+ await this.eagerBuffer.close();
3435
+ }
3436
+ }
3437
+
3438
+ // src/server/server.ts
3439
+ var drizzleHelpersPromise;
3440
+ function loadDrizzleHelpers() {
3441
+ if (drizzleHelpersPromise)
3442
+ return drizzleHelpersPromise;
3443
+ drizzleHelpersPromise = (async () => {
3444
+ try {
3445
+ const mod = await import("drizzle-orm");
3446
+ return {
3447
+ getTableName: mod.getTableName,
3448
+ sql: mod.sql
3449
+ };
3450
+ } catch {
3451
+ return null;
3452
+ }
3453
+ })();
3454
+ return drizzleHelpersPromise;
3455
+ }
3456
+ function makeDrizzleFallbackAtomic(helpers) {
3457
+ return {
3458
+ async begin(db) {
3459
+ const handle = db;
3460
+ if (typeof handle?.run !== "function") {
3461
+ throw new Error("server.tx({ atomic: true }) requires either a drizzle db with run() or a `txAtomic` adapter on server config");
3462
+ }
3463
+ await Promise.resolve(handle.run(helpers.sql`BEGIN`));
3464
+ },
3465
+ async commit(db) {
3466
+ const handle = db;
3467
+ await Promise.resolve(handle.run(helpers.sql`COMMIT`));
3468
+ },
3469
+ async rollback(db) {
3470
+ const handle = db;
3471
+ await Promise.resolve(handle.run(helpers.sql`ROLLBACK`));
3472
+ }
3473
+ };
3474
+ }
3475
+ function buildRoomRegex(pattern) {
3476
+ const paramNames = [];
3477
+ let regexStr = "";
3478
+ let lastIndex = 0;
3479
+ const paramPattern = /:(\w+)/g;
3480
+ let match = paramPattern.exec(pattern);
3481
+ while (match !== null) {
3482
+ regexStr += escapeRegex(pattern.slice(lastIndex, match.index));
3483
+ paramNames.push(match[1]);
3484
+ regexStr += "([^/]+)";
3485
+ lastIndex = match.index + match[0].length;
3486
+ match = paramPattern.exec(pattern);
3487
+ }
3488
+ regexStr += escapeRegex(pattern.slice(lastIndex));
3489
+ return { regex: new RegExp(`^${regexStr}$`), paramNames };
3490
+ }
3491
+ function escapeRegex(str) {
3492
+ return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
3493
+ }
3494
+ function createServer(config) {
3495
+ const handler = new MessageHandler({
3496
+ transport: config.transport,
3497
+ serverId: config.serverId ?? crypto.randomUUID(),
3498
+ db: config.db,
3499
+ onEvent: config.onEvent,
3500
+ maxConnectionsPerUser: config.maxConnectionsPerUser,
3501
+ allowAnonymous: config.allowAnonymous,
3502
+ queryTimeoutMs: config.queryTimeoutMs,
3503
+ maxBroadcastConcurrency: config.maxBroadcastConcurrency
3504
+ });
3505
+ if (config.storage) {
3506
+ handler.setStorage(config.storage);
3507
+ }
3508
+ let compactionConfig = null;
3509
+ let pollTimer = null;
3510
+ const lockChains = new Map;
3511
+ const activeTimers = new Set;
3512
+ if (config.poll && config.poll > 0) {
3513
+ let polling = false;
3514
+ pollTimer = setInterval(() => {
3515
+ if (polling)
3516
+ return;
3517
+ polling = true;
3518
+ (async () => {
3519
+ try {
3520
+ await handler.pollRemoteChanges();
3521
+ } catch (err) {
3522
+ console.error("[reflectdb] poll failed:", err);
3523
+ } finally {
3524
+ polling = false;
3525
+ }
3526
+ })();
3527
+ }, config.poll);
3528
+ }
3529
+ const server = {
3530
+ auth(callback) {
3531
+ handler.setAuth(callback);
3532
+ },
3533
+ query(name, callback, options) {
3534
+ const tableDependencies = new Set(options.tables);
3535
+ if (options?.broadcast === "eager" || options?.broadcast === "eager-durable") {
3536
+ if (options.conflict) {
3537
+ console.warn(`[reflectdb] Query "${name}" has broadcast: "${options.broadcast}" — conflict: "${typeof options.conflict === "string" ? options.conflict : "custom"}" will be ignored (conflict resolution is skipped for eager queries; writes apply last-writer-wins)`);
3538
+ }
3539
+ }
3540
+ handler.setQuery(name, {
3541
+ name,
3542
+ callback,
3543
+ tableDependencies,
3544
+ options
3545
+ });
3546
+ },
3547
+ room(pattern, callback) {
3548
+ const { regex, paramNames } = buildRoomRegex(pattern);
3549
+ handler.setRoom({
3550
+ pattern,
3551
+ regex,
3552
+ paramNames,
3553
+ callback
3554
+ });
3555
+ },
3556
+ rateLimit(cfg) {
3557
+ handler.setRateLimiter(createRateLimiter(cfg));
3558
+ if (cfg.ephemeralPerSecond !== undefined) {
3559
+ handler.setEphemeralRateLimit(cfg.ephemeralPerSecond);
3560
+ }
3561
+ },
3562
+ compaction(cfg) {
3563
+ compactionConfig = cfg;
3564
+ },
3565
+ minSchemaVersion(version) {
3566
+ handler.setMinSchemaVersion(version);
3567
+ },
3568
+ async runCompaction() {
3569
+ if (!compactionConfig)
3570
+ return;
3571
+ const minOpAge = parseDuration(compactionConfig.minOpAge);
3572
+ await handler.runCompaction(minOpAge);
3573
+ },
3574
+ async notifyChange(tableName, roomKey) {
3575
+ await handler.notifyChange(tableName, roomKey);
3576
+ },
3577
+ async reserveOpId(opId) {
3578
+ return handler.reserveOpId(opId);
3579
+ },
3580
+ async applyServerOp(op, execute, options) {
3581
+ return handler.applyServerOp(op, execute, options);
3582
+ },
3583
+ async emit(table, payload, options) {
3584
+ const type = options?.type ?? "insert";
3585
+ const rowId = options?.rowId ?? (payload.id ?? crypto.randomUUID());
3586
+ const result = await handler.applyServerOp({ type, table, rowId, payload }, undefined, options?.roomKey !== undefined ? { roomKey: options.roomKey } : undefined);
3587
+ return { hlc: result.hlc, rowId };
3588
+ },
3589
+ tx(arg1, arg2) {
3590
+ const rawOpts = typeof arg1 === "function" ? {} : arg1;
3591
+ const opts = { atomic: true, ...rawOpts };
3592
+ const fn = typeof arg1 === "function" ? arg1 : arg2;
3593
+ if (config.db == null) {
3594
+ return Promise.reject(new Error("server.tx requires a db on the server config"));
3595
+ }
3596
+ const touched = new Set;
3597
+ const wrap = (target, getTableNameFn) => new Proxy(target, {
3598
+ get(t, prop, receiver) {
3599
+ if (prop === "touch") {
3600
+ return (name) => {
3601
+ touched.add(name);
3602
+ };
3603
+ }
3604
+ const value = Reflect.get(t, prop, receiver);
3605
+ if ((prop === "insert" || prop === "update" || prop === "delete") && typeof value === "function") {
3606
+ return (table, ...rest) => {
3607
+ if (getTableNameFn) {
3608
+ try {
3609
+ touched.add(getTableNameFn(table));
3610
+ } catch {}
3611
+ }
3612
+ return value.call(t, table, ...rest);
3613
+ };
3614
+ }
3615
+ return typeof value === "function" ? value.bind(t) : value;
3616
+ }
3617
+ });
3618
+ const run = async () => {
3619
+ const drizzleHelpers = await loadDrizzleHelpers();
3620
+ const getTableNameFn = drizzleHelpers?.getTableName;
3621
+ let atomicAdapter;
3622
+ if (opts.atomic && typeof opts.atomic === "object") {
3623
+ atomicAdapter = opts.atomic;
3624
+ } else if (opts.atomic === true) {
3625
+ if (config.txAtomic) {
3626
+ atomicAdapter = config.txAtomic;
3627
+ } else if (drizzleHelpers) {
3628
+ atomicAdapter = makeDrizzleFallbackAtomic(drizzleHelpers);
3629
+ } else {
3630
+ throw new Error("server.tx({ atomic: true }) requires either a drizzle db (install `drizzle-orm`) or a `txAtomic` adapter on server config");
3631
+ }
3632
+ }
3633
+ let result;
3634
+ if (atomicAdapter) {
3635
+ await atomicAdapter.begin(config.db);
3636
+ try {
3637
+ result = await fn(wrap(config.db, getTableNameFn));
3638
+ await atomicAdapter.commit(config.db);
3639
+ } catch (err) {
3640
+ await atomicAdapter.rollback(config.db);
3641
+ throw err;
3642
+ }
3643
+ } else {
3644
+ result = await fn(wrap(config.db, getTableNameFn));
3645
+ }
3646
+ for (const t of touched) {
3647
+ await server.notifyChange(t);
3648
+ }
3649
+ return result;
3650
+ };
3651
+ return run();
3652
+ },
3653
+ lock(key, fn) {
3654
+ const prev = lockChains.get(key) ?? Promise.resolve();
3655
+ const next = prev.then(() => fn(), () => fn());
3656
+ const stored = next.then(() => {
3657
+ if (lockChains.get(key) === stored)
3658
+ lockChains.delete(key);
3659
+ }, () => {
3660
+ if (lockChains.get(key) === stored)
3661
+ lockChains.delete(key);
3662
+ });
3663
+ lockChains.set(key, stored);
3664
+ return next;
3665
+ },
3666
+ tryLock(key, fn) {
3667
+ if (lockChains.has(key)) {
3668
+ return Promise.resolve(null);
3669
+ }
3670
+ return server.lock(key, fn);
3671
+ },
3672
+ interval(ms, fn) {
3673
+ const handle = setInterval(() => {
3674
+ try {
3675
+ const result = fn();
3676
+ if (result && typeof result.then === "function") {
3677
+ result.catch((err) => {
3678
+ console.error("[reflectdb] interval error:", err);
3679
+ });
3680
+ }
3681
+ } catch (err) {
3682
+ console.error("[reflectdb] interval error:", err);
3683
+ }
3684
+ }, ms);
3685
+ activeTimers.add(handle);
3686
+ const ctrl = {
3687
+ clear() {
3688
+ clearInterval(handle);
3689
+ activeTimers.delete(handle);
3690
+ }
3691
+ };
3692
+ registerHotDispose(() => ctrl.clear());
3693
+ return ctrl;
3694
+ },
3695
+ timeout(ms, fn) {
3696
+ let done = false;
3697
+ const handle = setTimeout(() => {
3698
+ done = true;
3699
+ activeTimers.delete(handle);
3700
+ try {
3701
+ const result = fn();
3702
+ if (result && typeof result.then === "function") {
3703
+ result.catch((err) => {
3704
+ console.error("[reflectdb] timeout error:", err);
3705
+ });
3706
+ }
3707
+ } catch (err) {
3708
+ console.error("[reflectdb] timeout error:", err);
3709
+ }
3710
+ }, ms);
3711
+ activeTimers.add(handle);
3712
+ const ctrl = {
3713
+ clear() {
3714
+ if (done)
3715
+ return;
3716
+ clearTimeout(handle);
3717
+ activeTimers.delete(handle);
3718
+ }
3719
+ };
3720
+ registerHotDispose(() => ctrl.clear());
3721
+ return ctrl;
3722
+ },
3723
+ async close() {
3724
+ if (pollTimer) {
3725
+ clearInterval(pollTimer);
3726
+ pollTimer = null;
3727
+ }
3728
+ for (const t of activeTimers) {
3729
+ clearTimeout(t);
3730
+ clearInterval(t);
3731
+ }
3732
+ activeTimers.clear();
3733
+ await handler.close();
3734
+ await config.transport.close();
3735
+ }
3736
+ };
3737
+ return server;
3738
+ }
3739
+ function registerHotDispose(fn) {
3740
+ if (true) {
3741
+ const hot = {}.hot;
3742
+ if (hot && typeof hot.dispose === "function") {
3743
+ hot.dispose(fn);
3744
+ }
3745
+ }
3746
+ }
3747
+ function parseDuration(str) {
3748
+ const match = str.match(/^(\d+)(ms|s|m|h|d)$/);
3749
+ if (!match)
3750
+ throw new Error(`Invalid duration: "${str}". Expected format: <number><unit> (e.g., "1h", "30m", "500ms")`);
3751
+ const value = Number(match[1]);
3752
+ switch (match[2]) {
3753
+ case "ms":
3754
+ return value;
3755
+ case "s":
3756
+ return value * 1000;
3757
+ case "m":
3758
+ return value * 60 * 1000;
3759
+ case "h":
3760
+ return value * 60 * 60 * 1000;
3761
+ case "d":
3762
+ return value * 24 * 60 * 60 * 1000;
3763
+ default:
3764
+ return 0;
3765
+ }
3766
+ }
3767
+
3768
+ // src/server/index.ts
3769
+ init_types();
3770
+
3771
+ // src/server/storage/sqlite.ts
3772
+ var import_node_module3 = require("node:module");
3773
+ var requireFn2 = import_node_module3.createRequire(module.filename);
3774
+ function loadDatabase() {
3775
+ try {
3776
+ return requireFn2("bun:sqlite").Database;
3777
+ } catch {
3778
+ throw new Error("createSqliteStorage requires the Bun runtime (bun:sqlite is unavailable). Use createPostgresStorage on Node.");
3779
+ }
3780
+ }
3781
+ var OP_ID_RETENTION_MS = 24 * 60 * 60 * 1000;
3782
+ function createSqliteStorage(config = {}) {
3783
+ const db = config.db ?? new (loadDatabase())(config.path ?? ":memory:");
3784
+ db.run("PRAGMA journal_mode = WAL");
3785
+ db.run("PRAGMA synchronous = NORMAL");
3786
+ db.run(`
3787
+ CREATE TABLE IF NOT EXISTS _reflectdb_rows (
3788
+ tbl TEXT NOT NULL,
3789
+ row_id TEXT NOT NULL,
3790
+ data TEXT NOT NULL,
3791
+ col_clocks TEXT NOT NULL,
3792
+ hlc TEXT NOT NULL,
3793
+ PRIMARY KEY (tbl, row_id)
3794
+ )
3795
+ `);
3796
+ db.run(`
3797
+ CREATE TABLE IF NOT EXISTS _reflectdb_oplog (
3798
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
3799
+ tbl TEXT NOT NULL,
3800
+ op TEXT NOT NULL,
3801
+ row_id TEXT NOT NULL,
3802
+ payload TEXT,
3803
+ hlc TEXT NOT NULL,
3804
+ col_clocks TEXT NOT NULL
3805
+ )
3806
+ `);
3807
+ db.run(`
3808
+ CREATE INDEX IF NOT EXISTS idx_oplog_hlc ON _reflectdb_oplog (hlc)
3809
+ `);
3810
+ db.run(`
3811
+ CREATE INDEX IF NOT EXISTS idx_oplog_tbl_hlc ON _reflectdb_oplog (tbl, hlc)
3812
+ `);
3813
+ db.run(`
3814
+ CREATE TABLE IF NOT EXISTS _reflectdb_processed_ops (
3815
+ op_id TEXT PRIMARY KEY,
3816
+ created_at INTEGER NOT NULL
3817
+ )
3818
+ `);
3819
+ db.run(`
3820
+ CREATE INDEX IF NOT EXISTS idx_processed_ops_created_at ON _reflectdb_processed_ops (created_at)
3821
+ `);
3822
+ db.run(`
3823
+ CREATE TABLE IF NOT EXISTS _reflectdb_meta (
3824
+ key TEXT PRIMARY KEY,
3825
+ value TEXT NOT NULL
3826
+ )
3827
+ `);
3828
+ const getRowStmt = db.prepare("SELECT data, col_clocks, hlc FROM _reflectdb_rows WHERE tbl = ? AND row_id = ?");
3829
+ const putRowStmt = db.prepare("INSERT OR REPLACE INTO _reflectdb_rows (tbl, row_id, data, col_clocks, hlc) VALUES (?, ?, ?, ?, ?)");
3830
+ const deleteRowStmt = db.prepare("DELETE FROM _reflectdb_rows WHERE tbl = ? AND row_id = ?");
3831
+ const getRowsStmt = db.prepare("SELECT row_id, data, col_clocks FROM _reflectdb_rows WHERE tbl = ?");
3832
+ const appendOpStmt = db.prepare("INSERT INTO _reflectdb_oplog (tbl, op, row_id, payload, hlc, col_clocks) VALUES (?, ?, ?, ?, ?, ?)");
3833
+ const deleteOpsStmt = db.prepare("DELETE FROM _reflectdb_oplog WHERE hlc < ?");
3834
+ const reserveOpStmt = db.prepare("INSERT OR IGNORE INTO _reflectdb_processed_ops (op_id, created_at) VALUES (?, ?)");
3835
+ const cleanupOpsStmt = db.prepare("DELETE FROM _reflectdb_processed_ops WHERE created_at < ?");
3836
+ const getMetaStmt = db.prepare("SELECT value FROM _reflectdb_meta WHERE key = ?");
3837
+ const setMetaStmt = db.prepare("INSERT OR REPLACE INTO _reflectdb_meta (key, value) VALUES (?, ?)");
3838
+ return {
3839
+ async getRow(table, rowId) {
3840
+ const result = getRowStmt.get(table, rowId);
3841
+ if (!result) {
3842
+ return { row: null, rowHlc: null, colClocks: {} };
3843
+ }
3844
+ return {
3845
+ row: JSON.parse(result.data),
3846
+ rowHlc: result.hlc,
3847
+ colClocks: JSON.parse(result.col_clocks)
3848
+ };
3849
+ },
3850
+ async getRowsByIds(table, rowIds) {
3851
+ if (rowIds.length === 0)
3852
+ return {};
3853
+ const placeholders = rowIds.map(() => "?").join(", ");
3854
+ const stmt = db.prepare(`SELECT row_id, data, col_clocks, hlc FROM _reflectdb_rows WHERE tbl = ? AND row_id IN (${placeholders})`);
3855
+ const out = {};
3856
+ for (const r of stmt.all(table, ...rowIds)) {
3857
+ out[r.row_id] = {
3858
+ row: JSON.parse(r.data),
3859
+ rowHlc: r.hlc,
3860
+ colClocks: JSON.parse(r.col_clocks)
3861
+ };
3862
+ }
3863
+ return out;
3864
+ },
3865
+ async putRow(table, rowId, row, colClocks, hlc) {
3866
+ if (row === null) {
3867
+ deleteRowStmt.run(table, rowId);
3868
+ } else {
3869
+ putRowStmt.run(table, rowId, JSON.stringify(row), JSON.stringify(colClocks), hlc);
3870
+ }
3871
+ },
3872
+ async getRows(table) {
3873
+ const results = getRowsStmt.all(table);
3874
+ const rows = [];
3875
+ const colClocks = {};
3876
+ for (const result of results) {
3877
+ const data = JSON.parse(result.data);
3878
+ rows.push(data);
3879
+ colClocks[result.row_id] = JSON.parse(result.col_clocks);
3880
+ }
3881
+ return { rows, colClocks };
3882
+ },
3883
+ async appendOp(entry) {
3884
+ appendOpStmt.run(entry.table, entry.op, entry.rowId, entry.payload ? JSON.stringify(entry.payload) : null, entry.hlc, JSON.stringify(entry.colClocks));
3885
+ },
3886
+ async applyOp(table, rowId, row, colClocks, hlc, opType, payload) {
3887
+ const tx = db.transaction(() => {
3888
+ if (row === null) {
3889
+ deleteRowStmt.run(table, rowId);
3890
+ } else {
3891
+ putRowStmt.run(table, rowId, JSON.stringify(row), JSON.stringify(colClocks), hlc);
3892
+ }
3893
+ appendOpStmt.run(table, opType, rowId, payload ? JSON.stringify(payload) : null, hlc, JSON.stringify(colClocks));
3894
+ });
3895
+ tx();
3896
+ },
3897
+ async getOpsSince(since, tables) {
3898
+ if (tables.length === 0)
3899
+ return [];
3900
+ const placeholders = tables.map(() => "?").join(", ");
3901
+ const stmt = db.prepare(`SELECT tbl, op, row_id, payload, hlc, col_clocks FROM _reflectdb_oplog WHERE hlc > ? AND tbl IN (${placeholders}) ORDER BY hlc ASC`);
3902
+ const results = stmt.all(since, ...tables);
3903
+ return results.map((r) => ({
3904
+ table: r.tbl,
3905
+ op: r.op,
3906
+ rowId: r.row_id,
3907
+ payload: r.payload ? JSON.parse(r.payload) : null,
3908
+ hlc: r.hlc,
3909
+ colClocks: JSON.parse(r.col_clocks)
3910
+ }));
3911
+ },
3912
+ async getChangedTablesSince(since, tables) {
3913
+ if (tables.length === 0)
3914
+ return [];
3915
+ const placeholders = tables.map(() => "?").join(", ");
3916
+ const stmt = db.prepare(`SELECT DISTINCT tbl FROM _reflectdb_oplog WHERE hlc > ? AND tbl IN (${placeholders})`);
3917
+ return stmt.all(since, ...tables).map((r) => r.tbl);
3918
+ },
3919
+ async getOplogHead(tables) {
3920
+ if (tables.length === 0)
3921
+ return null;
3922
+ const placeholders = tables.map(() => "?").join(", ");
3923
+ const stmt = db.prepare(`SELECT MAX(hlc) AS head FROM _reflectdb_oplog WHERE tbl IN (${placeholders})`);
3924
+ return stmt.get(...tables)?.head ?? null;
3925
+ },
3926
+ async deleteOpsBefore(hlc) {
3927
+ const result = deleteOpsStmt.run(hlc);
3928
+ return result.changes;
3929
+ },
3930
+ async reserveOp(opId) {
3931
+ const now = Date.now();
3932
+ const result = reserveOpStmt.run(opId, now);
3933
+ if (Math.random() < 0.01) {
3934
+ cleanupOpsStmt.run(now - OP_ID_RETENTION_MS);
3935
+ }
3936
+ return result.changes > 0;
3937
+ },
3938
+ async reserveOps(opIds) {
3939
+ if (opIds.length === 0)
3940
+ return [];
3941
+ const now = Date.now();
3942
+ const fresh = [];
3943
+ const tx = db.transaction(() => {
3944
+ for (const opId of opIds) {
3945
+ if (reserveOpStmt.run(opId, now).changes > 0)
3946
+ fresh.push(opId);
3947
+ }
3948
+ });
3949
+ tx();
3950
+ if (Math.random() < 0.01) {
3951
+ cleanupOpsStmt.run(now - OP_ID_RETENTION_MS);
3952
+ }
3953
+ return fresh;
3954
+ },
3955
+ async getMeta(key) {
3956
+ const result = getMetaStmt.get(key);
3957
+ return result?.value ?? null;
3958
+ },
3959
+ async setMeta(key, value) {
3960
+ setMetaStmt.run(key, value);
3961
+ },
3962
+ async tryLock(key) {
3963
+ const lockKey = `lock:${key}`;
3964
+ const now = Date.now();
3965
+ const ttlMs = 60000;
3966
+ const expiry = now - ttlMs;
3967
+ const result = db.run(`INSERT INTO _reflectdb_meta (key, value) VALUES (?, ?)
3968
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
3969
+ WHERE CAST(_reflectdb_meta.value AS INTEGER) < ?`, [lockKey, String(now), String(expiry)]);
3970
+ return result.changes > 0;
3971
+ },
3972
+ async unlock(key) {
3973
+ db.run("DELETE FROM _reflectdb_meta WHERE key = ?", [`lock:${key}`]);
3974
+ },
3975
+ close() {
3976
+ db.close();
3977
+ }
3978
+ };
3979
+ }
3980
+ // src/server/storage/postgres.ts
3981
+ var OP_ID_RETENTION_MS2 = 24 * 60 * 60 * 1000;
3982
+ var OP_ID_GC_BATCH = 1e4;
3983
+ function hashKey(key) {
3984
+ let hash = 0xcbf29ce484222325n;
3985
+ for (let i = 0;i < key.length; i++) {
3986
+ hash ^= BigInt(key.charCodeAt(i));
3987
+ hash = hash * 0x100000001b3n & 0xffffffffffffffffn;
3988
+ }
3989
+ return (hash & 0x7fffffffffffffffn).toString();
3990
+ }
3991
+ function createPostgresStorage(clientOrConfig) {
3992
+ const config = "query" in clientOrConfig ? { client: clientOrConfig } : clientOrConfig;
3993
+ const client = config.client;
3994
+ const p = config.tablePrefix ?? "_reflectdb";
3995
+ let schemaReady = false;
3996
+ let schemaPromise = null;
3997
+ async function ensureSchema() {
3998
+ if (schemaReady)
3999
+ return;
4000
+ if (schemaPromise)
4001
+ return schemaPromise;
4002
+ schemaPromise = initSchema();
4003
+ await schemaPromise;
4004
+ schemaReady = true;
4005
+ }
4006
+ async function initSchema() {
4007
+ await client.query(`
4008
+ CREATE TABLE IF NOT EXISTS ${p}_rows (
4009
+ tbl TEXT NOT NULL,
4010
+ row_id TEXT NOT NULL,
4011
+ data JSONB NOT NULL,
4012
+ col_clocks JSONB NOT NULL,
4013
+ hlc TEXT NOT NULL,
4014
+ PRIMARY KEY (tbl, row_id)
4015
+ )
4016
+ `);
4017
+ await client.query(`
4018
+ CREATE TABLE IF NOT EXISTS ${p}_oplog (
4019
+ id BIGSERIAL PRIMARY KEY,
4020
+ tbl TEXT NOT NULL,
4021
+ op TEXT NOT NULL,
4022
+ row_id TEXT NOT NULL,
4023
+ payload JSONB,
4024
+ hlc TEXT NOT NULL,
4025
+ col_clocks JSONB NOT NULL
4026
+ )
4027
+ `);
4028
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_${p}_oplog_hlc ON ${p}_oplog (hlc)`);
4029
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_${p}_oplog_tbl_hlc ON ${p}_oplog (tbl, hlc)`);
4030
+ await client.query(`
4031
+ CREATE TABLE IF NOT EXISTS ${p}_processed_ops (
4032
+ op_id TEXT PRIMARY KEY,
4033
+ created_at BIGINT NOT NULL
4034
+ )
4035
+ `);
4036
+ await client.query(`CREATE INDEX IF NOT EXISTS idx_${p}_processed_ops_created_at ON ${p}_processed_ops (created_at)`);
4037
+ await client.query(`
4038
+ CREATE TABLE IF NOT EXISTS ${p}_meta (
4039
+ key TEXT PRIMARY KEY,
4040
+ value TEXT NOT NULL
4041
+ )
4042
+ `);
4043
+ }
4044
+ async function maybeGcProcessedOps(now) {
4045
+ if (Math.random() >= 0.01)
4046
+ return;
4047
+ try {
4048
+ await client.query(`DELETE FROM ${p}_processed_ops
4049
+ WHERE ctid IN (
4050
+ SELECT ctid FROM ${p}_processed_ops WHERE created_at < $1 LIMIT ${OP_ID_GC_BATCH}
4051
+ )`, [now - OP_ID_RETENTION_MS2]);
4052
+ } catch (err) {
4053
+ console.warn("[reflectdb] processed_ops retention sweep failed:", err);
4054
+ }
4055
+ }
4056
+ return {
4057
+ async ensureSchema() {
4058
+ await ensureSchema();
4059
+ },
4060
+ async getRow(table, rowId) {
4061
+ await ensureSchema();
4062
+ const { rows } = await client.query(`SELECT data, col_clocks, hlc FROM ${p}_rows WHERE tbl = $1 AND row_id = $2`, [table, rowId]);
4063
+ if (rows.length === 0) {
4064
+ return { row: null, rowHlc: null, colClocks: {} };
4065
+ }
4066
+ const r = rows[0];
4067
+ return {
4068
+ row: typeof r.data === "string" ? JSON.parse(r.data) : r.data,
4069
+ rowHlc: r.hlc,
4070
+ colClocks: typeof r.col_clocks === "string" ? JSON.parse(r.col_clocks) : r.col_clocks
4071
+ };
4072
+ },
4073
+ async getRowsByIds(table, rowIds) {
4074
+ await ensureSchema();
4075
+ if (rowIds.length === 0)
4076
+ return {};
4077
+ const placeholders = rowIds.map((_, i) => `$${i + 2}`).join(", ");
4078
+ const { rows } = await client.query(`SELECT row_id, data, col_clocks, hlc FROM ${p}_rows WHERE tbl = $1 AND row_id IN (${placeholders})`, [table, ...rowIds]);
4079
+ const out = {};
4080
+ for (const r of rows) {
4081
+ out[r.row_id] = {
4082
+ row: typeof r.data === "string" ? JSON.parse(r.data) : r.data,
4083
+ rowHlc: r.hlc,
4084
+ colClocks: typeof r.col_clocks === "string" ? JSON.parse(r.col_clocks) : r.col_clocks
4085
+ };
4086
+ }
4087
+ return out;
4088
+ },
4089
+ async putRow(table, rowId, row, colClocks, hlc) {
4090
+ await ensureSchema();
4091
+ if (row === null) {
4092
+ await client.query(`DELETE FROM ${p}_rows WHERE tbl = $1 AND row_id = $2`, [table, rowId]);
4093
+ } else {
4094
+ await client.query(`INSERT INTO ${p}_rows (tbl, row_id, data, col_clocks, hlc)
4095
+ VALUES ($1, $2, $3, $4, $5)
4096
+ ON CONFLICT (tbl, row_id)
4097
+ DO UPDATE SET data = $3, col_clocks = $4, hlc = $5`, [table, rowId, JSON.stringify(row), JSON.stringify(colClocks), hlc]);
4098
+ }
4099
+ },
4100
+ async getRows(table) {
4101
+ await ensureSchema();
4102
+ const { rows: results } = await client.query(`SELECT row_id, data, col_clocks FROM ${p}_rows WHERE tbl = $1`, [table]);
4103
+ const rows = [];
4104
+ const colClocks = {};
4105
+ for (const r of results) {
4106
+ rows.push(typeof r.data === "string" ? JSON.parse(r.data) : r.data);
4107
+ colClocks[r.row_id] = typeof r.col_clocks === "string" ? JSON.parse(r.col_clocks) : r.col_clocks;
4108
+ }
4109
+ return { rows, colClocks };
4110
+ },
4111
+ async appendOp(entry) {
4112
+ await ensureSchema();
4113
+ await client.query(`INSERT INTO ${p}_oplog (tbl, op, row_id, payload, hlc, col_clocks)
4114
+ VALUES ($1, $2, $3, $4, $5, $6)`, [
4115
+ entry.table,
4116
+ entry.op,
4117
+ entry.rowId,
4118
+ entry.payload ? JSON.stringify(entry.payload) : null,
4119
+ entry.hlc,
4120
+ JSON.stringify(entry.colClocks)
4121
+ ]);
4122
+ },
4123
+ async applyOp(table, rowId, row, colClocks, hlc, opType, payload) {
4124
+ await ensureSchema();
4125
+ const colClocksJson = JSON.stringify(colClocks);
4126
+ const payloadJson = payload ? JSON.stringify(payload) : null;
4127
+ if (row === null) {
4128
+ await client.query(`WITH row_delete AS (
4129
+ DELETE FROM ${p}_rows WHERE tbl = $1 AND row_id = $2
4130
+ )
4131
+ INSERT INTO ${p}_oplog (tbl, op, row_id, payload, hlc, col_clocks)
4132
+ VALUES ($1, $6, $2, $7, $5, $4)`, [table, rowId, null, colClocksJson, hlc, opType, payloadJson]);
4133
+ } else {
4134
+ await client.query(`WITH row_upsert AS (
4135
+ INSERT INTO ${p}_rows (tbl, row_id, data, col_clocks, hlc)
4136
+ VALUES ($1, $2, $3, $4, $5)
4137
+ ON CONFLICT (tbl, row_id)
4138
+ DO UPDATE SET data = $3, col_clocks = $4, hlc = $5
4139
+ )
4140
+ INSERT INTO ${p}_oplog (tbl, op, row_id, payload, hlc, col_clocks)
4141
+ VALUES ($1, $6, $2, $7, $5, $4)`, [table, rowId, JSON.stringify(row), colClocksJson, hlc, opType, payloadJson]);
4142
+ }
4143
+ },
4144
+ async tryLock(key) {
4145
+ await ensureSchema();
4146
+ const keyHash = hashKey(key);
4147
+ const { rows } = await client.query(`SELECT pg_try_advisory_lock($1) AS locked`, [keyHash]);
4148
+ return rows[0]?.locked === true;
4149
+ },
4150
+ async unlock(key) {
4151
+ const keyHash = hashKey(key);
4152
+ await client.query(`SELECT pg_advisory_unlock($1)`, [keyHash]);
4153
+ },
4154
+ async getOpsSince(since, tables) {
4155
+ await ensureSchema();
4156
+ if (tables.length === 0)
4157
+ return [];
4158
+ const placeholders = tables.map((_, i) => `$${i + 2}`).join(", ");
4159
+ const { rows } = await client.query(`SELECT tbl, op, row_id, payload, hlc, col_clocks
4160
+ FROM ${p}_oplog
4161
+ WHERE hlc > $1 AND tbl IN (${placeholders})
4162
+ ORDER BY hlc ASC`, [since, ...tables]);
4163
+ return rows.map((r) => ({
4164
+ table: r.tbl,
4165
+ op: r.op,
4166
+ rowId: r.row_id,
4167
+ payload: typeof r.payload === "string" ? JSON.parse(r.payload) : r.payload,
4168
+ hlc: r.hlc,
4169
+ colClocks: typeof r.col_clocks === "string" ? JSON.parse(r.col_clocks) : r.col_clocks
4170
+ }));
4171
+ },
4172
+ async getChangedTablesSince(since, tables) {
4173
+ await ensureSchema();
4174
+ if (tables.length === 0)
4175
+ return [];
4176
+ const placeholders = tables.map((_, i) => `$${i + 2}`).join(", ");
4177
+ const { rows } = await client.query(`SELECT DISTINCT tbl FROM ${p}_oplog WHERE hlc > $1 AND tbl IN (${placeholders})`, [since, ...tables]);
4178
+ return rows.map((r) => r.tbl);
4179
+ },
4180
+ async getOplogHead(tables) {
4181
+ await ensureSchema();
4182
+ if (tables.length === 0)
4183
+ return null;
4184
+ const placeholders = tables.map((_, i) => `$${i + 1}`).join(", ");
4185
+ const { rows } = await client.query(`SELECT MAX(hlc) AS head FROM ${p}_oplog WHERE tbl IN (${placeholders})`, tables);
4186
+ return rows[0]?.head ?? null;
4187
+ },
4188
+ async deleteOpsBefore(hlc) {
4189
+ await ensureSchema();
4190
+ const { rows } = await client.query(`WITH deleted AS (
4191
+ DELETE FROM ${p}_oplog WHERE hlc < $1 RETURNING 1
4192
+ ) SELECT COUNT(*)::text AS count FROM deleted`, [hlc]);
4193
+ return parseInt(rows[0]?.count ?? "0", 10);
4194
+ },
4195
+ async reserveOp(opId) {
4196
+ await ensureSchema();
4197
+ const now = Date.now();
4198
+ const { rows } = await client.query(`INSERT INTO ${p}_processed_ops (op_id, created_at)
4199
+ VALUES ($1, $2)
4200
+ ON CONFLICT (op_id) DO NOTHING
4201
+ RETURNING 1 AS inserted`, [opId, now]);
4202
+ await maybeGcProcessedOps(now);
4203
+ return rows.length > 0;
4204
+ },
4205
+ async reserveOps(opIds) {
4206
+ await ensureSchema();
4207
+ if (opIds.length === 0)
4208
+ return [];
4209
+ const now = Date.now();
4210
+ const values = opIds.map((_, i) => `($${i * 2 + 1}, $${i * 2 + 2})`).join(", ");
4211
+ const params = [];
4212
+ for (const id of opIds)
4213
+ params.push(id, now);
4214
+ const { rows } = await client.query(`INSERT INTO ${p}_processed_ops (op_id, created_at)
4215
+ VALUES ${values}
4216
+ ON CONFLICT (op_id) DO NOTHING
4217
+ RETURNING op_id`, params);
4218
+ await maybeGcProcessedOps(now);
4219
+ return rows.map((r) => r.op_id);
4220
+ },
4221
+ async getMeta(key) {
4222
+ await ensureSchema();
4223
+ const { rows } = await client.query(`SELECT value FROM ${p}_meta WHERE key = $1`, [key]);
4224
+ return rows[0]?.value ?? null;
4225
+ },
4226
+ async setMeta(key, value) {
4227
+ await ensureSchema();
4228
+ await client.query(`INSERT INTO ${p}_meta (key, value)
4229
+ VALUES ($1, $2)
4230
+ ON CONFLICT (key)
4231
+ DO UPDATE SET value = $2`, [key, value]);
4232
+ },
4233
+ close() {}
4234
+ };
4235
+ }
4236
+ // src/server/typed-server.ts
4237
+ function createSyncServer(config) {
4238
+ const server = createServer({
4239
+ db: config.db,
4240
+ transport: config.transport,
4241
+ serverId: config.serverId,
4242
+ storage: config.storage,
4243
+ onEvent: config.onEvent,
4244
+ poll: config.poll,
4245
+ maxConnectionsPerUser: config.maxConnectionsPerUser,
4246
+ allowAnonymous: config.allowAnonymous,
4247
+ queryTimeoutMs: config.queryTimeoutMs,
4248
+ maxBroadcastConcurrency: config.maxBroadcastConcurrency,
4249
+ txAtomic: config.txAtomic
4250
+ });
4251
+ const implementations = new Map;
4252
+ let authCallback = null;
4253
+ return {
4254
+ auth(callback) {
4255
+ authCallback = callback;
4256
+ server.auth(callback);
4257
+ },
4258
+ implement(name, impl) {
4259
+ const def = config.queries[name];
4260
+ if (!def) {
4261
+ throw new Error(`Unknown query "${name}"`);
4262
+ }
4263
+ if (def.__view) {
4264
+ throw new Error(`use server.view("${name}", ...) for view queries`);
4265
+ }
4266
+ if (def.__presence) {
4267
+ throw new Error(`"${name}" is a presence channel, not a writable query`);
4268
+ }
4269
+ const o = impl;
4270
+ const tables = o.tables ?? def.tables ?? [name];
4271
+ const pk = def.pk ?? "id";
4272
+ const conflict = def.conflict;
4273
+ const readonly = def.readonly;
4274
+ const countHints = def.countHints;
4275
+ const defServerSet = def.serverSet;
4276
+ const schemaServerSet = defServerSet && !Array.isArray(defServerSet) ? defServerSet : undefined;
4277
+ const implServerSet = o.serverSet;
4278
+ const mergedServerSet = schemaServerSet || implServerSet ? { ...schemaServerSet ?? {}, ...implServerSet ?? {} } : undefined;
4279
+ implementations.set(name, {
4280
+ query: o.query,
4281
+ mutate: o.mutate,
4282
+ authorize: o.authorize,
4283
+ serverSet: mergedServerSet,
4284
+ readonlyFields: readonly ? new Set(readonly) : undefined,
4285
+ tables,
4286
+ pk
4287
+ });
4288
+ server.query(name, o.query, {
4289
+ tables,
4290
+ pk,
4291
+ conflict,
4292
+ readonly: readonly ? [...readonly] : undefined,
4293
+ serverSet: mergedServerSet,
4294
+ countHints,
4295
+ count: o.count,
4296
+ groupBy: o.groupBy,
4297
+ room: o.room,
4298
+ broadcast: o.broadcast,
4299
+ flushInterval: o.flushInterval,
4300
+ maxBufferSize: o.maxBufferSize,
4301
+ authorize: o.authorize,
4302
+ mutate: o.mutate
4303
+ });
4304
+ },
4305
+ view(name, fn) {
4306
+ const def = config.queries[name];
4307
+ if (!def || !def.__view) {
4308
+ throw new Error(`"${name}" is not declared as a view in defineSyncQueries`);
4309
+ }
4310
+ const tables = def.deps ?? def.tables ?? [name];
4311
+ server.query(name, fn, {
4312
+ tables,
4313
+ mutate: async () => {
4314
+ const { MutationError: MutationError2 } = await Promise.resolve().then(() => (init_types(), exports_types));
4315
+ throw new MutationError2("readonly_query", `"${name}" is a view and is read-only`);
4316
+ }
4317
+ });
4318
+ },
4319
+ rest(restConfig) {
4320
+ const prefix = restConfig?.prefix ?? "/api";
4321
+ const runOp = (type, queryName, rowId, payload, impl, ctx) => server.applyServerOp({ type, table: queryName, rowId, payload }, async (stamped) => {
4322
+ const op = { ...stamped, type };
4323
+ if (impl.authorize) {
4324
+ await impl.authorize({ type: "write", table: queryName, op }, ctx, config.db);
4325
+ }
4326
+ await impl.mutate(op, ctx, config.db);
4327
+ });
4328
+ return async (req) => {
4329
+ const url = new URL(req.url);
4330
+ const path = url.pathname.slice(prefix.length);
4331
+ const segments = path.split("/").filter(Boolean);
4332
+ const queryName = segments[0];
4333
+ const rowId = segments[1];
4334
+ if (!queryName) {
4335
+ return Response.json({ queries: [...implementations.keys()] });
4336
+ }
4337
+ const impl = implementations.get(queryName);
4338
+ if (!impl) {
4339
+ return Response.json({ error: "not_found" }, { status: 404 });
4340
+ }
4341
+ let auth;
4342
+ try {
4343
+ if (!authCallback) {
4344
+ auth = { userId: "anonymous" };
4345
+ } else {
4346
+ auth = await authCallback(req);
4347
+ }
4348
+ } catch {
4349
+ return Response.json({ error: "unauthorized" }, { status: 401 });
4350
+ }
4351
+ const params = Object.fromEntries(url.searchParams);
4352
+ const ctx = { auth, params };
4353
+ const idempotencyKey = req.headers.get("Idempotency-Key") ?? req.headers.get("idempotency-key");
4354
+ const isMutation = req.method !== "GET";
4355
+ if (isMutation && idempotencyKey) {
4356
+ const fresh = await server.reserveOpId(`rest:${queryName}:${idempotencyKey}`);
4357
+ if (!fresh) {
4358
+ return Response.json({ ok: true, replayed: true });
4359
+ }
4360
+ }
4361
+ try {
4362
+ switch (req.method) {
4363
+ case "GET": {
4364
+ if (impl.authorize) {
4365
+ try {
4366
+ await impl.authorize({ type: "read", table: queryName, params }, ctx, config.db);
4367
+ } catch {
4368
+ return Response.json({ error: "forbidden" }, { status: 403 });
4369
+ }
4370
+ }
4371
+ const result = impl.query(ctx, config.db);
4372
+ const resolved = result && typeof result.then === "function" ? await result : result;
4373
+ const rows = Array.isArray(resolved) ? resolved : [];
4374
+ if (rowId) {
4375
+ const row = rows.find((r) => r[impl.pk] === rowId);
4376
+ return row ? Response.json(row) : Response.json({ error: "not_found" }, { status: 404 });
4377
+ }
4378
+ return Response.json(rows);
4379
+ }
4380
+ case "POST": {
4381
+ if (!impl.mutate) {
4382
+ return Response.json({ error: "readonly" }, { status: 405 });
4383
+ }
4384
+ const raw = await req.json();
4385
+ if (Array.isArray(raw)) {
4386
+ const results = [];
4387
+ let anyFailed = false;
4388
+ for (const item of raw) {
4389
+ const id2 = item[impl.pk] ?? crypto.randomUUID();
4390
+ const payload2 = { ...item };
4391
+ delete payload2[impl.pk];
4392
+ stripReadonly(payload2, impl.readonlyFields);
4393
+ applyServerSet(payload2, impl.serverSet, ctx);
4394
+ try {
4395
+ await runOp("insert", queryName, id2, payload2, impl, ctx);
4396
+ results.push({ ok: true, id: id2 });
4397
+ } catch (err) {
4398
+ anyFailed = true;
4399
+ results.push({
4400
+ ok: false,
4401
+ id: id2,
4402
+ error: err instanceof Error ? err.message : String(err)
4403
+ });
4404
+ }
4405
+ }
4406
+ const ids = results.filter((r) => r.ok).map((r) => r.id);
4407
+ return Response.json({ ids, results }, { status: anyFailed ? 207 : 201 });
4408
+ }
4409
+ const body = raw;
4410
+ const id = body[impl.pk] ?? crypto.randomUUID();
4411
+ const payload = { ...body };
4412
+ delete payload[impl.pk];
4413
+ stripReadonly(payload, impl.readonlyFields);
4414
+ applyServerSet(payload, impl.serverSet, ctx);
4415
+ await runOp("insert", queryName, id, payload, impl, ctx);
4416
+ return Response.json({ id }, { status: 201 });
4417
+ }
4418
+ case "PUT":
4419
+ case "PATCH": {
4420
+ if (!impl.mutate) {
4421
+ return Response.json({ error: "readonly" }, { status: 405 });
4422
+ }
4423
+ if (!rowId) {
4424
+ return Response.json({ error: "missing_id" }, { status: 400 });
4425
+ }
4426
+ const body = await req.json();
4427
+ const payload = { ...body };
4428
+ delete payload[impl.pk];
4429
+ stripReadonly(payload, impl.readonlyFields);
4430
+ applyServerSet(payload, impl.serverSet, ctx);
4431
+ await runOp("update", queryName, rowId, payload, impl, ctx);
4432
+ return Response.json({ ok: true });
4433
+ }
4434
+ case "DELETE": {
4435
+ if (!impl.mutate) {
4436
+ return Response.json({ error: "readonly" }, { status: 405 });
4437
+ }
4438
+ if (!rowId) {
4439
+ return Response.json({ error: "missing_id" }, { status: 400 });
4440
+ }
4441
+ await runOp("delete", queryName, rowId, null, impl, ctx);
4442
+ return Response.json({ ok: true });
4443
+ }
4444
+ default:
4445
+ return Response.json({ error: "method_not_allowed" }, { status: 405 });
4446
+ }
4447
+ } catch (err) {
4448
+ const message = err instanceof Error ? err.message : String(err);
4449
+ if (message.includes("forbidden") || message.includes("unauthorized")) {
4450
+ return Response.json({ error: message }, { status: 403 });
4451
+ }
4452
+ return Response.json({ error: message }, { status: 500 });
4453
+ }
4454
+ };
4455
+ },
4456
+ room: server.room,
4457
+ rateLimit: server.rateLimit,
4458
+ compaction: server.compaction,
4459
+ minSchemaVersion: server.minSchemaVersion,
4460
+ runCompaction: server.runCompaction,
4461
+ notifyChange: server.notifyChange,
4462
+ reserveOpId: server.reserveOpId,
4463
+ applyServerOp: server.applyServerOp,
4464
+ emit: server.emit,
4465
+ tx: server.tx,
4466
+ lock: server.lock,
4467
+ tryLock: server.tryLock,
4468
+ interval: server.interval,
4469
+ timeout: server.timeout,
4470
+ close: server.close
4471
+ };
4472
+ }
4473
+ function stripReadonly(payload, readonlyFields) {
4474
+ if (!readonlyFields)
4475
+ return;
4476
+ for (const key of readonlyFields) {
4477
+ delete payload[key];
4478
+ }
4479
+ }
4480
+ function applyServerSet(payload, serverSet, ctx) {
4481
+ if (!serverSet)
4482
+ return;
4483
+ for (const [key, val] of Object.entries(serverSet)) {
4484
+ payload[key] = typeof val === "function" ? val(ctx) : val;
4485
+ }
4486
+ }