edinburgh 0.1.2
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/LICENSE +15 -0
- package/README.md +804 -0
- package/build/src/bytes.d.ts +155 -0
- package/build/src/bytes.js +455 -0
- package/build/src/bytes.js.map +1 -0
- package/build/src/edinburgh.d.ts +47 -0
- package/build/src/edinburgh.js +93 -0
- package/build/src/edinburgh.js.map +1 -0
- package/build/src/indexes.d.ts +348 -0
- package/build/src/indexes.js +632 -0
- package/build/src/indexes.js.map +1 -0
- package/build/src/models.d.ts +192 -0
- package/build/src/models.js +457 -0
- package/build/src/models.js.map +1 -0
- package/build/src/types.d.ts +301 -0
- package/build/src/types.js +522 -0
- package/build/src/types.js.map +1 -0
- package/build/src/utils.d.ts +26 -0
- package/build/src/utils.js +32 -0
- package/build/src/utils.js.map +1 -0
- package/package.json +56 -0
- package/src/bytes.ts +500 -0
- package/src/edinburgh.ts +119 -0
- package/src/indexes.ts +810 -0
- package/src/models.ts +519 -0
- package/src/types.ts +635 -0
- package/src/utils.ts +39 -0
package/src/indexes.ts
ADDED
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import * as olmdb from "olmdb";
|
|
2
|
+
import { DatabaseError } from "olmdb";
|
|
3
|
+
import { Bytes } from "./bytes.js";
|
|
4
|
+
import { getMockModel, Model, modelRegistry } from "./models.js";
|
|
5
|
+
import { assert, logLevel } from "./utils.js";
|
|
6
|
+
import { deserializeType, serializeType, TypeWrapper } from "./types.js";
|
|
7
|
+
|
|
8
|
+
/** @internal Symbol used to access the underlying model from a proxy */
|
|
9
|
+
export const TARGET_SYMBOL = Symbol('target');
|
|
10
|
+
|
|
11
|
+
// Index system types and utilities
|
|
12
|
+
type IndexArgTypes<M extends typeof Model<any>, F extends readonly (keyof InstanceType<M> & string)[]> = {
|
|
13
|
+
[I in keyof F]: InstanceType<M>[F[I]]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const MAX_INDEX_ID_PREFIX = -1;
|
|
17
|
+
const INDEX_ID_PREFIX = -2;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Iterator for range queries on indexes.
|
|
21
|
+
* Handles common iteration logic for both primary and unique indexes.
|
|
22
|
+
* Implements both Iterator and Iterable interfaces for efficiency.
|
|
23
|
+
*/
|
|
24
|
+
class IndexRangeIterator<M extends typeof Model, F extends readonly (keyof InstanceType<M> & string)[]> implements Iterator<InstanceType<M>>, Iterable<InstanceType<M>> {
|
|
25
|
+
constructor(
|
|
26
|
+
private iterator: olmdb.DbIterator<any,any> | undefined,
|
|
27
|
+
private indexId: number,
|
|
28
|
+
private parentIndex: BaseIndex<M, F>
|
|
29
|
+
) {}
|
|
30
|
+
|
|
31
|
+
[Symbol.iterator](): Iterator<InstanceType<M>> {
|
|
32
|
+
return this;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
next(): IteratorResult<InstanceType<M>> {
|
|
36
|
+
if (!this.iterator) return { done: true, value: undefined };
|
|
37
|
+
const entry = this.iterator.next();
|
|
38
|
+
if (entry.done) {
|
|
39
|
+
this.iterator.close();
|
|
40
|
+
return { done: true, value: undefined };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Extract the key without the index ID
|
|
44
|
+
const keyBytes = new Bytes(entry.value.key);
|
|
45
|
+
const entryIndexId = keyBytes.readNumber();
|
|
46
|
+
assert(entryIndexId === this.indexId);
|
|
47
|
+
|
|
48
|
+
// Use polymorphism to get the model from the entry
|
|
49
|
+
const model = this.parentIndex._getModelFromEntry(keyBytes, new Bytes(entry.value.value));
|
|
50
|
+
|
|
51
|
+
if (!model) {
|
|
52
|
+
// This shouldn't happen, but skip if it does
|
|
53
|
+
return this.next();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return { done: false, value: model };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
count(): number {
|
|
60
|
+
let result = 0;
|
|
61
|
+
for (const _ of this) result++;
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fetch(): InstanceType<M> | undefined {
|
|
66
|
+
for (const model of this) {
|
|
67
|
+
return model; // Return the first model found
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type ArrayOrOnlyItem<ARG_TYPES extends readonly any[]> = ARG_TYPES extends readonly [infer A] ? (A | Partial<ARG_TYPES>) : Partial<ARG_TYPES>;
|
|
73
|
+
|
|
74
|
+
type FindOptions<ARG_TYPES extends readonly any[]> = (
|
|
75
|
+
(
|
|
76
|
+
{is: ArrayOrOnlyItem<ARG_TYPES>;} // Shortcut for setting `from` and `to` to the same value
|
|
77
|
+
|
|
|
78
|
+
(
|
|
79
|
+
(
|
|
80
|
+
{from: ArrayOrOnlyItem<ARG_TYPES>;}
|
|
81
|
+
|
|
|
82
|
+
{after: ArrayOrOnlyItem<ARG_TYPES>;}
|
|
83
|
+
|
|
|
84
|
+
{}
|
|
85
|
+
)
|
|
86
|
+
&
|
|
87
|
+
(
|
|
88
|
+
{to: ArrayOrOnlyItem<ARG_TYPES>;}
|
|
89
|
+
|
|
|
90
|
+
{before: ArrayOrOnlyItem<ARG_TYPES>;}
|
|
91
|
+
|
|
|
92
|
+
{}
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
) &
|
|
96
|
+
{
|
|
97
|
+
reverse?: boolean;
|
|
98
|
+
}
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Base class for database indexes for efficient lookups on model fields.
|
|
104
|
+
*
|
|
105
|
+
* Indexes enable fast queries on specific field combinations and enforce uniqueness constraints.
|
|
106
|
+
*
|
|
107
|
+
* @template M - The model class this index belongs to.
|
|
108
|
+
* @template F - The field names that make up this index.
|
|
109
|
+
*/
|
|
110
|
+
export abstract class BaseIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> {
|
|
111
|
+
public _MyModel: M;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Create a new index.
|
|
115
|
+
* @param MyModel - The model class this index belongs to.
|
|
116
|
+
* @param _fieldNames - Array of field names that make up this index.
|
|
117
|
+
*/
|
|
118
|
+
constructor(MyModel: M, public _fieldNames: F, isPrimary: boolean=false) {
|
|
119
|
+
this._MyModel = MyModel = getMockModel(MyModel);
|
|
120
|
+
// The primary key should be [0] in _indexes
|
|
121
|
+
(MyModel._indexes ||= [])[isPrimary ? 'unshift' : 'push'](this);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
_cachedIndexId?: number;
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Deserialize index key bytes back to field values.
|
|
128
|
+
* @param bytes - Bytes to read from.
|
|
129
|
+
* @returns Array of field values.
|
|
130
|
+
*/
|
|
131
|
+
_deserializeKey(bytes: Bytes): IndexArgTypes<M, F> {
|
|
132
|
+
const result: IndexArgTypes<M, F> = [] as any;
|
|
133
|
+
for (let i = 0; i < this._fieldNames.length; i++) {
|
|
134
|
+
const fieldName = this._fieldNames[i];
|
|
135
|
+
const fieldConfig = (this._MyModel.fields as any)[fieldName] as any;
|
|
136
|
+
fieldConfig.type.deserialize(result, i, bytes);
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Extract model from iterator entry - implemented differently by each index type.
|
|
143
|
+
* @param keyBytes - Key bytes with index ID already read.
|
|
144
|
+
* @param valueBytes - Value bytes from the entry.
|
|
145
|
+
* @returns Model instance or undefined.
|
|
146
|
+
* @internal
|
|
147
|
+
*/
|
|
148
|
+
abstract _getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Serialize field values to bytes for index key.
|
|
152
|
+
* @param args - Field values to serialize (can be partial for range queries).
|
|
153
|
+
* @param bytes - Bytes to write to.
|
|
154
|
+
* @internal
|
|
155
|
+
*/
|
|
156
|
+
_serializeArgs(args: Partial<IndexArgTypes<M, F>> | readonly any[], bytes: Bytes) {
|
|
157
|
+
const argsArray = Array.isArray(args) ? args : Object.values(args);
|
|
158
|
+
assert(argsArray.length <= this._fieldNames.length);
|
|
159
|
+
for (let i = 0; i < argsArray.length; i++) {
|
|
160
|
+
const fieldName = this._fieldNames[i];
|
|
161
|
+
const fieldConfig = this._MyModel.fields[fieldName];
|
|
162
|
+
fieldConfig.type.validateAndSerialize(argsArray, i, bytes);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Create database key from field values.
|
|
168
|
+
* @param args - Field values.
|
|
169
|
+
* @returns Database key bytes.
|
|
170
|
+
*/
|
|
171
|
+
_getKeyFromArgs(args: IndexArgTypes<M, F>): Uint8Array {
|
|
172
|
+
assert(args.length === this._fieldNames.length);
|
|
173
|
+
let indexId = this._getIndexId();
|
|
174
|
+
let keyBytes = new Bytes().writeNumber(indexId);
|
|
175
|
+
this._serializeArgs(args, keyBytes);
|
|
176
|
+
return keyBytes.getBuffer();
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Serialize model fields to bytes for index key.
|
|
181
|
+
* @param model - Model instance.
|
|
182
|
+
* @param bytes - Bytes to write to.
|
|
183
|
+
*/
|
|
184
|
+
_serializeModel(model: InstanceType<M>, bytes: Bytes) {
|
|
185
|
+
for (let i = 0; i < this._fieldNames.length; i++) {
|
|
186
|
+
const fieldName = this._fieldNames[i];
|
|
187
|
+
const fieldConfig = this._MyModel.fields[fieldName];
|
|
188
|
+
fieldConfig.type.validateAndSerialize(model, fieldName, bytes, model);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Create database key from model instance.
|
|
194
|
+
* @param model - Model instance.
|
|
195
|
+
* @param includeIndexId - Whether to include index ID in key.
|
|
196
|
+
* @returns Database key bytes or undefined if skipped.
|
|
197
|
+
* @internal
|
|
198
|
+
*/
|
|
199
|
+
_getKeyFromModel(model: InstanceType<M>, includeIndexId: boolean): Uint8Array {
|
|
200
|
+
const bytes = new Bytes();
|
|
201
|
+
if (includeIndexId) bytes.writeNumber(this._getIndexId());
|
|
202
|
+
this._serializeModel(model, bytes);
|
|
203
|
+
return bytes.getBuffer();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Extract field values from model for this index.
|
|
208
|
+
* @param model - Model instance.
|
|
209
|
+
* @returns Field values or undefined if should be skipped.
|
|
210
|
+
* @internal
|
|
211
|
+
*/
|
|
212
|
+
_modelToArgs(model: InstanceType<M>): IndexArgTypes<M, F> | undefined {
|
|
213
|
+
return this._checkSkip(model) ? undefined: this._fieldNames.map((fieldName) => model[fieldName]) as unknown as IndexArgTypes<M, F>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get or create unique index ID for this index.
|
|
218
|
+
* @returns Numeric index ID.
|
|
219
|
+
*/
|
|
220
|
+
_getIndexId(): number {
|
|
221
|
+
// Resolve an index to a number
|
|
222
|
+
let indexId = this._cachedIndexId;
|
|
223
|
+
if (indexId == null) {
|
|
224
|
+
const indexNameBytes = new Bytes().writeNumber(INDEX_ID_PREFIX).writeString(this._MyModel.tableName).writeString(this._getTypeName());
|
|
225
|
+
for(let name of this._fieldNames) {
|
|
226
|
+
indexNameBytes.writeString(name);
|
|
227
|
+
serializeType(this._MyModel.fields[name].type, indexNameBytes);
|
|
228
|
+
}
|
|
229
|
+
const indexNameBuf = indexNameBytes.getBuffer();
|
|
230
|
+
|
|
231
|
+
let result = olmdb.get(indexNameBuf);
|
|
232
|
+
if (result) {
|
|
233
|
+
indexId = this._cachedIndexId = new Bytes(result).readNumber();
|
|
234
|
+
} else {
|
|
235
|
+
const maxIndexIdBuf = new Bytes().writeNumber(MAX_INDEX_ID_PREFIX).getBuffer();
|
|
236
|
+
result = olmdb.get(maxIndexIdBuf);
|
|
237
|
+
indexId = result ? new Bytes(result).readNumber() + 1 : 1;
|
|
238
|
+
olmdb.onCommit(() => {
|
|
239
|
+
// Only if the transaction succeeds can we cache this id
|
|
240
|
+
this._cachedIndexId = indexId;
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const idBuf = new Bytes().writeNumber(indexId).getBuffer();
|
|
244
|
+
olmdb.put(indexNameBuf, idBuf);
|
|
245
|
+
olmdb.put(maxIndexIdBuf, idBuf); // This will also cause the transaction to rerun if we were raced
|
|
246
|
+
if (logLevel >= 1) {
|
|
247
|
+
console.log(`Created index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with id ${indexId}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return indexId;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Check if indexing should be skipped for a model instance.
|
|
256
|
+
* @param model - Model instance.
|
|
257
|
+
* @returns true if indexing should be skipped.
|
|
258
|
+
*/
|
|
259
|
+
_checkSkip(model: InstanceType<M>): boolean {
|
|
260
|
+
for (const fieldName of this._fieldNames) {
|
|
261
|
+
const fieldConfig = this._MyModel.fields[fieldName] as any;
|
|
262
|
+
if (fieldConfig.type.checkSkipIndex(model, fieldName)) return true;
|
|
263
|
+
}
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Find model instances using flexible range query options.
|
|
269
|
+
*
|
|
270
|
+
* Supports exact matches, inclusive/exclusive range queries, and reverse iteration.
|
|
271
|
+
* For single-field indexes, you can pass values directly or in arrays.
|
|
272
|
+
* For multi-field indexes, pass arrays or partial arrays for prefix matching.
|
|
273
|
+
*
|
|
274
|
+
* @param opts - Query options object
|
|
275
|
+
* @param opts.is - Exact match (sets both `from` and `to` to same value)
|
|
276
|
+
* @param opts.from - Range start (inclusive)
|
|
277
|
+
* @param opts.after - Range start (exclusive)
|
|
278
|
+
* @param opts.to - Range end (inclusive)
|
|
279
|
+
* @param opts.before - Range end (exclusive)
|
|
280
|
+
* @param opts.reverse - Whether to iterate in reverse order
|
|
281
|
+
* @returns An iterable of model instances matching the query
|
|
282
|
+
*
|
|
283
|
+
* @example
|
|
284
|
+
* ```typescript
|
|
285
|
+
* // Exact match
|
|
286
|
+
* for (const user of User.byEmail.find({is: "john@example.com"})) {
|
|
287
|
+
* console.log(user.name);
|
|
288
|
+
* }
|
|
289
|
+
*
|
|
290
|
+
* // Range query (inclusive)
|
|
291
|
+
* for (const user of User.byEmail.find({from: "a@", to: "m@"})) {
|
|
292
|
+
* console.log(user.email);
|
|
293
|
+
* }
|
|
294
|
+
*
|
|
295
|
+
* // Range query (exclusive)
|
|
296
|
+
* for (const user of User.byEmail.find({after: "a@", before: "m@"})) {
|
|
297
|
+
* console.log(user.email);
|
|
298
|
+
* }
|
|
299
|
+
*
|
|
300
|
+
* // Open-ended ranges
|
|
301
|
+
* for (const user of User.byEmail.find({from: "m@"})) { // m@ and later
|
|
302
|
+
* console.log(user.email);
|
|
303
|
+
* }
|
|
304
|
+
*
|
|
305
|
+
* for (const user of User.byEmail.find({to: "m@"})) { // up to and including m@
|
|
306
|
+
* console.log(user.email);
|
|
307
|
+
* }
|
|
308
|
+
*
|
|
309
|
+
* // Reverse iteration
|
|
310
|
+
* for (const user of User.byEmail.find({reverse: true})) {
|
|
311
|
+
* console.log(user.email); // Z to A order
|
|
312
|
+
* }
|
|
313
|
+
*
|
|
314
|
+
* // Multi-field index prefix matching
|
|
315
|
+
* for (const item of CompositeModel.pk.find({from: ["electronics", "phones"]})) {
|
|
316
|
+
* console.log(item.name); // All electronics/phones items
|
|
317
|
+
* }
|
|
318
|
+
*
|
|
319
|
+
* // For single-field indexes, you can use the value directly
|
|
320
|
+
* for (const user of User.byEmail.find({is: "john@example.com"})) {
|
|
321
|
+
* console.log(user.name);
|
|
322
|
+
* }
|
|
323
|
+
* ```
|
|
324
|
+
*/
|
|
325
|
+
public find(opts: FindOptions<IndexArgTypes<M, F>> = {}): IndexRangeIterator<M,F> {
|
|
326
|
+
const indexId = this._getIndexId();
|
|
327
|
+
|
|
328
|
+
let startKey: Bytes | undefined = new Bytes().writeNumber(indexId);
|
|
329
|
+
let endKey: Bytes | undefined = startKey.copy();
|
|
330
|
+
|
|
331
|
+
if ('is' in opts) {
|
|
332
|
+
// Exact match - set both start and end to the same value
|
|
333
|
+
this._serializeArgs(toArray(opts.is), startKey);
|
|
334
|
+
endKey = startKey.copy().increment();
|
|
335
|
+
} else {
|
|
336
|
+
// Range query
|
|
337
|
+
if ('from' in opts) {
|
|
338
|
+
this._serializeArgs(toArray(opts.from), startKey);
|
|
339
|
+
} else if ('after' in opts) {
|
|
340
|
+
this._serializeArgs(toArray(opts.after), startKey);
|
|
341
|
+
if (!startKey.increment()) {
|
|
342
|
+
// There can be nothing 'after' - return an empty iterator
|
|
343
|
+
return new IndexRangeIterator(undefined, indexId, this);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if ('to' in opts) {
|
|
348
|
+
this._serializeArgs(toArray(opts.to), endKey);
|
|
349
|
+
endKey.increment();
|
|
350
|
+
} else if ('before' in opts) {
|
|
351
|
+
this._serializeArgs(toArray(opts.before), endKey);
|
|
352
|
+
} else {
|
|
353
|
+
endKey = endKey.increment(); // Next indexId
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// For reverse scans, swap start/end keys since OLMDB expects it
|
|
358
|
+
const scanStart = opts.reverse ? endKey : startKey;
|
|
359
|
+
const scanEnd = opts.reverse ? startKey : endKey;
|
|
360
|
+
|
|
361
|
+
const iterator = olmdb.scan({
|
|
362
|
+
start: scanStart?.getBuffer(),
|
|
363
|
+
end: scanEnd?.getBuffer(),
|
|
364
|
+
reverse: opts.reverse || false,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
return new IndexRangeIterator(iterator, indexId, this);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Save index entry for a model instance.
|
|
372
|
+
* @param model - Model instance to save.
|
|
373
|
+
* @param originalKey - Original key if updating.
|
|
374
|
+
*/
|
|
375
|
+
abstract _save(model: InstanceType<M>, originalKey?: Uint8Array): Uint8Array | undefined;
|
|
376
|
+
|
|
377
|
+
abstract _getTypeName(): string;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function toArray<T>(args: T): T extends readonly any[] ? T : [T] {
|
|
381
|
+
// Use type assertion to satisfy TypeScript while maintaining runtime correctness
|
|
382
|
+
return (Array.isArray(args) ? args : [args]) as T extends readonly any[] ? T : [T];
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Primary index that stores the actual model data.
|
|
387
|
+
*
|
|
388
|
+
* @template M - The model class this index belongs to.
|
|
389
|
+
* @template F - The field names that make up this index.
|
|
390
|
+
*/
|
|
391
|
+
export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
|
|
392
|
+
|
|
393
|
+
constructor(MyModel: M, fieldNames: F) {
|
|
394
|
+
super(MyModel, fieldNames, true);
|
|
395
|
+
if (MyModel._pk && MyModel._pk !== this) {
|
|
396
|
+
throw new DatabaseError(`Model ${MyModel.tableName} already has a primary key defined`, 'INIT_ERROR');
|
|
397
|
+
}
|
|
398
|
+
MyModel._pk = this;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Get a model instance by primary key values.
|
|
403
|
+
* @param args - The primary key values.
|
|
404
|
+
* @returns The model instance if found, undefined otherwise.
|
|
405
|
+
*
|
|
406
|
+
* @example
|
|
407
|
+
* ```typescript
|
|
408
|
+
* const user = User.pk.get("john_doe");
|
|
409
|
+
* ```
|
|
410
|
+
*/
|
|
411
|
+
get(...args: IndexArgTypes<M, F>): InstanceType<M> | undefined {
|
|
412
|
+
let keyBuffer = this._getKeyFromArgs(args as IndexArgTypes<M, F>);
|
|
413
|
+
if (logLevel >= 3) {
|
|
414
|
+
console.log(`Getting primary ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] (id=${this._getIndexId()}) with key`, args, keyBuffer);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
let valueBuffer = olmdb.get(keyBuffer);
|
|
418
|
+
if (!valueBuffer) return;
|
|
419
|
+
|
|
420
|
+
// This is a primary index. So we can now deserialize all primary and non-primary fields into instance values.
|
|
421
|
+
const model = new (this._MyModel as any)() as InstanceType<M>;
|
|
422
|
+
// We'll want to set all loaded values on the unproxied target object.
|
|
423
|
+
const unproxied = (model as any)[TARGET_SYMBOL];
|
|
424
|
+
unproxied._state = 2; // Loaded from disk, unmodified
|
|
425
|
+
|
|
426
|
+
const valueBytes = new Bytes(valueBuffer);
|
|
427
|
+
let primaryKeyIndex = 0;
|
|
428
|
+
for (const [fieldName, fieldConfig] of Object.entries(this._MyModel.fields)) {
|
|
429
|
+
if (this._fieldNames.includes(fieldName as any)) { // Value is part of primary key
|
|
430
|
+
unproxied[fieldName as string] = args[primaryKeyIndex];
|
|
431
|
+
primaryKeyIndex++;
|
|
432
|
+
} else {
|
|
433
|
+
// We're passing in the proxied model
|
|
434
|
+
fieldConfig.type.deserialize(unproxied, fieldName, valueBytes, model);
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return model;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* Extract model from iterator entry for primary index.
|
|
443
|
+
* @param keyBytes - Key bytes with index ID already read.
|
|
444
|
+
* @param valueBytes - Value bytes from the entry.
|
|
445
|
+
* @returns Model instance or undefined.
|
|
446
|
+
* @internal
|
|
447
|
+
*/
|
|
448
|
+
_getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
|
|
449
|
+
const model = new (this._MyModel as any)() as InstanceType<M>;
|
|
450
|
+
// We'll want to set all loaded values on the unproxied target object.
|
|
451
|
+
const unproxied = (model as any)[TARGET_SYMBOL];
|
|
452
|
+
unproxied._state = 2; // Loaded from disk, unmodified
|
|
453
|
+
|
|
454
|
+
for (let i = 0; i < this._fieldNames.length; i++) {
|
|
455
|
+
const fieldName = this._fieldNames[i];
|
|
456
|
+
const fieldConfig = (this._MyModel.fields as any)[fieldName] as any;
|
|
457
|
+
fieldConfig.type.deserialize(unproxied, fieldName, keyBytes);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const [fieldName, fieldConfig] of Object.entries(this._MyModel.fields)) {
|
|
461
|
+
if (this._fieldNames.includes(fieldName as any)) continue; // Value is part of primary key
|
|
462
|
+
// We're passing in the proxied model
|
|
463
|
+
fieldConfig.type.deserialize(unproxied, fieldName, valueBytes, model);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
return model;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Save primary index entry.
|
|
471
|
+
* @param model - Model instance.
|
|
472
|
+
* @param originalKey - Original key if updating.
|
|
473
|
+
*/
|
|
474
|
+
_save(model: InstanceType<M>, originalKey?: Uint8Array): Uint8Array {
|
|
475
|
+
// Note: this can (and usually will) be called on the non-proxied model instance.
|
|
476
|
+
assert(this._MyModel.prototype === model.constructor.prototype);
|
|
477
|
+
|
|
478
|
+
let newKey = this._getKeyFromModel(model, true);
|
|
479
|
+
if (originalKey && Buffer.compare(newKey, originalKey)) throw new DatabaseError(`Cannot change primary key for ${this._MyModel.tableName}[${this._fieldNames.join(', ')}]: ${originalKey} -> ${newKey}`, 'PRIMARY_CHANGE');
|
|
480
|
+
|
|
481
|
+
// Serialize all non-primary key fields
|
|
482
|
+
let valBytes = new Bytes();
|
|
483
|
+
for (const [fieldName, fieldConfig] of Object.entries(model._fields)) {
|
|
484
|
+
if (!this._fieldNames.includes(fieldName as any)) {
|
|
485
|
+
fieldConfig.type.validateAndSerialize(model, fieldName, valBytes, model);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
olmdb.put(newKey, valBytes.getBuffer());
|
|
490
|
+
|
|
491
|
+
if (logLevel >= 2) {
|
|
492
|
+
const keyBytes = new Bytes(newKey);
|
|
493
|
+
let indexId = keyBytes.readNumber();
|
|
494
|
+
console.log(`Saved primary ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] (id=${indexId}) with key`, this._deserializeKey(keyBytes), keyBytes.getBuffer());
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
return newKey;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
_getTypeName(): string {
|
|
501
|
+
return 'primary';
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Unique index that stores references to the primary key.
|
|
507
|
+
*
|
|
508
|
+
* @template M - The model class this index belongs to.
|
|
509
|
+
* @template F - The field names that make up this index.
|
|
510
|
+
*/
|
|
511
|
+
export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
|
|
512
|
+
/**
|
|
513
|
+
* Get a model instance by unique index key values.
|
|
514
|
+
* @param args - The unique index key values.
|
|
515
|
+
* @returns The model instance if found, undefined otherwise.
|
|
516
|
+
*
|
|
517
|
+
* @example
|
|
518
|
+
* ```typescript
|
|
519
|
+
* const userByEmail = User.byEmail.get("john@example.com");
|
|
520
|
+
* ```
|
|
521
|
+
*/
|
|
522
|
+
get(...args: IndexArgTypes<M, F>): InstanceType<M> | undefined {
|
|
523
|
+
let keyBuffer = this._getKeyFromArgs(args as IndexArgTypes<M, F>);
|
|
524
|
+
if (logLevel >= 3) {
|
|
525
|
+
console.log(`Getting unique ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] (id=${this._getIndexId()}) with key`, args, keyBuffer);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
let valueBuffer = olmdb.get(keyBuffer);
|
|
529
|
+
if (!valueBuffer) return;
|
|
530
|
+
|
|
531
|
+
const pk = this._MyModel._pk!;
|
|
532
|
+
const valueArgs = pk._deserializeKey(new Bytes(valueBuffer))
|
|
533
|
+
const result = pk.get(...valueArgs);
|
|
534
|
+
if (!result) throw new DatabaseError(`Unique index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] points at non-existing primary for key: ${args.join(', ')}`, 'CONSISTENCY_ERROR');
|
|
535
|
+
return result;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Extract model from iterator entry for unique index.
|
|
540
|
+
* @param keyBytes - Key bytes with index ID already read.
|
|
541
|
+
* @param valueBytes - Value bytes from the entry.
|
|
542
|
+
* @returns Model instance or undefined.
|
|
543
|
+
* @internal
|
|
544
|
+
*/
|
|
545
|
+
_getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
|
|
546
|
+
// For unique indexes, the value contains the primary key
|
|
547
|
+
const pk = this._MyModel._pk!;
|
|
548
|
+
const primaryKeyArgs = pk._deserializeKey(valueBytes);
|
|
549
|
+
return pk.get(...primaryKeyArgs);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Save unique index entry.
|
|
554
|
+
* @param model - Model instance.
|
|
555
|
+
* @param originalKey - Original key if updating.
|
|
556
|
+
*/
|
|
557
|
+
_save(model: InstanceType<M>, originalKey?: Uint8Array): Uint8Array | undefined {
|
|
558
|
+
// Note: this can (and usually will) be called on the non-proxied model instance.
|
|
559
|
+
assert(this._MyModel.prototype === model.constructor.prototype);
|
|
560
|
+
|
|
561
|
+
let newKey = this._checkSkip(model) ? undefined : this._getKeyFromModel(model, true);
|
|
562
|
+
|
|
563
|
+
if (originalKey) {
|
|
564
|
+
if (newKey && Buffer.compare(newKey, originalKey) === 0) {
|
|
565
|
+
// No change in index key, nothing to do
|
|
566
|
+
return newKey;
|
|
567
|
+
}
|
|
568
|
+
olmdb.del(originalKey);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
if (!newKey) {
|
|
572
|
+
// No new key, nothing to do
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// Check that this is not a duplicate key
|
|
577
|
+
if (olmdb.get(newKey)) {
|
|
578
|
+
throw new DatabaseError(`Unique constraint violation for ${(model.constructor as any).tableName}[${this._fieldNames.join('+')}]`, 'UNIQUE_CONSTRAINT');
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
let linkKey = (model.constructor as any)._pk!._getKeyFromModel(model, false);
|
|
582
|
+
olmdb.put(newKey, linkKey);
|
|
583
|
+
|
|
584
|
+
if (logLevel >= 2) {
|
|
585
|
+
console.log(`Saved unique index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with key ${newKey}`);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
return newKey;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
_getTypeName(): string {
|
|
592
|
+
return 'unique';
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// OLMDB does not support storing empty values, so we use a single byte value for secondary indexes.
|
|
597
|
+
const SECONDARY_VALUE = new Uint8Array([1]); // Single byte value for secondary indexes
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Secondary index for non-unique lookups.
|
|
601
|
+
*
|
|
602
|
+
* @template M - The model class this index belongs to.
|
|
603
|
+
* @template F - The field names that make up this index.
|
|
604
|
+
*/
|
|
605
|
+
export class SecondaryIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
|
|
606
|
+
/**
|
|
607
|
+
* Save secondary index entry.
|
|
608
|
+
* @param model - Model instance.
|
|
609
|
+
* @param originalKey - Original key if updating.
|
|
610
|
+
*/
|
|
611
|
+
_save(model: InstanceType<M>, originalKey?: Uint8Array): Uint8Array | undefined {
|
|
612
|
+
// Note: this can (and usually will) be called on the non-proxied model instance.
|
|
613
|
+
assert(this._MyModel.prototype === model.constructor.prototype);
|
|
614
|
+
|
|
615
|
+
let newKey = this._getKeyFromModel(model, true);
|
|
616
|
+
|
|
617
|
+
if (originalKey) {
|
|
618
|
+
if (newKey && Buffer.compare(newKey, originalKey) === 0) {
|
|
619
|
+
// No change in index key, nothing to do
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
olmdb.del(originalKey);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (!newKey) {
|
|
626
|
+
// No new key, nothing to do (index should be skipped)
|
|
627
|
+
return;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// For secondary indexes, we store a single byte value
|
|
631
|
+
olmdb.put(newKey, SECONDARY_VALUE);
|
|
632
|
+
|
|
633
|
+
if (logLevel >= 2) {
|
|
634
|
+
console.log(`Saved secondary index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with key ${newKey}`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
return newKey;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Extract model from iterator entry for secondary index.
|
|
642
|
+
* @param keyBytes - Key bytes with index ID already read.
|
|
643
|
+
* @param valueBytes - Value bytes from the entry.
|
|
644
|
+
* @returns Model instance or undefined.
|
|
645
|
+
* @internal
|
|
646
|
+
*/
|
|
647
|
+
_getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
|
|
648
|
+
// For secondary indexes, the primary key is stored after the index fields in the key
|
|
649
|
+
|
|
650
|
+
// First skip past the index fields
|
|
651
|
+
const temp = [] as any[];
|
|
652
|
+
for (let i = 0; i < this._fieldNames.length; i++) {
|
|
653
|
+
const fieldName = this._fieldNames[i];
|
|
654
|
+
const fieldConfig = this._MyModel.fields[fieldName];
|
|
655
|
+
fieldConfig.type.deserialize(temp, 0, keyBytes);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Now deserialize the primary key from the remaining bytes
|
|
659
|
+
const pk = this._MyModel._pk!;
|
|
660
|
+
const primaryKeyArgs = pk._deserializeKey(keyBytes);
|
|
661
|
+
return pk.get(...primaryKeyArgs);
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Create secondary index key that includes both index fields and primary key.
|
|
666
|
+
* @param model - Model instance.
|
|
667
|
+
* @returns Database key bytes or undefined if skipped.
|
|
668
|
+
*/
|
|
669
|
+
_getKeyFromModel(model: InstanceType<M>, includeIndexId: boolean): Uint8Array {
|
|
670
|
+
const bytes = new Bytes();
|
|
671
|
+
if (includeIndexId) bytes.writeNumber(this._getIndexId());
|
|
672
|
+
|
|
673
|
+
// Write the index fields
|
|
674
|
+
this._serializeModel(model, bytes);
|
|
675
|
+
|
|
676
|
+
// Write the primary key fields
|
|
677
|
+
const pk = this._MyModel._pk!;
|
|
678
|
+
pk._serializeModel(model, bytes);
|
|
679
|
+
|
|
680
|
+
return bytes.getBuffer();
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
_getTypeName(): string {
|
|
684
|
+
return 'secondary';
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// Type alias for backward compatibility
|
|
689
|
+
export type Index<M extends typeof Model, F extends readonly (keyof InstanceType<M> & string)[]> =
|
|
690
|
+
PrimaryIndex<M, F> | UniqueIndex<M, F> | SecondaryIndex<M, F>;
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Create a primary index on model fields.
|
|
694
|
+
* @template M - The model class.
|
|
695
|
+
* @template F - The field name (for single field index).
|
|
696
|
+
* @template FS - The field names array (for composite index).
|
|
697
|
+
* @param MyModel - The model class to create the index for.
|
|
698
|
+
* @param field - Single field name for simple indexes.
|
|
699
|
+
* @param fields - Array of field names for composite indexes.
|
|
700
|
+
* @returns A new PrimaryIndex instance.
|
|
701
|
+
*
|
|
702
|
+
* @example
|
|
703
|
+
* ```typescript
|
|
704
|
+
* class User extends E.Model<User> {
|
|
705
|
+
* static pk = E.primary(User, ["id"]);
|
|
706
|
+
* static pkSingle = E.primary(User, "id");
|
|
707
|
+
* }
|
|
708
|
+
* ```
|
|
709
|
+
*/
|
|
710
|
+
export function primary<M extends typeof Model, const F extends (keyof InstanceType<M> & string)>(MyModel: M, field: F): PrimaryIndex<M, [F]>;
|
|
711
|
+
export function primary<M extends typeof Model, const FS extends readonly (keyof InstanceType<M> & string)[]>(MyModel: M, fields: FS): PrimaryIndex<M, FS>;
|
|
712
|
+
|
|
713
|
+
export function primary(MyModel: typeof Model, fields: any): PrimaryIndex<any, any> {
|
|
714
|
+
return new PrimaryIndex(MyModel, Array.isArray(fields) ? fields : [fields]);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Create a unique index on model fields.
|
|
719
|
+
* @template M - The model class.
|
|
720
|
+
* @template F - The field name (for single field index).
|
|
721
|
+
* @template FS - The field names array (for composite index).
|
|
722
|
+
* @param MyModel - The model class to create the index for.
|
|
723
|
+
* @param field - Single field name for simple indexes.
|
|
724
|
+
* @param fields - Array of field names for composite indexes.
|
|
725
|
+
* @returns A new UniqueIndex instance.
|
|
726
|
+
*
|
|
727
|
+
* @example
|
|
728
|
+
* ```typescript
|
|
729
|
+
* class User extends E.Model<User> {
|
|
730
|
+
* static byEmail = E.unique(User, "email");
|
|
731
|
+
* static byNameAge = E.unique(User, ["name", "age"]);
|
|
732
|
+
* }
|
|
733
|
+
* ```
|
|
734
|
+
*/
|
|
735
|
+
export function unique<M extends typeof Model, const F extends (keyof InstanceType<M> & string)>(MyModel: M, field: F): UniqueIndex<M, [F]>;
|
|
736
|
+
export function unique<M extends typeof Model, const FS extends readonly (keyof InstanceType<M> & string)[]>(MyModel: M, fields: FS): UniqueIndex<M, FS>;
|
|
737
|
+
|
|
738
|
+
export function unique(MyModel: typeof Model, fields: any): UniqueIndex<any, any> {
|
|
739
|
+
return new UniqueIndex(MyModel, Array.isArray(fields) ? fields : [fields]);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Create a secondary index on model fields.
|
|
744
|
+
* @template M - The model class.
|
|
745
|
+
* @template F - The field name (for single field index).
|
|
746
|
+
* @template FS - The field names array (for composite index).
|
|
747
|
+
* @param MyModel - The model class to create the index for.
|
|
748
|
+
* @param field - Single field name for simple indexes.
|
|
749
|
+
* @param fields - Array of field names for composite indexes.
|
|
750
|
+
* @returns A new SecondaryIndex instance.
|
|
751
|
+
*
|
|
752
|
+
* @example
|
|
753
|
+
* ```typescript
|
|
754
|
+
* class User extends E.Model<User> {
|
|
755
|
+
* static byAge = E.index(User, "age");
|
|
756
|
+
* static byTagsDate = E.index(User, ["tags", "createdAt"]);
|
|
757
|
+
* }
|
|
758
|
+
* ```
|
|
759
|
+
*/
|
|
760
|
+
export function index<M extends typeof Model, const F extends (keyof InstanceType<M> & string)>(MyModel: M, field: F): SecondaryIndex<M, [F]>;
|
|
761
|
+
export function index<M extends typeof Model, const FS extends readonly (keyof InstanceType<M> & string)[]>(MyModel: M, fields: FS): SecondaryIndex<M, FS>;
|
|
762
|
+
|
|
763
|
+
export function index(MyModel: typeof Model, fields: any): SecondaryIndex<any, any> {
|
|
764
|
+
return new SecondaryIndex(MyModel, Array.isArray(fields) ? fields : [fields]);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Dump database contents for debugging.
|
|
769
|
+
*
|
|
770
|
+
* Prints all indexes and their data to the console for inspection.
|
|
771
|
+
* This is primarily useful for development and debugging purposes.
|
|
772
|
+
*/
|
|
773
|
+
export function dump() {
|
|
774
|
+
let indexesById = new Map<number, {name: string, type: string, fields: Record<string, TypeWrapper<any>>}>();
|
|
775
|
+
console.log("--- Database dump ---")
|
|
776
|
+
for(const {key,value} of olmdb.scan()) {
|
|
777
|
+
const kb = new Bytes(key);
|
|
778
|
+
const vb = new Bytes(value);
|
|
779
|
+
const indexId = kb.readNumber();
|
|
780
|
+
if (indexId === MAX_INDEX_ID_PREFIX) {
|
|
781
|
+
console.log("* Max index id", vb.readNumber());
|
|
782
|
+
} else if (indexId === INDEX_ID_PREFIX) {
|
|
783
|
+
const name = kb.readString();
|
|
784
|
+
const type = kb.readString();
|
|
785
|
+
const fields: Record<string, TypeWrapper<any>> = {};
|
|
786
|
+
while(kb.readAvailable()) {
|
|
787
|
+
const name = kb.readString();
|
|
788
|
+
fields[name] = deserializeType(kb, 0);
|
|
789
|
+
}
|
|
790
|
+
const fieldDescription = Object.entries(fields).map(([name, type]) => `${name}:${type}`);
|
|
791
|
+
const indexId = vb.readNumber();
|
|
792
|
+
console.log(`* Definition for ${type} ${indexId} for ${name}[${fieldDescription.join(',')}]`);
|
|
793
|
+
indexesById.set(indexId, {name, type, fields});
|
|
794
|
+
} else if (indexId > 0 && indexesById.has(indexId)) {
|
|
795
|
+
const index = indexesById.get(indexId)!;
|
|
796
|
+
const {name, type, fields} = index;
|
|
797
|
+
const rowKey: any = {};
|
|
798
|
+
for(const [fieldName, fieldType] of Object.entries(fields)) {
|
|
799
|
+
fieldType.deserialize(rowKey, fieldName, kb);
|
|
800
|
+
}
|
|
801
|
+
const Model = modelRegistry[name]!;
|
|
802
|
+
// TODO: once we're storing schemas (serializeType) in the db, we can deserialize here
|
|
803
|
+
let displayValue = (type === 'secondary') ? Model._pk!._deserializeKey(kb) : vb;
|
|
804
|
+
console.log(`* Row for ${type} ${indexId} with key ${JSON.stringify(rowKey)}`, displayValue);
|
|
805
|
+
} else {
|
|
806
|
+
console.log(`* Unhandled ${indexId} index key=${kb} value=${vb}`);
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
console.log("--- End of database dump ---")
|
|
810
|
+
}
|