@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.
Files changed (65) hide show
  1. package/.claude/settings.local.json +23 -0
  2. package/.claude/skills/config-file.md +54 -0
  3. package/.claude/skills/deploying-bod-db.md +29 -0
  4. package/.claude/skills/developing-bod-db.md +127 -0
  5. package/.claude/skills/using-bod-db.md +403 -0
  6. package/CLAUDE.md +110 -0
  7. package/README.md +252 -0
  8. package/admin/rules.ts +12 -0
  9. package/admin/server.ts +523 -0
  10. package/admin/ui.html +2281 -0
  11. package/cli.ts +177 -0
  12. package/client.ts +2 -0
  13. package/config.ts +20 -0
  14. package/deploy/.env.example +1 -0
  15. package/deploy/base.yaml +18 -0
  16. package/deploy/boddb-logs.yaml +10 -0
  17. package/deploy/boddb.yaml +10 -0
  18. package/deploy/demo.html +196 -0
  19. package/deploy/deploy.ts +32 -0
  20. package/deploy/prod-logs.config.ts +15 -0
  21. package/deploy/prod.config.ts +15 -0
  22. package/index.ts +20 -0
  23. package/mcp.ts +78 -0
  24. package/package.json +29 -0
  25. package/react.ts +1 -0
  26. package/src/client/BodClient.ts +515 -0
  27. package/src/react/hooks.ts +121 -0
  28. package/src/server/BodDB.ts +319 -0
  29. package/src/server/ExpressionRules.ts +250 -0
  30. package/src/server/FTSEngine.ts +76 -0
  31. package/src/server/FileAdapter.ts +116 -0
  32. package/src/server/MCPAdapter.ts +409 -0
  33. package/src/server/MQEngine.ts +286 -0
  34. package/src/server/QueryEngine.ts +45 -0
  35. package/src/server/RulesEngine.ts +108 -0
  36. package/src/server/StorageEngine.ts +464 -0
  37. package/src/server/StreamEngine.ts +320 -0
  38. package/src/server/SubscriptionEngine.ts +120 -0
  39. package/src/server/Transport.ts +479 -0
  40. package/src/server/VectorEngine.ts +115 -0
  41. package/src/shared/errors.ts +15 -0
  42. package/src/shared/pathUtils.ts +94 -0
  43. package/src/shared/protocol.ts +59 -0
  44. package/src/shared/transforms.ts +99 -0
  45. package/tests/batch.test.ts +60 -0
  46. package/tests/bench.ts +205 -0
  47. package/tests/e2e.test.ts +284 -0
  48. package/tests/expression-rules.test.ts +114 -0
  49. package/tests/file-adapter.test.ts +57 -0
  50. package/tests/fts.test.ts +58 -0
  51. package/tests/mq-flow.test.ts +204 -0
  52. package/tests/mq.test.ts +326 -0
  53. package/tests/push.test.ts +55 -0
  54. package/tests/query.test.ts +60 -0
  55. package/tests/rules.test.ts +78 -0
  56. package/tests/sse.test.ts +78 -0
  57. package/tests/storage.test.ts +199 -0
  58. package/tests/stream.test.ts +385 -0
  59. package/tests/stress.test.ts +202 -0
  60. package/tests/subscriptions.test.ts +86 -0
  61. package/tests/transforms.test.ts +92 -0
  62. package/tests/transport.test.ts +209 -0
  63. package/tests/ttl.test.ts +70 -0
  64. package/tests/vector.test.ts +69 -0
  65. package/tsconfig.json +27 -0
