edinburgh 0.4.6 → 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 (77) hide show
  1. package/README.md +403 -461
  2. package/build/src/datapack.d.ts +9 -9
  3. package/build/src/datapack.js +10 -10
  4. package/build/src/datapack.js.map +1 -1
  5. package/build/src/edinburgh.d.ts +21 -10
  6. package/build/src/edinburgh.js +33 -55
  7. package/build/src/edinburgh.js.map +1 -1
  8. package/build/src/indexes.d.ts +99 -288
  9. package/build/src/indexes.js +253 -636
  10. package/build/src/indexes.js.map +1 -1
  11. package/build/src/migrate.js +17 -39
  12. package/build/src/migrate.js.map +1 -1
  13. package/build/src/models.d.ts +177 -113
  14. package/build/src/models.js +487 -259
  15. package/build/src/models.js.map +1 -1
  16. package/build/src/types.d.ts +41 -51
  17. package/build/src/types.js +39 -52
  18. package/build/src/types.js.map +1 -1
  19. package/build/src/utils.d.ts +4 -4
  20. package/build/src/utils.js +4 -4
  21. package/package.json +1 -3
  22. package/skill/AnyModelClass.md +7 -0
  23. package/skill/FindOptions.md +37 -0
  24. package/skill/Lifecycle Hooks.md +24 -0
  25. package/skill/{Model_delete.md → Lifecycle Hooks_delete.md } +2 -2
  26. package/skill/{Model_getPrimaryKeyHash.md → Lifecycle Hooks_getPrimaryKeyHash.md } +1 -1
  27. package/skill/{Model_isValid.md → Lifecycle Hooks_isValid.md } +1 -1
  28. package/skill/Lifecycle Hooks_migrate.md +26 -0
  29. package/skill/{Model_preCommit.md → Lifecycle Hooks_preCommit.md } +3 -5
  30. package/skill/{Model_preventPersist.md → Lifecycle Hooks_preventPersist.md } +2 -2
  31. package/skill/{Model_validate.md → Lifecycle Hooks_validate.md } +2 -2
  32. package/skill/ModelBase.md +7 -0
  33. package/skill/ModelClass.md +8 -0
  34. package/skill/SKILL.md +253 -215
  35. package/skill/Schema Evolution.md +19 -0
  36. package/skill/TypeWrapper_containsNull.md +11 -0
  37. package/skill/TypeWrapper_deserialize.md +9 -0
  38. package/skill/TypeWrapper_getError.md +11 -0
  39. package/skill/TypeWrapper_serialize.md +10 -0
  40. package/skill/TypeWrapper_serializeType.md +9 -0
  41. package/skill/array.md +2 -2
  42. package/skill/defineModel.md +23 -0
  43. package/skill/deleteEverything.md +8 -0
  44. package/skill/field.md +4 -4
  45. package/skill/link.md +12 -10
  46. package/skill/literal.md +1 -1
  47. package/skill/opt.md +1 -1
  48. package/skill/or.md +1 -1
  49. package/skill/record.md +1 -1
  50. package/skill/set.md +2 -2
  51. package/skill/setOnSaveCallback.md +2 -2
  52. package/skill/transact.md +3 -3
  53. package/src/datapack.ts +10 -10
  54. package/src/edinburgh.ts +46 -58
  55. package/src/indexes.ts +338 -802
  56. package/src/migrate.ts +15 -37
  57. package/src/models.ts +617 -314
  58. package/src/types.ts +61 -54
  59. package/src/utils.ts +4 -4
  60. package/skill/BaseIndex.md +0 -16
  61. package/skill/BaseIndex_batchProcess.md +0 -10
  62. package/skill/BaseIndex_find.md +0 -7
  63. package/skill/Model.md +0 -22
  64. package/skill/Model_findAll.md +0 -12
  65. package/skill/Model_migrate.md +0 -34
  66. package/skill/Model_replaceInto.md +0 -16
  67. package/skill/PrimaryIndex.md +0 -8
  68. package/skill/PrimaryIndex_get.md +0 -17
  69. package/skill/PrimaryIndex_getLazy.md +0 -13
  70. package/skill/SecondaryIndex.md +0 -9
  71. package/skill/UniqueIndex.md +0 -9
  72. package/skill/UniqueIndex_get.md +0 -17
  73. package/skill/dump.md +0 -8
  74. package/skill/index.md +0 -32
  75. package/skill/primary.md +0 -26
  76. package/skill/registerModel.md +0 -26
  77. package/skill/unique.md +0 -32
@@ -1,26 +1,16 @@
1
+ import * as lowlevel from "olmdb/lowlevel";
1
2
  import { DatabaseError } from "olmdb/lowlevel";
2
- import { AsyncLocalStorage } from "node:async_hooks";
3
- import { TypeWrapper, identifier } from "./types.js";
4
- import { scheduleInit } from "./edinburgh.js";
5
- export const txnStorage = new AsyncLocalStorage();
3
+ import DataPack from "./datapack.js";
4
+ import { deserializeType, serializeType, TypeWrapper, identifier } from "./types.js";
5
+ import { transact, currentTxn } from "./edinburgh.js";
6
+ import { PrimaryKey, UniqueIndex, SecondaryIndex } from "./indexes.js";
7
+ import { addErrorPath, dbGet, hashBytes, hashFunction } from "./utils.js";
8
+ let nextFakePkHash = -1;
6
9
  const PREVENT_PERSIST_DESCRIPTOR = {
7
10
  get() {
8
11
  throw new DatabaseError("Operation not allowed after preventPersist()", "NO_PERSIST");
9
12
  },
10
13
  };
