edinburgh 0.1.3 → 0.3.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/src/indexes.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  import * as olmdb from "olmdb";
2
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";
3
+ import { DataPack } from "./datapack.js";
4
+ import { FieldConfig, getMockModel, Model, modelRegistry } from "./models.js";
5
+ import { assert, logLevel, delayedInits, tryDelayedInits } from "./utils.js";
6
6
  import { deserializeType, serializeType, TypeWrapper } from "./types.js";
7
7
 
8
- /** @internal Symbol used to access the underlying model from a proxy */
9
- export const TARGET_SYMBOL = Symbol('target');
10
-
11
8
  // Index system types and utilities
12
9
  type IndexArgTypes<M extends typeof Model<any>, F extends readonly (keyof InstanceType<M> & string)[]> = {
13
10
  [I in keyof F]: InstanceType<M>[F[I]]
@@ -21,11 +18,11 @@ const INDEX_ID_PREFIX = -2;
21
18
  * Handles common iteration logic for both primary and unique indexes.
22
19
  * Implements both Iterator and Iterable interfaces for efficiency.
23
20
  */
24
- class IndexRangeIterator<M extends typeof Model, F extends readonly (keyof InstanceType<M> & string)[]> implements Iterator<InstanceType<M>>, Iterable<InstanceType<M>> {
21
+ export class IndexRangeIterator<M extends typeof Model> implements Iterator<InstanceType<M>>, Iterable<InstanceType<M>> {
25
22
  constructor(
26
23
  private iterator: olmdb.DbIterator<any,any> | undefined,
27
24
  private indexId: number,
28
- private parentIndex: BaseIndex<M, F>
25
+ private parentIndex: BaseIndex<M, any>
29
26
  ) {}
30
27
 
31
28
  [Symbol.iterator](): Iterator<InstanceType<M>> {
@@ -41,12 +38,12 @@ class IndexRangeIterator<M extends typeof Model, F extends readonly (keyof Insta
41
38
  }
42
39
 
43
40
  // Extract the key without the index ID
44
- const keyBytes = new Bytes(entry.value.key);
41
+ const keyBytes = new DataPack(entry.value.key);
45
42
  const entryIndexId = keyBytes.readNumber();
46
43
  assert(entryIndexId === this.indexId);
47
44
 
48
45
  // Use polymorphism to get the model from the entry
49
- const model = this.parentIndex._getModelFromEntry(keyBytes, new Bytes(entry.value.value));
46
+ const model = this.parentIndex._pairToInstance(keyBytes, entry.value.value);
50
47
 
51
48
  if (!model) {
52
49
  // This shouldn't happen, but skip if it does
@@ -98,6 +95,45 @@ type FindOptions<ARG_TYPES extends readonly any[]> = (
98
95
  }
99
96
  );
100
97
 
98
+ const canonicalUint8Arrays = new Map<number, WeakRef<Uint8Array>>();
99
+
100
+ export function testArraysEqual(array1: Uint8Array, array2: Uint8Array): boolean {
101
+ if (array1.length !== array2.length) return false;
102
+ for (let i = 0; i < array1.length; i++) {
103
+ if (array1[i] !== array2[i]) return false;
104
+ }
105
+ return true;
106
+ }
107
+
108
+ /**
109
+ * Get a singleton instance of a Uint8Array containing the given data.
110
+ * @param data - The Uint8Array to canonicalize.
111
+ * @returns A unique Uint8Array, backed by a right-sized copy of the ArrayBuffer.
112
+ */
113
+ function getSingletonUint8Array<T>(data: Uint8Array): Uint8Array {
114
+ let hash : number = 5381, reclaimHash: number | undefined;
115
+ for (const byte of data) {
116
+ hash = ((hash << 5) + hash + byte) >>> 0;
117
+ }
118
+ while(true) {
119
+ let weakRef = canonicalUint8Arrays.get(hash);
120
+ if (!weakRef) break;
121
+ if (weakRef) {
122
+ const orgData = weakRef.deref();
123
+ if (!orgData) { // weakRef expired
124
+ if (reclaimHash === undefined) reclaimHash = hash;
125
+ } else if (data===orgData || testArraysEqual(data, orgData)) {
126
+ return orgData;
127
+ }
128
+ // else: hash collision, use open addressing
129
+ }
130
+ hash = (hash+1) >>> 0;
131
+ }
132
+ let copy = data.slice(); // Make a copy, backed by a new, correctly sized ArrayBuffer
133
+ canonicalUint8Arrays.set(reclaimHash === undefined ? hash : reclaimHash, new WeakRef(copy));
134
+ return copy;
135
+ }
136
+
101
137
 
102
138
  /**
103
139
  * Base class for database indexes for efficient lookups on model fields.
@@ -109,108 +145,82 @@ type FindOptions<ARG_TYPES extends readonly any[]> = (
109
145
  */
110
146
  export abstract class BaseIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> {
111
147
  public _MyModel: M;
112
-
148
+ public _fieldTypes: Map<keyof InstanceType<M> & string, TypeWrapper<any>> = new Map();
149
+ public _fieldCount!: number;
150
+
113
151
  /**
114
152
  * Create a new index.
115
153
  * @param MyModel - The model class this index belongs to.
116
154
  * @param _fieldNames - Array of field names that make up this index.
117
155
  */
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);
156
+ constructor(MyModel: M, public _fieldNames: F) {
157
+ this._MyModel = getMockModel(MyModel);
158
+ delayedInits.add(this);
159
+ tryDelayedInits();
122
160
  }
123
161
 
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);
162
+ _delayedInit(): boolean {
163
+ if (!this._MyModel.fields) return false; // Awaiting model init
164
+ for(const fieldName of this._fieldNames) {
165
+ assert(typeof fieldName === 'string', 'Field names must be strings');
166
+ this._fieldTypes.set(fieldName, this._MyModel.fields[fieldName].type);
137
167
  }
138
- return result;
168
+ this._fieldCount = this._fieldNames.length;
169
+ return true;
139
170
  }
140
171
 
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;
172
+ _cachedIndexId?: number;
149
173
 
150
174
  /**
151
- * Serialize field values to bytes for index key.
175
+ * Serialize array of key values to a (index-id prefixed) Bytes instance that can be used as a key.
152
176
  * @param args - Field values to serialize (can be partial for range queries).
153
- * @param bytes - Bytes to write to.
177
+ * @returns A Bytes instance containing the index id and serialized key parts.
154
178
  * @internal
155
179
  */
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);
180
+ _argsToKeyBytes(args: [], allowPartial: boolean): DataPack;
181
+ _argsToKeyBytes(args: Partial<IndexArgTypes<M, F>>, allowPartial: boolean): DataPack;
182
+
183
+ _argsToKeyBytes(args: any, allowPartial: boolean) {
184
+ assert(allowPartial ? args.length <= this._fieldCount : args.length === this._fieldCount);
185
+ const bytes = new DataPack();
186
+ bytes.write(this._getIndexId());
187
+ let index = 0;
188
+ for(const fieldType of this._fieldTypes.values()) {
189
+ // For partial keys, undefined values are acceptable and represent open range suffixes
190
+ if (index >= args.length) break;
191
+ fieldType.serialize(args[index++], bytes);
163
192
  }
193
+ return bytes;
164
194
  }
165
195
 
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
- }
196
+ _argsToKeySingleton(args: IndexArgTypes<M, F>): Uint8Array {
197
+ const bytes = this._argsToKeyBytes(args, false);
198
+ return getSingletonUint8Array(bytes.toUint8Array());
190
199
  }
191
200
 
192
201
  /**
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.
202
+ * Extract model from iterator entry - implemented differently by each index type.
203
+ * @param keyBytes - Key bytes with index ID already read.
204
+ * @param valueBuffer - Value Uint8Array from the entry.
205
+ * @returns Model instance or undefined.
197
206
  * @internal
198
207
  */
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();
208
+ abstract _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined;
209
+
210
+ _hasNullIndexValues(model: InstanceType<M>) {
211
+ for(const fieldName of this._fieldTypes.keys()) {
212
+ if (model[fieldName] == null) return true;
213
+ }
214
+ return false;
204
215
  }
205
216
 
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>;
217
+ _instanceToKeyBytes(model: InstanceType<M>): DataPack {
218
+ const bytes = new DataPack();
219
+ bytes.write(this._getIndexId());
220
+ for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
221
+ fieldType.serialize(model[fieldName], bytes);
222
+ }
223
+ return bytes;
214
224
  }
