shelving 1.277.0 → 1.278.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.
@@ -48,7 +48,7 @@ export declare class ChangesDBProvider<I extends Identifier, T extends Data> ext
48
48
  /**
49
49
  * Replay the `changes` log onto another provider, re-issuing each write in order.
50
50
  *
51
- * - Useful for audit replay or syncing a secondary store.
51
+ * - Useful for audit replay or syncing a secondary store — and the commit mechanism for `MemoryDBProvider.transact()`.
52
52
  * - `"add"` changes replay as `DBProvider.setItem()` with the logged id, so the target keeps the same generated ids.
53
53
  * - The changes replay as a sequence of awaited writes — the replay itself is not atomic.
54
54
  *
@@ -43,7 +43,7 @@ export class ChangesDBProvider extends ThroughDBProvider {
43
43
  /**
44
44
  * Replay the `changes` log onto another provider, re-issuing each write in order.
45
45
  *
46
- * - Useful for audit replay or syncing a secondary store.
46
+ * - Useful for audit replay or syncing a secondary store — and the commit mechanism for `MemoryDBProvider.transact()`.
47
47
  * - `"add"` changes replay as `DBProvider.setItem()` with the logged id, so the target keeps the same generated ids.
48
48
  * - The changes replay as a sequence of awaited writes — the replay itself is not atomic.
49
49
  *
@@ -177,7 +177,7 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
177
177
  * - Reads see a consistent snapshot of the data from before the transaction, and do not see the transaction's own uncommitted writes.
178
178
  * - If the callback throws, nothing is committed and the error is rethrown.
179
179
  * - The callback may run more than once if the backend retries on contention, so it must have no side effects other than through its provider.
180
- * - Inside a transaction, realtime sequences and nested `transact()` calls throw `UnsupportedError`.
180
+ * - Portable code must not use realtime sequences or nested `transact()` calls inside a transaction — most backends throw `UnsupportedError`, though some (e.g. `MemoryDBProvider`) support them scoped to the transaction.
181
181
  * - Not every provider supports transactions — the base implementation throws `UnsupportedError`.
182
182
  *
183
183
  * @param callback Function that performs the transaction's reads and writes through the provider it receives.
@@ -89,7 +89,7 @@ export class DBProvider {
89
89
  * - Reads see a consistent snapshot of the data from before the transaction, and do not see the transaction's own uncommitted writes.
90
90
  * - If the callback throws, nothing is committed and the error is rethrown.
91
91
  * - The callback may run more than once if the backend retries on contention, so it must have no side effects other than through its provider.
92
- * - Inside a transaction, realtime sequences and nested `transact()` calls throw `UnsupportedError`.
92
+ * - Portable code must not use realtime sequences or nested `transact()` calls inside a transaction — most backends throw `UnsupportedError`, though some (e.g. `MemoryDBProvider`) support them scoped to the transaction.
93
93
  * - Not every provider supports transactions — the base implementation throws `UnsupportedError`.
94
94
  *
95
95
  * @param callback Function that performs the transaction's reads and writes through the provider it receives.
@@ -11,6 +11,7 @@ import { DBProvider } from "./DBProvider.js";
11
11
  * - Extremely fast (ideal as the cache behind `CacheDBProvider`!), but does not persist data after the process or browser window closes.
12
12
  * - Identity-preserving: `getItem()` etc. return the exact same object instance that was passed into `setItem()`.
13
13
  * - Supports live subscriptions, so it can back `ItemStore` / `QueryStore` reads.
14
+ * - Supports transactions: `transact()` runs the callback against a snapshot clone, captures its writes with `ChangesDBProvider`, and replays them onto this provider on success — sequences and nested transactions work inside the callback, scoped to the transaction.
14
15
  *
15
16
  * @see https://shelving.cc/db/MemoryDBProvider
16
17
  */
@@ -57,13 +58,32 @@ export declare class MemoryDBProvider<I extends Identifier = Identifier, T exten
57
58
  * @see https://shelving.cc/db/MemoryDBProvider/setItems