11
- /**
12
- * Returns the current transaction from AsyncLocalStorage.
13
- * Throws if called outside a transact() callback.
14
- * @internal
15
- */
16
- export function currentTxn() {
17
- const txn = txnStorage.getStore();
18
- if (!txn)
19
- throw new DatabaseError("No active transaction. Operations must be performed within a transact() callback.", 'NO_TRANSACTION');
20
- return txn;
21
- }
22
- import { PrimaryIndex } from "./indexes.js";
23
- import { addErrorPath, logLevel, dbGet, hashBytes, bytesEqual } from "./utils.js";
24
14
  /**
25
15
  * Create a field definition for a model property.
26
16
  *
@@ -29,16 +19,16 @@ import { addErrorPath, logLevel, dbGet, hashBytes, bytesEqual } from "./utils.js
29
19
  * This allows for both runtime introspection and compile-time type safety.
30
20
  *
31
21
  * @template T - The field type.
32
- * @param type - The type wrapper for this field.
33
- * @param options - Additional field configuration options.
22
+ * @param type The type wrapper for this field.
23
+ * @param options Additional field configuration options.
34
24
  * @returns The field value (typed as T, but actually returns FieldConfig<T>).
35
25
  *
36
26
  * @example
37
27
  * ```typescript
38
- * class User extends E.Model<User> {
28
+ * const User = E.defineModel("User", class {
39
29
  * name = E.field(E.string, {description: "User's full name"});
40
30
  * age = E.field(E.opt(E.number), {description: "User's age", default: 25});
41
- * }
31
+ * });
42
32
  * ```
43
33
  */
