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
package/src/models.ts CHANGED
@@ -1,9 +1,13 @@
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";
3
+ import DataPack from "./datapack.js";
4
+ import { deserializeType, serializeType, TypeWrapper, identifier } from "./types.js";
5
+ import { transact, currentTxn, type Transaction } from "./edinburgh.js";
5
6
 
6
- export const txnStorage = new AsyncLocalStorage<Transaction>();
7
+ import { PrimaryKey, NonPrimaryIndex, IndexRangeIterator, UniqueIndex, SecondaryIndex, FindOptions, VersionInfo } from "./indexes.js";
8
+ import { addErrorPath, dbGet, hashBytes, hashFunction } from "./utils.js";
9
+
10
+ let nextFakePkHash = -1;
7
11
 
8
12
 
9
13
  const PREVENT_PERSIST_DESCRIPTOR = {
@@ -12,25 +16,6 @@ const PREVENT_PERSIST_DESCRIPTOR = {
12
16
  },
13
17
  };
14
18
 
15
- /**
16
- * Returns the current transaction from AsyncLocalStorage.
17
- * Throws if called outside a transact() callback.
18
- * @internal
19
- */
20
- export function currentTxn(): Transaction {
21
- const txn = txnStorage.getStore();
22
- if (!txn) throw new DatabaseError("No active transaction. Operations must be performed within a transact() callback.", 'NO_TRANSACTION');
23
- return txn;
24
- }
25
-
26
- export interface Transaction {
27
- id: number;
28
- instances: Set<Model<unknown>>;
29
- instancesByPk: Map<number, Model<unknown>>;
30
- }
31
- import { BaseIndex as BaseIndex, PrimaryIndex, IndexRangeIterator } from "./indexes.js";
32
- import { addErrorPath, logLevel, assert, dbGet, hashBytes, bytesEqual } from "./utils.js";
33
-
34
19
  /**
35
20
  * Configuration interface for model fields.
36
21
  * @template T - The field type.
@@ -52,16 +37,16 @@ export interface FieldConfig<T> {
52
37
  * This allows for both runtime introspection and compile-time type safety.
53
38
  *
54
39
  * @template T - The field type.
55
- * @param type - The type wrapper for this field.
56
- * @param options - Additional field configuration options.
40
+ * @param type The type wrapper for this field.
41
+ * @param options Additional field configuration options.
57
42
  * @returns The field value (typed as T, but actually returns FieldConfig<T>).
58
43
  *
59
44
  * @example
60
45
  * ```typescript
61
- * class User extends E.Model<User> {
46
+ * const User = E.defineModel("User", class {
62
47
  * name = E.field(E.string, {description: "User's full name"});
63
48
  * age = E.field(E.opt(E.number), {description: "User's age", default: 25});
64
- * }
49
+ * });
65
50
  * ```
66
51
  */