58
59
  */
59
60
  setItems<II extends I, TT extends T>(collection: Collection<string, II, TT>, items: Items<II, TT>): void;
61
+ /**
62
+ * Clone this provider into a new plain `MemoryDBProvider` containing the same items.
63
+ *
64
+ * - Shallow: new tables and maps sharing the same (immutable) item instances, so cloning is cheap and unchanged items keep their identity.
65
+ * - The clone is always a plain `MemoryDBProvider` with plain `MemoryTable`s, even when called on a subclass — writes to the clone only touch its own memory, never a subclass's backing store (e.g. `StorageDBProvider` persistence).
66
+ *
67
+ * @example provider.clone() // MemoryDBProvider
68
+ * @see https://shelving.cc/db/MemoryDBProvider/clone
69
+ */
70
+ clone(): MemoryDBProvider<I, T>;
71
+ /**
72
+ * Runs the callback against a shallow clone of this provider, capturing its writes with `ChangesDBProvider`, then replays them onto this provider when the callback resolves.
73
+ * - If the callback throws, the clone and its captured changes are discarded and nothing is committed.
74
+ * - Reads inside the callback see a snapshot from when the transaction began, plus the transaction's own writes. Realtime sequences and nested `transact()` also work inside the callback, scoped to the transaction — portable code must not rely on any of this (see `DBProvider.transact()`).
75
+ * - The clone is disposed when the transaction completes or fails, ending any sequences opened inside the callback.
76
+ * - Writes made to this provider while the callback is running are kept — the captured changes replay on top in order (updates apply as deltas, sets and deletes overwrite), with no conflict detection; overlapping transactions replay in completion order (last write wins per item).
77
+ * - Query writes resolve two-step against the clone, so they commit to exactly the items they matched inside the transaction, even if concurrent writes changed which items match.
78
+ */
79
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
60
80
  [Symbol.asyncDispose](): Promise<void>;
61
81
  }
62
82
  /**
63
83
  * In-memory table holding the items of a single collection for a `MemoryDBProvider`.
64
84
  *
65
85
  * - Keys items by id in a `Map`, preserving the exact object instance passed in.
66
- * - Exposes a `next` `DeferredSequence` that resolves on every change, powering the live `*Sequence` subscriptions.
86
+ * - An internal `DeferredSequence` resolves on every change, powering the live `*Sequence` subscriptions.
67
87
  *
68
88
  * @example
69
89
  * const table = provider.getTable(users);
@@ -74,19 +94,16 @@ export declare class MemoryDBProvider<I extends Identifier = Identifier, T exten
74
94
  export declare class MemoryTable<I extends Identifier, T extends Data> implements AsyncDisposable {
75
95
  /** Actual data in this table. */
76
96
  protected readonly _data: Map<I, Item<I, T>>;
77
- /**
78
- * Deferred sequence that resolves on every change to this table.
79
- *
80
- * @see https://shelving.cc/db/MemoryTable/next
81
- */
82
- readonly next: DeferredSequence<void, void, void>;
97
+ /** Deferred sequence that resolves on every change to this table — `false` for a change, or `true` once when the table is disposed and its sequences should end. */
98
+ protected readonly _next: DeferredSequence<boolean, void, void>;
83
99
  /**
84
100
  * Collection this table stores the items of.
85
101
  *
86
102
  * @see https://shelving.cc/db/MemoryTable/collection
87
103
  */
88
104
  readonly collection: Collection<string, I, T>;