215
225
 
216
226
  /**
@@ -221,48 +231,39 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
221
231
  // Resolve an index to a number
222
232
  let indexId = this._cachedIndexId;
223
233
  if (indexId == null) {
224
- const indexNameBytes = new Bytes().writeNumber(INDEX_ID_PREFIX).writeString(this._MyModel.tableName).writeString(this._getTypeName());
234
+ const indexNameBytes = new DataPack().write(INDEX_ID_PREFIX).write(this._MyModel.tableName).write(this._getTypeName());
225
235
  for(let name of this._fieldNames) {
226
- indexNameBytes.writeString(name);
236
+ indexNameBytes.write(name);
227
237
  serializeType(this._MyModel.fields[name].type, indexNameBytes);
228
238
  }
229
- const indexNameBuf = indexNameBytes.getBuffer();
239
+ const indexNameBuf = indexNameBytes.toUint8Array();
230
240
 
231
241
  let result = olmdb.get(indexNameBuf);
232
242
  if (result) {
233
- indexId = this._cachedIndexId = new Bytes(result).readNumber();
243
+ indexId = this._cachedIndexId = new DataPack(result).readNumber();
234
244
  } else {
235
- const maxIndexIdBuf = new Bytes().writeNumber(MAX_INDEX_ID_PREFIX).getBuffer();
245
+ const maxIndexIdBuf = new DataPack().write(MAX_INDEX_ID_PREFIX).toUint8Array();
236
246
  result = olmdb.get(maxIndexIdBuf);
237
- indexId = result ? new Bytes(result).readNumber() + 1 : 1;
247
+ indexId = result ? new DataPack(result).readNumber() + 1 : 1;
238
248
  olmdb.onCommit(() => {
239
249
  // Only if the transaction succeeds can we cache this id
240
250
  this._cachedIndexId = indexId;
241
251
  });
242
252
 
243
- const idBuf = new Bytes().writeNumber(indexId).getBuffer();
253
+ const idBuf = new DataPack().write(indexId).toUint8Array();
244
254
  olmdb.put(indexNameBuf, idBuf);
245
255
  olmdb.put(maxIndexIdBuf, idBuf); // This will also cause the transaction to rerun if we were raced
246
256
  if (logLevel >= 1) {
247
- console.log(`Created index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with id ${indexId}`);
257
+ console.log(`Create ${this} with id ${indexId}`);
248
258
  }
249
259
  }
250
260
  }
251
261
  return indexId;
252
262
  }
253
263
 
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
- }
264
+
265
+ abstract _delete(model: InstanceType<M>): void;
266
+ abstract _write(model: InstanceType<M>): void;
266
267
 
267
268
  /**
268
269
  * Find model instances using flexible range query options.
@@ -322,35 +323,38 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
322
323
  * }
323
324
  * ```
324
325
  */
