@incoqnito.io/bajadab 1.1.0 → 1.2.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
@@ -8,6 +8,7 @@ A lightweight JSON-file-backed database for small, single-machine setups. No ser
8
8
  - `MAP` (keyed by id) or `ARRAY` (ordered list) in-memory storage per collection.
9
9
  - Configurable behavior for duplicate ids, missing ids, array deletion, auto-flush, and unloading dirty data.
10
10
  - Custom id extraction/assignment via a pluggable function registry.
11
+ - Optional secondary indexes for fast lookup by a derived key.
11
12
  - Atomic writes (write-to-temp, then rename) and, on POSIX systems, owner-only file permissions (`0600` files, `0700` directories).
12
13
  - In-process concurrency safety per collection and per database.
13
14
 
@@ -54,6 +55,7 @@ Every collection has an `IBCollectionConfig`. `db.config.defaultCollectionConfig
54
55
  | `autoFlushStrategy` | `ALWAYS_AUTO_FLUSH` (default) / `NO_AUTO_FLUSH` | Whether every mutation is written to disk immediately, or only on an explicit `flush()`. |
55
56
  | `dirtyUnloadStrategy` | `FLUSH` (default) / `ERROR` / `IGNORE` | What `unload()` does with unsaved changes. |
56
57
  | `getID` / `setID` | registry key (optional) | Custom id accessors — see below. Omit to use the default, which reads/writes a plain `.id` property. |
58
+ | `indexes` | `{ [indexKey: string]: registry key }` (optional) | Secondary indexes — see below. Omit for no indexes. |
57
59
 
58
60
  ## Custom ids
59
61
 
@@ -82,6 +84,34 @@ const users = await db.createCollection("users", {
82
84
 
83
85
  A `getID`/`setID` key that isn't found in the registry causes `add()` to reject, rather than silently falling back to the default.
84
86
 
87
+ ## Indexes
88
+
89
+ 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:
90
+
91
+ ```ts
92
+ class Registry implements IBFunctionRegistry {
93
+ private fns = new Map<string, Function>([
94
+ ["byEmail", async (v: any) => v.email],
95
+ ]);
96
+ fetch(key?: string) {
97
+ return key ? this.fns.get(key) : undefined;
98
+ }
99
+ }
100
+
101
+ const db = await getDatabase("./data", new Registry());
102
+ const users = await db.createCollection("users", {
103
+ ...db.config.defaultCollectionConfig,
104
+ indexes: { email: "byEmail" },
105
+ });
106
+
107
+ await users.add({ email: "a@example.com", name: "A" });
108
+ const matches = await users.getByIndex("email", "a@example.com");
109
+ ```
110
+
111
+ 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()`.
112
+
113
+ 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.
114
+
85
115
  ## Persistence and file layout
86
116
 
87
117
  Each database directory contains:
package/dist/base.d.ts CHANGED
@@ -48,6 +48,8 @@ export declare enum EBDirtyUnloadStrategy {
48
48
  export type TGetIDFunction = (v: any) => Promise<string | undefined>;
49
49
  /** Assigns an id onto a value. */
50
50
  export type TSetIDFunction = (v: any, id: string) => Promise<void>;
51
+ /** Extracts an index key (or several, for a multi-valued index) from a value; `undefined` means "not indexed". */
52
+ export type TIndexKeyFunction<T = any> = (v: T) => Promise<string | string[] | undefined>;
51
53
  /** Per-collection behavior. Passed to {@link IBDatabase.createCollection}/{@link IBDatabase.getCollection}. */
52
54
  export interface IBCollectionConfig {
53
55
  autoIDStrategy: EBAutoIDStrategy;
@@ -60,6 +62,10 @@ export interface IBCollectionConfig {
60
62
  getID?: string;
61
63
  /** Registry key for a custom {@link TSetIDFunction}. Omit to use the default (sets `.id`). */
62
64
  setID?: string;
65
+ /** Index name → registry key of a {@link TIndexKeyFunction} for that index. Omit for no indexes. */
66
+ indexes?: {
67
+ [indexKey: string]: string;
68
+ };
63
69
  }
64
70
  /** Database-wide configuration. */
65
71
  export interface IBDatabaseConfig {
@@ -78,6 +84,33 @@ export interface IBDatabase {
78
84
  /** Deletes a collection and invalidates any handle still held on it. Rejects if it doesn't exist. */
79
85
  dropCollection(collectionName: string): Promise<void>;
80
86
  }
87
+ /** What kind of collection mutation a handler subscribes to via {@link IBCollection.on}. */
88
+ export declare enum EBModificationType {
89
+ /** A value was stored by `add()` (including an id-based overwrite). */
90
+ ADD = "ADD",
91
+ /** A value was replaced by `update()`. */
92
+ UPDATE = "UPDATE",
93
+ /** A value was removed by `delete()`. */
94
+ DELETE = "DELETE"
95
+ }
96
+ /** Handler for {@link EBModificationType.ADD}: called with each value stored by `add()`. */
97
+ export type TAddHandler<T> = (added: T) => void | Promise<void>;
98
+ /** Handler for {@link EBModificationType.UPDATE}: called with the previous and the replacement value. */
99
+ export type TUpdateHandler<T> = (previous: T, updated: T) => void | Promise<void>;
100
+ /** Handler for {@link EBModificationType.DELETE}: called with each value removed by `delete()`. */
101
+ export type TDeleteHandler<T> = (deleted: T) => void | Promise<void>;
102
+ /** Handle returned by {@link IBCollection.on}. */
103
+ export interface IHandlerRegistration {
104
+ /** Unsubscribes the handler. Idempotent. */
105
+ cancel(): Promise<void>;
106
+ }
107
+ /** Whether a handler registered via {@link IBCollection.on} blocks the call that triggered it. */
108
+ export declare enum EBHandlerMode {
109
+ /** The triggering `add()`/`update()`/`delete()` call doesn't resolve until this handler has run. Default. */
110
+ SYNC = "SYNC",
111
+ /** The handler runs without the triggering call waiting for it. */
112
+ ASYNC = "ASYNC"
113
+ }
81
114
  /** A single collection of values of type `T`. Obtain one via {@link IBDatabase}. */
82
115
  export interface IBCollection<T = any> {
83
116
  readonly database: IBDatabase;
@@ -96,6 +129,16 @@ export interface IBCollection<T = any> {
96
129
  add(...v: T[]): Promise<number>;
97
130
  /** Replaces each item matching `predicate` with the (defined) result of `updater`. Returns the number updated. */
98
131
  update(predicate: (v: T) => boolean, updater: (v: T) => Promise<T>): Promise<number>;
132
+ /** Number of items, or only those matching `predicate`. */
133
+ count(predicate?: (v: T) => boolean): Promise<number>;
134
+ /** Items whose {@link IBCollectionConfig.indexes}-configured `indexKey` extracts `key`. Always a defensive copy. Throws if `indexKey` isn't configured. */
135
+ getByIndex(indexKey: string, key: string): Promise<T[]>;
136
+ /** 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. */
137
+ on(modificationType: EBModificationType.ADD, handler: TAddHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
138
+ /** Subscribes to `update()` mutations. Fires after the value is replaced, 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. */
139
+ on(modificationType: EBModificationType.UPDATE, handler: TUpdateHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
140
+ /** Subscribes to `delete()` mutations. Fires after the value is removed, 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. */
141
+ on(modificationType: EBModificationType.DELETE, handler: TDeleteHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
99
142
  /** Writes the current in-memory state to disk. */
100
143
  flush(): Promise<void>;
101
144
  /** Discards in-memory state and re-reads it from disk. */
package/dist/base.js CHANGED
@@ -51,4 +51,22 @@ export var EBDirtyUnloadStrategy;
51
51
  /** Discard the unsaved changes silently. */
52
52
  EBDirtyUnloadStrategy["IGNORE"] = "IGNORE";
53
53
  })(EBDirtyUnloadStrategy || (EBDirtyUnloadStrategy = {}));
54
+ /** What kind of collection mutation a handler subscribes to via {@link IBCollection.on}. */
55
+ export var EBModificationType;
56
+ (function (EBModificationType) {
57
+ /** A value was stored by `add()` (including an id-based overwrite). */
58
+ EBModificationType["ADD"] = "ADD";
59
+ /** A value was replaced by `update()`. */
60
+ EBModificationType["UPDATE"] = "UPDATE";
61
+ /** A value was removed by `delete()`. */
62
+ EBModificationType["DELETE"] = "DELETE";
63
+ })(EBModificationType || (EBModificationType = {}));
64
+ /** Whether a handler registered via {@link IBCollection.on} blocks the call that triggered it. */
65
+ export var EBHandlerMode;
66
+ (function (EBHandlerMode) {
67
+ /** The triggering `add()`/`update()`/`delete()` call doesn't resolve until this handler has run. Default. */
68
+ EBHandlerMode["SYNC"] = "SYNC";
69
+ /** The handler runs without the triggering call waiting for it. */
70
+ EBHandlerMode["ASYNC"] = "ASYNC";
71
+ })(EBHandlerMode || (EBHandlerMode = {}));
54
72
  //# sourceMappingURL=base.js.map
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"}
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;AA8CD,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,5 +1,5 @@
1
1
  import type { IODatabase } from "./fileio.js";
2
- import { IBCollection, IBCollectionConfig, IBDatabase, IBDatabaseConfig, IBFunctionRegistry, TGetIDFunction, TSetIDFunction } from "./base.js";
2
+ import { EBHandlerMode, EBModificationType, IBCollection, IBCollectionConfig, IBDatabase, IBDatabaseConfig, IBFunctionRegistry, 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`. */
@@ -30,16 +30,40 @@ export declare class BCollection<T = any> implements IBCollection<T> {
30
30
  private loaded;
31
31
  private dirty;
32
32
  private dropped;
33
+ private addHandlers;
34
+ private updateHandlers;
35
+ private deleteHandlers;
36
+ private indexDefinitions;
37
+ private indexRuntime;
33
38
  constructor(database: BDatabase, collectionID: string, collectionName: string, collectionConfig: IBCollectionConfig);
34
39
  private checkLoaded;
40
+ private runExclusive;
35
41
  private runProtected;
36
42
  /**
37
- * call this only within the proteing mutex
43
+ * call this only within the protecting mutex
38
44
  */
39
45
  markDropped(): void;
40
46
  get(predicate?: (v: T) => boolean): Promise<T[]>;
47
+ count(predicate?: (v: T) => boolean): Promise<number>;
48
+ getByIndex(indexKey: string, key: string): Promise<T[]>;
49
+ private invokeHandler;
50
+ private fireAdd;
51
+ private fireUpdate;
52
+ private fireDelete;
53
+ on(modificationType: EBModificationType.ADD, handler: TAddHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
54
+ on(modificationType: EBModificationType.UPDATE, handler: TUpdateHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
55
+ on(modificationType: EBModificationType.DELETE, handler: TDeleteHandler<T>, mode?: EBHandlerMode): Promise<IHandlerRegistration>;
41
56
  delete(predicate: (v: T) => boolean): Promise<number>;
42
57
  has(predicate: (v: T) => boolean): Promise<boolean>;
58
+ private resolveIndexDefinitions;
59
+ private indexKeysFor;
60
+ private addToIndexes;
61
+ private removeFromIndexes;
62
+ /**
63
+ * full rebuild from currently loaded data; only needed right after (re)load, since every
64
+ * value then is a freshly deserialized object the incremental add/update/delete hooks never saw
65
+ */
66
+ private rebuildIndexRuntime;
43
67
  private getID;
44
68
  add(...vs: T[]): Promise<number>;
45
69
  update(predicate: (v: T) => boolean, updater: (v: T) => Promise<T>): Promise<number>;
package/dist/basics.js CHANGED
@@ -1,6 +1,7 @@
1
+ import logger from "winston";
1
2
  import { uuidv7, } from "uuidv7";
2
3
  import { runMultiReentrantProtected, runReentrantProtected } from "@incoqnito.io/ts-iq-core";
3
- import { EBArrayDeletionStrategy, EBAutoFlushStrategy, EBAutoIDStrategy, EBDirtyUnloadStrategy, EBDuplicateIDStrategy, EBMemStorageStrategy, } from "./base.js";
4
+ import { EBArrayDeletionStrategy, EBAutoFlushStrategy, EBAutoIDStrategy, EBDirtyUnloadStrategy, EBDuplicateIDStrategy, EBHandlerMode, EBMemStorageStrategy, EBModificationType, } from "./base.js";
4
5
  import { canonicalName } from "./utils.js";
5
6
  /** Default {@link TGetIDFunction}: reads `v.id`, `undefined` if absent. */
6
7
  export const DEFAULT_GET_ID = (v) => ("id" in v) ? v.id : undefined;
@@ -89,6 +90,11 @@ export class BCollection {
89
90
  loaded = false;
90
91
  dirty = false;
91
92
  dropped = false;
93
+ addHandlers = [];
94
+ updateHandlers = [];
95
+ deleteHandlers = [];
96
+ indexDefinitions = new Map();
97
+ indexRuntime = new Map();
92
98
  constructor(database, collectionID, collectionName, collectionConfig) {
93
99
  this.database = database;
94
100
  this.collectionID = collectionID;
@@ -110,8 +116,11 @@ export class BCollection {
110
116
  await this.reload();
111
117
  }
112
118
  }
119
+ async runExclusive(fn) {
120
+ return runReentrantProtected(this.collectionName, fn);
121
+ }
113
122
  async runProtected(fn) {
114
- return runReentrantProtected(this.collectionName, async () => {
123
+ return this.runExclusive(async () => {
115
124
  if (this.dropped) {
116
125
  throw new Error(`collection "${this.collectionName}" has been dropped`);
117
126
  }
@@ -119,7 +128,7 @@ export class BCollection {
119
128
  });
120
129
  }
121
130
  /**
122
- * call this only within the proteing mutex
131
+ * call this only within the protecting mutex
123
132
  */
124
133
  markDropped() {
125
134
  this.dropped = true;
@@ -135,22 +144,117 @@ export class BCollection {
135
144
  default:
136
145
  case EBMemStorageStrategy.ARRAY:
137
146
  return !predicate ?
138
- // MARK this may be a bit slow for bigger collections
139
- [...this.arrayData] :
147
+ this.arrayData.slice() :
140
148
  this.arrayData.filter(predicate);
141
149
  }
142
150
  });
143
151
  }
152
+ async count(predicate) {
153
+ return this.runProtected(async () => {
154
+ await this.checkLoaded();
155
+ switch (this.collectionConfig.memStorageStrategy) {
156
+ case EBMemStorageStrategy.MAP:
157
+ return !predicate ?
158
+ this.mapData.size :
159
+ this.mapData.values().filter(predicate).reduce((n) => n + 1, 0);
160
+ default:
161
+ case EBMemStorageStrategy.ARRAY:
162
+ return !predicate ?
163
+ this.arrayData.length :
164
+ this.arrayData.filter(predicate).length;
165
+ }
166
+ });
167
+ }
168
+ async getByIndex(indexKey, key) {
169
+ return this.runProtected(async () => {
170
+ await this.checkLoaded();
171
+ const byKey = this.indexRuntime.get(indexKey);
172
+ if (!byKey) {
173
+ throw new Error(`index "${indexKey}" is not configured`);
174
+ }
175
+ return byKey.get(key)?.slice() ?? [];
176
+ });
177
+ }
178
+ async invokeHandler(fn, modificationType) {
179
+ try {
180
+ await fn();
181
+ }
182
+ catch (error) {
183
+ logger.error(`bajadab: collection "${this.collectionName}" ${modificationType} handler failed`, error);
184
+ }
185
+ }
186
+ async fireAdd(added) {
187
+ for (const v of added) {
188
+ for (const { handler, mode } of this.addHandlers) {
189
+ const call = this.invokeHandler(() => handler(v), EBModificationType.ADD);
190
+ if (mode === EBHandlerMode.SYNC) {
191
+ await call;
192
+ }
193
+ }
194
+ }
195
+ }
196
+ async fireUpdate(pairs) {
197
+ for (const [previous, updated] of pairs) {
198
+ for (const { handler, mode } of this.updateHandlers) {
199
+ const call = this.invokeHandler(() => handler(previous, updated), EBModificationType.UPDATE);
200
+ if (mode === EBHandlerMode.SYNC) {
201
+ await call;
202
+ }
203
+ }
204
+ }
205
+ }
206
+ async fireDelete(deleted) {
207
+ for (const v of deleted) {
208
+ for (const { handler, mode } of this.deleteHandlers) {
209
+ const call = this.invokeHandler(() => handler(v), EBModificationType.DELETE);
210
+ if (mode === EBHandlerMode.SYNC) {
211
+ await call;
212
+ }
213
+ }
214
+ }
215
+ }
216
+ async on(modificationType, handler, mode = EBHandlerMode.SYNC) {
217
+ return this.runProtected(async () => {
218
+ switch (modificationType) {
219
+ case EBModificationType.ADD: {
220
+ const h = handler;
221
+ this.addHandlers.push({ handler: h, mode });
222
+ return { cancel: async () => this.runExclusive(async () => { this.addHandlers = this.addHandlers.filter(x => x.handler !== h); }) };
223
+ }
224
+ case EBModificationType.UPDATE: {
225
+ const h = handler;
226
+ this.updateHandlers.push({ handler: h, mode });
227
+ return { cancel: async () => this.runExclusive(async () => { this.updateHandlers = this.updateHandlers.filter(x => x.handler !== h); }) };
228
+ }
229
+ case EBModificationType.DELETE: {
230
+ const h = handler;
231
+ this.deleteHandlers.push({ handler: h, mode });
232
+ return { cancel: async () => this.runExclusive(async () => { this.deleteHandlers = this.deleteHandlers.filter(x => x.handler !== h); }) };
233
+ }
234
+ default:
235
+ throw new Error(`unknown modification type ${modificationType}`);
236
+ }
237
+ });
238
+ }
144
239
  async delete(predicate) {
145
240
  return this.runProtected(async () => {
146
241
  await this.checkLoaded();
147
242
  let ret = 0;
243
+ const trackDeletes = this.deleteHandlers.length > 0;
244
+ const indexDeletes = this.indexDefinitions.size > 0;
245
+ const deleted = [];
148
246
  switch (this.collectionConfig.memStorageStrategy) {
149
247
  case EBMemStorageStrategy.MAP:
150
248
  for (const [key, value] of this.mapData) {
151
249
  if (predicate(value)) {
152
250
  ret++;
251
+ if (indexDeletes) {
252
+ await this.removeFromIndexes(value);
253
+ }
153
254
  this.mapData.delete(key);
255
+ if (trackDeletes) {
256
+ deleted.push(value);
257
+ }
154
258
  }
155
259
  }
156
260
  break;
@@ -167,6 +271,12 @@ export class BCollection {
167
271
  }
168
272
  else {
169
273
  ret++;
274
+ if (indexDeletes) {
275
+ await this.removeFromIndexes(item);
276
+ }
277
+ if (trackDeletes) {
278
+ deleted.push(item);
279
+ }
170
280
  }
171
281
  }
172
282
  this.arrayData.length = writeIndex;
@@ -174,6 +284,17 @@ export class BCollection {
174
284
  default:
175
285
  case EBArrayDeletionStrategy.NEW_ARRAY:
176
286
  const ol = this.arrayData.length;
287
+ if (trackDeletes || indexDeletes) {
288
+ const removed = this.arrayData.filter(predicate);
289
+ if (trackDeletes) {
290
+ deleted.push(...removed);
291
+ }
292
+ if (indexDeletes) {
293
+ for (const v of removed) {
294
+ await this.removeFromIndexes(v);
295
+ }
296
+ }
297
+ }
177
298
  this.arrayData = this.arrayData.filter(v => !predicate(v));
178
299
  ret += (ol - this.arrayData.length);
179
300
  break;
@@ -183,6 +304,9 @@ export class BCollection {
183
304
  if (ret > 0) {
184
305
  await this.doOnDirty();
185
306
  }
307
+ if (deleted.length > 0) {
308
+ await this.fireDelete(deleted);
309
+ }
186
310
  return ret;
187
311
  });
188
312
  }
@@ -198,6 +322,78 @@ export class BCollection {
198
322
  }
199
323
  });
200
324
  }
325
+ resolveIndexDefinitions() {
326
+ this.indexDefinitions = new Map();
327
+ if (!this.collectionConfig.indexes) {
328
+ return;
329
+ }
330
+ for (const [indexKey, registryKey] of Object.entries(this.collectionConfig.indexes)) {
331
+ const extractor = this.database.functionRegistry?.fetch(registryKey);
332
+ if (!extractor) {
333
+ throw new Error(`index key function "${registryKey}" is not registered`);
334
+ }
335
+ this.indexDefinitions.set(indexKey, extractor);
336
+ }
337
+ }
338
+ async indexKeysFor(v) {
339
+ const result = [];
340
+ for (const [indexKey, extractor] of this.indexDefinitions) {
341
+ const extracted = await extractor(v);
342
+ if (extracted === undefined) {
343
+ continue;
344
+ }
345
+ result.push([indexKey, Array.isArray(extracted) ? extracted : [extracted]]);
346
+ }
347
+ return result;
348
+ }
349
+ async addToIndexes(v) {
350
+ for (const [indexKey, keys] of await this.indexKeysFor(v)) {
351
+ const byKey = this.indexRuntime.get(indexKey);
352
+ for (const key of keys) {
353
+ let bucket = byKey.get(key);
354
+ if (!bucket) {
355
+ bucket = [];
356
+ byKey.set(key, bucket);
357
+ }
358
+ bucket.push(v);
359
+ }
360
+ }
361
+ }
362
+ async removeFromIndexes(v) {
363
+ for (const [indexKey, keys] of await this.indexKeysFor(v)) {
364
+ const byKey = this.indexRuntime.get(indexKey);
365
+ for (const key of keys) {
366
+ const bucket = byKey.get(key);
367
+ if (!bucket) {
368
+ continue;
369
+ }
370
+ const at = bucket.indexOf(v);
371
+ if (at !== -1) {
372
+ bucket.splice(at, 1);
373
+ }
374
+ if (bucket.length === 0) {
375
+ byKey.delete(key);
376
+ }
377
+ }
378
+ }
379
+ }
380
+ /**
381
+ * full rebuild from currently loaded data; only needed right after (re)load, since every
382
+ * value then is a freshly deserialized object the incremental add/update/delete hooks never saw
383
+ */
384
+ async rebuildIndexRuntime() {
385
+ // every configured index gets an entry up front, even if empty, so getByIndex()/
386
+ // addToIndexes()/removeFromIndexes() can assume it's there instead of checking twice
387
+ this.indexRuntime = new Map(Array.from(this.indexDefinitions.keys(), (indexKey) => [indexKey, new Map()]));
388
+ if (this.indexDefinitions.size === 0) {
389
+ return;
390
+ }
391
+ const values = this.collectionConfig.memStorageStrategy === EBMemStorageStrategy.MAP ?
392
+ [...this.mapData.values()] : this.arrayData;
393
+ for (const v of values) {
394
+ await this.addToIndexes(v);
395
+ }
396
+ }
201
397
  async getID(v) {
202
398
  let getter;
203
399
  if (this.collectionConfig.getID) {
@@ -241,6 +437,9 @@ export class BCollection {
241
437
  switch (this.collectionConfig.memStorageStrategy) {
242
438
  case EBMemStorageStrategy.MAP:
243
439
  let ret = 0;
440
+ const trackAddsMap = this.addHandlers.length > 0;
441
+ const indexAddsMap = this.indexDefinitions.size > 0;
442
+ const addedMap = [];
244
443
  for (const v of vs) {
245
444
  const id = await this.getID(v);
246
445
  if (this.mapData.has(id)) {
@@ -254,21 +453,42 @@ export class BCollection {
254
453
  case EBDuplicateIDStrategy.OVERWRITE:
255
454
  // replace the new value
256
455
  ret++;
456
+ if (indexAddsMap) {
457
+ await this.removeFromIndexes(this.mapData.get(id));
458
+ }
257
459
  this.mapData.set(id, v);
460
+ if (indexAddsMap) {
461
+ await this.addToIndexes(v);
462
+ }
463
+ if (trackAddsMap) {
464
+ addedMap.push(v);
465
+ }
258
466
  break;
259
467
  }
260
468
  }
261
469
  else {
262
470
  ret++;
263
471
  this.mapData.set(id, v);
472
+ if (indexAddsMap) {
473
+ await this.addToIndexes(v);
474
+ }
475
+ if (trackAddsMap) {
476
+ addedMap.push(v);
477
+ }
264
478
  }
265
479
  }
266
480
  if (ret > 0) {
267
481
  await this.doOnDirty();
268
482
  }
483
+ if (addedMap.length > 0) {
484
+ await this.fireAdd(addedMap);
485
+ }
269
486
  return ret;
270
487
  default:
271
488
  case EBMemStorageStrategy.ARRAY:
489
+ const trackAddsArray = this.addHandlers.length > 0;
490
+ const indexAddsArray = this.indexDefinitions.size > 0;
491
+ const addedArray = [];
272
492
  switch (this.collectionConfig.duplicateIDStrategy) {
273
493
  case EBDuplicateIDStrategy.ERROR:
274
494
  const haveIDs = new Set();
@@ -282,6 +502,12 @@ export class BCollection {
282
502
  }
283
503
  haveIDs.add(id);
284
504
  this.arrayData.push(v);
505
+ if (indexAddsArray) {
506
+ await this.addToIndexes(v);
507
+ }
508
+ if (trackAddsArray) {
509
+ addedArray.push(v);
510
+ }
285
511
  }
286
512
  break;
287
513
  case EBDuplicateIDStrategy.IGNORE:
@@ -294,6 +520,12 @@ export class BCollection {
294
520
  if (!seenIDs.has(id)) {
295
521
  seenIDs.add(id);
296
522
  this.arrayData.push(v);
523
+ if (indexAddsArray) {
524
+ await this.addToIndexes(v);
525
+ }
526
+ if (trackAddsArray) {
527
+ addedArray.push(v);
528
+ }
297
529
  }
298
530
  }
299
531
  break;
@@ -307,6 +539,9 @@ export class BCollection {
307
539
  const id = await this.getID(v);
308
540
  const existingIndex = indexMap.get(id);
309
541
  if (existingIndex !== undefined) {
542
+ if (indexAddsArray) {
543
+ await this.removeFromIndexes(this.arrayData[existingIndex]);
544
+ }
310
545
  this.arrayData[existingIndex] = v;
311
546
  }
312
547
  else {
@@ -314,12 +549,21 @@ export class BCollection {
314
549
  indexMap.set(id, this.arrayData.length);
315
550
  this.arrayData.push(v);
316
551
  }
552
+ if (indexAddsArray) {
553
+ await this.addToIndexes(v);
554
+ }
555
+ if (trackAddsArray) {
556
+ addedArray.push(v);
557
+ }
317
558
  }
318
559
  break;
319
560
  }
320
561
  if (vs.length > 0) {
321
562
  await this.doOnDirty();
322
563
  }
564
+ if (addedArray.length > 0) {
565
+ await this.fireAdd(addedArray);
566
+ }
323
567
  return vs.length;
324
568
  }
325
569
  });
@@ -328,6 +572,9 @@ export class BCollection {
328
572
  return this.runProtected(async () => {
329
573
  await this.checkLoaded();
330
574
  let ret = 0;
575
+ const trackUpdates = this.updateHandlers.length > 0;
576
+ const indexUpdates = this.indexDefinitions.size > 0;
577
+ const updated = [];
331
578
  switch (this.collectionConfig.memStorageStrategy) {
332
579
  case EBMemStorageStrategy.MAP:
333
580
  for (const [key, value] of this.mapData) {
@@ -335,7 +582,16 @@ export class BCollection {
335
582
  const replacement = await updater(value);
336
583
  if (replacement !== undefined) {
337
584
  ret++;
585
+ if (indexUpdates) {
586
+ await this.removeFromIndexes(value);
587
+ }
338
588
  this.mapData.set(key, replacement);
589
+ if (indexUpdates) {
590
+ await this.addToIndexes(replacement);
591
+ }
592
+ if (trackUpdates) {
593
+ updated.push([value, replacement]);
594
+ }
339
595
  }
340
596
  else {
341
597
  throw new Error("updater gave undefined");
@@ -347,10 +603,20 @@ export class BCollection {
347
603
  case EBMemStorageStrategy.ARRAY:
348
604
  for (let i = 0; i < this.arrayData.length; i++) {
349
605
  if (predicate(this.arrayData[i])) {
350
- const replacement = await updater(this.arrayData[i]);
606
+ const previous = this.arrayData[i];
607
+ const replacement = await updater(previous);
351
608
  if (replacement !== undefined) {
352
609
  ret++;
610
+ if (indexUpdates) {
611
+ await this.removeFromIndexes(previous);
612
+ }
353
613
  this.arrayData[i] = replacement;
614
+ if (indexUpdates) {
615
+ await this.addToIndexes(replacement);
616
+ }
617
+ if (trackUpdates) {
618
+ updated.push([previous, replacement]);
619
+ }
354
620
  }
355
621
  else {
356
622
  throw new Error("updater gave undefined");
@@ -362,6 +628,9 @@ export class BCollection {
362
628
  if (ret > 0) {
363
629
  await this.doOnDirty();
364
630
  }
631
+ if (updated.length > 0) {
632
+ await this.fireUpdate(updated);
633
+ }
365
634
  return ret;
366
635
  });
367
636
  }
@@ -400,6 +669,8 @@ export class BCollection {
400
669
  }
401
670
  this.loaded = true;
402
671
  this.dirty = false;
672
+ this.resolveIndexDefinitions();
673
+ await this.rebuildIndexRuntime();
403
674
  });
404
675
  }
405
676
  async unload() {