89
- constructor(collection: Collection<string, I, T>);
105
+ /** @param items Optional initial `[id, item]` entries to seed the table with. */
106
+ constructor(collection: Collection<string, I, T>, items?: Iterable<readonly [I, Item<I, T>]>);
90
107
  /**
91
108
  * Get an item by its id, or `undefined` if it doesn't exist.
92
109
  *
@@ -215,5 +232,15 @@ export declare class MemoryTable<I extends Identifier, T extends Data> implement
215
232
  deleteQuery(query: Query<Item<I, T>>): void;
216
233
  setItems(items: Items<I, T>): void;
217
234
  setItemsSequence(sequence: AsyncIterable<Items<I, T>>): AsyncIterable<Items<I, T>>;
235
+ /**
236
+ * Clone this table into a new plain `MemoryTable` containing the same items.
237
+ *
238
+ * - Shallow: a new map sharing the same (immutable) item instances, so cloning is cheap.
239
+ * - The clone is always a plain `MemoryTable`, even when called on a subclass — writes to the clone have no side effects (e.g. `StorageTable` persistence).
240
+ *
241
+ * @example table.clone() // MemoryTable
242
+ * @see https://shelving.cc/db/MemoryTable/clone
243
+ */
244
+ clone(): MemoryTable<I, T>;
218
245
  [Symbol.asyncDispose](): Promise<void>;
219
246
  }
@@ -8,6 +8,7 @@ import { countItems } from "../../util/iterate.js";
8
8
  import { queryItems, queryWritableItems } from "../../util/query.js";
9
9
  import { getRandom, getRandomKey } from "../../util/random.js";
10
10
  import { updateData } from "../../util/update.js";
11
+ import { ChangesDBProvider } from "./ChangesDBProvider.js";
11
12
  import { DBProvider } from "./DBProvider.js";
12
13
  /**
13
14
  * Synchronous in-memory database provider, storing each collection in a `MemoryTable`.
@@ -15,6 +16,7 @@ import { DBProvider } from "./DBProvider.js";
15
16
  * - Extremely fast (ideal as the cache behind `CacheDBProvider`!), but does not persist data after the process or browser window closes.
16
17
  * - Identity-preserving: `getItem()` etc. return the exact same object instance that was passed into `setItem()`.
17
18
  * - Supports live subscriptions, so it can back `ItemStore` / `QueryStore` reads.
19
+ * - Supports transactions: `transact()` runs the callback against a snapshot clone, captures its writes with `ChangesDBProvider`, and replays them onto this provider on success — sequences and nested transactions work inside the callback, scoped to the transaction.
18
20
  *
19
21
  * @see https://shelving.cc/db/MemoryDBProvider
20
22
  */
