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.
- package/README.md +483 -17
- package/dist/cjs/client/index.cjs +0 -1
- package/dist/cjs/client/storage/indexeddb.cjs +0 -1
- package/dist/cjs/core/index.cjs +0 -1
- package/dist/cjs/htmx/index.cjs +1720 -0
- package/dist/cjs/htmx/index.d.cts +760 -0
- package/dist/cjs/react/index.cjs +0 -1
- package/dist/cjs/server/drizzle.cjs +15 -4
- package/dist/cjs/server/ephemeral/index.cjs +0 -1
- package/dist/cjs/server/ephemeral/redis.cjs +0 -1
- package/dist/cjs/server/index.cjs +34 -13
- package/dist/cjs/server/index.d.cts +14 -0
- package/dist/cjs/server/storage/object/index.cjs +2284 -0
- package/dist/cjs/server/storage/object/index.d.cts +578 -0
- package/dist/cjs/svelte/index.cjs +0 -1
- package/dist/cjs/transport/bun-ws.cjs +0 -1
- package/dist/cjs/transport/polling.cjs +0 -1
- package/dist/cjs/transport/sse.cjs +52 -2
- package/dist/cjs/transport/sse.d.cts +34 -0
- package/dist/cjs/transport/ws.cjs +0 -1
- package/dist/cjs/vanilla/index.cjs +0 -1
- package/dist/client/index.js +1 -1
- package/dist/client/storage/indexeddb.js +1 -1
- package/dist/core/index.js +1 -1
- package/dist/htmx/index.d.ts +760 -0
- package/dist/htmx/index.js +297 -0
- package/dist/react/index.js +1 -1
- package/dist/server/drizzle.js +3 -2
- package/dist/server/ephemeral/index.js +1 -1
- package/dist/server/ephemeral/redis.js +1 -1
- package/dist/server/index.d.ts +14 -0
- package/dist/server/index.js +19 -6
- package/dist/server/storage/object/index.d.ts +578 -0
- package/dist/server/storage/object/index.js +2232 -0
- package/dist/shared/{esm-z1xse19c.js → esm-5ahpq25j.js} +4 -4
- package/dist/shared/esm-dcs8qa5n.js +263 -0
- package/dist/shared/esm-f11s9zpb.js +15 -0
- package/dist/shared/esm-g5h4a88j.js +9 -0
- package/dist/shared/{esm-ck88h30s.js → esm-xtkhzxg2.js} +1 -1
- package/dist/svelte/index.js +1 -1
- package/dist/transport/bun-ws.js +1 -1
- package/dist/transport/polling.js +1 -1
- package/dist/transport/sse.d.ts +34 -0
- package/dist/transport/sse.js +53 -2
- package/dist/transport/ws.js +1 -1
- package/dist/vanilla/index.js +5 -261
- package/package.json +26 -3
- 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;
|
|
@@ -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
|
-
|
|
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;
|