edinburgh 0.1.3 → 0.4.1

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,11 @@
1
- import * as olmdb from "olmdb";
2
- import { DatabaseError } from "olmdb";
3
- import { Bytes } from "./bytes.js";
4
- import { getMockModel, Model, modelRegistry } from "./models.js";
5
- import { assert, logLevel } from "./utils.js";
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
- /** @internal Symbol used to access the underlying model from a proxy */
9
- export const TARGET_SYMBOL = Symbol('target');
10
-
11
9
  // Index system types and utilities
12
10
  type IndexArgTypes<M extends typeof Model<any>, F extends readonly (keyof InstanceType<M> & string)[]> = {
13
11
  [I in keyof F]: InstanceType<M>[F[I]]
@@ -15,43 +13,56 @@ type IndexArgTypes<M extends typeof Model<any>, F extends readonly (keyof Instan
15
13
 
16
14
  const MAX_INDEX_ID_PREFIX = -1;
17
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
+ }
18
36
 
19
37
  /**
20
38
  * Iterator for range queries on indexes.
21
39
  * Handles common iteration logic for both primary and unique indexes.
22
40
  * Implements both Iterator and Iterable interfaces for efficiency.
23
41
  */
24
- class IndexRangeIterator<M extends typeof Model, F extends readonly (keyof InstanceType<M> & string)[]> implements Iterator<InstanceType<M>>, Iterable<InstanceType<M>> {
42
+ export class IndexRangeIterator<M extends typeof Model> implements Iterator<InstanceType<M>>, Iterable<InstanceType<M>> {
25
43
  constructor(
26
- private iterator: olmdb.DbIterator<any,any> | undefined,
44
+ private txn: Transaction,
45
+ private iteratorId: number,
27
46
  private indexId: number,
28
- private parentIndex: BaseIndex<M, F>
29
- ) {}
47
+ private parentIndex: BaseIndex<M, any>
48
+ ) {
49
+ }
30
50
 
31
51
  [Symbol.iterator](): Iterator<InstanceType<M>> {
32
52
  return this;
33
53
  }
34
54
 
35
55
  next(): IteratorResult<InstanceType<M>> {
36
- if (!this.iterator) return { done: true, value: undefined };
37
- const entry = this.iterator.next();
38
- if (entry.done) {
39
- this.iterator.close();
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;
40
61
  return { done: true, value: undefined };
41
62
  }
42
63
 
43
- // Extract the key without the index ID
44
- const keyBytes = new Bytes(entry.value.key);
45
- const entryIndexId = keyBytes.readNumber();
46
- assert(entryIndexId === this.indexId);
47
-
48
- // Use polymorphism to get the model from the entry
49
- const model = this.parentIndex._getModelFromEntry(keyBytes, new Bytes(entry.value.value));
50
-
51
- if (!model) {
52
- // This shouldn't happen, but skip if it does
53
- return this.next();
54
- }
64
+ // Dispatches to the _pairToInstance specific to the index type
65
+ const model = this.parentIndex._pairToInstance(this.txn, raw.key, raw.value);
55
66
 
56
67
  return { done: false, value: model };
57
68
  }
@@ -109,160 +120,150 @@ type FindOptions<ARG_TYPES extends readonly any[]> = (
109
120
  */
110
121
  export abstract class BaseIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> {
111
122
  public _MyModel: M;
123
+ public _fieldTypes: Map<keyof InstanceType<M> & string, TypeWrapper<any>> = new Map();
124
+ public _fieldCount!: number;
125
+ _resetIndexFieldDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
112
126
 
113
127
  /**
114
128
  * Create a new index.
115
129
  * @param MyModel - The model class this index belongs to.
116
130
  * @param _fieldNames - Array of field names that make up this index.
117
131
  */
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);
132
+ constructor(MyModel: M, public _fieldNames: F) {
133
+ this._MyModel = getMockModel(MyModel);
122
134
  }
123
135
 
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);
136
+ async _delayedInit() {
137
+ if (this._indexId != null) return; // Already initialized
138
+ for(const fieldName of this._fieldNames) {
139
+ assert(typeof fieldName === 'string', 'Field names must be strings');
140
+ this._fieldTypes.set(fieldName, this._MyModel.fields[fieldName].type);
141
+ }
142
+ this._fieldCount = this._fieldNames.length;
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
+ };
137
155
  }
138
- return result;
139
156
  }
