edinburgh 0.1.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/src/bytes.ts ADDED
@@ -0,0 +1,500 @@
1
+ const encoder = new TextEncoder();
2
+ const decoder = new TextDecoder('utf-8', { fatal: true });
3
+
4
+ const BIT_COUNTS = [19, 35, 53]; // Bit counts for large integers
5
+
6
+ /**
7
+ * A byte buffer for efficient reading and writing of primitive values and bit sequences.
8
+ *
9
+ * The Bytes class provides methods for serializing and deserializing various data types
10
+ * including numbers, strings, bit sequences, and other primitive values to/from byte buffers.
11
+ * It supports both reading and writing operations with automatic buffer management.
12
+ */
13
+ export class Bytes {
14
+ public buffer: Uint8Array;
15
+ public readByte: number = 0;
16
+ public readBit: number = 8;
17
+ public writeByte: number = 0;
18
+ public writeBit: number = 8;
19
+
20
+ static BASE64_CHARS = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz_$';
21
+ static BASE64_LOOKUP = (() => {
22
+ const arr = new Uint8Array(128); // all ASCII
23
+ for (let i = 0; i < this.BASE64_CHARS.length; ++i) arr[this.BASE64_CHARS.charCodeAt(i)] = i;
24
+ return arr;
25
+ })();
26
+
27
+ /**
28
+ * Create a new Bytes instance.
29
+ * @param data - Optional initial data as Uint8Array or buffer size as number.
30
+ */
31
+ constructor(data: Uint8Array | number | undefined = undefined) {
32
+ if (data instanceof Uint8Array) {
33
+ this.buffer = data;
34
+ this.writeByte = data.length;
35
+ } else {
36
+ this.buffer = new Uint8Array(data ?? 64);
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Reset read position to the beginning of the buffer.
42
+ * @returns This Bytes instance for chaining.
43
+ */
44
+ reset() {
45
+ this.readByte = 0;
46
+ this.readBit = 8;
47
+ return this;
48
+ }
49
+
50
+ /**
51
+ * Get the number of bytes written to the buffer.
52
+ * @returns The byte count.
53
+ */
54
+ byteCount(): number {
55
+ return this.writeByte + (this.writeBit < 8 ? 1 : 0);
56
+ }
57
+
58
+ /**
59
+ * Get the number of bits written to the buffer.
60
+ * @returns The bit count.
61
+ */
62
+ bitCount(): number {
63
+ return (this.writeByte * 8) + (8 - this.writeBit);
64
+ }
65
+
66
+ /**
67
+ * Check if there are more bytes available for reading.
68
+ * @returns true if more bytes can be read.
69
+ */
70
+ readAvailable(): boolean {
71
+ return this.readByte < this.buffer.length;
72
+ }
73
+
74
+ /**
75
+ * Read a specific number of bits from the buffer (safe up to 30 bits).
76
+ * @param bits - Number of bits to read (0-30).
77
+ * @param flip - Whether to flip the bit order.
78
+ * @returns The read value as a number.
79
+ */
80
+ readBits(bits: number, flip: boolean=false): number {
81
+ if (bits < 0 || bits > 30) {
82
+ throw new Error('Invalid bit count');
83
+ }
84
+
85
+ // Single bounds check upfront
86
+ const totalBitsRemaining = (this.buffer.length - this.readByte) * 8 - (8 - this.readBit);
87
+ if (bits > totalBitsRemaining) {
88
+ throw new Error('Not enough bits available');
89
+ }
90
+
91
+ let result = 0;
92
+ let bitsLeft = bits;
93
+
94
+ // Process bits while we need them
95
+ while (bitsLeft > 0) {
96
+ const take = Math.min(bitsLeft, this.readBit);
97
+ bitsLeft -= take;
98
+
99
+ if (take===8) { // Fast path for full byte
100
+ result = (result << 8) | this.buffer[this.readByte++];
101
+ // bitPos remains 8, bytePos is incremented
102
+ } else {
103
+ // Extract bits: shift right to get our bits at the bottom, then mask
104
+ const extracted = (this.buffer[this.readByte] >>> (this.readBit - take)) & ((1 << take) - 1);
105
+
106
+ // The >>> 0 ensures we treat the result as an unsigned integer
107
+ result = ((result << take) >>> 0) | extracted;
108
+ this.readBit -= take;
109
+
110
+ // Move to next byte if current is exhausted
111
+ if (this.readBit == 0) {
112
+ this.readByte++;
113
+ this.readBit = 8;
114
+ }
115
+ }
116
+ }
117
+
118
+ return flip ? ((1 << bits) - 1) ^ result : result;
119
+ }
120
+
121
+ /**
122
+ * Write a specific number of bits to the buffer (safe up to 30 bits).
123
+ * @param value - The value to write.
124
+ * @param bits - Number of bits to write (0-30).
125
+ * @param flip - Whether to flip the bit order.
126
+ * @returns This Bytes instance for chaining.
127
+ */
128
+ writeBits(value: number, bits: number, flip: boolean=false): Bytes {
129
+ if (bits < 0 || bits > 30) {
130
+ throw new Error('Invalid bit count');
131
+ }
132
+ value = flip ? ((1 << bits) - 1) ^ value : value;
133
+
134
+ this.ensureCapacity(1 + (bits >>> 3));
135
+
136
+ while (bits > 0) {
137
+ const bitsToWriteNow = Math.min(bits, this.writeBit);
138
+ bits -= bitsToWriteNow;
139
+
140
+ if (bitsToWriteNow === 8) { // Fast path for full bytes
141
+ this.buffer[this.writeByte++] = (value >>> bits) & 0xff;
142
+ // bitPos remains 8, bytePos is incremented
143
+ } else {
144
+ const extracted = (value >>> bits) & ((1 << bitsToWriteNow) - 1);
145
+ this.buffer[this.writeByte] |= extracted << (this.writeBit - bitsToWriteNow);
146
+
147
+ this.writeBit -= bitsToWriteNow;
148
+
149
+ // Advance to next byte if current byte is full
150
+ if (this.writeBit === 0) {
151
+ this.writeByte++;
152
+ this.writeBit = 8;
153
+ }
154
+ }
155
+ }
156
+ return this;
157
+ }
158
+
159
+ /**
160
+ * Write an unsigned integer using only the minimum required bits.
161
+ * @param value - The value to write (must be 0 <= value <= maxValue).
162
+ * @param maxValue - The maximum possible value.
163
+ * @returns This Bytes instance for chaining.
164
+ */
165
+ writeUIntN(value: number, maxValue: number): Bytes {
166
+ if (!Number.isInteger(value) || value < 0 || value > maxValue) {
167
+ throw new Error(`Value out of range: ${value} (max: ${maxValue})`);
168
+ }
169
+
170
+ const bitsNeeded = Math.ceil(Math.log2(maxValue + 1));
171
+ this.writeBits(value, bitsNeeded);
172
+ return this;
173
+ }
174
+
175
+ /**
176
+ * Read an unsigned integer that was written with writeUIntN.
177
+ * @param maxValue - The maximum possible value (same as used in writeUIntN).
178
+ * @returns The read value.
179
+ */
180
+ readUIntN(maxValue: number): number {
181
+ if (maxValue < 0) {
182
+ throw new Error(`Invalid max value: ${maxValue}`);
183
+ }
184
+
185
+ const bitsNeeded = Math.ceil(Math.log2(maxValue + 1));
186
+ return this.readBits(bitsNeeded);
187
+ }
188
+
189
+ /**
190
+ * Read a Base64-encoded string of specified character count.
191
+ * @param charCount - Number of characters to read.
192
+ * @returns The decoded string.
193
+ */
194
+ readBase64(charCount: number): string {
195
+ let result = '';
196
+ for(let i = 0; i < charCount; i++) {
197
+ result += Bytes.BASE64_CHARS[this.readBits(6)];
198
+ }
199
+ return result;
200
+ }
201
+
202
+ /**
203
+ * Write a Base64-encoded string to the buffer.
204
+ * @param value - The Base64 string to write.
205
+ * @returns This Bytes instance for chaining.
206
+ */
207
+ writeBase64(value: string) {
208
+ this.ensureCapacity(Math.ceil(value.length * 6 / 8));
209
+ for(let i = 0; i < value.length; i++) {
210
+ const v = Bytes.BASE64_LOOKUP[value.charCodeAt(i)];
211
+ if (v == undefined) throw new Error(`Invalid Base64 character: ${value[i]}`);
212
+ this.writeBits(v, 6);
213
+ }
214
+ return this;
215
+ }
216
+
217
+ /**
218
+ * Pad read position to byte boundary.
219
+ *
220
+ * If currently reading in the middle of a byte, advance to the next byte boundary.
221
+ */
222
+ padReadBits() {
223
+ // If we have any bits left in the current byte, pad them to 8
224
+ if (this.readBit < 8) {
225
+ this.readByte++;
226
+ this.readBit = 8;
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Pad write position to byte boundary.
232
+ *
233
+ * If currently writing in the middle of a byte, advance to the next byte boundary.
234
+ */
235
+ padWriteBits() {
236
+ // If we have any bits left in the current byte, pad them to 8
237
+ if (this.writeBit < 8) {
238
+ this.writeByte++;
239
+ this.writeBit = 8;
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Ensure the buffer has capacity for additional bytes.
245
+ * @param additionalBytes - Number of additional bytes needed.
246
+ */
247
+ ensureCapacity(additionalBytes: number): void {
248
+ const needed = this.writeByte + additionalBytes + 1;
249
+ if (needed <= this.buffer.length) return;
250
+
251
+ // Grow by 1.5x or the needed amount, whichever is larger
252
+ const newCapacity = Math.max(needed, Math.floor(this.buffer.length * 1.5));
253
+ const newBuffer = new Uint8Array(newCapacity);
254
+ newBuffer.set(this.buffer.subarray(0, this.byteCount()));
255
+ this.buffer = newBuffer;
256
+ }
257
+
258
+ /*
259
+ * 5 bit header
260
+ *
261
+ * 0-2: negative integer with 53, 35 or 19 bits
262
+ * 3-14: negative integer with 12..1 bits (the first bit is always 1, and thus left unwritten)
263
+ * 15: literal 0
264
+ * 16-27: positive integer with 1..12 bits (the first bit is always 1, and thus left unwritten)
265
+ * 28-30: positive integer with 19, 35 or 53 bits
266
+ * 31: float64 number (follows after bit padding)
267
+ *
268
+ * Some examples:
269
+ * 3 bits (incl sign) fit in 6 bits
270
+ * 5 bits (incl sign) fit in 8 bits (1 byte)
271
+ * 6 bits (incl sign) fit in 9 bits
272
+ * 20 bits (incl sign) fit in 24 bits (3 bytes)
273
+ * 36 bits (incl sign) fit in 40 bits (5 bytes)
274
+ * 54 bits (incl sign) fit in 58 bits
275
+ */
276
+
277
+ /**
278
+ * Write a number to the buffer using efficient encoding.
279
+ *
280
+ * Integers are encoded using variable-length encoding based on their magnitude.
281
+ * Large numbers and floating-point numbers use standard IEEE 754 representation.
282
+ *
283
+ * @param value - The number to write.
284
+ * @returns This Bytes instance for chaining.
285
+ */
286
+ writeNumber(value: number): Bytes {
287
+ if (Number.isInteger(value) && value >= Number.MIN_SAFE_INTEGER && value <= Number.MAX_SAFE_INTEGER) {
288
+ // 33 bit integer (incl sign)
289
+ let num = Math.abs(value);
290
+ let bitCount = Math.ceil(Math.log2(num + 1)); // 0 (for literal 0) til 32 (inclusive)
291
+ const isNegative = value < 0;
292
+
293
+ if (bitCount < 13) {
294
+ this.writeBits(isNegative ? 15-bitCount : 15+bitCount, 5); // header
295
+ // Don't write the leading 1 bit
296
+ if (bitCount > 1) this.writeBits(0 | num, bitCount-1, isNegative); // value
297
+ } else {
298
+ const sizeOption = bitCount <= 19 ? 0 : (bitCount <= 35 ? 1 : 2);
299
+ bitCount = BIT_COUNTS[sizeOption];
300
+
301
+ this.writeBits(isNegative ? 2-sizeOption : 28+sizeOption, 5); // header
302
+ if (bitCount <= 30) {
303
+ this.writeBits(0 | num, bitCount, isNegative); // data
304
+ } else {
305
+ this.writeBits(0 | (num / 0x40000000), bitCount-30, isNegative); // most significant bits
306
+ this.writeBits(num & 0x3fffffff, 30, isNegative); // least significant bits
307
+ }
308
+ }
309
+ } else {
310
+ // Float or large integer
311
+ this.writeBits(31, 5); // header for float
312
+ this.padWriteBits(); // Ensure we are byte-aligned
313
+
314
+ this.ensureCapacity(8); // float64 takes 8 bytes
315
+
316
+ const float64Array = new Float64Array(1);
317
+ float64Array[0] = value;
318
+ const bytes = new Uint8Array(float64Array.buffer);
319
+ this.buffer.set(bytes, this.writeByte);
320
+ this.writeByte += 8;
321
+ }
322
+ return this;
323
+ }
324
+
325
+ /**
326
+ * Read a number from the buffer.
327
+ *
328
+ * Reads numbers that were written using the writeNumber method,
329
+ * automatically detecting the encoding format.
330
+ *
331
+ * @returns The read number.
332
+ */
333
+ readNumber(): number {
334
+ const header = this.readBits(5);
335
+
336
+ if (header === 15) {
337
+ // Literal 0
338
+ return 0;
339
+ }
340
+ if (header === 31) {
341
+ // Float64
342
+ this.padReadBits();
343
+ const bytes = new Uint8Array(8);
344
+ for (let i = 0; i < 8; i++) {
345
+ bytes[i] = this.readBits(8);
346
+ }
347
+ return new Float64Array(bytes.buffer)[0];
348
+ }
349
+
350
+ // Integer handling - determine if positive/negative and get size
351
+ const isNegative = header < 15;
352
+
353
+ let value: number, bitCount: number;
354
+
355
+ if (header < 3 || header > 27) {
356
+ // Large integers
357
+ const sizeOption = isNegative ? 2 - header : header - 28;
358
+ bitCount = BIT_COUNTS[sizeOption];
359
+ if (bitCount <= 30) {
360
+ value = this.readBits(bitCount, isNegative);
361
+ } else {
362
+ // Read the two parts of the large integer
363
+ value = this.readBits(bitCount-30, isNegative) * 1073741824 + this.readBits(30, isNegative);
364
+ }
365
+
366
+ } else {
367
+ // Small integers: reconstruct bits with implicit leading 1
368
+ bitCount = isNegative ? 15 - header : header - 15;
369
+
370
+ // Read bits and add implicit leading 1
371
+ value = this.readBits(bitCount - 1, isNegative) | (1 << (bitCount - 1));
372
+ }
373
+
374
+ return isNegative ? -value : value;
375
+ }
376
+
377
+ /**
378
+ * Write a UTF-8 string to the buffer with null termination.
379
+ * @param value - The string to write.
380
+ * @returns This Bytes instance for chaining.
381
+ */
382
+ writeString(value: string): Bytes {
383
+ // Escape 0 characters and escape characters
384
+ value = value.replace(/[\u0001\u0000]/g, (match: string) => match === '\u0001' ? '\u0001e' : '\u0001z');
385
+ const encoded = encoder.encode(value);
386
+ this.padWriteBits();
387
+ this.ensureCapacity(encoded.length + 1); // +1 for null terminator
388
+ this.buffer.set(encoded, this.writeByte);
389
+ this.writeByte += encoded.length;
390
+ this.buffer[this.writeByte++] = 0; // Null terminator
391
+ return this;
392
+ }
393
+
394
+ /**
395
+ * Write another Bytes instance to this buffer.
396
+ * @param value - The Bytes instance to write.
397
+ * @returns This Bytes instance for chaining.
398
+ */
399
+ writeBytes(value: Bytes): Bytes {
400
+ let size = value.byteCount();
401
+ this.writeNumber(size);
402
+ this.padWriteBits();
403
+ this.ensureCapacity(size);
404
+ this.buffer.set(value.buffer, this.writeByte);
405
+ this.writeByte += size;
406
+ return this;
407
+ }
408
+
409
+ /**
410
+ * Read a Bytes instance from the buffer.
411
+ * @returns A new Bytes instance containing the read data.
412
+ */
413
+ readBytes(): Bytes {
414
+ const size = this.readNumber();
415
+ if (size < 0 || size > this.buffer.length - this.readByte) {
416
+ throw new Error('Invalid byte size read');
417
+ }
418
+ const bytes = new Bytes(this.buffer.subarray(this.readByte, this.readByte + size));
419
+ this.readByte += size;
420
+ return bytes;
421
+ }
422
+
423
+ /**
424
+ * Read a null-terminated UTF-8 string from the buffer.
425
+ * @returns The decoded string.
426
+ */
427
+ readString(): string {
428
+ this.padReadBits();
429
+ const start = this.readByte;
430
+ const end = this.buffer.indexOf(0, start);
431
+
432
+ if (end < 0) {
433
+ throw new Error('String not null-terminated');
434
+ }
435
+
436
+ const encoded = this.buffer.subarray(start, end);
437
+ this.readByte = end + 1; // Skip null terminator
438
+
439
+ // Decode and unescape
440
+ let value = decoder.decode(encoded);
441
+ value = value.replace(/\u0001[ez]/g, (m: string) => m === '\u0001e' ? '\u0001' : '\u0000');
442
+
443
+ return value;
444
+ }
445
+
446
+ /**
447
+ * Get the current buffer contents as a Uint8Array.
448
+ * @returns A new Uint8Array containing the written data.
449
+ */
450
+ getBuffer(): Uint8Array {
451
+ return this.buffer.subarray(0, this.byteCount());
452
+ }
453
+
454
+ [Symbol.for('nodejs.util.inspect.custom')](): string {
455
+ return Array.from(this.getBuffer())
456
+ .map(b => b.toString(16).padStart(2, '0'))
457
+ .join(' ') + ` (${this.byteCount()} bytes)`;
458
+ }
459
+
460
+ /**
461
+ * Create a copy of this Bytes instance.
462
+ * @returns A new Bytes instance with the same content.
463
+ */
464
+ copy() {
465
+ const result = new Bytes(this.buffer.slice(0));
466
+ result.writeByte = this.writeByte;
467
+ result.writeBit = this.writeBit;
468
+ return result;
469
+ }
470
+
471
+ /**
472
+ * Increment the last bit of the buffer. If it was already 1 set it to 0 and
473
+ * increment the previous bit, and so on. If all bits were 1, return undefined.
474
+ */
475
+ increment(): Bytes | undefined {
476
+ let startBit = this.writeBit;
477
+ for(let byte=this.writeByte; byte >= 0; byte--) {
478
+ for(let bit=startBit; bit < 8; bit++) {
479
+ const shift = 1 << bit;
480
+ if (this.buffer[byte] & shift) {
481
+ // Bit was 1, set to 0 and continue
482
+ this.buffer[byte] &= ~shift;
483
+ } else {
484
+ // Bit was 0, set to 1 and return
485
+ this.buffer[byte] |= shift;
486
+ return this;
487
+ }
488
+ }
489
+ startBit = 0;
490
+ }
491
+ return undefined; // All bits were 1 (and are now 0)
492
+ }
493
+
494
+ toString(): string {
495
+ // Convert this.getBuffer() as utf8 to a string, escaping non-printable characters:
496
+ return Array.from(this.getBuffer())
497
+ .map(b => b < 32 || b > 126 ? `\\x${b.toString(16).padStart(2, '0')}` : String.fromCharCode(b))
498
+ .join('');
499
+ }
500
+ }
@@ -0,0 +1,119 @@
1
+ import * as olmdb from "olmdb";
2
+ import { Model, MODIFIED_INSTANCES_SYMBOL, resetModelCaches } from "./models.js";
3
+
4
+ // Re-export public API from models
5
+ export {
6
+ Model,
7
+ registerModel,
8
+ field,
9
+ setOnSaveCallback
10
+ } from "./models.js";
11
+
12
+
13
+ // Re-export public API from types (only factory functions and instances)
14
+ export {
15
+ // Pre-defined type instances
16
+ string,
17
+ number,
18
+ boolean,
19
+ identifier,
20
+ undef,
21
+ // Type factory functions
22
+ opt,
23
+ or,
24
+ array,
25
+ literal,
26
+ link,
27
+ } from "./types.js";
28
+
29
+ // Re-export public API from indexes
30
+ export {
31
+ index,
32
+ primary,
33
+ unique,
34
+ dump,
35
+ } from "./indexes.js";
36
+
37
+ export { BaseIndex, UniqueIndex, PrimaryIndex } from './indexes.js';
38
+
39
+ // Re-export from OLMDB
40
+ export { init, onCommit, onRevert, getTransactionData, setTransactionData, DatabaseError } from "olmdb";
41
+
42
+
43
+ /**
44
+ * Executes a function within a database transaction context.
45
+ *
46
+ * Loading models (also through links in other models) and changing models can only be done from
47
+ * within a transaction.
48
+ *
49
+ * Transactions have a consistent view of the database, and changes made within a transaction are
50
+ * 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
52
+ * times.
53
+ *
54
+ * @template T - The return type of the transaction function.
55
+ * @param fn - The function to execute within the transaction context.
56
+ * @returns A promise that resolves with the function's return value.
57
+ * @throws {TypeError} If nested transactions are attempted.
58
+ * @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
+ * @throws {DatabaseError} With code "TXN_LIMIT" if maximum number of transactions is reached.
61
+ * @throws {DatabaseError} With code "LMDB-{code}" for LMDB-specific errors.
62
+ *
63
+ * @example
64
+ * ```typescript
65
+ * 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
68
+ * if (user.credits > 0) {
69
+ * user.credits--;
70
+ * return true;
71
+ * }
72
+ * return false;
73
+ * });
74
+ * ```
75
+ * ```typescript
76
+ * // Transaction with automatic retry on conflicts
77
+ * await E.transact(() => {
78
+ * const counter = Counter.load("global") || new Counter({id: "global", value: 0});
79
+ * counter.value++;
80
+ * });
81
+ * ```
82
+ */
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);
99
+ }
100
+ }
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;
108
+ }
109
+ });
110
+ }
111
+
112
+ 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();
119
+ }