325
- public find(opts: FindOptions<IndexArgTypes<M, F>> = {}): IndexRangeIterator<M,F> {
326
+ public find(opts: FindOptions<IndexArgTypes<M, F>> = {}): IndexRangeIterator<M> {
326
327
  const indexId = this._getIndexId();
327
328
 
328
- let startKey: Bytes | undefined = new Bytes().writeNumber(indexId);
329
- let endKey: Bytes | undefined = startKey.copy();
329
+ let startKey: DataPack | undefined;
330
+ let endKey: DataPack | undefined;
330
331
 
331
332
  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();
333
+ // Exact match - set both 'from' and 'to' to the same value
334
+ startKey = this._argsToKeyBytes(toArray(opts.is), true);
335
+ endKey = startKey.clone(true).increment();
335
336
  } else {
336
337
  // Range query
337
338
  if ('from' in opts) {
338
- this._serializeArgs(toArray(opts.from), startKey);
339
+ startKey = this._argsToKeyBytes(toArray(opts.from), true);
339
340
  } else if ('after' in opts) {
340
- this._serializeArgs(toArray(opts.after), startKey);
341
+ startKey = this._argsToKeyBytes(toArray(opts.after), true);
341
342
  if (!startKey.increment()) {
342
343
  // There can be nothing 'after' - return an empty iterator
343
344
  return new IndexRangeIterator(undefined, indexId, this);
344
345
  }
346
+ } else {
347
+ // Open start: begin at first key for this index id
348
+ startKey = this._argsToKeyBytes([], true);
345
349
  }
346
350
 
347
351
  if ('to' in opts) {
348
- this._serializeArgs(toArray(opts.to), endKey);
349
- endKey.increment();
352
+ endKey = this._argsToKeyBytes(toArray(opts.to), true).increment();
350
353
  } else if ('before' in opts) {
351
- this._serializeArgs(toArray(opts.before), endKey);
354
+ endKey = this._argsToKeyBytes(toArray(opts.before), true);
352
355
  } else {
353
- endKey = endKey.increment(); // Next indexId
356
+ // Open end: end at first key of the next index id
357
+ endKey = this._argsToKeyBytes([], true).increment(); // Next indexId
354
358
  }
355
359
  }
356
360
 
@@ -358,30 +362,33 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
358
362
  const scanStart = opts.reverse ? endKey : startKey;
359
363
  const scanEnd = opts.reverse ? startKey : endKey;
360
364
 
365
+ if (logLevel >= 3) {
366
+ console.log(`Scan ${this} start=${scanStart} end=${scanEnd} reverse=${opts.reverse||false}`);
367
+ }
361
368
  const iterator = olmdb.scan({
362
- start: scanStart?.getBuffer(),
363
- end: scanEnd?.getBuffer(),
369
+ start: scanStart?.toUint8Array(),
370
+ end: scanEnd?.toUint8Array(),
364
371
  reverse: opts.reverse || false,
365
372
  });
366
373
 
367
374
  return new IndexRangeIterator(iterator, indexId, this);
368
375
  }
