reflectdb 0.1.2 → 0.2.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 (48) hide show
  1. package/README.md +483 -17
  2. package/dist/cjs/client/index.cjs +0 -1
  3. package/dist/cjs/client/storage/indexeddb.cjs +0 -1
  4. package/dist/cjs/core/index.cjs +0 -1
  5. package/dist/cjs/htmx/index.cjs +1720 -0
  6. package/dist/cjs/htmx/index.d.cts +760 -0
  7. package/dist/cjs/react/index.cjs +0 -1
  8. package/dist/cjs/server/drizzle.cjs +15 -4
  9. package/dist/cjs/server/ephemeral/index.cjs +0 -1
  10. package/dist/cjs/server/ephemeral/redis.cjs +0 -1
  11. package/dist/cjs/server/index.cjs +34 -13
  12. package/dist/cjs/server/index.d.cts +14 -0
  13. package/dist/cjs/server/storage/object/index.cjs +2284 -0
  14. package/dist/cjs/server/storage/object/index.d.cts +578 -0
  15. package/dist/cjs/svelte/index.cjs +0 -1
  16. package/dist/cjs/transport/bun-ws.cjs +0 -1
  17. package/dist/cjs/transport/polling.cjs +0 -1
  18. package/dist/cjs/transport/sse.cjs +52 -2
  19. package/dist/cjs/transport/sse.d.cts +34 -0
  20. package/dist/cjs/transport/ws.cjs +0 -1
  21. package/dist/cjs/vanilla/index.cjs +0 -1
  22. package/dist/client/index.js +1 -1
  23. package/dist/client/storage/indexeddb.js +1 -1
  24. package/dist/core/index.js +1 -1
  25. package/dist/htmx/index.d.ts +760 -0
  26. package/dist/htmx/index.js +297 -0
  27. package/dist/react/index.js +1 -1
  28. package/dist/server/drizzle.js +3 -2
  29. package/dist/server/ephemeral/index.js +1 -1
  30. package/dist/server/ephemeral/redis.js +1 -1
  31. package/dist/server/index.d.ts +14 -0
  32. package/dist/server/index.js +19 -6
  33. package/dist/server/storage/object/index.d.ts +578 -0
  34. package/dist/server/storage/object/index.js +2232 -0
  35. package/dist/shared/{esm-z1xse19c.js → esm-5ahpq25j.js} +4 -4
  36. package/dist/shared/esm-dcs8qa5n.js +263 -0
  37. package/dist/shared/esm-f11s9zpb.js +15 -0
  38. package/dist/shared/esm-g5h4a88j.js +9 -0
  39. package/dist/shared/{esm-ck88h30s.js → esm-xtkhzxg2.js} +1 -1
  40. package/dist/svelte/index.js +1 -1
  41. package/dist/transport/bun-ws.js +1 -1
  42. package/dist/transport/polling.js +1 -1
  43. package/dist/transport/sse.d.ts +34 -0
  44. package/dist/transport/sse.js +53 -2
  45. package/dist/transport/ws.js +1 -1
  46. package/dist/vanilla/index.js +5 -261
  47. package/package.json +26 -3
  48. package/dist/shared/esm-k7kedp3y.js +0 -4
