edinburgh 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +322 -262
  2. package/build/src/datapack.d.ts +9 -9
  3. package/build/src/datapack.js +9 -9
  4. package/build/src/edinburgh.d.ts +18 -7
  5. package/build/src/edinburgh.js +30 -51
  6. package/build/src/edinburgh.js.map +1 -1
  7. package/build/src/indexes.d.ts +85 -205
  8. package/build/src/indexes.js +150 -503
  9. package/build/src/indexes.js.map +1 -1
  10. package/build/src/migrate.js +8 -10
  11. package/build/src/migrate.js.map +1 -1
  12. package/build/src/models.d.ts +152 -107
  13. package/build/src/models.js +433 -144
  14. package/build/src/models.js.map +1 -1
  15. package/build/src/types.d.ts +30 -48
  16. package/build/src/types.js +25 -24
  17. package/build/src/types.js.map +1 -1
  18. package/build/src/utils.d.ts +4 -4
  19. package/build/src/utils.js +4 -4
  20. package/package.json +1 -1
  21. package/skill/AnyModelClass.md +7 -0
  22. package/skill/FindOptions.md +37 -0
  23. package/skill/Lifecycle Hooks.md +24 -0
  24. package/skill/{Model_delete.md → Lifecycle Hooks_delete.md } +1 -1
  25. package/skill/{Model_getPrimaryKeyHash.md → Lifecycle Hooks_getPrimaryKeyHash.md } +1 -1
  26. package/skill/{Model_isValid.md → Lifecycle Hooks_isValid.md } +1 -1
  27. package/skill/Lifecycle Hooks_migrate.md +26 -0
  28. package/skill/{Model_preCommit.md → Lifecycle Hooks_preCommit.md } +2 -2
  29. package/skill/{Model_preventPersist.md → Lifecycle Hooks_preventPersist.md } +1 -1
  30. package/skill/{Model_validate.md → Lifecycle Hooks_validate.md } +2 -2
  31. package/skill/ModelBase.md +7 -0
  32. package/skill/ModelClass.md +8 -0
  33. package/skill/SKILL.md +180 -132
  34. package/skill/Schema Evolution.md +19 -0
  35. package/skill/TypeWrapper_containsNull.md +11 -0
  36. package/skill/TypeWrapper_deserialize.md +9 -0
  37. package/skill/TypeWrapper_getError.md +11 -0
  38. package/skill/TypeWrapper_serialize.md +10 -0
  39. package/skill/TypeWrapper_serializeType.md +9 -0
  40. package/skill/array.md +2 -2
  41. package/skill/defineModel.md +3 -2
  42. package/skill/deleteEverything.md +8 -0
  43. package/skill/field.md +3 -3
  44. package/skill/link.md +3 -3
  45. package/skill/literal.md +1 -1
  46. package/skill/opt.md +1 -1
  47. package/skill/or.md +1 -1
  48. package/skill/record.md +1 -1
  49. package/skill/set.md +2 -2
  50. package/skill/setOnSaveCallback.md +2 -2
  51. package/skill/transact.md +1 -1
  52. package/src/datapack.ts +9 -9
  53. package/src/edinburgh.ts +43 -52
  54. package/src/indexes.ts +251 -599
  55. package/src/migrate.ts +9 -10
  56. package/src/models.ts +528 -231
  57. package/src/types.ts +36 -34
  58. package/src/utils.ts +4 -4
  59. package/skill/BaseIndex.md +0 -16
  60. package/skill/BaseIndex_batchProcess.md +0 -10
  61. package/skill/BaseIndex_find.md +0 -7
  62. package/skill/BaseIndex_find_2.md +0 -7
  63. package/skill/BaseIndex_find_3.md +0 -7
  64. package/skill/BaseIndex_find_4.md +0 -7
  65. package/skill/Model.md +0 -20
  66. package/skill/Model_batchProcess.md +0 -8
  67. package/skill/Model_migrate.md +0 -32
  68. package/skill/Model_replaceInto.md +0 -16
  69. package/skill/NonPrimaryIndex.md +0 -10
  70. package/skill/SecondaryIndex.md +0 -9
  71. package/skill/UniqueIndex.md +0 -9
  72. package/skill/dump.md +0 -8
@@ -1,8 +1,7 @@
1
1
  import * as lowlevel from "olmdb/lowlevel";
2
2
  import { DatabaseError } from "olmdb/lowlevel";
3
3
  import DataPack from "./datapack.js";
4
- import { currentTxn } from "./models.js";
5
- import { scheduleInit, transact } from "./edinburgh.js";
4
+ import { currentTxn, transact } from "./edinburgh.js";
6
5
  import { assert, logLevel, dbGet, dbPut, dbDel, hashBytes, hashFunction, bytesEqual, toBuffer } from "./utils.js";
7
6
  import { deserializeType, serializeType } from "./types.js";
8
7
  const MAX_INDEX_ID_PREFIX = -1;