369
376
 
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
377
  abstract _getTypeName(): string;
378
+
379
+ toString() {
380
+ return `${this._getIndexId()}:${this._MyModel.tableName}:${this._getTypeName()}[${Array.from(this._fieldTypes.keys()).join(',')}]`;
381
+ }
378
382
  }
379
383
 
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];
384
+ function toArray<ARG_TYPES extends readonly any[]>(args: ArrayOrOnlyItem<ARG_TYPES>): Partial<ARG_TYPES> {
385
+ // Convert single value or array to array format compatible with Partial<ARG_TYPES>
386
+ return (Array.isArray(args) ? args : [args]) as Partial<ARG_TYPES>;
383
387
  }
384
388
 
389
+ /** @internal Symbol used to attach modified instances, keyed by singleton primary key, to a transaction */
390
+ export const INSTANCES_BY_PK_SYMBOL = Symbol('instances');
391
+
385
392
  /**
386
393
  * Primary index that stores the actual model data.
387
394
  *
@@ -389,13 +396,43 @@ function toArray<T>(args: T): T extends readonly any[] ? T : [T] {
389
396
  * @template F - The field names that make up this index.
390
397
  */
391
398
  export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
392
-
399
+
400
+ _nonKeyFields!: (keyof InstanceType<M> & string)[];
401
+ _lazyDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
402
+ _resetDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
403
+
393
404
  constructor(MyModel: M, fieldNames: F) {
394
- super(MyModel, fieldNames, true);
395
- if (MyModel._pk && MyModel._pk !== this) {
405
+ super(MyModel, fieldNames);
406
+ if (MyModel._primary) {
396
407
  throw new DatabaseError(`Model ${MyModel.tableName} already has a primary key defined`, 'INIT_ERROR');
397
408
  }
398
- MyModel._pk = this;
409
+ MyModel._primary = this;
410
+ }
411
+
412
+ _delayedInit(): boolean {
413
+ if (!super._delayedInit()) return false;
414
+ const MyModel = this._MyModel;
415
+ this._nonKeyFields = Object.keys(MyModel.fields).filter(fieldName => !this._fieldNames.includes(fieldName as any)) as any;
416
+
417
+ for(const fieldName of this._nonKeyFields) {
418
+ this._lazyDescriptors[fieldName] = {
419
+ configurable: true,
420
+ enumerable: true,
421
+ get(this: InstanceType<M>) {
422
+ this.constructor._primary._lazyNow(this);
423
+ return this[fieldName];
424
+ },
425
+ set(this: InstanceType<M>, value: any) {
426
+ this.constructor._primary._lazyNow(this);
427
+ this[fieldName] = value;
428
+ }
429
+ };
430
+ this._resetDescriptors[fieldName] = {
431
+ writable: true,
432
+ enumerable: true
433
+ };
434
+ }
435
+ return true;
399
436
  }
400
437
 
401
438
  /**
@@ -408,98 +445,151 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
408
445
  * const user = User.pk.get("john_doe");
409
446
  * ```
410
447
  */
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
- }
448
+ get(...args: IndexArgTypes<M, F> | [Uint8Array]): InstanceType<M> | undefined {
449
+ return this._get(args, false);
450
+ }
416
451
 
