bundis 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +289 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/package.json +47 -0
- package/src/cli.ts +38 -0
- package/src/commands/expire.ts +48 -0
- package/src/commands/handshake.ts +147 -0
- package/src/commands/hash.ts +106 -0
- package/src/commands/multikey.ts +63 -0
- package/src/commands/pubsub.ts +106 -0
- package/src/commands/set.ts +58 -0
- package/src/commands/string.ts +156 -0
- package/src/commands/transaction.ts +85 -0
- package/src/config.ts +57 -0
- package/src/connection.ts +97 -0
- package/src/dispatcher.ts +204 -0
- package/src/engine/context.ts +95 -0
- package/src/engine/errors.ts +93 -0
- package/src/index.ts +21 -0
- package/src/launch.ts +188 -0
- package/src/resp/parser.ts +133 -0
- package/src/resp/serializer.ts +114 -0
- package/src/resp/types.ts +49 -0
- package/src/server.ts +92 -0
- package/src/sidecar/pubsub.ts +162 -0
- package/src/sidecar/reaper.ts +34 -0
- package/src/sidecar/watch.ts +33 -0
- package/src/storage/sqlite.ts +567 -0
- package/src/storage/types.ts +82 -0
|
@@ -0,0 +1,567 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L6 SqliteStorage — bun:sqlite implementation of {@link StorageEngine}.
|
|
3
|
+
*
|
|
4
|
+
* Design (CLAUDE.md §5): metadata is unified in `keys` (type + ttl); values are
|
|
5
|
+
* split per type (`kv` / `hash_fields` / `set_members`). All payloads are BLOB
|
|
6
|
+
* for binary safety. WAL mode + single-writer assumption keep atomicity simple.
|
|
7
|
+
* bun:sqlite is synchronous, so read-modify-write under `withTransaction` is a
|
|
8
|
+
* genuine atomic unit.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Database } from "bun:sqlite";
|
|
12
|
+
import {
|
|
13
|
+
NotFloatError,
|
|
14
|
+
NotIntegerError,
|
|
15
|
+
RespError,
|
|
16
|
+
TypeMismatchError,
|
|
17
|
+
} from "../engine/errors";
|
|
18
|
+
import type { RedisType, SetOptions, StorageEngine } from "./types";
|
|
19
|
+
|
|
20
|
+
export interface SqliteStorageOptions {
|
|
21
|
+
/** Hook invoked with a key whenever it is mutated (drives WATCH versioning). */
|
|
22
|
+
readonly onWrite?: (key: Uint8Array) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class SqliteStorage implements StorageEngine {
|
|
26
|
+
#db: Database;
|
|
27
|
+
#onWrite: (key: Uint8Array) => void;
|
|
28
|
+
|
|
29
|
+
constructor(path = ":memory:", opts: SqliteStorageOptions = {}) {
|
|
30
|
+
this.#db = new Database(path, { create: true, strict: false });
|
|
31
|
+
this.#onWrite = opts.onWrite ?? (() => {});
|
|
32
|
+
this.#init();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
#init(): void {
|
|
36
|
+
this.#db.exec("PRAGMA journal_mode = WAL;");
|
|
37
|
+
this.#db.exec("PRAGMA synchronous = NORMAL;");
|
|
38
|
+
this.#db.exec("PRAGMA foreign_keys = ON;");
|
|
39
|
+
this.#db.exec(`
|
|
40
|
+
CREATE TABLE IF NOT EXISTS keys (
|
|
41
|
+
key BLOB PRIMARY KEY,
|
|
42
|
+
type TEXT NOT NULL,
|
|
43
|
+
expire_at_ms INTEGER
|
|
44
|
+
) WITHOUT ROWID;
|
|
45
|
+
CREATE INDEX IF NOT EXISTS idx_keys_expire
|
|
46
|
+
ON keys(expire_at_ms) WHERE expire_at_ms IS NOT NULL;
|
|
47
|
+
CREATE TABLE IF NOT EXISTS kv (
|
|
48
|
+
key BLOB PRIMARY KEY REFERENCES keys(key) ON DELETE CASCADE,
|
|
49
|
+
value BLOB NOT NULL
|
|
50
|
+
) WITHOUT ROWID;
|
|
51
|
+
CREATE TABLE IF NOT EXISTS hash_fields (
|
|
52
|
+
key BLOB NOT NULL,
|
|
53
|
+
field BLOB NOT NULL,
|
|
54
|
+
value BLOB NOT NULL,
|
|
55
|
+
PRIMARY KEY (key, field),
|
|
56
|
+
FOREIGN KEY (key) REFERENCES keys(key) ON DELETE CASCADE
|
|
57
|
+
) WITHOUT ROWID;
|
|
58
|
+
CREATE TABLE IF NOT EXISTS set_members (
|
|
59
|
+
key BLOB NOT NULL,
|
|
60
|
+
member BLOB NOT NULL,
|
|
61
|
+
PRIMARY KEY (key, member),
|
|
62
|
+
FOREIGN KEY (key) REFERENCES keys(key) ON DELETE CASCADE
|
|
63
|
+
) WITHOUT ROWID;
|
|
64
|
+
`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ── meta / ttl ──────────────────────────────────────────────────────────--
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Resolve a key's live metadata, lazily expiring it first (§5.3.1).
|
|
71
|
+
* Returns null when the key is absent or just expired (and deletes it).
|
|
72
|
+
*/
|
|
73
|
+
#meta(key: Uint8Array, now: number): { type: RedisType; expireAtMs: number | null } | null {
|
|
74
|
+
const row = this.#stmt(
|
|
75
|
+
"SELECT type, expire_at_ms FROM keys WHERE key = ?",
|
|
76
|
+
).get(key) as { type: RedisType; expire_at_ms: number | null } | null;
|
|
77
|
+
if (!row) return null;
|
|
78
|
+
if (row.expire_at_ms !== null && row.expire_at_ms <= now) {
|
|
79
|
+
this.#deleteKey(key);
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
return { type: row.type, expireAtMs: row.expire_at_ms };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Like {@link #meta} but throws TypeMismatchError if a live key isn't `want`. */
|
|
86
|
+
#expectType(key: Uint8Array, want: RedisType, now: number): boolean {
|
|
87
|
+
const meta = this.#meta(key, now);
|
|
88
|
+
if (!meta) return false;
|
|
89
|
+
if (meta.type !== want) throw new TypeMismatchError(meta.type, want);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
#deleteKey(key: Uint8Array): void {
|
|
94
|
+
// ON DELETE CASCADE removes child rows in kv/hash_fields/set_members.
|
|
95
|
+
this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
|
|
96
|
+
this.#onWrite(key);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#ensureKey(key: Uint8Array, type: RedisType): void {
|
|
100
|
+
this.#stmt(
|
|
101
|
+
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, ?, NULL) " +
|
|
102
|
+
"ON CONFLICT(key) DO NOTHING",
|
|
103
|
+
).run(key, type);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
typeOf(key: Uint8Array, now: number): RedisType | null {
|
|
107
|
+
return this.#meta(key, now)?.type ?? null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
exists(key: Uint8Array, now: number): boolean {
|
|
111
|
+
return this.#meta(key, now) !== null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
del(keys: Uint8Array[], now: number): number {
|
|
115
|
+
return this.withTransaction(() => {
|
|
116
|
+
let n = 0;
|
|
117
|
+
for (const key of keys) {
|
|
118
|
+
if (this.#meta(key, now) !== null) {
|
|
119
|
+
this.#deleteKey(key);
|
|
120
|
+
n++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return n;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
expireSet(key: Uint8Array, atMs: number, now: number): boolean {
|
|
128
|
+
if (this.#meta(key, now) === null) return false;
|
|
129
|
+
this.#stmt("UPDATE keys SET expire_at_ms = ? WHERE key = ?").run(atMs, key);
|
|
130
|
+
this.#onWrite(key);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pttl(key: Uint8Array, now: number): number {
|
|
135
|
+
const meta = this.#meta(key, now);
|
|
136
|
+
if (!meta) return -2;
|
|
137
|
+
if (meta.expireAtMs === null) return -1;
|
|
138
|
+
return Math.max(0, meta.expireAtMs - now);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
persist(key: Uint8Array, now: number): boolean {
|
|
142
|
+
const meta = this.#meta(key, now);
|
|
143
|
+
if (!meta || meta.expireAtMs === null) return false;
|
|
144
|
+
this.#stmt("UPDATE keys SET expire_at_ms = NULL WHERE key = ?").run(key);
|
|
145
|
+
this.#onWrite(key);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
sweepExpired(now: number): number {
|
|
150
|
+
return this.withTransaction(() => {
|
|
151
|
+
const rows = this.#stmt(
|
|
152
|
+
"SELECT key FROM keys WHERE expire_at_ms IS NOT NULL AND expire_at_ms <= ?",
|
|
153
|
+
).all(now) as Array<{ key: Uint8Array }>;
|
|
154
|
+
for (const r of rows) this.#deleteKey(r.key);
|
|
155
|
+
return rows.length;
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
dbsize(now: number): number {
|
|
160
|
+
this.sweepExpired(now);
|
|
161
|
+
const row = this.#stmt("SELECT COUNT(*) AS n FROM keys").get() as { n: number };
|
|
162
|
+
return row.n;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── string / kv ─────────────────────────────────────────────────────────--
|
|
166
|
+
|
|
167
|
+
kvGet(key: Uint8Array, now: number): Uint8Array | null {
|
|
168
|
+
if (!this.#expectType(key, "string", now)) return null;
|
|
169
|
+
const row = this.#stmt("SELECT value FROM kv WHERE key = ?").get(key) as
|
|
170
|
+
| { value: Uint8Array }
|
|
171
|
+
| null;
|
|
172
|
+
return row ? row.value : null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
kvSet(
|
|
176
|
+
key: Uint8Array,
|
|
177
|
+
value: Uint8Array,
|
|
178
|
+
now: number,
|
|
179
|
+
opts: SetOptions = {},
|
|
180
|
+
): "set" | "noop" {
|
|
181
|
+
return this.withTransaction(() => {
|
|
182
|
+
const meta = this.#meta(key, now);
|
|
183
|
+
if (opts.mode === "NX" && meta !== null) return "noop";
|
|
184
|
+
if (opts.mode === "XX" && meta === null) return "noop";
|
|
185
|
+
if (meta !== null && meta.type !== "string") {
|
|
186
|
+
// Overwriting any existing type with a string is allowed in Redis.
|
|
187
|
+
this.#deleteKey(key);
|
|
188
|
+
}
|
|
189
|
+
const expire =
|
|
190
|
+
opts.expireAtMs !== undefined && opts.expireAtMs !== null
|
|
191
|
+
? opts.expireAtMs
|
|
192
|
+
: opts.keepTtl && meta?.type === "string"
|
|
193
|
+
? (meta.expireAtMs ?? null)
|
|
194
|
+
: null;
|
|
195
|
+
this.#stmt(
|
|
196
|
+
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', ?) " +
|
|
197
|
+
"ON CONFLICT(key) DO UPDATE SET type='string', expire_at_ms=excluded.expire_at_ms",
|
|
198
|
+
).run(key, expire);
|
|
199
|
+
this.#stmt(
|
|
200
|
+
"INSERT INTO kv(key, value) VALUES (?, ?) " +
|
|
201
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
202
|
+
).run(key, value);
|
|
203
|
+
this.#onWrite(key);
|
|
204
|
+
return "set";
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
incrBy(key: Uint8Array, delta: bigint, now: number): bigint {
|
|
209
|
+
return this.withTransaction(() => {
|
|
210
|
+
const cur = this.kvGet(key, now);
|
|
211
|
+
let n: bigint;
|
|
212
|
+
if (cur === null) {
|
|
213
|
+
n = 0n;
|
|
214
|
+
} else {
|
|
215
|
+
n = parseIntStrict(cur);
|
|
216
|
+
}
|
|
217
|
+
const next = n + delta;
|
|
218
|
+
const buf = new TextEncoder().encode(next.toString());
|
|
219
|
+
this.#stmt(
|
|
220
|
+
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', NULL) " +
|
|
221
|
+
"ON CONFLICT(key) DO UPDATE SET type='string'",
|
|
222
|
+
).run(key);
|
|
223
|
+
this.#stmt(
|
|
224
|
+
"INSERT INTO kv(key, value) VALUES (?, ?) " +
|
|
225
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
226
|
+
).run(key, buf);
|
|
227
|
+
this.#onWrite(key);
|
|
228
|
+
return next;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
incrByFloat(key: Uint8Array, delta: number, now: number): number {
|
|
233
|
+
return this.withTransaction(() => {
|
|
234
|
+
const cur = this.kvGet(key, now);
|
|
235
|
+
const n = cur === null ? 0 : parseFloatStrict(cur);
|
|
236
|
+
const next = n + delta;
|
|
237
|
+
if (!Number.isFinite(next)) {
|
|
238
|
+
throw new RespError("ERR", "increment would produce NaN or Infinity");
|
|
239
|
+
}
|
|
240
|
+
const buf = new TextEncoder().encode(formatFloat(next));
|
|
241
|
+
this.#stmt(
|
|
242
|
+
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'string', NULL) " +
|
|
243
|
+
"ON CONFLICT(key) DO UPDATE SET type='string'",
|
|
244
|
+
).run(key);
|
|
245
|
+
this.#stmt(
|
|
246
|
+
"INSERT INTO kv(key, value) VALUES (?, ?) " +
|
|
247
|
+
"ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
|
248
|
+
).run(key, buf);
|
|
249
|
+
this.#onWrite(key);
|
|
250
|
+
return next;
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
append(key: Uint8Array, value: Uint8Array, now: number): number {
|
|
255
|
+
return this.withTransaction(() => {
|
|
256
|
+
const cur = this.kvGet(key, now);
|
|
257
|
+
const next =
|
|
258
|
+
cur === null ? value : concatBytes(cur, value);
|
|
259
|
+
this.kvSet(key, next, now, { keepTtl: true });
|
|
260
|
+
return next.length;
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ── hash ───────────────────────────────────────────────────────────────---
|
|
265
|
+
|
|
266
|
+
hSet(
|
|
267
|
+
key: Uint8Array,
|
|
268
|
+
pairs: ReadonlyArray<readonly [Uint8Array, Uint8Array]>,
|
|
269
|
+
now: number,
|
|
270
|
+
): number {
|
|
271
|
+
return this.withTransaction(() => {
|
|
272
|
+
this.#expectType(key, "hash", now);
|
|
273
|
+
this.#stmt(
|
|
274
|
+
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'hash', NULL) " +
|
|
275
|
+
"ON CONFLICT(key) DO NOTHING",
|
|
276
|
+
).run(key);
|
|
277
|
+
let added = 0;
|
|
278
|
+
const exists = this.#stmt("SELECT 1 FROM hash_fields WHERE key = ? AND field = ?");
|
|
279
|
+
const upsert = this.#stmt(
|
|
280
|
+
"INSERT INTO hash_fields(key, field, value) VALUES (?, ?, ?) " +
|
|
281
|
+
"ON CONFLICT(key, field) DO UPDATE SET value=excluded.value",
|
|
282
|
+
);
|
|
283
|
+
for (const [field, value] of pairs) {
|
|
284
|
+
if (!exists.get(key, field)) added++;
|
|
285
|
+
upsert.run(key, field, value);
|
|
286
|
+
}
|
|
287
|
+
this.#onWrite(key);
|
|
288
|
+
return added;
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
hGet(key: Uint8Array, field: Uint8Array, now: number): Uint8Array | null {
|
|
293
|
+
if (!this.#expectType(key, "hash", now)) return null;
|
|
294
|
+
const row = this.#stmt(
|
|
295
|
+
"SELECT value FROM hash_fields WHERE key = ? AND field = ?",
|
|
296
|
+
).get(key, field) as { value: Uint8Array } | null;
|
|
297
|
+
return row ? row.value : null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
hDel(key: Uint8Array, fields: Uint8Array[], now: number): number {
|
|
301
|
+
return this.withTransaction(() => {
|
|
302
|
+
if (!this.#expectType(key, "hash", now)) return 0;
|
|
303
|
+
const del = this.#stmt("DELETE FROM hash_fields WHERE key = ? AND field = ?");
|
|
304
|
+
let n = 0;
|
|
305
|
+
for (const f of fields) {
|
|
306
|
+
const changes = (del.run(key, f) as { changes: number }).changes;
|
|
307
|
+
n += changes;
|
|
308
|
+
}
|
|
309
|
+
this.#dropIfEmptyHash(key);
|
|
310
|
+
this.#onWrite(key);
|
|
311
|
+
return n;
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
hGetAll(key: Uint8Array, now: number): Array<[Uint8Array, Uint8Array]> {
|
|
316
|
+
if (!this.#expectType(key, "hash", now)) return [];
|
|
317
|
+
const rows = this.#stmt(
|
|
318
|
+
"SELECT field, value FROM hash_fields WHERE key = ?",
|
|
319
|
+
).all(key) as Array<{ field: Uint8Array; value: Uint8Array }>;
|
|
320
|
+
return rows.map((r) => [r.field, r.value]);
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
hKeys(key: Uint8Array, now: number): Uint8Array[] {
|
|
324
|
+
if (!this.#expectType(key, "hash", now)) return [];
|
|
325
|
+
return (
|
|
326
|
+
this.#stmt("SELECT field FROM hash_fields WHERE key = ?").all(key) as Array<{
|
|
327
|
+
field: Uint8Array;
|
|
328
|
+
}>
|
|
329
|
+
).map((r) => r.field);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
hVals(key: Uint8Array, now: number): Uint8Array[] {
|
|
333
|
+
if (!this.#expectType(key, "hash", now)) return [];
|
|
334
|
+
return (
|
|
335
|
+
this.#stmt("SELECT value FROM hash_fields WHERE key = ?").all(key) as Array<{
|
|
336
|
+
value: Uint8Array;
|
|
337
|
+
}>
|
|
338
|
+
).map((r) => r.value);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
hLen(key: Uint8Array, now: number): number {
|
|
342
|
+
if (!this.#expectType(key, "hash", now)) return 0;
|
|
343
|
+
const row = this.#stmt(
|
|
344
|
+
"SELECT COUNT(*) AS n FROM hash_fields WHERE key = ?",
|
|
345
|
+
).get(key) as { n: number };
|
|
346
|
+
return row.n;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
hExists(key: Uint8Array, field: Uint8Array, now: number): boolean {
|
|
350
|
+
if (!this.#expectType(key, "hash", now)) return false;
|
|
351
|
+
return (
|
|
352
|
+
this.#stmt("SELECT 1 FROM hash_fields WHERE key = ? AND field = ?").get(
|
|
353
|
+
key,
|
|
354
|
+
field,
|
|
355
|
+
) !== null
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
hIncrBy(key: Uint8Array, field: Uint8Array, delta: bigint, now: number): bigint {
|
|
360
|
+
return this.withTransaction(() => {
|
|
361
|
+
const cur = this.hGet(key, field, now);
|
|
362
|
+
const n = cur === null ? 0n : parseIntStrict(cur);
|
|
363
|
+
const next = n + delta;
|
|
364
|
+
this.hSet(key, [[field, new TextEncoder().encode(next.toString())]], now);
|
|
365
|
+
return next;
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
hIncrByFloat(key: Uint8Array, field: Uint8Array, delta: number, now: number): number {
|
|
370
|
+
return this.withTransaction(() => {
|
|
371
|
+
const cur = this.hGet(key, field, now);
|
|
372
|
+
const n = cur === null ? 0 : parseFloatStrict(cur);
|
|
373
|
+
const next = n + delta;
|
|
374
|
+
this.hSet(key, [[field, new TextEncoder().encode(formatFloat(next))]], now);
|
|
375
|
+
return next;
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
#dropIfEmptyHash(key: Uint8Array): void {
|
|
380
|
+
const row = this.#stmt(
|
|
381
|
+
"SELECT COUNT(*) AS n FROM hash_fields WHERE key = ?",
|
|
382
|
+
).get(key) as { n: number };
|
|
383
|
+
if (row.n === 0) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── set ────────────────────────────────────────────────────────────────---
|
|
387
|
+
|
|
388
|
+
sAdd(key: Uint8Array, members: Uint8Array[], now: number): number {
|
|
389
|
+
return this.withTransaction(() => {
|
|
390
|
+
this.#expectType(key, "set", now);
|
|
391
|
+
this.#stmt(
|
|
392
|
+
"INSERT INTO keys(key, type, expire_at_ms) VALUES (?, 'set', NULL) " +
|
|
393
|
+
"ON CONFLICT(key) DO NOTHING",
|
|
394
|
+
).run(key);
|
|
395
|
+
const ins = this.#stmt(
|
|
396
|
+
"INSERT INTO set_members(key, member) VALUES (?, ?) " +
|
|
397
|
+
"ON CONFLICT(key, member) DO NOTHING",
|
|
398
|
+
);
|
|
399
|
+
let added = 0;
|
|
400
|
+
for (const m of members) {
|
|
401
|
+
added += (ins.run(key, m) as { changes: number }).changes;
|
|
402
|
+
}
|
|
403
|
+
this.#onWrite(key);
|
|
404
|
+
return added;
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
sRem(key: Uint8Array, members: Uint8Array[], now: number): number {
|
|
409
|
+
return this.withTransaction(() => {
|
|
410
|
+
if (!this.#expectType(key, "set", now)) return 0;
|
|
411
|
+
const del = this.#stmt("DELETE FROM set_members WHERE key = ? AND member = ?");
|
|
412
|
+
let n = 0;
|
|
413
|
+
for (const m of members) n += (del.run(key, m) as { changes: number }).changes;
|
|
414
|
+
this.#dropIfEmptySet(key);
|
|
415
|
+
this.#onWrite(key);
|
|
416
|
+
return n;
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
sIsMember(key: Uint8Array, member: Uint8Array, now: number): boolean {
|
|
421
|
+
if (!this.#expectType(key, "set", now)) return false;
|
|
422
|
+
return (
|
|
423
|
+
this.#stmt("SELECT 1 FROM set_members WHERE key = ? AND member = ?").get(
|
|
424
|
+
key,
|
|
425
|
+
member,
|
|
426
|
+
) !== null
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
sMembers(key: Uint8Array, now: number): Uint8Array[] {
|
|
431
|
+
if (!this.#expectType(key, "set", now)) return [];
|
|
432
|
+
return (
|
|
433
|
+
this.#stmt("SELECT member FROM set_members WHERE key = ?").all(key) as Array<{
|
|
434
|
+
member: Uint8Array;
|
|
435
|
+
}>
|
|
436
|
+
).map((r) => r.member);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
sCard(key: Uint8Array, now: number): number {
|
|
440
|
+
if (!this.#expectType(key, "set", now)) return 0;
|
|
441
|
+
const row = this.#stmt(
|
|
442
|
+
"SELECT COUNT(*) AS n FROM set_members WHERE key = ?",
|
|
443
|
+
).get(key) as { n: number };
|
|
444
|
+
return row.n;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
sRandMember(
|
|
448
|
+
key: Uint8Array,
|
|
449
|
+
count: number | null,
|
|
450
|
+
now: number,
|
|
451
|
+
): Uint8Array[] | Uint8Array | null {
|
|
452
|
+
const members = this.sMembers(key, now);
|
|
453
|
+
if (count === null) {
|
|
454
|
+
if (members.length === 0) return null;
|
|
455
|
+
return members[Math.floor(Math.random() * members.length)]!;
|
|
456
|
+
}
|
|
457
|
+
if (count >= 0) {
|
|
458
|
+
return shuffle(members).slice(0, count);
|
|
459
|
+
}
|
|
460
|
+
// Negative count: allow repeats, |count| elements.
|
|
461
|
+
const out: Uint8Array[] = [];
|
|
462
|
+
if (members.length === 0) return out;
|
|
463
|
+
for (let i = 0; i < -count; i++) {
|
|
464
|
+
out.push(members[Math.floor(Math.random() * members.length)]!);
|
|
465
|
+
}
|
|
466
|
+
return out;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
sPop(
|
|
470
|
+
key: Uint8Array,
|
|
471
|
+
count: number | null,
|
|
472
|
+
now: number,
|
|
473
|
+
): Uint8Array[] | Uint8Array | null {
|
|
474
|
+
return this.withTransaction(() => {
|
|
475
|
+
const members = this.sMembers(key, now);
|
|
476
|
+
if (count === null) {
|
|
477
|
+
if (members.length === 0) return null;
|
|
478
|
+
const pick = members[Math.floor(Math.random() * members.length)]!;
|
|
479
|
+
this.sRem(key, [pick], now);
|
|
480
|
+
return pick;
|
|
481
|
+
}
|
|
482
|
+
const picks = shuffle(members).slice(0, Math.max(0, count));
|
|
483
|
+
if (picks.length > 0) this.sRem(key, picks, now);
|
|
484
|
+
return picks;
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
#dropIfEmptySet(key: Uint8Array): void {
|
|
489
|
+
const row = this.#stmt(
|
|
490
|
+
"SELECT COUNT(*) AS n FROM set_members WHERE key = ?",
|
|
491
|
+
).get(key) as { n: number };
|
|
492
|
+
if (row.n === 0) this.#stmt("DELETE FROM keys WHERE key = ?").run(key);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// ── atomicity / lifecycle ───────────────────────────────────────────────--
|
|
496
|
+
|
|
497
|
+
withTransaction<T>(fn: () => T): T {
|
|
498
|
+
// bun:sqlite's Database.transaction wraps in BEGIN/COMMIT/ROLLBACK and is
|
|
499
|
+
// re-entrant (nested calls become SAVEPOINTs), which we rely on since many
|
|
500
|
+
// primitives call each other.
|
|
501
|
+
return this.#db.transaction(fn)();
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
close(): void {
|
|
505
|
+
this.#db.close();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// ── statement cache ─────────────────────────────────────────────────────--
|
|
509
|
+
|
|
510
|
+
#stmtCache = new Map<string, ReturnType<Database["query"]>>();
|
|
511
|
+
#stmt(sql: string): ReturnType<Database["query"]> {
|
|
512
|
+
let s = this.#stmtCache.get(sql);
|
|
513
|
+
if (!s) {
|
|
514
|
+
s = this.#db.query(sql);
|
|
515
|
+
this.#stmtCache.set(sql, s);
|
|
516
|
+
}
|
|
517
|
+
return s;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── helpers ─────────────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
function parseIntStrict(bytes: Uint8Array): bigint {
|
|
524
|
+
const s = new TextDecoder().decode(bytes).trim();
|
|
525
|
+
if (!/^[+-]?\d+$/.test(s)) {
|
|
526
|
+
throw new NotIntegerError();
|
|
527
|
+
}
|
|
528
|
+
return BigInt(s);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function parseFloatStrict(bytes: Uint8Array): number {
|
|
532
|
+
const s = new TextDecoder().decode(bytes).trim();
|
|
533
|
+
if (s.length === 0) throw new NotFloatError();
|
|
534
|
+
const lower = s.toLowerCase();
|
|
535
|
+
if (lower === "inf" || lower === "+inf") return Infinity;
|
|
536
|
+
if (lower === "-inf") return -Infinity;
|
|
537
|
+
const n = Number(s);
|
|
538
|
+
if (Number.isNaN(n)) throw new NotFloatError();
|
|
539
|
+
return n;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function formatFloat(n: number): string {
|
|
543
|
+
if (n === Infinity) return "inf";
|
|
544
|
+
if (n === -Infinity) return "-inf";
|
|
545
|
+
// Trim trailing zeros the way Redis does for INCRBYFLOAT output.
|
|
546
|
+
let s = n.toPrecision(17);
|
|
547
|
+
if (s.includes(".") && !s.includes("e") && !s.includes("E")) {
|
|
548
|
+
s = s.replace(/0+$/, "").replace(/\.$/, "");
|
|
549
|
+
}
|
|
550
|
+
return Number(s).toString();
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|
554
|
+
const out = new Uint8Array(a.length + b.length);
|
|
555
|
+
out.set(a, 0);
|
|
556
|
+
out.set(b, a.length);
|
|
557
|
+
return out;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function shuffle<T>(arr: T[]): T[] {
|
|
561
|
+
const a = arr.slice();
|
|
562
|
+
for (let i = a.length - 1; i > 0; i--) {
|
|
563
|
+
const j = Math.floor(Math.random() * (i + 1));
|
|
564
|
+
[a[i], a[j]] = [a[j]!, a[i]!];
|
|
565
|
+
}
|
|
566
|
+
return a;
|
|
567
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* L6 storage abstraction (the storage SSOT, §3.2).
|
|
3
|
+
*
|
|
4
|
+
* The command engine depends only on this interface — never on SQLite directly.
|
|
5
|
+
* It exposes storage *primitives* (kv / field-map / member-set / ttl), not Redis
|
|
6
|
+
* commands, so a different backend or an in-memory mock could be swapped in.
|
|
7
|
+
*
|
|
8
|
+
* Binary safety: keys, values, fields and members are all raw byte arrays.
|
|
9
|
+
* Methods perform lazy expiry internally (an expired key reads as absent and is
|
|
10
|
+
* removed). Type collisions throw {@link ../engine/errors.TypeMismatchError}.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export type RedisType = "string" | "hash" | "set" | "list" | "zset";
|
|
14
|
+
|
|
15
|
+
export interface SetOptions {
|
|
16
|
+
/** Absolute expiry epoch-ms to assign, or null to leave/clear per `keepTtl`. */
|
|
17
|
+
readonly expireAtMs?: number | null;
|
|
18
|
+
/** NX: only set if key does not exist. XX: only if it exists. */
|
|
19
|
+
readonly mode?: "NX" | "XX";
|
|
20
|
+
/** Preserve an existing TTL instead of clearing it. */
|
|
21
|
+
readonly keepTtl?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface StorageEngine {
|
|
25
|
+
// ── meta / ttl ────────────────────────────────────────────────────────────
|
|
26
|
+
/** Stored type of a live key, or null if missing/expired. */
|
|
27
|
+
typeOf(key: Uint8Array, now: number): RedisType | null;
|
|
28
|
+
exists(key: Uint8Array, now: number): boolean;
|
|
29
|
+
/** Delete keys; returns how many existed. */
|
|
30
|
+
del(keys: Uint8Array[], now: number): number;
|
|
31
|
+
/** Set absolute expiry; returns false if key missing. */
|
|
32
|
+
expireSet(key: Uint8Array, atMs: number, now: number): boolean;
|
|
33
|
+
/** Remaining ms: -2 missing, -1 no expiry, else > 0. */
|
|
34
|
+
pttl(key: Uint8Array, now: number): number;
|
|
35
|
+
/** Remove expiry; returns true if a TTL was removed. */
|
|
36
|
+
persist(key: Uint8Array, now: number): boolean;
|
|
37
|
+
/** Active sweep of expired rows; returns rows removed. */
|
|
38
|
+
sweepExpired(now: number): number;
|
|
39
|
+
/** Total live key count (after lazy considerations are out of scope here). */
|
|
40
|
+
dbsize(now: number): number;
|
|
41
|
+
|
|
42
|
+
// ── string / kv ─────────────────────────────────────────────────────────--
|
|
43
|
+
kvGet(key: Uint8Array, now: number): Uint8Array | null;
|
|
44
|
+
/** Returns the value actually stored, or null when an NX/XX guard blocked it. */
|
|
45
|
+
kvSet(key: Uint8Array, value: Uint8Array, now: number, opts?: SetOptions): "set" | "noop";
|
|
46
|
+
/** Atomic add of `delta`; returns new value. Throws notInt on non-integer. */
|
|
47
|
+
incrBy(key: Uint8Array, delta: bigint, now: number): bigint;
|
|
48
|
+
/** Atomic float add; returns new value formatted by caller. */
|
|
49
|
+
incrByFloat(key: Uint8Array, delta: number, now: number): number;
|
|
50
|
+
append(key: Uint8Array, value: Uint8Array, now: number): number;
|
|
51
|
+
|
|
52
|
+
// ── hash ───────────────────────────────────────────────────────────────---
|
|
53
|
+
/** Set fields; returns count of newly-created (not overwritten) fields. */
|
|
54
|
+
hSet(key: Uint8Array, pairs: ReadonlyArray<readonly [Uint8Array, Uint8Array]>, now: number): number;
|
|
55
|
+
hGet(key: Uint8Array, field: Uint8Array, now: number): Uint8Array | null;
|
|
56
|
+
hDel(key: Uint8Array, fields: Uint8Array[], now: number): number;
|
|
57
|
+
hGetAll(key: Uint8Array, now: number): Array<[Uint8Array, Uint8Array]>;
|
|
58
|
+
hKeys(key: Uint8Array, now: number): Uint8Array[];
|
|
59
|
+
hVals(key: Uint8Array, now: number): Uint8Array[];
|
|
60
|
+
hLen(key: Uint8Array, now: number): number;
|
|
61
|
+
hExists(key: Uint8Array, field: Uint8Array, now: number): boolean;
|
|
62
|
+
hIncrBy(key: Uint8Array, field: Uint8Array, delta: bigint, now: number): bigint;
|
|
63
|
+
hIncrByFloat(key: Uint8Array, field: Uint8Array, delta: number, now: number): number;
|
|
64
|
+
|
|
65
|
+
// ── set ────────────────────────────────────────────────────────────────---
|
|
66
|
+
sAdd(key: Uint8Array, members: Uint8Array[], now: number): number;
|
|
67
|
+
sRem(key: Uint8Array, members: Uint8Array[], now: number): number;
|
|
68
|
+
sIsMember(key: Uint8Array, member: Uint8Array, now: number): boolean;
|
|
69
|
+
sMembers(key: Uint8Array, now: number): Uint8Array[];
|
|
70
|
+
sCard(key: Uint8Array, now: number): number;
|
|
71
|
+
/** Up to `count` random members without removal (count<0 => with repeats). */
|
|
72
|
+
sRandMember(key: Uint8Array, count: number | null, now: number): Uint8Array[] | Uint8Array | null;
|
|
73
|
+
/** Remove and return up to `count` random members. */
|
|
74
|
+
sPop(key: Uint8Array, count: number | null, now: number): Uint8Array[] | Uint8Array | null;
|
|
75
|
+
|
|
76
|
+
// ── atomicity ─────────────────────────────────────────────────────────────
|
|
77
|
+
/** Run `fn` inside a single SQLite transaction (single-writer assumption). */
|
|
78
|
+
withTransaction<T>(fn: () => T): T;
|
|
79
|
+
|
|
80
|
+
/** Close underlying resources. */
|
|
81
|
+
close(): void;
|
|
82
|
+
}
|