@@ -0,0 +1,578 @@
1
+ interface ExistingRow {
2
+ row: Record<string, unknown> | null;
3
+ rowHlc: string | null;
4
+ colClocks: Record<string, string>;
5
+ }
6
+ interface OpLogEntry {
7
+ table: string;
8
+ op: string;
9
+ rowId: string;
10
+ payload: Record<string, unknown> | null;
11
+ hlc: string;
12
+ colClocks: Record<string, string>;
13
+ }
14
+ interface StorageAdapter {
15
+ getRow(table: string, rowId: string): Promise<ExistingRow>;
16
+ /**
17
+ * Read many rows of one table in a single round trip, keyed by rowId.
18
+ * Missing rows are simply absent from the result.
19
+ *
20
+ * A 100-op batch otherwise issues 100 sequential `getRow` calls before it
21
+ * can even start writing. Optional — the handler falls back to `getRow`.
22
+ */
23
+ getRowsByIds?(table: string, rowIds: string[]): Promise<Record<string, ExistingRow>>;
24
+ putRow(table: string, rowId: string, row: Record<string, unknown> | null, colClocks: Record<string, string>, hlc: string): Promise<void>;
25
+ getRows(table: string, filter?: Record<string, unknown>): Promise<{
26
+ rows: Record<string, unknown>[];
27
+ colClocks: Record<string, Record<string, string>>;
28
+ }>;
29
+ appendOp(entry: OpLogEntry): Promise<void>;
30
+ /**
31
+ * Atomic write: putRow + appendOp in a single transaction.
32
+ * Prevents divergence between the row store and op log on crash.
33
+ * Optional — handler falls back to sequential putRow+appendOp if absent.
34
+ */
35
+ applyOp?(table: string, rowId: string, row: Record<string, unknown> | null, colClocks: Record<string, string>, hlc: string, opType: string, payload: Record<string, unknown> | null): Promise<void>;
36
+ getOpsSince(since: string, tables: string[]): Promise<OpLogEntry[]>;
37
+ /**
38
+ * Distinct table names with ops newer than `since`, restricted to `tables`.
39
+ *
40
+ * Resume only needs the set of changed tables — it re-executes each affected
41
+ * query and sends a snapshot rather than replaying ops. Without this,
42
+ * resume loads every op row since the watermark into memory just to collect
43
+ * distinct names, which is unbounded for a long-offline client.
44
+ *
45
+ * Optional — the handler falls back to `getOpsSince` when absent.
46
+ */
47
+ getChangedTablesSince?(since: string, tables: string[]): Promise<string[]>;
48
+ /**
49
+ * Highest op-log HLC across `tables`, or null when the log is empty.
50
+ *
51
+ * HA polling uses it as a cheap "did anything change at all" probe: without
52
+ * it, every poll tick re-executes every query for every subscriber group
53
+ * even when there were zero writes. Optional.
54
+ */
55
+ getOplogHead?(tables: string[]): Promise<string | null>;
56
+ deleteOpsBefore(hlc: string): Promise<number>;
57
+ /**
58
+ * Atomic reserve-or-detect-replay: returns true if this opId was fresh
59
+ * (inserted), false if it was already reserved. MUST be implemented as a
60
+ * single atomic operation (e.g. INSERT ... ON CONFLICT DO NOTHING) — a
61
+ * non-atomic check-then-write race-conditions in HA setups where multiple
62
+ * instances share storage and double-applies the op.
63
+ */
64
+ reserveOp(opId: string): Promise<boolean>;
65
+ /**
66
+ * Batch form of `reserveOp`: returns the subset of `opIds` that were fresh.
67
+ * Must be atomic per id, like `reserveOp`. Optional — the handler falls
68
+ * back to one `reserveOp` per op.
69
+ */
70
+ reserveOps?(opIds: string[]): Promise<string[]>;
71
+ getMeta(key: string): Promise<string | null>;
72
+ setMeta(key: string, value: string): Promise<void>;
73
+ /**
74
+ * Acquire a cross-instance lock (e.g. Postgres pg_advisory_lock).
75
+ * Returns true if acquired, false if another instance holds it.
76
+ * Optional — handler runs best-effort if absent.
77
+ */
78
+ tryLock?(key: string): Promise<boolean>;
79
+ unlock?(key: string): Promise<void>;
80
+ }
81
+ /**
82
+ * Contracts for the object-storage backend.
83
+ *
84
+ * Design: docs/object-storage.md. The short version — the object store is the
85
+ * only durable store, but it is never on the read path. State is authoritative
86
+ * in memory; writes append to a buffer that group-commits one object per batch;
87
+ * a CAS'd manifest is the linearization point.
88
+ *
89
+ * Nothing in this directory may statically import a `node:` builtin. `bunup`
90
+ * builds `src/` with `target: "browser"`, and a `node:` import anywhere here is
91
+ * hoisted into a shared chunk that every entry point — including
92
+ * `reflectdb/core` and `reflectdb/react` — side-effect-imports, which breaks
93
+ * consumer bundles outright. Use `src/server/node-require.ts` when a builtin is
94
+ * genuinely needed (the filesystem driver), and WebCrypto rather than
95
+ * `node:crypto` everywhere else.
96
+ */
97
+ /** A stored object plus the etag needed to CAS against it. */
98
+ interface ObjectRecord {
99
+ body: Uint8Array;
100
+ etag: string;
101
+ }
102
+ interface ObjectPutOptions {
103
+ /**
104
+ * Overwrite only if the current etag matches. A mismatch throws
105
+ * `PreconditionFailedError`. This is the CAS primitive the manifest and
106
+ * lease are built on.
107
+ */
108
+ ifMatch?: string;
109
+ /**
110
+ * Write only if the key is absent. Only `"*"` is meaningful, matching the S3
111
+ * header. Drivers reporting `caps.casWildcard === false` (MinIO) reject this
112
+ * — see `ObjectDriverCapabilities.casWildcard`.
113
+ */
114
+ ifNoneMatch?: "*";
115
+ }
116
+ interface ObjectListEntry {
117
+ key: string;
118
+ size: number;
119
+ }
120
+ interface ObjectDriverCapabilities {
121
+ /**
122
+ * Whether `ifNoneMatch: "*"` (create-if-absent) is supported.
123
+ *
124
+ * MinIO shipped conditional writes before AWS but requires an exact etag and
125
+ * rejects the wildcard, so create-if-absent is unavailable there. Stores on a
126
+ * driver reporting `false` require a one-time `init()` that unconditionally
127
+ * seeds `_lease` and `_manifest`; every later write is a plain `ifMatch`,
128
+ * which MinIO handles fine.
129
+ *
130
+ * `init()` is a deploy step. Racing it from N servers on a non-wildcard
131
+ * driver is unsafe.
132
+ */
133
+ casWildcard: boolean;
134
+ }
135
+ /**
136
+ * The whole provider surface. Four methods — everything above this interface is
137
+ * provider-agnostic and never learns which store it is talking to.
138
+ *
139
+ * Keys are store-relative; the driver owns bucket and prefix.
140
+ */
141
+ interface ObjectDriver {
142
+ /** Returns `null` when the key is absent — absence is not an error. */
143
+ get(key: string): Promise<ObjectRecord | null>;
144
+ /**
145
+ * Writes `body` and returns the new etag.
146
+ *
147
+ * @throws {PreconditionFailedError} when `ifMatch` / `ifNoneMatch` fails.
148
+ */
149
+ put(key: string, body: Uint8Array, opts?: ObjectPutOptions): Promise<string>;
150
+ /** Lists keys under `prefix`. Must page internally and return the full set. */
151
+ list(prefix: string): Promise<ObjectListEntry[]>;
152
+ /** Deletes keys. Absent keys are not an error. */
153
+ delete(keys: string[]): Promise<void>;
154
+ readonly caps: ObjectDriverCapabilities;
155
+ /** Releases any pooled resources. Optional. */
156
+ close?(): Promise<void> | void;
157
+ }
158
+ /**
159
+ * A conditional write lost. Callers treat this as "someone else moved first",
160
+ * never as a transport failure — retrying without re-reading is always wrong.
161
+ */
162
+ declare class PreconditionFailedError extends Error {
163
+ readonly key: string;
164
+ constructor(key: string, message?: string);
165
+ }
166
+ /** The write buffer exceeded `batch.maxBufferBytes` under `onBackpressure: "reject"`. */
167
+ declare class BackpressureError extends Error {
168
+ readonly bufferedBytes: number;
169
+ readonly limitBytes: number;
170
+ constructor(bufferedBytes: number, limitBytes: number);
171
+ }
172
+ /** In-memory state exceeded the configured budget under `memory.onExceeded: "reject"`. */
173
+ declare class MemoryLimitExceededError extends Error {
174
+ readonly usedBytes: number;
175
+ readonly limitBytes: number;
176
+ constructor(usedBytes: number, limitBytes: number);
177
+ }
178
+ /**
179
+ * The manifest names an object the store does not have.
180
+ *
181
+ * Always a hard failure: the manifest is the room's index, so a key it lists
182
+ * and the store cannot produce means data the room was told was durable is
183
+ * gone. Booting past it would present the loss as an empty room and then
184
+ * overwrite whatever survived. Typed rather than a bare `Error` because a
185
+ * caller whose data is disposable — a demo board, a scratch room — may
186
+ * legitimately choose to clear the prefix and start over, and that decision
187
+ * must never be made by matching on a message string.
188
+ */
189
+ declare class IncompleteStateError extends Error {
190
+ readonly roomId: string;
191
+ readonly key: string;
192
+ constructor(roomId: string, key: string, detail: string);
193
+ }
194
+ /**
195
+ * This instance is not the writer for the room: another holds an unexpired
196
+ * lease, or a renewal failed and the writer self-fenced.
197
+ */
198
+ declare class NotWriterError extends Error {
199
+ readonly roomId: string;
200
+ constructor(roomId: string, detail?: string);
201
+ }
202
+ /**
203
+ * The lease object. CAS'd on every acquire and renew; `epoch` is the fencing
204
+ * token stamped into every WAL segment name and manifest write.
205
+ */
206
+ interface LeaseRecord {
207
+ owner: string;
208
+ epoch: number;
209
+ /** Wall-clock ms. Coarse by design — the manifest CAS is the real guard. */
210
+ expiresAt: number;
211
+ }
212
+ interface WalSegmentRef {
213
+ key: string;
214
+ epoch: number;
215
+ seq: number;
216
+ bytes: number;
217
+ /** Highest HLC in the segment; lets replay skip segments below a snapshot. */
218
+ maxHlc: string;
219
+ }
220
+ /**
221
+ * The single CAS'd linearization point. Every field a booting reader needs to
222
+ * reconstruct state is here, so boot is one GET plus the objects it names.
223
+ */
224
+ interface ManifestRecord {
225
+ version: 1;
226
+ epoch: number;
227
+ /**
228
+ * Increments on every commit. Exists solely to make the manifest bytes differ
229
+ * on every write, which closes an ABA hole in etag-based CAS.
230
+ *
231
+ * S3 derives an etag from object content, so writing identical bytes leaves
232
+ * the etag unchanged. Without this counter a writer could read etag E, have
233
+ * another writer commit a manifest that happens to serialize identically, and
234
+ * still win its `ifMatch: E` — a lost update that no 412 reports. In practice
235
+ * `oplogHead` and `walSegs` almost always differ, but "almost always" is not
236
+ * a property to rest a linearization point on. A monotonic counter makes it
237
+ * impossible by construction rather than by luck.
238
+ */
239
+ commitSeq: number;
240
+ /**
241
+ * `writerId` of whoever wrote this version.
242
+ *
243
+ * Together with `commitSeq` it identifies a commit uniquely, which is what
244
+ * lets a writer that took a 412 tell "my own write, acknowledged late" from
245
+ * "someone else got there first". `epoch` cannot do that job under
246
+ * `concurrency: "optimistic"`: there is no lease, so every instance shares the
247
+ * manifest's epoch and two of them will attempt the same `commitSeq` — making
248
+ * an epoch-based check adopt a rival's commit as your own and silently drop
249
+ * the segment you just wrote.
250
+ */
251
+ lastWriter: string;
252
+ /** Key of the newest snapshot, or `null` before the first compaction. */
253
+ snapshotKey: string | null;
254
+ /** Highest HLC covered by the snapshot; segments at or below it are replaceable. */
255
+ snapshotHlc: string | null;
256
+ /** Segments to replay after the snapshot, in commit order. */
257
+ walSegs: WalSegmentRef[];
258
+ /** Highest HLC committed anywhere in the log. */
259
+ oplogHead: string | null;
260
+ /** `getMeta` / `setMeta` storage. Small by contract. */
261
+ meta: Record<string, string>;
262
+ /** Segments superseded by compaction, deleted once `gcGraceMs` has elapsed. */
263
+ pendingGc: {
264
+ key: string;
265
+ deletableAt: number;
266
+ }[];
267
+ }
268
+ /** A materialized row, mirroring `ExistingRow` plus its id. */
269
+ interface SnapshotRow {
270
+ table: string;
271
+ rowId: string;
272
+ row: Record<string, unknown>;
273
+ colClocks: Record<string, string>;
274
+ hlc: string;
275
+ }
276
+ interface SnapshotRecord {
277
+ version: 1;
278
+ hlc: string | null;
279
+ rows: SnapshotRow[];
280
+ /** Op ids still inside the replay-protection window, with their timestamps. */
281
+ reservedOps: [opId: string, atMs: number][];
282
+ }
283
+ type StoreProvider = "aws" | "r2" | "tigris" | "minio" | "gcs";
284
+ interface StoreCredentials {
285
+ keyId: string;
286
+ secret: string;
287
+ sessionToken?: string;
288
+ }
289
+ interface StoreConfig {
290
+ /** Fills `endpoint` and `urlStyle` when they are not given explicitly. */
291
+ provider?: StoreProvider;
292
+ bucket: string;
293
+ prefix?: string;
294
+ endpoint?: string;
295
+ region?: string;
296
+ urlStyle?: "vhost" | "path";
297
+ credentials: StoreCredentials;
298
+ /** Cloudflare R2 account id; only used to derive the endpoint for `provider: "r2"`. */
299
+ accountId?: string;
300
+ }
301
+ type DurabilityMode = "durable" | "buffered";
302
+ type BackpressurePolicy = "reject" | "degrade";
303
+ type MemoryPolicy = "reject" | "evict" | "spill";
304
+ type LeaseMode = "always" | "on-write";
305
+ /**
306
+ * How this process expects to share the room with other processes.
307
+ *
308
+ * `"single-writer"` (default) is the design in docs/object-storage.md: one
309
+ * writer per room, elected by a lease, holding authoritative state in memory.
310
+ * Reads never touch the network and a write is one segment PUT plus one CAS.
311
+ * It requires the deployment to route a room to one instance.
312
+ *
313
+ * `"optimistic"` drops the lease for platforms that cannot make that promise —
314
+ * Vercel functions, or anything where any request may land on any instance. It
315
+ * rests on the same guarantee the single-writer mode does: the manifest CAS is
316
+ * what keeps the data correct, and the lease was only ever an optimization to
317
+ * stop two servers doing redundant work. Concurrent writers race on the CAS,
318
+ * the loser re-reads and retries, and nobody is fenced.
319
+ *
320
+ * What it costs: in-memory state is no longer authoritative, because another
321
+ * instance may have committed since this one last looked. Call `refresh()`
322
+ * before serving a read that must be current — one conditional GET of the
323
+ * manifest, which is why the poll loop in a serverless deployment is cheap.
324
+ */
325
+ type ConcurrencyMode = "single-writer" | "optimistic";
326
+ type StorageHealth = "healthy" | "degraded" | "unavailable";
327
+ interface BatchConfig {
328
+ maxBytes?: number;
329
+ /** Coalesces ops arriving in the same event-loop tick. Not a flush interval. */
330
+ minLingerMs?: number;
331
+ maxBufferBytes?: number;
332
+ onBackpressure?: BackpressurePolicy;
333
+ }
334
+ interface CompactionConfig2 {
335
+ afterSegments?: number;
336
+ afterBytes?: number;
337
+ /** Delay before deleting superseded segments, so in-flight readers do not 404. */
338
+ gcGraceMs?: number;
339
+ }
340
+ interface LeaseConfig {
341
+ ttlMs?: number;
342
+ renewMs?: number;
343
+ mode?: LeaseMode;
344
+ }
345
+ interface MemoryConfig {
346
+ maxTotalBytes?: number;
347
+ maxRoomBytes?: number;
348
+ onExceeded?: MemoryPolicy;
349
+ idleEvictMs?: number;
350
+ }
351
+ interface ObjectStorageConfig {
352
+ /** A ready driver, or a `StoreConfig` from which the S3 driver is built. */
353
+ driver?: ObjectDriver;
354
+ store?: StoreConfig;
355
+ roomId: string;
356
+ /**
357
+ * Identifies this writer in the lease. Defaults to a random id; set it
358
+ * explicitly to make lease ownership legible in logs.
359
+ */
360
+ writerId?: string;
361
+ /**
362
+ * `"durable"` (default) acks after the manifest CAS — correct with no
363
+ * protocol change. `"buffered"` acks on memory apply and is LOSSY until the
364
+ * durable-watermark protocol lands: a crash before flush drops ops the client
365
+ * has already retired. See docs/object-storage.md.
366
+ */
367
+ durability?: DurabilityMode;
368
+ retentionMs?: number;
369
+ /**
370
+ * Defaults to `"single-writer"`. Set `"optimistic"` on a platform that cannot
371
+ * route a room to one instance — see `ConcurrencyMode`.
372
+ */
373
+ concurrency?: ConcurrencyMode;
374
+ batch?: BatchConfig;
375
+ compaction?: CompactionConfig2;
376
+ lease?: LeaseConfig;
377
+ memory?: MemoryConfig;
378
+ shutdownFlushMs?: number;
379
+ /** Fired after each batch reaches durability. Phase 2 broadcasts this to clients. */
380
+ onDurable?: (hlc: string) => void;
381
+ onHealthChange?: (health: StorageHealth) => void;
382
+ }
383
+ /** Every knob resolved. Internals read this, never the optional-laden input. */
384
+ interface ResolvedObjectStorageConfig {
385
+ roomId: string;
386
+ writerId: string;
387
+ durability: DurabilityMode;
388
+ retentionMs: number;
389
+ concurrency: ConcurrencyMode;
390
+ batch: Required<BatchConfig>;
391
+ compaction: Required<CompactionConfig2>;
392
+ lease: Required<LeaseConfig>;
393
+ memory: Required<MemoryConfig>;
394
+ shutdownFlushMs: number;
395
+ }
396
+ /** Everything `ResolvedObjectStorageConfig` carries except the per-room identity. */
397
+ type ObjectStorageDefaults = Omit<ResolvedObjectStorageConfig, "roomId" | "writerId">;
398
+ declare const OBJECT_STORAGE_DEFAULTS: ObjectStorageDefaults;
399
+ /**
400
+ * Every wall-clock read and every timer in this directory goes through a
401
+ * `Clock`. Lease expiry, op-id retention and flush backoff are all time-driven,
402
+ * and a test that has to `await delay(300_000)` to prove a lease lapsed is a
403
+ * test nobody runs. Injecting the clock keeps those paths deterministic.
404
+ */
405
+ interface Clock {
406
+ now(): number;
407
+ delay(ms: number): Promise<void>;
408
+ /** Schedules `fn` and returns a cancel function. */
409
+ setTimer(fn: () => void, ms: number): () => void;
410
+ }
411
+ /**
412
+ * Process-wide byte budget shared by every room.
413
+ *
414
+ * The budget is global rather than per-room because a per-room cap lets one
415
+ * whale room starve five hundred small ones: each stays under its own limit
416
+ * while the process as a whole runs out of heap. `maxRoomBytes` still exists as
417
+ * a blast-radius guard, but `maxTotalBytes` is the one that maps to the machine.
418
+ */
419
+ declare class ProcessMemoryBudget {
420
+ private limitBytes;
421
+ private usedBytes;
422
+ get used(): number;
423
+ get limit(): number;
424
+ /**
425
+ * Narrows the process budget. The tightest configuration wins: two rooms
426
+ * built with different `maxTotalBytes` values describe the same heap, and
427
+ * honoring the looser one would silently discard the stricter operator's
428
+ * intent.
429
+ */
430
+ constrain(limit: number): void;
431
+ add(delta: number): void;
432
+ }
433
+ /** The operations the fault hook can intercept. One hook call per driver call. */
434
+ type MemoryDriverOp = "get" | "put" | "list" | "delete";
435
+ interface MemoryDriverFaultContext {
436
+ op: MemoryDriverOp;
437
+ /**
438
+ * The key the call names. For `list` this is the prefix; for `delete`, which
439
+ * is a single request against the store (S3 `DeleteObjects`), it is every key
440
+ * joined with `","` — a fault there aborts the whole batch, as a real 500
441
+ * would.
442
+ */
443
+ key: string;
444
+ /**
445
+ * 1-based ordinal across *all* operations on this driver, so a test can pin a
446
+ * fault to a point in a sequence ("the 4th thing this room does"). For "the
447
+ * 2nd put" specifically, read `stats.put` inside the hook: counters are
448
+ * incremented before the hook runs, so during the 2nd put `stats.put === 2`.
449
+ */
450
+ call: number;
451
+ }
452
+ /**
453
+ * What to inject. `undefined` (or no hook) means "proceed normally".
454
+ *
455
+ * When both fields are given the delay happens *first*, then the throw — that is
456
+ * the shape of a real store failure worth testing, a request that hangs and then
457
+ * fails, rather than one that fails instantly.
458
+ */
459
+ type MemoryDriverFault = {
460
+ throw: Error;
461
+ } | {
462
+ delayMs: number;
463
+ } | {
464
+ throw: Error;
465
+ delayMs: number;
466
+ };
467
+ interface MemoryDriverOptions {
468
+ /**
469
+ * Reported as `caps.casWildcard`. Configurable so the MinIO path — a store
470
+ * that supports `If-Match` but rejects `If-None-Match: *` — is testable
471
+ * without a MinIO. Defaults to `true`.
472
+ */
473
+ casWildcard?: boolean;
474
+ faults?: {
475
+ /**
476
+ * Called before each operation. Return a fault to inject it, or
477
+ * `undefined` / nothing to let the operation proceed.
478
+ */
479
+ before?(ctx: MemoryDriverFaultContext): MemoryDriverFault | void;
480
+ };
481
+ }
482
+ /** Test affordances layered on top of the driver contract. */
483
+ interface MemoryDriverHandle {
484
+ /**
485
+ * Call counts per operation, for asserting the *cost* of a design decision
486
+ * rather than its behavior. Counts attempts: a call a fault aborted is still
487
+ * a call the room decided to make, which is the thing under test.
488
+ */
489
+ stats: {
490
+ get: number;
491
+ put: number;
492
+ list: number;
493
+ delete: number;
494
+ };
495
+ /**
496
+ * Snapshot of the whole store, keys in ascending order, bodies copied. For
497
+ * asserting what actually landed without going through `list` + `get` (which
498
+ * would itself move `stats` and fire fault hooks).
499
+ */
500
+ dump(): Map<string, Uint8Array>;
501
+ }
502
+ declare function createMemoryDriver(options?: MemoryDriverOptions): ObjectDriver & MemoryDriverHandle;
503
+ declare function createFilesystemDriver(rootDir: string): ObjectDriver & {
504
+ close?(): void;
505
+ };
506
+ interface S3DriverConfig extends StoreConfig {
507
+ /**
508
+ * TEST-ONLY transport override. Lets a test stub S3 without a network and
509
+ * assert on the exact signed request. Not part of the public configuration
510
+ * surface; production always uses global `fetch`.
511
+ *
512
+ * @internal
513
+ */
514
+ fetch?: typeof fetch;
515
+ /**
516
+ * TEST-ONLY clock, threaded into `signRequest` so a test can pin `x-amz-date`
517
+ * and assert a byte-exact `Authorization` header.
518
+ *
519
+ * @internal
520
+ */
521
+ now?: () => Date;
522
+ /**
523
+ * TEST-ONLY sleep, so retry backoff does not burn wall-clock in the suite.
524
+ *
525
+ * @internal
526
+ */
527
+ sleep?: (ms: number) => Promise<void>;
528
+ }
529
+ declare function createS3Driver(config: S3DriverConfig): ObjectDriver;
530
+ /**
531
+ * Room key namespace. The driver owns bucket and prefix; the room path is the
532
+ * adapter's, because `roomId` is adapter configuration.
533
+ *
534
+ * `encodeURIComponent` is not cosmetic: a room id containing `/` or `..` would
535
+ * otherwise write into another room's namespace, and room ids routinely come
536
+ * from user-controlled URL segments via `resolveRoomKey`.
537
+ */
538
+ declare function roomPrefix(roomId: string): string;
539
+ /** The adapter plus the lifecycle and observability surface the design adds. */
540
+ interface ObjectStorage extends StorageAdapter {
541
+ /**
542
+ * Boots the room: manifest, snapshot, WAL replay. Idempotent, and implied by
543
+ * the first call to any other method — call it explicitly to surface boot
544
+ * failures at startup rather than on the first query.
545
+ */
546
+ init(): Promise<void>;
547
+ /** Drains the write buffer and resolves once everything buffered is durable. */
548
+ flush(): Promise<void>;
549
+ close(): Promise<void>;
550
+ readonly health: StorageHealth;
551
+ /** Highest HLC known durable. Phase 2 broadcasts this as the retire watermark. */
552
+ readonly durableHlc: string | null;
553
+ onDurable(callback: (hlc: string) => void): void;
554
+ onHealthChange(callback: (health: StorageHealth) => void): void;
555
+ /**
556
+ * Folds in anything another instance has committed since this one last
557
+ * looked, and reports whether the room actually moved.
558
+ *
559
+ * Only meaningful under `concurrency: "optimistic"`, where in-memory state is
560
+ * NOT authoritative — under `"single-writer"` this instance IS the writer, so
561
+ * there is nothing to catch up on and this resolves `false` without a request.
562
+ *
563
+ * Costs one GET, and does nothing further when the manifest's `commitSeq` has
564
+ * not moved. That is what makes a serverless poll loop affordable: the steady
565
+ * state is a single small conditional read, and queries re-run only when this
566
+ * returns true.
567
+ */
568
+ refresh(): Promise<boolean>;
569
+ }
570
+ interface ObjectStorageOptions {
571
+ /** Injected for deterministic tests; defaults to wall-clock time and timers. */
572
+ clock?: Clock;
573
+ /** Process-wide memory budget. Tests pass their own so suites do not share one. */
574
+ budget?: ProcessMemoryBudget;
575
+ }
576
+ declare function resolveObjectStorageConfig(config: ObjectStorageConfig): ResolvedObjectStorageConfig;
577
+ declare function createObjectStorage(config: ObjectStorageConfig, options?: ObjectStorageOptions): ObjectStorage;
578
+ export { BackpressureError, BackpressurePolicy, BatchConfig, CompactionConfig2 as CompactionConfig, DurabilityMode, IncompleteStateError, LeaseConfig, LeaseMode, LeaseRecord, ManifestRecord, MemoryConfig, MemoryLimitExceededError, MemoryPolicy, NotWriterError, OBJECT_STORAGE_DEFAULTS, ObjectDriver, ObjectDriverCapabilities, ObjectListEntry, ObjectPutOptions, ObjectRecord, ObjectStorage, ObjectStorageConfig, ObjectStorageOptions, PreconditionFailedError, ResolvedObjectStorageConfig, SnapshotRecord, SnapshotRow, StorageHealth, StoreConfig, StoreCredentials, StoreProvider, WalSegmentRef, createFilesystemDriver, createMemoryDriver, createObjectStorage, createS3Driver, resolveObjectStorageConfig, roomPrefix };
@@ -1,4 +1,3 @@
1
- var import_node_module = require("node:module");
2
1
  var __defProp = Object.defineProperty;