417
- let valueBuffer = olmdb.get(keyBuffer);
418
- if (!valueBuffer) return;
452
+ /**
453
+ * Does the same as as `get()`, but will delay loading the instance from disk until the first
454
+ * property access. In case it turns out the instance doesn't exist, an error will be thrown
455
+ * at that time.
456
+ * @param args Primary key field values. (Or a single Uint8Array containing the key.)
457
+ * @returns The (lazily loaded) model instance.
458
+ */
459
+ getLazy(...args: IndexArgTypes<M, F> | [Uint8Array]): InstanceType<M> {
460
+ return this._get(args, true);
461
+ }
462
+
463
+ _get(args: IndexArgTypes<M, F> | [Uint8Array], lazy: true): InstanceType<M>;
464
+ _get(args: IndexArgTypes<M, F> | [Uint8Array], lazy: false): InstanceType<M> | undefined;
465
+ _get(args: IndexArgTypes<M, F> | [Uint8Array], lazy: boolean) {
466
+ let key, keyParts;
467
+ if (args.length === 1 && args[0] instanceof Uint8Array) {
468
+ key = getSingletonUint8Array(args[0]);
469
+ } else {
470
+ key = this._argsToKeySingleton(args as IndexArgTypes<M, F>);
471
+ keyParts = args;
472
+ }
473
+
474
+ const cachedInstances = olmdb.getTransactionData(INSTANCES_BY_PK_SYMBOL) as Map<Uint8Array, InstanceType<M>>;
475
+ const cached = cachedInstances.get(key);
476
+ if (cached) return cached;
477
+
478
+ let valueBuffer;
479
+ if (!lazy) {
480
+ valueBuffer = olmdb.get(key);
481
+ if (logLevel >= 3) {
482
+ console.log(`Get ${this} key=${new DataPack(key)} result=${valueBuffer && new DataPack(valueBuffer)}`);
483
+ }
484
+ if (!valueBuffer) return;
485
+ }
419
486
 
420
487
  // This is a primary index. So we can now deserialize all primary and non-primary fields into instance values.
421
488
  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);
489
+
490
+ // Store the canonical primary key on the model
491
+ model._primaryKey = key;
492
+
493
+ // Set the primary key fields on the model
494
+ if (keyParts) {
495
+ let index = 0;
496
+ for(const fieldName of this._fieldTypes.keys()) {
497
+ model._setLoadedField(fieldName, keyParts[index++] as any);
498
+ }
499
+ } else {
500
+ const bytes = new DataPack(key);
501
+ assert(bytes.readNumber() === this._MyModel._primary._getIndexId()); // Skip index id
502
+ for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
503
+ model._setLoadedField(fieldName, fieldType.deserialize(bytes));
435
504
  }
436
505
  }
437
506
 
507
+ if (valueBuffer) {
508
+ // Set other fields
509
+ this._setNonKeyValues(model, new DataPack(valueBuffer));
510
+ } else {
511
+ // Lazy - set getters for other fields
512
+ Object.defineProperties(model, this._lazyDescriptors);
513
+ }
514
+
515
+ cachedInstances.set(key, model);
438
516
  return model;
439
517
  }
440
518
 
