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