140
157
 
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;
158
+ _indexId?: number;
159
+
160
+ /** Human-readable signature for version tracking, e.g. "secondary category:string" */
161
+ _signature?: string;
149
162
 
150
163
  /**
151
- * Serialize field values to bytes for index key.
164
+ * Serialize array of key values to a (index-id prefixed) Bytes instance that can be used as a key.
152
165
  * @param args - Field values to serialize (can be partial for range queries).
153
- * @param bytes - Bytes to write to.
166
+ * @returns A Bytes instance containing the index id and serialized key parts.
154
167
  * @internal
155
168
  */
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);
169
+ _argsToKeyBytes(args: [], allowPartial: boolean): DataPack;
170
+ _argsToKeyBytes(args: Partial<IndexArgTypes<M, F>>, allowPartial: boolean): DataPack;
171
+
172
+ _argsToKeyBytes(args: any, allowPartial: boolean) {
173
+ assert(allowPartial ? args.length <= this._fieldCount : args.length === this._fieldCount);
174
+ const bytes = new DataPack();
175
+ bytes.write(this._indexId!);
176
+ let index = 0;
177
+ for(const fieldType of this._fieldTypes.values()) {
178
+ // For partial keys, undefined values are acceptable and represent open range suffixes
179
+ if (index >= args.length) break;
180
+ fieldType.serialize(args[index++], bytes);
163
181
  }
182
+ return bytes;
164
183
  }
165
184
 
166
185
  /**
167
- * Create database key from field values.
168
- * @param args - Field values.
169
- * @returns Database key bytes.
186
+ * Extract model from iterator entry - implemented differently by each index type.
187
+ * @param keyBuffer - Key bytes (including index id).
188
+ * @param valueBuffer - Value bytes from the entry.
189
+ * @returns Model instance or undefined.
190
+ * @internal
170
191
  */
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
- }
192
+ abstract _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, valueBuffer: ArrayBuffer): InstanceType<M>;
178
193
 
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);
194
+ _hasNullIndexValues(data: Record<string, any>) {
195
+ for(const fieldName of this._fieldTypes.keys()) {
196
+ if (data[fieldName] == null) return true;
189
197
  }
198
+ return false;
190
199
  }
191
200
 
192
- /**
193
- * Create database key from model instance.
194
- * @param model - Model instance.
195
- * @param includeIndexId - Whether to include index ID in key.
196
- * @returns Database key bytes or undefined if skipped.
197
- * @internal
198
- */
199
- _getKeyFromModel(model: InstanceType<M>, includeIndexId: boolean): Uint8Array {
200
- const bytes = new Bytes();
201
- if (includeIndexId) bytes.writeNumber(this._getIndexId());
202
- this._serializeModel(model, bytes);
203
- return bytes.getBuffer();
204
- }
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;
205
203
 
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>;
204
+ // Returns the indexId + serialized key fields. Used in some _serializeKey implementations
205
+ // and for calculating _primaryKey.
206
+ _serializeKeyFields(data: Record<string, any>): DataPack {
207
+ const bytes = new DataPack();
208
+ bytes.write(this._indexId!);
209
+ for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
210
+ fieldType.serialize(data[fieldName], bytes);
211
+ }
212
+ return bytes;
214
213
  }
215
214
 
216
215
  /**
217
- * Get or create unique index ID for this index.
218
- * @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.
219
218
  */