67
52
  export function field<T>(type: TypeWrapper<T>, options: Partial<FieldConfig<T>> = {}): T {
@@ -70,11 +55,8 @@ export function field<T>(type: TypeWrapper<T>, options: Partial<FieldConfig<T>>
70
55
  return options as any;
71
56
  }
72
57
 
73
- // Model registration and initialization
74
- export const modelRegistry: Record<string, typeof Model> = {};
75
-
76
58
  function isObjectEmpty(obj: object) {
77
- for (let _ of Object.keys(obj)) {
59
+ for (const _ of Object.keys(obj)) {
78
60
  return false;
79
61
  }
80
62
  return true;
@@ -82,91 +64,521 @@ function isObjectEmpty(obj: object) {
82
64
 
83
65
  export type Change = Record<any, any> | "created" | "deleted";
84
66
 
67
+ type FieldsOf<T> = T extends new () => infer I ? I : never;
68
+
69
+ type PKArgs<FIELDS, PK> =
70
+ PK extends readonly (keyof FIELDS & string)[]
71
+ ? { [I in keyof PK]: PK[I] extends keyof FIELDS ? FIELDS[PK[I]] : never }
72
+ : PK extends keyof FIELDS & string
73
+ ? [FIELDS[PK]]
74
+ : [string];
75
+
76
+ type IndexArgs<FIELDS, SPEC> =
77
+ SPEC extends readonly (keyof FIELDS & string)[]
78
+ ? { [I in keyof SPEC]: SPEC[I] extends keyof FIELDS ? FIELDS[SPEC[I]] : never }
79
+ : SPEC extends keyof FIELDS & string
80
+ ? [FIELDS[SPEC]]
81
+ : SPEC extends (instance: any) => infer R
82
+ ? R extends (infer V)[] ? [V] : [R]
83
+ : never;
84
+
85
85
  /**
86
- * Register a model class with the Edinburgh ORM system.
87
- *
88
- * @template T - The model class type.
89
- * @param MyModel - The model class to register.
90
- * @returns The enhanced model class with ORM capabilities.
91
- *
92
- * @example
93
- * ```typescript
94
- * ⁣@E.registerModel
95
- * class User extends E.Model<User> {
96
- * static pk = E.index(User, ["id"], "primary");
97
- * id = E.field(E.identifier);
98
- * name = E.field(E.string);
99
- * }
100
- * ```
86
+ * A model constructor with its generic information erased.
87
+ *
88
+ * Useful when accepting or storing arbitrary registered model classes.
101
89
  */
102
- export function registerModel<T extends typeof Model<unknown>>(MyModel: T): T {
103
- const MockModel = getMockModel(MyModel);
90
+ export type AnyModelClass = ModelClass<new () => any, readonly any[], any, any>;
104
91
 
105
- // Copy own static methods/properties
106
- for(const name of Object.getOwnPropertyNames(MyModel)) {
107
- if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock' && name !== 'override') {
108
- (MockModel as any)[name] = (MyModel as any)[name];
92
+ type SecondaryRegistry<FIELDS> = Record<string, NonPrimaryIndex<Model<FIELDS>, readonly (keyof FIELDS & string)[], readonly any[]>>;
93
+
94
+ function copyStaticMembersFromClassChain(target: object, source: Function) {
95
+ for (let current: any = source; current && current !== Function.prototype; current = Object.getPrototypeOf(current)) {
96
+ for (const key of Object.getOwnPropertyNames(current)) {
97
+ if (key === 'length' || key === 'name' || key === 'prototype') continue;
98
+ if (Object.prototype.hasOwnProperty.call(target, key)) continue;
99
+ Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(current, key)!);
109
100
  }
110
101
  }
111
- MockModel.tableName ||= MyModel.name;
102
+ }
112
103
 
113
- // Register the constructor by name
114
- if (MockModel.tableName in modelRegistry) {
115
- if (!(MyModel as any).override) {
116
- throw new DatabaseError(`Model with table name '${MockModel.tableName}' already registered`, 'INIT_ERROR');
104
+ // Model registration and initialization
105
+ export const modelRegistry: Record<string, AnyModelClass> = {};
106
+ export const pendingModelInits = new Set<AnyModelClass>();
107
+
108
+ // These static members are attached dynamically in defineModel(), so 'declare' tells TypeScript
109
+ // they exist at runtime without emitting duplicate class fields that would shadow those assignments.
110
+ class ModelClassRuntime<FIELDS, PKA extends readonly any[], UNIQUE = {}, INDEX = {}> extends PrimaryKey<Model<FIELDS>, readonly (keyof FIELDS & string)[], PKA> {
111
+ // Runtime table identifier used for index naming and diagnostics.
112
+ declare tableName: string;
113
+ // Field schema map used for validation and serialization.
114
+ declare fields: Record<string | symbol | number, FieldConfig<unknown>>;
115
+ // Registered unique/secondary indexes for this model.
116
+ declare _secondaries?: SecondaryRegistry<FIELDS>;
117
+ // Cached list of non-primary fields used for value serialization.
118
+ _nonKeyFields!: (keyof FIELDS & string)[];
119
+ // Lazy getter/setter descriptors installed on unloaded non-key fields.
120
+ _lazyDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
121
+ // Writable descriptors temporarily installed before hydrating value fields.
122
+ _resetDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
123
+ // Frozen descriptors applied to primary-key fields after key materialization.
124
+ _freezePrimaryKeyDescriptors: Record<string | symbol | number, PropertyDescriptor> = {};
125
+ // Active schema version number for value encoding.
126
+ _currentVersion!: number;
127
+ // Hash of the active migrate() function for schema identity.
128
+ _currentMigrateHash!: number;
129
+ // Cached historical schema metadata for lazy migration of old rows.
130
+ _versions: Map<number, VersionInfo> = new Map();
131
+
132
+ _serializeVersionValue(): Uint8Array {
133
+ const fields: [string, Uint8Array][] = [];
134
+ for (const fieldName of this._nonKeyFields) {
135
+ const tp = new DataPack();
136
+ serializeType(this.fields[fieldName].type, tp);
137
+ fields.push([fieldName, tp.toUint8Array()]);
117
138
  }
118
- delete modelRegistry[MockModel.tableName];
139
+ return new DataPack().write({
140
+ migrateHash: this._currentMigrateHash,
141
+ fields,
142
+ secondaryKeys: new Set(Object.values(this._secondaries || {}).map(sec => sec._signature!)),
143
+ }).toUint8Array();
119
144
  }
120
- modelRegistry[MockModel.tableName] = MockModel;
121
145
 
122
- return MockModel;
123
- }
146
+ async _initialize(reset = false): Promise<void> {
147
+ const allFieldTypes = new Map<string, TypeWrapper<any>>();
148
+ for (const [fieldName, fieldConfig] of Object.entries(this.fields)) {
149
+ allFieldTypes.set(fieldName, fieldConfig.type);
150
+ }
151
+ await super._initializeIndex(allFieldTypes, reset);
152
+
153
+ if (reset || this._nonKeyFields === undefined) {
154
+ this._nonKeyFields = Object.keys(this.fields).filter(fieldName => !this._indexFields.has(fieldName as any)) as any;
155
+ this._lazyDescriptors = {};
156
+ this._resetDescriptors = {};
157
+ this._freezePrimaryKeyDescriptors = {};
158
+
159
+ for (const fieldName of this._nonKeyFields) {
160
+ this._lazyDescriptors[fieldName] = {
161
+ configurable: true,
162
+ enumerable: true,
163
+ get(this: Model<FIELDS>) {
164
+ this.constructor._lazyLoad(this);
165
+ return this[fieldName];
166
+ },
167
+ set(this: Model<FIELDS>, value: any) {
168
+ this.constructor._lazyLoad(this);
169
+ this[fieldName] = value;
170
+ },
171
+ };
172
+ this._resetDescriptors[fieldName] = {
173
+ writable: true,
174
+ enumerable: true,
175
+ };
176
+ }
124
177
 
125
- export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
126
- const AnyOrgModel = OrgModel as any;
127
- if (AnyOrgModel._isMock) return OrgModel;
128
- if (AnyOrgModel._mock) return AnyOrgModel._mock;
178
+ for (const fieldName of this._indexFields.keys()) {
179
+ this._freezePrimaryKeyDescriptors[fieldName] = {
180
+ writable: false,
181
+ enumerable: true,
182
+ };
183
+ }
184
+ }
129
185
 
130
- const MockModel = function(this: any, initial?: Record<string,any> | undefined, txn: Transaction = currentTxn()) {
131
- // This constructor should only be called when the user does 'new Model'. We'll bypass this when
132
- // loading objects. Add to 'instances', so the object will be saved.
133
- this._txn = txn;
134
- txn.instances.add(this);
135
- if (initial) {
136
- Object.assign(this, initial);
137
- }
138
- } as any as T;
139
-
140
- // We want .constructor to point at our fake constructor function.
141
- OrgModel.prototype.constructor = MockModel as any;
142
-
143
- // Copy the prototype chain for the constructor as well as for instantiated objects
144
- Object.setPrototypeOf(MockModel, Object.getPrototypeOf(OrgModel));
145
- MockModel.prototype = OrgModel.prototype;
146
- (MockModel as any)._isMock = true;
147
- (MockModel as any)._original = OrgModel;
148
- AnyOrgModel._mock = MockModel;
149
- scheduleInit();
150
- return MockModel;
186
+ for (const sec of Object.values(this._secondaries || {})) {
187
+ await sec._initializeIndex(allFieldTypes, reset, this._indexFields);
188
+ }
189
+
190
+ const migrateFn = (this as any).migrate;
191
+ this._currentMigrateHash = migrateFn ? hashFunction(migrateFn) : 0;
192
+
193
+ const currentValueBytes = this._serializeVersionValue();
194
+ this._currentVersion = (await this._ensureVersionEntry(currentValueBytes)).version;
195
+ }
196
+
197
+ _getSecondary(name: string) {
198
+ const index = this._secondaries?.[name];
199
+ if (!index) throw new DatabaseError(`Unknown index '${name}' on model '${this.tableName}'`, 'INIT_ERROR');
200
+ return index;
201
+ }
202
+
203
+ _get(txn: Transaction, args: PKA | Uint8Array, loadNow: false | Uint8Array): Model<FIELDS>;
204
+ _get(txn: Transaction, args: PKA | Uint8Array, loadNow: true): Model<FIELDS> | undefined;
205
+ _get(txn: Transaction, args: PKA | Uint8Array, loadNow: boolean | Uint8Array): Model<FIELDS> | undefined {
206
+ let key: Uint8Array;
207
+ let keyParts: readonly any[] | undefined;
208
+ if (args instanceof Uint8Array) {
209
+ key = args;
210
+ } else {
211
+ key = this._argsToKeyBytes(args, false).toUint8Array();
212
+ keyParts = args;
213
+ }
214
+
215
+ const keyHash = hashBytes(key);
216
+ const cached = txn.instances.get(keyHash) as Model<FIELDS> | undefined;
217
+ if (cached) {
218
+ if (loadNow && loadNow !== true) {
219
+ Object.defineProperties(cached, this._resetDescriptors);
220
+ this._loadValueFields(cached, loadNow);
221
+ }
222
+ return cached;
223
+ }
224
+
225
+ let valueBuffer: Uint8Array | undefined;
226
+ if (loadNow) {
227
+ if (loadNow === true) {
228
+ valueBuffer = dbGet(txn.id, key);
229
+ if (!valueBuffer) return;
230
+ } else {
231
+ valueBuffer = loadNow;
232
+ }
233
+ }
234
+
235
+ const model = Object.create((this as any).prototype) as Model<FIELDS>;
236
+ model._txn = txn;
237
+ model._oldValues = {};
238
+ txn.instances.set(keyHash, model);
239
+
240
+ if (keyParts) {
241
+ let i = 0;
242
+ for (const fieldName of this._indexFields.keys()) {
243
+ model._setLoadedField(fieldName, keyParts[i++]);
244
+ }
245
+ } else {
246
+ const keyPack = new DataPack(key);
247
+ keyPack.readNumber();
248
+ for (const [fieldName, fieldType] of this._indexFields.entries()) {
249
+ model._setLoadedField(fieldName, fieldType.deserialize(keyPack));
250
+ }
251
+ }
252
+
253
+ model._setPrimaryKey(key, keyHash);
254
+ if (valueBuffer) {
255
+ this._loadValueFields(model, valueBuffer);
256
+ } else {
257
+ Object.defineProperties(model, this._lazyDescriptors);
258
+ }
259
+ return model;
260
+ }
261
+
262
+ _lazyLoad(model: Model<FIELDS>) {
263
+ const key = model._primaryKey!;
264
+ const valueBuffer = dbGet(model._txn.id, key);
265
+ if (!valueBuffer) throw new DatabaseError(`Lazy-loaded ${this.tableName}#${key} does not exist`, 'LAZY_FAIL');
266
+ Object.defineProperties(model, this._resetDescriptors);
267
+ this._loadValueFields(model, valueBuffer);
268
+ }
269
+
270
+ /**
271
+ * Load a model by primary key inside the current transaction.
272
+ *
273
+ * @returns The matching model, or `undefined` if no row exists.
274
+ */
275
+ get(...args: PKA): Model<FIELDS> | undefined {
276
+ return this._get(currentTxn(), args, true);
277
+ }
278
+
279
+ /**
280
+ * Load a model by primary key without fetching its non-key fields immediately.
281
+ *
282
+ * Accessing a lazy field later will load the remaining fields transparently.
283
+ */
284
+ getLazy(...args: PKA): Model<FIELDS> {
285
+ return this._get(currentTxn(), args, false);
286
+ }
287
+
288
+ _pairToInstance(txn: Transaction, keyBuffer: ArrayBuffer, valueBuffer: ArrayBuffer): Model<FIELDS> {
289
+ return this._get(txn, new Uint8Array(keyBuffer), new Uint8Array(valueBuffer))!;
290
+ }
291
+
292
+ /**
293
+ * Load an existing instance by primary key and update it, or create a new one.
294
+ * If a row already exists, its non-primary-key fields are updated in place.
295
+ * Otherwise, a new instance is created with `obj` as its initial properties.
296
+ *
297
+ * @param obj Partial model data that **must** include every primary key field.
298
+ * @returns The loaded-and-updated or newly created instance.
299
+ */
300
+ replaceInto(obj: Partial<FIELDS>): Model<FIELDS> {
301
+ const keyArgs: any[] = [];
302
+ for (const fieldName of this._indexFields.keys()) {
303
+ if (!(fieldName in (obj as any))) {
304
+ throw new DatabaseError(`replaceInto: missing primary key field '${fieldName}'`, "MISSING_PRIMARY_KEY");
305
+ }
306
+ keyArgs.push((obj as any)[fieldName]);
307
+ }
308
+ const existing = this.get(...keyArgs as any) as Model<FIELDS> | undefined;
309
+ if (existing) {
310
+ for (const key in obj as any) {
311
+ if (!this._indexFields.has(key as any)) {
312
+ (existing as any)[key] = (obj as any)[key];
313
+ }
314
+ }
315
+ return existing;
316
+ }
317
+ return new (this as any)(obj);
318
+ }
319
+
320
+ /**
321
+ * Look up a model through a named unique index.
322
+ *
323
+ * @param name The name from the model's `unique` definition.
324
+ * @param args The unique-index key values.
325
+ * @returns The matching model instance, if any.
326
+ */
327
+ getBy<K extends string & keyof UNIQUE>(name: K, ...args: IndexArgs<FIELDS, UNIQUE[K]>): Model<FIELDS> | undefined {
328
+ return (this._getSecondary(name) as any).getPK(...args);
329
+ }
330
+
331
+ /**
332
+ * Query rows through a named unique or secondary index.
333
+ *
334
+ * This mirrors `find()`, but targets a named entry from the model's `unique`
335
+ * or `index` registration.
336
+ */
337
+ findBy<K extends string & keyof (UNIQUE & INDEX)>(name: K, opts: FindOptions<IndexArgs<FIELDS, (UNIQUE & INDEX)[K]>, 'first'>): Model<FIELDS> | undefined;
338
+ findBy<K extends string & keyof (UNIQUE & INDEX)>(name: K, opts: FindOptions<IndexArgs<FIELDS, (UNIQUE & INDEX)[K]>, 'single'>): Model<FIELDS>;
339
+ findBy<K extends string & keyof (UNIQUE & INDEX)>(name: K, opts?: FindOptions<IndexArgs<FIELDS, (UNIQUE & INDEX)[K]>>): IndexRangeIterator<Model<FIELDS>>;
340
+ findBy(name: string, opts?: any): any {
341
+ return this._getSecondary(name).find(opts);
342
+ }
343
+
344
+ /**
345
+ * Process rows from a named unique or secondary index in batched transactions.
346
+ *
347
+ * Uses the same range options as `findBy()`, plus batch limits.
348
+ */
349
+ batchProcessBy<K extends string & keyof (UNIQUE & INDEX)>(
350
+ name: K,
351
+ opts: FindOptions<IndexArgs<FIELDS, (UNIQUE & INDEX)[K]>> & { limitSeconds?: number; limitRows?: number },
352
+ callback: (row: Model<FIELDS>) => any,
353
+ ): Promise<void> {
354
+ return this._getSecondary(name).batchProcess(opts, callback as any);
355
+ }
356
+
357
+ _loadValueFields(model: Model<FIELDS>, valueArray: Uint8Array) {
358
+ const valuePack = new DataPack(valueArray);
359
+ const version = valuePack.readNumber();
360
+
361
+ if (version === this._currentVersion) {
362
+ for (const fieldName of this._nonKeyFields) {
363
+ model._setLoadedField(fieldName, this.fields[fieldName].type.deserialize(valuePack));
364
+ }
365
+ } else {
366
+ this._migrateValueFields(model, version, valuePack);
367
+ }
368
+ }
369
+
370
+ _loadVersionInfo(txnId: number, version: number): VersionInfo {
371
+ let info = this._versions.get(version);
372
+ if (info) return info;
373
+
374
+ const key = this._versionInfoKey(version);
375
+ const raw = dbGet(txnId, key);
376
+ if (!raw) throw new DatabaseError(`Version ${version} info not found for index ${this}`, 'CONSISTENCY_ERROR');
377
+
378
+ const obj = new DataPack(raw).read() as any;
379
+ if (!obj || typeof obj.migrateHash !== 'number' || !Array.isArray(obj.fields) || !(obj.secondaryKeys instanceof Set)) {
380
+ throw new DatabaseError(`Version ${version} info is corrupted for index ${this}`, 'CONSISTENCY_ERROR');
381
+ }
382
+
383
+ const nonKeyFields = new Map<string, TypeWrapper<any>>();
384
+ for (const [name, typeBytes] of obj.fields) {
385
+ nonKeyFields.set(name, deserializeType(new DataPack(typeBytes), 0));
386
+ }
387
+
388
+ info = { migrateHash: obj.migrateHash, nonKeyFields, secondaryKeys: obj.secondaryKeys as Set<string> };
389
+ this._versions.set(version, info);
390
+ return info;
391
+ }
392
+
393
+ _migrateValueFields(model: Model<FIELDS>, version: number, valuePack: DataPack) {
394
+ const versionInfo = this._loadVersionInfo(model._txn.id, version);
395
+ const record: Record<string, any> = {};
396
+ for (const [name] of this._indexFields.entries()) record[name] = (model as any)[name];
397
+ for (const [name, type] of versionInfo.nonKeyFields.entries()) {
398
+ record[name] = type.deserialize(valuePack);
399
+ }
400
+
401
+ const migrateFn = (this as any).migrate;
402
+ if (migrateFn) migrateFn(record);
403
+
404
+ for (const fieldName of this._nonKeyFields) {
405
+ if (fieldName in record) {
406
+ model._setLoadedField(fieldName, record[fieldName]);
407
+ } else if (fieldName in model) {
408
+ model._setLoadedField(fieldName, (model as any)[fieldName]);
409
+ } else {
410
+ throw new DatabaseError(`Field ${fieldName} is missing in migrated data for ${model}`, 'MIGRATION_ERROR');
411
+ }
412
+ }
413
+ }
414
+
415
+ _serializeValue(data: Record<string, any>): Uint8Array {
416
+ const valueBytes = new DataPack();
417
+ valueBytes.write(this._currentVersion);
418
+ for (const fieldName of this._nonKeyFields) {
419
+ const fieldConfig = this.fields[fieldName] as FieldConfig<unknown>;
420
+ fieldConfig.type.serialize(data[fieldName], valueBytes);
421
+ }
422
+ return valueBytes.toUint8Array();
423
+ }
151
424
  }
152
425
 
153
- // Model base class and related symbols/state
154
- const INIT_INSTANCE_SYMBOL = Symbol();
426
+ /**
427
+ * Runtime base constructor for model classes returned by `defineModel()`.
428
+ *
429
+ * Prefer the `ModelClass` type alias for annotations and the result of
430
+ * `defineModel()` for concrete model classes.
431
+ */
432
+ export const ModelClass = ModelClassRuntime;
155
433
 
156
434
  /**
157
- * Model interface that ensures proper typing for the constructor property.
158
- * @template SUB - The concrete model subclass.
435
+ * The static side of a model class returned by `defineModel()`.
436
+ *
437
+ * Besides the class constructor itself, this includes primary-key lookup
438
+ * helpers like `get()` and `getLazy()`, range-query helpers like `find()`, and
439
+ * named-index helpers like `getBy()` and `findBy()`.
440
+ *
441
+ * @template T - The original class passed to `defineModel()`.
442
+ * @template PKA - Tuple of primary-key argument types.
443
+ * @template UNIQUE - Named unique-index specifications.
444
+ * @template INDEX - Named secondary-index specifications.
159
445
  */
160
- export interface Model<SUB> {
161
- constructor: typeof Model<SUB>;
446
+ export type ModelClass<T extends new () => any, PKA extends readonly any[], UNIQUE = {}, INDEX = {}> =
447
+ T
448
+ & ModelClassRuntime<FieldsOf<T>, PKA, UNIQUE, INDEX>
449
+ & {
450
+ new (initial?: Partial<FieldsOf<T>>, txn?: Transaction): Model<FieldsOf<T>>;
451
+ };
452
+
453
+ /**
454
+ * Minimal instance-side model shape used for typing the constructor property.
455
+ */
456
+ export interface ModelBase {
457
+ constructor: AnyModelClass;
458
+ }
459
+
460
+ /**
461
+ * Register a model class with the Edinburgh ORM system.
462
+ *
463
+ * Converts a plain class into a fully-featured model with database persistence,
464
+ * typed fields, primary key access, and optional secondary and unique indexes.
465
+ *
466
+ * @param tableName The database table name for this model.
467
+ * @param cls A plain class whose properties use E.field().
468
+ * @param opts Registration options.
469
+ * @param opts.pk Primary key field name or array of field names.
470
+ * @param opts.unique Named unique index specifications (field name, field array, or compute function).
471
+ * @param opts.index Named secondary index specifications (field name, field array, or compute function).
472
+ * @param opts.override Replace a previous model with the same table name.
473
+ * @returns The enhanced model constructor.
474
+ */
475
+ export function defineModel<
476
+ T extends new () => any,
477
+ const PK extends (keyof FieldsOf<T> & string) | readonly (keyof FieldsOf<T> & string)[],
478
+ const UNIQUE extends Record<string, (keyof FieldsOf<T> & string) | readonly (keyof FieldsOf<T> & string)[] | ((instance: any) => any)>,
479
+ const INDEX extends Record<string, (keyof FieldsOf<T> & string) | readonly (keyof FieldsOf<T> & string)[] | ((instance: any) => any)>,
480
+ >(
481
+ tableName: string,
482
+ cls: T,
483
+ opts?: { pk?: PK, unique?: UNIQUE, index?: INDEX, override?: boolean }
484
+ ): ModelClass<T, PKArgs<FieldsOf<T>, PK>, UNIQUE, INDEX> {
485
+ Object.setPrototypeOf(cls.prototype, ModelBase.prototype);
486
+ const MockModel = function(this: any, initial?: Record<string, any>, txn: Transaction = currentTxn()) {
487
+ this._txn = txn;
488
+ txn.instances.set(nextFakePkHash--, this);
489
+ if (initial) Object.assign(this, initial);
490
+ } as any;
491
+
492
+ const normalizeSpec = (spec: any) => typeof spec === 'string' ? [spec] : spec;
493
+ const queueInitialization = () => { pendingModelInits.add(MockModel); };
494
+ const loadPrimary = (txn: Transaction, primaryKey: Uint8Array, loadNow: boolean | Uint8Array) => MockModel._get(txn, primaryKey, loadNow);
495
+
496
+ cls.prototype.constructor = MockModel;
497
+ Object.setPrototypeOf(MockModel, ModelClassRuntime.prototype);
498
+ MockModel.prototype = cls.prototype;
499
+ copyStaticMembersFromClassChain(MockModel, cls);
500
+
501
+ MockModel.tableName = tableName;
502
+
503
+ if (MockModel.tableName in modelRegistry) {
504
+ if (!opts?.override) {
505
+ throw new DatabaseError(`Model with table name '${MockModel.tableName}' already registered`, 'INIT_ERROR');
506
+ }
507
+ delete modelRegistry[MockModel.tableName];
508
+ }
509
+
510
+ const instance = new cls();
511
+ if (!opts?.pk && !instance.id) {
512
+ instance.id = { type: identifier };
513
+ }
514
+
515
+ MockModel.fields = {};
516
+ for (const key in instance) {
517
+ const value = instance[key] as FieldConfig<unknown>;
518
+ if (value && value.type instanceof TypeWrapper) {
519
+ MockModel.fields[key] = value;
520
+
521
+ const defObj = value.default === undefined ? value.type : value;
522
+ const def = defObj.default;
523
+ if (typeof def === 'function') {
524
+ Object.defineProperty(MockModel.prototype, key, {
525
+ get() {
526
+ return (this[key] = def.call(defObj, this));
527
+ },
528
+ set(val: any) {
529
+ Object.defineProperty(this, key, {
530
+ value: val,
531
+ configurable: true,
532
+ writable: true,
533
+ enumerable: true,
534
+ });
535
+ },
536
+ configurable: true,
537
+ });
538
+ } else if (def !== undefined) {
539
+ MockModel.prototype[key] = def;
540
+ }
541
+ }
542
+ }
543
+
544
+ const primaryFields = opts?.pk ? (Array.isArray(opts.pk) ? opts.pk : [opts.pk]) : ['id'];
545
+ MockModel._indexFields = new Map();
546
+ for (const fieldName of primaryFields) {
547
+ const fieldConfig = MockModel.fields[fieldName];
548
+ if (!fieldConfig) {
549
+ throw new DatabaseError(`Unknown primary key field '${fieldName}' on model '${tableName}'`, 'INIT_ERROR');
550
+ }
551
+ MockModel._indexFields.set(fieldName, fieldConfig.type);
552
+ }
553
+
554
+ MockModel._secondaries = {};
555
+ MockModel._lazyDescriptors = {};
556
+ MockModel._resetDescriptors = {};
557
+ MockModel._freezePrimaryKeyDescriptors = {};
558
+ MockModel._versions = new Map();
559
+
560
+ if (opts?.unique) {
561
+ for (const [name, spec] of Object.entries<any>(opts.unique)) {
562
+ MockModel._secondaries[name] = new UniqueIndex(tableName, normalizeSpec(spec), loadPrimary, queueInitialization);
563
+ }
564
+ }
565
+ if (opts?.index) {
566
+ for (const [name, spec] of Object.entries<any>(opts.index)) {
567
+ MockModel._secondaries[name] = new SecondaryIndex(tableName, normalizeSpec(spec), loadPrimary, queueInitialization);
568
+ }
569
+ }
570
+
571
+ modelRegistry[MockModel.tableName] = MockModel;
572
+ pendingModelInits.add(MockModel);
573
+ return MockModel;
162
574
  }
163
575
 
164
576
  /**
165
577
  * Base class for all database models in the Edinburgh ORM.
166
578
  *
167
579
  * Models represent database entities with typed fields, automatic serialization,
168
- * change tracking, and relationship management. All model classes should extend
169
- * this base class and be decorated with `@E.registerModel`.
580
+ * change tracking, and relationship management. Model classes are created using
581
+ * `E.defineModel()`.
170
582
  *
171
583
  * ### Schema Evolution
172
584
  *
@@ -192,72 +604,46 @@ export interface Model<SUB> {
192
604
  *
193
605
  * - **`static migrate(record)`**: Called when deserializing rows written with an older schema
194
606
  * version. Receives a plain record object; mutate it in-place to match the current schema.
195
- * See {@link Model.migrate}.
196
607
  *
197
608
  * - **`preCommit()`**: Called on each modified instance right before the transaction commits.
198
609
  * Useful for computing derived fields, enforcing cross-field invariants, or creating related
199
- * instances. See {@link Model.preCommit}.
610
+ * instances.
200
611
  *
201
- * @template SUB - The concrete model subclass (for proper typing).
202
- *
203
612
  * @example
204
613
  * ```typescript
205
- * ⁣@E.registerModel
206
- * class User extends E.Model<User> {
207
- * static pk = E.primary(User, "id");
208
- *
614
+ * const User = E.defineModel("User", class {
209
615
  * id = E.field(E.identifier);
210
616
  * name = E.field(E.string);
211
617
  * email = E.field(E.string);
212
- *
213
- * static byEmail = E.unique(User, "email");
214
- * }
618
+ * }, {
619
+ * pk: "id",
620
+ * unique: { email: "email" },
621
+ * });
622
+ * // Optional: declare a companion type so `let u: User` works.
623
+ * // Not needed if you only use `new User()`, `User.find()`, etc.
624
+ * type User = InstanceType<typeof User>;
215
625
  * ```
216
626
  */
217
-
218
-
219
- export abstract class Model<SUB> {
220
- static _primary: PrimaryIndex<any, any>;
221
-
222
- /** @internal All non-primary indexes for this model. */
223
- static _secondaries?: BaseIndex<any, readonly (keyof any & string)[]>[];
224
-
225
- /** The database table name (defaults to class name). */
226
- static tableName: string;
227
-
228
- /** When true, registerModel replaces an existing model with the same tableName. */
229
- static override?: boolean;
230
-
231
- /** Field configuration metadata. */
232
- static fields: Record<string | symbol | number, FieldConfig<unknown>>;
233
-
627
+ export abstract class ModelBase {
234
628
  /**
235
629
  * Optional migration function called when deserializing rows written with an older schema version.
236
- * Receives a plain record with all fields (primary key fields + value fields) and should mutate it
237
- * in-place to match the current schema.
238
- *
239
- * This is called both during lazy loading (when a row is read from disk) and during batch
240
- * migration (via `runMigration()` / `npx migrate-edinburgh`). The function's source code is hashed
241
- * to detect changes. Modifying `migrate()` triggers a new schema version.
630
+ * Receives a plain record with all fields and should mutate it in-place to match the current schema.
631
+ * It runs during lazy loading and during `runMigration()`. Changing this method creates a new schema version.
632
+ * If it updates values used by secondary or unique indexes, those index entries are refreshed only by `runMigration()`.
242
633
  *
243
- * If `migrate()` changes values of fields used in secondary or unique indexes, those indexes
244
- * will only be updated when `runMigration()` is run (not during lazy loading).
245
- *
246
- * @param record - A plain object with all field values from the old schema version.
634
+ * @param record A plain object containing the row's field values from the older schema version.
247
635
  *
248
636
  * @example
249
637
  * ```typescript
250
- * ⁣@E.registerModel
251
- * class User extends E.Model<User> {
252
- * static pk = E.primary(User, "id");
638
+ * const User = E.defineModel("User", class {
253
639
  * id = E.field(E.identifier);
254
640
  * name = E.field(E.string);
255
- * role = E.field(E.string); // new field
641
+ * role = E.field(E.string);
256
642
  *
257
643
  * static migrate(record: Record<string, any>) {
258
- * record.role ??= "user"; // default for rows that predate the 'role' field
644
+ * record.role ??= "user";
259
645
  * }
260
- * }
646
+ * }, { pk: "id" });
261
647
  * ```
262
648
  */
263
649
  static migrate?(record: Record<string, any>): void;
@@ -272,20 +658,14 @@ export abstract class Model<SUB> {
272
658
  * @internal
273
659
  * - _oldValues===undefined: New instance, not yet saved.
274
660
  * - _oldValues===null: Instance is to be deleted.
661
+ * - _oldValues===false: Instance excluded from persistence (preventPersist).
275
662
  * - _oldValues is an object: Loaded (possibly only partial, still lazy) from disk, _oldValues contains (partial) old values
276
663
  */
277
- _oldValues: Record<string, any> | undefined | null;
664
+ _oldValues: Record<string, any> | undefined | null | false;
278
665
  _primaryKey: Uint8Array | undefined;
279
666
  _primaryKeyHash: number | undefined;
280
667
  _txn!: Transaction;
281
668
 
282
- constructor(initial: Partial<Omit<SUB, "constructor">> = {}) {
283
- // This constructor will only be called once, from `initModels`. All other instances will
284
- // be created by the 'fake' constructor. The typing for `initial` *is* important though.
285
- if (initial as any === INIT_INSTANCE_SYMBOL) return;
286
- throw new DatabaseError("The model needs a @E.registerModel decorator", 'INIT_ERROR');
287
- }
288
-
289
669
  /**
290
670
  * Optional hook called on each modified instance right before the transaction commits.
291
671
  * Runs before data is written to disk, so changes made here are included in the commit.
@@ -298,9 +678,7 @@ export abstract class Model<SUB> {
298
678
  *
299
679
  * @example
300
680
  * ```typescript
301
- * ⁣@E.registerModel
302
- * class Post extends E.Model<Post> {
303
- * static pk = E.primary(Post, "id");
681
+ * const Post = E.defineModel("Post", class {
304
682
  * id = E.field(E.identifier);
305
683
  * title = E.field(E.string);
306
684
  * slug = E.field(E.string);
@@ -308,94 +686,16 @@ export abstract class Model<SUB> {
308
686
  * preCommit() {
309
687
  * this.slug = this.title.toLowerCase().replace(/\s+/g, "-");
310
688
  * }
311
- * }
689
+ * }, { pk: "id" });
312
690
  * ```
313
691
  */
314
692
  preCommit?(): void;
315
693
 
316
- /**
317
- * Transform the model's `E.field` properties into the appropriate JavaScript properties. Normally this is done
318
- * automatically when using `transact()`, but in case you need to access `Model.fields` directly before the first
319
- * transaction, you can call this method manually.
320
- */
321
- static initFields(reset?: boolean): void {
322
- const MockModel = getMockModel(this);
323
-
324
- if (reset) {
325
- MockModel._primary._indexId = undefined;
326
- MockModel._primary._versions.clear();
327
- for (const sec of MockModel._secondaries || []) sec._indexId = undefined;
328
- }
329
-
330
- if (MockModel.fields) return;
331
-
332
- // First-time init: gather field configs from a temporary instance of the original class.
333
- const OrgModel = (MockModel as any)._original || this;
334
- const instance = new (OrgModel as any)(INIT_INSTANCE_SYMBOL);
335
-
336
- // If no primary key exists, create one using 'id' field
337
- if (!MockModel._primary) {
338
- if (!instance.id) {
339
- instance.id = { type: identifier };
340
- }
341
- // @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
342
- new PrimaryIndex(MockModel, ['id']);
343
- }
344
-
345
- MockModel.fields = {};
346
- for (const key in instance) {
347
- const value = instance[key] as FieldConfig<unknown>;
348
- // Check if this property contains field metadata
349
- if (value && value.type instanceof TypeWrapper) {
350
- // Set the configuration on the constructor's `fields` property
351
- MockModel.fields[key] = value;
352
-
353
- // Set default value on the prototype
354
- const defObj = value.default===undefined ? value.type : value;
355
- const def = defObj.default;
356
- if (typeof def === 'function') {
357
- // The default is a function. We'll define a getter on the property in the model prototype,
358
- // and once it is read, we'll run the function and set the value as a plain old property
359
- // on the instance object.
360
- Object.defineProperty(MockModel.prototype, key, {
361
- get() {
362
- // This will call set(), which will define the property on the instance.
363
- return (this[key] = def.call(defObj, this));
364
- },
365
- set(val: any) {
366
- Object.defineProperty(this, key, {
367
- value: val,
368
- configurable: true,
369
- writable: true,
370
- enumerable: true,
371
- })
372
- },
373
- configurable: true,
374
- });
375
- } else if (def !== undefined) {
376
- (MockModel.prototype as any)[key] = def;
377
- }
378
- }
379
- }
380
-
381
- if (logLevel >= 1) {
382
- console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
383
- }
384
- }
385
-
386
- static async _loadCreateIndexes(): Promise<void> {
387
- const MockModel = getMockModel(this);
388
- // Always run index inits (idempotent, skip if already initialized)
389
- await MockModel._primary._delayedInit();
390
- for (const sec of MockModel._secondaries || []) await sec._delayedInit();
391
- await MockModel._primary._initVersioning();
392
- }
393
-
394
694
  _setLoadedField(fieldName: string, value: any) {
395
- const oldValues = this._oldValues!;
695
+ const oldValues = this._oldValues! as Record<string, any>;
396
696
  if (oldValues.hasOwnProperty(fieldName)) return; // Already loaded earlier (as part of index key?)
397
697
 
398
- this[fieldName as keyof Model<SUB>] = value;
698
+ (this as any)[fieldName] = value;
399
699
  if (typeof value === 'object' && value !== null) {
400
700
  const fieldType = (this.constructor.fields[fieldName] as FieldConfig<unknown>).type;
401
701
  oldValues[fieldName] = fieldType.clone(value);
@@ -405,13 +705,24 @@ export abstract class Model<SUB> {
405
705
  }
406
706
  }
407
707
 
708
+ _restoreLazyFields() {
709
+ const oldValues = this._oldValues;
710
+ if (!oldValues || oldValues === null) return;
711
+ for (const [fieldName, descriptor] of Object.entries(this.constructor._lazyDescriptors)) {
712
+ if (!oldValues.hasOwnProperty(fieldName)) {
713
+ Object.defineProperty(this, fieldName, descriptor);
714
+ }
715
+ }
716
+ }
717
+
408
718
  /**
409
719
  * @returns The primary key for this instance.
410
720
  */
411
721
  getPrimaryKey(): Uint8Array {
412
722
  let key = this._primaryKey;
413
723
  if (key === undefined) {
414
- key = this.constructor._primary!._serializeKeyFields(this).toUint8Array();
724
+ if (this._oldValues === false) throw new DatabaseError("Operation not allowed after preventPersist()", "NO_PERSIST");
725
+ key = this.constructor._serializePK(this).toUint8Array();
415
726
  this._setPrimaryKey(key);
416
727
  }
417
728
  return key;
@@ -420,7 +731,7 @@ export abstract class Model<SUB> {
420
731
  _setPrimaryKey(key: Uint8Array, hash?: number) {
421
732
  this._primaryKey = key;
422
733
  this._primaryKeyHash = hash ?? hashBytes(key);
423
- Object.defineProperties(this, this.constructor._primary._freezePrimaryKeyDescriptors);
734
+ Object.defineProperties(this, this.constructor._freezePrimaryKeyDescriptors);
424
735
  }
425
736
 
426
737
  /**
@@ -432,21 +743,23 @@ export abstract class Model<SUB> {
432
743
  }
433
744
 
434
745
  isLazyField(field: keyof this) {
435
- const descr = this.constructor._primary!._lazyDescriptors[field];
436
- return !!(descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, field)?.get);
746
+ const oldValues = this._oldValues;
747
+ return !!(oldValues && oldValues !== null && field in this.constructor._lazyDescriptors && !oldValues.hasOwnProperty(field));
437
748
  }
438
749
 
439
750
  _write(txn: Transaction): undefined | Change {
440
751
  const oldValues = this._oldValues;
441
752
 
753
+ if (oldValues === false) return; // preventPersist() was called
754
+
442
755
  if (oldValues === null) { // Delete instance
443
756
  const pk = this._primaryKey;
444
757
  // Temporarily restore _oldValues so computed indexes can trigger lazy loads
445
758
  this._oldValues = {};
446
- for(const index of this.constructor._secondaries || []) {
759
+ for (const index of Object.values(this.constructor._secondaries || {})) {
447
760
  index._delete(txn, pk!, this);
448
761
  }
449
- this.constructor._primary._delete(txn, pk!, this);
762
+ this.constructor._deletePK(txn, pk!, this);
450
763
 
451
764
  return "deleted";
452
765
  }
@@ -461,10 +774,10 @@ export abstract class Model<SUB> {
461
774
  }
462
775
 
463
776
  // Insert the primary index
464
- this.constructor._primary!._write(txn, pk!, this);
777
+ this.constructor._writePK(txn, pk!, this);
465
778
 
466
779
  // Insert all secondaries
467
- for (const index of this.constructor._secondaries || []) {
780
+ for (const index of Object.values(this.constructor._secondaries || {})) {
468
781
  index._write(txn, pk!, this);
469
782
  }
470
783
 
@@ -476,11 +789,12 @@ export abstract class Model<SUB> {
476
789
  // the whole object just to see if something changed.
477
790
 
478
791
  // Add old values of changed fields to 'changed'.
479
- const fields = this.constructor.fields;
480
- let changed : Record<any, any> = {};
792
+ const changed: Record<string, any> = {};
793
+ const cls = this.constructor;
794
+ const fields = cls.fields;
481
795
  for(const fieldName in oldValues) {
482
796
  const oldValue = oldValues[fieldName];
483
- const newValue = this[fieldName as keyof Model<SUB>];
797
+ const newValue = this[fieldName as keyof ModelBase];
484
798
  if (newValue !== oldValue && !(fields[fieldName] as FieldConfig<unknown>).type.equals(newValue, oldValue)) {
485
799
  changed[fieldName] = oldValue;
486
800
  }
@@ -489,7 +803,7 @@ export abstract class Model<SUB> {
489
803
  if (isObjectEmpty(changed)) return; // No changes, nothing to do
490
804
 
491
805
  // Make sure primary has not been changed
492
- for (const field of this.constructor._primary!._fieldTypes.keys()) {
806
+ for (const field of cls._indexFields.keys()) {
493
807
  if (changed.hasOwnProperty(field)) {
494
808
  throw new DatabaseError(`Cannot modify primary key field: ${field}`, "CHANGE_PRIMARY");
495
809
  }
@@ -502,27 +816,11 @@ export abstract class Model<SUB> {
502
816
 
503
817
  // Update the primary index
504
818
  const pk = this._primaryKey!;
505
- this.constructor._primary!._write(txn, pk, this);
819
+ cls._writePK(txn, pk, this);
506
820
 
507
821
  // Update any secondaries with changed fields
508
- for (const index of this.constructor._secondaries || []) {
509
- if (index._computeFn) {
510
- // Computed indexes may depend on any field — compare serialized keys
511
- const oldKeyBytes = index._serializeKeyFields(oldValues).toUint8Array();
512
- const newKeyBytes = index._serializeKeyFields(this as any).toUint8Array();
513
- if (!bytesEqual(oldKeyBytes, newKeyBytes)) {
514
- index._delete(txn, pk, oldValues);
515
- index._write(txn, pk, this);
516
- }
517
- } else {
518
- for (const field of index._fieldTypes.keys()) {
519
- if (changed.hasOwnProperty(field)) {
520
- index._delete(txn, pk, oldValues);
521
- index._write(txn, pk, this);
522
- break;
523
- }
524
- }
525
- }
822
+ for (const index of Object.values(cls._secondaries || {})) {
823
+ index._update(txn, pk, this, oldValues);
526
824
  }
527
825
  return changed;
528
826
  }
@@ -534,60 +832,21 @@ export abstract class Model<SUB> {
534
832
  *
535
833
  * @example
536
834
  * ```typescript
537
- * const user = User.load("user123");
835
+ * const user = User.get("user123");
538
836
  * user.name = "New Name";
539
837
  * user.preventPersist(); // Changes won't be saved
540
838
  * ```
541
839
  */
542
840
  preventPersist() {
543
- this._txn.instances.delete(this);
841
+ if (this._oldValues === undefined && this._primaryKey !== undefined) {
842
+ throw new DatabaseError("Cannot preventPersist() after PK has been used", "INVALID");
843
+ }
844
+ this._oldValues = false;
544
845
  // Have access to '_txn' throw a descriptive error:
545
846
  Object.defineProperty(this, "_txn", PREVENT_PERSIST_DESCRIPTOR);
546
847
  return this;
547
848
  }
548
849
 
549
- /**
550
- * Find all instances of this model in the database, ordered by primary key.
551
- * @param opts - Optional parameters.
552
- * @param opts.reverse - If true, iterate in reverse order.
553
- * @returns An iterator.
554
- */
555
- static findAll<T extends typeof Model<unknown>>(this: T, opts?: {reverse?: boolean}): IndexRangeIterator<T> {
556
- return this._primary!.find(opts);
557
- }
558
-
559
- /**
560
- * Load an existing instance by primary key and update it, or create a new one.
561
- *
562
- * The provided object must contain all primary key fields. If a matching row exists,
563
- * the remaining properties from `obj` are set on the loaded instance. Otherwise a
564
- * new instance is created with `obj` as its initial properties.
565
- *
566
- * @param obj - Partial model data that **must** include every primary key field.
567
- * @returns The loaded-and-updated or newly created instance.
568
- */
569
- static replaceInto<T extends typeof Model<any>>(this: T, obj: Partial<Omit<InstanceType<T>, "constructor">>): InstanceType<T> {
570
- const pk = this._primary!;
571
- const keyArgs = [];
572
- for (const fieldName of pk._fieldTypes.keys()) {
573
- if (!(fieldName in (obj as any))) {
574
- throw new DatabaseError(`replaceInto: missing primary key field '${fieldName}'`, "MISSING_PRIMARY_KEY");
575
- }
576
- keyArgs.push((obj as any)[fieldName]);
577
- }
578
-
579
- const existing = pk.get(...keyArgs as any) as InstanceType<T> | undefined;
580
- if (existing) {
581
- for (const key in obj as any) {
582
- if (!pk._fieldTypes.has(key as any)) {
583
- (existing as any)[key] = (obj as any)[key];
584
- }
585
- }
586
- return existing;
587
- }
588
- return new (this as any)(obj) as InstanceType<T>;
589
- }
590
-
591
850
  /**
592
851
  * Delete this model instance from the database.
593
852
  *
@@ -595,18 +854,18 @@ export abstract class Model<SUB> {
595
854
  *
596
855
  * @example
597
856
  * ```typescript
598
- * const user = User.load("user123");
857
+ * const user = User.get("user123");
599
858
  * user.delete(); // Removes from database
600
859
  * ```
601
860
  */
602
861
  delete() {
603
- if (this._oldValues === undefined) throw new DatabaseError("Cannot delete unsaved instance", "NOT_SAVED");
862
+ if (this._oldValues === undefined) throw new DatabaseError("Cannot delete unsaved instance", "INVALID");
604
863
  this._oldValues = null;
605
864
  }
606
865
 
607
866
  /**
608
867
  * Validate all fields in this model instance.
609
- * @param raise - If true, throw on first validation error.
868
+ * @param raise If true, throw on first validation error.
610
869
  * @returns Array of validation errors (empty if valid).
611
870
  *
612
871
  * @example
@@ -620,11 +879,12 @@ export abstract class Model<SUB> {
620
879
  */
621
880
  validate(raise: boolean = false): Error[] {
622
881
  const errors: Error[] = [];
882
+ const cls = this.constructor;
623
883
 
624
- for (const [key, fieldConfig] of Object.entries(this.constructor.fields)) {
884
+ for (const [key, fieldConfig] of Object.entries(cls.fields)) {
625
885
  let e = fieldConfig.type.getError((this as any)[key]);
626
886
  if (e) {
627
- e = addErrorPath(e, this.constructor.tableName+"."+key);
887
+ e = addErrorPath(e, cls.tableName+"."+key);
628
888
  if (raise) throw e;
629
889
  errors.push(e as Error);
630
890
  }
@@ -649,7 +909,7 @@ export abstract class Model<SUB> {
649
909
  getState(): "deleted" | "created" | "loaded" | "lazy" {
650
910
  if (this._oldValues === null) return "deleted";
651
911
  if (this._oldValues === undefined) return "created";
652
- for(const [key,descr] of Object.entries(this.constructor._primary!._lazyDescriptors)) {
912
+ for(const [key,descr] of Object.entries(this.constructor._lazyDescriptors)) {
653
913
  if (descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, key)?.get) {
654
914
  return "lazy";
655
915
  }
@@ -658,12 +918,55 @@ export abstract class Model<SUB> {
658
918
  }
659
919
 
660
920
  toString(): string {
661
- const primary = this.constructor._primary;
662
- const pk = primary._keyToArray(this._primaryKey || primary._serializeKeyFields(this).toUint8Array(false));
663
- return `{Model:${this.constructor.tableName} ${this.getState()} ${pk}}`;
921
+ const cls = this.constructor;
922
+ const pk = cls._pkToArray(this._primaryKey || cls._serializePK(this).toUint8Array(false));
923
+ return `{Model:${cls.tableName} ${this.getState()} ${pk}}`;
664
924
  }
665
925
 
666
926
  [Symbol.for('nodejs.util.inspect.custom')]() {
667
927
  return this.toString();
668
928
  }
669
929
  }
930
+
931
+ /**
932
+ * Delete every key/value entry in the database and reinitialize all registered models.
933
+ *
934
+ * This clears rows, index metadata, and schema-version records. It is mainly useful
935
+ * for tests, local resets, or tooling that needs a completely empty database.
936
+ */
937
+ export async function deleteEverything(): Promise<void> {
938
+ let done = false;
939
+ while (!done) {
940
+ await transact(() => {
941
+ const txn = currentTxn();
942
+ const iteratorId = lowlevel.createIterator(txn.id, undefined, undefined, false);
943
+ const deadline = Date.now() + 150;
944
+ let count = 0;
945
+ try {
946
+ while (true) {
947
+ const raw = lowlevel.readIterator(iteratorId);
948
+ if (!raw) {
949
+ done = true;
950
+ break;
951
+ }
952
+ lowlevel.del(txn.id, raw.key);
953
+ if (++count >= 4096 || Date.now() >= deadline) break;
954
+ }
955
+ } finally {
956
+ lowlevel.closeIterator(iteratorId);
957
+ }
958
+ });
959
+ }
960
+
961
+ for (const model of Object.values(modelRegistry)) {
962
+ pendingModelInits.delete(model);
963
+ await model._initialize(true);
964
+ }
965
+ }
966
+
967
+ /**
968
+ * A model instance, including its user-defined fields.
969
+ * @template FIELDS - The fields defined on this model.
970
+ */
971
+ export type Model<FIELDS> = FIELDS & ModelBase;
972
+ export const Model = ModelBase;