@bod.ee/db 0.7.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/settings.local.json +23 -0
- package/.claude/skills/config-file.md +54 -0
- package/.claude/skills/deploying-bod-db.md +29 -0
- package/.claude/skills/developing-bod-db.md +127 -0
- package/.claude/skills/using-bod-db.md +403 -0
- package/CLAUDE.md +110 -0
- package/README.md +252 -0
- package/admin/rules.ts +12 -0
- package/admin/server.ts +523 -0
- package/admin/ui.html +2281 -0
- package/cli.ts +177 -0
- package/client.ts +2 -0
- package/config.ts +20 -0
- package/deploy/.env.example +1 -0
- package/deploy/base.yaml +18 -0
- package/deploy/boddb-logs.yaml +10 -0
- package/deploy/boddb.yaml +10 -0
- package/deploy/demo.html +196 -0
- package/deploy/deploy.ts +32 -0
- package/deploy/prod-logs.config.ts +15 -0
- package/deploy/prod.config.ts +15 -0
- package/index.ts +20 -0
- package/mcp.ts +78 -0
- package/package.json +29 -0
- package/react.ts +1 -0
- package/src/client/BodClient.ts +515 -0
- package/src/react/hooks.ts +121 -0
- package/src/server/BodDB.ts +319 -0
- package/src/server/ExpressionRules.ts +250 -0
- package/src/server/FTSEngine.ts +76 -0
- package/src/server/FileAdapter.ts +116 -0
- package/src/server/MCPAdapter.ts +409 -0
- package/src/server/MQEngine.ts +286 -0
- package/src/server/QueryEngine.ts +45 -0
- package/src/server/RulesEngine.ts +108 -0
- package/src/server/StorageEngine.ts +464 -0
- package/src/server/StreamEngine.ts +320 -0
- package/src/server/SubscriptionEngine.ts +120 -0
- package/src/server/Transport.ts +479 -0
- package/src/server/VectorEngine.ts +115 -0
- package/src/shared/errors.ts +15 -0
- package/src/shared/pathUtils.ts +94 -0
- package/src/shared/protocol.ts +59 -0
- package/src/shared/transforms.ts +99 -0
- package/tests/batch.test.ts +60 -0
- package/tests/bench.ts +205 -0
- package/tests/e2e.test.ts +284 -0
- package/tests/expression-rules.test.ts +114 -0
- package/tests/file-adapter.test.ts +57 -0
- package/tests/fts.test.ts +58 -0
- package/tests/mq-flow.test.ts +204 -0
- package/tests/mq.test.ts +326 -0
- package/tests/push.test.ts +55 -0
- package/tests/query.test.ts +60 -0
- package/tests/rules.test.ts +78 -0
- package/tests/sse.test.ts +78 -0
- package/tests/storage.test.ts +199 -0
- package/tests/stream.test.ts +385 -0
- package/tests/stress.test.ts +202 -0
- package/tests/subscriptions.test.ts +86 -0
- package/tests/transforms.test.ts +92 -0
- package/tests/transport.test.ts +209 -0
- package/tests/ttl.test.ts +70 -0
- package/tests/vector.test.ts +69 -0
- package/tsconfig.json +27 -0
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { Database } from 'bun:sqlite';
|
|
2
|
+
import { validatePath, flatten, reconstruct, prefixEnd } from '../shared/pathUtils.ts';
|
|
3
|
+
import type { QueryFilter, OrderClause } from '../shared/protocol.ts';
|
|
4
|
+
import { isSentinel, resolveTransforms } from '../shared/transforms.ts';
|
|
5
|
+
|
|
6
|
+
export class StorageEngineOptions {
|
|
7
|
+
path: string = ':memory:';
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class StorageEngine {
|
|
11
|
+
readonly db: Database;
|
|
12
|
+
readonly options: StorageEngineOptions;
|
|
13
|
+
|
|
14
|
+
private stmtGet!: ReturnType<Database['prepare']>;
|
|
15
|
+
private stmtGetRaw!: ReturnType<Database['prepare']>;
|
|
16
|
+
private stmtPrefix!: ReturnType<Database['prepare']>;
|
|
17
|
+
private stmtInsert!: ReturnType<Database['prepare']>;
|
|
18
|
+
private stmtDeleteExact!: ReturnType<Database['prepare']>;
|
|
19
|
+
private stmtDeletePrefix!: ReturnType<Database['prepare']>;
|
|
20
|
+
private stmtExistsPrefix!: ReturnType<Database['prepare']>;
|
|
21
|
+
private stmtSetExpiry!: ReturnType<Database['prepare']>;
|
|
22
|
+
private stmtSweep!: ReturnType<Database['prepare']>;
|
|
23
|
+
private stmtExpiredPaths!: ReturnType<Database['prepare']>;
|
|
24
|
+
private stmtPushIdempotent!: ReturnType<Database['prepare']>;
|
|
25
|
+
private stmtGetByIdempotencyKey!: ReturnType<Database['prepare']>;
|
|
26
|
+
private stmtQueryAfterKey!: ReturnType<Database['prepare']>;
|
|
27
|
+
|
|
28
|
+
constructor(options?: Partial<StorageEngineOptions>) {
|
|
29
|
+
this.options = { ...new StorageEngineOptions(), ...options };
|
|
30
|
+
this.db = new Database(this.options.path);
|
|
31
|
+
this.init();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
private init() {
|
|
35
|
+
this.db.run('PRAGMA journal_mode = WAL');
|
|
36
|
+
this.db.run('PRAGMA synchronous = NORMAL');
|
|
37
|
+
this.db.run(`
|
|
38
|
+
CREATE TABLE IF NOT EXISTS nodes (
|
|
39
|
+
path TEXT PRIMARY KEY,
|
|
40
|
+
value TEXT NOT NULL,
|
|
41
|
+
updated_at INTEGER NOT NULL
|
|
42
|
+
)
|
|
43
|
+
`);
|
|
44
|
+
|
|
45
|
+
// Migrations
|
|
46
|
+
const cols = this.db.prepare("PRAGMA table_info(nodes)").all() as Array<{ name: string }>;
|
|
47
|
+
if (!cols.some(c => c.name === 'expires_at')) {
|
|
48
|
+
this.db.run('ALTER TABLE nodes ADD COLUMN expires_at INTEGER');
|
|
49
|
+
}
|
|
50
|
+
if (!cols.some(c => c.name === 'idempotency_key')) {
|
|
51
|
+
this.db.run('ALTER TABLE nodes ADD COLUMN idempotency_key TEXT');
|
|
52
|
+
}
|
|
53
|
+
this.db.run('CREATE UNIQUE INDEX IF NOT EXISTS idx_idempotency ON nodes(idempotency_key) WHERE idempotency_key IS NOT NULL');
|
|
54
|
+
|
|
55
|
+
// MQ columns
|
|
56
|
+
if (!cols.some(c => c.name === 'mq_status')) this.db.run('ALTER TABLE nodes ADD COLUMN mq_status TEXT');
|
|
57
|
+
if (!cols.some(c => c.name === 'mq_inflight_until')) this.db.run('ALTER TABLE nodes ADD COLUMN mq_inflight_until INTEGER');
|
|
58
|
+
if (!cols.some(c => c.name === 'mq_delivery_count')) this.db.run('ALTER TABLE nodes ADD COLUMN mq_delivery_count INTEGER DEFAULT 0');
|
|
59
|
+
this.db.run('CREATE INDEX IF NOT EXISTS idx_mq ON nodes(path, mq_status, mq_inflight_until) WHERE mq_status IS NOT NULL');
|
|
60
|
+
|
|
61
|
+
this.stmtGet = this.db.prepare('SELECT path, value FROM nodes WHERE path = ? AND mq_status IS NULL');
|
|
62
|
+
this.stmtGetRaw = this.db.prepare('SELECT path, value FROM nodes WHERE path = ?');
|
|
63
|
+
this.stmtPrefix = this.db.prepare('SELECT path, value FROM nodes WHERE path >= ? AND path < ? AND mq_status IS NULL');
|
|
64
|
+
this.stmtInsert = this.db.prepare('INSERT OR REPLACE INTO nodes (path, value, updated_at) VALUES (?, ?, ?)');
|
|
65
|
+
this.stmtDeleteExact = this.db.prepare('DELETE FROM nodes WHERE path = ? AND mq_status IS NULL');
|
|
66
|
+
this.stmtDeletePrefix = this.db.prepare('DELETE FROM nodes WHERE path >= ? AND path < ? AND mq_status IS NULL');
|
|
67
|
+
this.stmtExistsPrefix = this.db.prepare('SELECT 1 FROM nodes WHERE path >= ? AND path < ? AND mq_status IS NULL LIMIT 1');
|
|
68
|
+
this.stmtSetExpiry = this.db.prepare('UPDATE nodes SET expires_at = ? WHERE path = ?');
|
|
69
|
+
this.stmtSweep = this.db.prepare('DELETE FROM nodes WHERE expires_at IS NOT NULL AND expires_at < ?');
|
|
70
|
+
this.stmtExpiredPaths = this.db.prepare('SELECT path FROM nodes WHERE expires_at IS NOT NULL AND expires_at < ?');
|
|
71
|
+
this.stmtPushIdempotent = this.db.prepare('INSERT OR IGNORE INTO nodes (path, value, updated_at, idempotency_key) VALUES (?, ?, ?, ?)');
|
|
72
|
+
this.stmtGetByIdempotencyKey = this.db.prepare('SELECT path FROM nodes WHERE idempotency_key = ?');
|
|
73
|
+
this.stmtQueryAfterKey = this.db.prepare('SELECT path, value FROM nodes WHERE path > ? AND path < ? AND mq_status IS NULL ORDER BY path ASC LIMIT ?');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Check if a path has any data (exact or subtree) */
|
|
77
|
+
exists(path: string): boolean {
|
|
78
|
+
path = validatePath(path);
|
|
79
|
+
const exact = this.stmtGet.get(path);
|
|
80
|
+
if (exact) return true;
|
|
81
|
+
const prefix = path + '/';
|
|
82
|
+
return !!this.stmtExistsPrefix.get(prefix, prefixEnd(prefix));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Get value at path (reconstructs subtree if needed) */
|
|
86
|
+
get(path: string, options?: { resolve?: boolean | string[] }): unknown {
|
|
87
|
+
path = validatePath(path);
|
|
88
|
+
|
|
89
|
+
// Try exact match first
|
|
90
|
+
const exact = this.stmtGet.get(path) as { path: string; value: string } | null;
|
|
91
|
+
let result: unknown;
|
|
92
|
+
if (exact) {
|
|
93
|
+
result = JSON.parse(exact.value);
|
|
94
|
+
} else {
|
|
95
|
+
// Try prefix (subtree)
|
|
96
|
+
const prefix = path + '/';
|
|
97
|
+
const rows = this.stmtPrefix.all(prefix, prefixEnd(prefix)) as Array<{ path: string; value: string }>;
|
|
98
|
+
if (rows.length === 0) return null;
|
|
99
|
+
result = reconstruct(path, rows);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (options?.resolve && result !== null && typeof result === 'object') {
|
|
103
|
+
result = this.resolveRefs(result, options.resolve === true ? undefined : options.resolve);
|
|
104
|
+
}
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Get value at path including MQ rows (for internal notification use) */
|
|
109
|
+
getRaw(path: string): unknown {
|
|
110
|
+
path = validatePath(path);
|
|
111
|
+
const exact = this.stmtGetRaw.get(path) as { path: string; value: string } | null;
|
|
112
|
+
if (exact) return JSON.parse(exact.value);
|
|
113
|
+
// Check subtree (including MQ rows)
|
|
114
|
+
const prefix = path + '/';
|
|
115
|
+
const row = this.db.prepare('SELECT 1 FROM nodes WHERE path >= ? AND path < ? LIMIT 1').get(prefix, prefixEnd(prefix));
|
|
116
|
+
if (row) return true; // signal existence without full reconstruction
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get immediate children of a path (one level deep). Returns { key, isLeaf, value? }[] */
|
|
121
|
+
getShallow(path?: string): Array<{ key: string; isLeaf: boolean; value?: unknown; ttl?: number }> {
|
|
122
|
+
const prefix = path ? path + '/' : '';
|
|
123
|
+
const end = prefix + '\uffff';
|
|
124
|
+
const rows = (prefix
|
|
125
|
+
? this.db.prepare('SELECT path, value, expires_at FROM nodes WHERE path >= ? AND path < ? ORDER BY path').all(prefix, end)
|
|
126
|
+
: this.db.prepare('SELECT path, value, expires_at FROM nodes ORDER BY path').all()
|
|
127
|
+
) as Array<{ path: string; value: string; expires_at: number | null }>;
|
|
128
|
+
|
|
129
|
+
const children: Array<{ key: string; isLeaf: boolean; value?: unknown; ttl?: number }> = [];
|
|
130
|
+
const seen = new Set<string>();
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
const rest = row.path.slice(prefix.length);
|
|
133
|
+
const slash = rest.indexOf('/');
|
|
134
|
+
const key = slash === -1 ? rest : rest.slice(0, slash);
|
|
135
|
+
if (!key || seen.has(key)) continue;
|
|
136
|
+
seen.add(key);
|
|
137
|
+
if (slash === -1) {
|
|
138
|
+
const entry: { key: string; isLeaf: boolean; value: unknown; ttl?: number } = { key, isLeaf: true, value: JSON.parse(row.value) };
|
|
139
|
+
if (row.expires_at) entry.ttl = Math.max(0, row.expires_at - Math.floor(Date.now() / 1000));
|
|
140
|
+
children.push(entry);
|
|
141
|
+
} else {
|
|
142
|
+
// Check if any child in this branch has TTL
|
|
143
|
+
const branchPrefix = prefix + key + '/';
|
|
144
|
+
const hasTTL = rows.some(r => r.path.startsWith(branchPrefix) && r.expires_at);
|
|
145
|
+
children.push(hasTTL ? { key, isLeaf: false, ttl: -1 } : { key, isLeaf: false });
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return children;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Resolve _ref fields in a value tree */
|
|
152
|
+
private resolveRefs(value: unknown, fields?: string[]): unknown {
|
|
153
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return value;
|
|
154
|
+
const obj = value as Record<string, unknown>;
|
|
155
|
+
const result: Record<string, unknown> = {};
|
|
156
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
157
|
+
if (val !== null && typeof val === 'object' && '_ref' in (val as Record<string, unknown>)) {
|
|
158
|
+
if (!fields || fields.includes(key)) {
|
|
159
|
+
const refPath = (val as { _ref: string })._ref;
|
|
160
|
+
result[key] = this.get(refPath);
|
|
161
|
+
} else {
|
|
162
|
+
result[key] = val;
|
|
163
|
+
}
|
|
164
|
+
} else if (typeof val === 'object' && val !== null && !Array.isArray(val)) {
|
|
165
|
+
result[key] = this.resolveRefs(val, fields);
|
|
166
|
+
} else {
|
|
167
|
+
result[key] = val;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return result;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Check if a value tree contains any sentinels */
|
|
174
|
+
private hasSentinels(value: unknown): boolean {
|
|
175
|
+
if (isSentinel(value)) return true;
|
|
176
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
|
|
177
|
+
return Object.values(value as Record<string, unknown>).some(v => this.hasSentinels(v));
|
|
178
|
+
}
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Set value at path (flattens objects into leaf rows) */
|
|
183
|
+
set(path: string, value: unknown): string[] {
|
|
184
|
+
path = validatePath(path);
|
|
185
|
+
if (this.hasSentinels(value)) {
|
|
186
|
+
value = resolveTransforms(value, () => this.get(path));
|
|
187
|
+
}
|
|
188
|
+
const now = Date.now();
|
|
189
|
+
const leaves = flatten(path, value);
|
|
190
|
+
|
|
191
|
+
// Delete existing subtree + exact
|
|
192
|
+
this.stmtDeleteExact.run(path);
|
|
193
|
+
const prefix = path + '/';
|
|
194
|
+
this.stmtDeletePrefix.run(prefix, prefixEnd(prefix));
|
|
195
|
+
|
|
196
|
+
// Insert new leaves
|
|
197
|
+
const tx = this.db.transaction(() => {
|
|
198
|
+
for (const leaf of leaves) {
|
|
199
|
+
this.stmtInsert.run(leaf.path, leaf.value, now);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
tx();
|
|
203
|
+
|
|
204
|
+
return leaves.map(l => l.path);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Delete value at path (exact + subtree) */
|
|
208
|
+
delete(path: string): void {
|
|
209
|
+
path = validatePath(path);
|
|
210
|
+
this.stmtDeleteExact.run(path);
|
|
211
|
+
const prefix = path + '/';
|
|
212
|
+
this.stmtDeletePrefix.run(prefix, prefixEnd(prefix));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Merge value at path — shallow merge for objects, replaces for primitives */
|
|
216
|
+
merge(path: string, value: unknown): string[] {
|
|
217
|
+
path = validatePath(path);
|
|
218
|
+
if (this.hasSentinels(value)) {
|
|
219
|
+
value = resolveTransforms(value, () => this.get(path));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// For non-objects, merge is the same as set
|
|
223
|
+
if (value === null || value === undefined || typeof value !== 'object' || Array.isArray(value)) {
|
|
224
|
+
return this.set(path, value);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// For objects: only delete+write the keys being provided, leave others intact
|
|
228
|
+
const now = Date.now();
|
|
229
|
+
const allPaths: string[] = [];
|
|
230
|
+
const obj = value as Record<string, unknown>;
|
|
231
|
+
|
|
232
|
+
const tx = this.db.transaction(() => {
|
|
233
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
234
|
+
const childPath = `${path}/${key}`;
|
|
235
|
+
if (val === null || val === undefined) {
|
|
236
|
+
// null = delete this key
|
|
237
|
+
this.stmtDeleteExact.run(childPath);
|
|
238
|
+
const prefix = childPath + '/';
|
|
239
|
+
this.stmtDeletePrefix.run(prefix, prefixEnd(prefix));
|
|
240
|
+
allPaths.push(childPath);
|
|
241
|
+
} else {
|
|
242
|
+
// Replace only this key's subtree
|
|
243
|
+
this.stmtDeleteExact.run(childPath);
|
|
244
|
+
const prefix = childPath + '/';
|
|
245
|
+
this.stmtDeletePrefix.run(prefix, prefixEnd(prefix));
|
|
246
|
+
const leaves = flatten(childPath, val);
|
|
247
|
+
for (const leaf of leaves) {
|
|
248
|
+
this.stmtInsert.run(leaf.path, leaf.value, now);
|
|
249
|
+
}
|
|
250
|
+
allPaths.push(...leaves.map(l => l.path));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
// Also clean up the exact parent if it was a leaf (now it has children)
|
|
254
|
+
this.stmtDeleteExact.run(path);
|
|
255
|
+
});
|
|
256
|
+
tx();
|
|
257
|
+
|
|
258
|
+
return allPaths;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Multi-path atomic update (merge semantics — shallow merge per path) */
|
|
262
|
+
update(updates: Record<string, unknown>): string[] {
|
|
263
|
+
const allPaths: string[] = [];
|
|
264
|
+
const tx = this.db.transaction(() => {
|
|
265
|
+
for (const [path, value] of Object.entries(updates)) {
|
|
266
|
+
if (value === null || value === undefined) {
|
|
267
|
+
this.delete(path);
|
|
268
|
+
allPaths.push(validatePath(path));
|
|
269
|
+
} else {
|
|
270
|
+
allPaths.push(...this.merge(path, value));
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
tx();
|
|
275
|
+
return allPaths;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Query children of a path with optional filters, ordering, limits */
|
|
279
|
+
query(
|
|
280
|
+
path: string,
|
|
281
|
+
filters?: QueryFilter[],
|
|
282
|
+
order?: OrderClause,
|
|
283
|
+
limit?: number,
|
|
284
|
+
offset?: number,
|
|
285
|
+
): Array<{ _path: string; _key: string; [k: string]: unknown }> {
|
|
286
|
+
path = validatePath(path);
|
|
287
|
+
const prefix = path + '/';
|
|
288
|
+
|
|
289
|
+
// Get all rows under this path
|
|
290
|
+
const rows = this.stmtPrefix.all(prefix, prefixEnd(prefix)) as Array<{ path: string; value: string }>;
|
|
291
|
+
if (rows.length === 0) return [];
|
|
292
|
+
|
|
293
|
+
// Group rows by direct child key
|
|
294
|
+
const children = new Map<string, Array<{ path: string; value: string }>>();
|
|
295
|
+
for (const row of rows) {
|
|
296
|
+
const rel = row.path.slice(prefix.length);
|
|
297
|
+
const childKey = rel.split('/')[0];
|
|
298
|
+
if (!children.has(childKey)) children.set(childKey, []);
|
|
299
|
+
children.get(childKey)!.push(row);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Reconstruct each child
|
|
303
|
+
let results: Array<{ _path: string; _key: string; [k: string]: unknown }> = [];
|
|
304
|
+
for (const [key, childRows] of children) {
|
|
305
|
+
const childPath = `${path}/${key}`;
|
|
306
|
+
const val = reconstruct(childPath, childRows);
|
|
307
|
+
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
|
308
|
+
results.push({ _path: childPath, _key: key, ...(val as Record<string, unknown>) });
|
|
309
|
+
} else {
|
|
310
|
+
results.push({ _path: childPath, _key: key, _value: val });
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Apply filters
|
|
315
|
+
if (filters?.length) {
|
|
316
|
+
for (const f of filters) {
|
|
317
|
+
results = results.filter(item => {
|
|
318
|
+
const v = (item as Record<string, unknown>)[f.field];
|
|
319
|
+
switch (f.op) {
|
|
320
|
+
case '==': return v === f.value;
|
|
321
|
+
case '!=': return v !== f.value;
|
|
322
|
+
case '<': return (v as number) < (f.value as number);
|
|
323
|
+
case '<=': return (v as number) <= (f.value as number);
|
|
324
|
+
case '>': return (v as number) > (f.value as number);
|
|
325
|
+
case '>=': return (v as number) >= (f.value as number);
|
|
326
|
+
default: return true;
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Apply ordering
|
|
333
|
+
if (order) {
|
|
334
|
+
const dir = order.dir === 'desc' ? -1 : 1;
|
|
335
|
+
results.sort((a, b) => {
|
|
336
|
+
const av = (a as Record<string, unknown>)[order.field];
|
|
337
|
+
const bv = (b as Record<string, unknown>)[order.field];
|
|
338
|
+
if (av == null && bv == null) return 0;
|
|
339
|
+
if (av == null) return dir;
|
|
340
|
+
if (bv == null) return -dir;
|
|
341
|
+
return av < bv ? -dir : av > bv ? dir : 0;
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Apply offset + limit
|
|
346
|
+
if (offset) results = results.slice(offset);
|
|
347
|
+
if (limit) results = results.slice(0, limit);
|
|
348
|
+
|
|
349
|
+
return results;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Create expression index for a field under a path prefix. Field must be alphanumeric. */
|
|
353
|
+
createIndex(basePath: string, field: string) {
|
|
354
|
+
if (!/^[a-zA-Z_]\w*$/.test(field)) throw new Error(`Invalid index field: "${field}"`);
|
|
355
|
+
const p = validatePath(basePath);
|
|
356
|
+
const safeName = `idx_${p.replace(/[^a-zA-Z0-9]/g, '_')}_${field}`;
|
|
357
|
+
const prefix = p + '/';
|
|
358
|
+
const end = prefixEnd(prefix);
|
|
359
|
+
// SQLite requires literals in index expressions — field/path are validated above
|
|
360
|
+
this.db.run(
|
|
361
|
+
`CREATE INDEX IF NOT EXISTS "${safeName}" ON nodes(json_extract(value, '$.${field}')) WHERE path >= '${prefix}' AND path < '${end}'`,
|
|
362
|
+
);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** Set expiry on all leaf paths under a given path */
|
|
366
|
+
setExpiry(path: string, ttlSeconds: number): void {
|
|
367
|
+
path = validatePath(path);
|
|
368
|
+
const expiresAt = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
369
|
+
// Set on exact match
|
|
370
|
+
this.stmtSetExpiry.run(expiresAt, path);
|
|
371
|
+
// Set on prefix matches
|
|
372
|
+
const prefix = path + '/';
|
|
373
|
+
const rows = this.stmtPrefix.all(prefix, prefixEnd(prefix)) as Array<{ path: string }>;
|
|
374
|
+
for (const row of rows) {
|
|
375
|
+
this.stmtSetExpiry.run(expiresAt, row.path);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/** Sweep expired entries, returns affected paths */
|
|
380
|
+
sweep(): string[] {
|
|
381
|
+
const now = Math.floor(Date.now() / 1000);
|
|
382
|
+
const expired = this.stmtExpiredPaths.all(now) as Array<{ path: string }>;
|
|
383
|
+
if (expired.length === 0) return [];
|
|
384
|
+
this.stmtSweep.run(now);
|
|
385
|
+
return expired.map(r => r.path);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Push a value as a single JSON row (not flattened) with auto-generated time-sortable key */
|
|
389
|
+
push(path: string, value: unknown, opts?: { idempotencyKey?: string }): { key: string; changedPaths: string[]; duplicate?: boolean } {
|
|
390
|
+
path = validatePath(path);
|
|
391
|
+
const key = generatePushId();
|
|
392
|
+
const fullPath = `${path}/${key}`;
|
|
393
|
+
const now = Date.now();
|
|
394
|
+
|
|
395
|
+
if (opts?.idempotencyKey) {
|
|
396
|
+
const existing = this.stmtGetByIdempotencyKey.get(opts.idempotencyKey) as { path: string } | null;
|
|
397
|
+
if (existing) {
|
|
398
|
+
const existingKey = existing.path.split('/').pop()!;
|
|
399
|
+
return { key: existingKey, changedPaths: [], duplicate: true };
|
|
400
|
+
}
|
|
401
|
+
this.stmtPushIdempotent.run(fullPath, JSON.stringify(value), now, opts.idempotencyKey);
|
|
402
|
+
return { key, changedPaths: [fullPath] };
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
this.stmtInsert.run(fullPath, JSON.stringify(value), now);
|
|
406
|
+
return { key, changedPaths: [fullPath] };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Query rows after a given key under a path prefix, ordered by path. For stream replay. */
|
|
410
|
+
queryAfterKey(path: string, afterKey: string | null, limit: number): Array<{ key: string; value: unknown }> {
|
|
411
|
+
path = validatePath(path);
|
|
412
|
+
const prefix = path + '/';
|
|
413
|
+
const startBound = afterKey ? `${path}/${afterKey}` : prefix;
|
|
414
|
+
const endBound = prefixEnd(prefix);
|
|
415
|
+
const rows = this.stmtQueryAfterKey.all(startBound, endBound, limit) as Array<{ path: string; value: string }>;
|
|
416
|
+
return rows.map(r => ({
|
|
417
|
+
key: r.path.slice(prefix.length),
|
|
418
|
+
value: JSON.parse(r.value),
|
|
419
|
+
}));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
close() {
|
|
423
|
+
this.db.close();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Firebase-style push ID: timestamp (base36) + random suffix
|
|
428
|
+
const PUSH_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
|
|
429
|
+
let lastPushTime = 0;
|
|
430
|
+
let lastRandChars: number[] = [];
|
|
431
|
+
|
|
432
|
+
export function generatePushId(): string {
|
|
433
|
+
let now = Date.now();
|
|
434
|
+
const isDuplicate = now === lastPushTime;
|
|
435
|
+
lastPushTime = now;
|
|
436
|
+
|
|
437
|
+
const timeChars = new Array(8);
|
|
438
|
+
for (let i = 7; i >= 0; i--) {
|
|
439
|
+
timeChars[i] = PUSH_CHARS.charAt(now % 62);
|
|
440
|
+
now = Math.floor(now / 62);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
let id = timeChars.join('');
|
|
444
|
+
|
|
445
|
+
if (!isDuplicate) {
|
|
446
|
+
lastRandChars = [];
|
|
447
|
+
for (let i = 0; i < 4; i++) {
|
|
448
|
+
lastRandChars.push(Math.floor(Math.random() * 62));
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
// Increment last random chars for guaranteed uniqueness
|
|
452
|
+
let i = lastRandChars.length - 1;
|
|
453
|
+
while (i >= 0 && lastRandChars[i] === 61) {
|
|
454
|
+
lastRandChars[i] = 0;
|
|
455
|
+
i--;
|
|
456
|
+
}
|
|
457
|
+
if (i >= 0) lastRandChars[i]++;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const c of lastRandChars) {
|
|
461
|
+
id += PUSH_CHARS.charAt(c);
|
|
462
|
+
}
|
|
463
|
+
return id;
|
|
464
|
+
}
|