edinburgh 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/models.ts ADDED
@@ -0,0 +1,519 @@
1
+ import { DatabaseError } from "olmdb";
2
+ import * as olmdb from "olmdb";
3
+ import { TypeWrapper, identifier } from "./types.js";
4
+ import { BaseIndex, TARGET_SYMBOL, PrimaryIndex } from "./indexes.js";
5
+ import { assert, addErrorPath, logLevel } from "./utils.js";
6
+
7
+ /**
8
+ * Configuration interface for model fields.
9
+ * @template T - The field type.
10
+ */
11
+ export interface FieldConfig<T> {
12
+ /** The type wrapper that defines how this field is serialized/validated. */
13
+ type: TypeWrapper<T>;
14
+ /** Optional human-readable description of the field. */
15
+ description?: string;
16
+ /** Optional default value or function that generates default values. */
17
+ default?: T | ((model: Record<string,any>) => T);
18
+ }
19
+
20
+ /**
21
+ * Create a field definition for a model property.
22
+ *
23
+ * This function uses TypeScript magic to return the field configuration object
24
+ * while appearing to return the actual field value type to the type system.
25
+ * This allows for both runtime introspection and compile-time type safety.
26
+ *
27
+ * @template T - The field type.
28
+ * @param type - The type wrapper for this field.
29
+ * @param options - Additional field configuration options.
30
+ * @returns The field value (typed as T, but actually returns FieldConfig<T>).
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * class User extends E.Model<User> {
35
+ * name = E.field(E.string, {description: "User's full name"});
36
+ * age = E.field(E.opt(E.number), {description: "User's age", default: 25});
37
+ * }
38
+ * ```
39
+ */
40
+ export function field<T>(type: TypeWrapper<T>, options: Partial<FieldConfig<T>> = {}): T {
41
+ // Return the config object, but TypeScript sees it as type T
42
+ options.type = type;
43
+ return options as any;
44
+ }
45
+
46
+ // Model registration and initialization
47
+ let uninitializedModels = new Set<typeof Model<unknown>>();
48
+ export const modelRegistry: Record<string, typeof Model> = {};
49
+
50
+ export function resetModelCaches() {
51
+ for(const model of Object.values(modelRegistry)) {
52
+ for(const index of model._indexes || []) {
53
+ index._cachedIndexId = undefined;
54
+ }
55
+ }
56
+ }
57
+
58
+ function isObjectEmpty(obj: object) {
59
+ for (let key in obj) {
60
+ if (obj.hasOwnProperty(key)) return false;
61
+ }
62
+ return true;
63
+ }
64
+
65
+ type OnSaveType = (model: InstanceType<typeof Model>, newKey: Uint8Array | undefined, oldKey: Uint8Array | undefined) => void;
66
+ let onSave: OnSaveType | undefined;
67
+ /**
68
+ * Set a callback function to be called after a model is saved and committed.
69
+ *
70
+ * @param callback The callback function to set. As arguments, it receives the model instance, the new key (undefined in case of a delete), and the old key (undefined in case of a create).
71
+ */
72
+ export function setOnSaveCallback(callback: OnSaveType | undefined) {
73
+ onSave = callback;
74
+ }
75
+ const onSaveQueue: [InstanceType<typeof Model>, Uint8Array | undefined, Uint8Array | undefined][] = [];
76
+ function onSaveRevert() {
77
+ onSaveQueue.length = 0;
78
+ }
79
+ function onSaveCommit() {
80
+ if (onSave) {
81
+ for(let arr of onSaveQueue) {
82
+ onSave(...arr);
83
+ }
84
+ }
85
+ onSaveQueue.length = 0;
86
+ }
87
+ function queueOnSave(arr: [InstanceType<typeof Model>, Uint8Array | undefined, Uint8Array | undefined]) {
88
+ if (onSave) {
89
+ if (!onSaveQueue.length) {
90
+ olmdb.onCommit(onSaveCommit);
91
+ olmdb.onRevert(onSaveRevert);
92
+ }
93
+ onSaveQueue.push(arr);
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Register a model class with the Edinburgh ORM system.
99
+ *
100
+ * This decorator function transforms the model class to use a proxy-based constructor
101
+ * that enables change tracking and automatic field initialization. It also extracts
102
+ * field metadata and sets up default values on the prototype.
103
+ *
104
+ * @template T - The model class type.
105
+ * @param MyModel - The model class to register.
106
+ * @returns The enhanced model class with ORM capabilities.
107
+ *
108
+ * @example
109
+ * ```typescript
110
+ * ⁣@E.registerModel
111
+ * class User extends E.Model<User> {
112
+ * static pk = E.index(User, ["id"], "primary");
113
+ * id = E.field(E.identifier);
114
+ * name = E.field(E.string);
115
+ * }
116
+ * ```
117
+ */
118
+ export function registerModel<T extends typeof Model<unknown>>(MyModel: T): T {
119
+ const MockModel = getMockModel(MyModel);
120
+
121
+ // Copy own static methods/properties
122
+ for(const name of Object.getOwnPropertyNames(MyModel)) {
123
+ if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock') {
124
+ (MockModel as any)[name] = (MyModel as any)[name];
125
+ }
126
+ }
127
+
128
+ // Initialize an empty `fields` object, and set it on both constructors, as well as on the prototype.
129
+ MockModel.fields = MockModel.prototype._fields = {};
130
+ MockModel.tableName ||= MyModel.name; // Set the table name to the class name if not already set
131
+
132
+ // Register the constructor by name
133
+ if (MockModel.tableName in modelRegistry) throw new DatabaseError(`Model with table name '${MockModel.tableName}' already registered`, 'INIT_ERROR');
134
+ modelRegistry[MockModel.tableName] = MockModel;
135
+
136
+ // Attempt to instantiate the class and gather field metadata
137
+ uninitializedModels.add(MyModel);
138
+ initModels();
139
+
140
+ return MockModel;
141
+ }
142
+
143
+ export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
144
+ const AnyOrgModel = OrgModel as any;
145
+ if (AnyOrgModel._isMock) return OrgModel;
146
+ if (AnyOrgModel._mock) return AnyOrgModel._mock;
147
+
148
+ const MockModel = function (this: any, initial?: Record<string,any>) {
149
+ if (uninitializedModels.has(this.constructor)) {
150
+ throw new DatabaseError("Cannot instantiate while linked models haven't been registered yet", 'INIT_ERROR');
151
+ }
152
+ if (initial && !isObjectEmpty(initial)) {
153
+ Object.assign(this, initial);
154
+ const modifiedInstances = olmdb.getTransactionData(MODIFIED_INSTANCES_SYMBOL) as Set<Model<any>>;
155
+ modifiedInstances.add(this);
156
+ }
157
+
158
+ return new Proxy(this, modificationTracker);
159
+ } as any as T;
160
+
161
+ // We want .constructor to point at our fake constructor function.
162
+ OrgModel.prototype.constructor = MockModel as any;
163
+
164
+ // Copy the prototype chain for the constructor as well as for instantiated objects
165
+ Object.setPrototypeOf(MockModel, Object.getPrototypeOf(OrgModel));
166
+ MockModel.prototype = OrgModel.prototype;
167
+ (MockModel as any)._isMock = true;
168
+ AnyOrgModel._mock = MockModel;
169
+ return MockModel;
170
+ }
171
+
172
+ function initModels() {
173
+ for(const OrgModel of uninitializedModels) {
174
+ const MockModel = getMockModel(OrgModel);
175
+ // Create an instance (the only one to ever exist) of the actual class,
176
+ // in order to gather field config data.
177
+ let instance;
178
+ try {
179
+ instance = new (OrgModel as any)(INIT_INSTANCE_SYMBOL);
180
+ } catch(e) {
181
+ if (!(e instanceof ReferenceError)) throw e;
182
+ // ReferenceError: Cannot access 'SomeLinkedClass' before initialization.
183
+ // We'll try again after the next class has successfully initialized.
184
+ continue;
185
+ }
186
+
187
+ uninitializedModels.delete(OrgModel);
188
+
189
+ // If no primary key exists, create one using 'id' field
190
+ if (!MockModel._pk) {
191
+ // If no `id` field exists, add it automatically
192
+ if (!instance.id) {
193
+ instance.id = { type: identifier };
194
+ }
195
+ // @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
196
+ new PrimaryIndex(MockModel, ['id']);
197
+ }
198
+
199
+ for (const key in instance) {
200
+ const value = instance[key] as FieldConfig<unknown>;
201
+ // Check if this property contains field metadata
202
+ if (value && value.type instanceof TypeWrapper) {
203
+ // Set the configuration on the constructor's `fields` property
204
+ MockModel.fields[key] = value;
205
+
206
+ // Set default value on the prototype
207
+ const defObj = value.default===undefined ? value.type : value;
208
+ const def = defObj.default;
209
+ if (typeof def === 'function') {
210
+ // The default is a function. We'll define a getter on the property in the model prototype,
211
+ // and once it is read, we'll run the function and set the value as a plain old property
212
+ // on the instance object.
213
+ Object.defineProperty(MockModel.prototype, key, {
214
+ get() {
215
+ // This will call set(), which will define the property on the instance.
216
+ return (this[key] = def.call(defObj, this));
217
+ },
218
+ set(val: any) {
219
+ Object.defineProperty(this, key, {
220
+ value: val,
221
+ configurable: true,
222
+ writable: true
223
+ })
224
+ },
225
+ configurable: true,
226
+ });
227
+ } else if (def !== undefined) {
228
+ (MockModel.prototype as any)[key] = def;
229
+ }
230
+ }
231
+ }
232
+
233
+ if (logLevel >= 1) {
234
+ console.log(`Registered model ${MockModel.tableName}[${MockModel._pk!._fieldNames.join(',')}] with fields: ${Object.keys(MockModel.fields).join(' ')}`);
235
+ }
236
+ }
237
+ }
238
+
239
+ // Model base class and related symbols/state
240
+ const INIT_INSTANCE_SYMBOL = Symbol();
241
+
242
+ /** @internal Symbol used to attach modified instances to running transaction */
243
+ export const MODIFIED_INSTANCES_SYMBOL = Symbol('modifiedInstances');
244
+
245
+ /** @internal Symbol used to access the underlying model from a proxy */
246
+
247
+ /**
248
+ * Model interface that ensures proper typing for the constructor property.
249
+ * @template SUB - The concrete model subclass.
250
+ */
251
+ export interface Model<SUB> {
252
+ constructor: typeof Model<SUB>;
253
+ }
254
+
255
+ /**
256
+ * Base class for all database models in the Edinburgh ORM.
257
+ *
258
+ * Models represent database entities with typed fields, automatic serialization,
259
+ * change tracking, and relationship management. All model classes should extend
260
+ * this base class and be decorated with `@registerModel`.
261
+ *
262
+ * @template SUB - The concrete model subclass (for proper typing).
263
+ *
264
+ * @example
265
+ * ```typescript
266
+ * ⁣@E.registerModel
267
+ * class User extends E.Model<User> {
268
+ * static pk = E.index(User, ["id"], "primary");
269
+ *
270
+ * id = E.field(E.identifier);
271
+ * name = E.field(E.string);
272
+ * email = E.field(E.string);
273
+ *
274
+ * static byEmail = E.index(User, "email", "unique");
275
+ * }
276
+ * ```
277
+ */
278
+ export abstract class Model<SUB> {
279
+ /** @internal Primary key index for this model. */
280
+ static _pk?: PrimaryIndex<any, any>;
281
+ /** @internal All indexes for this model, the primary key being first. */
282
+ static _indexes?: BaseIndex<any, any>[];
283
+
284
+ /** The database table name (defaults to class name). */
285
+ static tableName: string;
286
+ /** Field configuration metadata. */
287
+ static fields: Record<string, FieldConfig<unknown>>;
288
+
289
+ /*
290
+ * IMPORTANT: We cannot use instance property initializers here, because we will be
291
+ * initializing the class through a fake constructor that will skip these. This is
292
+ * intentional, as we don't want to run the initializers for the fields.
293
+ */
294
+
295
+ /** @internal Field configuration for this instance. */
296
+ _fields!: Record<string, FieldConfig<unknown>>;
297
+
298
+ /**
299
+ * @internal State tracking for this model instance:
300
+ * - undefined: new instance, unmodified
301
+ * - 1: new instance, modified (and in modifiedInstances)
302
+ * - 2: loaded from disk, unmodified
303
+ * - 3: persistence disabled
304
+ * - array: loaded from disk, modified (and in modifiedInstances), array values are original index buffers
305
+ */
306
+ _state: undefined | 1 | 2 | 3 | Array<Uint8Array>;
307
+
308
+ constructor(initial: Partial<Omit<SUB, "constructor">> = {}) {
309
+ // This constructor will only be called once, from `initModels`. All other instances will
310
+ // be created by the 'fake' constructor. The typing for `initial` *is* important though.
311
+ if (initial as any !== INIT_INSTANCE_SYMBOL) {
312
+ throw new DatabaseError("The model needs a @registerModel decorator", 'INIT_ERROR');
313
+ }
314
+ }
315
+
316
+ _save() {
317
+ // For performance, we'll work on the unproxied object, as we know we don't require change tracking for save.
318
+ const unproxiedModel = ((this as any)[TARGET_SYMBOL] || this) as Model<SUB>;
319
+
320
+ unproxiedModel.validate(true);
321
+
322
+ // Handle unique indexes
323
+ const indexes = this.constructor._indexes!;
324
+ const originalKeys = typeof unproxiedModel._state === 'object' ? unproxiedModel._state : undefined;
325
+ const newPk = indexes[0]._save(unproxiedModel, originalKeys?.[0]);
326
+ for (let i=1; i<indexes.length; i++) {
327
+ indexes[i]._save(unproxiedModel, originalKeys?.[i]);
328
+ }
329
+
330
+ queueOnSave([this, newPk, originalKeys?.[0]]);
331
+
332
+ unproxiedModel._state = 2; // Loaded from disk, unmodified
333
+ }
334
+
335
+
336
+ /**
337
+ * Load a model instance by primary key.
338
+ * @param args - Primary key field values.
339
+ * @returns The model instance if found, undefined otherwise.
340
+ *
341
+ * @example
342
+ * ```typescript
343
+ * const user = User.load("user123");
344
+ * const post = Post.load("post456", "en");
345
+ * ```
346
+ */
347
+ static load<SUB>(this: typeof Model<SUB>, ...args: any[]): SUB | undefined {
348
+ return this._pk!.get(...args);
349
+ }
350
+
351
+ /**
352
+ * Prevent this instance from being persisted to the database.
353
+ *
354
+ * Removes the instance from the modified instances set and disables
355
+ * automatic persistence at transaction commit.
356
+ *
357
+ * @returns This model instance for chaining.
358
+ *
359
+ * @example
360
+ * ```typescript
361
+ * const user = User.load("user123");
362
+ * user.name = "New Name";
363
+ * user.preventPersist(); // Changes won't be saved
364
+ * ```
365
+ */
366
+ preventPersist() {
367
+ const modifiedInstances = olmdb.getTransactionData(MODIFIED_INSTANCES_SYMBOL) as Set<Model<any>>;
368
+ const unproxiedModel = (this as any)[TARGET_SYMBOL] || this;
369
+ modifiedInstances.delete(unproxiedModel);
370
+
371
+ unproxiedModel._state = 3; // no persist
372
+ return this;
373
+ }
374
+
375
+ /**
376
+ * Delete this model instance from the database.
377
+ *
378
+ * Removes the instance and all its index entries from the database and prevents further persistence.
379
+ *
380
+ * @example
381
+ * ```typescript
382
+ * const user = User.load("user123");
383
+ * user.delete(); // Removes from database
384
+ * ```
385
+ */
386
+ delete() {
387
+ const unproxiedModel = ((this as any)[TARGET_SYMBOL] || this) as Model<SUB>;
388
+
389
+ if (this._state === 2 || typeof this._state === 'object') {
390
+ for(const index of unproxiedModel.constructor._indexes!) {
391
+ const key = index._getKeyFromModel(unproxiedModel, true);
392
+ olmdb.del(key);
393
+ if (index instanceof PrimaryIndex) queueOnSave([this, undefined, key]);
394
+ }
395
+ }
396
+
397
+ this.preventPersist();
398
+ }
399
+
400
+ /**
401
+ * Validate all fields in this model instance.
402
+ * @param raise - If true, throw on first validation error.
403
+ * @returns Array of validation errors (empty if valid).
404
+ *
405
+ * @example
406
+ * ```typescript
407
+ * const user = new User();
408
+ * const errors = user.validate();
409
+ * if (errors.length > 0) {
410
+ * console.log("Validation failed:", errors);
411
+ * }
412
+ * ```
413
+ */
414
+ validate(raise: boolean = false): DatabaseError[] {
415
+ const errors: DatabaseError[] = [];
416
+
417
+ for (const [key, fieldConfig] of Object.entries(this._fields)) {
418
+ for (const error of fieldConfig.type.getErrors(this, key)) {
419
+ addErrorPath(error, key);
420
+ if (raise) throw error;
421
+ errors.push(error);
422
+ }
423
+ }
424
+ return errors;
425
+ }
426
+
427
+ /**
428
+ * Check if this model instance is valid.
429
+ * @returns true if all validations pass.
430
+ *
431
+ * @example
432
+ * ```typescript
433
+ * const user = new User({name: "John"});
434
+ * if (!user.isValid()) shoutAtTheUser();
435
+ * ```
436
+ */
437
+ isValid(): boolean {
438
+ return this.validate().length === 0;
439
+ }
440
+ }
441
+
442
+ // We use recursive proxies to track modifications made to, say, arrays within models. In
443
+ // order to know which model a nested object belongs to, we maintain a WeakMap that maps
444
+ // objects to their owner (unproxied) model.
445
+ const modificationOwnerMap = new WeakMap<object, Model<any>>();
446
+
447
+ // A cache for the proxies around nested objects, so that we don't need to recreate them
448
+ // every time we access a property on a nested object (and so that their identity remains
449
+ // the same).
450
+ const modificationProxyCache = new WeakMap<object, any>();
451
+
452
+ // Single proxy handler for both models and nested objects
453
+ export const modificationTracker: ProxyHandler<any> = {
454
+ get(target, prop) {
455
+ if (prop === TARGET_SYMBOL) return target;
456
+ const value = target[prop];
457
+ if (!value || typeof value !== 'object' || (value instanceof Model)) return value;
458
+
459
+ // Check cache first
460
+ let proxy = modificationProxyCache.get(value);
461
+ if (proxy) return proxy;
462
+
463
+ let model;
464
+ if (target instanceof Model) {
465
+ if (!target._fields[prop as string]) {
466
+ // No need to track properties that are not model fields.
467
+ return value;
468
+ }
469
+ model = target;
470
+ } else {
471
+ model = modificationOwnerMap.get(target);
472
+ assert(model);
473
+ }
474
+
475
+ let state = model._state;
476
+ if (state !== undefined && state !== 2) {
477
+ // We don't need to track changes for this model (anymore). So we can just return the unproxied object.
478
+ // As we doing the modificationProxyCache lookup first, the identity of returned objects will not change:
479
+ // once a proxied object is returned, the same property will always return a proxied object.
480
+ return value;
481
+ }
482
+
483
+ if (modificationOwnerMap.get(value)) {
484
+ throw new DatabaseError("Object cannot be embedded in multiple model instances", 'VALUE_ERROR');
485
+ }
486
+ modificationOwnerMap.set(value, model);
487
+ proxy = new Proxy(value, modificationTracker);
488
+ modificationProxyCache.set(value, proxy);
489
+ return proxy;
490
+ },
491
+ set(target, prop, value) {
492
+ let model;
493
+ if (target instanceof Model) {
494
+ model = target;
495
+ if (!model._fields[prop as string]) {
496
+ // No need to track properties that are not model fields.
497
+ (target as any)[prop] = value;
498
+ return true;
499
+ }
500
+ } else {
501
+ model = modificationOwnerMap.get(target);
502
+ assert(model);
503
+ }
504
+
505
+ let state = model._state;
506
+ if (state === undefined || state === 2) {
507
+ const modifiedInstances = olmdb.getTransactionData(MODIFIED_INSTANCES_SYMBOL) as Set<Model<any>>;
508
+ modifiedInstances.add(model);
509
+ if (state === 2) {
510
+ model._state = model.constructor._indexes!.map(idx => idx._getKeyFromModel(model, true));
511
+ } else {
512
+ model._state = 1;
513
+ }
514
+ }
515
+
516
+ target[prop] = value;
517
+ return true;
518
+ }
519
+ };