3
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -1,4 +1,3 @@
1
- var import_node_module = require("node:module");
2
1
  var __defProp = Object.defineProperty;
3
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -1,4 +1,3 @@
1
- var import_node_module = require("node:module");
2
1
  var __defProp = Object.defineProperty;
3
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -1,4 +1,3 @@
1
- var import_node_module = require("node:module");
2
1
  var __defProp = Object.defineProperty;
3
2
  var __getOwnPropNames = Object.getOwnPropertyNames;
4
3
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
@@ -112,11 +111,16 @@ __export(exports_sse, {
112
111
  createSseServerTransport: () => createSseServerTransport
113
112
  });
114
113
  module.exports = __toCommonJS(exports_sse);
114
+ var COLLECT_STABLE_TICKS = 3;
115
+ var MAX_COLLECT_TICKS = 60;
115
116
  function createSseServerTransport(cfg = {}) {
116
117
  const controllers = new Map;
117
118
  const buffers = new Map;
118
119
  const counters = new Map;
119
120
  const replayBufferSize = cfg.replayBufferSize ?? 256;
121
+ const serverless = cfg.serverless ?? false;
122
+ const collecting = new Map;
123
+ const connected = new Set;
120
124
  const maxMessageBytes = cfg.maxMessageBytes ?? 1e6;
121
125
  let messageHandler = null;
122
126
  let connectHandler = null;
@@ -164,7 +168,10 @@ data: ${data}
164
168
  }
