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/build/src/models.js
CHANGED
|
@@ -1,8 +1,26 @@
|
|
|
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
|
+
export const txnStorage = new AsyncLocalStorage();
|
|
6
|
+
const PREVENT_PERSIST_DESCRIPTOR = {
|
|
7
|
+
get() {
|
|
8
|
+
throw new DatabaseError("Operation not allowed after preventPersist()", "NO_PERSIST");
|
|
9
|
+
},
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* Returns the current transaction from AsyncLocalStorage.
|
|
13
|
+
* Throws if called outside a transact() callback.
|
|
14
|
+
* @internal
|
|
15
|
+
*/
|
|
16
|
+
export function currentTxn() {
|
|
17
|
+
const txn = txnStorage.getStore();
|
|
18
|
+
if (!txn)
|
|
19
|
+
throw new DatabaseError("No active transaction. Operations must be performed within a transact() callback.", 'NO_TRANSACTION');
|
|
20
|
+
return txn;
|
|
21
|
+
}
|
|
22
|
+
import { PrimaryIndex } from "./indexes.js";
|
|
23
|
+
import { addErrorPath, logLevel, dbGet, hashBytes } from "./utils.js";
|
|
6
24
|
/**
|
|
7
25
|
* Create a field definition for a model property.
|
|
8
26
|
*
|
|
@@ -29,59 +47,16 @@ export function field(type, options = {}) {
|
|
|
29
47
|
return options;
|
|
30
48
|
}
|
|
31
49
|
// Model registration and initialization
|
|
32
|
-
let uninitializedModels = new Set();
|
|
33
50
|
export const modelRegistry = {};
|
|
34
|
-
export function resetModelCaches() {
|
|
35
|
-
for (const model of Object.values(modelRegistry)) {
|
|
36
|
-
for (const index of model._indexes || []) {
|
|
37
|
-
index._cachedIndexId = undefined;
|
|
38
|
-
}
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
51
|
function isObjectEmpty(obj) {
|
|
42
|
-
for (let
|
|
43
|
-
|
|
44
|
-
return false;
|
|
52
|
+
for (let _ of Object.keys(obj)) {
|
|
53
|
+
return false;
|
|
45
54
|
}
|
|
46
55
|
return true;
|
|
47
56
|
}
|
|
48
|
-
let onSave;
|
|
49
|
-
/**
|
|
50
|
-
* Set a callback function to be called after a model is saved and committed.
|
|
51
|
-
*
|
|
52
|
-
* @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).
|
|
53
|
-
*/
|
|
54
|
-
export function setOnSaveCallback(callback) {
|
|
55
|
-
onSave = callback;
|
|
56
|
-
}
|
|
57
|
-
const onSaveQueue = [];
|
|
58
|
-
function onSaveRevert() {
|
|
59
|
-
onSaveQueue.length = 0;
|
|
60
|
-
}
|
|
61
|
-
function onSaveCommit() {
|
|
62
|
-
if (onSave) {
|
|
63
|
-
for (let arr of onSaveQueue) {
|
|
64
|
-
onSave(...arr);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
onSaveQueue.length = 0;
|
|
68
|
-
}
|
|
69
|
-
function queueOnSave(arr) {
|
|
70
|
-
if (onSave) {
|
|
71
|
-
if (!onSaveQueue.length) {
|
|
72
|
-
olmdb.onCommit(onSaveCommit);
|
|
73
|
-
olmdb.onRevert(onSaveRevert);
|
|
74
|
-
}
|
|
75
|
-
onSaveQueue.push(arr);
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
57
|
/**
|
|
79
58
|
* Register a model class with the Edinburgh ORM system.
|
|
80
59
|
*
|
|
81
|
-
* This decorator function transforms the model class to use a proxy-based constructor
|
|
82
|
-
* that enables change tracking and automatic field initialization. It also extracts
|
|
83
|
-
* field metadata and sets up default values on the prototype.
|
|
84
|
-
*
|
|
85
60
|
* @template T - The model class type.
|
|
86
61
|
* @param MyModel - The model class to register.
|
|
87
62
|
* @returns The enhanced model class with ORM capabilities.
|
|
@@ -100,20 +75,19 @@ export function registerModel(MyModel) {
|
|
|
100
75
|
const MockModel = getMockModel(MyModel);
|
|
101
76
|
// Copy own static methods/properties
|
|
102
77
|
for (const name of Object.getOwnPropertyNames(MyModel)) {
|
|
103
|
-
if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock') {
|
|
78
|
+
if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock' && name !== 'override') {
|
|
104
79
|
MockModel[name] = MyModel[name];
|
|
105
80
|
}
|
|
106
81
|
}
|
|
107
|
-
|
|
108
|
-
MockModel.fields = MockModel.prototype._fields = {};
|
|
109
|
-
MockModel.tableName ||= MyModel.name; // Set the table name to the class name if not already set
|
|
82
|
+
MockModel.tableName ||= MyModel.name;
|
|
110
83
|
// Register the constructor by name
|
|
111
|
-
if (MockModel.tableName in modelRegistry)
|
|
112
|
-
|
|
84
|
+
if (MockModel.tableName in modelRegistry) {
|
|
85
|
+
if (!MyModel.override) {
|
|
86
|
+
throw new DatabaseError(`Model with table name '${MockModel.tableName}' already registered`, 'INIT_ERROR');
|
|
87
|
+
}
|
|
88
|
+
delete modelRegistry[MockModel.tableName];
|
|
89
|
+
}
|
|
113
90
|
modelRegistry[MockModel.tableName] = MockModel;
|
|
114
|
-
// Attempt to instantiate the class and gather field metadata
|
|
115
|
-
uninitializedModels.add(MyModel);
|
|
116
|
-
initModels();
|
|
117
91
|
return MockModel;
|
|
118
92
|
}
|
|
119
93
|
export function getMockModel(OrgModel) {
|
|
@@ -122,16 +96,14 @@ export function getMockModel(OrgModel) {
|
|
|
122
96
|
return OrgModel;
|
|
123
97
|
if (AnyOrgModel._mock)
|
|
124
98
|
return AnyOrgModel._mock;
|
|
125
|
-
const MockModel = function (initial) {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
99
|
+
const MockModel = function (initial, txn = currentTxn()) {
|
|
100
|
+
// This constructor should only be called when the user does 'new Model'. We'll bypass this when
|
|
101
|
+
// loading objects. Add to 'instances', so the object will be saved.
|
|
102
|
+
this._txn = txn;
|
|
103
|
+
txn.instances.add(this);
|
|
104
|
+
if (initial) {
|
|
130
105
|
Object.assign(this, initial);
|
|
131
|
-
const modifiedInstances = olmdb.getTransactionData(MODIFIED_INSTANCES_SYMBOL);
|
|
132
|
-
modifiedInstances.add(this);
|
|
133
106
|
}
|
|
134
|
-
return new Proxy(this, modificationTracker);
|
|
135
107
|
};
|
|
136
108
|
// We want .constructor to point at our fake constructor function.
|
|
137
109
|
OrgModel.prototype.constructor = MockModel;
|
|
@@ -139,77 +111,13 @@ export function getMockModel(OrgModel) {
|
|
|
139
111
|
Object.setPrototypeOf(MockModel, Object.getPrototypeOf(OrgModel));
|
|
140
112
|
MockModel.prototype = OrgModel.prototype;
|
|
141
113
|
MockModel._isMock = true;
|
|
114
|
+
MockModel._original = OrgModel;
|
|
142
115
|
AnyOrgModel._mock = MockModel;
|
|
116
|
+
scheduleInit();
|
|
143
117
|
return MockModel;
|
|
144
118
|
}
|
|
145
|
-
function initModels() {
|
|
146
|
-
for (const OrgModel of uninitializedModels) {
|
|
147
|
-
const MockModel = getMockModel(OrgModel);
|
|
148
|
-
// Create an instance (the only one to ever exist) of the actual class,
|
|
149
|
-
// in order to gather field config data.
|
|
150
|
-
let instance;
|
|
151
|
-
try {
|
|
152
|
-
instance = new OrgModel(INIT_INSTANCE_SYMBOL);
|
|
153
|
-
}
|
|
154
|
-
catch (e) {
|
|
155
|
-
if (!(e instanceof ReferenceError))
|
|
156
|
-
throw e;
|
|
157
|
-
// ReferenceError: Cannot access 'SomeLinkedClass' before initialization.
|
|
158
|
-
// We'll try again after the next class has successfully initialized.
|
|
159
|
-
continue;
|
|
160
|
-
}
|
|
161
|
-
uninitializedModels.delete(OrgModel);
|
|
162
|
-
// If no primary key exists, create one using 'id' field
|
|
163
|
-
if (!MockModel._pk) {
|
|
164
|
-
// If no `id` field exists, add it automatically
|
|
165
|
-
if (!instance.id) {
|
|
166
|
-
instance.id = { type: identifier };
|
|
167
|
-
}
|
|
168
|
-
// @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
|
|
169
|
-
new PrimaryIndex(MockModel, ['id']);
|
|
170
|
-
}
|
|
171
|
-
for (const key in instance) {
|
|
172
|
-
const value = instance[key];
|
|
173
|
-
// Check if this property contains field metadata
|
|
174
|
-
if (value && value.type instanceof TypeWrapper) {
|
|
175
|
-
// Set the configuration on the constructor's `fields` property
|
|
176
|
-
MockModel.fields[key] = value;
|
|
177
|
-
// Set default value on the prototype
|
|
178
|
-
const defObj = value.default === undefined ? value.type : value;
|
|
179
|
-
const def = defObj.default;
|
|
180
|
-
if (typeof def === 'function') {
|
|
181
|
-
// The default is a function. We'll define a getter on the property in the model prototype,
|
|
182
|
-
// and once it is read, we'll run the function and set the value as a plain old property
|
|
183
|
-
// on the instance object.
|
|
184
|
-
Object.defineProperty(MockModel.prototype, key, {
|
|
185
|
-
get() {
|
|
186
|
-
// This will call set(), which will define the property on the instance.
|
|
187
|
-
return (this[key] = def.call(defObj, this));
|
|
188
|
-
},
|
|
189
|
-
set(val) {
|
|
190
|
-
Object.defineProperty(this, key, {
|
|
191
|
-
value: val,
|
|
192
|
-
configurable: true,
|
|
193
|
-
writable: true
|
|
194
|
-
});
|
|
195
|
-
},
|
|
196
|
-
configurable: true,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
else if (def !== undefined) {
|
|
200
|
-
MockModel.prototype[key] = def;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
if (logLevel >= 1) {
|
|
205
|
-
console.log(`Registered model ${MockModel.tableName}[${MockModel._pk._fieldNames.join(',')}] with fields: ${Object.keys(MockModel.fields).join(' ')}`);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
119
|
// Model base class and related symbols/state
|
|
210
120
|
const INIT_INSTANCE_SYMBOL = Symbol();
|
|
211
|
-
/** @internal Symbol used to attach modified instances to running transaction */
|
|
212
|
-
export const MODIFIED_INSTANCES_SYMBOL = Symbol('modifiedInstances');
|
|
213
121
|
/**
|
|
214
122
|
* Base class for all database models in the Edinburgh ORM.
|
|
215
123
|
*
|
|
@@ -217,29 +125,60 @@ export const MODIFIED_INSTANCES_SYMBOL = Symbol('modifiedInstances');
|
|
|
217
125
|
* change tracking, and relationship management. All model classes should extend
|
|
218
126
|
* this base class and be decorated with `@registerModel`.
|
|
219
127
|
*
|
|
128
|
+
* ### Schema Evolution
|
|
129
|
+
*
|
|
130
|
+
* Edinburgh tracks the schema version of each model automatically. When you add, remove, or
|
|
131
|
+
* change the types of fields, or add/remove indexes, Edinburgh detects the new schema version.
|
|
132
|
+
*
|
|
133
|
+
* **Lazy migration:** Changes to non-key field values are migrated lazily, when a row with an
|
|
134
|
+
* old schema version is read from disk, it is deserialized using the old schema and optionally
|
|
135
|
+
* transformed by the static `migrate()` function. This happens transparently on every read
|
|
136
|
+
* and requires no downtime or batch processing.
|
|
137
|
+
*
|
|
138
|
+
* **Batch migration (via `npx migrate-edinburgh` or `runMigration()`):** Certain schema changes
|
|
139
|
+
* require an explicit migration run:
|
|
140
|
+
* - Adding or removing secondary/unique indexes
|
|
141
|
+
* - Changing the fields or types of an existing index
|
|
142
|
+
* - A `migrate()` function that changes values used in secondary index fields
|
|
143
|
+
*
|
|
144
|
+
* The batch migration tool populates new indexes, deletes orphaned ones, and updates index
|
|
145
|
+
* entries whose values were changed by `migrate()`. It does *not* rewrite primary data rows
|
|
146
|
+
* (lazy migration handles that).
|
|
147
|
+
*
|
|
148
|
+
* ### Lifecycle Hooks
|
|
149
|
+
*
|
|
150
|
+
* - **`static migrate(record)`**: Called when deserializing rows written with an older schema
|
|
151
|
+
* version. Receives a plain record object; mutate it in-place to match the current schema.
|
|
152
|
+
* See {@link Model.migrate}.
|
|
153
|
+
*
|
|
154
|
+
* - **`preCommit()`**: Called on each modified instance right before the transaction commits.
|
|
155
|
+
* Useful for computing derived fields, enforcing cross-field invariants, or creating related
|
|
156
|
+
* instances. See {@link Model.preCommit}.
|
|
157
|
+
*
|
|
220
158
|
* @template SUB - The concrete model subclass (for proper typing).
|
|
221
159
|
*
|
|
222
160
|
* @example
|
|
223
161
|
* ```typescript
|
|
224
162
|
* @E.registerModel
|
|
225
163
|
* class User extends E.Model<User> {
|
|
226
|
-
* static pk = E.
|
|
164
|
+
* static pk = E.primary(User, "id");
|
|
227
165
|
*
|
|
228
166
|
* id = E.field(E.identifier);
|
|
229
167
|
* name = E.field(E.string);
|
|
230
168
|
* email = E.field(E.string);
|
|
231
169
|
*
|
|
232
|
-
* static byEmail = E.
|
|
170
|
+
* static byEmail = E.unique(User, "email");
|
|
233
171
|
* }
|
|
234
172
|
* ```
|
|
235
173
|
*/
|
|
236
174
|
export class Model {
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
static _indexes;
|
|
175
|
+
static _primary;
|
|
176
|
+
/** @internal All non-primary indexes for this model. */
|
|
177
|
+
static _secondaries;
|
|
241
178
|
/** The database table name (defaults to class name). */
|
|
242
179
|
static tableName;
|
|
180
|
+
/** When true, registerModel replaces an existing model with the same tableName. */
|
|
181
|
+
static override;
|
|
243
182
|
/** Field configuration metadata. */
|
|
244
183
|
static fields;
|
|
245
184
|
/*
|
|
@@ -247,58 +186,197 @@ export class Model {
|
|
|
247
186
|
* initializing the class through a fake constructor that will skip these. This is
|
|
248
187
|
* intentional, as we don't want to run the initializers for the fields.
|
|
249
188
|
*/
|
|
250
|
-
/** @internal Field configuration for this instance. */
|
|
251
|
-
_fields;
|
|
252
189
|
/**
|
|
253
|
-
* @internal
|
|
254
|
-
* - undefined:
|
|
255
|
-
* -
|
|
256
|
-
* -
|
|
257
|
-
* - 3: persistence disabled
|
|
258
|
-
* - array: loaded from disk, modified (and in modifiedInstances), array values are original index buffers
|
|
190
|
+
* @internal
|
|
191
|
+
* - _oldValues===undefined: New instance, not yet saved.
|
|
192
|
+
* - _oldValues===null: Instance is to be deleted.
|
|
193
|
+
* - _oldValues is an object: Loaded (possibly only partial, still lazy) from disk, _oldValues contains (partial) old values
|
|
259
194
|
*/
|
|
260
|
-
|
|
195
|
+
_oldValues;
|
|
196
|
+
_primaryKey;
|
|
197
|
+
_primaryKeyHash;
|
|
198
|
+
_txn;
|
|
261
199
|
constructor(initial = {}) {
|
|
262
200
|
// This constructor will only be called once, from `initModels`. All other instances will
|
|
263
201
|
// be created by the 'fake' constructor. The typing for `initial` *is* important though.
|
|
264
|
-
if (initial
|
|
265
|
-
|
|
202
|
+
if (initial === INIT_INSTANCE_SYMBOL)
|
|
203
|
+
return;
|
|
204
|
+
throw new DatabaseError("The model needs a @registerModel decorator", 'INIT_ERROR');
|
|
205
|
+
}
|
|
206
|
+
static async _delayedInit(cleared) {
|
|
207
|
+
const MockModel = getMockModel(this);
|
|
208
|
+
if (cleared) {
|
|
209
|
+
MockModel._primary._indexId = undefined;
|
|
210
|
+
MockModel._primary._versions.clear();
|
|
211
|
+
for (const sec of MockModel._secondaries || [])
|
|
212
|
+
sec._indexId = undefined;
|
|
266
213
|
}
|
|
214
|
+
if (!MockModel.fields) {
|
|
215
|
+
// First-time init: gather field configs from a temporary instance of the original class.
|
|
216
|
+
const OrgModel = MockModel._original || this;
|
|
217
|
+
const instance = new OrgModel(INIT_INSTANCE_SYMBOL);
|
|
218
|
+
// If no primary key exists, create one using 'id' field
|
|
219
|
+
if (!MockModel._primary) {
|
|
220
|
+
if (!instance.id) {
|
|
221
|
+
instance.id = { type: identifier };
|
|
222
|
+
}
|
|
223
|
+
// @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
|
|
224
|
+
new PrimaryIndex(MockModel, ['id']);
|
|
225
|
+
}
|
|
226
|
+
MockModel.fields = {};
|
|
227
|
+
for (const key in instance) {
|
|
228
|
+
const value = instance[key];
|
|
229
|
+
// Check if this property contains field metadata
|
|
230
|
+
if (value && value.type instanceof TypeWrapper) {
|
|
231
|
+
// Set the configuration on the constructor's `fields` property
|
|
232
|
+
MockModel.fields[key] = value;
|
|
233
|
+
// Set default value on the prototype
|
|
234
|
+
const defObj = value.default === undefined ? value.type : value;
|
|
235
|
+
const def = defObj.default;
|
|
236
|
+
if (typeof def === 'function') {
|
|
237
|
+
// The default is a function. We'll define a getter on the property in the model prototype,
|
|
238
|
+
// and once it is read, we'll run the function and set the value as a plain old property
|
|
239
|
+
// on the instance object.
|
|
240
|
+
Object.defineProperty(MockModel.prototype, key, {
|
|
241
|
+
get() {
|
|
242
|
+
// This will call set(), which will define the property on the instance.
|
|
243
|
+
return (this[key] = def.call(defObj, this));
|
|
244
|
+
},
|
|
245
|
+
set(val) {
|
|
246
|
+
Object.defineProperty(this, key, {
|
|
247
|
+
value: val,
|
|
248
|
+
configurable: true,
|
|
249
|
+
writable: true,
|
|
250
|
+
enumerable: true,
|
|
251
|
+
});
|
|
252
|
+
},
|
|
253
|
+
configurable: true,
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
else if (def !== undefined) {
|
|
257
|
+
MockModel.prototype[key] = def;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
if (logLevel >= 1) {
|
|
262
|
+
console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
// Always run index inits (idempotent, skip if already initialized)
|
|
266
|
+
await MockModel._primary._delayedInit();
|
|
267
|
+
for (const sec of MockModel._secondaries || [])
|
|
268
|
+
await sec._delayedInit();
|
|
269
|
+
await MockModel._primary._initVersioning();
|
|
267
270
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
271
|
+
_setLoadedField(fieldName, value) {
|
|
272
|
+
const oldValues = this._oldValues;
|
|
273
|
+
if (oldValues.hasOwnProperty(fieldName))
|
|
274
|
+
return; // Already loaded earlier (as part of index key?)
|
|
275
|
+
this[fieldName] = value;
|
|
276
|
+
if (typeof value === 'object' && value !== null) {
|
|
277
|
+
const fieldType = this.constructor.fields[fieldName].type;
|
|
278
|
+
oldValues[fieldName] = fieldType.clone(value);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
// This path is just an optimization
|
|
282
|
+
oldValues[fieldName] = value;
|
|
278
283
|
}
|
|
279
|
-
queueOnSave([this, newPk, originalKeys?.[0]]);
|
|
280
|
-
unproxiedModel._state = 2; // Loaded from disk, unmodified
|
|
281
284
|
}
|
|
282
285
|
/**
|
|
283
|
-
*
|
|
284
|
-
* @param args - Primary key field values.
|
|
285
|
-
* @returns The model instance if found, undefined otherwise.
|
|
286
|
-
*
|
|
287
|
-
* @example
|
|
288
|
-
* ```typescript
|
|
289
|
-
* const user = User.load("user123");
|
|
290
|
-
* const post = Post.load("post456", "en");
|
|
291
|
-
* ```
|
|
286
|
+
* @returns The primary key for this instance.
|
|
292
287
|
*/
|
|
293
|
-
|
|
294
|
-
|
|
288
|
+
getPrimaryKey() {
|
|
289
|
+
let key = this._primaryKey;
|
|
290
|
+
if (key === undefined) {
|
|
291
|
+
key = this.constructor._primary._serializeKeyFields(this).toUint8Array();
|
|
292
|
+
this._setPrimaryKey(key);
|
|
293
|
+
}
|
|
294
|
+
return key;
|
|
295
|
+
}
|
|
296
|
+
_setPrimaryKey(key, hash) {
|
|
297
|
+
this._primaryKey = key;
|
|
298
|
+
this._primaryKeyHash = hash ?? hashBytes(key);
|
|
299
|
+
Object.defineProperties(this, this.constructor._primary._freezePrimaryKeyDescriptors);
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* @returns A 53-bit positive integer non-cryptographic hash of the primary key, or undefined if not yet saved.
|
|
303
|
+
*/
|
|
304
|
+
getPrimaryKeyHash() {
|
|
305
|
+
if (this._primaryKeyHash === undefined)
|
|
306
|
+
this.getPrimaryKey();
|
|
307
|
+
return this._primaryKeyHash;
|
|
308
|
+
}
|
|
309
|
+
isLazyField(field) {
|
|
310
|
+
const descr = this.constructor._primary._lazyDescriptors[field];
|
|
311
|
+
return !!(descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, field)?.get);
|
|
312
|
+
}
|
|
313
|
+
_write(txn) {
|
|
314
|
+
const oldValues = this._oldValues;
|
|
315
|
+
if (oldValues === null) { // Delete instance
|
|
316
|
+
const pk = this._primaryKey;
|
|
317
|
+
this.constructor._primary._delete(txn, pk, this);
|
|
318
|
+
for (const index of this.constructor._secondaries || []) {
|
|
319
|
+
index._delete(txn, pk, this);
|
|
320
|
+
}
|
|
321
|
+
return "deleted";
|
|
322
|
+
}
|
|
323
|
+
if (oldValues === undefined) { // Create instance
|
|
324
|
+
this.validate(true);
|
|
325
|
+
// Make sure the primary key does not already exist
|
|
326
|
+
const pk = this.getPrimaryKey();
|
|
327
|
+
if (dbGet(txn.id, pk)) {
|
|
328
|
+
throw new DatabaseError("Unique constraint violation", "UNIQUE_CONSTRAINT");
|
|
329
|
+
}
|
|
330
|
+
// Insert the primary index
|
|
331
|
+
this.constructor._primary._write(txn, pk, this);
|
|
332
|
+
// Insert all secondaries
|
|
333
|
+
for (const index of this.constructor._secondaries || []) {
|
|
334
|
+
index._write(txn, pk, this);
|
|
335
|
+
}
|
|
336
|
+
return "created";
|
|
337
|
+
}
|
|
338
|
+
// oldValues is an object.
|
|
339
|
+
// We're doing an update. Note that we may still be in a lazy state, and we don't want to load
|
|
340
|
+
// the whole object just to see if something changed.
|
|
341
|
+
// Add old values of changed fields to 'changed'.
|
|
342
|
+
const fields = this.constructor.fields;
|
|
343
|
+
let changed = {};
|
|
344
|
+
for (const fieldName in oldValues) {
|
|
345
|
+
const oldValue = oldValues[fieldName];
|
|
346
|
+
const newValue = this[fieldName];
|
|
347
|
+
if (newValue !== oldValue && !fields[fieldName].type.equals(newValue, oldValue)) {
|
|
348
|
+
changed[fieldName] = oldValue;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
if (isObjectEmpty(changed))
|
|
352
|
+
return; // No changes, nothing to do
|
|
353
|
+
// Make sure primary has not been changed
|
|
354
|
+
for (const field of this.constructor._primary._fieldTypes.keys()) {
|
|
355
|
+
if (changed.hasOwnProperty(field)) {
|
|
356
|
+
throw new DatabaseError(`Cannot modify primary key field: ${field}`, "CHANGE_PRIMARY");
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
// We have changes. Now it's okay for any lazy fields to be loaded (which the validate will trigger).
|
|
360
|
+
// Raise any validation errors
|
|
361
|
+
this.validate(true);
|
|
362
|
+
// Update the primary index
|
|
363
|
+
const pk = this._primaryKey;
|
|
364
|
+
this.constructor._primary._write(txn, pk, this);
|
|
365
|
+
// Update any secondaries with changed fields
|
|
366
|
+
for (const index of this.constructor._secondaries || []) {
|
|
367
|
+
for (const field of index._fieldTypes.keys()) {
|
|
368
|
+
if (changed.hasOwnProperty(field)) {
|
|
369
|
+
index._delete(txn, pk, oldValues);
|
|
370
|
+
index._write(txn, pk, this);
|
|
371
|
+
break;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return changed;
|
|
295
376
|
}
|
|
296
377
|
/**
|
|
297
378
|
* Prevent this instance from being persisted to the database.
|
|
298
379
|
*
|
|
299
|
-
* Removes the instance from the modified instances set and disables
|
|
300
|
-
* automatic persistence at transaction commit.
|
|
301
|
-
*
|
|
302
380
|
* @returns This model instance for chaining.
|
|
303
381
|
*
|
|
304
382
|
* @example
|
|
@@ -309,12 +387,50 @@ export class Model {
|
|
|
309
387
|
* ```
|
|
310
388
|
*/
|
|
311
389
|
preventPersist() {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
unproxiedModel._state = 3; // no persist
|
|
390
|
+
this._txn.instances.delete(this);
|
|
391
|
+
// Have access to '_txn' throw a descriptive error:
|
|
392
|
+
Object.defineProperty(this, "_txn", PREVENT_PERSIST_DESCRIPTOR);
|
|
316
393
|
return this;
|
|
317
394
|
}
|
|
395
|
+
/**
|
|
396
|
+
* Find all instances of this model in the database, ordered by primary key.
|
|
397
|
+
* @param opts - Optional parameters.
|
|
398
|
+
* @param opts.reverse - If true, iterate in reverse order.
|
|
399
|
+
* @returns An iterator.
|
|
400
|
+
*/
|
|
401
|
+
static findAll(opts) {
|
|
402
|
+
return this._primary.find(opts);
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Load an existing instance by primary key and update it, or create a new one.
|
|
406
|
+
*
|
|
407
|
+
* The provided object must contain all primary key fields. If a matching row exists,
|
|
408
|
+
* the remaining properties from `obj` are set on the loaded instance. Otherwise a
|
|
409
|
+
* new instance is created with `obj` as its initial properties.
|
|
410
|
+
*
|
|
411
|
+
* @param obj - Partial model data that **must** include every primary key field.
|
|
412
|
+
* @returns The loaded-and-updated or newly created instance.
|
|
413
|
+
*/
|
|
414
|
+
static replaceInto(obj) {
|
|
415
|
+
const pk = this._primary;
|
|
416
|
+
const keyArgs = [];
|
|
417
|
+
for (const fieldName of pk._fieldTypes.keys()) {
|
|
418
|
+
if (!(fieldName in obj)) {
|
|
419
|
+
throw new DatabaseError(`replaceInto: missing primary key field '${fieldName}'`, "MISSING_PRIMARY_KEY");
|
|
420
|
+
}
|
|
421
|
+
keyArgs.push(obj[fieldName]);
|
|
422
|
+
}
|
|
423
|
+
const existing = pk.get(...keyArgs);
|
|
424
|
+
if (existing) {
|
|
425
|
+
for (const key in obj) {
|
|
426
|
+
if (!pk._fieldTypes.has(key)) {
|
|
427
|
+
existing[key] = obj[key];
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return existing;
|
|
431
|
+
}
|
|
432
|
+
return new this(obj);
|
|
433
|
+
}
|
|
318
434
|
/**
|
|
319
435
|
* Delete this model instance from the database.
|
|
320
436
|
*
|
|
@@ -327,16 +443,9 @@ export class Model {
|
|
|
327
443
|
* ```
|
|
328
444
|
*/
|
|
329
445
|
delete() {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
const key = index._getKeyFromModel(unproxiedModel, true);
|
|
334
|
-
olmdb.del(key);
|
|
335
|
-
if (index instanceof PrimaryIndex)
|
|
336
|
-
queueOnSave([this, undefined, key]);
|
|
337
|
-
}
|
|
338
|
-
}
|
|
339
|
-
this.preventPersist();
|
|
446
|
+
if (this._oldValues === undefined)
|
|
447
|
+
throw new DatabaseError("Cannot delete unsaved instance", "NOT_SAVED");
|
|
448
|
+
this._oldValues = null;
|
|
340
449
|
}
|
|
341
450
|
/**
|
|
342
451
|
* Validate all fields in this model instance.
|
|
@@ -354,12 +463,13 @@ export class Model {
|
|
|
354
463
|
*/
|
|
355
464
|
validate(raise = false) {
|
|
356
465
|
const errors = [];
|
|
357
|
-
for (const [key, fieldConfig] of Object.entries(this.
|
|
358
|
-
|
|
359
|
-
|
|
466
|
+
for (const [key, fieldConfig] of Object.entries(this.constructor.fields)) {
|
|
467
|
+
let e = fieldConfig.type.getError(this[key]);
|
|
468
|
+
if (e) {
|
|
469
|
+
e = addErrorPath(e, this.constructor.tableName + "." + key);
|
|
360
470
|
if (raise)
|
|
361
|
-
throw
|
|
362
|
-
errors.push(
|
|
471
|
+
throw e;
|
|
472
|
+
errors.push(e);
|
|
363
473
|
}
|
|
364
474
|
}
|
|
365
475
|
return errors;
|
|
@@ -377,81 +487,25 @@ export class Model {
|
|
|
377
487
|
isValid() {
|
|
378
488
|
return this.validate().length === 0;
|
|
379
489
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
const modificationProxyCache = new WeakMap();
|
|
389
|
-
// Single proxy handler for both models and nested objects
|
|
390
|
-
export const modificationTracker = {
|
|
391
|
-
get(target, prop) {
|
|
392
|
-
if (prop === TARGET_SYMBOL)
|
|
393
|
-
return target;
|
|
394
|
-
const value = target[prop];
|
|
395
|
-
if (!value || typeof value !== 'object' || (value instanceof Model))
|
|
396
|
-
return value;
|
|
397
|
-
// Check cache first
|
|
398
|
-
let proxy = modificationProxyCache.get(value);
|
|
399
|
-
if (proxy)
|
|
400
|
-
return proxy;
|
|
401
|
-
let model;
|
|
402
|
-
if (target instanceof Model) {
|
|
403
|
-
if (!target._fields[prop]) {
|
|
404
|
-
// No need to track properties that are not model fields.
|
|
405
|
-
return value;
|
|
490
|
+
getState() {
|
|
491
|
+
if (this._oldValues === null)
|
|
492
|
+
return "deleted";
|
|
493
|
+
if (this._oldValues === undefined)
|
|
494
|
+
return "created";
|
|
495
|
+
for (const [key, descr] of Object.entries(this.constructor._primary._lazyDescriptors)) {
|
|
496
|
+
if (descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, key)?.get) {
|
|
497
|
+
return "lazy";
|
|
406
498
|
}
|
|
407
|
-
model = target;
|
|
408
|
-
}
|
|
409
|
-
else {
|
|
410
|
-
model = modificationOwnerMap.get(target);
|
|
411
|
-
assert(model);
|
|
412
|
-
}
|
|
413
|
-
let state = model._state;
|
|
414
|
-
if (state !== undefined && state !== 2) {
|
|
415
|
-
// We don't need to track changes for this model (anymore). So we can just return the unproxied object.
|
|
416
|
-
// As we doing the modificationProxyCache lookup first, the identity of returned objects will not change:
|
|
417
|
-
// once a proxied object is returned, the same property will always return a proxied object.
|
|
418
|
-
return value;
|
|
419
499
|
}
|
|
420
|
-
|
|
421
|
-
throw new DatabaseError("Object cannot be embedded in multiple model instances", 'VALUE_ERROR');
|
|
422
|
-
}
|
|
423
|
-
modificationOwnerMap.set(value, model);
|
|
424
|
-
proxy = new Proxy(value, modificationTracker);
|
|
425
|
-
modificationProxyCache.set(value, proxy);
|
|
426
|
-
return proxy;
|
|
427
|
-
},
|
|
428
|
-
set(target, prop, value) {
|
|
429
|
-
let model;
|
|
430
|
-
if (target instanceof Model) {
|
|
431
|
-
model = target;
|
|
432
|
-
if (!model._fields[prop]) {
|
|
433
|
-
// No need to track properties that are not model fields.
|
|
434
|
-
target[prop] = value;
|
|
435
|
-
return true;
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
else {
|
|
439
|
-
model = modificationOwnerMap.get(target);
|
|
440
|
-
assert(model);
|
|
441
|
-
}
|
|
442
|
-
let state = model._state;
|
|
443
|
-
if (state === undefined || state === 2) {
|
|
444
|
-
const modifiedInstances = olmdb.getTransactionData(MODIFIED_INSTANCES_SYMBOL);
|
|
445
|
-
modifiedInstances.add(model);
|
|
446
|
-
if (state === 2) {
|
|
447
|
-
model._state = model.constructor._indexes.map(idx => idx._getKeyFromModel(model, true));
|
|
448
|
-
}
|
|
449
|
-
else {
|
|
450
|
-
model._state = 1;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
target[prop] = value;
|
|
454
|
-
return true;
|
|
500
|
+
return "loaded";
|
|
455
501
|
}
|
|
456
|
-
|
|
502
|
+
toString() {
|
|
503
|
+
const primary = this.constructor._primary;
|
|
504
|
+
const pk = primary._keyToArray(this._primaryKey || primary._serializeKeyFields(this).toUint8Array(false));
|
|
505
|
+
return `{Model:${this.constructor.tableName} ${this.getState()} ${pk}}`;
|
|
506
|
+
}
|
|
507
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
508
|
+
return this.toString();
|
|
509
|
+
}
|
|
510
|
+
}
|
|
457
511
|
//# sourceMappingURL=models.js.map
|