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/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
+ }