edinburgh 0.3.0 → 0.4.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 CHANGED
@@ -1,8 +1,9 @@
1
- import * as olmdb from "olmdb";
2
- import { DatabaseError } from "olmdb";
3
- import { DataPack } from "./datapack.js";
4
- import { FieldConfig, getMockModel, Model, modelRegistry } from "./models.js";
5
- import { assert, logLevel, delayedInits, tryDelayedInits } from "./utils.js";
1
+ import * as lowlevel from "olmdb/lowlevel";
2
+ import { DatabaseError } from "olmdb/lowlevel";
3
+ import DataPack from "./datapack.js";
4
+ import { FieldConfig, getMockModel, Model, Transaction, currentTxn } from "./models.js";
5
+ import { scheduleInit, transact } from "./edinburgh.js";
6
+ import { assert, logLevel, dbGet, dbPut, dbDel, hashBytes, toBuffer } from "./utils.js";
6
7
  import { deserializeType, serializeType, TypeWrapper } from "./types.js";
7
8
 
8
9
  // Index system types and utilities
@@ -12,6 +13,26 @@ type IndexArgTypes<M extends typeof Model<any>, F extends readonly (keyof Instan
12
13
 
13
14
  const MAX_INDEX_ID_PREFIX = -1;
14
15
  const INDEX_ID_PREFIX = -2;
16
+ const VERSION_INFO_PREFIX = -3;
17
+
18
+ const MAX_INDEX_ID_BUFFER = new DataPack().write(MAX_INDEX_ID_PREFIX).toUint8Array();
19
+
20
+ /** Cached information about a specific version of a primary index's value format. */
21
+ interface VersionInfo {
22
+ migrateHash: number;
23
+ /** Non-key field names → TypeWrappers for deserialization of this version's data. */
24
+ nonKeyFields: Map<string, TypeWrapper<any>>;
25
+ /** Set of serialized secondary index signatures that existed in this version. */
26
+ secondaryKeys: Set<string>;
27
+ }
28
+
29
+ function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
30
+ if (a.length !== b.length) return false;
31
+ for (let i = 0; i < a.length; i++) {
32
+ if (a[i] !== b[i]) return false;
33
+ }
34
+ return true;
35
+ }
15
36
 
16
37
  /**
17
38
  * Iterator for range queries on indexes.
@@ -20,35 +41,28 @@ const INDEX_ID_PREFIX = -2;
20
41
  */
21
42
  export class IndexRangeIterator<M extends typeof Model> implements Iterator<InstanceType<M>>, Iterable<InstanceType<M>> {
22
43
  constructor(
23
- private iterator: olmdb.DbIterator<any,any> | undefined,
44
+ private txn: Transaction,
45
+ private iteratorId: number,
24
46
  private indexId: number,
25
47
  private parentIndex: BaseIndex<M, any>
26
- ) {}
48
+ ) {
49
+ }
27
50
 
28
51
  [Symbol.iterator](): Iterator<InstanceType<M>> {
29
52
  return this;
30
53
  }
31
54
 
32
55
  next(): IteratorResult<InstanceType<M>> {
33
- if (!this.iterator) return { done: true, value: undefined };
34
- const entry = this.iterator.next();
35
- if (entry.done) {
36
- this.iterator.close();
56
+ if (this.iteratorId < 0) return { done: true, value: undefined };
57
+ const raw = lowlevel.readIterator(this.iteratorId);
58
+ if (!raw) {
59
+ lowlevel.closeIterator(this.iteratorId);
60
+ this.iteratorId = -1;
37
61
  return { done: true, value: undefined };
38
62
  }
39
63
 
40
- // Extract the key without the index ID
41
- const keyBytes = new DataPack(entry.value.key);
42
- const entryIndexId = keyBytes.readNumber();
43
- assert(entryIndexId === this.indexId);
44
-
45
- // Use polymorphism to get the model from the entry
46
- const model = this.parentIndex._pairToInstance(keyBytes, entry.value.value);
47
-
48
- if (!model) {
49
- // This shouldn't happen, but skip if it does
50
- return this.next();
51
- }
64
+ // Dispatches to the _pairToInstance specific to the index type
65
+ const model = this.parentIndex._pairToInstance(this.txn, raw.key, raw.value);
52
66
 
53
67
  return { done: false, value: model };
54
68
  }
@@ -95,45 +109,6 @@ type FindOptions<ARG_TYPES extends readonly any[]> = (
95
109
  }
96
110
  );
97
111
 
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
-
137
112
 
138
113
  /**
139
114
  * Base class for database indexes for efficient lookups on model fields.
@@ -147,7 +122,8 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
147
122
  public _MyModel: M;
148
123
  public _fieldTypes: Map<keyof InstanceType<M> & string, TypeWrapper<any>> = new Map();
149
124
  public _fieldCount!: number;
150
-
125
+ _resetIndexFieldDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
126
+
151
127
  /**
152
128
  * Create a new index.
153
129
  * @param MyModel - The model class this index belongs to.
@@ -155,21 +131,34 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
155
131
  */
156
132
  constructor(MyModel: M, public _fieldNames: F) {
157
133
  this._MyModel = getMockModel(MyModel);
158
- delayedInits.add(this);
159
- tryDelayedInits();
160
134
  }
161
135
 
162
- _delayedInit(): boolean {
163
- if (!this._MyModel.fields) return false; // Awaiting model init
136
+ async _delayedInit() {
137
+ if (this._indexId != null) return; // Already initialized
164
138
  for(const fieldName of this._fieldNames) {
165
139
  assert(typeof fieldName === 'string', 'Field names must be strings');
166
140
  this._fieldTypes.set(fieldName, this._MyModel.fields[fieldName].type);
167
141
  }
168
142
  this._fieldCount = this._fieldNames.length;
169
- return true;
143
+ await this._retrieveIndexId();
144
+
145
+ // Human-readable signature for version tracking, e.g. "secondary category:string"
146
+ this._signature = this._getTypeName() + ' ' +
147
+ Array.from(this._fieldTypes.entries()).map(([n, t]) => n + ':' + t).join(' ');
148
+
149
+ for(const fieldName of this._fieldTypes.keys()) {
150
+ this._resetIndexFieldDescriptors[fieldName] = {
151
+ writable: true,
152
+ configurable: true,
153
+ enumerable: true
154
+ };
155
+ }
170
156
  }
171
157
 
172
- _cachedIndexId?: number;
158
+ _indexId?: number;
159
+
160
+ /** Human-readable signature for version tracking, e.g. "secondary category:string" */
161
+ _signature?: string;
173
162
 
174
163
  /**
175
164
  * Serialize array of key values to a (index-id prefixed) Bytes instance that can be used as a key.
@@ -183,7 +172,7 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
183
172
  _argsToKeyBytes(args: any, allowPartial: boolean) {
184
173
  assert(allowPartial ? args.length <= this._fieldCount : args.length === this._fieldCount);
185
174
  const bytes = new DataPack();
186
- bytes.write(this._getIndexId());
175
+ bytes.write(this._indexId!);
187
176
  let index = 0;
188
177
  for(const fieldType of this._fieldTypes.values()) {
189
178
  // For partial keys, undefined values are acceptable and represent open range suffixes
@@ -193,77 +182,88 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
193
182
  return bytes;
194
183
  }
195
184
 
196
- _argsToKeySingleton(args: IndexArgTypes<M, F>): Uint8Array {
197
- const bytes = this._argsToKeyBytes(args, false);
198
- return getSingletonUint8Array(bytes.toUint8Array());
199
- }
200
-
201
185
  /**
202
186
  * 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.
187
+ * @param keyBuffer - Key bytes (including index id).
188
+ * @param valueBuffer - Value bytes from the entry.
205
189
  * @returns Model instance or undefined.
206
190
  * @internal
207
191
  */
208
- abstract _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined;
192
+ abstract _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, valueBuffer: ArrayBuffer): InstanceType<M>;
209
193
 
