edinburgh 0.1.3 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +450 -218
- package/build/src/datapack.d.ts +138 -0
- package/build/src/datapack.js +684 -0
- package/build/src/datapack.js.map +1 -0
- package/build/src/edinburgh.d.ts +41 -11
- package/build/src/edinburgh.js +163 -43
- package/build/src/edinburgh.js.map +1 -1
- package/build/src/indexes.d.ts +100 -111
- package/build/src/indexes.js +679 -369
- package/build/src/indexes.js.map +1 -1
- package/build/src/migrate-cli.d.ts +20 -0
- package/build/src/migrate-cli.js +122 -0
- package/build/src/migrate-cli.js.map +1 -0
- package/build/src/migrate.d.ts +33 -0
- package/build/src/migrate.js +225 -0
- package/build/src/migrate.js.map +1 -0
- package/build/src/models.d.ts +147 -46
- package/build/src/models.js +322 -268
- package/build/src/models.js.map +1 -1
- package/build/src/types.d.ts +209 -260
- package/build/src/types.js +423 -324
- package/build/src/types.js.map +1 -1
- package/build/src/utils.d.ts +9 -9
- package/build/src/utils.js +32 -9
- package/build/src/utils.js.map +1 -1
- package/package.json +14 -11
- package/src/datapack.ts +726 -0
- package/src/edinburgh.ts +174 -43
- package/src/indexes.ts +722 -380
- package/src/migrate-cli.ts +138 -0
- package/src/migrate.ts +267 -0
- package/src/models.ts +415 -285
- package/src/types.ts +510 -391
- package/src/utils.ts +40 -12
- package/build/src/bytes.d.ts +0 -155
- package/build/src/bytes.js +0 -455
- package/build/src/bytes.js.map +0 -1
- package/src/bytes.ts +0 -500
package/src/models.ts
CHANGED
|
@@ -1,8 +1,35 @@
|
|
|
1
|
-
import { DatabaseError } from "olmdb";
|
|
2
|
-
import
|
|
1
|
+
import { DatabaseError } from "olmdb/lowlevel";
|
|
2
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
3
3
|
import { TypeWrapper, identifier } from "./types.js";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import { scheduleInit } from "./edinburgh.js";
|
|
5
|
+
|
|
6
|
+
export const txnStorage = new AsyncLocalStorage<Transaction>();
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const PREVENT_PERSIST_DESCRIPTOR = {
|
|
10
|
+
get() {
|
|
11
|
+
throw new DatabaseError("Operation not allowed after preventPersist()", "NO_PERSIST");
|
|
12
|
+
},
|
|
13
|
+
};
|
|
14
|
+
|
|
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 } from "./utils.js";
|
|
6
33
|
|
|
7
34
|
/**
|
|
8
35
|
* Configuration interface for model fields.
|
|
@@ -44,63 +71,20 @@ export function field<T>(type: TypeWrapper<T>, options: Partial<FieldConfig<T>>
|
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
// Model registration and initialization
|
|
47
|
-
let uninitializedModels = new Set<typeof Model<unknown>>();
|
|
48
74
|
export const modelRegistry: Record<string, typeof Model> = {};
|
|
49
75
|
|
|
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
76
|
function isObjectEmpty(obj: object) {
|
|
59
|
-
for (let
|
|
60
|
-
|
|
77
|
+
for (let _ of Object.keys(obj)) {
|
|
78
|
+
return false;
|
|
61
79
|
}
|
|
62
80
|
return true;
|
|
63
81
|
}
|
|
64
82
|
|
|
65
|
-
type
|
|
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
|
-
}
|
|
83
|
+
export type Change = Record<any, any> | "created" | "deleted";
|
|
96
84
|
|
|
97
85
|
/**
|
|
98
86
|
* Register a model class with the Edinburgh ORM system.
|
|
99
87
|
*
|
|
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
88
|
* @template T - The model class type.
|
|
105
89
|
* @param MyModel - The model class to register.
|
|
106
90
|
* @returns The enhanced model class with ORM capabilities.
|
|
@@ -120,24 +104,22 @@ export function registerModel<T extends typeof Model<unknown>>(MyModel: T): T {
|
|
|
120
104
|
|
|
121
105
|
// Copy own static methods/properties
|
|
122
106
|
for(const name of Object.getOwnPropertyNames(MyModel)) {
|
|
123
|
-
if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock') {
|
|
107
|
+
if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock' && name !== 'override') {
|
|
124
108
|
(MockModel as any)[name] = (MyModel as any)[name];
|
|
125
109
|
}
|
|
126
110
|
}
|
|
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
|
|
111
|
+
MockModel.tableName ||= MyModel.name;
|
|
131
112
|
|
|
132
113
|
// Register the constructor by name
|
|
133
|
-
if (MockModel.tableName in modelRegistry)
|
|
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');
|
|
117
|
+
}
|
|
118
|
+
delete modelRegistry[MockModel.tableName];
|
|
119
|
+
}
|
|
134
120
|
modelRegistry[MockModel.tableName] = MockModel;
|
|
135
121
|
|
|
136
|
-
|
|
137
|
-
uninitializedModels.add(MyModel);
|
|
138
|
-
initModels();
|
|
139
|
-
|
|
140
|
-
return MockModel;
|
|
122
|
+
return MockModel;
|
|
141
123
|
}
|
|
142
124
|
|
|
143
125
|
export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
|
|
@@ -145,17 +127,14 @@ export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
|
|
|
145
127
|
if (AnyOrgModel._isMock) return OrgModel;
|
|
146
128
|
if (AnyOrgModel._mock) return AnyOrgModel._mock;
|
|
147
129
|
|
|
148
|
-
const MockModel = function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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) {
|
|
153
136
|
Object.assign(this, initial);
|
|
154
|
-
const modifiedInstances = olmdb.getTransactionData(MODIFIED_INSTANCES_SYMBOL) as Set<Model<any>>;
|
|
155
|
-
modifiedInstances.add(this);
|
|
156
137
|
}
|
|
157
|
-
|
|
158
|
-
return new Proxy(this, modificationTracker);
|
|
159
138
|
} as any as T;
|
|
160
139
|
|
|
161
140
|
// We want .constructor to point at our fake constructor function.
|
|
@@ -165,85 +144,15 @@ export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
|
|
|
165
144
|
Object.setPrototypeOf(MockModel, Object.getPrototypeOf(OrgModel));
|
|
166
145
|
MockModel.prototype = OrgModel.prototype;
|
|
167
146
|
(MockModel as any)._isMock = true;
|
|
147
|
+
(MockModel as any)._original = OrgModel;
|
|
168
148
|
AnyOrgModel._mock = MockModel;
|
|
149
|
+
scheduleInit();
|
|
169
150
|
return MockModel;
|
|
170
151
|
}
|
|
171
152
|
|
|
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
153
|
// Model base class and related symbols/state
|
|
240
154
|
const INIT_INSTANCE_SYMBOL = Symbol();
|
|
241
155
|
|
|
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
156
|
/**
|
|
248
157
|
* Model interface that ensures proper typing for the constructor property.
|
|
249
158
|
* @template SUB - The concrete model subclass.
|
|
@@ -259,32 +168,99 @@ export interface Model<SUB> {
|
|
|
259
168
|
* change tracking, and relationship management. All model classes should extend
|
|
260
169
|
* this base class and be decorated with `@registerModel`.
|
|
261
170
|
*
|
|
171
|
+
* ### Schema Evolution
|
|
172
|
+
*
|
|
173
|
+
* Edinburgh tracks the schema version of each model automatically. When you add, remove, or
|
|
174
|
+
* change the types of fields, or add/remove indexes, Edinburgh detects the new schema version.
|
|
175
|
+
*
|
|
176
|
+
* **Lazy migration:** Changes to non-key field values are migrated lazily, when a row with an
|
|
177
|
+
* old schema version is read from disk, it is deserialized using the old schema and optionally
|
|
178
|
+
* transformed by the static `migrate()` function. This happens transparently on every read
|
|
179
|
+
* and requires no downtime or batch processing.
|
|
180
|
+
*
|
|
181
|
+
* **Batch migration (via `npx migrate-edinburgh` or `runMigration()`):** Certain schema changes
|
|
182
|
+
* require an explicit migration run:
|
|
183
|
+
* - Adding or removing secondary/unique indexes
|
|
184
|
+
* - Changing the fields or types of an existing index
|
|
185
|
+
* - A `migrate()` function that changes values used in secondary index fields
|
|
186
|
+
*
|
|
187
|
+
* The batch migration tool populates new indexes, deletes orphaned ones, and updates index
|
|
188
|
+
* entries whose values were changed by `migrate()`. It does *not* rewrite primary data rows
|
|
189
|
+
* (lazy migration handles that).
|
|
190
|
+
*
|
|
191
|
+
* ### Lifecycle Hooks
|
|
192
|
+
*
|
|
193
|
+
* - **`static migrate(record)`**: Called when deserializing rows written with an older schema
|
|
194
|
+
* version. Receives a plain record object; mutate it in-place to match the current schema.
|
|
195
|
+
* See {@link Model.migrate}.
|
|
196
|
+
*
|
|
197
|
+
* - **`preCommit()`**: Called on each modified instance right before the transaction commits.
|
|
198
|
+
* Useful for computing derived fields, enforcing cross-field invariants, or creating related
|
|
199
|
+
* instances. See {@link Model.preCommit}.
|
|
200
|
+
*
|
|
262
201
|
* @template SUB - The concrete model subclass (for proper typing).
|
|
263
202
|
*
|
|
264
203
|
* @example
|
|
265
204
|
* ```typescript
|
|
266
205
|
* @E.registerModel
|
|
267
206
|
* class User extends E.Model<User> {
|
|
268
|
-
* static pk = E.
|
|
207
|
+
* static pk = E.primary(User, "id");
|
|
269
208
|
*
|
|
270
209
|
* id = E.field(E.identifier);
|
|
271
210
|
* name = E.field(E.string);
|
|
272
211
|
* email = E.field(E.string);
|
|
273
212
|
*
|
|
274
|
-
* static byEmail = E.
|
|
213
|
+
* static byEmail = E.unique(User, "email");
|
|
275
214
|
* }
|
|
276
215
|
* ```
|
|
277
216
|
*/
|
|
217
|
+
|
|
218
|
+
|
|
278
219
|
export abstract class Model<SUB> {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
/** @internal All indexes for this model
|
|
282
|
-
static
|
|
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)[]>[];
|
|
283
224
|
|
|
284
225
|
/** The database table name (defaults to class name). */
|
|
285
226
|
static tableName: string;
|
|
227
|
+
|
|
228
|
+
/** When true, registerModel replaces an existing model with the same tableName. */
|
|
229
|
+
static override?: boolean;
|
|
230
|
+
|
|
286
231
|
/** Field configuration metadata. */
|
|
287
|
-
static fields: Record<string, FieldConfig<unknown>>;
|
|
232
|
+
static fields: Record<string | symbol | number, FieldConfig<unknown>>;
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* 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.
|
|
242
|
+
*
|
|
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.
|
|
247
|
+
*
|
|
248
|
+
* @example
|
|
249
|
+
* ```typescript
|
|
250
|
+
* @E.registerModel
|
|
251
|
+
* class User extends E.Model<User> {
|
|
252
|
+
* static pk = E.primary(User, "id");
|
|
253
|
+
* id = E.field(E.identifier);
|
|
254
|
+
* name = E.field(E.string);
|
|
255
|
+
* role = E.field(E.string); // new field
|
|
256
|
+
*
|
|
257
|
+
* static migrate(record: Record<string, any>) {
|
|
258
|
+
* record.role ??= "user"; // default for rows that predate the 'role' field
|
|
259
|
+
* }
|
|
260
|
+
* }
|
|
261
|
+
* ```
|
|
262
|
+
*/
|
|
263
|
+
static migrate?(record: Record<string, any>): void;
|
|
288
264
|
|
|
289
265
|
/*
|
|
290
266
|
* IMPORTANT: We cannot use instance property initializers here, because we will be
|
|
@@ -292,68 +268,248 @@ export abstract class Model<SUB> {
|
|
|
292
268
|
* intentional, as we don't want to run the initializers for the fields.
|
|
293
269
|
*/
|
|
294
270
|
|
|
295
|
-
/** @internal Field configuration for this instance. */
|
|
296
|
-
_fields!: Record<string, FieldConfig<unknown>>;
|
|
297
|
-
|
|
298
271
|
/**
|
|
299
|
-
* @internal
|
|
300
|
-
* - undefined:
|
|
301
|
-
* -
|
|
302
|
-
* -
|
|
303
|
-
* - 3: persistence disabled
|
|
304
|
-
* - array: loaded from disk, modified (and in modifiedInstances), array values are original index buffers
|
|
272
|
+
* @internal
|
|
273
|
+
* - _oldValues===undefined: New instance, not yet saved.
|
|
274
|
+
* - _oldValues===null: Instance is to be deleted.
|
|
275
|
+
* - _oldValues is an object: Loaded (possibly only partial, still lazy) from disk, _oldValues contains (partial) old values
|
|
305
276
|
*/
|
|
306
|
-
|
|
277
|
+
_oldValues: Record<string, any> | undefined | null;
|
|
278
|
+
_primaryKey: Uint8Array | undefined;
|
|
279
|
+
_primaryKeyHash: number | undefined;
|
|
280
|
+
_txn!: Transaction;
|
|
307
281
|
|
|
308
282
|
constructor(initial: Partial<Omit<SUB, "constructor">> = {}) {
|
|
309
283
|
// This constructor will only be called once, from `initModels`. All other instances will
|
|
310
284
|
// be created by the 'fake' constructor. The typing for `initial` *is* important though.
|
|
311
|
-
if (initial as any
|
|
312
|
-
|
|
313
|
-
}
|
|
285
|
+
if (initial as any === INIT_INSTANCE_SYMBOL) return;
|
|
286
|
+
throw new DatabaseError("The model needs a @registerModel decorator", 'INIT_ERROR');
|
|
314
287
|
}
|
|
315
288
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
289
|
+
/**
|
|
290
|
+
* Optional hook called on each modified instance right before the transaction commits.
|
|
291
|
+
* Runs before data is written to disk, so changes made here are included in the commit.
|
|
292
|
+
*
|
|
293
|
+
* Common use cases:
|
|
294
|
+
* - Computing derived or denormalized fields
|
|
295
|
+
* - Enforcing cross-field validation rules
|
|
296
|
+
* - Creating or updating related model instances (newly created instances will also
|
|
297
|
+
* have their `preCommit()` called)
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```typescript
|
|
301
|
+
* @E.registerModel
|
|
302
|
+
* class Post extends E.Model<Post> {
|
|
303
|
+
* static pk = E.primary(Post, "id");
|
|
304
|
+
* id = E.field(E.identifier);
|
|
305
|
+
* title = E.field(E.string);
|
|
306
|
+
* slug = E.field(E.string);
|
|
307
|
+
*
|
|
308
|
+
* preCommit() {
|
|
309
|
+
* this.slug = this.title.toLowerCase().replace(/\s+/g, "-");
|
|
310
|
+
* }
|
|
311
|
+
* }
|
|
312
|
+
* ```
|
|
313
|
+
*/
|
|
314
|
+
preCommit?(): void;
|
|
319
315
|
|
|
320
|
-
|
|
316
|
+
static async _delayedInit(cleared?: boolean): Promise<void> {
|
|
317
|
+
const MockModel = getMockModel(this);
|
|
321
318
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
for (let i=1; i<indexes.length; i++) {
|
|
327
|
-
indexes[i]._save(unproxiedModel, originalKeys?.[i]);
|
|
319
|
+
if (cleared) {
|
|
320
|
+
MockModel._primary._indexId = undefined;
|
|
321
|
+
MockModel._primary._versions.clear();
|
|
322
|
+
for (const sec of MockModel._secondaries || []) sec._indexId = undefined;
|
|
328
323
|
}
|
|
329
324
|
|
|
330
|
-
|
|
325
|
+
if (!MockModel.fields) {
|
|
326
|
+
// First-time init: gather field configs from a temporary instance of the original class.
|
|
327
|
+
const OrgModel = (MockModel as any)._original || this;
|
|
328
|
+
const instance = new (OrgModel as any)(INIT_INSTANCE_SYMBOL);
|
|
329
|
+
|
|
330
|
+
// If no primary key exists, create one using 'id' field
|
|
331
|
+
if (!MockModel._primary) {
|
|
332
|
+
if (!instance.id) {
|
|
333
|
+
instance.id = { type: identifier };
|
|
334
|
+
}
|
|
335
|
+
// @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
|
|
336
|
+
new PrimaryIndex(MockModel, ['id']);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
MockModel.fields = {};
|
|
340
|
+
for (const key in instance) {
|
|
341
|
+
const value = instance[key] as FieldConfig<unknown>;
|
|
342
|
+
// Check if this property contains field metadata
|
|
343
|
+
if (value && value.type instanceof TypeWrapper) {
|
|
344
|
+
// Set the configuration on the constructor's `fields` property
|
|
345
|
+
MockModel.fields[key] = value;
|
|
346
|
+
|
|
347
|
+
// Set default value on the prototype
|
|
348
|
+
const defObj = value.default===undefined ? value.type : value;
|
|
349
|
+
const def = defObj.default;
|
|
350
|
+
if (typeof def === 'function') {
|
|
351
|
+
// The default is a function. We'll define a getter on the property in the model prototype,
|
|
352
|
+
// and once it is read, we'll run the function and set the value as a plain old property
|
|
353
|
+
// on the instance object.
|
|
354
|
+
Object.defineProperty(MockModel.prototype, key, {
|
|
355
|
+
get() {
|
|
356
|
+
// This will call set(), which will define the property on the instance.
|
|
357
|
+
return (this[key] = def.call(defObj, this));
|
|
358
|
+
},
|
|
359
|
+
set(val: any) {
|
|
360
|
+
Object.defineProperty(this, key, {
|
|
361
|
+
value: val,
|
|
362
|
+
configurable: true,
|
|
363
|
+
writable: true,
|
|
364
|
+
enumerable: true,
|
|
365
|
+
})
|
|
366
|
+
},
|
|
367
|
+
configurable: true,
|
|
368
|
+
});
|
|
369
|
+
} else if (def !== undefined) {
|
|
370
|
+
(MockModel.prototype as any)[key] = def;
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
331
374
|
|
|
332
|
-
|
|
375
|
+
if (logLevel >= 1) {
|
|
376
|
+
console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Always run index inits (idempotent, skip if already initialized)
|
|
381
|
+
await MockModel._primary._delayedInit();
|
|
382
|
+
for (const sec of MockModel._secondaries || []) await sec._delayedInit();
|
|
383
|
+
await MockModel._primary._initVersioning();
|
|
333
384
|
}
|
|
334
385
|
|
|
386
|
+
_setLoadedField(fieldName: string, value: any) {
|
|
387
|
+
const oldValues = this._oldValues!;
|
|
388
|
+
if (oldValues.hasOwnProperty(fieldName)) return; // Already loaded earlier (as part of index key?)
|
|
389
|
+
|
|
390
|
+
this[fieldName as keyof Model<SUB>] = value;
|
|
391
|
+
if (typeof value === 'object' && value !== null) {
|
|
392
|
+
const fieldType = (this.constructor.fields[fieldName] as FieldConfig<unknown>).type;
|
|
393
|
+
oldValues[fieldName] = fieldType.clone(value);
|
|
394
|
+
} else {
|
|
395
|
+
// This path is just an optimization
|
|
396
|
+
oldValues[fieldName] = value;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
335
399
|
|
|
336
400
|
/**
|
|
337
|
-
*
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
401
|
+
* @returns The primary key for this instance.
|
|
402
|
+
*/
|
|
403
|
+
getPrimaryKey(): Uint8Array {
|
|
404
|
+
let key = this._primaryKey;
|
|
405
|
+
if (key === undefined) {
|
|
406
|
+
key = this.constructor._primary!._serializeKeyFields(this).toUint8Array();
|
|
407
|
+
this._setPrimaryKey(key);
|
|
408
|
+
}
|
|
409
|
+
return key;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
_setPrimaryKey(key: Uint8Array, hash?: number) {
|
|
413
|
+
this._primaryKey = key;
|
|
414
|
+
this._primaryKeyHash = hash ?? hashBytes(key);
|
|
415
|
+
Object.defineProperties(this, this.constructor._primary._freezePrimaryKeyDescriptors);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* @returns A 53-bit positive integer non-cryptographic hash of the primary key, or undefined if not yet saved.
|
|
346
420
|
*/
|
|
347
|
-
|
|
348
|
-
|
|
421
|
+
getPrimaryKeyHash(): number {
|
|
422
|
+
if (this._primaryKeyHash === undefined) this.getPrimaryKey();
|
|
423
|
+
return this._primaryKeyHash!;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
isLazyField(field: keyof this) {
|
|
427
|
+
const descr = this.constructor._primary!._lazyDescriptors[field];
|
|
428
|
+
return !!(descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, field)?.get);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
_write(txn: Transaction): undefined | Change {
|
|
432
|
+
const oldValues = this._oldValues;
|
|
433
|
+
|
|
434
|
+
if (oldValues === null) { // Delete instance
|
|
435
|
+
const pk = this._primaryKey;
|
|
436
|
+
this.constructor._primary._delete(txn, pk!, this);
|
|
437
|
+
for(const index of this.constructor._secondaries || []) {
|
|
438
|
+
index._delete(txn, pk!, this);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
return "deleted";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (oldValues === undefined) { // Create instance
|
|
445
|
+
this.validate(true);
|
|
446
|
+
|
|
447
|
+
// Make sure the primary key does not already exist
|
|
448
|
+
const pk = this.getPrimaryKey();
|
|
449
|
+
if (dbGet(txn.id, pk!)) {
|
|
450
|
+
throw new DatabaseError("Unique constraint violation", "UNIQUE_CONSTRAINT");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Insert the primary index
|
|
454
|
+
this.constructor._primary!._write(txn, pk!, this);
|
|
455
|
+
|
|
456
|
+
// Insert all secondaries
|
|
457
|
+
for (const index of this.constructor._secondaries || []) {
|
|
458
|
+
index._write(txn, pk!, this);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
return "created";
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// oldValues is an object.
|
|
465
|
+
// We're doing an update. Note that we may still be in a lazy state, and we don't want to load
|
|
466
|
+
// the whole object just to see if something changed.
|
|
467
|
+
|
|
468
|
+
// Add old values of changed fields to 'changed'.
|
|
469
|
+
const fields = this.constructor.fields;
|
|
470
|
+
let changed : Record<any, any> = {};
|
|
471
|
+
for(const fieldName in oldValues) {
|
|
472
|
+
const oldValue = oldValues[fieldName];
|
|
473
|
+
const newValue = this[fieldName as keyof Model<SUB>];
|
|
474
|
+
if (newValue !== oldValue && !(fields[fieldName] as FieldConfig<unknown>).type.equals(newValue, oldValue)) {
|
|
475
|
+
changed[fieldName] = oldValue;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
if (isObjectEmpty(changed)) return; // No changes, nothing to do
|
|
480
|
+
|
|
481
|
+
// Make sure primary has not been changed
|
|
482
|
+
for (const field of this.constructor._primary!._fieldTypes.keys()) {
|
|
483
|
+
if (changed.hasOwnProperty(field)) {
|
|
484
|
+
throw new DatabaseError(`Cannot modify primary key field: ${field}`, "CHANGE_PRIMARY");
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// We have changes. Now it's okay for any lazy fields to be loaded (which the validate will trigger).
|
|
489
|
+
|
|
490
|
+
// Raise any validation errors
|
|
491
|
+
this.validate(true);
|
|
492
|
+
|
|
493
|
+
// Update the primary index
|
|
494
|
+
const pk = this._primaryKey!;
|
|
495
|
+
this.constructor._primary!._write(txn, pk, this);
|
|
496
|
+
|
|
497
|
+
// Update any secondaries with changed fields
|
|
498
|
+
for (const index of this.constructor._secondaries || []) {
|
|
499
|
+
for (const field of index._fieldTypes.keys()) {
|
|
500
|
+
if (changed.hasOwnProperty(field)) {
|
|
501
|
+
index._delete(txn, pk, oldValues);
|
|
502
|
+
index._write(txn, pk, this);
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
return changed;
|
|
349
508
|
}
|
|
350
509
|
|
|
351
510
|
/**
|
|
352
511
|
* Prevent this instance from being persisted to the database.
|
|
353
512
|
*
|
|
354
|
-
* Removes the instance from the modified instances set and disables
|
|
355
|
-
* automatic persistence at transaction commit.
|
|
356
|
-
*
|
|
357
513
|
* @returns This model instance for chaining.
|
|
358
514
|
*
|
|
359
515
|
* @example
|
|
@@ -364,14 +520,54 @@ export abstract class Model<SUB> {
|
|
|
364
520
|
* ```
|
|
365
521
|
*/
|
|
366
522
|
preventPersist() {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
unproxiedModel._state = 3; // no persist
|
|
523
|
+
this._txn.instances.delete(this);
|
|
524
|
+
// Have access to '_txn' throw a descriptive error:
|
|
525
|
+
Object.defineProperty(this, "_txn", PREVENT_PERSIST_DESCRIPTOR);
|
|
372
526
|
return this;
|
|
373
527
|
}
|
|
374
528
|
|
|
529
|
+
/**
|
|
530
|
+
* Find all instances of this model in the database, ordered by primary key.
|
|
531
|
+
* @param opts - Optional parameters.
|
|
532
|
+
* @param opts.reverse - If true, iterate in reverse order.
|
|
533
|
+
* @returns An iterator.
|
|
534
|
+
*/
|
|
535
|
+
static findAll<T extends typeof Model<unknown>>(this: T, opts?: {reverse?: boolean}): IndexRangeIterator<T> {
|
|
536
|
+
return this._primary!.find(opts);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Load an existing instance by primary key and update it, or create a new one.
|
|
541
|
+
*
|
|
542
|
+
* The provided object must contain all primary key fields. If a matching row exists,
|
|
543
|
+
* the remaining properties from `obj` are set on the loaded instance. Otherwise a
|
|
544
|
+
* new instance is created with `obj` as its initial properties.
|
|
545
|
+
*
|
|
546
|
+
* @param obj - Partial model data that **must** include every primary key field.
|
|
547
|
+
* @returns The loaded-and-updated or newly created instance.
|
|
548
|
+
*/
|
|
549
|
+
static replaceInto<T extends typeof Model<any>>(this: T, obj: Partial<Omit<InstanceType<T>, "constructor">>): InstanceType<T> {
|
|
550
|
+
const pk = this._primary!;
|
|
551
|
+
const keyArgs = [];
|
|
552
|
+
for (const fieldName of pk._fieldTypes.keys()) {
|
|
553
|
+
if (!(fieldName in (obj as any))) {
|
|
554
|
+
throw new DatabaseError(`replaceInto: missing primary key field '${fieldName}'`, "MISSING_PRIMARY_KEY");
|
|
555
|
+
}
|
|
556
|
+
keyArgs.push((obj as any)[fieldName]);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const existing = pk.get(...keyArgs as any) as InstanceType<T> | undefined;
|
|
560
|
+
if (existing) {
|
|
561
|
+
for (const key in obj as any) {
|
|
562
|
+
if (!pk._fieldTypes.has(key as any)) {
|
|
563
|
+
(existing as any)[key] = (obj as any)[key];
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
return existing;
|
|
567
|
+
}
|
|
568
|
+
return new (this as any)(obj) as InstanceType<T>;
|
|
569
|
+
}
|
|
570
|
+
|
|
375
571
|
/**
|
|
376
572
|
* Delete this model instance from the database.
|
|
377
573
|
*
|
|
@@ -384,17 +580,8 @@ export abstract class Model<SUB> {
|
|
|
384
580
|
* ```
|
|
385
581
|
*/
|
|
386
582
|
delete() {
|
|
387
|
-
|
|
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();
|
|
583
|
+
if (this._oldValues === undefined) throw new DatabaseError("Cannot delete unsaved instance", "NOT_SAVED");
|
|
584
|
+
this._oldValues = null;
|
|
398
585
|
}
|
|
399
586
|
|
|
400
587
|
/**
|
|
@@ -411,14 +598,15 @@ export abstract class Model<SUB> {
|
|
|
411
598
|
* }
|
|
412
599
|
* ```
|
|
413
600
|
*/
|
|
414
|
-
validate(raise: boolean = false):
|
|
415
|
-
const errors:
|
|
601
|
+
validate(raise: boolean = false): Error[] {
|
|
602
|
+
const errors: Error[] = [];
|
|
416
603
|
|
|
417
|
-
for (const [key, fieldConfig] of Object.entries(this.
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
604
|
+
for (const [key, fieldConfig] of Object.entries(this.constructor.fields)) {
|
|
605
|
+
let e = fieldConfig.type.getError((this as any)[key]);
|
|
606
|
+
if (e) {
|
|
607
|
+
e = addErrorPath(e, this.constructor.tableName+"."+key);
|
|
608
|
+
if (raise) throw e;
|
|
609
|
+
errors.push(e as Error);
|
|
422
610
|
}
|
|
423
611
|
}
|
|
424
612
|
return errors;
|
|
@@ -437,83 +625,25 @@ export abstract class Model<SUB> {
|
|
|
437
625
|
isValid(): boolean {
|
|
438
626
|
return this.validate().length === 0;
|
|
439
627
|
}
|
|
440
|
-
}
|
|
441
628
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
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;
|
|
629
|
+
getState(): "deleted" | "created" | "loaded" | "lazy" {
|
|
630
|
+
if (this._oldValues === null) return "deleted";
|
|
631
|
+
if (this._oldValues === undefined) return "created";
|
|
632
|
+
for(const [key,descr] of Object.entries(this.constructor._primary!._lazyDescriptors)) {
|
|
633
|
+
if (descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, key)?.get) {
|
|
634
|
+
return "lazy";
|
|
468
635
|
}
|
|
469
|
-
model = target;
|
|
470
|
-
} else {
|
|
471
|
-
model = modificationOwnerMap.get(target);
|
|
472
|
-
assert(model);
|
|
473
636
|
}
|
|
637
|
+
return "loaded";
|
|
638
|
+
}
|
|
474
639
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
}
|
|
640
|
+
toString(): string {
|
|
641
|
+
const primary = this.constructor._primary;
|
|
642
|
+
const pk = primary._keyToArray(this._primaryKey || primary._serializeKeyFields(this).toUint8Array(false));
|
|
643
|
+
return `{Model:${this.constructor.tableName} ${this.getState()} ${pk}}`;
|
|
644
|
+
}
|
|
515
645
|
|
|
516
|
-
|
|
517
|
-
return
|
|
646
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
647
|
+
return this.toString();
|
|
518
648
|
}
|
|
519
|
-
}
|
|
649
|
+
}
|