441
519
  /**
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
520
+ * Create a canonical primary key buffer for the given model instance.
521
+ * Returns a singleton Uint8Array for stable Map/Set identity usage.
447
522
  */
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
523
+ _instanceToKeySingleton(model: InstanceType<M>): Uint8Array {
524
+ const bytes = this._instanceToKeyBytes(model);
525
+ return getSingletonUint8Array(bytes.toUint8Array());
526
+ }
453
527
 
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);
528
+ _lazyNow(model: InstanceType<M>) {
529
+ let valueBuffer = olmdb.get(model._primaryKey!);
530
+ if (logLevel >= 3) {
531
+ console.log(`Lazy retrieve ${this} key=${new DataPack(model._primaryKey)} result=${valueBuffer && new DataPack(valueBuffer)}`);
458
532
  }
533
+ if (!valueBuffer) throw new DatabaseError(`Lazy-loaded ${model.constructor.name}#${model._primaryKey} does not exist`, 'LAZY_FAIL');
534
+ Object.defineProperties(model, this._resetDescriptors);
535
+ this._setNonKeyValues(model, new DataPack(valueBuffer));
536
+ }
537
+
538
+ _setNonKeyValues(model: InstanceType<M>, valueBytes: DataPack) {
539
+ const fieldConfigs = this._MyModel.fields;
459
540
 
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);
541
+ for (const fieldName of this._nonKeyFields) {
542
+ const value = fieldConfigs[fieldName].type.deserialize(valueBytes);
543
+ model._setLoadedField(fieldName, value);
464
544
  }
545
+ }
465
546
 
466
- return model;
547
+ _keyToArray(key: Uint8Array): IndexArgTypes<M, F> {
548
+ const bytes = new DataPack(key);
549
+ return this._fieldTypes.values().map((fieldType) => {
550
+ return fieldType.deserialize(bytes);
551
+ }) as any;
467
552
  }
468
553
 
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());
554
+ _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined {
555
+ const valueBytes = new DataPack(valueBuffer);
556
+ const model = new (this._MyModel as any)() as InstanceType<M>;
490
557
 
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());
558
+ for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
559
+ model._setLoadedField(fieldName, fieldType.deserialize(keyBytes));
495
560
  }
561
+ model._primaryKey = getSingletonUint8Array(keyBytes.toUint8Array());
496
562
 
497
- return newKey;
563
+ this._setNonKeyValues(model, valueBytes);
564
+
565
+ return model;
498
566
  }
499
567
 
500
568
  _getTypeName(): string {
501
569
  return 'primary';
502
570
  }
571
+
572
+ _write(model: InstanceType<M>) {
573
+ let valueBytes = new DataPack();
574
+ const fieldConfigs = this._MyModel.fields as any;
575
+ for (const fieldName of this._nonKeyFields) {
576
+ const fieldConfig = fieldConfigs[fieldName] as FieldConfig<unknown>;
577
+ fieldConfig.type.serialize(model[fieldName], valueBytes);
578
+ }
579
+ if (logLevel >= 2) {
580
+ console.log(`Write ${this} key=${new DataPack(model._getCreatePrimaryKey())} value=${valueBytes}`);
581
+ }
582
+ olmdb.put(model._getCreatePrimaryKey(), valueBytes.toUint8Array());
583
+ }
584
+
585
+ _delete(model: InstanceType<M>) {
586
+ if (model._primaryKey) {
587
+ if (logLevel >= 2) {
588
+ console.log(`Delete ${this} key=${new DataPack(model._primaryKey)}`);
589
+ }
590
+ olmdb.del(model._primaryKey);
591
+ }
592
+ }
503
593
  }
504
594
 
505
595
  /**
@@ -509,6 +599,12 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
509
599
  * @template F - The field names that make up this index.
510
600
  */
511
601
  export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
602
+
603
+ constructor(MyModel: M, fieldNames: F) {
604
+ super(MyModel, fieldNames);
605
+ (this._MyModel._secondaries ||= []).push(this);
606
+ }
607
+
512
608
  /**
513
609
  * Get a model instance by unique index key values.
514
610
  * @param args - The unique index key values.
@@ -520,21 +616,44 @@ export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof
520
616
  * ```
521
617
  */
522
618
  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
- }
619
+ let keyBuffer = this._argsToKeySingleton(args);
527
620
 
528
621
  let valueBuffer = olmdb.get(keyBuffer);
622
+ if (logLevel >= 3) {
623
+ console.log(`Get ${this} key=${new DataPack(keyBuffer)} result=${valueBuffer}`);
624
+ }
529
625
  if (!valueBuffer) return;