210
- _hasNullIndexValues(model: InstanceType<M>) {
194
+ _hasNullIndexValues(data: Record<string, any>) {
211
195
  for(const fieldName of this._fieldTypes.keys()) {
212
- if (model[fieldName] == null) return true;
196
+ if (data[fieldName] == null) return true;
213
197
  }
214
198
  return false;
215
199
  }
216
200
 
217
- _instanceToKeyBytes(model: InstanceType<M>): DataPack {
201
+ // Must return the exact key that will be used to write to the K/V store
202
+ abstract _serializeKey(primaryKey: Uint8Array, data: Record<string, any>): Uint8Array;
203
+
204
+ // Returns the indexId + serialized key fields. Used in some _serializeKey implementations
205
+ // and for calculating _primaryKey.
206
+ _serializeKeyFields(data: Record<string, any>): DataPack {
218
207
  const bytes = new DataPack();
219
- bytes.write(this._getIndexId());
208
+ bytes.write(this._indexId!);
220
209
  for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
221
- fieldType.serialize(model[fieldName], bytes);
210
+ fieldType.serialize(data[fieldName], bytes);
222
211
  }
223
212
  return bytes;
224
213
  }
225
214
 
226
215
  /**
227
- * Get or create unique index ID for this index.
228
- * @returns Numeric index ID.
216
+ * Retrieve (or create) a stable index ID from the DB, with retry on transaction races.
217
+ * Sets `this._indexId` on success.
229
218
  */
230
- _getIndexId(): number {
231
- // Resolve an index to a number
232
- let indexId = this._cachedIndexId;
233
- if (indexId == null) {
234
- const indexNameBytes = new DataPack().write(INDEX_ID_PREFIX).write(this._MyModel.tableName).write(this._getTypeName());
235
- for(let name of this._fieldNames) {
219
+ async _retrieveIndexId(): Promise<void> {
220
+ const indexNameBytes = new DataPack().write(INDEX_ID_PREFIX).write(this._MyModel.tableName).write(this._getTypeName());
221
+ for(let name of this._fieldNames) {
222
+ indexNameBytes.write(name);
223
+ serializeType(this._MyModel.fields[name].type, indexNameBytes);
224
+ }
225
+ // For non-primary indexes, include primary key field info to avoid misinterpreting
226
+ // values when the primary key schema changes.
227
+ if (this._MyModel._primary !== (this as any)) {
228
+ indexNameBytes.write(undefined); // separator
229
+ for (const name of this._MyModel._primary._fieldNames) {
236
230
  indexNameBytes.write(name);
237
231
  serializeType(this._MyModel.fields[name].type, indexNameBytes);
238
232
  }
239
- const indexNameBuf = indexNameBytes.toUint8Array();
240
-
241
- let result = olmdb.get(indexNameBuf);
242
- if (result) {
243
- indexId = this._cachedIndexId = new DataPack(result).readNumber();
244
- } else {
245
- const maxIndexIdBuf = new DataPack().write(MAX_INDEX_ID_PREFIX).toUint8Array();
246
- result = olmdb.get(maxIndexIdBuf);
247
- indexId = result ? new DataPack(result).readNumber() + 1 : 1;
248
- olmdb.onCommit(() => {
249
- // Only if the transaction succeeds can we cache this id
250
- this._cachedIndexId = indexId;
251
- });
252
-
253
- const idBuf = new DataPack().write(indexId).toUint8Array();
254
- olmdb.put(indexNameBuf, idBuf);
255
- olmdb.put(maxIndexIdBuf, idBuf); // This will also cause the transaction to rerun if we were raced
256
- if (logLevel >= 1) {
257
- console.log(`Create ${this} with id ${indexId}`);
233
+ }
234
+ const indexNameBuf = indexNameBytes.toUint8Array();
235
+
236
+ while (true) {
237
+ const txnId = lowlevel.startTransaction();
238
+ try {
239
+ let result = dbGet(txnId, indexNameBuf);
240
+ let id: number;
241
+ if (result) {
242
+ id = new DataPack(result).readNumber();
243
+ } else {
244
+ result = dbGet(txnId, MAX_INDEX_ID_BUFFER);
245
+ id = result ? new DataPack(result).readNumber() + 1 : 1;
246
+ const idBuf = new DataPack().write(id).toUint8Array();
247
+ dbPut(txnId, indexNameBuf, idBuf);
248
+ dbPut(txnId, MAX_INDEX_ID_BUFFER, idBuf);
249
+ if (logLevel >= 1) console.log(`[edinburgh] Create index ${this}`);
250
+ }
251
+ const commitResult = lowlevel.commitTransaction(txnId);
252
+ const commitSeq = typeof commitResult === 'number' ? commitResult : await commitResult;
253
+ if (commitSeq > 0) {
254
+ this._indexId = id;
255
+ return;
258
256
  }
257
+ } catch (e) {
258
+ try { lowlevel.abortTransaction(txnId); } catch {}
259
+ throw e;
259
260
  }
260
261
  }
261
- return indexId;
262
262
  }
263
263
 
264
264
 
265
- abstract _delete(model: InstanceType<M>): void;
266
- abstract _write(model: InstanceType<M>): void;
265
+ abstract _delete(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>): void;
266
+ abstract _write(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>): void;
267
267
 
268
268
  /**
269
269
  * Find model instances using flexible range query options.
@@ -323,61 +323,129 @@ export abstract class BaseIndex<M extends typeof Model, const F extends readonly
323
323
  * }
324
324
  * ```
325
325
  */
326
- public find(opts: FindOptions<IndexArgTypes<M, F>> = {}): IndexRangeIterator<M> {
327
- const indexId = this._getIndexId();
328
-
326
+ _computeKeyBounds(opts: FindOptions<IndexArgTypes<M, F>>): [DataPack | undefined, DataPack | undefined] | null {
329
327
  let startKey: DataPack | undefined;
330
328
  let endKey: DataPack | undefined;
331
-
332
329
  if ('is' in opts) {
333
- // Exact match - set both 'from' and 'to' to the same value
334
330
  startKey = this._argsToKeyBytes(toArray(opts.is), true);
335
331
  endKey = startKey.clone(true).increment();
336
332
  } else {
337
- // Range query
338
333
  if ('from' in opts) {
339
334
  startKey = this._argsToKeyBytes(toArray(opts.from), true);
340
335
  } else if ('after' in opts) {
341
336
  startKey = this._argsToKeyBytes(toArray(opts.after), true);
342
- if (!startKey.increment()) {
343
- // There can be nothing 'after' - return an empty iterator
344
- return new IndexRangeIterator(undefined, indexId, this);
345
- }
337
+ if (!startKey.increment()) return null;
346
338
  } else {
347
- // Open start: begin at first key for this index id
348
339
  startKey = this._argsToKeyBytes([], true);
349
340
  }
350
-
351
341
  if ('to' in opts) {
352
342
  endKey = this._argsToKeyBytes(toArray(opts.to), true).increment();
353
343
  } else if ('before' in opts) {
354
344
  endKey = this._argsToKeyBytes(toArray(opts.before), true);
355
345
  } else {
356
- // Open end: end at first key of the next index id
357
- endKey = this._argsToKeyBytes([], true).increment(); // Next indexId
346
+ endKey = this._argsToKeyBytes([], true).increment();
358
347
  }
359
348
  }
349
+ return [startKey, endKey];
350
+ }
351
+
352
+ public find(opts: FindOptions<IndexArgTypes<M, F>> = {}): IndexRangeIterator<M> {
353
+ const txn = currentTxn();
354
+ const indexId = this._indexId!;
355
+
356
+ const bounds = this._computeKeyBounds(opts);
357
+ if (!bounds) return new IndexRangeIterator(txn, -1, indexId, this);
358
+ const [startKey, endKey] = bounds;
360
359
 
361
360
  // For reverse scans, swap start/end keys since OLMDB expects it
362
361
  const scanStart = opts.reverse ? endKey : startKey;
363
362
  const scanEnd = opts.reverse ? startKey : endKey;
364
363
 
365
364
  if (logLevel >= 3) {
366
- console.log(`Scan ${this} start=${scanStart} end=${scanEnd} reverse=${opts.reverse||false}`);
365
+ console.log(`[edinburgh] Scan ${this} start=${scanStart} end=${scanEnd} reverse=${opts.reverse||false}`);
367
366
  }
368
- const iterator = olmdb.scan({
369
- start: scanStart?.toUint8Array(),
370
- end: scanEnd?.toUint8Array(),
371
- reverse: opts.reverse || false,
372
- });
367
+ const startBuf = scanStart?.toUint8Array();
368
+ const endBuf = scanEnd?.toUint8Array();
369
+ const iteratorId = lowlevel.createIterator(
370
+ txn.id,
371
+ startBuf ? toBuffer(startBuf) : undefined,
372
+ endBuf ? toBuffer(endBuf) : undefined,
373
+ opts.reverse || false,
374
+ );
373
375
 
374
- return new IndexRangeIterator(iterator, indexId, this);
376
+ return new IndexRangeIterator(txn, iteratorId, indexId, this);
377
+ }
378
+
379
+ /**
380
+ * Process all matching rows in batched transactions.
381
+ *
382
+ * Uses the same query options as {@link find}. The batch is committed and a new
383
+ * transaction started once either `limitSeconds` or `limitRows` is exceeded.
384
+ *
385
+ * @param opts - Query options (same as `find()`), plus:
386
+ * @param opts.limitSeconds - Max seconds per transaction batch (default: 1)
387
+ * @param opts.limitRows - Max rows per transaction batch (default: 4096)
388
+ * @param callback - Called for each matching row within a transaction
389
+ */
390
+ public async batchProcess(
391
+ opts: FindOptions<IndexArgTypes<M, F>> & { limitSeconds?: number; limitRows?: number } = {} as any,
392
+ callback: (row: InstanceType<M>) => void | Promise<void>
393
+ ): Promise<void> {
394
+ const limitMs = (opts.limitSeconds ?? 1) * 1000;
395
+ const limitRows = opts.limitRows ?? 4096;
396
+ const reverse = opts.reverse ?? false;
397
+
398
+ const bounds = this._computeKeyBounds(opts);
399
+ if (!bounds) return;
400
+ const startKey = bounds[0]?.toUint8Array();
401
+ const endKey = bounds[1]?.toUint8Array();
402
+ let cursor: Uint8Array | undefined;
403
+
404
+ while (true) {
405
+ const next = await transact(async (): Promise<Uint8Array | null> => {
406
+ const txn = currentTxn();
407
+ const batchStart = cursor && !reverse ? cursor : startKey;
408
+ const batchEnd = cursor && reverse ? cursor : endKey;
409
+ const scanStart = reverse ? batchEnd : batchStart;
410
+ const scanEnd = reverse ? batchStart : batchEnd;
411
+
412
+ const iteratorId = lowlevel.createIterator(
413
+ txn.id,
414
+ scanStart ? toBuffer(scanStart) : undefined,
415
+ scanEnd ? toBuffer(scanEnd) : undefined,
416
+ reverse,
417
+ );
418
+
419
+ const t0 = Date.now();
420
+ let count = 0;
421
+ let lastRawKey: Uint8Array | undefined;
422
+ try {
423
+ while (true) {
424
+ const raw = lowlevel.readIterator(iteratorId);
425
+ if (!raw) return null;
426
+ lastRawKey = new Uint8Array(raw.key);
427
+ await callback(this._pairToInstance(txn, raw.key, raw.value));
428
+ if (++count >= limitRows || Date.now() - t0 >= limitMs) break;
429
+ }
430
+ } finally {
431
+ lowlevel.closeIterator(iteratorId);
432
+ }
433
+
434
+ lastRawKey = lastRawKey.slice(); // Copy, as lastRawKey points at OLMDB's internal read-only mmap
435
+ if (reverse) return lastRawKey!;
436
+ const nk = new DataPack(lastRawKey!);
437
+ return nk.increment() ? nk.toUint8Array() : null;
438
+ });
439
+
440
+ if (next === null) break;
441
+ cursor = next;
442
+ }
375
443
  }
376
444
 
377
445
  abstract _getTypeName(): string;
378
446
 
379
447
  toString() {
380
- return `${this._getIndexId()}:${this._MyModel.tableName}:${this._getTypeName()}[${Array.from(this._fieldTypes.keys()).join(',')}]`;
448
+ return `${this._indexId}:${this._MyModel.tableName}:${this._getTypeName()}[${Array.from(this._fieldTypes.keys()).join(',')}]`;
381
449
  }
382
450
  }
383
451
 
@@ -386,9 +454,6 @@ function toArray<ARG_TYPES extends readonly any[]>(args: ArrayOrOnlyItem<ARG_TYP
386
454
  return (Array.isArray(args) ? args : [args]) as Partial<ARG_TYPES>;
387
455
  }
388
456
 
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
-
392
457
  /**
393
458
  * Primary index that stores the actual model data.
394
459
  *
@@ -400,17 +465,26 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
400
465
  _nonKeyFields!: (keyof InstanceType<M> & string)[];
401
466
  _lazyDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
402
467
  _resetDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
468
+ _freezePrimaryKeyDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
469
+
470
+ /** Current version number for this primary index's value format. */
471
+ _currentVersion!: number;
472
+ /** Hash of the current migrate() function source, or 0 if none. */
473
+ _currentMigrateHash!: number;
474
+ /** Cached version info for old versions (loaded on demand). */
475
+ _versions: Map<number, VersionInfo> = new Map();
403
476
 
404
477
  constructor(MyModel: M, fieldNames: F) {
405
478
  super(MyModel, fieldNames);
406
479
  if (MyModel._primary) {
407
- throw new DatabaseError(`Model ${MyModel.tableName} already has a primary key defined`, 'INIT_ERROR');
480
+ throw new DatabaseError(`There's already a primary index defined: ${MyModel._primary}. This error may also indicate that your tsconfig.json needs to have "target": "ES2022" set.`, 'INIT_ERROR');
408
481
  }
409
482
  MyModel._primary = this;
410
483
  }
411
484
 
412
- _delayedInit(): boolean {
413
- if (!super._delayedInit()) return false;
485
+ async _delayedInit() {
486
+ if (this._indexId != null) return; // Already initialized
487
+ await super._delayedInit();
414
488
  const MyModel = this._MyModel;
415
489
  this._nonKeyFields = Object.keys(MyModel.fields).filter(fieldName => !this._fieldNames.includes(fieldName as any)) as any;
416
490
 
@@ -432,7 +506,104 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
432
506
  enumerable: true
433
507
  };
434
508
  }
435
- return true;
509
+
510
+ for(const fieldName of this._fieldNames) {
511
+ this._freezePrimaryKeyDescriptors[fieldName] = {
512
+ writable: false,
513
+ enumerable: true
514
+ };
515
+ }
516
+
517
+ }
518
+
519
+ /** Serialize the current version fingerprint as a DataPack object. */
520
+ _serializeVersionValue(): Uint8Array {
521
+ const fields: [string, Uint8Array][] = [];
522
+ for (const fieldName of this._nonKeyFields) {
523
+ const tp = new DataPack();
524
+ serializeType(this._MyModel.fields[fieldName].type, tp);
525
+ fields.push([fieldName, tp.toUint8Array()]);
526
+ }
527
+ return new DataPack().write({
528
+ migrateHash: this._currentMigrateHash,
529
+ fields,
530
+ secondaryKeys: new Set((this._MyModel._secondaries || []).map(sec => sec._signature!)),
531
+ }).toUint8Array();
532
+ }
533
+
534
+ /** Look up or create the current version number for this primary index. */
535
+ async _initVersioning(): Promise<void> {
536
+ // Compute migrate hash from function source
537
+ const migrateFn = (this._MyModel as any)._original?.migrate ?? (this._MyModel as any).migrate;
538
+ this._currentMigrateHash = migrateFn ? hashBytes(new TextEncoder().encode(migrateFn.toString().replace(/\s\s+/g, ' ').trim())) : 0;
539
+
540
+ const currentValueBytes = this._serializeVersionValue();
541
+
542
+ // Scan last 20 version info rows for this primary index
543
+ const scanStart = new DataPack().write(VERSION_INFO_PREFIX).write(this._indexId!);
544
+ const scanEnd = scanStart.clone(true).increment();
545
+
546
+ while (true) {
547
+ const txnId = lowlevel.startTransaction();
548
+ try {
549
+ const iteratorId = lowlevel.createIterator(
550
+ txnId,
551
+ scanEnd ? toBuffer(scanEnd.toUint8Array()) : undefined,
552
+ toBuffer(scanStart.toUint8Array()),
553
+ true // reverse - scan newest versions first
554
+ );
555
+
556
+ let count = 0;
557
+ let maxVersion = 0;
558
+ let found = false;
559
+
560
+ try {
561
+ while (count < 20) {
562
+ const raw = lowlevel.readIterator(iteratorId);
563
+ if (!raw) break;
564
+ count++;
565
+
566
+ const keyPack = new DataPack(new Uint8Array(raw.key));
567
+ keyPack.readNumber(); // skip VERSION_INFO_PREFIX
568
+ keyPack.readNumber(); // skip indexId
569
+ const versionNum = keyPack.readNumber();
570
+ maxVersion = Math.max(maxVersion, versionNum);
571
+
572
+ const valueBytes = new Uint8Array(raw.value);
573
+ if (bytesEqual(valueBytes, currentValueBytes)) {
574
+ this._currentVersion = versionNum;
575
+ found = true;
576
+ break;
577
+ }
578
+ }
579
+ } finally {
580
+ lowlevel.closeIterator(iteratorId);
581
+ }
582
+
583
+ if (found) {
584
+ lowlevel.abortTransaction(txnId);
585
+ return;
586
+ }
587
+
588
+ // No match found - create new version
589
+ this._currentVersion = maxVersion + 1;
590
+ const versionKey = new DataPack()
591
+ .write(VERSION_INFO_PREFIX)
592
+ .write(this._indexId!)
593
+ .write(this._currentVersion)
594
+ .toUint8Array();
595
+ dbPut(txnId, versionKey, currentValueBytes);
596
+ if (logLevel >= 1) console.log(`[edinburgh] Create version ${this._currentVersion} for ${this}`);
597
+
598
+ const commitResult = lowlevel.commitTransaction(txnId);
599
+ const commitSeq = typeof commitResult === 'number' ? commitResult : await commitResult;
600
+ if (commitSeq > 0) return;
601
+ // Race - retry
602
+ } catch (e) {
603
+ try { lowlevel.abortTransaction(txnId); } catch {}
604
+ throw e;
605
+ }
606
+ }
436
607
  }
437
608
 
438
609
  /**
@@ -445,8 +616,8 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
445
616
  * const user = User.pk.get("john_doe");
446
617
  * ```
447
618
  */
448
- get(...args: IndexArgTypes<M, F> | [Uint8Array]): InstanceType<M> | undefined {
449
- return this._get(args, false);
619
+ get(...args: IndexArgTypes<M, F>): InstanceType<M> | undefined {
620
+ return this._get(currentTxn(), args, true);
450
621
  }
451
622
 
452
623
  /**
@@ -456,39 +627,50 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
456
627
  * @param args Primary key field values. (Or a single Uint8Array containing the key.)
457
628
  * @returns The (lazily loaded) model instance.
458
629
  */
459
- getLazy(...args: IndexArgTypes<M, F> | [Uint8Array]): InstanceType<M> {
460
- return this._get(args, true);
630
+ getLazy(...args: IndexArgTypes<M, F>): InstanceType<M> {
631
+ return this._get(currentTxn(), args, false);
461
632
  }
462
633
 
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]);
634
+ _get(txn: Transaction, args: IndexArgTypes<M, F> | Uint8Array, loadNow: false | Uint8Array): InstanceType<M>;
635
+ _get(txn: Transaction, args: IndexArgTypes<M, F> | Uint8Array, loadNow: true): InstanceType<M> | undefined;
636
+ _get(txn: Transaction, args: IndexArgTypes<M, F> | Uint8Array, loadNow: boolean | Uint8Array) {
637
+ let key: Uint8Array, keyParts;
638
+ if (args instanceof Uint8Array) {
639
+ key = args;
469
640
  } else {
470
- key = this._argsToKeySingleton(args as IndexArgTypes<M, F>);
641
+ key = this._argsToKeyBytes(args as IndexArgTypes<M, F>, false).toUint8Array();
471
642
  keyParts = args;
472
643
  }
644
+
645
+ const keyHash = hashBytes(key);
646
+ const cached = txn.instancesByPk.get(keyHash) as InstanceType<M>;
647
+ if (cached) {
648
+ if (loadNow && loadNow !== true) {
649
+ // The object already exists, but it may still be lazy-loaded
650
+ Object.defineProperties(cached, this._resetDescriptors);
651
+ this._setNonKeyValues(cached, loadNow);
652
+ }
653
+ return cached;
654
+ }
473
655
 
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)}`);
656
+ let valueBuffer: Uint8Array | undefined;
657
+ if (loadNow) {
658
+ if (loadNow === true) {
659
+ valueBuffer = dbGet(txn.id, key);
660
+ if (logLevel >= 3) {
661
+ console.log(`[edinburgh] Get ${this} key=${new DataPack(key)} result=${valueBuffer && new DataPack(valueBuffer)}`);
662
+ }
663
+ if (!valueBuffer) return;
664
+ } else {
665
+ valueBuffer = loadNow; // Uint8Array
483
666
  }
484
- if (!valueBuffer) return;
485
667
  }
486
668
 
487
669
  // This is a primary index. So we can now deserialize all primary and non-primary fields into instance values.
488
- const model = new (this._MyModel as any)() as InstanceType<M>;
489
-
490
- // Store the canonical primary key on the model
491
- model._primaryKey = key;
670
+ const model = new (this._MyModel as any)(undefined, txn) as InstanceType<M>;
671
+
672
+ // Set to the original value for all fields that are loaded by _setLoadedField
673
+ model._oldValues = {};
492
674
 
493
675
  // Set the primary key fields on the model
494
676
  if (keyParts) {
@@ -498,97 +680,151 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
498
680
  }
499
681
  } else {
500
682
  const bytes = new DataPack(key);
501
- assert(bytes.readNumber() === this._MyModel._primary._getIndexId()); // Skip index id
683
+ assert(bytes.readNumber() === this._MyModel._primary._indexId); // Skip index id
502
684
  for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
503
685
  model._setLoadedField(fieldName, fieldType.deserialize(bytes));
504
686
  }
505
687
  }
506
688
 
689
+ // Store the canonical primary key on the model, set the hash, and freeze the primary key fields.
690
+ model._setPrimaryKey(key, keyHash);
691
+
507
692
  if (valueBuffer) {
508
- // Set other fields
509
- this._setNonKeyValues(model, new DataPack(valueBuffer));
693
+ // Non-lazy load. Set other fields
694
+ this._setNonKeyValues(model, valueBuffer);
510
695
  } else {
511
696
  // Lazy - set getters for other fields
512
697
  Object.defineProperties(model, this._lazyDescriptors);
698
+ // When creating a lazy instance, we don't need to add it to txn.instances yet, as only the
699
+ // primary key fields are loaded, and they cannot be modified (so we don't need to check).
700
+ // When any other field is set, that will trigger a lazy-load, adding the instance to
701
+ // txn.instances.
513
702
  }
514
-
515
- cachedInstances.set(key, model);
703
+
704
+ txn.instancesByPk.set(keyHash, model);
516
705
  return model;
517
706
  }
518
707
 
519
- /**
520
- * Create a canonical primary key buffer for the given model instance.
521
- * Returns a singleton Uint8Array for stable Map/Set identity usage.
522
- */
523
- _instanceToKeySingleton(model: InstanceType<M>): Uint8Array {
524
- const bytes = this._instanceToKeyBytes(model);
525
- return getSingletonUint8Array(bytes.toUint8Array());
708
+ _serializeKey(primaryKey: Uint8Array, _data: Record<string, any>): Uint8Array {
709
+ return primaryKey;
526
710
  }
527
711
 
528
712
  _lazyNow(model: InstanceType<M>) {
529
- let valueBuffer = olmdb.get(model._primaryKey!);
713
+ let valueBuffer = dbGet(model._txn.id, model._primaryKey!);
530
714
  if (logLevel >= 3) {
531
- console.log(`Lazy retrieve ${this} key=${new DataPack(model._primaryKey)} result=${valueBuffer && new DataPack(valueBuffer)}`);
715
+ console.log(`[edinburgh] Lazy retrieve ${this} key=${new DataPack(model._primaryKey)} result=${valueBuffer && new DataPack(valueBuffer)}`);
532
716
  }
533
717
  if (!valueBuffer) throw new DatabaseError(`Lazy-loaded ${model.constructor.name}#${model._primaryKey} does not exist`, 'LAZY_FAIL');
534
718
  Object.defineProperties(model, this._resetDescriptors);
535
- this._setNonKeyValues(model, new DataPack(valueBuffer));
719
+ this._setNonKeyValues(model, valueBuffer);
536
720
  }
537
721
 
538
- _setNonKeyValues(model: InstanceType<M>, valueBytes: DataPack) {
722
+ _setNonKeyValues(model: InstanceType<M>, valueArray: Uint8Array) {
539
723
  const fieldConfigs = this._MyModel.fields;
724
+ const valuePack = new DataPack(valueArray);
725
+ const version = valuePack.readNumber();
540
726
 
541
- for (const fieldName of this._nonKeyFields) {
542
- const value = fieldConfigs[fieldName].type.deserialize(valueBytes);
543
- model._setLoadedField(fieldName, value);
727
+ if (version === this._currentVersion) {
728
+ for (const fieldName of this._nonKeyFields) {
729
+ model._setLoadedField(fieldName, fieldConfigs[fieldName].type.deserialize(valuePack));
730
+ }
731
+ } else {
732
+ this._migrateFromVersion(model, version, valuePack);
544
733
  }
545
734
  }
546
735
 
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;
736
+ /** Load a version's info from DB, caching the result. */
737
+ _loadVersionInfo(txnId: number, version: number): VersionInfo {
738
+ let info = this._versions.get(version);
739
+ if (info) return info;
740
+
741
+ const key = new DataPack()
742
+ .write(VERSION_INFO_PREFIX)
743
+ .write(this._indexId!)
744
+ .write(version)
745
+ .toUint8Array();
746
+ const raw = dbGet(txnId, key);
747
+ if (!raw) throw new DatabaseError(`Version ${version} info not found for index ${this}`, 'CONSISTENCY_ERROR');
748
+
749
+ const obj = new DataPack(raw).read() as any;
750
+ if (!obj || typeof obj.migrateHash !== 'number' || !Array.isArray(obj.fields) || !(obj.secondaryKeys instanceof Set))
751
+ throw new DatabaseError(`Version ${version} info is corrupted for index ${this}`, 'CONSISTENCY_ERROR');
752
+
753
+ const nonKeyFields = new Map<string, TypeWrapper<any>>();
754
+ for (const [name, typeBytes] of obj.fields) {
755
+ nonKeyFields.set(name, deserializeType(new DataPack(typeBytes), 0));
756
+ }
757
+
758
+ info = { migrateHash: obj.migrateHash, nonKeyFields, secondaryKeys: obj.secondaryKeys as Set<string> };
759
+ this._versions.set(version, info);
760
+ return info;
552
761
  }
553
762
 
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>;
763
+ /** Deserialize and migrate a row from an old version. */
764
+ _migrateFromVersion(model: InstanceType<M>, version: number, valuePack: DataPack) {
765
+ const versionInfo = this._loadVersionInfo(model._txn.id, version);
557
766
 
558
- for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
559
- model._setLoadedField(fieldName, fieldType.deserialize(keyBytes));
767
+ // Deserialize using old field types into a plain record
768
+ const record: Record<string, any> = {};
769
+ for (const [name] of this._fieldTypes.entries()) record[name] = (model as any)[name]; // pk fields
770
+ for (const [name, type] of versionInfo.nonKeyFields.entries()) {
771
+ record[name] = type.deserialize(valuePack);
560
772
  }
561
- model._primaryKey = getSingletonUint8Array(keyBytes.toUint8Array());
562
-
563
- this._setNonKeyValues(model, valueBytes);
564
773
 
565
- return model;
774
+ // Run migrate() if it exists
775
+ const migrateFn = (this._MyModel as any).migrate;
776
+ if (migrateFn) migrateFn(record);
777
+
778
+ // Set non-key fields on model from the (possibly migrated) record
779
+ for (const fieldName of this._nonKeyFields) {
780
+ if (fieldName in record) {
781
+ model._setLoadedField(fieldName, record[fieldName]);
782
+ } else if (fieldName in model) {
783
+ // Instantiate the default value
784
+ model._setLoadedField(fieldName, (model as any)[fieldName]);
785
+ } else {
786
+ throw new DatabaseError(`Field ${fieldName} is missing in migrated data for ${model}`, 'MIGRATION_ERROR');
787
+ }
788
+ }
789
+ }
790
+
791
+ _keyToArray(key: Uint8Array): IndexArgTypes<M, F> {
792
+ const bytes = new DataPack(key);
793
+ assert(bytes.readNumber() === this._indexId);
794
+ const result = [] as any[];
795
+ for (const fieldType of this._fieldTypes.values()) {
796
+ result.push(fieldType.deserialize(bytes));
797
+ }
798
+ return result as any;
799
+ }
800
+
801
+ _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, valueBuffer: ArrayBuffer): InstanceType<M> {
802
+ return this._get(txn, new Uint8Array(keyBuffer), new Uint8Array(valueBuffer));
566
803
  }
567
804
 
568
805
  _getTypeName(): string {
569
806
  return 'primary';
570
807
  }
571
808
 
572
- _write(model: InstanceType<M>) {
809
+ _write(txn: Transaction, primaryKey: Uint8Array, data: Record<string, any>) {
573
810
  let valueBytes = new DataPack();
811
+ valueBytes.write(this._currentVersion);
574
812
  const fieldConfigs = this._MyModel.fields as any;
575
813
  for (const fieldName of this._nonKeyFields) {
576
814
  const fieldConfig = fieldConfigs[fieldName] as FieldConfig<unknown>;
577
- fieldConfig.type.serialize(model[fieldName], valueBytes);
815
+ fieldConfig.type.serialize(data[fieldName], valueBytes);
578
816
  }
579
817
  if (logLevel >= 2) {
580
- console.log(`Write ${this} key=${new DataPack(model._getCreatePrimaryKey())} value=${valueBytes}`);
818
+ console.log(`[edinburgh] Write ${this} key=${new DataPack(primaryKey)} value=${valueBytes}`);
581
819
  }
582
- olmdb.put(model._getCreatePrimaryKey(), valueBytes.toUint8Array());
820
+ dbPut(txn.id, primaryKey, valueBytes.toUint8Array());
583
821
  }
584
822
 
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);
823
+ _delete(txn: Transaction, primaryKey: Uint8Array, _data: Record<string, any>) {
824
+ if (logLevel >= 2) {
825
+ console.log(`[edinburgh] Delete ${this} key=${new DataPack(primaryKey)}`);
591
826
  }
827
+ dbDel(txn.id, primaryKey);
592
828
  }
593
829
  }
594
830
 
@@ -603,6 +839,7 @@ export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof
603
839
  constructor(MyModel: M, fieldNames: F) {
604
840
  super(MyModel, fieldNames);
605
841
  (this._MyModel._secondaries ||= []).push(this);
842
+ scheduleInit();
606
843
  }
607
844
 
608
845
  /**
@@ -616,72 +853,69 @@ export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof
616
853
  * ```
617
854
  */
618
855
  get(...args: IndexArgTypes<M, F>): InstanceType<M> | undefined {
619
- let keyBuffer = this._argsToKeySingleton(args);
856
+ const txn = currentTxn();
857
+ let keyBuffer = this._argsToKeyBytes(args, false).toUint8Array();
620
858
 
621
- let valueBuffer = olmdb.get(keyBuffer);
859
+ let valueBuffer = dbGet(txn.id, keyBuffer);
622
860
  if (logLevel >= 3) {
623
- console.log(`Get ${this} key=${new DataPack(keyBuffer)} result=${valueBuffer}`);
861
+ console.log(`[edinburgh] Get ${this} key=${new DataPack(keyBuffer)} result=${valueBuffer}`);
624
862
  }
625
863
  if (!valueBuffer) return;
626
864
 
627
865
  const pk = this._MyModel._primary!;
628
- const result = pk.get(valueBuffer);
866
+ const result = pk._get(txn, valueBuffer, true);
629
867
  if (!result) throw new DatabaseError(`Unique index ${this} points at non-existing primary for key: ${args.join(', ')}`, 'CONSISTENCY_ERROR');
630
868
  return result;
631
869
  }
632
870
 
633
- _delete(model: InstanceType<M>) {
634
- if (!this._hasNullIndexValues(model)) {
635
- const keyBytes = this._instanceToKeyBytes(model);
871
+ _serializeKey(primaryKey: Uint8Array, data: Record<string, any>): Uint8Array {
872
+ return this._serializeKeyFields(data).toUint8Array();
873
+ }
874
+
875
+ _delete(txn: Transaction, primaryKey: Uint8Array, data: Record<string, any>) {
876
+ if (!this._hasNullIndexValues(data)) {
877
+ const key = this._serializeKey(primaryKey, data);
636
878
  if (logLevel >= 2) {
637
- console.log(`Delete ${this} key=${keyBytes}`);
879
+ console.log(`[edinburgh] Delete ${this} key=${key}`);
638
880
  }
639
- olmdb.del(keyBytes.toUint8Array());
881
+ dbDel(txn.id, key);
640
882
  }
641
883
  }
642
884
 
643
- _write(model: InstanceType<M>) {
644
- if (!this._hasNullIndexValues(model)) {
645
- const key = this._instanceToKeyBytes(model);
885
+ _write(txn: Transaction, primaryKey: Uint8Array, data: Record<string, any>) {
886
+ if (!this._hasNullIndexValues(data)) {
887
+ const key = this._serializeKey(primaryKey, data);
646
888
  if (logLevel >= 2) {
647
- console.log(`Write ${this} key=${key} value=${new DataPack(model._primaryKey)}`);
889
+ console.log(`[edinburgh] Write ${this} key=${key} value=${new DataPack(primaryKey)}`);
648
890
  }
649
- const keyBuffer = key.toUint8Array();
650
- if (olmdb.get(keyBuffer)) {
891
+ if (dbGet(txn.id, key)) {
651
892
  throw new DatabaseError(`Unique constraint violation for ${this} key ${key}`, 'UNIQUE_CONSTRAINT');
652
893
  }
653
- olmdb.put(keyBuffer, model._primaryKey!);
894
+ dbPut(txn.id, key, primaryKey);
654
895
  }
655
896
  }
656
897
 
657
- /**
658
- * Extract model from iterator entry for unique index.
659
- * @param keyBytes - Key bytes with index ID already read.
660
- * @param valueBytes - Value bytes from the entry.
661
- * @returns Model instance or undefined.
662
- * @internal
663
- */
664
- _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined {
898
+ _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, valueBuffer: ArrayBuffer): InstanceType<M> {
665
899
  // For unique indexes, the value contains the primary key
666
900
 
901
+ const keyPack = new DataPack(new Uint8Array(keyBuffer));
902
+ keyPack.readNumber(); // discard index id
903
+
667
904
  const pk = this._MyModel._primary!;
668
- const model = pk.getLazy(valueBuffer);
905
+ const model = pk._get(txn, new Uint8Array(valueBuffer), false);
669
906
 
670
- // Read the index fields from the key, overriding lazy loading for these fields
907
+ // _get will have created lazy-load getters for our indexed fields. Let's turn them back into
908
+ // regular properties:
909
+ Object.defineProperties(model, this._resetIndexFieldDescriptors);
910
+
911
+ // Set the values for our indexed fields
671
912
  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));
913
+ model._setLoadedField(name, fieldType.deserialize(keyPack));
679
914
  }
680
915
 
681
916
  return model;
682
917
  }
683
918
 
684
-
685
919
  _getTypeName(): string {
686
920
  return 'unique';
687
921
  }
@@ -701,64 +935,60 @@ export class SecondaryIndex<M extends typeof Model, const F extends readonly (ke
701
935
  constructor(MyModel: M, fieldNames: F) {
702
936
  super(MyModel, fieldNames);
703
937
  (this._MyModel._secondaries ||= []).push(this);
938
+ scheduleInit();
704
939
  }
705
940
 
706
- /**
707
- * Extract model from iterator entry for secondary index.
708
- * @param keyBytes - Key bytes with index ID already read.
709
- * @param valueBuffer - Value Uint8Array from the entry.
710
- * @returns Model instance or undefined.
711
- * @internal
712
- */
713
- _pairToInstance(keyBytes: DataPack, valueBuffer: Uint8Array): InstanceType<M> | undefined {
941
+ _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, _valueBuffer: ArrayBuffer): InstanceType<M> {
714
942
  // For secondary indexes, the primary key is stored after the index fields in the key
943
+
944
+ const keyPack = new DataPack(new Uint8Array(keyBuffer));
945
+ keyPack.readNumber(); // discard index id
715
946
 
716
947
  // Read the index fields, saving them for later
717
948
  const indexFields = new Map();
718
949
  for(const [name, type] of this._fieldTypes.entries()) {
719
- indexFields.set(name, type.deserialize(keyBytes));
950
+ indexFields.set(name, type.deserialize(keyPack));
720
951
  }
721
952
 
722
- const primaryKey = keyBytes.readUint8Array();
723
- const model = this._MyModel._primary!.getLazy(primaryKey);
953
+ const primaryKey = keyPack.readUint8Array();
954
+ const model = this._MyModel._primary!._get(txn, primaryKey, false);
955
+
956
+
957
+ // _get will have created lazy-load getters for our indexed fields. Let's turn them back into
958
+ // regular properties:
959
+ Object.defineProperties(model, this._resetIndexFieldDescriptors);
724
960
 
725
- // Add the index fields to the model, overriding lazy loading for these fields
961
+ // Set the values for our indexed fields
726
962
  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
963
  model._setLoadedField(name, value);
734
964
  }
735
965
 
736
966
  return model;
737
967
  }
738
968
 
739
- _instanceToKeyBytes(model: InstanceType<M>): DataPack {
969
+ _serializeKey(primaryKey: Uint8Array, model: InstanceType<M>): Uint8Array {
740
970
  // index id + index fields + primary key
741
- const bytes = super._instanceToKeyBytes(model);
742
- bytes.write(model._getCreatePrimaryKey());
743
- return bytes;
971
+ const bytes = super._serializeKeyFields(model);
972
+ bytes.write(primaryKey);
973
+ return bytes.toUint8Array();
744
974
  }
745
975
 
746
- _write(model: InstanceType<M>) {
976
+ _write(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>) {
747
977
  if (this._hasNullIndexValues(model)) return;
748
- const keyBytes = this._instanceToKeyBytes(model);
978
+ const key = this._serializeKey(primaryKey, model);
749
979
  if (logLevel >= 2) {
750
- console.log(`Write ${this} key=${keyBytes}`);
980
+ console.log(`[edinburgh] Write ${this} key=${key}`);
751
981
  }
752
- olmdb.put(keyBytes.toUint8Array(), SECONDARY_VALUE);
982
+ dbPut(txn.id, key, SECONDARY_VALUE);
753
983
  }
754
984
 
755
- _delete(model: InstanceType<M>): void {
985
+ _delete(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>): void {
756
986
  if (this._hasNullIndexValues(model)) return;
757
- const keyBytes = this._instanceToKeyBytes(model);
987
+ const key = this._serializeKey(primaryKey, model);
758
988
  if (logLevel >= 2) {
759
- console.log(`Delete ${this} key=${keyBytes}`);
989
+ console.log(`[edinburgh] Delete ${this} key=${key}`);
760
990
  }
761
- olmdb.del(keyBytes.toUint8Array());
991
+ dbDel(txn.id, key);
762
992
  }
763
993
 
764
994
  _getTypeName(): string {
@@ -852,38 +1082,71 @@ export function index(MyModel: typeof Model, fields: any): SecondaryIndex<any, a
852
1082
  * This is primarily useful for development and debugging purposes.
853
1083
  */
854
1084
  export function dump() {
1085
+ const txn = currentTxn();
855
1086
  let indexesById = new Map<number, {name: string, type: string, fields: Record<string, TypeWrapper<any>>}>();
856
- console.log("--- Database dump ---")
857
- for(const {key,value} of olmdb.scan()) {
858
- const kb = new DataPack(key);
859
- const vb = new DataPack(value);
1087
+ let versions = new Map<number, Map<number, Map<string, TypeWrapper<any>>>>();
1088
+ console.log("--- edinburgh database dump ---")
1089
+ const iteratorId = lowlevel.createIterator(txn.id, undefined, undefined, false);
1090
+ try {
1091
+ while (true) {
1092
+ const raw = lowlevel.readIterator(iteratorId);
1093
+ if (!raw) break;
1094
+ const kb = new DataPack(new Uint8Array(raw.key));
1095
+ const vb = new DataPack(new Uint8Array(raw.value));
860
1096
  const indexId = kb.readNumber();
861
1097
  if (indexId === MAX_INDEX_ID_PREFIX) {
862
1098
  console.log("* Max index id", vb.readNumber());
1099
+ } else if (indexId === VERSION_INFO_PREFIX) {
1100
+ const idxId = kb.readNumber();
1101
+ const version = kb.readNumber();
1102
+ const obj = vb.read() as any;
1103
+ const nonKeyFields = new Map<string, TypeWrapper<any>>();
1104
+ for (const [name, typeBytes] of obj.fields) {
1105
+ nonKeyFields.set(name, deserializeType(new DataPack(typeBytes), 0));
1106
+ }
1107
+ if (!versions.has(idxId)) versions.set(idxId, new Map());
1108
+ versions.get(idxId)!.set(version, nonKeyFields);
1109
+ console.log(`* Version ${version} for index ${idxId}: fields=[${[...nonKeyFields.keys()].join(',')}]`);
863
1110
  } else if (indexId === INDEX_ID_PREFIX) {
864
1111
  const name = kb.readString();
865
1112
  const type = kb.readString();
866
1113
  const fields: Record<string, TypeWrapper<any>> = {};
867
1114
  while(kb.readAvailable()) {
868
- const name = kb.readString();
1115
+ const name = kb.read();
1116
+ if (name === undefined) break; // what follows are primary key fields (when this is a secondary index)
869
1117
  fields[name] = deserializeType(kb, 0);
870
1118
  }
1119
+
871
1120
  const indexId = vb.readNumber();
872
1121
  console.log(`* Index definition ${indexId}:${name}:${type}[${Object.keys(fields).join(',')}]`);
873
1122
  indexesById.set(indexId, {name, type, fields});
874
1123
  } else if (indexId > 0 && indexesById.has(indexId)) {
875
1124
  const index = indexesById.get(indexId)!;
876
- const {name, type, fields} = index;
877
- const rowKey: any = {};
878
- for(const [fieldName, fieldType] of Object.entries(fields)) {
879
- rowKey[fieldName] = fieldType.deserialize(kb);
1125
+ let name, type, rowKey: any, rowValue: any;
1126
+ if (index) {
1127
+ name = index.name;
1128
+ type = index.type;
1129
+ const fields = index.fields;
1130
+ rowKey = {};
1131
+ for(const [fieldName, fieldType] of Object.entries(fields)) {
1132
+ rowKey[fieldName] = fieldType.deserialize(kb);
1133
+ }
1134
+ if (type === 'primary') {
1135
+ const version = vb.readNumber();
1136
+ const vFields = versions.get(indexId)?.get(version);
1137
+ if (vFields) {
1138
+ rowValue = {};
1139
+ for (const [fieldName, fieldType] of vFields) {
1140
+ rowValue[fieldName] = fieldType.deserialize(vb);
1141
+ }
1142
+ }
1143
+ }
880
1144
  }
881
- // const Model = modelRegistry[name]!;
882
- // TODO: once we're storing schemas (serializeType) in the db, we can deserialize here
883
- console.log(`* Row for ${indexId}:${name}:${type}[${Object.keys(fields).join(',')}] key=${kb} value=${vb}`);
1145
+ console.log(`* Row for ${indexId}:${name}:${type}`, rowKey ?? kb, rowValue ?? vb);
884
1146
  } else {
885
1147
  console.log(`* Unhandled '${indexId}' key=${kb} value=${vb}`);
886
1148
  }
887
1149
  }
888
- console.log("--- End of database dump ---")
1150
+ } finally { lowlevel.closeIterator(iteratorId); }
1151
+ console.log("--- end ---")
889
1152
  }