@push.rocks/smartmongo 3.0.0 → 4.0.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/dist_ts/00_commitinfo_data.js +1 -1
- package/dist_ts/tsmdb/engine/IndexEngine.d.ts +23 -3
- package/dist_ts/tsmdb/engine/IndexEngine.js +357 -55
- package/dist_ts/tsmdb/engine/QueryPlanner.d.ts +64 -0
- package/dist_ts/tsmdb/engine/QueryPlanner.js +308 -0
- package/dist_ts/tsmdb/engine/SessionEngine.d.ts +117 -0
- package/dist_ts/tsmdb/engine/SessionEngine.js +232 -0
- package/dist_ts/tsmdb/index.d.ts +7 -0
- package/dist_ts/tsmdb/index.js +6 -1
- package/dist_ts/tsmdb/server/CommandRouter.d.ts +36 -0
- package/dist_ts/tsmdb/server/CommandRouter.js +91 -1
- package/dist_ts/tsmdb/server/TsmdbServer.js +3 -1
- package/dist_ts/tsmdb/server/handlers/AdminHandler.js +106 -6
- package/dist_ts/tsmdb/server/handlers/DeleteHandler.js +15 -3
- package/dist_ts/tsmdb/server/handlers/FindHandler.js +44 -14
- package/dist_ts/tsmdb/server/handlers/InsertHandler.js +4 -1
- package/dist_ts/tsmdb/server/handlers/UpdateHandler.js +31 -5
- package/dist_ts/tsmdb/storage/FileStorageAdapter.d.ts +25 -1
- package/dist_ts/tsmdb/storage/FileStorageAdapter.js +75 -6
- package/dist_ts/tsmdb/storage/IStorageAdapter.d.ts +5 -0
- package/dist_ts/tsmdb/storage/MemoryStorageAdapter.d.ts +1 -0
- package/dist_ts/tsmdb/storage/MemoryStorageAdapter.js +12 -1
- package/dist_ts/tsmdb/storage/WAL.d.ts +117 -0
- package/dist_ts/tsmdb/storage/WAL.js +286 -0
- package/dist_ts/tsmdb/utils/checksum.d.ts +30 -0
- package/dist_ts/tsmdb/utils/checksum.js +77 -0
- package/dist_ts/tsmdb/utils/index.d.ts +1 -0
- package/dist_ts/tsmdb/utils/index.js +2 -0
- package/package.json +1 -1
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/tsmdb/engine/IndexEngine.ts +375 -56
- package/ts/tsmdb/engine/QueryPlanner.ts +393 -0
- package/ts/tsmdb/engine/SessionEngine.ts +292 -0
- package/ts/tsmdb/index.ts +9 -0
- package/ts/tsmdb/server/CommandRouter.ts +109 -0
- package/ts/tsmdb/server/TsmdbServer.ts +3 -0
- package/ts/tsmdb/server/handlers/AdminHandler.ts +110 -5
- package/ts/tsmdb/server/handlers/DeleteHandler.ts +17 -2
- package/ts/tsmdb/server/handlers/FindHandler.ts +42 -13
- package/ts/tsmdb/server/handlers/InsertHandler.ts +6 -0
- package/ts/tsmdb/server/handlers/UpdateHandler.ts +33 -4
- package/ts/tsmdb/storage/FileStorageAdapter.ts +88 -5
- package/ts/tsmdb/storage/IStorageAdapter.ts +6 -0
- package/ts/tsmdb/storage/MemoryStorageAdapter.ts +12 -0
- package/ts/tsmdb/storage/WAL.ts +375 -0
- package/ts/tsmdb/utils/checksum.ts +88 -0
- package/ts/tsmdb/utils/index.ts +1 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import * as plugins from '../tsmdb.plugins.js';
|
|
2
|
+
import type { Document, IStoredDocument } from '../types/interfaces.js';
|
|
3
|
+
import { IndexEngine } from './IndexEngine.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Query execution plan types
|
|
7
|
+
*/
|
|
8
|
+
export type TQueryPlanType = 'IXSCAN' | 'COLLSCAN' | 'FETCH' | 'IXSCAN_RANGE';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Represents a query execution plan
|
|
12
|
+
*/
|
|
13
|
+
export interface IQueryPlan {
|
|
14
|
+
/** The type of scan used */
|
|
15
|
+
type: TQueryPlanType;
|
|
16
|
+
/** Index name if using an index */
|
|
17
|
+
indexName?: string;
|
|
18
|
+
/** Index key specification */
|
|
19
|
+
indexKey?: Record<string, 1 | -1 | string>;
|
|
20
|
+
/** Whether the query can be fully satisfied by the index */
|
|
21
|
+
indexCovering: boolean;
|
|
22
|
+
/** Estimated selectivity (0-1, lower is more selective) */
|
|
23
|
+
selectivity: number;
|
|
24
|
+
/** Whether range operators are used */
|
|
25
|
+
usesRange: boolean;
|
|
26
|
+
/** Fields used from the index */
|
|
27
|
+
indexFieldsUsed: string[];
|
|
28
|
+
/** Filter conditions that must be applied post-index lookup */
|
|
29
|
+
residualFilter?: Document;
|
|
30
|
+
/** Explanation for debugging */
|
|
31
|
+
explanation: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Filter operator analysis
|
|
36
|
+
*/
|
|
37
|
+
interface IFilterOperatorInfo {
|
|
38
|
+
field: string;
|
|
39
|
+
operators: string[];
|
|
40
|
+
equality: boolean;
|
|
41
|
+
range: boolean;
|
|
42
|
+
in: boolean;
|
|
43
|
+
exists: boolean;
|
|
44
|
+
regex: boolean;
|
|
45
|
+
values: Record<string, any>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* QueryPlanner - Analyzes queries and selects optimal execution plans
|
|
50
|
+
*/
|
|
51
|
+
export class QueryPlanner {
|
|
52
|
+
private indexEngine: IndexEngine;
|
|
53
|
+
|
|
54
|
+
constructor(indexEngine: IndexEngine) {
|
|
55
|
+
this.indexEngine = indexEngine;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Generate an execution plan for a query filter
|
|
60
|
+
*/
|
|
61
|
+
async plan(filter: Document): Promise<IQueryPlan> {
|
|
62
|
+
await this.indexEngine['initialize']();
|
|
63
|
+
|
|
64
|
+
// Empty filter = full collection scan
|
|
65
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
type: 'COLLSCAN',
|
|
68
|
+
indexCovering: false,
|
|
69
|
+
selectivity: 1.0,
|
|
70
|
+
usesRange: false,
|
|
71
|
+
indexFieldsUsed: [],
|
|
72
|
+
explanation: 'No filter specified, full collection scan required',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Analyze the filter
|
|
77
|
+
const operatorInfo = this.analyzeFilter(filter);
|
|
78
|
+
|
|
79
|
+
// Get available indexes
|
|
80
|
+
const indexes = await this.indexEngine.listIndexes();
|
|
81
|
+
|
|
82
|
+
// Score each index
|
|
83
|
+
let bestPlan: IQueryPlan | null = null;
|
|
84
|
+
let bestScore = -1;
|
|
85
|
+
|
|
86
|
+
for (const index of indexes) {
|
|
87
|
+
const plan = this.scoreIndex(index, operatorInfo, filter);
|
|
88
|
+
if (plan.selectivity < 1.0) {
|
|
89
|
+
const score = this.calculateScore(plan);
|
|
90
|
+
if (score > bestScore) {
|
|
91
|
+
bestScore = score;
|
|
92
|
+
bestPlan = plan;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// If no suitable index found, fall back to collection scan
|
|
98
|
+
if (!bestPlan || bestScore <= 0) {
|
|
99
|
+
return {
|
|
100
|
+
type: 'COLLSCAN',
|
|
101
|
+
indexCovering: false,
|
|
102
|
+
selectivity: 1.0,
|
|
103
|
+
usesRange: false,
|
|
104
|
+
indexFieldsUsed: [],
|
|
105
|
+
explanation: 'No suitable index found for this query',
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return bestPlan;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Analyze filter to extract operator information per field
|
|
114
|
+
*/
|
|
115
|
+
private analyzeFilter(filter: Document, prefix = ''): Map<string, IFilterOperatorInfo> {
|
|
116
|
+
const result = new Map<string, IFilterOperatorInfo>();
|
|
117
|
+
|
|
118
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
119
|
+
// Skip logical operators at the top level
|
|
120
|
+
if (key.startsWith('$')) {
|
|
121
|
+
if (key === '$and' && Array.isArray(value)) {
|
|
122
|
+
// Merge $and conditions
|
|
123
|
+
for (const subFilter of value) {
|
|
124
|
+
const subInfo = this.analyzeFilter(subFilter, prefix);
|
|
125
|
+
for (const [field, info] of subInfo) {
|
|
126
|
+
if (result.has(field)) {
|
|
127
|
+
// Merge operators
|
|
128
|
+
const existing = result.get(field)!;
|
|
129
|
+
existing.operators.push(...info.operators);
|
|
130
|
+
existing.equality = existing.equality || info.equality;
|
|
131
|
+
existing.range = existing.range || info.range;
|
|
132
|
+
existing.in = existing.in || info.in;
|
|
133
|
+
Object.assign(existing.values, info.values);
|
|
134
|
+
} else {
|
|
135
|
+
result.set(field, info);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
144
|
+
const info: IFilterOperatorInfo = {
|
|
145
|
+
field: fullKey,
|
|
146
|
+
operators: [],
|
|
147
|
+
equality: false,
|
|
148
|
+
range: false,
|
|
149
|
+
in: false,
|
|
150
|
+
exists: false,
|
|
151
|
+
regex: false,
|
|
152
|
+
values: {},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (typeof value !== 'object' || value === null || value instanceof plugins.bson.ObjectId || value instanceof Date) {
|
|
156
|
+
// Direct equality
|
|
157
|
+
info.equality = true;
|
|
158
|
+
info.operators.push('$eq');
|
|
159
|
+
info.values['$eq'] = value;
|
|
160
|
+
} else if (Array.isArray(value)) {
|
|
161
|
+
// Array equality (rare, but possible)
|
|
162
|
+
info.equality = true;
|
|
163
|
+
info.operators.push('$eq');
|
|
164
|
+
info.values['$eq'] = value;
|
|
165
|
+
} else {
|
|
166
|
+
// Operator object
|
|
167
|
+
for (const [op, opValue] of Object.entries(value)) {
|
|
168
|
+
if (op.startsWith('$')) {
|
|
169
|
+
info.operators.push(op);
|
|
170
|
+
info.values[op] = opValue;
|
|
171
|
+
|
|
172
|
+
switch (op) {
|
|
173
|
+
case '$eq':
|
|
174
|
+
info.equality = true;
|
|
175
|
+
break;
|
|
176
|
+
case '$ne':
|
|
177
|
+
case '$not':
|
|
178
|
+
// These can use indexes but with low selectivity
|
|
179
|
+
break;
|
|
180
|
+
case '$in':
|
|
181
|
+
info.in = true;
|
|
182
|
+
break;
|
|
183
|
+
case '$nin':
|
|
184
|
+
// Can't efficiently use indexes
|
|
185
|
+
break;
|
|
186
|
+
case '$gt':
|
|
187
|
+
case '$gte':
|
|
188
|
+
case '$lt':
|
|
189
|
+
case '$lte':
|
|
190
|
+
info.range = true;
|
|
191
|
+
break;
|
|
192
|
+
case '$exists':
|
|
193
|
+
info.exists = true;
|
|
194
|
+
break;
|
|
195
|
+
case '$regex':
|
|
196
|
+
info.regex = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
// Nested object - recurse
|
|
201
|
+
const nestedInfo = this.analyzeFilter({ [op]: opValue }, fullKey);
|
|
202
|
+
for (const [nestedField, nestedFieldInfo] of nestedInfo) {
|
|
203
|
+
result.set(nestedField, nestedFieldInfo);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (info.operators.length > 0) {
|
|
210
|
+
result.set(fullKey, info);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return result;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Score an index for the given filter
|
|
219
|
+
*/
|
|
220
|
+
private scoreIndex(
|
|
221
|
+
index: { name: string; key: Record<string, any>; unique?: boolean; sparse?: boolean },
|
|
222
|
+
operatorInfo: Map<string, IFilterOperatorInfo>,
|
|
223
|
+
filter: Document
|
|
224
|
+
): IQueryPlan {
|
|
225
|
+
const indexFields = Object.keys(index.key);
|
|
226
|
+
const usedFields: string[] = [];
|
|
227
|
+
let usesRange = false;
|
|
228
|
+
let canUseIndex = true;
|
|
229
|
+
let selectivity = 1.0;
|
|
230
|
+
let residualFilter: Document | undefined;
|
|
231
|
+
|
|
232
|
+
// Check each index field in order
|
|
233
|
+
for (const field of indexFields) {
|
|
234
|
+
const info = operatorInfo.get(field);
|
|
235
|
+
if (!info) {
|
|
236
|
+
// Index field not in filter - stop here
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
usedFields.push(field);
|
|
241
|
+
|
|
242
|
+
// Calculate selectivity based on operator
|
|
243
|
+
if (info.equality) {
|
|
244
|
+
// Equality has high selectivity
|
|
245
|
+
selectivity *= 0.01; // Assume 1% match
|
|
246
|
+
} else if (info.in) {
|
|
247
|
+
// $in selectivity depends on array size
|
|
248
|
+
const inValues = info.values['$in'];
|
|
249
|
+
if (Array.isArray(inValues)) {
|
|
250
|
+
selectivity *= Math.min(0.5, inValues.length * 0.01);
|
|
251
|
+
} else {
|
|
252
|
+
selectivity *= 0.1;
|
|
253
|
+
}
|
|
254
|
+
} else if (info.range) {
|
|
255
|
+
// Range queries have moderate selectivity
|
|
256
|
+
selectivity *= 0.25;
|
|
257
|
+
usesRange = true;
|
|
258
|
+
// After range, can't use more index fields efficiently
|
|
259
|
+
break;
|
|
260
|
+
} else if (info.exists) {
|
|
261
|
+
// $exists can use sparse indexes
|
|
262
|
+
selectivity *= 0.5;
|
|
263
|
+
} else {
|
|
264
|
+
// Other operators may not be indexable
|
|
265
|
+
canUseIndex = false;
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!canUseIndex || usedFields.length === 0) {
|
|
271
|
+
return {
|
|
272
|
+
type: 'COLLSCAN',
|
|
273
|
+
indexCovering: false,
|
|
274
|
+
selectivity: 1.0,
|
|
275
|
+
usesRange: false,
|
|
276
|
+
indexFieldsUsed: [],
|
|
277
|
+
explanation: `Index ${index.name} cannot be used for this query`,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Build residual filter for conditions not covered by index
|
|
282
|
+
const coveredFields = new Set(usedFields);
|
|
283
|
+
const residualConditions: Record<string, any> = {};
|
|
284
|
+
for (const [field, info] of operatorInfo) {
|
|
285
|
+
if (!coveredFields.has(field)) {
|
|
286
|
+
// This field isn't covered by the index
|
|
287
|
+
if (info.equality) {
|
|
288
|
+
residualConditions[field] = info.values['$eq'];
|
|
289
|
+
} else {
|
|
290
|
+
residualConditions[field] = info.values;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (Object.keys(residualConditions).length > 0) {
|
|
296
|
+
residualFilter = residualConditions;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Unique indexes have better selectivity for equality
|
|
300
|
+
if (index.unique && usedFields.length === indexFields.length) {
|
|
301
|
+
selectivity = Math.min(selectivity, 0.001); // At most 1 document
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
type: usesRange ? 'IXSCAN_RANGE' : 'IXSCAN',
|
|
306
|
+
indexName: index.name,
|
|
307
|
+
indexKey: index.key,
|
|
308
|
+
indexCovering: Object.keys(residualConditions).length === 0,
|
|
309
|
+
selectivity,
|
|
310
|
+
usesRange,
|
|
311
|
+
indexFieldsUsed: usedFields,
|
|
312
|
+
residualFilter,
|
|
313
|
+
explanation: `Using index ${index.name} on fields [${usedFields.join(', ')}]`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Calculate overall score for a plan (higher is better)
|
|
319
|
+
*/
|
|
320
|
+
private calculateScore(plan: IQueryPlan): number {
|
|
321
|
+
let score = 0;
|
|
322
|
+
|
|
323
|
+
// Lower selectivity is better (fewer documents to fetch)
|
|
324
|
+
score += (1 - plan.selectivity) * 100;
|
|
325
|
+
|
|
326
|
+
// Index covering queries are best
|
|
327
|
+
if (plan.indexCovering) {
|
|
328
|
+
score += 50;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// More index fields used is better
|
|
332
|
+
score += plan.indexFieldsUsed.length * 10;
|
|
333
|
+
|
|
334
|
+
// Equality scans are better than range scans
|
|
335
|
+
if (!plan.usesRange) {
|
|
336
|
+
score += 20;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return score;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Explain a query - returns detailed plan information
|
|
344
|
+
*/
|
|
345
|
+
async explain(filter: Document): Promise<{
|
|
346
|
+
queryPlanner: {
|
|
347
|
+
plannerVersion: number;
|
|
348
|
+
namespace: string;
|
|
349
|
+
indexFilterSet: boolean;
|
|
350
|
+
winningPlan: IQueryPlan;
|
|
351
|
+
rejectedPlans: IQueryPlan[];
|
|
352
|
+
};
|
|
353
|
+
}> {
|
|
354
|
+
await this.indexEngine['initialize']();
|
|
355
|
+
|
|
356
|
+
// Analyze the filter
|
|
357
|
+
const operatorInfo = this.analyzeFilter(filter);
|
|
358
|
+
|
|
359
|
+
// Get available indexes
|
|
360
|
+
const indexes = await this.indexEngine.listIndexes();
|
|
361
|
+
|
|
362
|
+
// Score all indexes
|
|
363
|
+
const plans: IQueryPlan[] = [];
|
|
364
|
+
|
|
365
|
+
for (const index of indexes) {
|
|
366
|
+
const plan = this.scoreIndex(index, operatorInfo, filter);
|
|
367
|
+
plans.push(plan);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Add collection scan as fallback
|
|
371
|
+
plans.push({
|
|
372
|
+
type: 'COLLSCAN',
|
|
373
|
+
indexCovering: false,
|
|
374
|
+
selectivity: 1.0,
|
|
375
|
+
usesRange: false,
|
|
376
|
+
indexFieldsUsed: [],
|
|
377
|
+
explanation: 'Full collection scan',
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
// Sort by score (best first)
|
|
381
|
+
plans.sort((a, b) => this.calculateScore(b) - this.calculateScore(a));
|
|
382
|
+
|
|
383
|
+
return {
|
|
384
|
+
queryPlanner: {
|
|
385
|
+
plannerVersion: 1,
|
|
386
|
+
namespace: `${this.indexEngine['dbName']}.${this.indexEngine['collName']}`,
|
|
387
|
+
indexFilterSet: false,
|
|
388
|
+
winningPlan: plans[0],
|
|
389
|
+
rejectedPlans: plans.slice(1),
|
|
390
|
+
},
|
|
391
|
+
};
|
|
392
|
+
}
|
|
393
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import * as plugins from '../tsmdb.plugins.js';
|
|
2
|
+
import type { TransactionEngine } from './TransactionEngine.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Session state
|
|
6
|
+
*/
|
|
7
|
+
export interface ISession {
|
|
8
|
+
/** Session ID (UUID) */
|
|
9
|
+
id: string;
|
|
10
|
+
/** Timestamp when the session was created */
|
|
11
|
+
createdAt: number;
|
|
12
|
+
/** Timestamp of the last activity */
|
|
13
|
+
lastActivityAt: number;
|
|
14
|
+
/** Current transaction ID if any */
|
|
15
|
+
txnId?: string;
|
|
16
|
+
/** Transaction number for ordering */
|
|
17
|
+
txnNumber?: number;
|
|
18
|
+
/** Whether the session is in a transaction */
|
|
19
|
+
inTransaction: boolean;
|
|
20
|
+
/** Session metadata */
|
|
21
|
+
metadata?: Record<string, any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Session engine options
|
|
26
|
+
*/
|
|
27
|
+
export interface ISessionEngineOptions {
|
|
28
|
+
/** Session timeout in milliseconds (default: 30 minutes) */
|
|
29
|
+
sessionTimeoutMs?: number;
|
|
30
|
+
/** Interval to check for expired sessions in ms (default: 60 seconds) */
|
|
31
|
+
cleanupIntervalMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Session engine for managing client sessions
|
|
36
|
+
* - Tracks session lifecycle (create, touch, end)
|
|
37
|
+
* - Links sessions to transactions
|
|
38
|
+
* - Auto-aborts transactions on session expiry
|
|
39
|
+
*/
|
|
40
|
+
export class SessionEngine {
|
|
41
|
+
private sessions: Map<string, ISession> = new Map();
|
|
42
|
+
private sessionTimeoutMs: number;
|
|
43
|
+
private cleanupInterval?: ReturnType<typeof setInterval>;
|
|
44
|
+
private transactionEngine?: TransactionEngine;
|
|
45
|
+
|
|
46
|
+
constructor(options?: ISessionEngineOptions) {
|
|
47
|
+
this.sessionTimeoutMs = options?.sessionTimeoutMs ?? 30 * 60 * 1000; // 30 minutes default
|
|
48
|
+
const cleanupIntervalMs = options?.cleanupIntervalMs ?? 60 * 1000; // 1 minute default
|
|
49
|
+
|
|
50
|
+
// Start cleanup interval
|
|
51
|
+
this.cleanupInterval = setInterval(() => {
|
|
52
|
+
this.cleanupExpiredSessions();
|
|
53
|
+
}, cleanupIntervalMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Set the transaction engine to use for auto-abort
|
|
58
|
+
*/
|
|
59
|
+
setTransactionEngine(engine: TransactionEngine): void {
|
|
60
|
+
this.transactionEngine = engine;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start a new session
|
|
65
|
+
*/
|
|
66
|
+
startSession(sessionId?: string, metadata?: Record<string, any>): ISession {
|
|
67
|
+
const id = sessionId ?? new plugins.bson.UUID().toHexString();
|
|
68
|
+
const now = Date.now();
|
|
69
|
+
|
|
70
|
+
const session: ISession = {
|
|
71
|
+
id,
|
|
72
|
+
createdAt: now,
|
|
73
|
+
lastActivityAt: now,
|
|
74
|
+
inTransaction: false,
|
|
75
|
+
metadata,
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
this.sessions.set(id, session);
|
|
79
|
+
return session;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Get a session by ID
|
|
84
|
+
*/
|
|
85
|
+
getSession(sessionId: string): ISession | undefined {
|
|
86
|
+
const session = this.sessions.get(sessionId);
|
|
87
|
+
if (session && this.isSessionExpired(session)) {
|
|
88
|
+
// Session expired, clean it up
|
|
89
|
+
this.endSession(sessionId);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return session;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Touch a session to update last activity time
|
|
97
|
+
*/
|
|
98
|
+
touchSession(sessionId: string): boolean {
|
|
99
|
+
const session = this.sessions.get(sessionId);
|
|
100
|
+
if (!session) return false;
|
|
101
|
+
|
|
102
|
+
if (this.isSessionExpired(session)) {
|
|
103
|
+
this.endSession(sessionId);
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
session.lastActivityAt = Date.now();
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* End a session explicitly
|
|
113
|
+
* This will also abort any active transaction
|
|
114
|
+
*/
|
|
115
|
+
async endSession(sessionId: string): Promise<boolean> {
|
|
116
|
+
const session = this.sessions.get(sessionId);
|
|
117
|
+
if (!session) return false;
|
|
118
|
+
|
|
119
|
+
// If session has an active transaction, abort it
|
|
120
|
+
if (session.inTransaction && session.txnId && this.transactionEngine) {
|
|
121
|
+
try {
|
|
122
|
+
await this.transactionEngine.abortTransaction(session.txnId);
|
|
123
|
+
} catch (e) {
|
|
124
|
+
// Ignore abort errors during cleanup
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
this.sessions.delete(sessionId);
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Start a transaction in a session
|
|
134
|
+
*/
|
|
135
|
+
startTransaction(sessionId: string, txnId: string, txnNumber?: number): boolean {
|
|
136
|
+
const session = this.sessions.get(sessionId);
|
|
137
|
+
if (!session) return false;
|
|
138
|
+
|
|
139
|
+
if (this.isSessionExpired(session)) {
|
|
140
|
+
this.endSession(sessionId);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
session.txnId = txnId;
|
|
145
|
+
session.txnNumber = txnNumber;
|
|
146
|
+
session.inTransaction = true;
|
|
147
|
+
session.lastActivityAt = Date.now();
|
|
148
|
+
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* End a transaction in a session (commit or abort)
|
|
154
|
+
*/
|
|
155
|
+
endTransaction(sessionId: string): boolean {
|
|
156
|
+
const session = this.sessions.get(sessionId);
|
|
157
|
+
if (!session) return false;
|
|
158
|
+
|
|
159
|
+
session.txnId = undefined;
|
|
160
|
+
session.txnNumber = undefined;
|
|
161
|
+
session.inTransaction = false;
|
|
162
|
+
session.lastActivityAt = Date.now();
|
|
163
|
+
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Get transaction ID for a session
|
|
169
|
+
*/
|
|
170
|
+
getTransactionId(sessionId: string): string | undefined {
|
|
171
|
+
const session = this.sessions.get(sessionId);
|
|
172
|
+
return session?.txnId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Check if session is in a transaction
|
|
177
|
+
*/
|
|
178
|
+
isInTransaction(sessionId: string): boolean {
|
|
179
|
+
const session = this.sessions.get(sessionId);
|
|
180
|
+
return session?.inTransaction ?? false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Check if a session is expired
|
|
185
|
+
*/
|
|
186
|
+
isSessionExpired(session: ISession): boolean {
|
|
187
|
+
return Date.now() - session.lastActivityAt > this.sessionTimeoutMs;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Cleanup expired sessions
|
|
192
|
+
* This is called periodically by the cleanup interval
|
|
193
|
+
*/
|
|
194
|
+
private async cleanupExpiredSessions(): Promise<void> {
|
|
195
|
+
const expiredSessions: string[] = [];
|
|
196
|
+
|
|
197
|
+
for (const [id, session] of this.sessions) {
|
|
198
|
+
if (this.isSessionExpired(session)) {
|
|
199
|
+
expiredSessions.push(id);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// End all expired sessions (this will also abort their transactions)
|
|
204
|
+
for (const sessionId of expiredSessions) {
|
|
205
|
+
await this.endSession(sessionId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Get all active sessions
|
|
211
|
+
*/
|
|
212
|
+
listSessions(): ISession[] {
|
|
213
|
+
const activeSessions: ISession[] = [];
|
|
214
|
+
for (const session of this.sessions.values()) {
|
|
215
|
+
if (!this.isSessionExpired(session)) {
|
|
216
|
+
activeSessions.push(session);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return activeSessions;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get session count
|
|
224
|
+
*/
|
|
225
|
+
getSessionCount(): number {
|
|
226
|
+
return this.sessions.size;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Get sessions with active transactions
|
|
231
|
+
*/
|
|
232
|
+
getSessionsWithTransactions(): ISession[] {
|
|
233
|
+
return this.listSessions().filter(s => s.inTransaction);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Refresh session timeout
|
|
238
|
+
*/
|
|
239
|
+
refreshSession(sessionId: string): boolean {
|
|
240
|
+
return this.touchSession(sessionId);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Close the session engine and cleanup
|
|
245
|
+
*/
|
|
246
|
+
close(): void {
|
|
247
|
+
if (this.cleanupInterval) {
|
|
248
|
+
clearInterval(this.cleanupInterval);
|
|
249
|
+
this.cleanupInterval = undefined;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Clear all sessions
|
|
253
|
+
this.sessions.clear();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get or create a session for a given session ID
|
|
258
|
+
* Useful for handling MongoDB driver session requests
|
|
259
|
+
*/
|
|
260
|
+
getOrCreateSession(sessionId: string): ISession {
|
|
261
|
+
let session = this.getSession(sessionId);
|
|
262
|
+
if (!session) {
|
|
263
|
+
session = this.startSession(sessionId);
|
|
264
|
+
} else {
|
|
265
|
+
this.touchSession(sessionId);
|
|
266
|
+
}
|
|
267
|
+
return session;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Extract session ID from MongoDB lsid (logical session ID)
|
|
272
|
+
*/
|
|
273
|
+
static extractSessionId(lsid: any): string | undefined {
|
|
274
|
+
if (!lsid) return undefined;
|
|
275
|
+
|
|
276
|
+
// MongoDB session ID format: { id: UUID }
|
|
277
|
+
if (lsid.id) {
|
|
278
|
+
if (lsid.id instanceof plugins.bson.UUID) {
|
|
279
|
+
return lsid.id.toHexString();
|
|
280
|
+
}
|
|
281
|
+
if (typeof lsid.id === 'string') {
|
|
282
|
+
return lsid.id;
|
|
283
|
+
}
|
|
284
|
+
if (lsid.id.$binary?.base64) {
|
|
285
|
+
// Binary UUID format
|
|
286
|
+
return Buffer.from(lsid.id.$binary.base64, 'base64').toString('hex');
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return undefined;
|
|
291
|
+
}
|
|
292
|
+
}
|
package/ts/tsmdb/index.ts
CHANGED
|
@@ -19,6 +19,8 @@ export type { IStorageAdapter } from './storage/IStorageAdapter.js';
|
|
|
19
19
|
export { MemoryStorageAdapter } from './storage/MemoryStorageAdapter.js';
|
|
20
20
|
export { FileStorageAdapter } from './storage/FileStorageAdapter.js';
|
|
21
21
|
export { OpLog } from './storage/OpLog.js';
|
|
22
|
+
export { WAL } from './storage/WAL.js';
|
|
23
|
+
export type { IWalEntry, TWalOperation } from './storage/WAL.js';
|
|
22
24
|
|
|
23
25
|
// Export engines
|
|
24
26
|
export { QueryEngine } from './engine/QueryEngine.js';
|
|
@@ -26,6 +28,10 @@ export { UpdateEngine } from './engine/UpdateEngine.js';
|
|
|
26
28
|
export { AggregationEngine } from './engine/AggregationEngine.js';
|
|
27
29
|
export { IndexEngine } from './engine/IndexEngine.js';
|
|
28
30
|
export { TransactionEngine } from './engine/TransactionEngine.js';
|
|
31
|
+
export { QueryPlanner } from './engine/QueryPlanner.js';
|
|
32
|
+
export type { IQueryPlan, TQueryPlanType } from './engine/QueryPlanner.js';
|
|
33
|
+
export { SessionEngine } from './engine/SessionEngine.js';
|
|
34
|
+
export type { ISession, ISessionEngineOptions } from './engine/SessionEngine.js';
|
|
29
35
|
|
|
30
36
|
// Export server (the main entry point for using TsmDB)
|
|
31
37
|
export { TsmdbServer } from './server/TsmdbServer.js';
|
|
@@ -35,3 +41,6 @@ export type { ITsmdbServerOptions } from './server/TsmdbServer.js';
|
|
|
35
41
|
export { WireProtocol } from './server/WireProtocol.js';
|
|
36
42
|
export { CommandRouter } from './server/CommandRouter.js';
|
|
37
43
|
export type { ICommandHandler, IHandlerContext, ICursorState } from './server/CommandRouter.js';
|
|
44
|
+
|
|
45
|
+
// Export utilities
|
|
46
|
+
export * from './utils/checksum.js';
|