@@ -17,17 +16,13 @@ const MAX_INDEX_ID_BUFFER = new DataPack().write(MAX_INDEX_ID_PREFIX).toUint8Arr
17
16
  export class IndexRangeIterator extends Iterator {
18
17
  txn;
19
18
  iteratorId;
20
- indexId;
21
19
  parentIndex;
22
- constructor(txn, iteratorId, indexId, parentIndex) {
20
+ constructor(txn, iteratorId, parentIndex) {
23
21
  super();
24
22
  this.txn = txn;
25
23
  this.iteratorId = iteratorId;
26
- this.indexId = indexId;
27
24
  this.parentIndex = parentIndex;
28
25
  }
29
- // This is also in Iterator<InstanceType<M>>, but we'll repeat it here for deps that
30
- // don't have ESNext.Iterator in their TypeScript lib set.
31
26
  [Symbol.iterator]() { return this; }
32
27
  next() {
33
28
  if (this.iteratorId < 0)
@@ -38,7 +33,6 @@ export class IndexRangeIterator extends Iterator {
38
33
  this.iteratorId = -1;
39
34
  return { done: true, value: undefined };
40
35
  }
41
- // Dispatches to the _pairToInstance specific to the index type
42
36
  const model = this.parentIndex._pairToInstance(this.txn, raw.key, raw.value);
43
37
  return { done: false, value: model };
44
38
  }
@@ -50,7 +44,7 @@ export class IndexRangeIterator extends Iterator {
50
44
  }
51
45
  fetch() {
52
46
  for (const model of this) {
53
- return model; // Return the first model found
47
+ return model;
54
48
  }
55
49
  }
56
50
  }
@@ -58,53 +52,50 @@ export class IndexRangeIterator extends Iterator {
58
52
  * Base class for database indexes for efficient lookups on model fields.
59
53
  *
60
54
  * Indexes enable fast queries on specific field combinations and enforce uniqueness constraints.
61
- *
62
- * @template M - The model class this index belongs to.
63
- * @template F - The field names that make up this index.
64
55
  */
65
56
  export class BaseIndex {
66
- _fieldNames;
67
- _MyModel;
68
- _fieldTypes = new Map();
69
- _fieldCount;
57
+ tableName;
58
+ _indexFields = new Map();
70
59
  _computeFn;
71
- /**
72
- * Create a new index.
73
- * @param MyModel - The model class this index belongs to.
74
- * @param _fieldNames - Array of field names that make up this index.
75
- */
76
- constructor(MyModel, _fieldNames) {
77
- this._fieldNames = _fieldNames;
78
- this._MyModel = MyModel;
60
+ _indexId;
61
+ _signature;
62
+ constructor(tableName, fieldNames) {
63
+ this.tableName = tableName;
64
+ this._indexFields = new Map(fieldNames.map(fieldName => [fieldName, undefined]));
79
65
  }
80
- async _delayedInit() {
81
- if (this._indexId != null)
82
- return; // Already initialized
66
+ async _initializeIndex(fields, reset = false, primaryFieldTypes) {
67
+ const fieldNames = [...this._indexFields.keys()];
68
+ if (reset) {
69
+ this._indexId = undefined;
70
+ this._signature = undefined;
71
+ }
72
+ else if (this._indexId != null) {
73
+ return;
74
+ }
83
75
  if (this._computeFn) {
84
- this._fieldCount = 1;
76
+ this._indexFields = new Map();
85
77
  }
86
78
  else {
87
- for (const fieldName of this._fieldNames) {
79
+ this._indexFields = new Map();
80
+ for (const fieldName of fieldNames) {
88
81
  assert(typeof fieldName === 'string', 'Field names must be strings');
89
- this._fieldTypes.set(fieldName, this._MyModel.fields[fieldName].type);
82
+ const fieldType = fields.get(fieldName);
83
+ assert(fieldType, `Unknown field '${fieldName}' in ${this}`);
84
+ this._indexFields.set(fieldName, fieldType);
90
85
  }
91
- this._fieldCount = this._fieldNames.length;
92
86
  }
93
- await this._retrieveIndexId();
94
- // Human-readable signature for version tracking, e.g. "secondary category:string"
87
+ await this._retrieveIndexId(fields, primaryFieldTypes);
95
88
  if (this._computeFn) {
96
89
  this._signature = this._getTypeName() + ' ' + hashFunction(this._computeFn);
97
90
  }
98
91
  else {
99
92
  this._signature = this._getTypeName() + ' ' +
100
- Array.from(this._fieldTypes.entries()).map(([n, t]) => n + ':' + t).join(' ');
93
+ Array.from(this._indexFields.entries()).map(([name, fieldType]) => name + ':' + fieldType).join(' ');
101
94
  }
102
95
  }
103
- _indexId;
104
- /** Human-readable signature for version tracking, e.g. "secondary category:string" */
105
- _signature;
106
96
  _argsToKeyBytes(args, allowPartial) {
107
- assert(allowPartial ? args.length <= this._fieldCount : args.length === this._fieldCount);
97
+ const expectedCount = this._computeFn ? 1 : this._indexFields.size;
98
+ assert(allowPartial ? args.length <= expectedCount : args.length === expectedCount);
108
99
  const bytes = new DataPack();
109
100
  bytes.write(this._indexId);
110
101
  if (this._computeFn) {
@@ -113,8 +104,7 @@ export class BaseIndex {
113
104
  }
114
105
  else {
115
106
  let index = 0;
116
- for (const fieldType of this._fieldTypes.values()) {
117
- // For partial keys, undefined values are acceptable and represent open range suffixes
107
+ for (const fieldType of this._indexFields.values()) {
118
108
  if (index >= args.length)
119
109
  break;
120
110
  fieldType.serialize(args[index++], bytes);
@@ -122,28 +112,22 @@ export class BaseIndex {
122
112
  }
123
113
  return bytes;
124
114
  }
125
- /**
126
- * Retrieve (or create) a stable index ID from the DB, with retry on transaction races.
127
- * Sets `this._indexId` on success.
128
- */
129
- async _retrieveIndexId() {
130
- const indexNameBytes = new DataPack().write(INDEX_ID_PREFIX).write(this._MyModel.tableName).write(this._getTypeName());
115
+ async _retrieveIndexId(fields, primaryFieldTypes) {
116
+ const indexNameBytes = new DataPack().write(INDEX_ID_PREFIX).write(this.tableName).write(this._getTypeName());
131
117
  if (this._computeFn) {
132
118
  indexNameBytes.write(hashFunction(this._computeFn));
133
119
  }
134
120
  else {
135
- for (let name of this._fieldNames) {
121
+ for (const name of this._indexFields.keys()) {
136
122
  indexNameBytes.write(name);
137
- serializeType(this._MyModel.fields[name].type, indexNameBytes);
123
+ serializeType(fields.get(name), indexNameBytes);
138
124
  }
139
125
  }
140
- // For non-primary indexes, include primary key field info to avoid misinterpreting
141
- // values when the primary key schema changes.
142
- if (this._MyModel._primary !== this) {
143
- indexNameBytes.write(undefined); // separator
144
- for (const name of this._MyModel._primary._fieldNames) {
126
+ if (primaryFieldTypes) {
127
+ indexNameBytes.write(undefined);
128
+ for (const [name, fieldType] of primaryFieldTypes.entries()) {
145
129
  indexNameBytes.write(name);
146
- serializeType(this._MyModel.fields[name].type, indexNameBytes);
130
+ serializeType(fieldType, indexNameBytes);
147
131
  }
148
132
  }
149
133
  const indexNameBuf = indexNameBytes.toUint8Array();
@@ -180,64 +164,6 @@ export class BaseIndex {
180
164
  }
181
165
  }
182
166
  }
183
- /**
184
- * Find model instances using flexible range query options.
185
- *
186
- * Supports exact matches, inclusive/exclusive range queries, and reverse iteration.
187
- * For single-field indexes, you can pass values directly or in arrays.
188
- * For multi-field indexes, pass arrays or partial arrays for prefix matching.
189
- *
190
- * @param opts - Query options object
191
- * @param opts.is - Exact match (sets both `from` and `to` to same value)
192
- * @param opts.from - Range start (inclusive)
193
- * @param opts.after - Range start (exclusive)
194
- * @param opts.to - Range end (inclusive)
195
- * @param opts.before - Range end (exclusive)
196
- * @param opts.reverse - Whether to iterate in reverse order
197
- * @returns An iterable of model instances matching the query
198
- *
199
- * @example
200
- * ```typescript
201
- * // Exact match
202
- * for (const user of User.byEmail.find({is: "john@example.com"})) {
203
- * console.log(user.name);
204
- * }
205
- *
206
- * // Range query (inclusive)
207
- * for (const user of User.byEmail.find({from: "a@", to: "m@"})) {
208
- * console.log(user.email);
209
- * }
210
- *
211
- * // Range query (exclusive)
212
- * for (const user of User.byEmail.find({after: "a@", before: "m@"})) {
213
- * console.log(user.email);
214
- * }
215
- *
216
- * // Open-ended ranges
217
- * for (const user of User.byEmail.find({from: "m@"})) { // m@ and later
218
- * console.log(user.email);
219
- * }
220
- *
221
- * for (const user of User.byEmail.find({to: "m@"})) { // up to and including m@
222
- * console.log(user.email);
223
- * }
224
- *
225
- * // Reverse iteration
226
- * for (const user of User.byEmail.find({reverse: true})) {
227
- * console.log(user.email); // Z to A order
228
- * }
229
- *
230
- * // Multi-field index prefix matching
231
- * for (const item of CompositeModel.find({from: ["electronics", "phones"]})) {
232
- * console.log(item.name); // All electronics/phones items
233
- * }
234
- *
235
- * // For single-field indexes, you can use the value directly
236
- * for (const user of User.byEmail.find({is: "john@example.com"})) {
237
- * console.log(user.name);
238
- * }
239
- * ```
240
- */
241
167
  _computeKeyBounds(opts) {
242
168
  let startKey;
243
169
  let endKey;
@@ -271,17 +197,15 @@ export class BaseIndex {
271
197
  }
272
198
  find(opts = {}) {
273
199
  const txn = currentTxn();
274
- const indexId = this._indexId;
275
200
  const bounds = this._computeKeyBounds(opts);
276
201
  if (!bounds) {
277
202
  if (opts.fetch === 'single')
278
203
  throw new DatabaseError('Expected exactly one result, got none', 'NOT_FOUND');
279
204
  if (opts.fetch === 'first')
280
205
  return undefined;
281
- return new IndexRangeIterator(txn, -1, indexId, this);
206
+ return new IndexRangeIterator(txn, -1, this);
282
207
  }
283
208
  const [startKey, endKey] = bounds;
284
- // For reverse scans, swap start/end keys since OLMDB expects it
285
209
  const scanStart = opts.reverse ? endKey : startKey;
286
210
  const scanEnd = opts.reverse ? startKey : endKey;
287
211
  if (logLevel >= 3) {
@@ -290,7 +214,7 @@ export class BaseIndex {
290
214
  const startBuf = scanStart?.toUint8Array();
291
215
  const endBuf = scanEnd?.toUint8Array();
292
216
  const iteratorId = lowlevel.createIterator(txn.id, startBuf ? toBuffer(startBuf) : undefined, endBuf ? toBuffer(endBuf) : undefined, opts.reverse || false);
293
- const iter = new IndexRangeIterator(txn, iteratorId, indexId, this);
217
+ const iter = new IndexRangeIterator(txn, iteratorId, this);
294
218
  if (opts.fetch === 'first')
295
219
  return iter.fetch();
296
220
  if (opts.fetch === 'single') {
@@ -304,15 +228,13 @@ export class BaseIndex {
304
228
  return iter;
305
229
  }
306
230
  /**
307
- * Process all matching rows in batched transactions.
231
+ * Process matching rows in batched transactions.
308
232
  *
309
- * Uses the same query options as {@link find}. The batch is committed and a new
310
- * transaction started once either `limitSeconds` or `limitRows` is exceeded.
233
+ * Uses the same range options as {@link find}, plus optional row and time
234
+ * limits that control when the current transaction is committed and a new one starts.
311
235
  *
312
- * @param opts - Query options (same as `find()`), plus:
313
- * @param opts.limitSeconds - Max seconds per transaction batch (default: 1)
314
- * @param opts.limitRows - Max rows per transaction batch (default: 4096)
315
- * @param callback - Called for each matching row within a transaction
236
+ * @param opts Query options plus batch limits.
237
+ * @param callback Called for each matching row inside a transaction.
316
238
  */
317
239
  async batchProcess(opts = {}, callback) {
318
240
  const limitMs = (opts.limitSeconds ?? 1) * 1000;
@@ -349,11 +271,11 @@ export class BaseIndex {
349
271
  finally {
350
272
  lowlevel.closeIterator(iteratorId);
351
273
  }
352
- lastRawKey = lastRawKey.slice(); // Copy, as lastRawKey points at OLMDB's internal read-only mmap
274
+ lastRawKey = lastRawKey.slice();
353
275
  if (reverse)
354
276
  return lastRawKey;
355
- const nk = new DataPack(lastRawKey);
356
- return nk.increment() ? nk.toUint8Array() : null;
277
+ const nextKey = new DataPack(lastRawKey);
278
+ return nextKey.increment() ? nextKey.toUint8Array() : null;
357
279
  });
358
280
  if (next === null)
359
281
  break;
@@ -361,99 +283,30 @@ export class BaseIndex {
361
283
  }
362
284
  }
363
285
  toString() {
364
- return `${this._indexId}:${this._MyModel.tableName}:${this._getTypeName()}[${Array.from(this._fieldTypes.keys()).join(',')}]`;
286
+ return `${this._indexId}:${this.tableName}:${this._getTypeName()}[${Array.from(this._indexFields.keys()).join(',')}]`;
365
287
  }
366
288
  }
367
- function toArray(args) {
368
- // Convert single value or array to array format compatible with Partial<ARG_TYPES>
369
- return (Array.isArray(args) ? args : [args]);
370
- }
371
- /**
372
- * Primary index that stores the actual model data.
373
- *
374
- * @template M - The model class this index belongs to.
375
- * @template F - The field names that make up this index.
376
- */
377
- export class PrimaryIndex extends BaseIndex {
378
- _nonKeyFields;
379
- _lazyDescriptors = {};
380
- _resetDescriptors = {};
381
- _freezePrimaryKeyDescriptors = {};
382
- /** Current version number for this primary index's value format. */
383
- _currentVersion;
384
- /** Hash of the current migrate() function source, or 0 if none. */
385
- _currentMigrateHash;
386
- /** Cached version info for old versions (loaded on demand). */
387
- _versions = new Map();
388
- constructor(MyModel, fieldNames) {
389
- super(MyModel, fieldNames);
390
- if (MyModel._primary) {
391
- 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');
392
- }
393
- MyModel._primary = this;
394
- }
395
- async _delayedInit() {
396
- if (this._indexId != null)
397
- return; // Already initialized
398
- await super._delayedInit();
399
- const MyModel = this._MyModel;
400
- this._nonKeyFields = Object.keys(MyModel.fields).filter(fieldName => !this._fieldNames.includes(fieldName));
401
- for (const fieldName of this._nonKeyFields) {
402
- this._lazyDescriptors[fieldName] = {
403
- configurable: true,
404
- enumerable: true,
405
- get() {
406
- this.constructor._primary._lazyNow(this);
407
- return this[fieldName];
408
- },
409
- set(value) {
410
- this.constructor._primary._lazyNow(this);
411
- this[fieldName] = value;
412
- }
413
- };
414
- this._resetDescriptors[fieldName] = {
415
- writable: true,
416
- enumerable: true
417
- };
418
- }
419
- for (const fieldName of this._fieldNames) {
420
- this._freezePrimaryKeyDescriptors[fieldName] = {
421
- writable: false,
422
- enumerable: true
423
- };
424
- }
289
+ export class PrimaryKey extends BaseIndex {
290
+ _getTypeName() {
291
+ return 'primary';
425
292
  }
426
- /** Serialize the current version fingerprint as a DataPack object. */
427
- _serializeVersionValue() {
428
- const fields = [];
429
- for (const fieldName of this._nonKeyFields) {
430
- const tp = new DataPack();
431
- serializeType(this._MyModel.fields[fieldName].type, tp);
432
- fields.push([fieldName, tp.toUint8Array()]);
433
- }
434
- return new DataPack().write({
435
- migrateHash: this._currentMigrateHash,
436
- fields,
437
- secondaryKeys: new Set((this._MyModel._secondaries || []).map(sec => sec._signature)),
438
- }).toUint8Array();
439
- }
440
- /** Look up or create the current version number for this primary index. */
441
- async _initVersioning() {
442
- // Compute migrate hash from function source
443
- const migrateFn = this._MyModel._original?.migrate ?? this._MyModel.migrate;
444
- this._currentMigrateHash = migrateFn ? hashFunction(migrateFn) : 0;
445
- const currentValueBytes = this._serializeVersionValue();
446
- // Scan last 20 version info rows for this primary index
293
+ _versionInfoKey(version) {
294
+ return new DataPack()
295
+ .write(VERSION_INFO_PREFIX)
296
+ .write(this._indexId)
297
+ .write(version)
298
+ .toUint8Array();
299
+ }
300
+ async _ensureVersionEntry(currentValueBytes) {
447
301
  const scanStart = new DataPack().write(VERSION_INFO_PREFIX).write(this._indexId);
448
302
  const scanEnd = scanStart.clone(true).increment();
449
303
  while (true) {
450
304
  const txnId = lowlevel.startTransaction();
451
305
  try {
452
- const iteratorId = lowlevel.createIterator(txnId, scanEnd ? toBuffer(scanEnd.toUint8Array()) : undefined, toBuffer(scanStart.toUint8Array()), true // reverse - scan newest versions first
453
- );
306
+ const iteratorId = lowlevel.createIterator(txnId, scanEnd ? toBuffer(scanEnd.toUint8Array()) : undefined, toBuffer(scanStart.toUint8Array()), true);
454
307
  let count = 0;
455
308
  let maxVersion = 0;
456
- let found = false;
309
+ let matchingVersion;
457
310
  try {
458
311
  while (count < 20) {
459
312
  const raw = lowlevel.readIterator(iteratorId);
@@ -461,14 +314,13 @@ export class PrimaryIndex extends BaseIndex {
461
314
  break;
462
315
  count++;
463
316
  const keyPack = new DataPack(new Uint8Array(raw.key));
464
- keyPack.readNumber(); // skip VERSION_INFO_PREFIX
465
- keyPack.readNumber(); // skip indexId
317
+ keyPack.readNumber();
318
+ keyPack.readNumber();
466
319
  const versionNum = keyPack.readNumber();
467
320
  maxVersion = Math.max(maxVersion, versionNum);
468
321
  const valueBytes = new Uint8Array(raw.value);
469
322
  if (bytesEqual(valueBytes, currentValueBytes)) {
470
- this._currentVersion = versionNum;
471
- found = true;
323
+ matchingVersion = versionNum;
472
324
  break;
473
325
  }
474
326
  }
@@ -476,25 +328,18 @@ export class PrimaryIndex extends BaseIndex {
476
328
  finally {
477
329
  lowlevel.closeIterator(iteratorId);
478
330
  }
479
- if (found) {
331
+ if (matchingVersion !== undefined) {
480
332
  lowlevel.abortTransaction(txnId);
481
- return;
333
+ return { version: matchingVersion, created: false };
482
334
  }
483
- // No match found - create new version
484
- this._currentVersion = maxVersion + 1;
485
- const versionKey = new DataPack()
486
- .write(VERSION_INFO_PREFIX)
487
- .write(this._indexId)
488
- .write(this._currentVersion)
489
- .toUint8Array();
490
- dbPut(txnId, versionKey, currentValueBytes);
335
+ const version = maxVersion + 1;
336
+ dbPut(txnId, this._versionInfoKey(version), currentValueBytes);
491
337
  if (logLevel >= 1)
492
- console.log(`[edinburgh] Create version ${this._currentVersion} for ${this}`);
338
+ console.log(`[edinburgh] Create version ${version} for ${this}`);
493
339
  const commitResult = lowlevel.commitTransaction(txnId);
494
340
  const commitSeq = typeof commitResult === 'number' ? commitResult : await commitResult;
495
341
  if (commitSeq > 0)
496
- return;
497
- // Race - retry
342
+ return { version, created: true };
498
343
  }
499
344
  catch (e) {
500
345
  try {
@@ -505,247 +350,63 @@ export class PrimaryIndex extends BaseIndex {
505
350
  }
506
351
  }
507
352
  }
508
- /**
509
- * Get a model instance by primary key values.
510
- * @param args - The primary key values.
511
- * @returns The model instance if found, undefined otherwise.
512
- *
513
- * @example
514
- * ```typescript
515
- * const user = User.get("john_doe");
516
- * ```
517
- */
518
- get(...args) {
519
- return this._get(currentTxn(), args, true);
520
- }
521
- /**
522
- * Does the same as as `get()`, but will delay loading the instance from disk until the first
523
- * property access. In case it turns out the instance doesn't exist, an error will be thrown
524
- * at that time.
525
- * @param args Primary key field values. (Or a single Uint8Array containing the key.)
526
- * @returns The (lazily loaded) model instance.
527
- */
528
- getLazy(...args) {
529
- return this._get(currentTxn(), args, false);
530
- }
531
- _get(txn, args, loadNow) {
532
- let key, keyParts;
533
- if (args instanceof Uint8Array) {
534
- key = args;
535
- }
536
- else {
537
- key = this._argsToKeyBytes(args, false).toUint8Array();
538
- keyParts = args;
539
- }
540
- const keyHash = hashBytes(key);
541
- const cached = txn.instancesByPk.get(keyHash);
542
- if (cached) {
543
- if (loadNow && loadNow !== true) {
544
- // The object already exists, but it may still be lazy-loaded
545
- Object.defineProperties(cached, this._resetDescriptors);
546
- this._setNonKeyValues(cached, loadNow);
547
- }
548
- return cached;
549
- }
550
- let valueBuffer;
551
- if (loadNow) {
552
- if (loadNow === true) {
553
- valueBuffer = dbGet(txn.id, key);
554
- if (logLevel >= 3) {
555
- console.log(`[edinburgh] Get ${this} key=${new DataPack(key)} result=${valueBuffer && new DataPack(valueBuffer)}`);
556
- }
557
- if (!valueBuffer)
558
- return;
559
- }
560
- else {
561
- valueBuffer = loadNow; // Uint8Array
562
- }
563
- }
564
- // This is a primary index. So we can now deserialize all primary and non-primary fields into instance values.
565
- const model = new this._MyModel(undefined, txn);
566
- // Set to the original value for all fields that are loaded by _setLoadedField
567
- model._oldValues = {};
568
- // Set the primary key fields on the model
569
- if (keyParts) {
570
- let index = 0;
571
- for (const fieldName of this._fieldTypes.keys()) {
572
- model._setLoadedField(fieldName, keyParts[index++]);
573
- }
574
- }
575
- else {
576
- const bytes = new DataPack(key);
577
- assert(bytes.readNumber() === this._MyModel._primary._indexId); // Skip index id
578
- for (const [fieldName, fieldType] of this._fieldTypes.entries()) {
579
- model._setLoadedField(fieldName, fieldType.deserialize(bytes));
580
- }
581
- }
582
- // Store the primary key on the model, set the hash, and freeze the primary key fields.
583
- model._setPrimaryKey(key, keyHash);
584
- if (valueBuffer) {
585
- // Non-lazy load. Set other fields
586
- this._setNonKeyValues(model, valueBuffer);
587
- }
588
- else {
589
- // Lazy - set getters for other fields
590
- Object.defineProperties(model, this._lazyDescriptors);
591
- // When creating a lazy instance, we don't need to add it to txn.instances yet, as only the
592
- // primary key fields are loaded, and they cannot be modified (so we don't need to check).
593
- // When any other field is set, that will trigger a lazy-load, adding the instance to
594
- // txn.instances.
595
- }
596
- txn.instancesByPk.set(keyHash, model);
597
- return model;
598
- }
599
- /**
600
- * Serialize primary key bytes from field values: indexId + typed field values.
601
- */
602
- _serializeKey(data) {
353
+ _serializePK(data) {
603
354
  const bytes = new DataPack();
604
355
  bytes.write(this._indexId);
605
- for (const [fieldName, fieldType] of this._fieldTypes.entries()) {
356
+ for (const [fieldName, fieldType] of this._indexFields.entries()) {
606
357
  fieldType.serialize(data[fieldName], bytes);
607
358
  }
608
359
  return bytes;
609
360
  }
610
- _lazyNow(model) {
611
- let valueBuffer = dbGet(model._txn.id, model._primaryKey);
612
- if (logLevel >= 3) {
613
- console.log(`[edinburgh] Lazy retrieve ${this} key=${new DataPack(model._primaryKey)} result=${valueBuffer && new DataPack(valueBuffer)}`);
614
- }
615
- if (!valueBuffer)
616
- throw new DatabaseError(`Lazy-loaded ${model.constructor.name}#${model._primaryKey} does not exist`, 'LAZY_FAIL');
617
- Object.defineProperties(model, this._resetDescriptors);
618
- this._setNonKeyValues(model, valueBuffer);
619
- }
620
- _setNonKeyValues(model, valueArray) {
621
- const fieldConfigs = this._MyModel.fields;
622
- const valuePack = new DataPack(valueArray);
623
- const version = valuePack.readNumber();
624
- if (version === this._currentVersion) {
625
- for (const fieldName of this._nonKeyFields) {
626
- model._setLoadedField(fieldName, fieldConfigs[fieldName].type.deserialize(valuePack));
627
- }
628
- }
629
- else {
630
- this._migrateFromVersion(model, version, valuePack);
631
- }
632
- }
633
- /** Load a version's info from DB, caching the result. */
634
- _loadVersionInfo(txnId, version) {
635
- let info = this._versions.get(version);
636
- if (info)
637
- return info;
638
- const key = new DataPack()
639
- .write(VERSION_INFO_PREFIX)
640
- .write(this._indexId)
641
- .write(version)
642
- .toUint8Array();
643
- const raw = dbGet(txnId, key);
644
- if (!raw)
645
- throw new DatabaseError(`Version ${version} info not found for index ${this}`, 'CONSISTENCY_ERROR');
646
- const obj = new DataPack(raw).read();
647
- if (!obj || typeof obj.migrateHash !== 'number' || !Array.isArray(obj.fields) || !(obj.secondaryKeys instanceof Set))
648
- throw new DatabaseError(`Version ${version} info is corrupted for index ${this}`, 'CONSISTENCY_ERROR');
649
- const nonKeyFields = new Map();
650
- for (const [name, typeBytes] of obj.fields) {
651
- nonKeyFields.set(name, deserializeType(new DataPack(typeBytes), 0));
652
- }
653
- info = { migrateHash: obj.migrateHash, nonKeyFields, secondaryKeys: obj.secondaryKeys };
654
- this._versions.set(version, info);
655
- return info;
656
- }
657
- /** Deserialize and migrate a row from an old version. */
658
- _migrateFromVersion(model, version, valuePack) {
659
- const versionInfo = this._loadVersionInfo(model._txn.id, version);
660
- // Deserialize using old field types into a plain record
661
- const record = {};
662
- for (const [name] of this._fieldTypes.entries())
663
- record[name] = model[name]; // pk fields
664
- for (const [name, type] of versionInfo.nonKeyFields.entries()) {
665
- record[name] = type.deserialize(valuePack);
666
- }
667
- // Run migrate() if it exists
668
- const migrateFn = this._MyModel.migrate;
669
- if (migrateFn)
670
- migrateFn(record);
671
- // Set non-key fields on model from the (possibly migrated) record
672
- for (const fieldName of this._nonKeyFields) {
673
- if (fieldName in record) {
674
- model._setLoadedField(fieldName, record[fieldName]);
675
- }
676
- else if (fieldName in model) {
677
- // Instantiate the default value
678
- model._setLoadedField(fieldName, model[fieldName]);
679
- }
680
- else {
681
- throw new DatabaseError(`Field ${fieldName} is missing in migrated data for ${model}`, 'MIGRATION_ERROR');
682
- }
683
- }
684
- }
685
- _keyToArray(key) {
361
+ _pkToArray(key) {
686
362
  const bytes = new DataPack(key);
687
363
  assert(bytes.readNumber() === this._indexId);
688
364
  const result = [];
689
- for (const fieldType of this._fieldTypes.values()) {
365
+ for (const fieldType of this._indexFields.values()) {
690
366
  result.push(fieldType.deserialize(bytes));
691
367
  }
692
368
  return result;
693
369
  }
694
- _pairToInstance(txn, keyBuffer, valueBuffer) {
695
- return this._get(txn, new Uint8Array(keyBuffer), new Uint8Array(valueBuffer));
696
- }
697
- _getTypeName() {
698
- return 'primary';
699
- }
700
- _write(txn, primaryKey, data) {
701
- let valueBytes = new DataPack();
702
- valueBytes.write(this._currentVersion);
703
- const fieldConfigs = this._MyModel.fields;
704
- for (const fieldName of this._nonKeyFields) {
705
- const fieldConfig = fieldConfigs[fieldName];
706
- fieldConfig.type.serialize(data[fieldName], valueBytes);
707
- }
370
+ _writePK(txn, primaryKey, data) {
371
+ const valueBytes = this._serializeValue(data);
708
372
  if (logLevel >= 2) {
709
- console.log(`[edinburgh] Write ${this} key=${new DataPack(primaryKey)} value=${valueBytes}`);
373
+ console.log(`[edinburgh] Write ${this} key=${new DataPack(primaryKey)} value=${new DataPack(valueBytes)}`);
710
374
  }
711
- dbPut(txn.id, primaryKey, valueBytes.toUint8Array());
375
+ dbPut(txn.id, primaryKey, valueBytes);
712
376
  }
713
- _delete(txn, primaryKey, _data) {
377
+ _deletePK(txn, primaryKey, _data) {
714
378
  if (logLevel >= 2) {
715
379
  console.log(`[edinburgh] Delete ${this} key=${new DataPack(primaryKey)}`);
716
380
  }
717
381
  dbDel(txn.id, primaryKey);
718
382
  }
719
383
  }
720
- // OLMDB does not support storing empty values, so we use a single byte value for secondary indexes.
384
+ function toArray(args) {
385
+ return (Array.isArray(args) ? args : [args]);
386
+ }
721
387
  const SECONDARY_VALUE = new DataPack().write(undefined).toUint8Array();
722
- /**
723
- * Abstract base for all non-primary indexes (unique and secondary).
724
- * Provides shared key serialization, write/delete/update logic.
725
- */
726
388
  export class NonPrimaryIndex extends BaseIndex {
389
+ _loadPrimary;
727
390
  _resetIndexFieldDescriptors = {};
728
- constructor(MyModel, fieldsOrFn) {
729
- super(MyModel, typeof fieldsOrFn === 'function' ? [] : fieldsOrFn);
391
+ constructor(tableName, fieldsOrFn, _loadPrimary, queueInitialization) {
392
+ super(tableName, typeof fieldsOrFn === 'function' ? [] : fieldsOrFn);
393
+ this._loadPrimary = _loadPrimary;
730
394
  if (typeof fieldsOrFn === 'function')
731
395
  this._computeFn = fieldsOrFn;
732
- (this._MyModel._secondaries ||= []).push(this);
733
- scheduleInit();
396
+ queueInitialization();
734
397
  }
735
- async _delayedInit() {
736
- await super._delayedInit();
737
- for (const fieldName of this._fieldTypes.keys()) {
398
+ async _initializeIndex(fields, reset = false, primaryFieldTypes) {
399
+ if (reset)
400
+ this._resetIndexFieldDescriptors = {};
401
+ await super._initializeIndex(fields, reset, primaryFieldTypes);
402
+ for (const fieldName of this._indexFields.keys()) {
738
403
  this._resetIndexFieldDescriptors[fieldName] = {
739
404
  writable: true,
740
405
  configurable: true,
741
- enumerable: true
406
+ enumerable: true,
742
407
  };
743
408
  }
744
409
  }
745
- /**
746
- * Build DataPack key prefixes (indexId + field/computed values). Returns [] to skip indexing.
747
- * SecondaryIndex appends the primary key to each pack before converting to Uint8Array.
748
- */
749
410
  _buildKeyPacks(data) {
750
411
  if (this._computeFn) {
751
412
  return this._computeFn(data).map((value) => {
@@ -755,20 +416,19 @@ export class NonPrimaryIndex extends BaseIndex {
755
416
  return bytes;
756
417
  });
757
418
  }
758
- for (const fieldName of this._fieldTypes.keys()) {
419
+ for (const fieldName of this._indexFields.keys()) {
759
420
  if (data[fieldName] == null)
760
421
  return [];
761
422
  }
762
423
  const bytes = new DataPack();
763
424
  bytes.write(this._indexId);
764
- for (const [fieldName, fieldType] of this._fieldTypes.entries()) {
425
+ for (const [fieldName, fieldType] of this._indexFields.entries()) {
765
426
  fieldType.serialize(data[fieldName], bytes);
766
427
  }
767
428
  return [bytes];
768
429
  }
769
- /** Serialize all index keys. Default: key = indexId + fields. */
770
430
  _serializeKeys(primaryKey, data) {
771
- return this._buildKeyPacks(data).map(p => p.toUint8Array());
431
+ return this._buildKeyPacks(data).map(pack => pack.toUint8Array());
772
432
  }
773
433
  _write(txn, primaryKey, model) {
774
434
  for (const key of this._serializeKeys(primaryKey, model)) {
@@ -784,14 +444,9 @@ export class NonPrimaryIndex extends BaseIndex {
784
444
  dbDel(txn.id, key);
785
445
  }
786
446
  }
787
- /**
788
- * Granular update: diff old vs new keys and only insert/delete what changed.
789
- * For non-computed indexes, uses a fast path that checks which fields changed.
790
- */
791
447
  _update(txn, primaryKey, newData, oldData) {
792
448
  const oldKeys = this._serializeKeys(primaryKey, oldData);
793
449
  const newKeys = this._serializeKeys(primaryKey, newData);
794
- // Fast path: no changes and max 1 key
795
450
  if (oldKeys.length === newKeys.length && (oldKeys.length === 0 || bytesEqual(oldKeys[0], newKeys[0]))) {
796
451
  return 0;
797
452
  }
@@ -820,21 +475,23 @@ export class NonPrimaryIndex extends BaseIndex {
820
475
  return changes;
821
476
  }
822
477
  }
823
- /**
824
- * Unique index that stores references to the primary key.
825
- */
826
478
  export class UniqueIndex extends NonPrimaryIndex {
827
- get(...args) {
479
+ constructor(tableName, fieldsOrFn, loadPrimary, queueInitialization) {
480
+ super(tableName, fieldsOrFn, loadPrimary, queueInitialization);
481
+ }
482
+ _getTypeName() {
483
+ return this._computeFn ? 'fn-unique' : 'unique';
484
+ }
485
+ getPK(...args) {
828
486
  const txn = currentTxn();
829
- let keyBuffer = this._argsToKeyBytes(args, false).toUint8Array();
830
- let valueBuffer = dbGet(txn.id, keyBuffer);
487
+ const keyBuffer = this._argsToKeyBytes(args, false).toUint8Array();
488
+ const valueBuffer = dbGet(txn.id, keyBuffer);
831
489
  if (logLevel >= 3) {
832
490
  console.log(`[edinburgh] Get ${this} key=${new DataPack(keyBuffer)} result=${valueBuffer}`);
833
491
  }
834
492
  if (!valueBuffer)
835
493
  return;
836
- const pk = this._MyModel._primary;
837
- const result = pk._get(txn, valueBuffer, true);
494
+ const result = this._loadPrimary(txn, valueBuffer, true);
838
495
  if (!result)
839
496
  throw new DatabaseError(`Unique index ${this} points at non-existing primary for key: ${args.join(', ')}`, 'CONSISTENCY_ERROR');
840
497
  return result;
@@ -845,65 +502,60 @@ export class UniqueIndex extends NonPrimaryIndex {
845
502
  dbPut(txn.id, key, primaryKey);
846
503
  }
847
504
  _pairToInstance(txn, keyBuffer, valueBuffer) {
848
- const pk = this._MyModel._primary;
849
- const model = pk._get(txn, new Uint8Array(valueBuffer), false);
850
- if (this._fieldTypes.size > 0) {
505
+ const model = this._loadPrimary(txn, new Uint8Array(valueBuffer), false);
506
+ if (this._indexFields.size > 0) {
851
507
  const keyPack = new DataPack(new Uint8Array(keyBuffer));
852
- keyPack.readNumber(); // discard index id
508
+ keyPack.readNumber();
853
509
  Object.defineProperties(model, this._resetIndexFieldDescriptors);
854
- for (const [name, fieldType] of this._fieldTypes.entries()) {
510
+ for (const [name, fieldType] of this._indexFields.entries()) {
855
511
  model._setLoadedField(name, fieldType.deserialize(keyPack));
856
512
  }
857
513
  }
514
+ model._restoreLazyFields?.();
858
515
  return model;
859
516
  }
860
- _getTypeName() {
861
- return this._computeFn ? 'fn-unique' : 'unique';
862
- }
863
517
  }
864
- /**
865
- * Secondary index for non-unique lookups.
866
- */
867
518
  export class SecondaryIndex extends NonPrimaryIndex {
519
+ constructor(tableName, fieldsOrFn, loadPrimary, queueInitialization) {
520
+ super(tableName, fieldsOrFn, loadPrimary, queueInitialization);
521
+ }
522
+ _getTypeName() {
523
+ return this._computeFn ? 'fn-secondary' : 'secondary';
524
+ }
868
525
  _pairToInstance(txn, keyBuffer, _valueBuffer) {
869
526
  const keyPack = new DataPack(new Uint8Array(keyBuffer));
870
- keyPack.readNumber(); // discard index id
527
+ keyPack.readNumber();
871
528
  const indexFields = new Map();
872
- for (const [name, type] of this._fieldTypes.entries()) {
873
- indexFields.set(name, type.deserialize(keyPack));
529
+ for (const [name, fieldType] of this._indexFields.entries()) {
530
+ indexFields.set(name, fieldType.deserialize(keyPack));
874
531
  }
875
532
  if (this._computeFn)
876
- keyPack.read(); // skip computed value
533
+ keyPack.read();
877
534
  const primaryKey = keyPack.readUint8Array();
878
- const model = this._MyModel._primary._get(txn, primaryKey, false);
535
+ const model = this._loadPrimary(txn, primaryKey, false);
879
536
  if (indexFields.size > 0) {
880
537
  Object.defineProperties(model, this._resetIndexFieldDescriptors);
881
538
  for (const [name, value] of indexFields) {
882
539
  model._setLoadedField(name, value);
883
540
  }
884
541
  }
542
+ model._restoreLazyFields?.();
885
543
  return model;
886
544
  }
887
545
  _serializeKeys(primaryKey, data) {
888
- return this._buildKeyPacks(data).map(p => { p.write(primaryKey); return p.toUint8Array(); });
546
+ return this._buildKeyPacks(data).map(pack => {
547
+ pack.write(primaryKey);
548
+ return pack.toUint8Array();
549
+ });
889
550
  }
890
551
  _writeKey(txn, key, _primaryKey) {
891
552
  dbPut(txn.id, key, SECONDARY_VALUE);
892
553
  }
893
- _getTypeName() {
894
- return this._computeFn ? 'fn-secondary' : 'secondary';
895
- }
896
554
  }
897
- /**
898
- * Dump database contents for debugging.
899
- *
900
- * Prints all indexes and their data to the console for inspection.
901
- * This is primarily useful for development and debugging purposes.
902
- */
903
555
  export function dump() {
904
556
  const txn = currentTxn();
905
- let indexesById = new Map();
906
- let versions = new Map();
557
+ const indexesById = new Map();
558
+ const versions = new Map();
907
559
  console.log("--- edinburgh database dump ---");
908
560
  const iteratorId = lowlevel.createIterator(txn.id, undefined, undefined, false);
909
561
  try {
@@ -935,38 +587,33 @@ export function dump() {
935
587
  const type = kb.readString();
936
588
  const fields = {};
937
589
  while (kb.readAvailable()) {
938
- const name = kb.read();
939
- if (typeof name !== 'string')
940
- break; // undefined separator or computed hash
941
- fields[name] = deserializeType(kb, 0);
590
+ const fieldName = kb.read();
591
+ if (typeof fieldName !== 'string')
592
+ break;
593
+ fields[fieldName] = deserializeType(kb, 0);
942
594
  }
943
- const indexId = vb.readNumber();
944
- console.log(`* Index definition ${indexId}:${name}:${type}[${Object.keys(fields).join(',')}]`);
945
- indexesById.set(indexId, { name, type, fields });
595
+ const definedIndexId = vb.readNumber();
596
+ console.log(`* Index definition ${definedIndexId}:${name}:${type}[${Object.keys(fields).join(',')}]`);
597
+ indexesById.set(definedIndexId, { name, type, fields });
946
598
  }
947
599
  else if (indexId > 0 && indexesById.has(indexId)) {
948
600
  const index = indexesById.get(indexId);
949
- let name, type, rowKey, rowValue;
950
- if (index) {
951
- name = index.name;
952
- type = index.type;
953
- const fields = index.fields;
954
- rowKey = {};
955
- for (const [fieldName, fieldType] of Object.entries(fields)) {
956
- rowKey[fieldName] = fieldType.deserialize(kb);
957
- }
958
- if (type === 'primary') {
959
- const version = vb.readNumber();
960
- const vFields = versions.get(indexId)?.get(version);
961
- if (vFields) {
962
- rowValue = {};
963
- for (const [fieldName, fieldType] of vFields) {
964
- rowValue[fieldName] = fieldType.deserialize(vb);
965
- }
601
+ let rowKey = {};
602
+ let rowValue;
603
+ for (const [fieldName, fieldType] of Object.entries(index.fields)) {
604
+ rowKey[fieldName] = fieldType.deserialize(kb);
605
+ }
606
+ if (index.type === 'primary') {
607
+ const version = vb.readNumber();
608
+ const valueFields = versions.get(indexId)?.get(version);
609
+ if (valueFields) {
610
+ rowValue = {};
611
+ for (const [fieldName, fieldType] of valueFields) {
612
+ rowValue[fieldName] = fieldType.deserialize(vb);
966
613
  }
967
614
  }
968
615
  }
969
- console.log(`* Row for ${indexId}:${name}:${type}`, rowKey ?? kb, rowValue ?? vb);
616
+ console.log(`* Row for ${indexId}:${index.name}:${index.type}`, rowKey ?? kb, rowValue ?? vb);
970
617
  }
971
618
  else {
972
619
  console.log(`* Unhandled '${indexId}' key=${kb} value=${vb}`);