44
34
  export function field(type, options = {}) {
@@ -46,84 +36,412 @@ export function field(type, options = {}) {
46
36
  options.type = type;
47
37
  return options;
48
38
  }
49
- // Model registration and initialization
50
- export const modelRegistry = {};
51
39
  function isObjectEmpty(obj) {
52
- for (let _ of Object.keys(obj)) {
40
+ for (const _ of Object.keys(obj)) {
53
41
  return false;
54
42
  }
55
43
  return true;
56
44
  }
45
+ function copyStaticMembersFromClassChain(target, source) {
46
+ for (let current = source; current && current !== Function.prototype; current = Object.getPrototypeOf(current)) {
47
+ for (const key of Object.getOwnPropertyNames(current)) {
48
+ if (key === 'length' || key === 'name' || key === 'prototype')
49
+ continue;
50
+ if (Object.prototype.hasOwnProperty.call(target, key))
51
+ continue;
52
+ Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(current, key));
53
+ }
54
+ }
55
+ }
56
+ // Model registration and initialization
57
+ export const modelRegistry = {};
58
+ export const pendingModelInits = new Set();
59
+ // These static members are attached dynamically in defineModel(), so 'declare' tells TypeScript
60
+ // they exist at runtime without emitting duplicate class fields that would shadow those assignments.
61
+ class ModelClassRuntime extends PrimaryKey {
62
+ // Cached list of non-primary fields used for value serialization.
63
+ _nonKeyFields;
64
+ // Lazy getter/setter descriptors installed on unloaded non-key fields.
65
+ _lazyDescriptors = {};
66
+ // Writable descriptors temporarily installed before hydrating value fields.
67
+ _resetDescriptors = {};
68
+ // Frozen descriptors applied to primary-key fields after key materialization.
69
+ _freezePrimaryKeyDescriptors = {};
70
+ // Active schema version number for value encoding.
71
+ _currentVersion;
72
+ // Hash of the active migrate() function for schema identity.
73
+ _currentMigrateHash;
74
+ // Cached historical schema metadata for lazy migration of old rows.
75
+ _versions = new Map();
76
+ _serializeVersionValue() {
77
+ const fields = [];
78
+ for (const fieldName of this._nonKeyFields) {
79
+ const tp = new DataPack();
80
+ serializeType(this.fields[fieldName].type, tp);
81
+ fields.push([fieldName, tp.toUint8Array()]);
82
+ }
83
+ return new DataPack().write({
84
+ migrateHash: this._currentMigrateHash,
85
+ fields,
86
+ secondaryKeys: new Set(Object.values(this._secondaries || {}).map(sec => sec._signature)),
87
+ }).toUint8Array();
88
+ }
89
+ async _initialize(reset = false) {
90
+ const allFieldTypes = new Map();
91
+ for (const [fieldName, fieldConfig] of Object.entries(this.fields)) {
92
+ allFieldTypes.set(fieldName, fieldConfig.type);
93
+ }
94
+ await super._initializeIndex(allFieldTypes, reset);
95
+ if (reset || this._nonKeyFields === undefined) {
96
+ this._nonKeyFields = Object.keys(this.fields).filter(fieldName => !this._indexFields.has(fieldName));
97
+ this._lazyDescriptors = {};
98
+ this._resetDescriptors = {};
99
+ this._freezePrimaryKeyDescriptors = {};
100
+ for (const fieldName of this._nonKeyFields) {
101
+ this._lazyDescriptors[fieldName] = {
102
+ configurable: true,
103
+ enumerable: true,
104
+ get() {
105
+ this.constructor._lazyLoad(this);
106
+ return this[fieldName];
107
+ },
108
+ set(value) {
109
+ this.constructor._lazyLoad(this);
110
+ this[fieldName] = value;
111
+ },
112
+ };
113
+ this._resetDescriptors[fieldName] = {
114
+ writable: true,
115
+ enumerable: true,
116
+ };
117
+ }
118
+ for (const fieldName of this._indexFields.keys()) {
119
+ this._freezePrimaryKeyDescriptors[fieldName] = {
120
+ writable: false,
121
+ enumerable: true,
122
+ };
123
+ }
124
+ }
125
+ for (const sec of Object.values(this._secondaries || {})) {
126
+ await sec._initializeIndex(allFieldTypes, reset, this._indexFields);
127
+ }
128
+ const migrateFn = this.migrate;
129
+ this._currentMigrateHash = migrateFn ? hashFunction(migrateFn) : 0;
130
+ const currentValueBytes = this._serializeVersionValue();
131
+ this._currentVersion = (await this._ensureVersionEntry(currentValueBytes)).version;
132
+ }
133
+ _getSecondary(name) {
134
+ const index = this._secondaries?.[name];
135
+ if (!index)
136
+ throw new DatabaseError(`Unknown index '${name}' on model '${this.tableName}'`, 'INIT_ERROR');
137
+ return index;
138
+ }
139
+ _get(txn, args, loadNow) {
140
+ let key;
141
+ let keyParts;
142
+ if (args instanceof Uint8Array) {
143
+ key = args;
144
+ }
145
+ else {
146
+ key = this._argsToKeyBytes(args, false).toUint8Array();
147
+ keyParts = args;
148
+ }
149
+ const keyHash = hashBytes(key);
150
+ const cached = txn.instances.get(keyHash);
151
+ if (cached) {
152
+ if (loadNow && loadNow !== true) {
153
+ Object.defineProperties(cached, this._resetDescriptors);
154
+ this._loadValueFields(cached, loadNow);
155
+ }
156
+ return cached;
157
+ }
158
+ let valueBuffer;
159
+ if (loadNow) {
160
+ if (loadNow === true) {
161
+ valueBuffer = dbGet(txn.id, key);
162
+ if (!valueBuffer)
163
+ return;
164
+ }
165
+ else {
166
+ valueBuffer = loadNow;
167
+ }
168
+ }
169
+ const model = Object.create(this.prototype);
170
+ model._txn = txn;
171
+ model._oldValues = {};
172
+ txn.instances.set(keyHash, model);
173
+ if (keyParts) {
174
+ let i = 0;
175
+ for (const fieldName of this._indexFields.keys()) {
176
+ model._setLoadedField(fieldName, keyParts[i++]);
177
+ }
178
+ }
179
+ else {
180
+ const keyPack = new DataPack(key);
181
+ keyPack.readNumber();
182
+ for (const [fieldName, fieldType] of this._indexFields.entries()) {
183
+ model._setLoadedField(fieldName, fieldType.deserialize(keyPack));
184
+ }
185
+ }
186
+ model._setPrimaryKey(key, keyHash);
187
+ if (valueBuffer) {
188
+ this._loadValueFields(model, valueBuffer);
189
+ }
190
+ else {
191
+ Object.defineProperties(model, this._lazyDescriptors);
192
+ }
193
+ return model;
194
+ }
195
+ _lazyLoad(model) {
196
+ const key = model._primaryKey;
197
+ const valueBuffer = dbGet(model._txn.id, key);
198
+ if (!valueBuffer)
199
+ throw new DatabaseError(`Lazy-loaded ${this.tableName}#${key} does not exist`, 'LAZY_FAIL');
200
+ Object.defineProperties(model, this._resetDescriptors);
201
+ this._loadValueFields(model, valueBuffer);
202
+ }
203
+ /**
204
+ * Load a model by primary key inside the current transaction.
205
+ *
206
+ * @returns The matching model, or `undefined` if no row exists.
207
+ */
208
+ get(...args) {
209
+ return this._get(currentTxn(), args, true);
210
+ }
211
+ /**
212
+ * Load a model by primary key without fetching its non-key fields immediately.
213
+ *
214
+ * Accessing a lazy field later will load the remaining fields transparently.
215
+ */
216
+ getLazy(...args) {
217
+ return this._get(currentTxn(), args, false);
218
+ }
219
+ _pairToInstance(txn, keyBuffer, valueBuffer) {
220
+ return this._get(txn, new Uint8Array(keyBuffer), new Uint8Array(valueBuffer));
221
+ }
222
+ /**
223
+ * Load an existing instance by primary key and update it, or create a new one.
224
+ * If a row already exists, its non-primary-key fields are updated in place.
225
+ * Otherwise, a new instance is created with `obj` as its initial properties.
226
+ *
227
+ * @param obj Partial model data that **must** include every primary key field.
228
+ * @returns The loaded-and-updated or newly created instance.
229
+ */
230
+ replaceInto(obj) {
231
+ const keyArgs = [];
232
+ for (const fieldName of this._indexFields.keys()) {
233
+ if (!(fieldName in obj)) {
234
+ throw new DatabaseError(`replaceInto: missing primary key field '${fieldName}'`, "MISSING_PRIMARY_KEY");
235
+ }
236
+ keyArgs.push(obj[fieldName]);
237
+ }
238
+ const existing = this.get(...keyArgs);
239
+ if (existing) {
240
+ for (const key in obj) {
241
+ if (!this._indexFields.has(key)) {
242
+ existing[key] = obj[key];
243
+ }
244
+ }
245
+ return existing;
246
+ }
247
+ return new this(obj);
248
+ }
249
+ /**
250
+ * Look up a model through a named unique index.
251
+ *
252
+ * @param name The name from the model's `unique` definition.
253
+ * @param args The unique-index key values.
254
+ * @returns The matching model instance, if any.
255
+ */
256
+ getBy(name, ...args) {
257
+ return this._getSecondary(name).getPK(...args);
258
+ }
259
+ findBy(name, opts) {
260
+ return this._getSecondary(name).find(opts);
261
+ }
262
+ /**
263
+ * Process rows from a named unique or secondary index in batched transactions.
264
+ *
265
+ * Uses the same range options as `findBy()`, plus batch limits.
266
+ */
267
+ batchProcessBy(name, opts, callback) {
268
+ return this._getSecondary(name).batchProcess(opts, callback);
269
+ }
270
+ _loadValueFields(model, valueArray) {
271
+ const valuePack = new DataPack(valueArray);
272
+ const version = valuePack.readNumber();
273
+ if (version === this._currentVersion) {
274
+ for (const fieldName of this._nonKeyFields) {
275
+ model._setLoadedField(fieldName, this.fields[fieldName].type.deserialize(valuePack));
276
+ }
277
+ }
278
+ else {
279
+ this._migrateValueFields(model, version, valuePack);
280
+ }
281
+ }
282
+ _loadVersionInfo(txnId, version) {
283
+ let info = this._versions.get(version);
284
+ if (info)
285
+ return info;
286
+ const key = this._versionInfoKey(version);
287
+ const raw = dbGet(txnId, key);
288
+ if (!raw)
289
+ throw new DatabaseError(`Version ${version} info not found for index ${this}`, 'CONSISTENCY_ERROR');
290
+ const obj = new DataPack(raw).read();
291
+ if (!obj || typeof obj.migrateHash !== 'number' || !Array.isArray(obj.fields) || !(obj.secondaryKeys instanceof Set)) {
292
+ throw new DatabaseError(`Version ${version} info is corrupted for index ${this}`, 'CONSISTENCY_ERROR');
293
+ }
294
+ const nonKeyFields = new Map();
295
+ for (const [name, typeBytes] of obj.fields) {
296
+ nonKeyFields.set(name, deserializeType(new DataPack(typeBytes), 0));
297
+ }
298
+ info = { migrateHash: obj.migrateHash, nonKeyFields, secondaryKeys: obj.secondaryKeys };
299
+ this._versions.set(version, info);
300
+ return info;
301
+ }
302
+ _migrateValueFields(model, version, valuePack) {
303
+ const versionInfo = this._loadVersionInfo(model._txn.id, version);
304
+ const record = {};
305
+ for (const [name] of this._indexFields.entries())
306
+ record[name] = model[name];
307
+ for (const [name, type] of versionInfo.nonKeyFields.entries()) {
308
+ record[name] = type.deserialize(valuePack);
309
+ }
310
+ const migrateFn = this.migrate;
311
+ if (migrateFn)
312
+ migrateFn(record);
313
+ for (const fieldName of this._nonKeyFields) {
314
+ if (fieldName in record) {
315
+ model._setLoadedField(fieldName, record[fieldName]);
316
+ }
317
+ else if (fieldName in model) {
318
+ model._setLoadedField(fieldName, model[fieldName]);
319
+ }
320
+ else {
321
+ throw new DatabaseError(`Field ${fieldName} is missing in migrated data for ${model}`, 'MIGRATION_ERROR');
322
+ }
323
+ }
324
+ }
325
+ _serializeValue(data) {
326
+ const valueBytes = new DataPack();
327
+ valueBytes.write(this._currentVersion);
328
+ for (const fieldName of this._nonKeyFields) {
329
+ const fieldConfig = this.fields[fieldName];
330
+ fieldConfig.type.serialize(data[fieldName], valueBytes);
331
+ }
332
+ return valueBytes.toUint8Array();
333
+ }
334
+ }
335
+ /**
336
+ * Runtime base constructor for model classes returned by `defineModel()`.
337
+ *
338
+ * Prefer the `ModelClass` type alias for annotations and the result of
339
+ * `defineModel()` for concrete model classes.
340
+ */
341
+ export const ModelClass = ModelClassRuntime;
57
342
  /**
58
343
  * Register a model class with the Edinburgh ORM system.
59
344
  *
60
- * @template T - The model class type.
61
- * @param MyModel - The model class to register.
62
- * @returns The enhanced model class with ORM capabilities.
345
+ * Converts a plain class into a fully-featured model with database persistence,
346
+ * typed fields, primary key access, and optional secondary and unique indexes.
63
347
  *
64
- * @example
65
- * ```typescript
66
- * ⁣@E.registerModel
67
- * class User extends E.Model<User> {
68
- * static pk = E.index(User, ["id"], "primary");
69
- * id = E.field(E.identifier);
70
- * name = E.field(E.string);
71
- * }
72
- * ```
348
+ * @param tableName The database table name for this model.
349
+ * @param cls A plain class whose properties use E.field().
350
+ * @param opts Registration options.
351
+ * @param opts.pk Primary key field name or array of field names.
352
+ * @param opts.unique Named unique index specifications (field name, field array, or compute function).
353
+ * @param opts.index Named secondary index specifications (field name, field array, or compute function).
354
+ * @param opts.override Replace a previous model with the same table name.
355
+ * @returns The enhanced model constructor.
73
356
  */
74
- export function registerModel(MyModel) {
75
- const MockModel = getMockModel(MyModel);
76
- // Copy own static methods/properties
77
- for (const name of Object.getOwnPropertyNames(MyModel)) {
78
- if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock' && name !== 'override') {
79
- MockModel[name] = MyModel[name];
80
- }
81
- }
82
- MockModel.tableName ||= MyModel.name;
83
- // Register the constructor by name
357
+ export function defineModel(tableName, cls, opts) {
358
+ Object.setPrototypeOf(cls.prototype, ModelBase.prototype);
359
+ const MockModel = function (initial, txn = currentTxn()) {
360
+ this._txn = txn;
361
+ txn.instances.set(nextFakePkHash--, this);
362
+ if (initial)
363
+ Object.assign(this, initial);
364
+ };
365
+ const normalizeSpec = (spec) => typeof spec === 'string' ? [spec] : spec;
366
+ const queueInitialization = () => { pendingModelInits.add(MockModel); };
367
+ const loadPrimary = (txn, primaryKey, loadNow) => MockModel._get(txn, primaryKey, loadNow);
368
+ cls.prototype.constructor = MockModel;
369
+ Object.setPrototypeOf(MockModel, ModelClassRuntime.prototype);
370
+ MockModel.prototype = cls.prototype;
371
+ copyStaticMembersFromClassChain(MockModel, cls);
372
+ MockModel.tableName = tableName;
84
373
  if (MockModel.tableName in modelRegistry) {
85
- if (!MyModel.override) {
374
+ if (!opts?.override) {
86
375
  throw new DatabaseError(`Model with table name '${MockModel.tableName}' already registered`, 'INIT_ERROR');
87
376
  }
88
377
  delete modelRegistry[MockModel.tableName];
89
378
  }
90
- modelRegistry[MockModel.tableName] = MockModel;
91
- return MockModel;
92
- }
93
- export function getMockModel(OrgModel) {
94
- const AnyOrgModel = OrgModel;
95
- if (AnyOrgModel._isMock)
96
- return OrgModel;
97
- if (AnyOrgModel._mock)
98
- return AnyOrgModel._mock;
99
- const MockModel = function (initial, txn = currentTxn()) {
100
- // This constructor should only be called when the user does 'new Model'. We'll bypass this when
101
- // loading objects. Add to 'instances', so the object will be saved.
102
- this._txn = txn;
103
- txn.instances.add(this);
104
- if (initial) {
105
- Object.assign(this, initial);
379
+ const instance = new cls();
380
+ if (!opts?.pk && !instance.id) {
381
+ instance.id = { type: identifier };
382
+ }
383
+ MockModel.fields = {};
384
+ for (const key in instance) {
385
+ const value = instance[key];
386
+ if (value && value.type instanceof TypeWrapper) {
387
+ MockModel.fields[key] = value;
388
+ const defObj = value.default === undefined ? value.type : value;
389
+ const def = defObj.default;
390
+ if (typeof def === 'function') {
391
+ Object.defineProperty(MockModel.prototype, key, {
392
+ get() {
393
+ return (this[key] = def.call(defObj, this));
394
+ },
395
+ set(val) {
396
+ Object.defineProperty(this, key, {
397
+ value: val,
398
+ configurable: true,
399
+ writable: true,
400
+ enumerable: true,
401
+ });
402
+ },
403
+ configurable: true,
404
+ });
405
+ }
406
+ else if (def !== undefined) {
407
+ MockModel.prototype[key] = def;
408
+ }
106
409
  }
107
- };
108
- // We want .constructor to point at our fake constructor function.
109
- OrgModel.prototype.constructor = MockModel;
110
- // Copy the prototype chain for the constructor as well as for instantiated objects
111
- Object.setPrototypeOf(MockModel, Object.getPrototypeOf(OrgModel));
112
- MockModel.prototype = OrgModel.prototype;
113
- MockModel._isMock = true;
114
- MockModel._original = OrgModel;
115
- AnyOrgModel._mock = MockModel;
116
- scheduleInit();
410
+ }
411
+ const primaryFields = opts?.pk ? (Array.isArray(opts.pk) ? opts.pk : [opts.pk]) : ['id'];
412
+ MockModel._indexFields = new Map();
413
+ for (const fieldName of primaryFields) {
414
+ const fieldConfig = MockModel.fields[fieldName];
415
+ if (!fieldConfig) {
416
+ throw new DatabaseError(`Unknown primary key field '${fieldName}' on model '${tableName}'`, 'INIT_ERROR');
417
+ }
418
+ MockModel._indexFields.set(fieldName, fieldConfig.type);
419
+ }
420
+ MockModel._secondaries = {};
421
+ MockModel._lazyDescriptors = {};
422
+ MockModel._resetDescriptors = {};
423
+ MockModel._freezePrimaryKeyDescriptors = {};
424
+ MockModel._versions = new Map();
425
+ if (opts?.unique) {
426
+ for (const [name, spec] of Object.entries(opts.unique)) {
427
+ MockModel._secondaries[name] = new UniqueIndex(tableName, normalizeSpec(spec), loadPrimary, queueInitialization);
428
+ }
429
+ }
430
+ if (opts?.index) {
431
+ for (const [name, spec] of Object.entries(opts.index)) {
432
+ MockModel._secondaries[name] = new SecondaryIndex(tableName, normalizeSpec(spec), loadPrimary, queueInitialization);
433
+ }
434
+ }
435
+ modelRegistry[MockModel.tableName] = MockModel;
436
+ pendingModelInits.add(MockModel);
117
437
  return MockModel;
118
438
  }
119
- // Model base class and related symbols/state
120
- const INIT_INSTANCE_SYMBOL = Symbol();
121
439
  /**
122
440
  * Base class for all database models in the Edinburgh ORM.
123
441
  *
124
442
  * Models represent database entities with typed fields, automatic serialization,
125
- * change tracking, and relationship management. All model classes should extend
126
- * this base class and be decorated with `@E.registerModel`.
443
+ * change tracking, and relationship management. Model classes are created using
444
+ * `E.defineModel()`.
127
445
  *
128
446
  * ### Schema Evolution
129
447
  *
@@ -149,38 +467,27 @@ const INIT_INSTANCE_SYMBOL = Symbol();
149
467
  *
150
468
  * - **`static migrate(record)`**: Called when deserializing rows written with an older schema
151
469
  * version. Receives a plain record object; mutate it in-place to match the current schema.
152
- * See {@link Model.migrate}.
153
470
  *
154
471
  * - **`preCommit()`**: Called on each modified instance right before the transaction commits.
155
472
  * Useful for computing derived fields, enforcing cross-field invariants, or creating related
156
- * instances. See {@link Model.preCommit}.
157
- *
158
- * @template SUB - The concrete model subclass (for proper typing).
473
+ * instances.
159
474
  *
160
475
  * @example
161
476
  * ```typescript
162
- * ⁣@E.registerModel
163
- * class User extends E.Model<User> {
164
- * static pk = E.primary(User, "id");
165
- *
477
+ * const User = E.defineModel("User", class {
166
478
  * id = E.field(E.identifier);
167
479
  * name = E.field(E.string);
168
480
  * email = E.field(E.string);
169
- *
170
- * static byEmail = E.unique(User, "email");
171
- * }
481
+ * }, {
482
+ * pk: "id",
483
+ * unique: { email: "email" },
484
+ * });
485
+ * // Optional: declare a companion type so `let u: User` works.
486
+ * // Not needed if you only use `new User()`, `User.find()`, etc.
487
+ * type User = InstanceType<typeof User>;
172
488
  * ```
173
489
  */
174
- export class Model {
175
- static _primary;
176
- /** @internal All non-primary indexes for this model. */
177
- static _secondaries;
178
- /** The database table name (defaults to class name). */
179
- static tableName;
180
- /** When true, registerModel replaces an existing model with the same tableName. */
181
- static override;
182
- /** Field configuration metadata. */
183
- static fields;
490
+ export class ModelBase {
184
491
  /*
185
492
  * IMPORTANT: We cannot use instance property initializers here, because we will be
186
493
  * initializing the class through a fake constructor that will skip these. This is
@@ -190,92 +497,13 @@ export class Model {
190
497
  * @internal
191
498
  * - _oldValues===undefined: New instance, not yet saved.
192
499
  * - _oldValues===null: Instance is to be deleted.
500
+ * - _oldValues===false: Instance excluded from persistence (preventPersist).
193
501
  * - _oldValues is an object: Loaded (possibly only partial, still lazy) from disk, _oldValues contains (partial) old values
194
502
  */
195
503
  _oldValues;
196
504
  _primaryKey;
197
505
  _primaryKeyHash;
198
506
  _txn;
199
- constructor(initial = {}) {
200
- // This constructor will only be called once, from `initModels`. All other instances will
201
- // be created by the 'fake' constructor. The typing for `initial` *is* important though.
202
- if (initial === INIT_INSTANCE_SYMBOL)
203
- return;
204
- throw new DatabaseError("The model needs a @E.registerModel decorator", 'INIT_ERROR');
205
- }
206
- /**
207
- * Transform the model's `E.field` properties into the appropriate JavaScript properties. Normally this is done
208
- * automatically when using `transact()`, but in case you need to access `Model.fields` directly before the first
209
- * transaction, you can call this method manually.
210
- */
211
- static initFields(reset) {
212
- const MockModel = getMockModel(this);
213
- if (reset) {
214
- MockModel._primary._indexId = undefined;
215
- MockModel._primary._versions.clear();
216
- for (const sec of MockModel._secondaries || [])
217
- sec._indexId = undefined;
218
- }
219
- if (MockModel.fields)
220
- return;
221
- // First-time init: gather field configs from a temporary instance of the original class.
222
- const OrgModel = MockModel._original || this;
223
- const instance = new OrgModel(INIT_INSTANCE_SYMBOL);
224
- // If no primary key exists, create one using 'id' field
225
- if (!MockModel._primary) {
226
- if (!instance.id) {
227
- instance.id = { type: identifier };
228
- }
229
- // @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
230
- new PrimaryIndex(MockModel, ['id']);
231
- }
232
- MockModel.fields = {};
233
- for (const key in instance) {
234
- const value = instance[key];
235
- // Check if this property contains field metadata
236
- if (value && value.type instanceof TypeWrapper) {
237
- // Set the configuration on the constructor's `fields` property
238
- MockModel.fields[key] = value;
239
- // Set default value on the prototype
240
- const defObj = value.default === undefined ? value.type : value;
241
- const def = defObj.default;
242
- if (typeof def === 'function') {
243
- // The default is a function. We'll define a getter on the property in the model prototype,
244
- // and once it is read, we'll run the function and set the value as a plain old property
245
- // on the instance object.
246
- Object.defineProperty(MockModel.prototype, key, {
247
- get() {
248
- // This will call set(), which will define the property on the instance.
249
- return (this[key] = def.call(defObj, this));
250
- },
251
- set(val) {
252
- Object.defineProperty(this, key, {
253
- value: val,
254
- configurable: true,
255
- writable: true,
256
- enumerable: true,
257
- });
258
- },
259
- configurable: true,
260
- });
261
- }
262
- else if (def !== undefined) {
263
- MockModel.prototype[key] = def;
264
- }
265
- }
266
- }
267
- if (logLevel >= 1) {
268
- console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
269
- }
270
- }
271
- static async _loadCreateIndexes() {
272
- const MockModel = getMockModel(this);
273
- // Always run index inits (idempotent, skip if already initialized)
274
- await MockModel._primary._delayedInit();
275
- for (const sec of MockModel._secondaries || [])
276
- await sec._delayedInit();
277
- await MockModel._primary._initVersioning();
278
- }
279
507
  _setLoadedField(fieldName, value) {
280
508
  const oldValues = this._oldValues;
281
509
  if (oldValues.hasOwnProperty(fieldName))
@@ -290,13 +518,25 @@ export class Model {
290
518
  oldValues[fieldName] = value;
291
519
  }
292
520
  }
521
+ _restoreLazyFields() {
522
+ const oldValues = this._oldValues;
523
+ if (!oldValues || oldValues === null)
524
+ return;
525
+ for (const [fieldName, descriptor] of Object.entries(this.constructor._lazyDescriptors)) {
526
+ if (!oldValues.hasOwnProperty(fieldName)) {
527
+ Object.defineProperty(this, fieldName, descriptor);
528
+ }
529
+ }
530
+ }
293
531
  /**
294
532
  * @returns The primary key for this instance.
295
533
  */
296
534
  getPrimaryKey() {
297
535
  let key = this._primaryKey;
298
536
  if (key === undefined) {
299
- key = this.constructor._primary._serializeKeyFields(this).toUint8Array();
537
+ if (this._oldValues === false)
538
+ throw new DatabaseError("Operation not allowed after preventPersist()", "NO_PERSIST");
539
+ key = this.constructor._serializePK(this).toUint8Array();
300
540
  this._setPrimaryKey(key);
301
541
  }
302
542
  return key;
@@ -304,7 +544,7 @@ export class Model {
304
544
  _setPrimaryKey(key, hash) {
305
545
  this._primaryKey = key;
306
546
  this._primaryKeyHash = hash ?? hashBytes(key);
307
- Object.defineProperties(this, this.constructor._primary._freezePrimaryKeyDescriptors);
547
+ Object.defineProperties(this, this.constructor._freezePrimaryKeyDescriptors);
308
548
  }
309
549
  /**
310
550
  * @returns A 53-bit positive integer non-cryptographic hash of the primary key, or undefined if not yet saved.
@@ -315,19 +555,21 @@ export class Model {
315
555
  return this._primaryKeyHash;
316
556
  }
317
557
  isLazyField(field) {
318
- const descr = this.constructor._primary._lazyDescriptors[field];
319
- return !!(descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, field)?.get);
558
+ const oldValues = this._oldValues;
559
+ return !!(oldValues && oldValues !== null && field in this.constructor._lazyDescriptors && !oldValues.hasOwnProperty(field));
320
560
  }
321
561
  _write(txn) {
322
562
  const oldValues = this._oldValues;
563
+ if (oldValues === false)
564
+ return; // preventPersist() was called
323
565
  if (oldValues === null) { // Delete instance
324
566
  const pk = this._primaryKey;
325
567
  // Temporarily restore _oldValues so computed indexes can trigger lazy loads
326
568
  this._oldValues = {};
327
- for (const index of this.constructor._secondaries || []) {
569
+ for (const index of Object.values(this.constructor._secondaries || {})) {
328
570
  index._delete(txn, pk, this);
329
571
  }
330
- this.constructor._primary._delete(txn, pk, this);
572
+ this.constructor._deletePK(txn, pk, this);
331
573
  return "deleted";
332
574
  }
333
575
  if (oldValues === undefined) { // Create instance
@@ -338,9 +580,9 @@ export class Model {
338
580
  throw new DatabaseError("Unique constraint violation", "UNIQUE_CONSTRAINT");
339
581
  }
340
582
  // Insert the primary index
341
- this.constructor._primary._write(txn, pk, this);
583
+ this.constructor._writePK(txn, pk, this);
342
584
  // Insert all secondaries
343
- for (const index of this.constructor._secondaries || []) {
585
+ for (const index of Object.values(this.constructor._secondaries || {})) {
344
586
  index._write(txn, pk, this);
345
587
  }
346
588
  return "created";
@@ -349,8 +591,9 @@ export class Model {
349
591
  // We're doing an update. Note that we may still be in a lazy state, and we don't want to load
350
592
  // the whole object just to see if something changed.
351
593
  // Add old values of changed fields to 'changed'.
352
- const fields = this.constructor.fields;
353
- let changed = {};
594
+ const changed = {};
595
+ const cls = this.constructor;
596
+ const fields = cls.fields;
354
597
  for (const fieldName in oldValues) {
355
598
  const oldValue = oldValues[fieldName];
356
599
  const newValue = this[fieldName];
@@ -361,7 +604,7 @@ export class Model {
361
604
  if (isObjectEmpty(changed))
362
605
  return; // No changes, nothing to do
363
606
  // Make sure primary has not been changed
364
- for (const field of this.constructor._primary._fieldTypes.keys()) {
607
+ for (const field of cls._indexFields.keys()) {
365
608
  if (changed.hasOwnProperty(field)) {
366
609
  throw new DatabaseError(`Cannot modify primary key field: ${field}`, "CHANGE_PRIMARY");
367
610
  }
@@ -371,27 +614,10 @@ export class Model {
371
614
  this.validate(true);
372
615
  // Update the primary index
373
616
  const pk = this._primaryKey;
374
- this.constructor._primary._write(txn, pk, this);
617
+ cls._writePK(txn, pk, this);
375
618
  // Update any secondaries with changed fields
376
- for (const index of this.constructor._secondaries || []) {
377
- if (index._computeFn) {
378
- // Computed indexes may depend on any field — compare serialized keys
379
- const oldKeyBytes = index._serializeKeyFields(oldValues).toUint8Array();
380
- const newKeyBytes = index._serializeKeyFields(this).toUint8Array();
381
- if (!bytesEqual(oldKeyBytes, newKeyBytes)) {
382
- index._delete(txn, pk, oldValues);
383
- index._write(txn, pk, this);
384
- }
385
- }
386
- else {
387
- for (const field of index._fieldTypes.keys()) {
388
- if (changed.hasOwnProperty(field)) {
389
- index._delete(txn, pk, oldValues);
390
- index._write(txn, pk, this);
391
- break;
392
- }
393
- }
394
- }
619
+ for (const index of Object.values(cls._secondaries || {})) {
620
+ index._update(txn, pk, this, oldValues);
395
621
  }
396
622
  return changed;
397
623
  }
@@ -402,56 +628,20 @@ export class Model {
402
628
  *
403
629
  * @example
404
630
  * ```typescript
405
- * const user = User.load("user123");
631
+ * const user = User.get("user123");
406
632
  * user.name = "New Name";
407
633
  * user.preventPersist(); // Changes won't be saved
408
634
  * ```
409
635
  */
410
636
  preventPersist() {
411
- this._txn.instances.delete(this);
637
+ if (this._oldValues === undefined && this._primaryKey !== undefined) {
638
+ throw new DatabaseError("Cannot preventPersist() after PK has been used", "INVALID");
639
+ }
640
+ this._oldValues = false;
412
641
  // Have access to '_txn' throw a descriptive error:
413
642
  Object.defineProperty(this, "_txn", PREVENT_PERSIST_DESCRIPTOR);
414
643
  return this;
415
644
  }
416
- /**
417
- * Find all instances of this model in the database, ordered by primary key.
418
- * @param opts - Optional parameters.
419
- * @param opts.reverse - If true, iterate in reverse order.
420
- * @returns An iterator.
421
- */
422
- static findAll(opts) {
423
- return this._primary.find(opts);
424
- }
425
- /**
426
- * Load an existing instance by primary key and update it, or create a new one.
427
- *
428
- * The provided object must contain all primary key fields. If a matching row exists,
429
- * the remaining properties from `obj` are set on the loaded instance. Otherwise a
430
- * new instance is created with `obj` as its initial properties.
431
- *
432
- * @param obj - Partial model data that **must** include every primary key field.
433
- * @returns The loaded-and-updated or newly created instance.
434
- */
435
- static replaceInto(obj) {
436
- const pk = this._primary;
437
- const keyArgs = [];
438
- for (const fieldName of pk._fieldTypes.keys()) {
439
- if (!(fieldName in obj)) {
440
- throw new DatabaseError(`replaceInto: missing primary key field '${fieldName}'`, "MISSING_PRIMARY_KEY");
441
- }
442
- keyArgs.push(obj[fieldName]);
443
- }
444
- const existing = pk.get(...keyArgs);
445
- if (existing) {
446
- for (const key in obj) {
447
- if (!pk._fieldTypes.has(key)) {
448
- existing[key] = obj[key];
449
- }
450
- }
451
- return existing;
452
- }
453
- return new this(obj);
454
- }
455
645
  /**
456
646
  * Delete this model instance from the database.
457
647
  *
@@ -459,18 +649,18 @@ export class Model {
459
649
  *
460
650
  * @example
461
651
  * ```typescript
462
- * const user = User.load("user123");
652
+ * const user = User.get("user123");
463
653
  * user.delete(); // Removes from database
464
654
  * ```
465
655
  */
466
656
  delete() {
467
657
  if (this._oldValues === undefined)
468
- throw new DatabaseError("Cannot delete unsaved instance", "NOT_SAVED");
658
+ throw new DatabaseError("Cannot delete unsaved instance", "INVALID");
469
659
  this._oldValues = null;
470
660
  }
471
661
  /**
472
662
  * Validate all fields in this model instance.
473
- * @param raise - If true, throw on first validation error.
663
+ * @param raise If true, throw on first validation error.
474
664
  * @returns Array of validation errors (empty if valid).
475
665
  *
476
666
  * @example
@@ -484,10 +674,11 @@ export class Model {
484
674
  */
485
675
  validate(raise = false) {
486
676
  const errors = [];
487
- for (const [key, fieldConfig] of Object.entries(this.constructor.fields)) {
677
+ const cls = this.constructor;
678
+ for (const [key, fieldConfig] of Object.entries(cls.fields)) {
488
679
  let e = fieldConfig.type.getError(this[key]);
489
680
  if (e) {
490
- e = addErrorPath(e, this.constructor.tableName + "." + key);
681
+ e = addErrorPath(e, cls.tableName + "." + key);
491
682
  if (raise)
492
683
  throw e;
493
684
  errors.push(e);
@@ -513,7 +704,7 @@ export class Model {
513
704
  return "deleted";
514
705
  if (this._oldValues === undefined)
515
706
  return "created";
516
- for (const [key, descr] of Object.entries(this.constructor._primary._lazyDescriptors)) {
707
+ for (const [key, descr] of Object.entries(this.constructor._lazyDescriptors)) {
517
708
  if (descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, key)?.get) {
518
709
  return "lazy";
519
710
  }
@@ -521,12 +712,49 @@ export class Model {
521
712
  return "loaded";
522
713
  }
523
714
  toString() {
524
- const primary = this.constructor._primary;
525
- const pk = primary._keyToArray(this._primaryKey || primary._serializeKeyFields(this).toUint8Array(false));
526
- return `{Model:${this.constructor.tableName} ${this.getState()} ${pk}}`;
715
+ const cls = this.constructor;
716
+ const pk = cls._pkToArray(this._primaryKey || cls._serializePK(this).toUint8Array(false));
717
+ return `{Model:${cls.tableName} ${this.getState()} ${pk}}`;
527
718
  }
528
719
  [Symbol.for('nodejs.util.inspect.custom')]() {
529
720
  return this.toString();
530
721
  }
531
722
  }
723
+ /**
724
+ * Delete every key/value entry in the database and reinitialize all registered models.
725
+ *
726
+ * This clears rows, index metadata, and schema-version records. It is mainly useful
727
+ * for tests, local resets, or tooling that needs a completely empty database.
728
+ */
729
+ export async function deleteEverything() {
730
+ let done = false;
731
+ while (!done) {
732
+ await transact(() => {
733
+ const txn = currentTxn();
734
+ const iteratorId = lowlevel.createIterator(txn.id, undefined, undefined, false);
735
+ const deadline = Date.now() + 150;
736
+ let count = 0;
737
+ try {
738
+ while (true) {
739
+ const raw = lowlevel.readIterator(iteratorId);
740
+ if (!raw) {
741
+ done = true;
742
+ break;
743
+ }
744
+ lowlevel.del(txn.id, raw.key);
745
+ if (++count >= 4096 || Date.now() >= deadline)
746
+ break;
747
+ }
748
+ }
749
+ finally {
750
+ lowlevel.closeIterator(iteratorId);
751
+ }
752
+ });
753
+ }
754
+ for (const model of Object.values(modelRegistry)) {
755
+ pendingModelInits.delete(model);
756
+ await model._initialize(true);
757
+ }
758
+ }
759
+ export const Model = ModelBase;
532
760
  //# sourceMappingURL=models.js.map