220
- _getIndexId(): number {
221
- // Resolve an index to a number
222
- let indexId = this._cachedIndexId;
223
- if (indexId == null) {
224
- const indexNameBytes = new Bytes().writeNumber(INDEX_ID_PREFIX).writeString(this._MyModel.tableName).writeString(this._getTypeName());
225
- for(let name of this._fieldNames) {
226
- indexNameBytes.writeString(name);
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) {
230
+ indexNameBytes.write(name);
227
231
  serializeType(this._MyModel.fields[name].type, indexNameBytes);
228
232
  }
229
- const indexNameBuf = indexNameBytes.getBuffer();
230
-
231
- let result = olmdb.get(indexNameBuf);
232
- if (result) {
233
- indexId = this._cachedIndexId = new Bytes(result).readNumber();
234
- } else {
235
- const maxIndexIdBuf = new Bytes().writeNumber(MAX_INDEX_ID_PREFIX).getBuffer();
236
- result = olmdb.get(maxIndexIdBuf);
237
- indexId = result ? new Bytes(result).readNumber() + 1 : 1;
238
- olmdb.onCommit(() => {
239
- // Only if the transaction succeeds can we cache this id
240
- this._cachedIndexId = indexId;
241
- });
242
-
243
- const idBuf = new Bytes().writeNumber(indexId).getBuffer();
244
- olmdb.put(indexNameBuf, idBuf);
245
- olmdb.put(maxIndexIdBuf, idBuf); // This will also cause the transaction to rerun if we were raced
246
- if (logLevel >= 1) {
247
- console.log(`Created index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with id ${indexId}`);
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;
248
256
  }
257
+ } catch (e) {
258
+ try { lowlevel.abortTransaction(txnId); } catch {}
259
+ throw e;
249
260
  }
250
261
  }
251
- 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(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>): void;
266
+ abstract _write(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>): void;
266
267
 
267
268
  /**
268
269
  * Find model instances using flexible range query options.
@@ -322,64 +323,135 @@ 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
- const indexId = this._getIndexId();
327
-
328
- let startKey: Bytes | undefined = new Bytes().writeNumber(indexId);
329
- let endKey: Bytes | undefined = startKey.copy();
330
-
326
+ _computeKeyBounds(opts: FindOptions<IndexArgTypes<M, F>>): [DataPack | undefined, DataPack | undefined] | null {
327
+ let startKey: DataPack | undefined;
328
+ let endKey: DataPack | undefined;
331
329
  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();
330
+ startKey = this._argsToKeyBytes(toArray(opts.is), true);
331
+ endKey = startKey.clone(true).increment();
335
332
  } else {
336
- // Range query
337
333
  if ('from' in opts) {
338
- this._serializeArgs(toArray(opts.from), startKey);
334
+ startKey = this._argsToKeyBytes(toArray(opts.from), true);
339
335
  } else if ('after' in opts) {
340
- this._serializeArgs(toArray(opts.after), startKey);
341
- if (!startKey.increment()) {
342
- // There can be nothing 'after' - return an empty iterator
343
- return new IndexRangeIterator(undefined, indexId, this);
344
- }
336
+ startKey = this._argsToKeyBytes(toArray(opts.after), true);
337
+ if (!startKey.increment()) return null;
338
+ } else {
339
+ startKey = this._argsToKeyBytes([], true);
345
340
  }
346
-
347
341
  if ('to' in opts) {
348
- this._serializeArgs(toArray(opts.to), endKey);
349
- endKey.increment();
342
+ endKey = this._argsToKeyBytes(toArray(opts.to), true).increment();
350
343
  } else if ('before' in opts) {
351
- this._serializeArgs(toArray(opts.before), endKey);
344
+ endKey = this._argsToKeyBytes(toArray(opts.before), true);
352
345
  } else {
353
- endKey = endKey.increment(); // Next indexId
346
+ endKey = this._argsToKeyBytes([], true).increment();
354
347
  }
355
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;
356
359
 
357
360
  // For reverse scans, swap start/end keys since OLMDB expects it
358
361
  const scanStart = opts.reverse ? endKey : startKey;
359
362
  const scanEnd = opts.reverse ? startKey : endKey;
360
363
 
361
- const iterator = olmdb.scan({
362
- start: scanStart?.getBuffer(),
363
- end: scanEnd?.getBuffer(),
364
- reverse: opts.reverse || false,
365
- });
364
+ if (logLevel >= 3) {
365
+ console.log(`[edinburgh] Scan ${this} start=${scanStart} end=${scanEnd} reverse=${opts.reverse||false}`);
366
+ }
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
+ );
366
375
 
367
- return new IndexRangeIterator(iterator, indexId, this);
376
+ return new IndexRangeIterator(txn, iteratorId, indexId, this);
368
377
  }
369
378
 
370
379
  /**
371
- * Save index entry for a model instance.
372
- * @param model - Model instance to save.
373
- * @param originalKey - Original key if updating.
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
374
389
  */
375
- abstract _save(model: InstanceType<M>, originalKey?: Uint8Array): Uint8Array | undefined;
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
+ }
443
+ }
376
444
 