@@ -0,0 +1,319 @@
1
+ import { StorageEngine } from './StorageEngine.ts';
2
+ import { SubscriptionEngine, type SubscriptionCallback, type ChildCallback } from './SubscriptionEngine.ts';
3
+ import { QueryEngine } from './QueryEngine.ts';
4
+ import { RulesEngine, type PathRule } from './RulesEngine.ts';
5
+ import { Transport, type TransportOptions } from './Transport.ts';
6
+ import { FTSEngine, type FTSEngineOptions } from './FTSEngine.ts';
7
+ import { VectorEngine, type VectorEngineOptions } from './VectorEngine.ts';
8
+ import { StreamEngine, type CompactOptions } from './StreamEngine.ts';
9
+ import { MQEngine, type MQEngineOptions } from './MQEngine.ts';
10
+ import { validatePath } from '../shared/pathUtils.ts';
11
+
12
+ export interface TransactionProxy {
13
+ get(path: string): unknown;
14
+ set(path: string, value: unknown): void;
15
+ update(updates: Record<string, unknown>): void;
16
+ delete(path: string): void;
17
+ }
18
+
19
+ export class BodDBOptions {
20
+ path: string = ':memory:';
21
+ /** Inline rules OR path to .json/.ts config file */
22
+ rules?: Record<string, PathRule> | string;
23
+ /** Index definitions: { "basePath": ["field1", "field2"] } */
24
+ indexes?: Record<string, string[]>;
25
+ /** TTL sweep interval in ms (0 = disabled, default 60000) */
26
+ sweepInterval: number = 60000;
27
+ /** FTS5 full-text search config */
28
+ fts?: Partial<FTSEngineOptions>;
29
+ /** Vector search config */
30
+ vectors?: Partial<VectorEngineOptions>;
31
+ /** Auto-compact stream topics on sweep: { "events/orders": { maxAge: 86400 } } */
32
+ compact?: Record<string, CompactOptions>;
33
+ /** Message queue config */
34
+ mq?: Partial<MQEngineOptions>;
35
+ port?: number;
36
+ auth?: TransportOptions['auth'];
37
+ transport?: Partial<TransportOptions>;
38
+ }
39
+
40
+ export class BodDB {
41
+ readonly storage: StorageEngine;
42
+ readonly subs: SubscriptionEngine;
43
+ readonly rules: RulesEngine;
44
+ readonly stream: StreamEngine;
45
+ readonly mq: MQEngine;
46
+ readonly fts: FTSEngine | null = null;
47
+ readonly vectors: VectorEngine | null = null;
48
+ readonly options: BodDBOptions;
49
+ private _transport: Transport | null = null;
50
+ get transport(): Transport | null { return this._transport; }
51
+ private sweepTimer: ReturnType<typeof setInterval> | null = null;
52
+
53
+ constructor(options?: Partial<BodDBOptions>) {
54
+ this.options = { ...new BodDBOptions(), ...options };
55
+ this.storage = new StorageEngine({ path: this.options.path });
56
+ this.subs = new SubscriptionEngine();
57
+ this.stream = new StreamEngine(this.storage, this.subs, { compact: this.options.compact });
58
+ this.mq = new MQEngine(this.storage, {
59
+ ...this.options.mq,
60
+ notify: (paths) => {
61
+ if (this.subs.hasSubscriptions) {
62
+ this.subs.notify(paths, (p) => this.storage.getRaw(p));
63
+ }
64
+ },
65
+ });
66
+
67
+ const rulesConfig = this.options.rules;
68
+ const resolvedRules = typeof rulesConfig === 'string'
69
+ ? BodDB.loadRulesFileSync(rulesConfig)
70
+ : (rulesConfig ?? {});
71
+ this.rules = new RulesEngine({ rules: resolvedRules });
72
+
73
+ // Auto-create indexes from config
74
+ if (this.options.indexes) {
75
+ for (const [basePath, fields] of Object.entries(this.options.indexes)) {
76
+ for (const field of fields) {
77
+ this.storage.createIndex(basePath, field);
78
+ }
79
+ }
80
+ }
81
+
82
+ // Initialize FTS if configured
83
+ if (this.options.fts) {
84
+ this.fts = new FTSEngine(this.storage.db, this.options.fts);
85
+ }
86
+
87
+ // Initialize vectors if configured
88
+ if (this.options.vectors) {
89
+ this.vectors = new VectorEngine(this.storage.db, this.options.vectors);
90
+ }
91
+
92
+ // Start TTL sweep
93
+ if (this.options.sweepInterval > 0) {
94
+ this.sweepTimer = setInterval(() => this.sweep(), this.options.sweepInterval);
95
+ }
96
+ }
97
+
98
+ /** Load rules from a JSON or TS file synchronously */
99
+ private static loadRulesFileSync(filePath: string): Record<string, PathRule> {
100
+ if (filePath.endsWith('.json')) {
101
+ const file = Bun.file(filePath);
102
+ // Use spawnSync to read synchronously since Bun.file is async
103
+ const text = require('fs').readFileSync(filePath, 'utf-8');
104
+ const parsed = JSON.parse(text);
105
+ return parsed.rules ?? parsed;
106
+ }
107
+ throw new Error(`Unsupported rules file format: ${filePath}. Use .json or call BodDB.create() for .ts files.`);
108
+ }
109
+
110
+ /** Async factory — accepts a config file path (string) or inline options */
111
+ static async create(configOrOptions?: string | Partial<BodDBOptions>): Promise<BodDB> {
112
+ const options = typeof configOrOptions === 'string'
113
+ ? await BodDB.loadConfigFile(configOrOptions)
114
+ : configOrOptions;
115
+
116
+ if (typeof options?.rules === 'string' && (options.rules.endsWith('.ts') || options.rules.endsWith('.js'))) {
117
+ const mod = await import(options.rules);
118
+ const rules = mod.rules ?? mod.default;
119
+ return new BodDB({ ...options, rules });
120
+ }
121
+ return new BodDB(options);
122
+ }
123
+
124
+ /** Load a full config file (.ts/.js/.json) → BodDBOptions */
125
+ private static async loadConfigFile(filePath: string): Promise<Partial<BodDBOptions>> {
126
+ const { resolve } = require('path');
127
+ const absPath = resolve(filePath);
128
+ if (filePath.endsWith('.ts') || filePath.endsWith('.js')) {
129
+ const mod = await import(absPath);
130
+ return mod.default ?? mod.config ?? mod;
131
+ }
132
+ if (filePath.endsWith('.json')) {
133
+ const text = require('fs').readFileSync(absPath, 'utf-8');
134
+ const parsed = JSON.parse(text);
135
+ return parsed.config ?? parsed;
136
+ }
137
+ throw new Error(`Unsupported config file format: ${filePath}. Use .ts, .js, or .json.`);
138
+ }
139
+
140
+ get(path: string): unknown {
141
+ return this.storage.get(path);
142
+ }
143
+
144
+ getShallow(path?: string): Array<{ key: string; isLeaf: boolean; value?: unknown }> {
145
+ return this.storage.getShallow(path);
146
+ }
147
+
148
+ set(path: string, value: unknown, options?: { ttl?: number }): void {
149
+ const p = validatePath(path);
150
+ const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : new Set<string>();
151
+ const changed = this.storage.set(path, value);
152
+ if (options?.ttl) {
153
+ this.storage.setExpiry(path, options.ttl);
154
+ }
155
+ if (this.subs.hasSubscriptions) {
156
+ this.subs.notify(changed, (pp) => this.storage.get(pp), existedBefore);
157
+ }
158
+ }
159
+
160
+ /** Manually trigger TTL sweep + stream auto-compact, returns expired paths */
161
+ sweep(): string[] {
162
+ const expired = this.storage.sweep();
163
+ if (expired.length > 0 && this.subs.hasSubscriptions) {
164
+ this.subs.notify(expired, (p) => this.storage.get(p));
165
+ }
166
+ this.stream.autoCompact();
167
+ this.mq.sweep();
168
+ return expired;
169
+ }
170
+
171
+ update(updates: Record<string, unknown>): void {
172
+ const existedBefore = new Set<string>();
173
+ if (this.subs.hasChildSubscriptions) {
174
+ for (const path of Object.keys(updates)) {
175
+ const p = validatePath(path);
176
+ for (const ep of this.snapshotExisting(p)) existedBefore.add(ep);
177
+ }
178
+ }
179
+ const changed = this.storage.update(updates);
180
+ if (this.subs.hasSubscriptions) {
181
+ this.subs.notify(changed, (p) => this.storage.get(p), existedBefore);
182
+ }
183
+ }
184
+
185
+ delete(path: string): void {
186
+ const p = validatePath(path);
187
+ const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : new Set<string>();
188
+ this.storage.delete(path);
189
+ if (this.subs.hasSubscriptions) {
190
+ this.subs.notify([p], (pp) => this.storage.get(pp), existedBefore);
191
+ }
192
+ }
193
+
194
+ /** Push a value with auto-generated time-sortable key (stored as single JSON row, not flattened) */
195
+ push(path: string, value: unknown, opts?: { idempotencyKey?: string }): string {
196
+ const p = validatePath(path);
197
+ const existedBefore = this.subs.hasChildSubscriptions ? this.snapshotExisting(p) : new Set<string>();
198
+ const { key, changedPaths, duplicate } = this.storage.push(path, value, opts);
199
+ if (!duplicate && this.subs.hasSubscriptions) {
200
+ this.subs.notify(changedPaths, (pp) => this.storage.get(pp), existedBefore);
201
+ }
202
+ return key;
203
+ }
204
+
205
+ query(path: string): QueryEngine {
206
+ return new QueryEngine(this.storage, path);
207
+ }
208
+
209
+ /** Full-text search (requires fts config) */
210
+ search(options: { text: string; path?: string; limit?: number }): Array<{ path: string; data: unknown; rank: number }> {
211
+ if (!this.fts) throw new Error('FTS not configured. Pass { fts: {} } in options.');
212
+ const results = this.fts.search({ text: options.text, prefix: options.path, limit: options.limit });
213
+ return results.map(r => ({ path: r.path, data: this.storage.get(r.path), rank: r.rank }));
214
+ }
215
+
216
+ /** Index a path for full-text search */
217
+ index(path: string, content: string): void;
218
+ index(path: string, fields: string[]): void;
219
+ index(path: string, contentOrFields: string | string[]): void {
220
+ if (!this.fts) throw new Error('FTS not configured. Pass { fts: {} } in options.');
221
+ if (typeof contentOrFields === 'string') {
222
+ this.fts.index(path, contentOrFields);
223
+ } else {
224
+ const data = this.storage.get(path);
225
+ if (data && typeof data === 'object') {
226
+ this.fts.indexFields(path, data as Record<string, unknown>, contentOrFields);
227
+ }
228
+ }
229
+ }
230
+
231
+ /** Vector similarity search (requires vectors config) */
232
+ vectorSearch(options: { query: number[]; path?: string; limit?: number; threshold?: number }): Array<{ path: string; data: unknown; score: number }> {
233
+ if (!this.vectors) throw new Error('Vectors not configured. Pass { vectors: {} } in options.');
234
+ const results = this.vectors.search({ query: options.query, prefix: options.path, limit: options.limit, threshold: options.threshold });
235
+ return results.map(r => ({ path: r.path, data: this.storage.get(r.path), score: r.score }));
236
+ }
237
+
238
+ on(path: string, cb: SubscriptionCallback): () => void {
239
+ return this.subs.onValue(path, cb);
240
+ }
241
+
242
+ onChild(path: string, cb: ChildCallback): () => void {
243
+ return this.subs.onChild(path, cb);
244
+ }
245
+
246
+ /** Run multiple operations in a single SQLite transaction. Notifications fire after commit. */
247
+ transaction<T>(fn: (tx: TransactionProxy) => T): T {
248
+ const allChanged: string[] = [];
249
+ const allExistedBefore = new Set<string>();
250
+ const proxy: TransactionProxy = {
251
+ get: (path: string) => this.storage.get(path),
252
+ set: (path: string, value: unknown) => {
253
+ const p = validatePath(path);
254
+ if (this.subs.hasChildSubscriptions) {
255
+ for (const ep of this.snapshotExisting(p)) allExistedBefore.add(ep);
256
+ }
257
+ allChanged.push(...this.storage.set(path, value));
258
+ },
259
+ update: (updates: Record<string, unknown>) => {
260
+ if (this.subs.hasChildSubscriptions) {
261
+ for (const path of Object.keys(updates)) {
262
+ for (const ep of this.snapshotExisting(validatePath(path))) allExistedBefore.add(ep);
263
+ }
264
+ }
265
+ allChanged.push(...this.storage.update(updates));
266
+ },
267
+ delete: (path: string) => {
268
+ const p = validatePath(path);
269
+ if (this.subs.hasChildSubscriptions) {
270
+ for (const ep of this.snapshotExisting(p)) allExistedBefore.add(ep);
271
+ }
272
+ this.storage.delete(path);
273
+ allChanged.push(p);
274
+ },
275
+ };
276
+
277
+ const sqliteTx = this.storage.db.transaction(() => fn(proxy));
278
+ const result = sqliteTx();
279
+
280
+ if (this.subs.hasSubscriptions && allChanged.length > 0) {
281
+ this.subs.notify(allChanged, (p) => this.storage.get(p), allExistedBefore);
282
+ }
283
+ return result;
284
+ }
285
+
286
+ /** Start WebSocket + REST server */
287
+ serve(options?: Partial<TransportOptions>) {
288
+ const port = options?.port ?? this.options.port;
289
+ const auth = options?.auth ?? this.options.auth;
290
+ this._transport = new Transport(this, this.rules, { ...this.options.transport, ...options, port, auth });
291
+ return this._transport.start();
292
+ }
293
+
294
+ stop() {
295
+ this._transport?.stop();
296
+ this._transport = null;
297
+ }
298
+
299
+ close(): void {
300
+ if (this.sweepTimer) {
301
+ clearInterval(this.sweepTimer);
302
+ this.sweepTimer = null;
303
+ }
304
+ this.stop();
305
+ this.subs.clear();
306
+ this.storage.close();
307
+ }
308
+
309
+ private snapshotExisting(path: string): Set<string> {
310
+ const existing = new Set<string>();
311
+ if (this.storage.exists(path)) existing.add(path);
312
+ const parts = path.split('/');
313
+ for (let i = 1; i < parts.length; i++) {
314
+ const childPath = parts.slice(0, i + 1).join('/');
315
+ if (this.storage.exists(childPath)) existing.add(childPath);
316
+ }
317
+ return existing;
318
+ }
319
+ }
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Expression-based rules engine (V2).
3
+ * Parses Firebase-style string expressions into safe AST — no eval.
4
+ * Supports: auth.uid, auth.role, $wildcards, data, newData, comparisons, logical ops.
5
+ */
6
+
7
+ // AST node types
8
+ type Expr =
9
+ | { type: 'literal'; value: unknown }
10
+ | { type: 'path'; segments: string[] } // auth.uid, data.name, $uid
11
+ | { type: 'binary'; op: string; left: Expr; right: Expr }
12
+ | { type: 'unary'; op: '!'; expr: Expr }
13
+ | { type: 'logical'; op: '&&' | '||'; left: Expr; right: Expr };
14
+
15
+ // Tokenizer
16
+ type Token =
17
+ | { type: 'ident'; value: string }
18
+ | { type: 'string'; value: string }
19
+ | { type: 'number'; value: number }
20
+ | { type: 'bool'; value: boolean }
21
+ | { type: 'null' }
22
+ | { type: 'op'; value: string }
23
+ | { type: 'dot' }
24
+ | { type: 'paren'; value: '(' | ')' }
25
+ | { type: 'not' };
26
+
27
+ function tokenize(input: string): Token[] {
28
+ const tokens: Token[] = [];
29
+ let i = 0;
30
+
31
+ while (i < input.length) {
32
+ const ch = input[i];
33
+
34
+ if (/\s/.test(ch)) { i++; continue; }
35
+
36
+ // Strings
37
+ if (ch === "'" || ch === '"') {
38
+ const quote = ch;
39
+ let str = '';
40
+ i++;
41
+ while (i < input.length && input[i] !== quote) {
42
+ if (input[i] === '\\') { i++; str += input[i] ?? ''; }
43
+ else str += input[i];
44
+ i++;
45
+ }
46
+ i++; // skip closing quote
47
+ tokens.push({ type: 'string', value: str });
48
+ continue;
49
+ }
50
+
51
+ // Numbers
52
+ if (/\d/.test(ch) || (ch === '-' && i + 1 < input.length && /\d/.test(input[i + 1]))) {
53
+ let num = '';
54
+ if (ch === '-') { num += ch; i++; }
55
+ while (i < input.length && /[\d.]/.test(input[i])) { num += input[i]; i++; }
56
+ tokens.push({ type: 'number', value: Number(num) });
57
+ continue;
58
+ }
59
+
60
+ // Operators (multi-char first)
61
+ const twoChar = input.slice(i, i + 2);
62
+ if (['===', '!=='].includes(input.slice(i, i + 3))) {
63
+ tokens.push({ type: 'op', value: input.slice(i, i + 3) });
64
+ i += 3;
65
+ continue;
66
+ }
67
+ if (['==', '!=', '<=', '>=', '&&', '||'].includes(twoChar)) {
68
+ tokens.push(twoChar === '&&' || twoChar === '||' ? { type: 'op', value: twoChar } : { type: 'op', value: twoChar });
69
+ i += 2;
70
+ continue;
71
+ }
72
+ if (['<', '>'].includes(ch)) {
73
+ tokens.push({ type: 'op', value: ch });
74
+ i++;
75
+ continue;
76
+ }
77
+
78
+ if (ch === '!') { tokens.push({ type: 'not' }); i++; continue; }
79
+ if (ch === '.') { tokens.push({ type: 'dot' }); i++; continue; }
80
+ if (ch === '(' || ch === ')') { tokens.push({ type: 'paren', value: ch }); i++; continue; }
81
+
82
+ // Identifiers (including $wildcards)
83
+ if (/[a-zA-Z_$]/.test(ch)) {
84
+ let ident = '';
85
+ while (i < input.length && /[a-zA-Z0-9_$]/.test(input[i])) { ident += input[i]; i++; }
86
+ if (ident === 'true') tokens.push({ type: 'bool', value: true });
87
+ else if (ident === 'false') tokens.push({ type: 'bool', value: false });
88
+ else if (ident === 'null') tokens.push({ type: 'null' });
89
+ else tokens.push({ type: 'ident', value: ident });
90
+ continue;
91
+ }
92
+
93
+ throw new Error(`Unexpected character '${ch}' in expression at position ${i}`);
94
+ }
95
+
96
+ return tokens;
97
+ }
98
+
99
+ // Parser (recursive descent)
100
+ function parse(tokens: Token[]): Expr {
101
+ let pos = 0;
102
+
103
+ function peek(): Token | undefined { return tokens[pos]; }
104
+ function advance(): Token { return tokens[pos++]; }
105
+
106
+ function parseExpr(): Expr { return parseOr(); }
107
+
108
+ function parseOr(): Expr {
109
+ let left = parseAnd();
110
+ while (peek()?.type === 'op' && (peek() as any).value === '||') {
111
+ advance();
112
+ left = { type: 'logical', op: '||', left, right: parseAnd() };
113
+ }
114
+ return left;
115
+ }
116
+
117
+ function parseAnd(): Expr {
118
+ let left = parseComparison();
119
+ while (peek()?.type === 'op' && (peek() as any).value === '&&') {
120
+ advance();
121
+ left = { type: 'logical', op: '&&', left, right: parseComparison() };
122
+ }
123
+ return left;
124
+ }
125
+
126
+ function parseComparison(): Expr {
127
+ let left = parseUnary();
128
+ const t = peek();
129
+ if (t?.type === 'op' && ['==', '!=', '===', '!==', '<', '<=', '>', '>='].includes((t as any).value)) {
130
+ const op = (advance() as any).value;
131
+ return { type: 'binary', op, left, right: parseUnary() };
132
+ }
133
+ return left;
134
+ }
135
+
136
+ function parseUnary(): Expr {
137
+ if (peek()?.type === 'not') {
138
+ advance();
139
+ return { type: 'unary', op: '!', expr: parseUnary() };
140
+ }
141
+ return parsePrimary();
142
+ }
143
+
144
+ function parsePrimary(): Expr {
145
+ const t = peek();
146
+ if (!t) throw new Error('Unexpected end of expression');
147
+
148
+ if (t.type === 'paren' && t.value === '(') {
149
+ advance();
150
+ const expr = parseExpr();
151
+ if (peek()?.type !== 'paren' || (peek() as any).value !== ')') throw new Error('Expected )');
152
+ advance();
153
+ return expr;
154
+ }
155
+
156
+ if (t.type === 'string') { advance(); return { type: 'literal', value: t.value }; }
157
+ if (t.type === 'number') { advance(); return { type: 'literal', value: t.value }; }
158
+ if (t.type === 'bool') { advance(); return { type: 'literal', value: t.value }; }
159
+ if (t.type === 'null') { advance(); return { type: 'literal', value: null }; }
160
+
161
+ if (t.type === 'ident') {
162
+ const segments = [advance().value as string];
163
+ while (peek()?.type === 'dot' && tokens[pos + 1]?.type === 'ident') {
164
+ advance(); // dot
165
+ segments.push((advance() as any).value);
166
+ }
167
+ return { type: 'path', segments };
168
+ }
169
+
170
+ throw new Error(`Unexpected token: ${JSON.stringify(t)}`);
171
+ }
172
+
173
+ const result = parseExpr();
174
+ if (pos < tokens.length) throw new Error(`Unexpected token at position ${pos}: ${JSON.stringify(tokens[pos])}`);
175
+ return result;
176
+ }
177
+
178
+ // Evaluator
179
+ interface EvalContext {
180
+ auth: Record<string, unknown> | null;
181
+ params: Record<string, string>;
182
+ data: unknown;
183
+ newData: unknown;
184
+ }
185
+
186
+ function evaluate(expr: Expr, ctx: EvalContext): unknown {
187
+ switch (expr.type) {
188
+ case 'literal':
189
+ return expr.value;
190
+
191
+ case 'path': {
192
+ const [root, ...rest] = expr.segments;
193
+ let val: unknown;
194
+ if (root.startsWith('$')) {
195
+ val = ctx.params[root.slice(1)] ?? null;
196
+ } else if (root === 'auth') {
197
+ val = ctx.auth;
198
+ } else if (root === 'data') {
199
+ val = ctx.data;
200
+ } else if (root === 'newData') {
201
+ val = ctx.newData;
202
+ } else {
203
+ return null;
204
+ }
205
+ for (const seg of rest) {
206
+ if (val == null || typeof val !== 'object') return null;
207
+ val = (val as Record<string, unknown>)[seg];
208
+ }
209
+ return val ?? null;
210
+ }
211
+
212
+ case 'binary': {
213
+ const l = evaluate(expr.left, ctx);
214
+ const r = evaluate(expr.right, ctx);
215
+ switch (expr.op) {
216
+ case '==': case '===': return l === r;
217
+ case '!=': case '!==': return l !== r;
218
+ case '<': return (l as number) < (r as number);
219
+ case '<=': return (l as number) <= (r as number);
220
+ case '>': return (l as number) > (r as number);
221
+ case '>=': return (l as number) >= (r as number);
222
+ default: return false;
223
+ }
224
+ }
225
+
226
+ case 'unary':
227
+ return !evaluate(expr.expr, ctx);
228
+
229
+ case 'logical': {
230
+ const lv = evaluate(expr.left, ctx);
231
+ if (expr.op === '&&') return lv ? evaluate(expr.right, ctx) : false;
232
+ return lv ? lv : evaluate(expr.right, ctx);
233
+ }
234
+ }
235
+ }
236
+
237
+ /** Compile an expression string into a reusable evaluator function */
238
+ export function compileRule(expression: string | boolean): (ctx: EvalContext) => boolean {
239
+ if (typeof expression === 'boolean') return () => expression;
240
+ const ast = parse(tokenize(expression));
241
+ return (ctx) => {
242
+ try {
243
+ return !!evaluate(ast, ctx);
244
+ } catch {
245
+ return false;
246
+ }
247
+ };
248
+ }
249
+
250
+ export type { EvalContext };
@@ -0,0 +1,76 @@
1
+ import type { Database } from 'bun:sqlite';
2
+
3
+ export class FTSEngineOptions {
4
+ /** Auto-index writes for paths matching these prefixes */
5
+ indexPaths?: string[];
6
+ /** Fields to extract and index from written objects */
7
+ fields?: string[];
8
+ }
9
+
10
+ export class FTSEngine {
11
+ readonly options: FTSEngineOptions;
12
+ private db: Database;
13
+
14
+ constructor(db: Database, options?: Partial<FTSEngineOptions>) {
15
+ this.options = { ...new FTSEngineOptions(), ...options };
16
+ this.db = db;
17
+ this.init();
18
+ }
19
+
20
+ private init() {
21
+ this.db.run(`CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(path, content)`);
22
+ }
23
+
24
+ /** Index a path with text content */
25
+ index(path: string, content: string): void {
26
+ // Upsert: delete then insert
27
+ this.db.run('DELETE FROM fts WHERE path = ?', [path]);
28
+ this.db.run('INSERT INTO fts (path, content) VALUES (?, ?)', [path, content]);
29
+ }
30
+
31
+ /** Index specific fields from an object */
32
+ indexFields(path: string, obj: Record<string, unknown>, fields?: string[]): void {
33
+ const fieldsToIndex = fields ?? this.options.fields;
34
+ if (!fieldsToIndex?.length) return;
35
+
36
+ const parts: string[] = [];
37
+ for (const field of fieldsToIndex) {
38
+ const val = obj[field];
39
+ if (typeof val === 'string') parts.push(val);
40
+ }
41
+ if (parts.length > 0) {
42
+ this.index(path, parts.join(' '));
43
+ }
44
+ }
45
+
46
+ /** Remove a path from the FTS index */
47
+ remove(path: string): void {
48
+ this.db.run('DELETE FROM fts WHERE path = ?', [path]);
49
+ }
50
+
51
+ /** Search for text, returns matching paths with rank */
52
+ search(options: { text: string; prefix?: string; limit?: number }): Array<{ path: string; rank: number }> {
53
+ let query = 'SELECT path, rank FROM fts WHERE fts MATCH ?';
54
+ const params: unknown[] = [options.text];
55
+
56
+ if (options.prefix) {
57
+ query += ' AND path >= ? AND path < ?';
58
+ params.push(options.prefix + '/', options.prefix + '/\uffff');
59
+ }
60
+
61
+ query += ' ORDER BY rank';
62
+
63
+ if (options.limit) {
64
+ query += ' LIMIT ?';
65
+ params.push(options.limit);
66
+ }
67
+
68
+ return this.db.prepare(query).all(...params) as Array<{ path: string; rank: number }>;
69
+ }
70
+
71
+ /** Check if a path should be auto-indexed based on config */
72
+ shouldAutoIndex(path: string): boolean {
73
+ if (!this.options.indexPaths?.length) return false;
74
+ return this.options.indexPaths.some(prefix => path.startsWith(prefix + '/') || path === prefix);
75
+ }
76
+ }