530
626
 
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');
627
+ const pk = this._MyModel._primary!;
628
+ const result = pk.get(valueBuffer);
629
+ if (!result) throw new DatabaseError(`Unique index ${this} points at non-existing primary for key: ${args.join(', ')}`, 'CONSISTENCY_ERROR');
535
630
  return result;
536
631
  }
537
632
 
633
+ _delete(model: InstanceType<M>) {
634
+ if (!this._hasNullIndexValues(model)) {
635
+ const keyBytes = this._instanceToKeyBytes(model);
636
+ if (logLevel >= 2) {
637
+ console.log(`Delete ${this} key=${keyBytes}`);
638
+ }
639
+ olmdb.del(keyBytes.toUint8Array());
640
+ }
641
+ }
642
+
643
+ _write(model: InstanceType<M>) {
644
+ if (!this._hasNullIndexValues(model)) {
645
+ const key = this._instanceToKeyBytes(model);
646
+ if (logLevel >= 2) {
647
+ console.log(`Write ${this} key=${key} value=${new DataPack(model._primaryKey)}`);
648
+ }
649
+ const keyBuffer = key.toUint8Array();
650
+ if (olmdb.get(keyBuffer)) {
651
+ throw new DatabaseError(`Unique constraint violation for ${this} key ${key}`, 'UNIQUE_CONSTRAINT');
652
+ }
653
+ olmdb.put(keyBuffer, model._primaryKey!);
654
+ }
655
+ }
656
+
538
657
  /**
539
658
  * Extract model from iterator entry for unique index.
540
659
  * @param keyBytes - Key bytes with index ID already read.
@@ -542,59 +661,34 @@ export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof
542
661
  * @returns Model instance or undefined.
543
662
  * @internal
544
663
  */
545
- _getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
664
+ _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined {
546
665
  // 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
666
 
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');
667
+ const pk = this._MyModel._primary!;
668
+ const model = pk.getLazy(valueBuffer);
669
+
670
+ // Read the index fields from the key, overriding lazy loading for these fields
671
+ for(const [name, fieldType] of this._fieldTypes.entries()) {
672
+ // getLazy will have created a getter for this field - make it a normal property instead
673
+ Object.defineProperty(model, name, {
674
+ writable: true,
675
+ configurable: true,
676
+ enumerable: true
677
+ });
678
+ model._setLoadedField(name, fieldType.deserialize(keyBytes));
579
679
  }
580
-
581
- let linkKey = (model.constructor as any)._pk!._getKeyFromModel(model, false);
582
- olmdb.put(newKey, linkKey);
583
680
 
584
- if (logLevel >= 2) {
585
- console.log(`Saved unique index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with key ${newKey}`);
586
- }
587
-
588
- return newKey;
681
+ return model;
589
682
  }
590
683
 
684
+
591
685
  _getTypeName(): string {
592
686
  return 'unique';
593
687
  }
594
688
  }
595
689
 
596
690
  // 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
691
+ const SECONDARY_VALUE = new DataPack().write(undefined).toUint8Array(); // Single byte value for secondary indexes
598
692
 
599
693
  /**
600
694
  * Secondary index for non-unique lookups.
@@ -603,81 +697,68 @@ const SECONDARY_VALUE = new Uint8Array([1]); // Single byte value for secondary
603
697
  * @template F - The field names that make up this index.
604
698
  */
605
699
  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
700
 
633
- if (logLevel >= 2) {
634
- console.log(`Saved secondary index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with key ${newKey}`);
635
- }
636
-
637
- return newKey;
701
+ constructor(MyModel: M, fieldNames: F) {
702
+ super(MyModel, fieldNames);
703
+ (this._MyModel._secondaries ||= []).push(this);
638
704
  }
639
705
 
640
706
  /**
641
707
  * Extract model from iterator entry for secondary index.
642
708
  * @param keyBytes - Key bytes with index ID already read.
643
- * @param valueBytes - Value bytes from the entry.
709
+ * @param valueBuffer - Value Uint8Array from the entry.
644
710
  * @returns Model instance or undefined.
645
711
  * @internal
646
712
  */