377
445
  abstract _getTypeName(): string;
446
+
447
+ toString() {
448
+ return `${this._indexId}:${this._MyModel.tableName}:${this._getTypeName()}[${Array.from(this._fieldTypes.keys()).join(',')}]`;
449
+ }
378
450
  }
379
451
 
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];
452
+ function toArray<ARG_TYPES extends readonly any[]>(args: ArrayOrOnlyItem<ARG_TYPES>): Partial<ARG_TYPES> {
453
+ // Convert single value or array to array format compatible with Partial<ARG_TYPES>
454
+ return (Array.isArray(args) ? args : [args]) as Partial<ARG_TYPES>;
383
455
  }
384
456
 
385
457
  /**
@@ -389,13 +461,149 @@ function toArray<T>(args: T): T extends readonly any[] ? T : [T] {
389
461
  * @template F - The field names that make up this index.
390
462
  */
391
463
  export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
392
-
464
+
465
+ _nonKeyFields!: (keyof InstanceType<M> & string)[];
466
+ _lazyDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
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();
476
+
393
477
  constructor(MyModel: M, fieldNames: F) {
394
- super(MyModel, fieldNames, true);
395
- if (MyModel._pk && MyModel._pk !== this) {
396
- throw new DatabaseError(`Model ${MyModel.tableName} already has a primary key defined`, 'INIT_ERROR');
478
+ super(MyModel, fieldNames);
479
+ if (MyModel._primary) {
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');
481
+ }
482
+ MyModel._primary = this;
483
+ }
484
+
485
+ async _delayedInit() {
486
+ if (this._indexId != null) return; // Already initialized
487
+ await super._delayedInit();
488
+ const MyModel = this._MyModel;
489
+ this._nonKeyFields = Object.keys(MyModel.fields).filter(fieldName => !this._fieldNames.includes(fieldName as any)) as any;
490
+
491
+ for(const fieldName of this._nonKeyFields) {
492
+ this._lazyDescriptors[fieldName] = {
493
+ configurable: true,
494
+ enumerable: true,
495
+ get(this: InstanceType<M>) {
496
+ this.constructor._primary._lazyNow(this);
497
+ return this[fieldName];
498
+ },
499
+ set(this: InstanceType<M>, value: any) {
500
+ this.constructor._primary._lazyNow(this);
501
+ this[fieldName] = value;
502
+ }
503
+ };
504
+ this._resetDescriptors[fieldName] = {
505
+ writable: true,
506
+ enumerable: true
507
+ };
508
+ }
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
+ }
397
606
  }
398
- MyModel._pk = this;
399
607
  }
400
608
 
401
609
  /**
@@ -409,97 +617,215 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
409
617
  * ```
410
618
  */
411
619
  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);
620
+ return this._get(currentTxn(), args, true);
621
+ }
622
+
623
+ /**
624
+ * Does the same as as `get()`, but will delay loading the instance from disk until the first
625
+ * property access. In case it turns out the instance doesn't exist, an error will be thrown
626
+ * at that time.
627
+ * @param args Primary key field values. (Or a single Uint8Array containing the key.)
628
+ * @returns The (lazily loaded) model instance.
629
+ */
630
+ getLazy(...args: IndexArgTypes<M, F>): InstanceType<M> {
631
+ return this._get(currentTxn(), args, false);
632
+ }
633
+
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;
640
+ } else {
641
+ key = this._argsToKeyBytes(args as IndexArgTypes<M, F>, false).toUint8Array();
642
+ keyParts = args;
415
643
  }
416
644
 
417
- let valueBuffer = olmdb.get(keyBuffer);
418
- if (!valueBuffer) return;
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
+ }
419
655
 
420
- // This is a primary index. So we can now deserialize all primary and non-primary fields into instance values.
421
- const model = new (this._MyModel as any)() as InstanceType<M>;
422
- // We'll want to set all loaded values on the unproxied target object.
423
- const unproxied = (model as any)[TARGET_SYMBOL];
424
- unproxied._state = 2; // Loaded from disk, unmodified
425
-
426
- const valueBytes = new Bytes(valueBuffer);
427
- let primaryKeyIndex = 0;
428
- for (const [fieldName, fieldConfig] of Object.entries(this._MyModel.fields)) {
429
- if (this._fieldNames.includes(fieldName as any)) { // Value is part of primary key
430
- unproxied[fieldName as string] = args[primaryKeyIndex];
431
- primaryKeyIndex++;
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;
432
664
  } else {
433
- // We're passing in the proxied model
434
- fieldConfig.type.deserialize(unproxied, fieldName, valueBytes, model);
665
+ valueBuffer = loadNow; // Uint8Array
435
666
  }
436
667
  }