165
169
  }
166
170
  }
167
- connectHandler?.(clientId, new Request("https://sse-connect"));
171
+ if (!connected.has(clientId)) {
172
+ connected.add(clientId);
173
+ connectHandler?.(clientId, new Request("https://sse-connect"));
174
+ }
168
175
  },
169
176
  handleMessage(clientId, message) {
170
177
  let byteLen;
@@ -182,6 +189,7 @@ data: ${data}
182
189
  },
183
190
  handleDisconnect(clientId) {
184
191
  controllers.delete(clientId);
192
+ connected.delete(clientId);
185
193
  disconnectHandler?.(clientId);
186
194
  },
187
195
  createEventStream(clientId, lastEventId) {
@@ -195,6 +203,11 @@ data: ${data}
195
203
  });
196
204
  },
197
205
  async send(clientId, message) {
206
+ const sink = collecting.get(clientId);
207
+ if (sink) {
208
+ sink.push(message);
209
+ return;
210
+ }
198
211
  const id = nextId(clientId);
199
212
  const encoded = encode(id, message);
200
213
  const controller = controllers.get(clientId);
@@ -216,6 +229,31 @@ data: ${data}
216
229
  throw new TransportSendError(clientId, `sse replay buffer overflow (size ${replayBufferSize}); undelivered frames dropped`);
217
230
  }