647
- _getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
713
+ _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined {
648
714
  // For secondary indexes, the primary key is stored after the index fields in the key
649
715
 
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);
716
+ // Read the index fields, saving them for later
717
+ const indexFields = new Map();
718
+ for(const [name, type] of this._fieldTypes.entries()) {
719
+ indexFields.set(name, type.deserialize(keyBytes));
656
720
  }
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);
721
+
722
+ const primaryKey = keyBytes.readUint8Array();
723
+ const model = this._MyModel._primary!.getLazy(primaryKey);
724
+
725
+ // Add the index fields to the model, overriding lazy loading for these fields
726
+ for(const [name, value] of indexFields) {
727
+ // getLazy will have created a getter for this field - make it a normal property instead
728
+ Object.defineProperty(model, name, {
729
+ writable: true,
730
+ configurable: true,
731
+ enumerable: true
732
+ });
733
+ model._setLoadedField(name, value);
734
+ }
735
+
736
+ return model;
662
737
  }
663
738
 
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();
739
+ _instanceToKeyBytes(model: InstanceType<M>): DataPack {
740
+ // index id + index fields + primary key
741
+ const bytes = super._instanceToKeyBytes(model);
742
+ bytes.write(model._getCreatePrimaryKey());
743
+ return bytes;
744
+ }
745
+
746
+ _write(model: InstanceType<M>) {
747
+ if (this._hasNullIndexValues(model)) return;
748
+ const keyBytes = this._instanceToKeyBytes(model);
749
+ if (logLevel >= 2) {
750
+ console.log(`Write ${this} key=${keyBytes}`);
751
+ }
752
+ olmdb.put(keyBytes.toUint8Array(), SECONDARY_VALUE);
753
+ }
754
+
755
+ _delete(model: InstanceType<M>): void {
756
+ if (this._hasNullIndexValues(model)) return;
757
+ const keyBytes = this._instanceToKeyBytes(model);
758
+ if (logLevel >= 2) {
759
+ console.log(`Delete ${this} key=${keyBytes}`);
760
+ }
761
+ olmdb.del(keyBytes.toUint8Array());
681
762
  }
682
763
 
683
764
  _getTypeName(): string {
@@ -774,8 +855,8 @@ export function dump() {
774
855
  let indexesById = new Map<number, {name: string, type: string, fields: Record<string, TypeWrapper<any>>}>();
775
856
  console.log("--- Database dump ---")
776
857
  for(const {key,value} of olmdb.scan()) {
777
- const kb = new Bytes(key);
778
- const vb = new Bytes(value);
858
+ const kb = new DataPack(key);
859
+ const vb = new DataPack(value);
779
860
  const indexId = kb.readNumber();
780
861
  if (indexId === MAX_INDEX_ID_PREFIX) {
781
862
  console.log("* Max index id", vb.readNumber());
@@ -787,23 +868,21 @@ export function dump() {
787
868
  const name = kb.readString();
788
869
  fields[name] = deserializeType(kb, 0);
789
870
  }
790
- const fieldDescription = Object.entries(fields).map(([name, type]) => `${name}:${type}`);
791
871
  const indexId = vb.readNumber();
792
- console.log(`* Definition for ${type} ${indexId} for ${name}[${fieldDescription.join(',')}]`);
872
+ console.log(`* Index definition ${indexId}:${name}:${type}[${Object.keys(fields).join(',')}]`);
793
873
  indexesById.set(indexId, {name, type, fields});
794
874
  } else if (indexId > 0 && indexesById.has(indexId)) {
795
875
  const index = indexesById.get(indexId)!;
796
876
  const {name, type, fields} = index;
797
877
  const rowKey: any = {};
798
878
  for(const [fieldName, fieldType] of Object.entries(fields)) {
799
- fieldType.deserialize(rowKey, fieldName, kb);
879
+ rowKey[fieldName] = fieldType.deserialize(kb);
800
880
  }
801
- const Model = modelRegistry[name]!;
881
+ // const Model = modelRegistry[name]!;
802
882
  // 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);
883
+ console.log(`* Row for ${indexId}:${name}:${type}[${Object.keys(fields).join(',')}] key=${kb} value=${vb}`);
805
884
  } else {
806
- console.log(`* Unhandled ${indexId} index key=${kb} value=${vb}`);
885
+ console.log(`* Unhandled '${indexId}' key=${kb} value=${vb}`);
807
886
  }
808
887
  }
809
888
  console.log("--- End of database dump ---")