@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,286 @@
|
|
|
1
|
+
import type { StorageEngine } from './StorageEngine.ts';
|
|
2
|
+
import { generatePushId } from './StorageEngine.ts';
|
|
3
|
+
import { validatePath, prefixEnd } from '../shared/pathUtils.ts';
|
|
4
|
+
import type { Database } from 'bun:sqlite';
|
|
5
|
+
|
|
6
|
+
export class MQEngineOptions {
|
|
7
|
+
visibilityTimeout: number = 30; // seconds
|
|
8
|
+
maxDeliveries: number = 5;
|
|
9
|
+
dlqSuffix: string = '_dlq';
|
|
10
|
+
queues?: Record<string, Partial<MQEngineOptions>>; // per-queue overrides, longest prefix match
|
|
11
|
+
/** Called after mutating operations with affected paths */
|
|
12
|
+
notify?: (paths: string[]) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MQMessage {
|
|
16
|
+
key: string;
|
|
17
|
+
path: string;
|
|
18
|
+
data: unknown;
|
|
19
|
+
deliveryCount: number;
|
|
20
|
+
status: 'pending' | 'inflight' | 'dead';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export class MQEngine {
|
|
24
|
+
readonly options: MQEngineOptions;
|
|
25
|
+
private db: Database;
|
|
26
|
+
|
|
27
|
+
private stmtPush: ReturnType<Database['prepare']>;
|
|
28
|
+
private stmtPushIdempotent: ReturnType<Database['prepare']>;
|
|
29
|
+
private stmtGetByIdempotencyKey: ReturnType<Database['prepare']>;
|
|
30
|
+
private stmtFetchCandidates: ReturnType<Database['prepare']>;
|
|
31
|
+
private stmtClaim: ReturnType<Database['prepare']>;
|
|
32
|
+
private stmtGetRow: ReturnType<Database['prepare']>;
|
|
33
|
+
private stmtAck: ReturnType<Database['prepare']>;
|
|
34
|
+
private stmtRelease: ReturnType<Database['prepare']>;
|
|
35
|
+
private stmtSweepFetch: ReturnType<Database['prepare']>;
|
|
36
|
+
private stmtPeek: ReturnType<Database['prepare']>;
|
|
37
|
+
private stmtDlqInsert: ReturnType<Database['prepare']>;
|
|
38
|
+
private stmtDlqDelete: ReturnType<Database['prepare']>;
|
|
39
|
+
private stmtPurge: ReturnType<Database['prepare']>;
|
|
40
|
+
private stmtPurgeAll: ReturnType<Database['prepare']>;
|
|
41
|
+
|
|
42
|
+
constructor(private storage: StorageEngine, options?: Partial<MQEngineOptions>) {
|
|
43
|
+
this.options = { ...new MQEngineOptions(), ...options };
|
|
44
|
+
this.db = storage.db;
|
|
45
|
+
|
|
46
|
+
this.stmtPush = this.db.prepare(
|
|
47
|
+
'INSERT INTO nodes (path, value, updated_at, mq_status, mq_delivery_count) VALUES (?, ?, ?, \'pending\', 0)'
|
|
48
|
+
);
|
|
49
|
+
this.stmtPushIdempotent = this.db.prepare(
|
|
50
|
+
'INSERT OR IGNORE INTO nodes (path, value, updated_at, mq_status, mq_delivery_count, idempotency_key) VALUES (?, ?, ?, \'pending\', 0, ?)'
|
|
51
|
+
);
|
|
52
|
+
this.stmtGetByIdempotencyKey = this.db.prepare(
|
|
53
|
+
'SELECT path FROM nodes WHERE idempotency_key = ?'
|
|
54
|
+
);
|
|
55
|
+
// Fetch pending OR expired inflight, ordered by path (chronological for push IDs)
|
|
56
|
+
this.stmtFetchCandidates = this.db.prepare(
|
|
57
|
+
`SELECT path, value, mq_delivery_count FROM nodes
|
|
58
|
+
WHERE path >= ? AND path < ?
|
|
59
|
+
AND mq_status IS NOT NULL
|
|
60
|
+
AND (mq_status = 'pending' OR (mq_status = 'inflight' AND mq_inflight_until < ?))
|
|
61
|
+
ORDER BY path ASC LIMIT ?`
|
|
62
|
+
);
|
|
63
|
+
// TOCTOU-safe claim: only update if still eligible
|
|
64
|
+
this.stmtClaim = this.db.prepare(
|
|
65
|
+
`UPDATE nodes SET mq_status = 'inflight', mq_inflight_until = ?, mq_delivery_count = mq_delivery_count + 1
|
|
66
|
+
WHERE path = ? AND mq_status IS NOT NULL
|
|
67
|
+
AND (mq_status = 'pending' OR (mq_status = 'inflight' AND mq_inflight_until < ?))`
|
|
68
|
+
);
|
|
69
|
+
this.stmtGetRow = this.db.prepare(
|
|
70
|
+
'SELECT path, value, mq_delivery_count FROM nodes WHERE path = ? AND mq_status IS NOT NULL'
|
|
71
|
+
);
|
|
72
|
+
this.stmtAck = this.db.prepare(
|
|
73
|
+
'DELETE FROM nodes WHERE path = ? AND mq_status IS NOT NULL'
|
|
74
|
+
);
|
|
75
|
+
this.stmtRelease = this.db.prepare(
|
|
76
|
+
`UPDATE nodes SET mq_status = 'pending', mq_inflight_until = NULL WHERE path = ? AND mq_status = 'inflight'`
|
|
77
|
+
);
|
|
78
|
+
this.stmtSweepFetch = this.db.prepare(
|
|
79
|
+
`SELECT path, value, mq_delivery_count FROM nodes
|
|
80
|
+
WHERE mq_status = 'inflight' AND mq_inflight_until < ?`
|
|
81
|
+
);
|
|
82
|
+
this.stmtPeek = this.db.prepare(
|
|
83
|
+
`SELECT path, value, mq_delivery_count, mq_status FROM nodes
|
|
84
|
+
WHERE path >= ? AND path < ? AND mq_status IS NOT NULL
|
|
85
|
+
ORDER BY path ASC LIMIT ?`
|
|
86
|
+
);
|
|
87
|
+
this.stmtDlqInsert = this.db.prepare(
|
|
88
|
+
'INSERT OR REPLACE INTO nodes (path, value, updated_at, mq_status) VALUES (?, ?, ?, \'dead\')'
|
|
89
|
+
);
|
|
90
|
+
this.stmtDlqDelete = this.db.prepare(
|
|
91
|
+
'DELETE FROM nodes WHERE path = ?'
|
|
92
|
+
);
|
|
93
|
+
this.stmtPurge = this.db.prepare(
|
|
94
|
+
`DELETE FROM nodes WHERE path >= ? AND path < ? AND mq_status = 'pending'`
|
|
95
|
+
);
|
|
96
|
+
this.stmtPurgeAll = this.db.prepare(
|
|
97
|
+
`DELETE FROM nodes WHERE path >= ? AND path < ? AND mq_status IS NOT NULL`
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Push a message onto the queue. Returns the generated key. */
|
|
102
|
+
push(queue: string, value: unknown, opts?: { idempotencyKey?: string }): string {
|
|
103
|
+
queue = validatePath(queue);
|
|
104
|
+
const key = generatePushId();
|
|
105
|
+
const fullPath = `${queue}/${key}`;
|
|
106
|
+
const now = Date.now();
|
|
107
|
+
const json = JSON.stringify(value);
|
|
108
|
+
|
|
109
|
+
if (opts?.idempotencyKey) {
|
|
110
|
+
const existing = this.stmtGetByIdempotencyKey.get(opts.idempotencyKey) as { path: string } | null;
|
|
111
|
+
if (existing) return existing.path.split('/').pop()!;
|
|
112
|
+
this.stmtPushIdempotent.run(fullPath, json, now, opts.idempotencyKey);
|
|
113
|
+
} else {
|
|
114
|
+
this.stmtPush.run(fullPath, json, now);
|
|
115
|
+
}
|
|
116
|
+
this.emit([fullPath, queue]);
|
|
117
|
+
return key;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Fetch and claim up to `count` messages. Atomic via transaction. */
|
|
121
|
+
fetch(queue: string, count: number = 1): MQMessage[] {
|
|
122
|
+
queue = validatePath(queue);
|
|
123
|
+
const prefix = queue + '/';
|
|
124
|
+
const end = prefixEnd(prefix);
|
|
125
|
+
const opts = this.optionsFor(queue);
|
|
126
|
+
const nowMs = Date.now();
|
|
127
|
+
const inflightUntil = nowMs + opts.visibilityTimeout * 1000;
|
|
128
|
+
|
|
129
|
+
const messages: MQMessage[] = [];
|
|
130
|
+
const tx = this.db.transaction(() => {
|
|
131
|
+
const candidates = this.stmtFetchCandidates.all(prefix, end, nowMs, count) as Array<{ path: string; value: string; mq_delivery_count: number }>;
|
|
132
|
+
for (const row of candidates) {
|
|
133
|
+
const result = this.stmtClaim.run(inflightUntil, row.path, nowMs);
|
|
134
|
+
if (result.changes > 0) {
|
|
135
|
+
// Re-read to get updated delivery count
|
|
136
|
+
const updated = this.stmtGetRow.get(row.path) as { path: string; value: string; mq_delivery_count: number } | null;
|
|
137
|
+
if (updated) {
|
|
138
|
+
const key = updated.path.slice(prefix.length);
|
|
139
|
+
messages.push({
|
|
140
|
+
key,
|
|
141
|
+
path: updated.path,
|
|
142
|
+
data: JSON.parse(updated.value),
|
|
143
|
+
deliveryCount: updated.mq_delivery_count,
|
|
144
|
+
status: 'inflight',
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
tx();
|
|
151
|
+
return messages;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Acknowledge (delete) a processed message. */
|
|
155
|
+
ack(queue: string, key: string): void {
|
|
156
|
+
queue = validatePath(queue);
|
|
157
|
+
const fullPath = `${queue}/${key}`;
|
|
158
|
+
this.stmtAck.run(fullPath);
|
|
159
|
+
this.emit([fullPath, queue]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Release a message back to pending (negative ack). */
|
|
163
|
+
nack(queue: string, key: string): void {
|
|
164
|
+
queue = validatePath(queue);
|
|
165
|
+
const fullPath = `${queue}/${key}`;
|
|
166
|
+
this.stmtRelease.run(fullPath);
|
|
167
|
+
this.emit([fullPath, queue]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Peek at messages without claiming them. */
|
|
171
|
+
peek(queue: string, count: number = 10): MQMessage[] {
|
|
172
|
+
queue = validatePath(queue);
|
|
173
|
+
const prefix = queue + '/';
|
|
174
|
+
const end = prefixEnd(prefix);
|
|
175
|
+
const dlqPrefix = queue + '/' + this.options.dlqSuffix;
|
|
176
|
+
|
|
177
|
+
const rows = this.stmtPeek.all(prefix, end, count) as Array<{ path: string; value: string; mq_delivery_count: number; mq_status: string }>;
|
|
178
|
+
return rows
|
|
179
|
+
.filter(r => !r.path.startsWith(dlqPrefix))
|
|
180
|
+
.map(r => ({
|
|
181
|
+
key: r.path.slice(prefix.length),
|
|
182
|
+
path: r.path,
|
|
183
|
+
data: JSON.parse(r.value),
|
|
184
|
+
deliveryCount: r.mq_delivery_count,
|
|
185
|
+
status: r.mq_status as 'pending' | 'inflight',
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Read dead letter queue messages. */
|
|
190
|
+
dlq(queue: string): MQMessage[] {
|
|
191
|
+
queue = validatePath(queue);
|
|
192
|
+
const dlqPath = `${queue}/${this.options.dlqSuffix}`;
|
|
193
|
+
const prefix = dlqPath + '/';
|
|
194
|
+
const end = prefixEnd(prefix);
|
|
195
|
+
|
|
196
|
+
const rows = this.db.prepare(
|
|
197
|
+
'SELECT path, value FROM nodes WHERE path >= ? AND path < ? AND mq_status = \'dead\' ORDER BY path ASC'
|
|
198
|
+
).all(prefix, end) as Array<{ path: string; value: string }>;
|
|
199
|
+
|
|
200
|
+
return rows.map(r => ({
|
|
201
|
+
key: r.path.slice(prefix.length),
|
|
202
|
+
path: r.path,
|
|
203
|
+
data: JSON.parse(r.value),
|
|
204
|
+
deliveryCount: 0,
|
|
205
|
+
status: 'dead' as const,
|
|
206
|
+
}));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Delete messages in a queue. Default: pending only. Pass { all: true } to include inflight + DLQ. */
|
|
210
|
+
purge(queue: string, opts?: { all?: boolean }): number {
|
|
211
|
+
queue = validatePath(queue);
|
|
212
|
+
const prefix = queue + '/';
|
|
213
|
+
const end = prefixEnd(prefix);
|
|
214
|
+
const result = (opts?.all ? this.stmtPurgeAll : this.stmtPurge).run(prefix, end);
|
|
215
|
+
if (result.changes > 0) this.emit([queue]);
|
|
216
|
+
return result.changes;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Reclaim expired inflight messages; move exhausted ones to DLQ. */
|
|
220
|
+
sweep(): { reclaimed: number; dlqd: number } {
|
|
221
|
+
const nowMs = Date.now();
|
|
222
|
+
let reclaimed = 0;
|
|
223
|
+
let dlqd = 0;
|
|
224
|
+
|
|
225
|
+
const expired = this.stmtSweepFetch.all(nowMs) as Array<{ path: string; value: string; mq_delivery_count: number }>;
|
|
226
|
+
|
|
227
|
+
const tx = this.db.transaction(() => {
|
|
228
|
+
for (const row of expired) {
|
|
229
|
+
// Determine which queue this belongs to and get options
|
|
230
|
+
const opts = this.optionsForPath(row.path);
|
|
231
|
+
if (row.mq_delivery_count >= opts.maxDeliveries) {
|
|
232
|
+
// Move to DLQ
|
|
233
|
+
const dlqPath = this.dlqPathForMessage(row.path, opts.dlqSuffix);
|
|
234
|
+
this.stmtDlqInsert.run(dlqPath, row.value, Date.now());
|
|
235
|
+
this.stmtDlqDelete.run(row.path);
|
|
236
|
+
dlqd++;
|
|
237
|
+
} else {
|
|
238
|
+
// Release back to pending
|
|
239
|
+
this.stmtRelease.run(row.path);
|
|
240
|
+
reclaimed++;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
tx();
|
|
245
|
+
|
|
246
|
+
if (expired.length > 0) {
|
|
247
|
+
this.emit(expired.map(r => r.path));
|
|
248
|
+
}
|
|
249
|
+
return { reclaimed, dlqd };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private emit(paths: string[]): void {
|
|
253
|
+
this.options.notify?.(paths);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Get effective options for a queue path (longest prefix match from queues config). */
|
|
257
|
+
private optionsFor(queue: string): MQEngineOptions {
|
|
258
|
+
if (!this.options.queues) return this.options;
|
|
259
|
+
let bestMatch = '';
|
|
260
|
+
let bestOpts: Partial<MQEngineOptions> | undefined;
|
|
261
|
+
for (const [prefix, opts] of Object.entries(this.options.queues)) {
|
|
262
|
+
if (queue.startsWith(prefix) && prefix.length > bestMatch.length) {
|
|
263
|
+
bestMatch = prefix;
|
|
264
|
+
bestOpts = opts;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return bestOpts ? { ...this.options, ...bestOpts } as MQEngineOptions : this.options;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Get options for a full message path (e.g. queues/jobs/abc123). */
|
|
271
|
+
private optionsForPath(fullPath: string): MQEngineOptions {
|
|
272
|
+
// Try to find the queue by stripping the last segment (the push key)
|
|
273
|
+
const lastSlash = fullPath.lastIndexOf('/');
|
|
274
|
+
if (lastSlash < 0) return this.options;
|
|
275
|
+
const queue = fullPath.slice(0, lastSlash);
|
|
276
|
+
return this.optionsFor(queue);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Build DLQ path from a message's full path. */
|
|
280
|
+
private dlqPathForMessage(fullPath: string, dlqSuffix: string): string {
|
|
281
|
+
const lastSlash = fullPath.lastIndexOf('/');
|
|
282
|
+
const queue = fullPath.slice(0, lastSlash);
|
|
283
|
+
const key = fullPath.slice(lastSlash + 1);
|
|
284
|
+
return `${queue}/${dlqSuffix}/${key}`;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { QueryFilter, OrderClause } from '../shared/protocol.ts';
|
|
2
|
+
import type { StorageEngine } from './StorageEngine.ts';
|
|
3
|
+
|
|
4
|
+
/** Fluent query builder that delegates to StorageEngine.query */
|
|
5
|
+
export class QueryEngine {
|
|
6
|
+
private filters: QueryFilter[] = [];
|
|
7
|
+
private orderClause?: OrderClause;
|
|
8
|
+
private limitVal?: number;
|
|
9
|
+
private offsetVal?: number;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private storage: StorageEngine,
|
|
13
|
+
private basePath: string,
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
where(field: string, op: QueryFilter['op'], value: unknown): QueryEngine {
|
|
17
|
+
this.filters.push({ field, op, value });
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
order(field: string, dir: 'asc' | 'desc' = 'asc'): QueryEngine {
|
|
22
|
+
this.orderClause = { field, dir };
|
|
23
|
+
return this;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
limit(n: number): QueryEngine {
|
|
27
|
+
this.limitVal = n;
|
|
28
|
+
return this;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
offset(n: number): QueryEngine {
|
|
32
|
+
this.offsetVal = n;
|
|
33
|
+
return this;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
get() {
|
|
37
|
+
return this.storage.query(
|
|
38
|
+
this.basePath,
|
|
39
|
+
this.filters.length ? this.filters : undefined,
|
|
40
|
+
this.orderClause,
|
|
41
|
+
this.limitVal,
|
|
42
|
+
this.offsetVal,
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { normalizePath } from '../shared/pathUtils.ts';
|
|
2
|
+
import { compileRule } from './ExpressionRules.ts';
|
|
3
|
+
|
|
4
|
+
export interface RuleContext {
|
|
5
|
+
auth: Record<string, unknown> | null;
|
|
6
|
+
path: string;
|
|
7
|
+
params: Record<string, string>;
|
|
8
|
+
data: unknown;
|
|
9
|
+
newData: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Rule can be a function (V1) or expression string/boolean (V2) */
|
|
13
|
+
export interface PathRule {
|
|
14
|
+
read?: ((ctx: RuleContext) => boolean) | string | boolean;
|
|
15
|
+
write?: ((ctx: RuleContext) => boolean) | string | boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class RulesEngineOptions {
|
|
19
|
+
rules: Record<string, PathRule> = {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface CompiledPathRule {
|
|
23
|
+
read?: (ctx: RuleContext) => boolean;
|
|
24
|
+
write?: (ctx: RuleContext) => boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class RulesEngine {
|
|
28
|
+
readonly options: RulesEngineOptions;
|
|
29
|
+
private compiled: Map<string, CompiledPathRule>;
|
|
30
|
+
|
|
31
|
+
constructor(options?: Partial<RulesEngineOptions>) {
|
|
32
|
+
this.options = { ...new RulesEngineOptions(), ...options };
|
|
33
|
+
this.compiled = new Map();
|
|
34
|
+
|
|
35
|
+
// Pre-compile expression rules
|
|
36
|
+
for (const [pattern, rule] of Object.entries(this.options.rules)) {
|
|
37
|
+
const compiled: CompiledPathRule = {};
|
|
38
|
+
if (rule.read !== undefined) {
|
|
39
|
+
compiled.read = typeof rule.read === 'function' ? rule.read : compileRule(rule.read);
|
|
40
|
+
}
|
|
41
|
+
if (rule.write !== undefined) {
|
|
42
|
+
compiled.write = typeof rule.write === 'function' ? rule.write : compileRule(rule.write);
|
|
43
|
+
}
|
|
44
|
+
this.compiled.set(pattern, compiled);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Check if an operation is allowed. Returns true if no rules match (open by default). */
|
|
49
|
+
check(
|
|
50
|
+
op: 'read' | 'write',
|
|
51
|
+
path: string,
|
|
52
|
+
auth: Record<string, unknown> | null,
|
|
53
|
+
data?: unknown,
|
|
54
|
+
newData?: unknown,
|
|
55
|
+
): boolean {
|
|
56
|
+
path = normalizePath(path);
|
|
57
|
+
const pathParts = path.split('/');
|
|
58
|
+
|
|
59
|
+
let bestMatch: { rule: CompiledPathRule; params: Record<string, string>; specificity: number } | null = null;
|
|
60
|
+
|
|
61
|
+
for (const [pattern, rule] of this.compiled) {
|
|
62
|
+
const patternParts = normalizePath(pattern).split('/');
|
|
63
|
+
const params = matchPattern(patternParts, pathParts);
|
|
64
|
+
if (params !== null) {
|
|
65
|
+
const specificity = patternParts.length;
|
|
66
|
+
if (!bestMatch || specificity > bestMatch.specificity) {
|
|
67
|
+
bestMatch = { rule, params, specificity };
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!bestMatch) return true;
|
|
73
|
+
|
|
74
|
+
const fn = bestMatch.rule[op];
|
|
75
|
+
if (fn === undefined) return true;
|
|
76
|
+
|
|
77
|
+
const ctx: RuleContext = {
|
|
78
|
+
auth,
|
|
79
|
+
path,
|
|
80
|
+
params: bestMatch.params,
|
|
81
|
+
data: data ?? null,
|
|
82
|
+
newData: newData ?? null,
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
return fn(ctx);
|
|
87
|
+
} catch {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function matchPattern(
|
|
94
|
+
patternParts: string[],
|
|
95
|
+
pathParts: string[],
|
|
96
|
+
): Record<string, string> | null {
|
|
97
|
+
if (patternParts.length > pathParts.length) return null;
|
|
98
|
+
|
|
99
|
+
const params: Record<string, string> = {};
|
|
100
|
+
for (let i = 0; i < patternParts.length; i++) {
|
|
101
|
+
if (patternParts[i].startsWith('$')) {
|
|
102
|
+
params[patternParts[i].slice(1)] = pathParts[i];
|
|
103
|
+
} else if (patternParts[i] !== pathParts[i]) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return params;
|
|
108
|
+
}
|