@incoqnito.io/bajadab 1.2.2 → 1.3.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 CHANGED
@@ -1,174 +1,192 @@
1
- # bajadab
2
-
3
- A lightweight JSON-file-backed database for small, single-machine setups. No server, no native bindings — just collections of values persisted as JSON under a directory you choose.
4
-
5
- ## Features
6
-
7
- - Collections stored as plain JSON files, one directory per database.
8
- - `MAP` (keyed by id) or `ARRAY` (ordered list) in-memory storage per collection.
9
- - Configurable behavior for duplicate ids, missing ids, array deletion, auto-flush, and unloading dirty data.
10
- - Custom id extraction/assignment via a pluggable function registry.
11
- - Optional secondary indexes for fast lookup by a derived key.
12
- - Atomic writes (write-to-temp, then rename) and, on POSIX systems, owner-only file permissions (`0600` files, `0700` directories).
13
- - In-process concurrency safety per collection and per database.
14
- - `db.snapshot(targetDir, options?)` to copy the whole database into a new timestamped version folder, optionally pruning older ones.
15
- - `db.transaction(collectionNames, fn)` to apply reads and writes across several collections as one unit — committed together, or not applied at all.
16
-
17
- ## Install
18
-
19
- ```
20
- npm install @incoqnito.io/bajadab
21
- ```
22
-
23
- Requires Node.js `>= 24`.
24
-
25
- ## Quick start
26
-
27
- ```ts
28
- import { getDatabase, EBMemStorageStrategy } from "@incoqnito.io/bajadab";
29
-
30
- const db = await getDatabase("./data");
31
-
32
- const notes = await db.getCollection<{ id?: string; text: string }>("notes", {
33
- ...db.config.defaultCollectionConfig,
34
- memStorageStrategy: EBMemStorageStrategy.ARRAY,
35
- });
36
-
37
- await notes.add({ text: "hello" });
38
- const all = await notes.get();
39
- const some = await notes.get(n => n.text.startsWith("h"));
40
-
41
- await notes.delete(n => n.text === "hello");
42
- await db.dropCollection("notes");
43
- ```
44
-
45
- `getDatabase(basePath)` opens the database at `basePath`, creating it if it doesn't exist. `db.getCollection(name, config?)` returns the named collection, creating it with `config` (or the database's default) if it doesn't exist yet; `db.createCollection(name, config?)` instead rejects if one already exists. Collection names are matched case- and whitespace-insensitively (`"Notes"` and `" notes "` refer to the same collection).
46
-
47
- ## Configuring a collection
48
-
49
- Every collection has an `IBCollectionConfig`. `db.config.defaultCollectionConfig` holds the database's defaults — spread it and override what you need, as above.
50
-
51
- | Option | Values | Meaning |
52
- | --- | --- | --- |
53
- | `memStorageStrategy` | `MAP` (default) / `ARRAY` | Keyed lookup by id, or an ordered list. |
54
- | `autoIDStrategy` | `AUTO_ID_UUID` (default) / `NO_AUTO_ID` | Generate a `uuidv7` for values with no id, or reject them. |
55
- | `duplicateIDStrategy` | `OVERWRITE` (default) / `ERROR` / `IGNORE` | What `add()` does when a value's id already exists. |
56
- | `arrayDeletionStrategy` | `IN_PLACE` (default) / `NEW_ARRAY` | How `delete()` removes matches from an `ARRAY` collection. |
57
- | `autoFlushStrategy` | `ALWAYS_AUTO_FLUSH` (default) / `NO_AUTO_FLUSH` | Whether every mutation is written to disk immediately, or only on an explicit `flush()`. |
58
- | `dirtyUnloadStrategy` | `FLUSH` (default) / `ERROR` / `IGNORE` | What `unload()` does with unsaved changes. |
59
- | `getID` / `setID` | registry key (optional) | Custom id accessors — see below. Omit to use the default, which reads/writes a plain `.id` property. |
60
- | `indexes` | `{ [indexKey: string]: registry key }` (optional) | Secondary indexes — see below. Omit for no indexes. |
61
-
62
- ## Custom ids
63
-
64
- By default, a value's id is its `.id` property. To use something else, implement `IBFunctionRegistry` and register `getID`/`setID` functions under whatever keys you configure:
65
-
66
- ```ts
67
- import { getDatabase, IBFunctionRegistry } from "@incoqnito.io/bajadab";
68
-
69
- class Registry implements IBFunctionRegistry {
70
- private fns = new Map<string, Function>([
71
- ["userGetID", async (v: any) => v.userId],
72
- ["userSetID", async (v: any, id: string) => { v.userId = id; }],
73
- ]);
74
- fetch(key?: string) {
75
- return key ? this.fns.get(key) : undefined;
76
- }
77
- }
78
-
79
- const db = await getDatabase("./data", new Registry());
80
- const users = await db.createCollection("users", {
81
- ...db.config.defaultCollectionConfig,
82
- getID: "userGetID",
83
- setID: "userSetID",
84
- });
85
- ```
86
-
87
- A `getID`/`setID` key that isn't found in the registry causes `add()` to reject, rather than silently falling back to the default.
88
-
89
- ## Indexes
90
-
91
- A collection can declare secondary indexes for fast lookup by a derived key. Like `getID`/`setID`, the extractor function lives in the registry — only its key is part of the (persisted) collection config:
92
-
93
- ```ts
94
- class Registry implements IBFunctionRegistry {
95
- private fns = new Map<string, Function>([
96
- ["byEmail", async (v: any) => v.email],
97
- ]);
98
- fetch(key?: string) {
99
- return key ? this.fns.get(key) : undefined;
100
- }
101
- }
102
-
103
- const db = await getDatabase("./data", new Registry());
104
- const users = await db.createCollection("users", {
105
- ...db.config.defaultCollectionConfig,
106
- indexes: { email: "byEmail" },
107
- });
108
-
109
- await users.add({ email: "a@example.com", name: "A" });
110
- const matches = await users.getByIndex("email", "a@example.com");
111
- ```
112
-
113
- An extractor returns a `string`, a `string[]` for a multi-valued index, or `undefined` to leave a value out of the index. `getByIndex()` rejects if `indexKey` isn't configured. A configured index whose registry key isn't found rejects on the collection's first use after it's (re)loaded — `get()`, `add()`, whatever comes first — not just the mutations that actually need indexing; that's a wider blast radius than `getID`/`setID`, which only ever fail inside `add()`.
114
-
115
- The index is maintained incrementally: `add()`/`update()`/`delete()` update only the affected buckets, not the whole index. A full rebuild only happens once, right after `reload()` loads fresh data from disk.
116
-
117
- ## Persistence and file layout
118
-
119
- Each database directory contains:
120
-
121
- - `dbmeta.json` — the database config and a name → id map for its collections.
122
- - `<id>.json` — per-collection metadata (name, config, id).
123
- - `<id>_data/data.json` — the collection's actual values.
124
-
125
- Collections are addressed by a stable id, not by name, so a handle to a since-renamed-or-recreated collection can't end up reading or writing the wrong data. Every write goes through a temp-file-then-rename step to avoid partial writes. On POSIX systems, created files get mode `0600` and directories `0700`; this has no effect on Windows, which has no equivalent permission bits.
126
-
127
- ## Concurrency
128
-
129
- Operations on the same collection, and database-level operations (`create`/`get`/`has`/`dropCollection`), are serialized within one process. There is no cross-process locking — multiple processes pointed at the same directory can still race on the underlying files.
130
-
131
- ## Snapshots
132
-
133
- `db.snapshot(targetDir, options?)` flushes every loaded collection, then copies the whole database directory into a new version folder under `targetDir` (created if it doesn't exist yet). The version folder is named after a sortable timestamp (e.g. `20260904T153012345Z`) and is itself a complete, independent database — open it directly with `getDatabase(versionPath)`.
134
-
135
- ```ts
136
- const versionPath = await db.snapshot("./backups");
137
- // later, e.g. after a bad deploy:
138
- const restored = await getDatabase(versionPath);
139
- ```
140
-
141
- `targetDir` is a pool that can hold several versions; each call adds one more. Options:
142
-
143
- | Option | Values | Meaning |
144
- | --- | --- | --- |
145
- | `label` | string (optional) | Appended to the version folder's timestamp, e.g. `..._before-deploy`. Letters, digits, `-` and `_` only. |
146
- | `keepLast` | number (optional) | Older version folders under `targetDir` to keep besides the one just created. `0` keeps only the new one. Omit to keep everything. Negative values reject. |
147
-
148
- Pruning only ever deletes folders matching bajadab's own naming scheme — anything else you keep in `targetDir` is left alone.
149
-
150
- `snapshot()` blocks every other operation on the database (same as `dropCollection()`, just across all collections at once) for as long as the copy takes, to guarantee a consistent point-in-time copy. There's no separate `restoreSnapshot()` — restoring means copying a version folder back over the original `basePath` (while nothing has it open), or just pointing `getDatabase()` at the version folder directly.
151
-
152
- ## Transactions
153
-
154
- `db.transaction(collectionNames, fn)` runs `fn` against isolated, in-memory working copies of the named collections. Reads and writes inside `fn` only see the transaction's own state until it resolves:
155
-
156
- ```ts
157
- await db.transaction(["accounts", "orders"], async (tx) => {
158
- const accounts = await tx.getCollection<Account>("accounts");
159
- const orders = await tx.getCollection<Order>("orders");
160
-
161
- await accounts.update(a => a.id === "u1", async a => ({ ...a, balance: a.balance - 10 }));
162
- await orders.add({ userId: "u1", total: 10 });
163
- });
164
- ```
165
-
166
- If `fn` resolves, every declared collection's live state is replaced by its working copy and flushed; `ADD`/`UPDATE`/`DELETE` handlers registered with `on()` fire once per collection, for the net difference between the state before the transaction and the committed state — a value added and later deleted within the same transaction fires nothing, and a value updated twice fires one `UPDATE` carrying the pre-transaction value and the final value. If `fn` throws, nothing is applied, nothing is flushed, and no handler fires — every declared collection stays exactly as it was.
167
-
168
- `tx.getCollection(name)` only accepts names passed to `transaction()`; anything else throws. It mirrors `IBCollection`'s read/write surface (`get`/`has`/`count`/`getByIndex`/`add`/`update`/`delete`) but not `flush`/`reload`/`unload`/`on` — those don't make sense against a working copy that might still be rolled back.
169
-
170
- `transaction()` locks every declared collection for its whole duration (like `snapshot()` locks the whole database), so nothing else can read or write them until it resolves. Across collections, disk writes at commit still happen one file at a time — a crash mid-commit can leave a transaction partially applied on disk. There's no cross-process locking here either, same as everywhere else in bajadab.
171
-
172
- ## License
173
-
174
- See [LICENSE](./LICENSE).
1
+ # bajadab
2
+
3
+ A lightweight JSON-file-backed database for small, single-machine setups. No server, no native bindings — just collections of values persisted as JSON under a directory you choose.
4
+
5
+ ## Features
6
+
7
+ - Collections stored as plain JSON files, one directory per database.
8
+ - `MAP` (keyed by id) or `ARRAY` (ordered list) in-memory storage per collection.
9
+ - Configurable behavior for duplicate ids, missing ids, array deletion, auto-flush, and unloading dirty data.
10
+ - Custom id extraction/assignment via a pluggable function registry.
11
+ - Optional secondary indexes for fast lookup by a derived key.
12
+ - Atomic writes (write-to-temp, then rename) and, on POSIX systems, owner-only file permissions (`0600` files, `0700` directories).
13
+ - In-process concurrency safety via one lock per database — deadlock-free, even when handlers touch other collections.
14
+ - `db.snapshot(targetDir, options?)` to copy the whole database into a new timestamped version folder, optionally pruning older ones.
15
+ - `db.transaction(collectionNames, fn)` to apply reads and writes across several collections as one unit — committed together, or not applied at all.
16
+
17
+ ## Install
18
+
19
+ ```
20
+ npm install @incoqnito.io/bajadab
21
+ ```
22
+
23
+ Requires Node.js `>= 24`.
24
+
25
+ ## Quick start
26
+
27
+ ```ts
28
+ import { getDatabase, EBMemStorageStrategy } from "@incoqnito.io/bajadab";
29
+
30
+ const db = await getDatabase("./data");
31
+
32
+ const notes = await db.getCollection<{ id?: string; text: string }>("notes", {
33
+ ...db.config.defaultCollectionConfig,
34
+ memStorageStrategy: EBMemStorageStrategy.ARRAY,
35
+ });
36
+
37
+ await notes.add({ text: "hello" });
38
+ const all = await notes.get();
39
+ const some = await notes.get(n => n.text.startsWith("h"));
40
+
41
+ await notes.delete(n => n.text === "hello");
42
+ await db.dropCollection("notes");
43
+ ```
44
+
45
+ `getDatabase(basePath)` opens the database at `basePath`, creating it if it doesn't exist. `db.getCollection(name, config?)` returns the named collection, creating it with `config` (or the database's default) if it doesn't exist yet; `db.createCollection(name, config?)` instead rejects if one already exists. Collection names are matched case- and whitespace-insensitively (`"Notes"` and `" notes "` refer to the same collection).
46
+
47
+ ## Configuring a collection
48
+
49
+ Every collection has an `IBCollectionConfig`. `db.config.defaultCollectionConfig` holds the database's defaults — spread it and override what you need, as above.
50
+
51
+ | Option | Values | Meaning |
52
+ | --- | --- | --- |
53
+ | `memStorageStrategy` | `MAP` (default) / `ARRAY` | Keyed lookup by id, or an ordered list. |
54
+ | `autoIDStrategy` | `AUTO_ID_UUID` (default) / `NO_AUTO_ID` | Generate a `uuidv7` for values with no id, or reject them. |
55
+ | `duplicateIDStrategy` | `OVERWRITE` (default) / `ERROR` / `IGNORE` | What `add()` does when a value's id already exists. |
56
+ | `arrayDeletionStrategy` | `IN_PLACE` (default) / `NEW_ARRAY` | How `delete()` removes matches from an `ARRAY` collection. |
57
+ | `autoFlushStrategy` | `ALWAYS_AUTO_FLUSH` (default) / `NO_AUTO_FLUSH` | Whether every mutation is written to disk immediately, or only on an explicit `flush()`. |
58
+ | `dirtyUnloadStrategy` | `FLUSH` (default) / `ERROR` / `IGNORE` | What `unload()` does with unsaved changes. |
59
+ | `getID` / `setID` | registry key (optional) | Custom id accessors — see below. Omit to use the default, which reads/writes a plain `.id` property. |
60
+ | `indexes` | `{ [indexKey: string]: registry key }` (optional) | Secondary indexes — see below. Omit for no indexes. |
61
+
62
+ ## Custom ids
63
+
64
+ By default, a value's id is its `.id` property. To use something else, implement `IBFunctionRegistry` and register `getID`/`setID` functions under whatever keys you configure:
65
+
66
+ ```ts
67
+ import { getDatabase, IBFunctionRegistry } from "@incoqnito.io/bajadab";
68
+
69
+ class Registry implements IBFunctionRegistry {
70
+ private fns = new Map<string, Function>([
71
+ ["userGetID", async (v: any) => v.userId],
72
+ ["userSetID", async (v: any, id: string) => { v.userId = id; }],
73
+ ]);
74
+ fetch(key?: string) {
75
+ return key ? this.fns.get(key) : undefined;
76
+ }
77
+ }
78
+
79
+ const db = await getDatabase("./data", new Registry());
80
+ const users = await db.createCollection("users", {
81
+ ...db.config.defaultCollectionConfig,
82
+ getID: "userGetID",
83
+ setID: "userSetID",
84
+ });
85
+ ```
86
+
87
+ A `getID`/`setID` key that isn't found in the registry causes `add()` to reject, rather than silently falling back to the default.
88
+
89
+ ## Indexes
90
+
91
+ A collection can declare secondary indexes for fast lookup by a derived key. Like `getID`/`setID`, the extractor function lives in the registry — only its key is part of the (persisted) collection config:
92
+
93
+ ```ts
94
+ class Registry implements IBFunctionRegistry {
95
+ private fns = new Map<string, Function>([
96
+ ["byEmail", async (v: any) => v.email],
97
+ ]);
98
+ fetch(key?: string) {
99
+ return key ? this.fns.get(key) : undefined;
100
+ }
101
+ }
102
+
103
+ const db = await getDatabase("./data", new Registry());
104
+ const users = await db.createCollection("users", {
105
+ ...db.config.defaultCollectionConfig,
106
+ indexes: { email: "byEmail" },
107
+ });
108
+
109
+ await users.add({ email: "a@example.com", name: "A" });
110
+ const matches = await users.getByIndex("email", "a@example.com");
111
+ ```
112
+
113
+ An extractor returns a `string`, a `string[]` for a multi-valued index, or `undefined` to leave a value out of the index. `getByIndex()` rejects if `indexKey` isn't configured. A configured index whose registry key isn't found rejects on the collection's first use after it's (re)loaded — `get()`, `add()`, whatever comes first — not just the mutations that actually need indexing; that's a wider blast radius than `getID`/`setID`, which only ever fail inside `add()`.
114
+
115
+ The index is maintained incrementally: `add()`/`update()`/`delete()` update only the affected buckets, not the whole index. A full rebuild only happens once, right after `reload()` loads fresh data from disk. Each value remembers the keys it was indexed under, so re-adding a value that was mutated in place moves it out of its old buckets correctly.
116
+
117
+ ## Values are stored by reference
118
+
119
+ bajadab keeps the objects you hand it; it doesn't copy them. `get()`/`getByIndex()` return a new array, but the values in it are the stored objects themselves, and `update()`'s updater receives the stored object too. Change values only by passing a new object to `update()` (`async v => ({ ...v, value: 5 })`) or to `add()` with an existing id:
120
+
121
+ - Mutating a value you got from `get()` changes the in-memory state without marking the collection dirty, updating indexes, or firing handlers.
122
+ - An updater that mutates in place and returns the same object still keeps indexes consistent, but the `UPDATE` handler receives that same object as both `previous` and `updated`.
123
+ - Inside a transaction, in-place mutations are outside transactional safety: they aren't reverted on rollback. A value mutated directly fires nothing on commit; an in-place updater passed to `update()` fires `UPDATE` with the same object as both `previous` and `updated`, same as outside a transaction.
124
+
125
+ `update()` is all-or-nothing: if the updater throws or returns `undefined` for any matching item, no item is replaced.
126
+
127
+ **Ids are immutable.** Once a value is stored, never change its id — neither in place nor through the object `update()`'s updater returns. bajadab doesn't check this; a changed id leaves the collection inconsistent (a `MAP` entry stored under its old key, a duplicate id in an `ARRAY`). To "rename" a value, `delete()` it and `add()` it under the new id.
128
+
129
+ ## Persistence and file layout
130
+
131
+ Each database directory contains:
132
+
133
+ - `dbmeta.json` — the database config and a name → id map for its collections.
134
+ - `<id>.json` — per-collection metadata (name, config, id).
135
+ - `<id>_data/data.json` — the collection's actual values.
136
+
137
+ Collections are addressed by a stable id, not by name, so a handle to a since-renamed-or-recreated collection can't end up reading or writing the wrong data. Every write goes through a temp-file-then-rename step to avoid partial writes. A rename that fails with `EPERM`/`EBUSY`/`EACCES` (typically a virus scanner or indexer briefly holding the file on Windows) is retried a few times before giving up; a failed write never leaves its temp file behind. On POSIX systems, created files get mode `0600` and directories `0700`; this has no effect on Windows, which has no equivalent permission bits.
138
+
139
+ ## Concurrency
140
+
141
+ All operations on a database — on any of its collections as well as `create`/`get`/`has`/`dropCollection()`, `snapshot()` and `transaction()` — are serialized within one process by a single lock per database. The lock is reentrant: code that runs while an operation holds it (handlers, predicates, updaters, a transaction's `fn`) can call any other operation on the same database without deadlocking. The flip side is that such code blocks the whole database while it runs, so keep `EBHandlerMode.SYNC` handlers and transaction callbacks short, and don't await anything slow or external inside them. There is no cross-process locking — multiple processes pointed at the same directory can still race on the underlying files.
142
+
143
+ ## Snapshots
144
+
145
+ `db.snapshot(targetDir, options?)` flushes every loaded collection, then copies the whole database directory into a new version folder under `targetDir` (created if it doesn't exist yet). The version folder is named after a sortable timestamp (e.g. `20260904T153012345Z`) and is itself a complete, independent database — open it directly with `getDatabase(versionPath)`.
146
+
147
+ ```ts
148
+ const versionPath = await db.snapshot("./backups");
149
+ // later, e.g. after a bad deploy:
150
+ const restored = await getDatabase(versionPath);
151
+ ```
152
+
153
+ `targetDir` is a pool that can hold several versions; each call adds one more. Options:
154
+
155
+ | Option | Values | Meaning |
156
+ | --- | --- | --- |
157
+ | `label` | string (optional) | Appended to the version folder's timestamp, e.g. `..._before-deploy`. Letters, digits, `-` and `_` only. |
158
+ | `keepLast` | number (optional) | Older version folders under `targetDir` to keep besides the one just created. `0` keeps only the new one. Omit to keep everything. Negative values reject. |
159
+
160
+ Pruning only ever deletes folders matching bajadab's own naming scheme — anything else you keep in `targetDir` is left alone.
161
+
162
+ Version folder names have millisecond resolution. Two `snapshot()` calls into the same `targetDir` within the same millisecond (and with the same or no `label`) resolve to the same folder name: the second copy is merged into the first, and if that copy fails, the cleanup removes the earlier snapshot along with it. Don't take snapshots into the same `targetDir` in such quick succession.
163
+
164
+ `snapshot()` blocks every other operation on the database for as long as the copy takes, to guarantee a consistent point-in-time copy. There's no separate `restoreSnapshot()` — restoring means copying a version folder back over the original `basePath` (while nothing has it open), or just pointing `getDatabase()` at the version folder directly.
165
+
166
+ ## Transactions
167
+
168
+ `db.transaction(collectionNames, fn)` runs `fn` against isolated, in-memory working copies of the named collections. Reads and writes inside `fn` only see the transaction's own state until it resolves:
169
+
170
+ ```ts
171
+ await db.transaction(["accounts", "orders"], async (tx) => {
172
+ const accounts = await tx.getCollection<Account>("accounts");
173
+ const orders = await tx.getCollection<Order>("orders");
174
+
175
+ await accounts.update(a => a.id === "u1", async a => ({ ...a, balance: a.balance - 10 }));
176
+ await orders.add({ userId: "u1", total: 10 });
177
+ });
178
+ ```
179
+
180
+ Every name passed to `transaction()` must be an existing collection; otherwise it rejects before `fn` runs, and nothing is created.
181
+
182
+ If `fn` resolves, every declared collection's working copy is first written to a temp file. If any of these writes fails, all temp files are discarded and nothing is applied. Otherwise the temp files replace the collections' files, every live state is replaced by its working copy, and only then do the `ADD`/`UPDATE`/`DELETE` handlers registered with `on()` fire — so a handler that reads or writes another declared collection already sees its committed state. They fire once per id the transaction touched, typed by the last operation on it, the same way as outside a transaction: `ADD` if the value is new or was last stored via `add()` (including an id-based overwrite), `UPDATE` (pre-transaction value → final value) if it was last changed via `update()`, `DELETE` if it's gone. A value added and later deleted within the same transaction fires nothing, and a value updated twice fires one `UPDATE`. If `fn` throws, nothing is applied, nothing is flushed, and no handler fires — every declared collection stays exactly as it was. This only covers changes made through the working copies' `add()`/`update()`/`delete()`; values mutated in place are shared with the live collection and aren't isolated (see "Values are stored by reference").
183
+
184
+ `tx.getCollection(name)` only accepts names passed to `transaction()`; anything else throws. It mirrors `IBCollection`'s read/write surface (`get`/`has`/`count`/`getByIndex`/`add`/`update`/`delete`) but not `flush`/`reload`/`unload`/`on` — those don't make sense against a working copy that might still be rolled back.
185
+
186
+ Inside `fn`, `add()`/`update()`/`delete()` through a declared collection's regular handle (the one from `db.getCollection()`) reject — those writes would otherwise be overwritten by the commit. Reading through it is fine and shows the pre-transaction state; collections not declared to the transaction stay fully usable, but writes to them aren't part of the transaction. Calling `transaction()` again from inside `fn` rejects as well, whichever collections it names.
187
+
188
+ `transaction()` holds the database lock for its whole duration, so every other operation on the database — on any collection — waits until it resolves. Across collections, the final renames at commit still happen one file at a time — a crash, or a rename that keeps failing, between them can leave a transaction partially applied. If a rename fails, the collections renamed so far are applied (on disk and in memory, and their handlers fire), the rest are discarded, and `transaction()` rejects. There's no cross-process locking here either, same as everywhere else in bajadab.
189
+
190
+ ## License
191
+
192
+ See [LICENSE](./LICENSE).
package/dist/base.d.ts CHANGED
@@ -97,12 +97,16 @@ export interface IBDatabase {
97
97
  */
98
98
  snapshot(targetDir: string, options?: IBSnapshotOptions): Promise<string>;
99
99
  /**
100
- * Runs `fn` against isolated, in-memory working copies of `collectionNames`. Reads and writes inside `fn`
101
- * are visible only within the transaction. If `fn` resolves, every working copy replaces its collection's
102
- * live state and is flushed, then `ADD`/`UPDATE`/`DELETE` handlers fire once per net change between the
103
- * state before the transaction and the committed state - a value added and later deleted within the same
104
- * transaction fires nothing. If `fn` throws, nothing is applied, nothing is flushed, and no handler fires.
105
- * Only collections named in `collectionNames` can be reached from within `fn`.
100
+ * Runs `fn` against isolated, in-memory working copies of `collectionNames`, which must all exist. Reads and
101
+ * writes inside `fn` are visible only within the transaction. If `fn` resolves, every working copy is written
102
+ * to a temp file first - if any of those writes fails, nothing is applied - then all of them replace their
103
+ * files and their collections' live state, and only then do `ADD`/`UPDATE`/`DELETE` handlers fire: once per
104
+ * id the transaction touched, typed by the last operation on it, same as outside a transaction - a value
105
+ * added and later deleted within the same transaction fires nothing. If `fn` throws, nothing is applied,
106
+ * nothing is flushed, and no handler fires.
107
+ * Only collections named in `collectionNames` can be reached from within `fn`; writing to one of them through
108
+ * its regular {@link IBCollection} handle inside `fn` rejects, and so does a nested `transaction()` call.
109
+ * Values mutated in place are shared with the live collection and are neither isolated nor reverted.
106
110
  */
107
111
  transaction(collectionNames: string[], fn: (tx: IBTransaction) => Promise<void>): Promise<void>;
108
112
  }
@@ -141,7 +145,7 @@ export interface IBCollection<T = any> {
141
145
  readonly collectionName: string;
142
146
  /** Stable identity used to address this collection's storage, independent of its name. */
143
147
  readonly collectionID: string;
144
- /** All items, or only those matching `predicate`. Always a defensive copy, never the live storage. */
148
+ /** All items, or only those matching `predicate`. A new array, but holding the stored objects themselves - don't mutate them in place. */
145
149
  get(predicate?: (v: T) => boolean): Promise<T[]>;
146
150
  /** Deletes matching items. Returns the number removed. */
147
151
  delete(predicate: (v: T) => boolean): Promise<number>;
@@ -149,11 +153,11 @@ export interface IBCollection<T = any> {
149
153
  has(predicate: (v: T) => boolean): Promise<boolean>;
150
154
  /** Adds values, resolving/assigning ids per the collection's id strategies. Returns the number added. */
151
155
  add(...v: T[]): Promise<number>;
152
- /** Replaces each item matching `predicate` with the (defined) result of `updater`. Returns the number updated. */
156
+ /** Replaces each item matching `predicate` with the (defined) result of `updater`; all-or-nothing if `updater` throws or returns `undefined`. `updater` receives the stored object - return a new one rather than mutating it, and never change its id. Returns the number updated. */
153
157
  update(predicate: (v: T) => boolean, updater: (v: T) => Promise<T>): Promise<number>;
154
158
  /** Number of items, or only those matching `predicate`. */
155
159
  count(predicate?: (v: T) => boolean): Promise<number>;
156
- /** Items whose {@link IBCollectionConfig.indexes}-configured `indexKey` extracts `key`. Always a defensive copy. Throws if `indexKey` isn't configured. */
160
+ /** Items whose {@link IBCollectionConfig.indexes}-configured `indexKey` extracts `key`. A new array, but holding the stored objects themselves. Throws if `indexKey` isn't configured. */
157
161
  getByIndex(indexKey: string, key: string): Promise<T[]>;
158
162
  /** Subscribes to `add()` mutations. Fires after the value is stored, including any auto-flush to disk. `mode` defaults to {@link EBHandlerMode.SYNC}. A handler's own errors never fail the triggering call; they're logged only. */
159
163
  on(modificationType: EBModificationType.ADD, handler: TAddHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
package/dist/base.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"base.js","sourceRoot":"","sources":["../src/main/base.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,MAAM,CAAN,IAAY,gBAKX;AALD,WAAY,gBAAgB;IACxB,mCAAmC;IACnC,6CAAyB,CAAA;IACzB,mEAAmE;IACnE,iDAA6B,CAAA;AACjC,CAAC,EALW,gBAAgB,KAAhB,gBAAgB,QAK3B;AAED,uDAAuD;AACvD,MAAM,CAAN,IAAY,oBAMX;AAND,WAAY,oBAAoB;IAC5B,oEAAoE;IACpE,mCAAW,CAAA;IACX,+BAA+B;IAC/B,gDAAgD;IAChD,uCAAe,CAAA;AACnB,CAAC,EANW,oBAAoB,KAApB,oBAAoB,QAM/B;AAED,iFAAiF;AACjF,MAAM,CAAN,IAAY,qBAOX;AAPD,WAAY,qBAAqB;IAC7B,kCAAkC;IAClC,gDAAuB,CAAA;IACvB,6BAA6B;IAC7B,wCAAe,CAAA;IACf,iDAAiD;IACjD,0CAAiB,CAAA;AACrB,CAAC,EAPW,qBAAqB,KAArB,qBAAqB,QAOhC;AAED,wFAAwF;AACxF,MAAM,CAAN,IAAY,uBAKX;AALD,WAAY,uBAAuB;IAC/B,wCAAwC;IACxC,kDAAuB,CAAA;IACvB,2CAA2C;IAC3C,gDAAqB,CAAA;AACzB,CAAC,EALW,uBAAuB,KAAvB,uBAAuB,QAKlC;AAED,6DAA6D;AAC7D,MAAM,CAAN,IAAY,mBAKX;AALD,WAAY,mBAAmB;IAC3B,2DAA2D;IAC3D,sDAA+B,CAAA;IAC/B,2DAA2D;IAC3D,8DAAuC,CAAA;AAC3C,CAAC,EALW,mBAAmB,KAAnB,mBAAmB,QAK9B;AAED,kEAAkE;AAClE,MAAM,CAAN,IAAY,qBAOX;AAPD,WAAY,qBAAqB;IAC7B,yBAAyB;IACzB,wCAAe,CAAA;IACf,gCAAgC;IAChC,wCAAe,CAAA;IACf,4CAA4C;IAC5C,0CAAiB,CAAA;AACrB,CAAC,EAPW,qBAAqB,KAArB,qBAAqB,QAOhC;AAqED,4FAA4F;AAC5F,MAAM,CAAN,IAAY,kBAOX;AAPD,WAAY,kBAAkB;IAC1B,uEAAuE;IACvE,iCAAW,CAAA;IACX,0CAA0C;IAC1C,uCAAiB,CAAA;IACjB,yCAAyC;IACzC,uCAAiB,CAAA;AACrB,CAAC,EAPW,kBAAkB,KAAlB,kBAAkB,QAO7B;AAeD,kGAAkG;AAClG,MAAM,CAAN,IAAY,aAKX;AALD,WAAY,aAAa;IACrB,6GAA6G;IAC7G,8BAAa,CAAA;IACb,mEAAmE;IACnE,gCAAe,CAAA;AACnB,CAAC,EALW,aAAa,KAAb,aAAa,QAKxB"}
1
+ {"version":3,"file":"base.js","sourceRoot":"","sources":["../src/main/base.ts"],"names":[],"mappings":"AAAA,+DAA+D;AAC/D,MAAM,CAAN,IAAY,gBAKX;AALD,WAAY,gBAAgB;IACxB,mCAAmC;IACnC,6CAAyB,CAAA;IACzB,mEAAmE;IACnE,iDAA6B,CAAA;AACjC,CAAC,EALW,gBAAgB,KAAhB,gBAAgB,QAK3B;AAED,uDAAuD;AACvD,MAAM,CAAN,IAAY,oBAMX;AAND,WAAY,oBAAoB;IAC5B,oEAAoE;IACpE,mCAAW,CAAA;IACX,+BAA+B;IAC/B,gDAAgD;IAChD,uCAAe,CAAA;AACnB,CAAC,EANW,oBAAoB,KAApB,oBAAoB,QAM/B;AAED,iFAAiF;AACjF,MAAM,CAAN,IAAY,qBAOX;AAPD,WAAY,qBAAqB;IAC7B,kCAAkC;IAClC,gDAAuB,CAAA;IACvB,6BAA6B;IAC7B,wCAAe,CAAA;IACf,iDAAiD;IACjD,0CAAiB,CAAA;AACrB,CAAC,EAPW,qBAAqB,KAArB,qBAAqB,QAOhC;AAED,wFAAwF;AACxF,MAAM,CAAN,IAAY,uBAKX;AALD,WAAY,uBAAuB;IAC/B,wCAAwC;IACxC,kDAAuB,CAAA;IACvB,2CAA2C;IAC3C,gDAAqB,CAAA;AACzB,CAAC,EALW,uBAAuB,KAAvB,uBAAuB,QAKlC;AAED,6DAA6D;AAC7D,MAAM,CAAN,IAAY,mBAKX;AALD,WAAY,mBAAmB;IAC3B,2DAA2D;IAC3D,sDAA+B,CAAA;IAC/B,2DAA2D;IAC3D,8DAAuC,CAAA;AAC3C,CAAC,EALW,mBAAmB,KAAnB,mBAAmB,QAK9B;AAED,kEAAkE;AAClE,MAAM,CAAN,IAAY,qBAOX;AAPD,WAAY,qBAAqB;IAC7B,yBAAyB;IACzB,wCAAe,CAAA;IACf,gCAAgC;IAChC,wCAAe,CAAA;IACf,4CAA4C;IAC5C,0CAAiB,CAAA;AACrB,CAAC,EAPW,qBAAqB,KAArB,qBAAqB,QAOhC;AAyED,4FAA4F;AAC5F,MAAM,CAAN,IAAY,kBAOX;AAPD,WAAY,kBAAkB;IAC1B,uEAAuE;IACvE,iCAAW,CAAA;IACX,0CAA0C;IAC1C,uCAAiB,CAAA;IACjB,yCAAyC;IACzC,uCAAiB,CAAA;AACrB,CAAC,EAPW,kBAAkB,KAAlB,kBAAkB,QAO7B;AAeD,kGAAkG;AAClG,MAAM,CAAN,IAAY,aAKX;AALD,WAAY,aAAa;IACrB,6GAA6G;IAC7G,8BAAa,CAAA;IACb,mEAAmE;IACnE,gCAAe,CAAA;AACnB,CAAC,EALW,aAAa,KAAb,aAAa,QAKxB"}
package/dist/basics.d.ts CHANGED
@@ -1,9 +1,14 @@
1
- import type { IODatabase } from "./fileio.js";
1
+ import type { IODatabase, IPreparedWrite } from "./fileio.js";
2
2
  import { EBHandlerMode, EBModificationType, IBCollection, IBCollectionConfig, IBDatabase, IBDatabaseConfig, IBFunctionRegistry, IBSnapshotOptions, IBTransaction, IHandlerRegistration, TAddHandler, TDeleteHandler, TGetIDFunction, TSetIDFunction, TUpdateHandler } from "./base.js";
3
3
  /** Default {@link TGetIDFunction}: reads `v.id`, `undefined` if absent. */
4
4
  export declare const DEFAULT_GET_ID: TGetIDFunction;
5
5
  /** Default {@link TSetIDFunction}: assigns to `v.id`. */
6
6
  export declare const DEFAULT_SET_ID: TSetIDFunction;
7
+ export interface IPreparedTransactionCommit {
8
+ readonly write: IPreparedWrite;
9
+ apply(): void;
10
+ fire(): Promise<void>;
11
+ }
7
12
  /** {@link IBCollectionConfig} used when a collection is created without an explicit one. */
8
13
  export declare const DEFAULT_COLLECTION_CONFIG: IBCollectionConfig;
9
14
  /** {@link IBDatabaseConfig} used when `getDatabase()` creates a fresh database. */
@@ -13,7 +18,11 @@ export declare class BDatabase implements IBDatabase {
13
18
  private collections;
14
19
  readonly io: IODatabase;
15
20
  readonly functionRegistry?: IBFunctionRegistry;
21
+ readonly iid: string;
22
+ private readonly transactionScope;
16
23
  constructor(io: IODatabase, config?: IBDatabaseConfig, functionRegistry?: IBFunctionRegistry);
24
+ runExclusive<R>(fn: () => Promise<R>): Promise<R>;
25
+ isInTransaction(collectionName: string): boolean;
17
26
  addCollection(collection: BCollection): void;
18
27
  getCollection<T>(collectionName: string, collectionConfig?: IBCollectionConfig): Promise<IBCollection<T>>;
19
28
  createCollection<T = any>(collectionName: string, collectionConfig?: IBCollectionConfig): Promise<BCollection<T>>;
@@ -32,15 +41,19 @@ export declare class BCollection<T = any> implements IBCollection<T> {
32
41
  private loaded;
33
42
  private dirty;
34
43
  private dropped;
44
+ private transactionClone;
35
45
  private addHandlers;
36
46
  private updateHandlers;
37
47
  private deleteHandlers;
38
48
  private indexDefinitions;
39
49
  private indexRuntime;
50
+ private indexedKeys;
51
+ private txChanges?;
40
52
  constructor(database: BDatabase, collectionID: string, collectionName: string, collectionConfig: IBCollectionConfig);
41
53
  private checkLoaded;
42
54
  private runExclusive;
43
55
  private runProtected;
56
+ private runMutation;
44
57
  /**
45
58
  * call this only within the protecting mutex
46
59
  */
@@ -60,6 +73,8 @@ export declare class BCollection<T = any> implements IBCollection<T> {
60
73
  private resolveIndexDefinitions;
61
74
  private indexKeysFor;
62
75
  private addToIndexes;
76
+ private indexKeysForAll;
77
+ private addKeysToIndexes;
63
78
  private removeFromIndexes;
64
79
  /**
65
80
  * full rebuild from currently loaded data; only needed right after (re)load, since every
@@ -70,10 +85,14 @@ export declare class BCollection<T = any> implements IBCollection<T> {
70
85
  /**
71
86
  * id -> value for every stored item, resolved via the plain id getter (no auto-id assignment - every
72
87
  * stored item already has one). Used to diff a transaction's working copy against the pre-transaction
73
- * state in {@link commitTransactionClone}.
88
+ * state in {@link prepareTransactionCommit}.
74
89
  */
75
90
  private idKeyedSnapshot;
76
- private getID;
91
+ private resolveIDSetter;
92
+ private resolveIncomingIDs;
93
+ private assignMissingIDs;
94
+ private storedArrayIDs;
95
+ private recordTxChanges;
77
96
  add(...vs: T[]): Promise<number>;
78
97
  update(predicate: (v: T) => boolean, updater: (v: T) => Promise<T>): Promise<number>;
79
98
  private doOnDirty;
@@ -88,12 +107,10 @@ export declare class BCollection<T = any> implements IBCollection<T> {
88
107
  */
89
108
  cloneForTransaction(): Promise<BCollection<T>>;
90
109
  /**
91
- * Applies a working copy from {@link cloneForTransaction} back onto this collection: replaces the live
92
- * state, flushes once, then fires `ADD`/`UPDATE`/`DELETE` handlers for the net difference between this
93
- * collection's state before the transaction and `clone`'s state - not for each intermediate operation the
94
- * transaction performed. The before/after diff itself (and the id resolution it needs) only runs when at
95
- * least one handler is registered on this collection - a transaction on a collection nobody listens to
96
- * skips it entirely. Call only while already holding this collection's mutex for the whole transaction.
110
+ * Writes `clone`'s state to a temp file (see {@link IPreparedWrite}) and computes the handler events for the
111
+ * net changes against this collection's current state: one per id the transaction touched, typed by the last
112
+ * operation on it (`DELETE` if gone, `ADD` if new or last stored via `add()`, `UPDATE` otherwise). Nothing is
113
+ * applied until `apply()`; call only while already holding this collection's mutex for the whole transaction.
97
114
  */
98
- commitTransactionClone(clone: BCollection<T>): Promise<void>;
115
+ prepareTransactionCommit(clone: BCollection<T>): Promise<IPreparedTransactionCommit>;
99
116
  }