668
+
669
+ // This is a primary index. So we can now deserialize all primary and non-primary fields into instance values.
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 = {};
437
674
 
675
+ // Set the primary key fields on the model
676
+ if (keyParts) {
677
+ let index = 0;
678
+ for(const fieldName of this._fieldTypes.keys()) {
679
+ model._setLoadedField(fieldName, keyParts[index++] as any);
680
+ }
681
+ } else {
682
+ const bytes = new DataPack(key);
683
+ assert(bytes.readNumber() === this._MyModel._primary._indexId); // Skip index id
684
+ for(const [fieldName, fieldType] of this._fieldTypes.entries()) {
685
+ model._setLoadedField(fieldName, fieldType.deserialize(bytes));
686
+ }
687
+ }
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
+
692
+ if (valueBuffer) {
693
+ // Non-lazy load. Set other fields
694
+ this._setNonKeyValues(model, valueBuffer);
695
+ } else {
696
+ // Lazy - set getters for other fields
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.
702
+ }
703
+
704
+ txn.instancesByPk.set(keyHash, model);
438
705
  return model;
439
706
  }
440
707
 
441
- /**
442
- * Extract model from iterator entry for primary index.
443
- * @param keyBytes - Key bytes with index ID already read.
444
- * @param valueBytes - Value bytes from the entry.
445
- * @returns Model instance or undefined.
446
- * @internal
447
- */
448
- _getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
449
- const model = new (this._MyModel as any)() as InstanceType<M>;
450
- // We'll want to set all loaded values on the unproxied target object.
451
- const unproxied = (model as any)[TARGET_SYMBOL];
452
- unproxied._state = 2; // Loaded from disk, unmodified
708
+ _serializeKey(primaryKey: Uint8Array, _data: Record<string, any>): Uint8Array {
709
+ return primaryKey;
710
+ }
453
711
 
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);
712
+ _lazyNow(model: InstanceType<M>) {
713
+ let valueBuffer = dbGet(model._txn.id, model._primaryKey!);
714
+ if (logLevel >= 3) {
715
+ console.log(`[edinburgh] Lazy retrieve ${this} key=${new DataPack(model._primaryKey)} result=${valueBuffer && new DataPack(valueBuffer)}`);
458
716
  }
717
+ if (!valueBuffer) throw new DatabaseError(`Lazy-loaded ${model.constructor.name}#${model._primaryKey} does not exist`, 'LAZY_FAIL');
718
+ Object.defineProperties(model, this._resetDescriptors);
719
+ this._setNonKeyValues(model, valueBuffer);
720
+ }
721
+
722
+ _setNonKeyValues(model: InstanceType<M>, valueArray: Uint8Array) {
723
+ const fieldConfigs = this._MyModel.fields;
724
+ const valuePack = new DataPack(valueArray);
725
+ const version = valuePack.readNumber();
459
726
 
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);
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);
464
733
  }
734
+ }
465
735
 
466
- return model;
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;
467
761
  }
468
762
 
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);
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);
766
+
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);
772
+ }
773
+
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');
486
787
  }
487
788
  }
488
-
489
- olmdb.put(newKey, valBytes.getBuffer());
789
+ }
490
790
 
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());
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));
495
797
  }
496
-
497
- return newKey;
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));
498
803
  }
499
804
 
500
805
  _getTypeName(): string {
501
806
  return 'primary';
502
807
  }