@@ -89,6 +91,42 @@ export class MemoryDBProvider extends DBProvider {
89
91
  setItems(collection, items) {
90
92
  this.getTable(collection).setItems(items);
91
93
  }
94
+ /**
95
+ * Clone this provider into a new plain `MemoryDBProvider` containing the same items.
96
+ *
97
+ * - Shallow: new tables and maps sharing the same (immutable) item instances, so cloning is cheap and unchanged items keep their identity.
98
+ * - The clone is always a plain `MemoryDBProvider` with plain `MemoryTable`s, even when called on a subclass — writes to the clone only touch its own memory, never a subclass's backing store (e.g. `StorageDBProvider` persistence).
99
+ *
100
+ * @example provider.clone() // MemoryDBProvider
101
+ * @see https://shelving.cc/db/MemoryDBProvider/clone
102
+ */
103
+ clone() {
104
+ const clone = new MemoryDBProvider();
105
+ for (const [name, table] of Object.entries(this._tables))
106
+ if (table)
107
+ clone._tables[name] = table.clone();
108
+ return clone;
109
+ }
110
+ /**
111
+ * Runs the callback against a shallow clone of this provider, capturing its writes with `ChangesDBProvider`, then replays them onto this provider when the callback resolves.
112
+ * - If the callback throws, the clone and its captured changes are discarded and nothing is committed.
113
+ * - Reads inside the callback see a snapshot from when the transaction began, plus the transaction's own writes. Realtime sequences and nested `transact()` also work inside the callback, scoped to the transaction — portable code must not rely on any of this (see `DBProvider.transact()`).
114
+ * - The clone is disposed when the transaction completes or fails, ending any sequences opened inside the callback.
115
+ * - Writes made to this provider while the callback is running are kept — the captured changes replay on top in order (updates apply as deltas, sets and deletes overwrite), with no conflict detection; overlapping transactions replay in completion order (last write wins per item).
116
+ * - Query writes resolve two-step against the clone, so they commit to exactly the items they matched inside the transaction, even if concurrent writes changed which items match.
117
+ */
118
+ async transact(callback) {
119
+ const clone = this.clone();
120
+ try {
121
+ const transaction = new ChangesDBProvider(clone);
122
+ const result = await callback(transaction);
123
+ await transaction.replay(this); // Commit the captured changes.
124
+ return result;
125
+ }
126
+ finally {
127
+ await clone[Symbol.asyncDispose](); // End any sequences opened inside the callback.
128
+ }
129
+ }
92
130
  // Implement `AsyncDisposable`
93
131
  async [Symbol.asyncDispose]() {
94
132
  await awaitDispose(...Object.values(this._tables), // Dispose all tables.
@@ -99,7 +137,7 @@ export class MemoryDBProvider extends DBProvider {
99
137
  * In-memory table holding the items of a single collection for a `MemoryDBProvider`.
100
138
  *
101
139
  * - Keys items by id in a `Map`, preserving the exact object instance passed in.
102
- * - Exposes a `next` `DeferredSequence` that resolves on every change, powering the live `*Sequence` subscriptions.
140
+ * - An internal `DeferredSequence` resolves on every change, powering the live `*Sequence` subscriptions.
103
141
  *
104
142
  * @example
105
143
  * const table = provider.getTable(users);
@@ -109,21 +147,19 @@ export class MemoryDBProvider extends DBProvider {
109
147
  */
110
148
  export class MemoryTable {
111
149
  /** Actual data in this table. */
112
- _data = new Map();
113
- /**
114
- * Deferred sequence that resolves on every change to this table.
115
- *
116
- * @see https://shelving.cc/db/MemoryTable/next
117
- */
118
- next = new DeferredSequence();
150
+ _data;
151
+ /** Deferred sequence that resolves on every change to this table — `false` for a change, or `true` once when the table is disposed and its sequences should end. */
152
+ _next = new DeferredSequence();
119
153
  /**
120
154
  * Collection this table stores the items of.
121
155
  *
122
156
  * @see https://shelving.cc/db/MemoryTable/collection
123
157
  */
124
158
  collection;
125
- constructor(collection) {
159
+ /** @param items Optional initial `[id, item]` entries to seed the table with. */
160
+ constructor(collection, items) {
126
161
  this.collection = collection;
162
+ this._data = new Map(items);
127
163
  }
128
164
  /**
129
165
  * Get an item by its id, or `undefined` if it doesn't exist.
@@ -150,7 +186,9 @@ export class MemoryTable {
150
186
  let lastValue = this.getItem(id);
151
187
  yield lastValue;
152
188
  while (true) {
153
- await this.next;
189
+ const done = await this._next;
190
+ if (done)
191
+ return;
154
192
  const nextValue = this.getItem(id);
155
193
  if (nextValue !== lastValue) {
156
194
  yield nextValue;
@@ -199,7 +237,7 @@ export class MemoryTable {
199
237
  const item = getItem(id, data);
200
238
  if (this._data.get(id) !== item) {
201
239
  this._data.set(id, item);
202
- this.next.resolve();
240
+ this._next.resolve(false);
203
241
  }
204
242
  }
205
243
  /**
@@ -244,7 +282,7 @@ export class MemoryTable {
244
282
  deleteItem(id) {
245
283
  if (this._data.has(id)) {
246
284
  this._data.delete(id);
247
- this.next.resolve();
285
+ this._next.resolve(false);
248
286
  }
249
287
  }
250
288
  /**
@@ -283,7 +321,9 @@ export class MemoryTable {
283
321
  let lastItems = this.getQuery(query);
284
322
  yield lastItems;
285
323
  while (true) {
286
- await this.next;
324
+ const done = await this._next;
325
+ if (done)
326
+ return;
287
327
  const nextItems = this.getQuery(query);
288
328
  if (!isArrayEqual(lastItems, nextItems)) {
289
329
  yield nextItems;
@@ -329,10 +369,20 @@ export class MemoryTable {
329
369
  yield items;
330
370
  }
331
371
  }
372
+ /**
373
+ * Clone this table into a new plain `MemoryTable` containing the same items.
374
+ *
375
+ * - Shallow: a new map sharing the same (immutable) item instances, so cloning is cheap.
376
+ * - The clone is always a plain `MemoryTable`, even when called on a subclass — writes to the clone have no side effects (e.g. `StorageTable` persistence).
377
+ *
378
+ * @example table.clone() // MemoryTable
379
+ * @see https://shelving.cc/db/MemoryTable/clone
380
+ */
381
+ clone() {
382
+ return new MemoryTable(this.collection, this._data);
383
+ }
332
384
  // Implement `AsyncDisposable`
333
385
  async [Symbol.asyncDispose]() {
334
- await awaitDispose(
335
- // Empty by default.
336
- );
386
+ await awaitDispose(() => this._next.resolve(true));
337
387
  }
338
388
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.277.0",
3
+ "version": "1.278.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,10 +2,12 @@ import type { DBProvider } from "../db/provider/DBProvider.js";
2
2
  import type { Data } from "../util/data.js";
3
3
  /** Options for `testDBProvider()`, declaring the capabilities of the provider under test. */
4
4
  export interface TestDBProviderOptions {
5
- /** Whether the provider supports realtime sequences — when `false`, sequences are asserted to throw `UnsupportedError`. @default true */
5
+ /** Whether the provider supports realtime sequences — when `false`, sequences are asserted to throw `UnsupportedError` (including inside `transact()`); when `true` combined with `transactions`, sequences inside a transaction are asserted to observe the transaction and end with it. @default true */
6
6
  readonly realtime?: boolean;
7
7
  /** Whether the provider supports `transact()` — when `false`, it is asserted to throw `UnsupportedError`. @default false */
8
8
  readonly transactions?: boolean;
9
+ /** Whether `transact()` can be nested, committing the inner transaction into the outer — when `false`, nested calls are asserted to throw `UnsupportedError`. @default false */
10
+ readonly nestedTransactions?: boolean;
9
11
  }
10
12
  /**
11
13
  * Register the universal `DBProvider` contract test suite against a provider, so every backend proves the same behaviour.
@@ -19,4 +21,4 @@ export interface TestDBProviderOptions {
19
21
  * @example testDBProvider("MemoryDBProvider", () => new MemoryDBProvider<string>());
20
22
  * @see https://shelving.cc/test/testDBProvider
21
23
  */
22
- export declare function testDBProvider(name: string, createProvider: () => DBProvider<string, Data> | PromiseLike<DBProvider<string, Data>>, { realtime, transactions }?: TestDBProviderOptions): void;
24
+ export declare function testDBProvider(name: string, createProvider: () => DBProvider<string, Data> | PromiseLike<DBProvider<string, Data>>, { realtime, transactions, nestedTransactions }?: TestDBProviderOptions): void;
@@ -18,7 +18,7 @@ import { expectOrderedItems, expectUnorderedItems } from "./util.js";
18
18
  * @example testDBProvider("MemoryDBProvider", () => new MemoryDBProvider<string>());
19
19
  * @see https://shelving.cc/test/testDBProvider
20
20
  */
21
- export function testDBProvider(name, createProvider, { realtime = true, transactions = false } = {}) {
21
+ export function testDBProvider(name, createProvider, { realtime = true, transactions = false, nestedTransactions = false } = {}) {
22
22
  // Create the provider and wipe both fixture collections so each test starts clean.
23
23
  async function init() {
24
24
  const provider = await createProvider();
@@ -204,6 +204,7 @@ export function testDBProvider(name, createProvider, { realtime = true, transact
204
204
  if (transactions) {
205
205
  test("transact(): commits reads and writes atomically", async () => {
206
206
  const db = await init();
207
+ expect(await db.transact(async () => 123)).toBe(123); // The callback's value is returned.
207
208
  await db.setItem(BASICS_COLLECTION, "basic1", basic1);
208
209
  await db.setItem(BASICS_COLLECTION, "basic2", basic2);
209
210
  const id = await db.transact(async (tx) => {
@@ -252,15 +253,58 @@ export function testDBProvider(name, createProvider, { realtime = true, transact
252
253
  expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { str: "TX" }), ["basic1", "basic2", "basic3"]);
253
254
  expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(6);
254
255
  });
255
- test("transact(): sequences and nested transactions are unsupported inside a transaction", async () => {
256
- const db = await init();
257
- expect(await db.transact(async () => 123)).toBe(123);
258
- await db.transact(async (tx) => {
259
- expect(() => tx.getItemSequence(BASICS_COLLECTION, "basic1")).toThrow(UnsupportedError);
260
- expect(() => tx.getQuerySequence(BASICS_COLLECTION, {})).toThrow(UnsupportedError);
261
- expect(() => tx.transact(async () => undefined)).toThrow(UnsupportedError);
256
+ // Sequence support inside a transaction follows the `realtime` flag — a provider that supports both capabilities supports them together.
257
+ if (realtime) {
258
+ test("transact(): sequences inside a transaction observe the transaction and end with it", async () => {
259
+ const db = await init();
260
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
261
+ const emissions = [];
262
+ let sequence;
263
+ await db.transact(async (tx) => {
264
+ sequence = (async () => {
265
+ for await (const item of tx.getItemSequence(BASICS_COLLECTION, "basic1"))
266
+ emissions.push(item);
267
+ })();
268
+ await runMicrotasks();
269
+ await tx.updateItem(BASICS_COLLECTION, "basic1", { str: "TX" });
270
+ await runMicrotasks();
271
+ });
272
+ await sequence; // The sequence ends when the transaction completes (this would hang otherwise).
273
+ expect(emissions[0]).toMatchObject(basic1);
274
+ expect(emissions[1]).toMatchObject({ ...basic1, str: "TX" });
275
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject({ ...basic1, str: "TX" });
262
276
  });
263
- });
277
+ }
278
+ else {
279
+ test("transact(): sequences are unsupported inside a transaction", async () => {
280
+ const db = await init();
281
+ await db.transact(async (tx) => {
282
+ expect(() => tx.getItemSequence(BASICS_COLLECTION, "basic1")).toThrow(UnsupportedError);
283
+ expect(() => tx.getQuerySequence(BASICS_COLLECTION, {})).toThrow(UnsupportedError);
284
+ });
285
+ });
286
+ }
287
+ if (nestedTransactions) {
288
+ test("transact(): nested transactions commit into the outer transaction", async () => {
289
+ const db = await init();
290
+ await db.transact(async (tx) => {
291
+ await tx.transact(async (nested) => {
292
+ await nested.setItem(BASICS_COLLECTION, "basic1", basic1);
293
+ });
294
+ expect(await tx.getItem(BASICS_COLLECTION, "basic1")).toMatchObject(basic1); // The inner commit is visible to the outer transaction…
295
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toBe(undefined); // …but not yet committed to the provider.
296
+ });
297
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject(basic1);
298
+ });
299
+ }
300
+ else {
301
+ test("transact(): nested transactions are unsupported", async () => {
302
+ const db = await init();
303
+ await db.transact(async (tx) => {
304
+ expect(() => tx.transact(async () => undefined)).toThrow(UnsupportedError);
305
+ });
306
+ });
307
+ }
264
308
  }
265
309
  else {
266
310
  test("transactions are not supported", async () => {