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/src/edinburgh.ts CHANGED
@@ -1,20 +1,27 @@
1
- import * as olmdb from "olmdb";
2
- import { Model, MODIFIED_INSTANCES_SYMBOL, resetModelCaches } from "./models.js";
1
+ import * as lowlevel from "olmdb/lowlevel";
2
+ import { init as olmdbInit, DatabaseError } from "olmdb/lowlevel";
3
+ import { modelRegistry, txnStorage, currentTxn } from "./models.js";
4
+
5
+ let initNeeded = false;
6
+ export function scheduleInit() { initNeeded = true; }
7
+
3
8
 
4
9
  // Re-export public API from models
5
10
  export {
6
11
  Model,
7
12
  registerModel,
8
13
  field,
9
- setOnSaveCallback
10
14
  } from "./models.js";
11
15
 
16
+ import type { Transaction, Change, Model } from "./models.js";
12
17
 
13
18
  // Re-export public API from types (only factory functions and instances)
14
19
  export {
15
20
  // Pre-defined type instances
16
21
  string,
22
+ orderedString,
17
23
  number,
24
+ dateTime,
18
25
  boolean,
19
26
  identifier,
20
27
  undef,
@@ -34,11 +41,40 @@ export {
34
41
  dump,
35
42
  } from "./indexes.js";
36
43
 
37
- export { BaseIndex, UniqueIndex, PrimaryIndex } from './indexes.js';
44
+ export { BaseIndex, UniqueIndex, PrimaryIndex, SecondaryIndex } from './indexes.js';
45
+
46
+ export { type Change } from './models.js';
47
+ export type { Transaction } from './models.js';
48
+ export { DatabaseError } from "olmdb/lowlevel";
49
+ export { runMigration } from './migrate.js';
50
+ export type { MigrationOptions, MigrationResult } from './migrate.js';
51
+
52
+ let olmdbReady = false;
53
+ let maxRetryCount = 6;
54
+
55
+ /**
56
+ * Initialize the database with the specified directory path.
57
+ * This function may be called multiple times with the same parameters. If it is not called before the first transact(),
58
+ * the database will be automatically initialized with the default directory.
59
+ *
60
+ * @example
61
+ * ```typescript
62
+ * init("./my-database");
63
+ * ```
64
+ */
65
+ export function init(dbDir: string): void {
66
+ olmdbReady = true;
67
+ olmdbInit(dbDir);
68
+ }
69
+
70
+ let pendingInit: Promise<void> | undefined;
38
71
 
39
- // Re-export from OLMDB
40
- export { init, onCommit, onRevert, getTransactionData, setTransactionData, DatabaseError } from "olmdb";
41
72
 
73
+ const STALE_INSTANCE_DESCRIPTOR = {
74
+ get() {
75
+ throw new DatabaseError("The transaction for this model instance has ended", "STALE_INSTANCE");
76
+ },
77
+ };
42
78
 
43
79
  /**
44
80
  * Executes a function within a database transaction context.
@@ -48,23 +84,20 @@ export { init, onCommit, onRevert, getTransactionData, setTransactionData, Datab
48
84
  *
49
85
  * Transactions have a consistent view of the database, and changes made within a transaction are
50
86
  * isolated from other transactions until they are committed. In case a commit clashes with changes
51
- * made by another transaction, the transaction function will automatically be re-executed up to 10
87
+ * made by another transaction, the transaction function will automatically be re-executed up to 6
52
88
  * times.
53
89
  *
54
90
  * @template T - The return type of the transaction function.
55
- * @param fn - The function to execute within the transaction context.
91
+ * @param fn - The function to execute within the transaction context. Receives a Transaction instance.
56
92
  * @returns A promise that resolves with the function's return value.
57
- * @throws {TypeError} If nested transactions are attempted.
58
93
  * @throws {DatabaseError} With code "RACING_TRANSACTION" if the transaction fails after retries due to conflicts.
59
- * @throws {DatabaseError} With code "TRANSACTION_FAILED" if the transaction fails for other reasons.
60
94
  * @throws {DatabaseError} With code "TXN_LIMIT" if maximum number of transactions is reached.
61
95
  * @throws {DatabaseError} With code "LMDB-{code}" for LMDB-specific errors.
62
96
  *
63
97
  * @example
64
98
  * ```typescript
65
99
  * const paid = await E.transact(() => {
66
- * const user = User.load("john_doe");
67
- * // This is concurrency-safe - the function will rerun if it is raced by another transaction
100
+ * const user = User.pk.get("john_doe");
68
101
  * if (user.credits > 0) {
69
102
  * user.credits--;
70
103
  * return true;
@@ -75,45 +108,143 @@ export { init, onCommit, onRevert, getTransactionData, setTransactionData, Datab
75
108
  * ```typescript
76
109
  * // Transaction with automatic retry on conflicts
77
110
  * await E.transact(() => {
78
- * const counter = Counter.load("global") || new Counter({id: "global", value: 0});
111
+ * const counter = Counter.pk.get("global") || new Counter({id: "global", value: 0});
79
112
  * counter.value++;
80
113
  * });
81
114
  * ```
82
115
  */
83
- export function transact<T>(fn: () => T): Promise<T> {
84
- return olmdb.transact(() => {
85
- const modifiedInstances = new Set<Model<any>>();
86
- olmdb.setTransactionData(MODIFIED_INSTANCES_SYMBOL, modifiedInstances);
87
-
88
- const savedInstances: Set<Model<any>> = new Set();
89
- try {
90
- const result = fn();
91
- // Save all modified instances before committing.
92
- while(modifiedInstances.size > 0) {
93
- // Back referencing can cause models to be scheduled for save() a second time,
94
- // which is why we require the outer loop.
95
- for (const instance of modifiedInstances) {
96
- instance._save();
97
- savedInstances.add(instance);
98
- modifiedInstances.delete(instance);
116
+ export async function transact<T>(fn: () => T): Promise<T> {
117
+ while (initNeeded || pendingInit) {
118
+ // Make sure only one async task is doing the inits, the rest should wait for it
119
+ if (pendingInit) {
120
+ await pendingInit;
121
+ } else {
122
+ pendingInit = (async () => {
123
+ if (!olmdbReady) olmdbInit('.edinburgh');
124
+ olmdbReady = true;
125
+
126
+ initNeeded = false;
127
+ for (const model of Object.values(modelRegistry)) {
128
+ await model._delayedInit();
129
+ }
130
+ pendingInit = undefined;
131
+ })();
132
+ }
133
+ }
134
+
135
+ // try {
136
+ for (let retryCount = 0; retryCount < maxRetryCount; retryCount++) {
137
+ const txnId = lowlevel.startTransaction();
138
+ const txn: Transaction = { id: txnId, instances: new Set(), instancesByPk: new Map() };
139
+ const onSaveItems: Map<Model<unknown>, Change> | undefined = onSaveCallback ? new Map() : undefined;
140
+
141
+ let result: T | undefined;
142
+ try {
143
+ await txnStorage.run(txn, async function() {
144
+ result = await fn();
145
+
146
+ // Call preCommit() on all instances before writing.
147
+ // Note: Set iteration visits newly added items, so preCommit() creating
148
+ // new instances is handled correctly.
149
+ for (const instance of txn.instances) {
150
+ instance.preCommit?.();
151
+ }
152
+
153
+ // Save all modified instances before committing
154
+ // This needs to happen inside txnStorage.run, because resolving default values
155
+ // for identifiers requires database access.
156
+ for (const instance of txn.instances) {
157
+ const change = instance._write(txn);
158
+ if (onSaveItems && change) {
159
+ onSaveItems.set(instance, change);
160
+ }
161
+ }
162
+ });
163
+ } catch (e: any) {
164
+ try { lowlevel.abortTransaction(txnId); } catch {}
165
+ throw e;
166
+ } finally {
167
+ // Make the instances read-only to make it clear that their transaction has ended.
168
+ for (const instance of txn.instances) {
169
+ delete instance._oldValues;
170
+ Object.defineProperty(instance, "_txn", STALE_INSTANCE_DESCRIPTOR);
171
+ Object.freeze(instance);
99
172
  }
173
+ // Destroy the transaction object, to make sure things crash if they are used after
174
+ // this point, and to help the GC reclaim memory.
175
+ txn.id = txn.instances = txn.instancesByPk = undefined as any;
100
176
  }
101
-
102
- return result;
103
- } catch (error) {
104
- // Discard changes on all saved and still unsaved instances
105
- for (const instance of savedInstances) instance.preventPersist();
106
- for (const instance of modifiedInstances) instance.preventPersist();
107
- throw error;
177
+
178
+ const commitResult = lowlevel.commitTransaction(txnId);
179
+ const commitSeq = typeof commitResult === 'number' ? commitResult : await commitResult;
180
+
181
+ if (commitSeq > 0) {
182
+ // Success
183
+ if (onSaveItems?.size) {
184
+ onSaveCallback!(commitSeq, onSaveItems);
185
+ }
186
+ return result as T;
187
+ }
188
+
189
+ // Race condition - retry
108
190
  }
109
- });
191
+ throw new DatabaseError("Transaction keeps getting raced", "RACING_TRANSACTION");
192
+ // } catch (e: Error | any) {
193
+ // // This hackery is required to provide useful stack traces.
194
+ // const callerStack = new Error().stack?.replace(/^.*?\n/, '');
195
+ // e.stack += "\nat async:\n" + callerStack;
196
+ // throw e;
197
+ // }
198
+ }
199
+
200
+ /**
201
+ * Set the maximum number of retries for a transaction in case of conflicts.
202
+ * The default value is 6. Setting it to 0 will disable retries and cause transactions to fail immediately on conflict.
203
+ *
204
+ * @param count The maximum number of retries for a transaction.
205
+ */
206
+ export function setMaxRetryCount(count: number) {
207
+ maxRetryCount = count;
110
208
  }
111
209
 
210
+ let onSaveCallback: ((commitId: number, items: Map<Model<any>, Change>) => void) | undefined;
211
+
212
+ /**
213
+ * Set a callback function to be called after a model is saved and committed.
214
+ *
215
+ * @param callback The callback function to set. It gets called after each successful
216
+ * `transact()` commit that has changes, with the following arguments:
217
+ * - A sequential number. Higher numbers have been committed after lower numbers.
218
+ * - A map of model instances to their changes. The change can be "created", "deleted", or an object containing the old values.
219
+ */
220
+ export function setOnSaveCallback(callback: ((commitId: number, items: Map<Model<any>, Change>) => void) | undefined) {
221
+ onSaveCallback = callback;
222
+ }
223
+
224
+
112
225
  export async function deleteEverything(): Promise<void> {
113
- await olmdb.transact(() => {
114
- for (const {key} of olmdb.scan()) {
115
- olmdb.del(key);
116
- }
117
- });
118
- await resetModelCaches();
226
+ let done = false;
227
+ while (!done) {
228
+ await transact(() => {
229
+ const txn = currentTxn();
230
+ const iteratorId = lowlevel.createIterator(txn.id, undefined, undefined, false);
231
+ const deadline = Date.now() + 150;
232
+ let count = 0;
233
+ try {
234
+ while (true) {
235
+ const raw = lowlevel.readIterator(iteratorId);
236
+ if (!raw) { done = true; break; }
237
+ lowlevel.del(txn.id, raw.key);
238
+ if (++count >= 4096 || Date.now() >= deadline) break;
239
+ }
240
+ } finally {
241
+ lowlevel.closeIterator(iteratorId);
242
+ }
243
+ });
244
+ }
245
+ // Re-init indexes since metadata was deleted
246
+ for (const model of Object.values(modelRegistry)) {
247
+ if (!model.fields) continue;
248
+ await model._delayedInit(true);
249
+ }
119
250
  }