808
+
809
+ _write(txn: Transaction, primaryKey: Uint8Array, data: Record<string, any>) {
810
+ let valueBytes = new DataPack();
811
+ valueBytes.write(this._currentVersion);
812
+ const fieldConfigs = this._MyModel.fields as any;
813
+ for (const fieldName of this._nonKeyFields) {
814
+ const fieldConfig = fieldConfigs[fieldName] as FieldConfig<unknown>;
815
+ fieldConfig.type.serialize(data[fieldName], valueBytes);
816
+ }
817
+ if (logLevel >= 2) {
818
+ console.log(`[edinburgh] Write ${this} key=${new DataPack(primaryKey)} value=${valueBytes}`);
819
+ }
820
+ dbPut(txn.id, primaryKey, valueBytes.toUint8Array());
821
+ }
822
+
823
+ _delete(txn: Transaction, primaryKey: Uint8Array, _data: Record<string, any>) {
824
+ if (logLevel >= 2) {
825
+ console.log(`[edinburgh] Delete ${this} key=${new DataPack(primaryKey)}`);
826
+ }
827
+ dbDel(txn.id, primaryKey);
828
+ }
503
829
  }
504
830
 
505
831
  /**
@@ -509,6 +835,13 @@ export class PrimaryIndex<M extends typeof Model, const F extends readonly (keyo
509
835
  * @template F - The field names that make up this index.
510
836
  */
