@push.rocks/smartmongo 2.0.14 → 2.2.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 +2 -2
- package/dist_ts/congodb/congodb.plugins.d.ts +10 -0
- package/dist_ts/congodb/congodb.plugins.js +14 -0
- package/dist_ts/congodb/engine/AggregationEngine.d.ts +66 -0
- package/dist_ts/congodb/engine/AggregationEngine.js +189 -0
- package/dist_ts/congodb/engine/IndexEngine.d.ts +77 -0
- package/dist_ts/congodb/engine/IndexEngine.js +376 -0
- package/dist_ts/congodb/engine/QueryEngine.d.ts +54 -0
- package/dist_ts/congodb/engine/QueryEngine.js +271 -0
- package/dist_ts/congodb/engine/TransactionEngine.d.ts +85 -0
- package/dist_ts/congodb/engine/TransactionEngine.js +287 -0
- package/dist_ts/congodb/engine/UpdateEngine.d.ts +47 -0
- package/dist_ts/congodb/engine/UpdateEngine.js +461 -0
- package/dist_ts/congodb/errors/CongoErrors.d.ts +100 -0
- package/dist_ts/congodb/errors/CongoErrors.js +155 -0
- package/dist_ts/congodb/index.d.ts +19 -0
- package/dist_ts/congodb/index.js +26 -0
- package/dist_ts/congodb/server/CommandRouter.d.ts +51 -0
- package/dist_ts/congodb/server/CommandRouter.js +132 -0
- package/dist_ts/congodb/server/CongoServer.d.ts +95 -0
- package/dist_ts/congodb/server/CongoServer.js +227 -0
- package/dist_ts/congodb/server/WireProtocol.d.ts +117 -0
- package/dist_ts/congodb/server/WireProtocol.js +298 -0
- package/dist_ts/congodb/server/handlers/AdminHandler.d.ts +100 -0
- package/dist_ts/congodb/server/handlers/AdminHandler.js +568 -0
- package/dist_ts/congodb/server/handlers/AggregateHandler.d.ts +31 -0
- package/dist_ts/congodb/server/handlers/AggregateHandler.js +277 -0
- package/dist_ts/congodb/server/handlers/DeleteHandler.d.ts +8 -0
- package/dist_ts/congodb/server/handlers/DeleteHandler.js +83 -0
- package/dist_ts/congodb/server/handlers/FindHandler.d.ts +31 -0
- package/dist_ts/congodb/server/handlers/FindHandler.js +261 -0
- package/dist_ts/congodb/server/handlers/HelloHandler.d.ts +11 -0
- package/dist_ts/congodb/server/handlers/HelloHandler.js +62 -0
- package/dist_ts/congodb/server/handlers/IndexHandler.d.ts +20 -0
- package/dist_ts/congodb/server/handlers/IndexHandler.js +183 -0
- package/dist_ts/congodb/server/handlers/InsertHandler.d.ts +8 -0
- package/dist_ts/congodb/server/handlers/InsertHandler.js +76 -0
- package/dist_ts/congodb/server/handlers/UpdateHandler.d.ts +24 -0
- package/dist_ts/congodb/server/handlers/UpdateHandler.js +270 -0
- package/dist_ts/congodb/server/handlers/index.d.ts +8 -0
- package/dist_ts/congodb/server/handlers/index.js +10 -0
- package/dist_ts/congodb/server/index.d.ts +6 -0
- package/dist_ts/congodb/server/index.js +7 -0
- package/dist_ts/congodb/storage/FileStorageAdapter.d.ts +61 -0
- package/dist_ts/congodb/storage/FileStorageAdapter.js +396 -0
- package/dist_ts/congodb/storage/IStorageAdapter.d.ts +140 -0
- package/dist_ts/congodb/storage/IStorageAdapter.js +2 -0
- package/dist_ts/congodb/storage/MemoryStorageAdapter.d.ts +66 -0
- package/dist_ts/congodb/storage/MemoryStorageAdapter.js +367 -0
- package/dist_ts/congodb/storage/OpLog.d.ts +93 -0
- package/dist_ts/congodb/storage/OpLog.js +221 -0
- package/dist_ts/congodb/types/interfaces.d.ts +363 -0
- package/dist_ts/congodb/types/interfaces.js +2 -0
- package/dist_ts/index.d.ts +1 -0
- package/dist_ts/index.js +8 -6
- package/npmextra.json +17 -7
- package/package.json +20 -12
- package/readme.hints.md +79 -0
- package/readme.md +398 -44
- package/ts/00_commitinfo_data.ts +1 -1
- package/ts/congodb/congodb.plugins.ts +17 -0
- package/ts/congodb/engine/AggregationEngine.ts +283 -0
- package/ts/congodb/engine/IndexEngine.ts +479 -0
- package/ts/congodb/engine/QueryEngine.ts +301 -0
- package/ts/congodb/engine/TransactionEngine.ts +351 -0
- package/ts/congodb/engine/UpdateEngine.ts +506 -0
- package/ts/congodb/errors/CongoErrors.ts +181 -0
- package/ts/congodb/index.ts +37 -0
- package/ts/congodb/server/CommandRouter.ts +180 -0
- package/ts/congodb/server/CongoServer.ts +298 -0
- package/ts/congodb/server/WireProtocol.ts +416 -0
- package/ts/congodb/server/handlers/AdminHandler.ts +614 -0
- package/ts/congodb/server/handlers/AggregateHandler.ts +342 -0
- package/ts/congodb/server/handlers/DeleteHandler.ts +100 -0
- package/ts/congodb/server/handlers/FindHandler.ts +301 -0
- package/ts/congodb/server/handlers/HelloHandler.ts +78 -0
- package/ts/congodb/server/handlers/IndexHandler.ts +207 -0
- package/ts/congodb/server/handlers/InsertHandler.ts +91 -0
- package/ts/congodb/server/handlers/UpdateHandler.ts +315 -0
- package/ts/congodb/server/handlers/index.ts +10 -0
- package/ts/congodb/server/index.ts +10 -0
- package/ts/congodb/storage/FileStorageAdapter.ts +479 -0
- package/ts/congodb/storage/IStorageAdapter.ts +202 -0
- package/ts/congodb/storage/MemoryStorageAdapter.ts +443 -0
- package/ts/congodb/storage/OpLog.ts +282 -0
- package/ts/congodb/types/interfaces.ts +433 -0
- package/ts/index.ts +3 -0
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import * as plugins from '../congodb.plugins.js';
|
|
2
|
+
import type { Document, IStoredDocument, ISortSpecification, ISortDirection } from '../types/interfaces.js';
|
|
3
|
+
|
|
4
|
+
// Import mingo Query class
|
|
5
|
+
import { Query } from 'mingo';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Query engine using mingo for MongoDB-compatible query matching
|
|
9
|
+
*/
|
|
10
|
+
export class QueryEngine {
|
|
11
|
+
/**
|
|
12
|
+
* Filter documents by a MongoDB query filter
|
|
13
|
+
*/
|
|
14
|
+
static filter(documents: IStoredDocument[], filter: Document): IStoredDocument[] {
|
|
15
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
16
|
+
return documents;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const query = new Query(filter);
|
|
20
|
+
return documents.filter(doc => query.test(doc));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Test if a single document matches a filter
|
|
25
|
+
*/
|
|
26
|
+
static matches(document: Document, filter: Document): boolean {
|
|
27
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const query = new Query(filter);
|
|
32
|
+
return query.test(document);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Find a single document matching the filter
|
|
37
|
+
*/
|
|
38
|
+
static findOne(documents: IStoredDocument[], filter: Document): IStoredDocument | null {
|
|
39
|
+
if (!filter || Object.keys(filter).length === 0) {
|
|
40
|
+
return documents[0] || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const query = new Query(filter);
|
|
44
|
+
for (const doc of documents) {
|
|
45
|
+
if (query.test(doc)) {
|
|
46
|
+
return doc;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Sort documents by a sort specification
|
|
54
|
+
*/
|
|
55
|
+
static sort(documents: IStoredDocument[], sort: ISortSpecification): IStoredDocument[] {
|
|
56
|
+
if (!sort) {
|
|
57
|
+
return documents;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Normalize sort specification to array of [field, direction] pairs
|
|
61
|
+
const sortFields: Array<[string, number]> = [];
|
|
62
|
+
|
|
63
|
+
if (Array.isArray(sort)) {
|
|
64
|
+
for (const [field, direction] of sort) {
|
|
65
|
+
sortFields.push([field, this.normalizeDirection(direction)]);
|
|
66
|
+
}
|
|
67
|
+
} else {
|
|
68
|
+
for (const [field, direction] of Object.entries(sort)) {
|
|
69
|
+
sortFields.push([field, this.normalizeDirection(direction)]);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return [...documents].sort((a, b) => {
|
|
74
|
+
for (const [field, direction] of sortFields) {
|
|
75
|
+
const aVal = this.getNestedValue(a, field);
|
|
76
|
+
const bVal = this.getNestedValue(b, field);
|
|
77
|
+
|
|
78
|
+
const comparison = this.compareValues(aVal, bVal);
|
|
79
|
+
if (comparison !== 0) {
|
|
80
|
+
return comparison * direction;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return 0;
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Apply projection to documents
|
|
89
|
+
*/
|
|
90
|
+
static project(documents: IStoredDocument[], projection: Document): Document[] {
|
|
91
|
+
if (!projection || Object.keys(projection).length === 0) {
|
|
92
|
+
return documents;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Determine if this is inclusion or exclusion projection
|
|
96
|
+
const keys = Object.keys(projection);
|
|
97
|
+
const hasInclusion = keys.some(k => k !== '_id' && projection[k] === 1);
|
|
98
|
+
const hasExclusion = keys.some(k => k !== '_id' && projection[k] === 0);
|
|
99
|
+
|
|
100
|
+
// Can't mix inclusion and exclusion (except for _id)
|
|
101
|
+
if (hasInclusion && hasExclusion) {
|
|
102
|
+
throw new Error('Cannot mix inclusion and exclusion in projection');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return documents.map(doc => {
|
|
106
|
+
if (hasInclusion) {
|
|
107
|
+
// Inclusion projection
|
|
108
|
+
const result: Document = {};
|
|
109
|
+
|
|
110
|
+
// Handle _id
|
|
111
|
+
if (projection._id !== 0 && projection._id !== false) {
|
|
112
|
+
result._id = doc._id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const key of keys) {
|
|
116
|
+
if (key === '_id') continue;
|
|
117
|
+
if (projection[key] === 1 || projection[key] === true) {
|
|
118
|
+
const value = this.getNestedValue(doc, key);
|
|
119
|
+
if (value !== undefined) {
|
|
120
|
+
this.setNestedValue(result, key, value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return result;
|
|
126
|
+
} else {
|
|
127
|
+
// Exclusion projection - start with copy and remove fields
|
|
128
|
+
const result = { ...doc };
|
|
129
|
+
|
|
130
|
+
for (const key of keys) {
|
|
131
|
+
if (projection[key] === 0 || projection[key] === false) {
|
|
132
|
+
this.deleteNestedValue(result, key);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return result;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get distinct values for a field
|
|
143
|
+
*/
|
|
144
|
+
static distinct(documents: IStoredDocument[], field: string, filter?: Document): any[] {
|
|
145
|
+
let docs = documents;
|
|
146
|
+
if (filter && Object.keys(filter).length > 0) {
|
|
147
|
+
docs = this.filter(documents, filter);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const values = new Set<any>();
|
|
151
|
+
for (const doc of docs) {
|
|
152
|
+
const value = this.getNestedValue(doc, field);
|
|
153
|
+
if (value !== undefined) {
|
|
154
|
+
if (Array.isArray(value)) {
|
|
155
|
+
// For arrays, add each element
|
|
156
|
+
for (const v of value) {
|
|
157
|
+
values.add(this.toComparable(v));
|
|
158
|
+
}
|
|
159
|
+
} else {
|
|
160
|
+
values.add(this.toComparable(value));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return Array.from(values);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Normalize sort direction to 1 or -1
|
|
170
|
+
*/
|
|
171
|
+
private static normalizeDirection(direction: ISortDirection): number {
|
|
172
|
+
if (typeof direction === 'number') {
|
|
173
|
+
return direction > 0 ? 1 : -1;
|
|
174
|
+
}
|
|
175
|
+
if (direction === 'asc' || direction === 'ascending') {
|
|
176
|
+
return 1;
|
|
177
|
+
}
|
|
178
|
+
return -1;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Get a nested value from an object using dot notation
|
|
183
|
+
*/
|
|
184
|
+
static getNestedValue(obj: any, path: string): any {
|
|
185
|
+
const parts = path.split('.');
|
|
186
|
+
let current = obj;
|
|
187
|
+
|
|
188
|
+
for (const part of parts) {
|
|
189
|
+
if (current === null || current === undefined) {
|
|
190
|
+
return undefined;
|
|
191
|
+
}
|
|
192
|
+
if (Array.isArray(current)) {
|
|
193
|
+
// Handle array access
|
|
194
|
+
const index = parseInt(part, 10);
|
|
195
|
+
if (!isNaN(index)) {
|
|
196
|
+
current = current[index];
|
|
197
|
+
} else {
|
|
198
|
+
// Get the field from all array elements
|
|
199
|
+
return current.map(item => this.getNestedValue(item, part)).flat();
|
|
200
|
+
}
|
|
201
|
+
} else {
|
|
202
|
+
current = current[part];
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return current;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Set a nested value in an object using dot notation
|
|
211
|
+
*/
|
|
212
|
+
private static setNestedValue(obj: any, path: string, value: any): void {
|
|
213
|
+
const parts = path.split('.');
|
|
214
|
+
let current = obj;
|
|
215
|
+
|
|
216
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
217
|
+
const part = parts[i];
|
|
218
|
+
if (!(part in current)) {
|
|
219
|
+
current[part] = {};
|
|
220
|
+
}
|
|
221
|
+
current = current[part];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
current[parts[parts.length - 1]] = value;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Delete a nested value from an object using dot notation
|
|
229
|
+
*/
|
|
230
|
+
private static deleteNestedValue(obj: any, path: string): void {
|
|
231
|
+
const parts = path.split('.');
|
|
232
|
+
let current = obj;
|
|
233
|
+
|
|
234
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
235
|
+
const part = parts[i];
|
|
236
|
+
if (!(part in current)) {
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
current = current[part];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
delete current[parts[parts.length - 1]];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Compare two values for sorting
|
|
247
|
+
*/
|
|
248
|
+
private static compareValues(a: any, b: any): number {
|
|
249
|
+
// Handle undefined/null
|
|
250
|
+
if (a === undefined && b === undefined) return 0;
|
|
251
|
+
if (a === undefined) return -1;
|
|
252
|
+
if (b === undefined) return 1;
|
|
253
|
+
if (a === null && b === null) return 0;
|
|
254
|
+
if (a === null) return -1;
|
|
255
|
+
if (b === null) return 1;
|
|
256
|
+
|
|
257
|
+
// Handle ObjectId
|
|
258
|
+
if (a instanceof plugins.bson.ObjectId && b instanceof plugins.bson.ObjectId) {
|
|
259
|
+
return a.toHexString().localeCompare(b.toHexString());
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Handle dates
|
|
263
|
+
if (a instanceof Date && b instanceof Date) {
|
|
264
|
+
return a.getTime() - b.getTime();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Handle numbers
|
|
268
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
269
|
+
return a - b;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Handle strings
|
|
273
|
+
if (typeof a === 'string' && typeof b === 'string') {
|
|
274
|
+
return a.localeCompare(b);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Handle booleans
|
|
278
|
+
if (typeof a === 'boolean' && typeof b === 'boolean') {
|
|
279
|
+
return (a ? 1 : 0) - (b ? 1 : 0);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Fall back to string comparison
|
|
283
|
+
return String(a).localeCompare(String(b));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Convert a value to a comparable form (for distinct)
|
|
288
|
+
*/
|
|
289
|
+
private static toComparable(value: any): any {
|
|
290
|
+
if (value instanceof plugins.bson.ObjectId) {
|
|
291
|
+
return value.toHexString();
|
|
292
|
+
}
|
|
293
|
+
if (value instanceof Date) {
|
|
294
|
+
return value.toISOString();
|
|
295
|
+
}
|
|
296
|
+
if (typeof value === 'object' && value !== null) {
|
|
297
|
+
return JSON.stringify(value);
|
|
298
|
+
}
|
|
299
|
+
return value;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import * as plugins from '../congodb.plugins.js';
|
|
2
|
+
import type { IStorageAdapter } from '../storage/IStorageAdapter.js';
|
|
3
|
+
import type { Document, IStoredDocument, ITransactionOptions } from '../types/interfaces.js';
|
|
4
|
+
import { CongoTransactionError, CongoWriteConflictError } from '../errors/CongoErrors.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Transaction state
|
|
8
|
+
*/
|
|
9
|
+
export interface ITransactionState {
|
|
10
|
+
id: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
startTime: plugins.bson.Timestamp;
|
|
13
|
+
status: 'active' | 'committed' | 'aborted';
|
|
14
|
+
readSet: Map<string, Set<string>>; // ns -> document _ids read
|
|
15
|
+
writeSet: Map<string, Map<string, { op: 'insert' | 'update' | 'delete'; doc?: IStoredDocument; originalDoc?: IStoredDocument }>>; // ns -> _id -> operation
|
|
16
|
+
snapshots: Map<string, IStoredDocument[]>; // ns -> snapshot of documents
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Transaction engine for ACID transaction support
|
|
21
|
+
*/
|
|
22
|
+
export class TransactionEngine {
|
|
23
|
+
private storage: IStorageAdapter;
|
|
24
|
+
private transactions: Map<string, ITransactionState> = new Map();
|
|
25
|
+
private txnCounter = 0;
|
|
26
|
+
|
|
27
|
+
constructor(storage: IStorageAdapter) {
|
|
28
|
+
this.storage = storage;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Start a new transaction
|
|
33
|
+
*/
|
|
34
|
+
startTransaction(sessionId: string, options?: ITransactionOptions): string {
|
|
35
|
+
this.txnCounter++;
|
|
36
|
+
const txnId = `txn_${sessionId}_${this.txnCounter}`;
|
|
37
|
+
|
|
38
|
+
const transaction: ITransactionState = {
|
|
39
|
+
id: txnId,
|
|
40
|
+
sessionId,
|
|
41
|
+
startTime: new plugins.bson.Timestamp({ t: Math.floor(Date.now() / 1000), i: this.txnCounter }),
|
|
42
|
+
status: 'active',
|
|
43
|
+
readSet: new Map(),
|
|
44
|
+
writeSet: new Map(),
|
|
45
|
+
snapshots: new Map(),
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
this.transactions.set(txnId, transaction);
|
|
49
|
+
return txnId;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get a transaction by ID
|
|
54
|
+
*/
|
|
55
|
+
getTransaction(txnId: string): ITransactionState | undefined {
|
|
56
|
+
return this.transactions.get(txnId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Check if a transaction is active
|
|
61
|
+
*/
|
|
62
|
+
isActive(txnId: string): boolean {
|
|
63
|
+
const txn = this.transactions.get(txnId);
|
|
64
|
+
return txn?.status === 'active';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Get or create a snapshot for a namespace
|
|
69
|
+
*/
|
|
70
|
+
async getSnapshot(txnId: string, dbName: string, collName: string): Promise<IStoredDocument[]> {
|
|
71
|
+
const txn = this.transactions.get(txnId);
|
|
72
|
+
if (!txn || txn.status !== 'active') {
|
|
73
|
+
throw new CongoTransactionError('Transaction is not active');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ns = `${dbName}.${collName}`;
|
|
77
|
+
if (!txn.snapshots.has(ns)) {
|
|
78
|
+
const snapshot = await this.storage.createSnapshot(dbName, collName);
|
|
79
|
+
txn.snapshots.set(ns, snapshot);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Apply transaction writes to snapshot
|
|
83
|
+
const snapshot = txn.snapshots.get(ns)!;
|
|
84
|
+
const writes = txn.writeSet.get(ns);
|
|
85
|
+
|
|
86
|
+
if (!writes) {
|
|
87
|
+
return snapshot;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Create a modified view of the snapshot
|
|
91
|
+
const result: IStoredDocument[] = [];
|
|
92
|
+
const deletedIds = new Set<string>();
|
|
93
|
+
const modifiedDocs = new Map<string, IStoredDocument>();
|
|
94
|
+
|
|
95
|
+
for (const [idStr, write] of writes) {
|
|
96
|
+
if (write.op === 'delete') {
|
|
97
|
+
deletedIds.add(idStr);
|
|
98
|
+
} else if (write.op === 'update' || write.op === 'insert') {
|
|
99
|
+
if (write.doc) {
|
|
100
|
+
modifiedDocs.set(idStr, write.doc);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Add existing documents (not deleted, possibly modified)
|
|
106
|
+
for (const doc of snapshot) {
|
|
107
|
+
const idStr = doc._id.toHexString();
|
|
108
|
+
if (deletedIds.has(idStr)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (modifiedDocs.has(idStr)) {
|
|
112
|
+
result.push(modifiedDocs.get(idStr)!);
|
|
113
|
+
modifiedDocs.delete(idStr);
|
|
114
|
+
} else {
|
|
115
|
+
result.push(doc);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Add new documents (inserts)
|
|
120
|
+
for (const doc of modifiedDocs.values()) {
|
|
121
|
+
result.push(doc);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Record a read operation
|
|
129
|
+
*/
|
|
130
|
+
recordRead(txnId: string, dbName: string, collName: string, docIds: string[]): void {
|
|
131
|
+
const txn = this.transactions.get(txnId);
|
|
132
|
+
if (!txn || txn.status !== 'active') return;
|
|
133
|
+
|
|
134
|
+
const ns = `${dbName}.${collName}`;
|
|
135
|
+
if (!txn.readSet.has(ns)) {
|
|
136
|
+
txn.readSet.set(ns, new Set());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const readSet = txn.readSet.get(ns)!;
|
|
140
|
+
for (const id of docIds) {
|
|
141
|
+
readSet.add(id);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Record a write operation (insert)
|
|
147
|
+
*/
|
|
148
|
+
recordInsert(txnId: string, dbName: string, collName: string, doc: IStoredDocument): void {
|
|
149
|
+
const txn = this.transactions.get(txnId);
|
|
150
|
+
if (!txn || txn.status !== 'active') {
|
|
151
|
+
throw new CongoTransactionError('Transaction is not active');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const ns = `${dbName}.${collName}`;
|
|
155
|
+
if (!txn.writeSet.has(ns)) {
|
|
156
|
+
txn.writeSet.set(ns, new Map());
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
txn.writeSet.get(ns)!.set(doc._id.toHexString(), {
|
|
160
|
+
op: 'insert',
|
|
161
|
+
doc,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Record a write operation (update)
|
|
167
|
+
*/
|
|
168
|
+
recordUpdate(
|
|
169
|
+
txnId: string,
|
|
170
|
+
dbName: string,
|
|
171
|
+
collName: string,
|
|
172
|
+
originalDoc: IStoredDocument,
|
|
173
|
+
updatedDoc: IStoredDocument
|
|
174
|
+
): void {
|
|
175
|
+
const txn = this.transactions.get(txnId);
|
|
176
|
+
if (!txn || txn.status !== 'active') {
|
|
177
|
+
throw new CongoTransactionError('Transaction is not active');
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const ns = `${dbName}.${collName}`;
|
|
181
|
+
if (!txn.writeSet.has(ns)) {
|
|
182
|
+
txn.writeSet.set(ns, new Map());
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const idStr = originalDoc._id.toHexString();
|
|
186
|
+
const existing = txn.writeSet.get(ns)!.get(idStr);
|
|
187
|
+
|
|
188
|
+
// If we already have a write for this document, update it
|
|
189
|
+
if (existing) {
|
|
190
|
+
existing.doc = updatedDoc;
|
|
191
|
+
} else {
|
|
192
|
+
txn.writeSet.get(ns)!.set(idStr, {
|
|
193
|
+
op: 'update',
|
|
194
|
+
doc: updatedDoc,
|
|
195
|
+
originalDoc,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Record a write operation (delete)
|
|
202
|
+
*/
|
|
203
|
+
recordDelete(txnId: string, dbName: string, collName: string, doc: IStoredDocument): void {
|
|
204
|
+
const txn = this.transactions.get(txnId);
|
|
205
|
+
if (!txn || txn.status !== 'active') {
|
|
206
|
+
throw new CongoTransactionError('Transaction is not active');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const ns = `${dbName}.${collName}`;
|
|
210
|
+
if (!txn.writeSet.has(ns)) {
|
|
211
|
+
txn.writeSet.set(ns, new Map());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const idStr = doc._id.toHexString();
|
|
215
|
+
const existing = txn.writeSet.get(ns)!.get(idStr);
|
|
216
|
+
|
|
217
|
+
if (existing && existing.op === 'insert') {
|
|
218
|
+
// If we inserted and then deleted, just remove the write
|
|
219
|
+
txn.writeSet.get(ns)!.delete(idStr);
|
|
220
|
+
} else {
|
|
221
|
+
txn.writeSet.get(ns)!.set(idStr, {
|
|
222
|
+
op: 'delete',
|
|
223
|
+
originalDoc: doc,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Commit a transaction
|
|
230
|
+
*/
|
|
231
|
+
async commitTransaction(txnId: string): Promise<void> {
|
|
232
|
+
const txn = this.transactions.get(txnId);
|
|
233
|
+
if (!txn) {
|
|
234
|
+
throw new CongoTransactionError('Transaction not found');
|
|
235
|
+
}
|
|
236
|
+
if (txn.status !== 'active') {
|
|
237
|
+
throw new CongoTransactionError(`Cannot commit transaction in state: ${txn.status}`);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Check for write conflicts
|
|
241
|
+
for (const [ns, writes] of txn.writeSet) {
|
|
242
|
+
const [dbName, collName] = ns.split('.');
|
|
243
|
+
const ids = Array.from(writes.keys()).map(id => new plugins.bson.ObjectId(id));
|
|
244
|
+
|
|
245
|
+
const hasConflicts = await this.storage.hasConflicts(dbName, collName, ids, txn.startTime);
|
|
246
|
+
if (hasConflicts) {
|
|
247
|
+
txn.status = 'aborted';
|
|
248
|
+
throw new CongoWriteConflictError();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Apply all writes
|
|
253
|
+
for (const [ns, writes] of txn.writeSet) {
|
|
254
|
+
const [dbName, collName] = ns.split('.');
|
|
255
|
+
|
|
256
|
+
for (const [idStr, write] of writes) {
|
|
257
|
+
switch (write.op) {
|
|
258
|
+
case 'insert':
|
|
259
|
+
if (write.doc) {
|
|
260
|
+
await this.storage.insertOne(dbName, collName, write.doc);
|
|
261
|
+
}
|
|
262
|
+
break;
|
|
263
|
+
case 'update':
|
|
264
|
+
if (write.doc) {
|
|
265
|
+
await this.storage.updateById(dbName, collName, new plugins.bson.ObjectId(idStr), write.doc);
|
|
266
|
+
}
|
|
267
|
+
break;
|
|
268
|
+
case 'delete':
|
|
269
|
+
await this.storage.deleteById(dbName, collName, new plugins.bson.ObjectId(idStr));
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
txn.status = 'committed';
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Abort a transaction
|
|
280
|
+
*/
|
|
281
|
+
async abortTransaction(txnId: string): Promise<void> {
|
|
282
|
+
const txn = this.transactions.get(txnId);
|
|
283
|
+
if (!txn) {
|
|
284
|
+
throw new CongoTransactionError('Transaction not found');
|
|
285
|
+
}
|
|
286
|
+
if (txn.status !== 'active') {
|
|
287
|
+
// Already committed or aborted, just return
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Simply discard all buffered writes
|
|
292
|
+
txn.writeSet.clear();
|
|
293
|
+
txn.readSet.clear();
|
|
294
|
+
txn.snapshots.clear();
|
|
295
|
+
txn.status = 'aborted';
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* End a transaction (cleanup)
|
|
300
|
+
*/
|
|
301
|
+
endTransaction(txnId: string): void {
|
|
302
|
+
this.transactions.delete(txnId);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Get all pending writes for a namespace
|
|
307
|
+
*/
|
|
308
|
+
getPendingWrites(txnId: string, dbName: string, collName: string): Map<string, { op: 'insert' | 'update' | 'delete'; doc?: IStoredDocument }> | undefined {
|
|
309
|
+
const txn = this.transactions.get(txnId);
|
|
310
|
+
if (!txn) return undefined;
|
|
311
|
+
|
|
312
|
+
const ns = `${dbName}.${collName}`;
|
|
313
|
+
return txn.writeSet.get(ns);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Execute a callback within a transaction, with automatic retry on conflict
|
|
318
|
+
*/
|
|
319
|
+
async withTransaction<T>(
|
|
320
|
+
sessionId: string,
|
|
321
|
+
callback: (txnId: string) => Promise<T>,
|
|
322
|
+
options?: ITransactionOptions & { maxRetries?: number }
|
|
323
|
+
): Promise<T> {
|
|
324
|
+
const maxRetries = options?.maxRetries ?? 3;
|
|
325
|
+
let lastError: Error | undefined;
|
|
326
|
+
|
|
327
|
+
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
328
|
+
const txnId = this.startTransaction(sessionId, options);
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
const result = await callback(txnId);
|
|
332
|
+
await this.commitTransaction(txnId);
|
|
333
|
+
this.endTransaction(txnId);
|
|
334
|
+
return result;
|
|
335
|
+
} catch (error: any) {
|
|
336
|
+
await this.abortTransaction(txnId);
|
|
337
|
+
this.endTransaction(txnId);
|
|
338
|
+
|
|
339
|
+
if (error instanceof CongoWriteConflictError && attempt < maxRetries - 1) {
|
|
340
|
+
// Retry on write conflict
|
|
341
|
+
lastError = error;
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
throw lastError || new CongoTransactionError('Transaction failed after max retries');
|
|
350
|
+
}
|
|
351
|
+
}
|