edinburgh 0.3.0 → 0.4.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/LICENSE +1 -1
- package/README.md +691 -212
- package/build/src/datapack.d.ts +22 -3
- package/build/src/datapack.js +105 -41
- package/build/src/datapack.js.map +1 -1
- package/build/src/edinburgh.d.ts +31 -13
- package/build/src/edinburgh.js +149 -62
- package/build/src/edinburgh.js.map +1 -1
- package/build/src/indexes.d.ts +78 -56
- package/build/src/indexes.js +519 -284
- 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 +130 -25
- package/build/src/models.js +271 -169
- package/build/src/models.js.map +1 -1
- package/build/src/types.d.ts +24 -7
- package/build/src/types.js +49 -15
- package/build/src/types.js.map +1 -1
- package/build/src/utils.d.ts +6 -10
- package/build/src/utils.js +26 -32
- package/build/src/utils.js.map +1 -1
- package/package.json +12 -10
- package/skill/SKILL.md +1349 -0
- package/src/datapack.ts +117 -46
- package/src/edinburgh.ts +156 -64
- package/src/indexes.ts +550 -287
- package/src/migrate-cli.ts +138 -0
- package/src/migrate.ts +267 -0
- package/src/models.ts +352 -184
- package/src/types.ts +59 -16
- package/src/utils.ts +32 -32
package/src/models.ts
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
|
-
import
|
|
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 { 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
|
+
}
|
|
4
31
|
import { BaseIndex as BaseIndex, PrimaryIndex, IndexRangeIterator } from "./indexes.js";
|
|
5
|
-
import { addErrorPath, logLevel,
|
|
6
|
-
import { on } from "events";
|
|
32
|
+
import { addErrorPath, logLevel, assert, dbGet, hashBytes } from "./utils.js";
|
|
7
33
|
|
|
8
34
|
/**
|
|
9
35
|
* Configuration interface for model fields.
|
|
@@ -47,15 +73,6 @@ export function field<T>(type: TypeWrapper<T>, options: Partial<FieldConfig<T>>
|
|
|
47
73
|
// Model registration and initialization
|
|
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._secondaries || []) {
|
|
53
|
-
index._cachedIndexId = undefined;
|
|
54
|
-
}
|
|
55
|
-
delete model._primary?._cachedIndexId;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
76
|
function isObjectEmpty(obj: object) {
|
|
60
77
|
for (let _ of Object.keys(obj)) {
|
|
61
78
|
return false;
|
|
@@ -63,9 +80,7 @@ function isObjectEmpty(obj: object) {
|
|
|
63
80
|
return true;
|
|
64
81
|
}
|
|
65
82
|
|
|
66
|
-
export type
|
|
67
|
-
changed: Record<any, any> | "updated" | "deleted";
|
|
68
|
-
}
|
|
83
|
+
export type Change = Record<any, any> | "created" | "deleted";
|
|
69
84
|
|
|
70
85
|
/**
|
|
71
86
|
* Register a model class with the Edinburgh ORM system.
|
|
@@ -89,21 +104,21 @@ export function registerModel<T extends typeof Model<unknown>>(MyModel: T): T {
|
|
|
89
104
|
|
|
90
105
|
// Copy own static methods/properties
|
|
91
106
|
for(const name of Object.getOwnPropertyNames(MyModel)) {
|
|
92
|
-
if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock') {
|
|
107
|
+
if (name !== 'length' && name !== 'prototype' && name !== 'name' && name !== 'mock' && name !== 'override') {
|
|
93
108
|
(MockModel as any)[name] = (MyModel as any)[name];
|
|
94
109
|
}
|
|
95
110
|
}
|
|
96
|
-
|
|
97
|
-
MockModel.tableName ||= MyModel.name; // Set the table name to the class name if not already set
|
|
111
|
+
MockModel.tableName ||= MyModel.name;
|
|
98
112
|
|
|
99
113
|
// Register the constructor by name
|
|
100
|
-
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
|
+
}
|
|
101
120
|
modelRegistry[MockModel.tableName] = MockModel;
|
|
102
121
|
|
|
103
|
-
// Attempt to instantiate the class and gather field metadata
|
|
104
|
-
delayedInits.add(MyModel);
|
|
105
|
-
tryDelayedInits();
|
|
106
|
-
|
|
107
122
|
return MockModel;
|
|
108
123
|
}
|
|
109
124
|
|
|
@@ -112,16 +127,14 @@ export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
|
|
|
112
127
|
if (AnyOrgModel._isMock) return OrgModel;
|
|
113
128
|
if (AnyOrgModel._mock) return AnyOrgModel._mock;
|
|
114
129
|
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (initial
|
|
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) {
|
|
121
136
|
Object.assign(this, initial);
|
|
122
137
|
}
|
|
123
|
-
const instances = olmdb.getTransactionData(INSTANCES_SYMBOL) as Set<Model<any>>;
|
|
124
|
-
instances.add(this);
|
|
125
138
|
} as any as T;
|
|
126
139
|
|
|
127
140
|
// We want .constructor to point at our fake constructor function.
|
|
@@ -131,18 +144,15 @@ export function getMockModel<T extends typeof Model<unknown>>(OrgModel: T): T {
|
|
|
131
144
|
Object.setPrototypeOf(MockModel, Object.getPrototypeOf(OrgModel));
|
|
132
145
|
MockModel.prototype = OrgModel.prototype;
|
|
133
146
|
(MockModel as any)._isMock = true;
|
|
147
|
+
(MockModel as any)._original = OrgModel;
|
|
134
148
|
AnyOrgModel._mock = MockModel;
|
|
149
|
+
scheduleInit();
|
|
135
150
|
return MockModel;
|
|
136
151
|
}
|
|
137
152
|
|
|
138
153
|
// Model base class and related symbols/state
|
|
139
154
|
const INIT_INSTANCE_SYMBOL = Symbol();
|
|
140
155
|
|
|
141
|
-
/** @internal Symbol used to attach modified instances to running transaction */
|
|
142
|
-
export const INSTANCES_SYMBOL = Symbol('instances');
|
|
143
|
-
|
|
144
|
-
/** @internal Symbol used to access the underlying model from a proxy */
|
|
145
|
-
|
|
146
156
|
/**
|
|
147
157
|
* Model interface that ensures proper typing for the constructor property.
|
|
148
158
|
* @template SUB - The concrete model subclass.
|
|
@@ -158,19 +168,49 @@ export interface Model<SUB> {
|
|
|
158
168
|
* change tracking, and relationship management. All model classes should extend
|
|
159
169
|
* this base class and be decorated with `@registerModel`.
|
|
160
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
|
+
*
|
|
161
201
|
* @template SUB - The concrete model subclass (for proper typing).
|
|
162
202
|
*
|
|
163
203
|
* @example
|
|
164
204
|
* ```typescript
|
|
165
205
|
* @E.registerModel
|
|
166
206
|
* class User extends E.Model<User> {
|
|
167
|
-
* static pk = E.
|
|
207
|
+
* static pk = E.primary(User, "id");
|
|
168
208
|
*
|
|
169
209
|
* id = E.field(E.identifier);
|
|
170
210
|
* name = E.field(E.string);
|
|
171
211
|
* email = E.field(E.string);
|
|
172
212
|
*
|
|
173
|
-
* static byEmail = E.
|
|
213
|
+
* static byEmail = E.unique(User, "email");
|
|
174
214
|
* }
|
|
175
215
|
* ```
|
|
176
216
|
*/
|
|
@@ -185,9 +225,43 @@ export abstract class Model<SUB> {
|
|
|
185
225
|
/** The database table name (defaults to class name). */
|
|
186
226
|
static tableName: string;
|
|
187
227
|
|
|
228
|
+
/** When true, registerModel replaces an existing model with the same tableName. */
|
|
229
|
+
static override?: boolean;
|
|
230
|
+
|
|
188
231
|
/** Field configuration metadata. */
|
|
189
232
|
static fields: Record<string | symbol | number, FieldConfig<unknown>>;
|
|
190
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;
|
|
264
|
+
|
|
191
265
|
/*
|
|
192
266
|
* IMPORTANT: We cannot use instance property initializers here, because we will be
|
|
193
267
|
* initializing the class through a fake constructor that will skip these. This is
|
|
@@ -196,118 +270,157 @@ export abstract class Model<SUB> {
|
|
|
196
270
|
|
|
197
271
|
/**
|
|
198
272
|
* @internal
|
|
199
|
-
* -
|
|
200
|
-
* - _oldValues
|
|
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
|
|
201
276
|
*/
|
|
202
|
-
_oldValues:
|
|
277
|
+
_oldValues: Record<string, any> | undefined | null;
|
|
203
278
|
_primaryKey: Uint8Array | undefined;
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
* This property can be used in `setOnSave` callbacks to determine how a model instance has changed.
|
|
207
|
-
* If the value is undefined, the instance has been created. If it's "deleted" the instance has
|
|
208
|
-
* been deleted. If its an object, the instance has been modified and the object contains the old values.
|
|
209
|
-
*
|
|
210
|
-
* Note: this property should **not** be accessed *during* a `transact()` -- it's state is an implementation
|
|
211
|
-
* detail that may change semantics at any minor release.
|
|
212
|
-
*/
|
|
213
|
-
changed?: Record<any,any> | "deleted" | "created";
|
|
214
|
-
|
|
215
|
-
// Reference the static `fields` property; the mock constructor copies it here for performance
|
|
216
|
-
_fields!: Record<string | symbol | number, FieldConfig<unknown>>;
|
|
279
|
+
_primaryKeyHash: number | undefined;
|
|
280
|
+
_txn!: Transaction;
|
|
217
281
|
|
|
218
282
|
constructor(initial: Partial<Omit<SUB, "constructor">> = {}) {
|
|
219
283
|
// This constructor will only be called once, from `initModels`. All other instances will
|
|
220
284
|
// be created by the 'fake' constructor. The typing for `initial` *is* important though.
|
|
221
|
-
if (initial as any
|
|
222
|
-
|
|
223
|
-
}
|
|
285
|
+
if (initial as any === INIT_INSTANCE_SYMBOL) return;
|
|
286
|
+
throw new DatabaseError("The model needs a @registerModel decorator", 'INIT_ERROR');
|
|
224
287
|
}
|
|
225
288
|
|
|
226
|
-
|
|
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;
|
|
315
|
+
|
|
316
|
+
static async _delayedInit(cleared?: boolean): Promise<void> {
|
|
227
317
|
const MockModel = getMockModel(this);
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
} catch(e) {
|
|
234
|
-
if (!(e instanceof ReferenceError)) throw e;
|
|
235
|
-
// ReferenceError: Cannot access 'SomeLinkedClass' before initialization.
|
|
236
|
-
// We'll try again after the next class has successfully initialized.
|
|
237
|
-
return false;
|
|
318
|
+
|
|
319
|
+
if (cleared) {
|
|
320
|
+
MockModel._primary._indexId = undefined;
|
|
321
|
+
MockModel._primary._versions.clear();
|
|
322
|
+
for (const sec of MockModel._secondaries || []) sec._indexId = undefined;
|
|
238
323
|
}
|
|
239
324
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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']);
|
|
245
337
|
}
|
|
246
|
-
// @ts-ignore-next-line - `id` is not part of the type, but the user probably shouldn't touch it anyhow
|
|
247
|
-
new PrimaryIndex(MockModel, ['id']);
|
|
248
|
-
}
|
|
249
338
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
+
}
|
|
282
372
|
}
|
|
283
373
|
}
|
|
284
|
-
}
|
|
285
374
|
|
|
286
|
-
|
|
287
|
-
|
|
375
|
+
if (logLevel >= 1) {
|
|
376
|
+
console.log(`[edinburgh] Registered model ${MockModel.tableName} with fields: ${Object.keys(MockModel.fields).join(' ')}`);
|
|
377
|
+
}
|
|
288
378
|
}
|
|
289
379
|
|
|
290
|
-
|
|
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();
|
|
291
384
|
}
|
|
292
385
|
|
|
293
386
|
_setLoadedField(fieldName: string, value: any) {
|
|
294
|
-
const
|
|
295
|
-
if (
|
|
387
|
+
const oldValues = this._oldValues!;
|
|
388
|
+
if (oldValues.hasOwnProperty(fieldName)) return; // Already loaded earlier (as part of index key?)
|
|
296
389
|
|
|
297
|
-
const fieldType = (this._fields[fieldName] as FieldConfig<unknown>).type;
|
|
298
390
|
this[fieldName as keyof Model<SUB>] = value;
|
|
299
|
-
|
|
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
|
+
}
|
|
300
398
|
}
|
|
301
399
|
|
|
302
400
|
/**
|
|
303
|
-
* @returns The primary key for this instance
|
|
401
|
+
* @returns The primary key for this instance.
|
|
304
402
|
*/
|
|
305
|
-
getPrimaryKey(): Uint8Array
|
|
306
|
-
|
|
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;
|
|
307
410
|
}
|
|
308
411
|
|
|
309
|
-
|
|
310
|
-
|
|
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.
|
|
420
|
+
*/
|
|
421
|
+
getPrimaryKeyHash(): number {
|
|
422
|
+
if (this._primaryKeyHash === undefined) this.getPrimaryKey();
|
|
423
|
+
return this._primaryKeyHash!;
|
|
311
424
|
}
|
|
312
425
|
|
|
313
426
|
isLazyField(field: keyof this) {
|
|
@@ -315,82 +428,83 @@ export abstract class Model<SUB> {
|
|
|
315
428
|
return !!(descr && 'get' in descr && descr.get === Reflect.getOwnPropertyDescriptor(this, field)?.get);
|
|
316
429
|
}
|
|
317
430
|
|
|
318
|
-
|
|
431
|
+
_write(txn: Transaction): undefined | Change {
|
|
319
432
|
const oldValues = this._oldValues;
|
|
320
|
-
let changed : Record<any, any> | "created" | "deleted";
|
|
321
|
-
|
|
322
|
-
if (oldValues) {
|
|
323
|
-
// We're doing an update. Note that we may still be in a lazy state, and we don't want to load
|
|
324
|
-
// the whole object just to see if something changed.
|
|
325
|
-
|
|
326
|
-
// Delete all items from this.changed that have not actually changed.
|
|
327
|
-
const fields = this._fields;
|
|
328
|
-
changed = {};
|
|
329
|
-
for(const fieldName of Object.keys(oldValues) as Iterable<keyof Model<SUB>>) {
|
|
330
|
-
const oldValue = oldValues[fieldName];
|
|
331
|
-
if (!(fields[fieldName] as FieldConfig<unknown>).type.equals(this[fieldName], oldValue)) {
|
|
332
|
-
changed[fieldName] = oldValue;
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
if (isObjectEmpty(changed)) return; // No changes, nothing to do
|
|
336
|
-
|
|
337
|
-
// Make sure primary has not been changed
|
|
338
|
-
for (const field of this.constructor._primary!._fieldTypes.keys()) {
|
|
339
|
-
if (changed.hasOwnProperty(field)) {
|
|
340
|
-
throw new DatabaseError(`Cannot modify primary key field: ${field}`, "CHANGE_PRIMARY");
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
433
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
this.validate(true);
|
|
348
|
-
|
|
349
|
-
// Update the primary index
|
|
350
|
-
this.constructor._primary!._write(this);
|
|
351
|
-
|
|
352
|
-
// Update any secondaries with changed fields
|
|
353
|
-
for (const index of this.constructor._secondaries || []) {
|
|
354
|
-
for (const field of index._fieldTypes.keys()) {
|
|
355
|
-
if (changed.hasOwnProperty(field)) {
|
|
356
|
-
// We need to update this index - first delete the old one
|
|
357
|
-
index._delete(oldValues);
|
|
358
|
-
index._write(this)
|
|
359
|
-
break;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
} else if (this._primaryKey) { // Deleted instance
|
|
364
|
-
this.constructor._primary._delete(this);
|
|
434
|
+
if (oldValues === null) { // Delete instance
|
|
435
|
+
const pk = this._primaryKey;
|
|
436
|
+
this.constructor._primary._delete(txn, pk!, this);
|
|
365
437
|
for(const index of this.constructor._secondaries || []) {
|
|
366
|
-
index._delete(this);
|
|
438
|
+
index._delete(txn, pk!, this);
|
|
367
439
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
440
|
+
|
|
441
|
+
return "deleted";
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
if (oldValues === undefined) { // Create instance
|
|
372
445
|
this.validate(true);
|
|
373
446
|
|
|
374
447
|
// Make sure the primary key does not already exist
|
|
375
|
-
|
|
448
|
+
const pk = this.getPrimaryKey();
|
|
449
|
+
if (dbGet(txn.id, pk!)) {
|
|
376
450
|
throw new DatabaseError("Unique constraint violation", "UNIQUE_CONSTRAINT");
|
|
377
451
|
}
|
|
378
452
|
|
|
379
453
|
// Insert the primary index
|
|
380
|
-
this.constructor._primary!._write(this);
|
|
454
|
+
this.constructor._primary!._write(txn, pk!, this);
|
|
381
455
|
|
|
382
456
|
// Insert all secondaries
|
|
383
457
|
for (const index of this.constructor._secondaries || []) {
|
|
384
|
-
index._write(this);
|
|
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;
|
|
385
476
|
}
|
|
477
|
+
}
|
|
386
478
|
|
|
387
|
-
|
|
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
|
+
}
|
|
388
486
|
}
|
|
389
487
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
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
|
+
}
|
|
393
506
|
}
|
|
507
|
+
return changed;
|
|
394
508
|
}
|
|
395
509
|
|
|
396
510
|
/**
|
|
@@ -406,8 +520,9 @@ export abstract class Model<SUB> {
|
|
|
406
520
|
* ```
|
|
407
521
|
*/
|
|
408
522
|
preventPersist() {
|
|
409
|
-
|
|
410
|
-
|
|
523
|
+
this._txn.instances.delete(this);
|
|
524
|
+
// Have access to '_txn' throw a descriptive error:
|
|
525
|
+
Object.defineProperty(this, "_txn", PREVENT_PERSIST_DESCRIPTOR);
|
|
411
526
|
return this;
|
|
412
527
|
}
|
|
413
528
|
|
|
@@ -421,6 +536,38 @@ export abstract class Model<SUB> {
|
|
|
421
536
|
return this._primary!.find(opts);
|
|
422
537
|
}
|
|
423
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
|
+
|
|
424
571
|
/**
|
|
425
572
|
* Delete this model instance from the database.
|
|
426
573
|
*
|
|
@@ -433,8 +580,8 @@ export abstract class Model<SUB> {
|
|
|
433
580
|
* ```
|
|
434
581
|
*/
|
|
435
582
|
delete() {
|
|
436
|
-
if (
|
|
437
|
-
this._oldValues =
|
|
583
|
+
if (this._oldValues === undefined) throw new DatabaseError("Cannot delete unsaved instance", "NOT_SAVED");
|
|
584
|
+
this._oldValues = null;
|
|
438
585
|
}
|
|
439
586
|
|
|
440
587
|
/**
|
|
@@ -454,7 +601,7 @@ export abstract class Model<SUB> {
|
|
|
454
601
|
validate(raise: boolean = false): Error[] {
|
|
455
602
|
const errors: Error[] = [];
|
|
456
603
|
|
|
457
|
-
for (const [key, fieldConfig] of Object.entries(this.
|
|
604
|
+
for (const [key, fieldConfig] of Object.entries(this.constructor.fields)) {
|
|
458
605
|
let e = fieldConfig.type.getError((this as any)[key]);
|
|
459
606
|
if (e) {
|
|
460
607
|
e = addErrorPath(e, this.constructor.tableName+"."+key);
|
|
@@ -478,4 +625,25 @@ export abstract class Model<SUB> {
|
|
|
478
625
|
isValid(): boolean {
|
|
479
626
|
return this.validate().length === 0;
|
|
480
627
|
}
|
|
628
|
+
|
|
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";
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return "loaded";
|
|
638
|
+
}
|
|
639
|
+
|
|
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
|
+
}
|
|
645
|
+
|
|
646
|
+
[Symbol.for('nodejs.util.inspect.custom')]() {
|
|
647
|
+
return this.toString();
|
|
648
|
+
}
|
|
481
649
|
}
|