511
837
  export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof InstanceType<M> & string)[]> extends BaseIndex<M, F> {
838
+
839
+ constructor(MyModel: M, fieldNames: F) {
840
+ super(MyModel, fieldNames);
841
+ (this._MyModel._secondaries ||= []).push(this);
842
+ scheduleInit();
843
+ }
844
+
512
845
  /**
513
846
  * Get a model instance by unique index key values.
514
847
  * @param args - The unique index key values.
@@ -520,72 +853,67 @@ export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof
520
853
  * ```
521
854
  */
522
855
  get(...args: IndexArgTypes<M, F>): InstanceType<M> | undefined {
523
- let keyBuffer = this._getKeyFromArgs(args as IndexArgTypes<M, F>);
856
+ const txn = currentTxn();
857
+ let keyBuffer = this._argsToKeyBytes(args, false).toUint8Array();
858
+
859
+ let valueBuffer = dbGet(txn.id, keyBuffer);
524
860
  if (logLevel >= 3) {
525
- console.log(`Getting unique ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] (id=${this._getIndexId()}) with key`, args, keyBuffer);
861
+ console.log(`[edinburgh] Get ${this} key=${new DataPack(keyBuffer)} result=${valueBuffer}`);
526
862
  }
527
-
528
- let valueBuffer = olmdb.get(keyBuffer);
529
863
  if (!valueBuffer) return;
530
864
 
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');
865
+ const pk = this._MyModel._primary!;
866
+ const result = pk._get(txn, valueBuffer, true);
867
+ if (!result) throw new DatabaseError(`Unique index ${this} points at non-existing primary for key: ${args.join(', ')}`, 'CONSISTENCY_ERROR');
535
868
  return result;
536
869
  }
537
870
 
538
- /**
539
- * Extract model from iterator entry for unique index.
540
- * @param keyBytes - Key bytes with index ID already read.
541
- * @param valueBytes - Value bytes from the entry.
542
- * @returns Model instance or undefined.
543
- * @internal
544
- */
545
- _getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
546
- // For unique indexes, the value contains the primary key
547
- const pk = this._MyModel._pk!;
548
- const primaryKeyArgs = pk._deserializeKey(valueBytes);
549
- return pk.get(...primaryKeyArgs);
871
+ _serializeKey(primaryKey: Uint8Array, data: Record<string, any>): Uint8Array {
872
+ return this._serializeKeyFields(data).toUint8Array();
550
873
  }
551
874
 
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;
875
+ _delete(txn: Transaction, primaryKey: Uint8Array, data: Record<string, any>) {
876
+ if (!this._hasNullIndexValues(data)) {
877
+ const key = this._serializeKey(primaryKey, data);
878
+ if (logLevel >= 2) {
879
+ console.log(`[edinburgh] Delete ${this} key=${key}`);
567
880
  }
568
- olmdb.del(originalKey);
569
- }
570
-
571
- if (!newKey) {
572
- // No new key, nothing to do
573
- return;
881
+ dbDel(txn.id, key);
574
882
  }
883
+ }
575
884
 
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');
885
+ _write(txn: Transaction, primaryKey: Uint8Array, data: Record<string, any>) {
886
+ if (!this._hasNullIndexValues(data)) {
887
+ const key = this._serializeKey(primaryKey, data);
888
+ if (logLevel >= 2) {
889
+ console.log(`[edinburgh] Write ${this} key=${key} value=${new DataPack(primaryKey)}`);
890
+ }
891
+ if (dbGet(txn.id, key)) {
892
+ throw new DatabaseError(`Unique constraint violation for ${this} key ${key}`, 'UNIQUE_CONSTRAINT');
893
+ }
894
+ dbPut(txn.id, key, primaryKey);
579
895
  }
580
-
581
- let linkKey = (model.constructor as any)._pk!._getKeyFromModel(model, false);
582
- olmdb.put(newKey, linkKey);
896
+ }
583
897
 
584
- if (logLevel >= 2) {
585
- console.log(`Saved unique index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with key ${newKey}`);
898
+ _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, valueBuffer: ArrayBuffer): InstanceType<M> {
899
+ // For unique indexes, the value contains the primary key
900
+
901
+ const keyPack = new DataPack(new Uint8Array(keyBuffer));
902
+ keyPack.readNumber(); // discard index id
903
+
904
+ const pk = this._MyModel._primary!;
905
+ const model = pk._get(txn, new Uint8Array(valueBuffer), false);
906
+
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
912
+ for(const [name, fieldType] of this._fieldTypes.entries()) {
913
+ model._setLoadedField(name, fieldType.deserialize(keyPack));
586
914
  }
587
-
588
- return newKey;
915
+
916
+ return model;
589
917
  }
590
918
 
591
919
  _getTypeName(): string {
@@ -594,7 +922,7 @@ export class UniqueIndex<M extends typeof Model, const F extends readonly (keyof
594
922
  }
595
923
 
596
924
  // 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
925
+ const SECONDARY_VALUE = new DataPack().write(undefined).toUint8Array(); // Single byte value for secondary indexes
598
926
 
599
927
  /**
600
928
  * Secondary index for non-unique lookups.
@@ -603,81 +931,64 @@ const SECONDARY_VALUE = new Uint8Array([1]); // Single byte value for secondary
603
931
  * @template F - The field names that make up this index.
604
932
  */
605
933
  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
934
 
615
- let newKey = this._getKeyFromModel(model, true);
935
+ constructor(MyModel: M, fieldNames: F) {
936
+ super(MyModel, fieldNames);
937
+ (this._MyModel._secondaries ||= []).push(this);
938
+ scheduleInit();
939
+ }
616
940
 
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
- }
941
+ _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, _valueBuffer: ArrayBuffer): InstanceType<M> {
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
624
946
 
625
- if (!newKey) {
626
- // No new key, nothing to do (index should be skipped)
627
- return;
947
+ // Read the index fields, saving them for later
948
+ const indexFields = new Map();
949
+ for(const [name, type] of this._fieldTypes.entries()) {
950
+ indexFields.set(name, type.deserialize(keyPack));
628
951
  }
629
952
 
630
- // For secondary indexes, we store a single byte value
631
- olmdb.put(newKey, SECONDARY_VALUE);
953
+ const primaryKey = keyPack.readUint8Array();
954
+ const model = this._MyModel._primary!._get(txn, primaryKey, false);
632
955
 
633
- if (logLevel >= 2) {
634
- console.log(`Saved secondary index ${this._MyModel.tableName}[${this._fieldNames.join(', ')}] with key ${newKey}`);
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);
960
+
961
+ // Set the values for our indexed fields
962
+ for(const [name, value] of indexFields) {
963
+ model._setLoadedField(name, value);
635
964
  }
636
965
 
637
- return newKey;
966
+ return model;
638
967
  }
639
968
 
640
- /**
641
- * Extract model from iterator entry for secondary index.
642
- * @param keyBytes - Key bytes with index ID already read.
643
- * @param valueBytes - Value bytes from the entry.
644
- * @returns Model instance or undefined.
645
- * @internal
646
- */
647
- _getModelFromEntry(keyBytes: Bytes, valueBytes: Bytes): InstanceType<M> | undefined {
648
- // For secondary indexes, the primary key is stored after the index fields in the key
649
-
650
- // First skip past the index fields
651
- const temp = [] as any[];
652
- for (let i = 0; i < this._fieldNames.length; i++) {
653
- const fieldName = this._fieldNames[i];
654
- const fieldConfig = this._MyModel.fields[fieldName];
655
- fieldConfig.type.deserialize(temp, 0, keyBytes);
969
+ _serializeKey(primaryKey: Uint8Array, model: InstanceType<M>): Uint8Array {
970
+ // index id + index fields + primary key
971
+ const bytes = super._serializeKeyFields(model);
972
+ bytes.write(primaryKey);
973
+ return bytes.toUint8Array();
974
+ }
975
+
976
+ _write(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>) {
977
+ if (this._hasNullIndexValues(model)) return;
978
+ const key = this._serializeKey(primaryKey, model);
979
+ if (logLevel >= 2) {
980
+ console.log(`[edinburgh] Write ${this} key=${key}`);
656
981
  }
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);
982
+ dbPut(txn.id, key, SECONDARY_VALUE);
662
983
  }
663
984
 
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();
985
+ _delete(txn: Transaction, primaryKey: Uint8Array, model: InstanceType<M>): void {
986
+ if (this._hasNullIndexValues(model)) return;
987
+ const key = this._serializeKey(primaryKey, model);
988
+ if (logLevel >= 2) {
989
+ console.log(`[edinburgh] Delete ${this} key=${key}`);
990
+ }
991
+ dbDel(txn.id, key);
681
992
  }
682
993
 
683
994
  _getTypeName(): string {
@@ -771,40 +1082,71 @@ export function index(MyModel: typeof Model, fields: any): SecondaryIndex<any, a
771
1082
  * This is primarily useful for development and debugging purposes.
772
1083
  */
773
1084
  export function dump() {
1085
+ const txn = currentTxn();
774
1086
  let indexesById = new Map<number, {name: string, type: string, fields: Record<string, TypeWrapper<any>>}>();
775
- console.log("--- Database dump ---")
776
- for(const {key,value} of olmdb.scan()) {
777
- const kb = new Bytes(key);
778
- const vb = new Bytes(value);
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));
779
1096
  const indexId = kb.readNumber();
780
1097
  if (indexId === MAX_INDEX_ID_PREFIX) {
781
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(',')}]`);
782
1110
  } else if (indexId === INDEX_ID_PREFIX) {
783
1111
  const name = kb.readString();
784
1112
  const type = kb.readString();
785
1113
  const fields: Record<string, TypeWrapper<any>> = {};
786
1114
  while(kb.readAvailable()) {
787
- 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)
788
1117
  fields[name] = deserializeType(kb, 0);
789
1118
  }
790
- const fieldDescription = Object.entries(fields).map(([name, type]) => `${name}:${type}`);
1119
+
791
1120
  const indexId = vb.readNumber();
792
- console.log(`* Definition for ${type} ${indexId} for ${name}[${fieldDescription.join(',')}]`);
1121
+ console.log(`* Index definition ${indexId}:${name}:${type}[${Object.keys(fields).join(',')}]`);
793
1122
  indexesById.set(indexId, {name, type, fields});
794
1123
  } else if (indexId > 0 && indexesById.has(indexId)) {
795
1124
  const index = indexesById.get(indexId)!;
796
- const {name, type, fields} = index;
797
- const rowKey: any = {};
798
- for(const [fieldName, fieldType] of Object.entries(fields)) {
799
- fieldType.deserialize(rowKey, fieldName, kb);
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
+ }
800
1144
  }
801
- const Model = modelRegistry[name]!;
802
- // TODO: once we're storing schemas (serializeType) in the db, we can deserialize here
803
- let displayValue = (type === 'secondary') ? Model._pk!._deserializeKey(kb) : vb;
804
- console.log(`* Row for ${type} ${indexId} with key ${JSON.stringify(rowKey)}`, displayValue);
1145
+ console.log(`* Row for ${indexId}:${name}:${type}`, rowKey ?? kb, rowValue ?? vb);
805
1146
  } else {
806
- console.log(`* Unhandled ${indexId} index key=${kb} value=${vb}`);
1147
+ console.log(`* Unhandled '${indexId}' key=${kb} value=${vb}`);
807
1148
  }
808
1149
  }
809
- console.log("--- End of database dump ---")
1150
+ } finally { lowlevel.closeIterator(iteratorId); }
1151
+ console.log("--- end ---")
810
1152
  }