218
231
  },
232
+ async collectReplies(clientId, message, settle) {
233
+ if (!serverless) {
234
+ this.handleMessage(clientId, message);
235
+ return [];
236
+ }
237
+ if (!connected.has(clientId)) {
238
+ connected.add(clientId);
239
+ connectHandler?.(clientId, new Request("https://sse-connect"));
240
+ }
241
+ const sink = [];
242
+ collecting.set(clientId, sink);
243
+ try {
244
+ this.handleMessage(clientId, message);
245
+ await (settle?.() ?? Promise.resolve());
246
+ let stable = 0;
247
+ for (let i = 0;i < MAX_COLLECT_TICKS && stable < COLLECT_STABLE_TICKS; i++) {
248
+ const before = sink.length;
249
+ await new Promise((resolve) => setTimeout(resolve, 0));
250
+ stable = sink.length === before ? stable + 1 : 0;
251
+ }
252
+ } finally {
253
+ collecting.delete(clientId);
254
+ }
255
+ return sink;
256
+ },
219
257
  async broadcast(_roomId, message, exclude) {
220
258
  for (const [clientId, controller] of controllers) {
221
259
  if (clientId === exclude)
@@ -281,6 +319,18 @@ function createSseClientTransport(config) {
281
319
  if (!res.ok) {
282
320
  throw new Error(`SSE send failed: ${res.status} ${res.statusText}`);
283
321
  }
322
+ if (!config.serverless)
323
+ return;
324
+ let replies;
325
+ try {
326
+ const body = await res.json();
327
+ replies = Array.isArray(body) ? body : body.messages ?? [];
328
+ } catch {
329
+ console.warn("[reflectdb] SSE: serverless mode is on but the POST returned no JSON replies. " + "Set `serverless: true` on createSseServerTransport and return " + "`collectReplies(...)` from the message endpoint.");
330
+ return;
331
+ }
332
+ for (const reply of replies)
333
+ handler?.(reply);
284
334
  },
285
335
  subscribe(h) {
286
336
  handler = h;