shelving 1.276.0 → 1.277.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.
@@ -74,7 +74,7 @@ export declare class APICache<P, R> implements AsyncDisposable {
74
74
  * @example cache.refresh(getUser, { id: "abc" })
75
75
  * @see https://shelving.cc/api/APICache/refresh
76
76
  */
77
- refresh<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, maxAge?: number): void;
77
+ refresh<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP, maxAge?: number): Promise<void>;
78
78
  /**
79
79
  * Trigger a refetch on all stores for an endpoint.
80
80
  *
@@ -83,6 +83,6 @@ export declare class APICache<P, R> implements AsyncDisposable {
83
83
  * @example cache.refreshAll(getUser)
84
84
  * @see https://shelving.cc/api/APICache/refreshAll
85
85
  */
86
- refreshAll<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, maxAge?: number): void;
86
+ refreshAll<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, maxAge?: number): Promise<void>;
87
87
  [Symbol.asyncDispose](): Promise<void>;
88
88
  }
@@ -78,8 +78,8 @@ export class APICache {
78
78
  * @example cache.refresh(getUser, { id: "abc" })
79
79
  * @see https://shelving.cc/api/APICache/refresh
80
80
  */
81
- refresh(endpoint, payload, maxAge) {
82
- this._get(endpoint)?.refresh(payload, maxAge);
81
+ async refresh(endpoint, payload, maxAge) {
82
+ await this._get(endpoint)?.refresh(payload, maxAge);
83
83
  }
84
84
  /**
85
85
  * Trigger a refetch on all stores for an endpoint.
@@ -89,8 +89,8 @@ export class APICache {
89
89
  * @example cache.refreshAll(getUser)
90
90
  * @see https://shelving.cc/api/APICache/refreshAll
91
91
  */
92
- refreshAll(endpoint, maxAge) {
93
- this._get(endpoint)?.refreshAll(maxAge);
92
+ async refreshAll(endpoint, maxAge) {
93
+ await this._get(endpoint)?.refreshAll(maxAge);
94
94
  }
95
95
  // Implement `AsyncDisposable`
96
96
  [Symbol.asyncDispose]() {
@@ -43,13 +43,13 @@ export declare class CachedAPIProvider<P, R> extends ThroughAPIProvider<P, R> im
43
43
  * @param payload The payload identifying the cached result.
44
44
  * @see https://shelving.cc/api/CachedAPIProvider/refresh
45
45
  */
46
- refresh<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP): void;
46
+ refresh<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>, payload: PP): Promise<void>;
47
47
  /**
48
48
  * Refresh every cached result for an endpoint, across all payloads.
49
49
  *
50
50
  * @param endpoint The endpoint whose cached results should be refreshed.
51
51
  * @see https://shelving.cc/api/CachedAPIProvider/refreshAll
52
52
  */
53
- refreshAll<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>): void;
53
+ refreshAll<PP extends P, RR extends R>(endpoint: Endpoint<PP, RR>): Promise<void>;
54
54
  [Symbol.asyncDispose](): Promise<void>;
55
55
  }
@@ -52,8 +52,8 @@ export class CachedAPIProvider extends ThroughAPIProvider {
52
52
  * @param payload The payload identifying the cached result.
53
53
  * @see https://shelving.cc/api/CachedAPIProvider/refresh
54
54
  */
55
- refresh(endpoint, payload) {
56
- this._cache.refresh(endpoint, payload, this.maxAge);
55
+ async refresh(endpoint, payload) {
56
+ await this._cache.refresh(endpoint, payload, this.maxAge);
57
57
  }
58
58
  /**
59
59
  * Refresh every cached result for an endpoint, across all payloads.
@@ -61,8 +61,8 @@ export class CachedAPIProvider extends ThroughAPIProvider {
61
61
  * @param endpoint The endpoint whose cached results should be refreshed.
62
62
  * @see https://shelving.cc/api/CachedAPIProvider/refreshAll
63
63
  */
64
- refreshAll(endpoint) {
65
- this._cache.refreshAll(endpoint, this.maxAge);
64
+ async refreshAll(endpoint) {
65
+ await this._cache.refreshAll(endpoint, this.maxAge);
66
66
  }
67
67
  // Implement `AsyncDisposable`
68
68
  async [Symbol.asyncDispose]() {
@@ -1,7 +1,6 @@
1
1
  import type { MutableArray } from "../../util/array.js";
2
2
  import type { Data } from "../../util/data.js";
3
3
  import type { Identifier, Item } from "../../util/item.js";
4
- import type { Query } from "../../util/query.js";
5
4
  import type { Updates } from "../../util/update.js";
6
5
  import type { Collection } from "../collection/Collection.js";
7
6
  import type { DBProvider } from "./DBProvider.js";
@@ -9,15 +8,14 @@ import { ThroughDBProvider } from "./ThroughDBProvider.js";
9
8
  /**
10
9
  * Structured log entry recording a single database write performed through a `ChangesDBProvider`.
11
10
  *
12
- * - `action` is the kind of write; `collection` is the collection name; `id`, `query`, `data`, and `updates` carry whichever fields apply to that write.
11
+ * - `action` is the kind of write; `collection` is the `Collection` the write applies to; `id` is the item that was written; `data` and `updates` carry whichever fields apply to that write.
13
12
  *
14
13
  * @see https://shelving.cc/db/DBChange
15
14
  */
16
- export type DBChange<I extends Identifier> = {
15
+ export type DBChange<I extends Identifier, T extends Data = Data> = {
17
16
  readonly action: "add" | "set" | "update" | "delete";
18
- readonly collection: string;
19
- readonly id?: I | undefined;
20
- readonly query?: unknown;
17
+ readonly collection: Collection<string, I, T>;
18
+ readonly id: I;
21
19
  readonly data?: unknown;
22
20
  readonly updates?: unknown;
23
21
  };
@@ -25,6 +23,8 @@ export type DBChange<I extends Identifier> = {
25
23
  * Database provider that records every write it performs to its `changes` log.
26
24
  *
27
25
  * - Wraps a `source` provider, delegates each write, then appends a `DBChange` entry describing what happened.
26
+ * - Every change is an explicit per-item write with an `id` — query writes are inherited two-step from `ThroughDBProvider`, so they arrive here as the individual item writes they resolved to.
27
+ * - Replay the log onto another provider with `ChangesDBProvider.replay()`.
28
28
  * - Useful for building audit logging, change feeds, or assertions in tests; reads are passed straight through and not logged.
29
29
  *
30
30
  * @see https://shelving.cc/db/ChangesDBProvider
@@ -35,8 +35,8 @@ export declare class ChangesDBProvider<I extends Identifier, T extends Data> ext
35
35
  *
36
36
  * @see https://shelving.cc/db/ChangesDBProvider/changes
37
37
  */
38
- get changes(): ReadonlyArray<DBChange<I>>;
39
- readonly _changes: MutableArray<DBChange<I>>;
38
+ get changes(): ReadonlyArray<DBChange<I, T>>;
39
+ readonly _changes: MutableArray<DBChange<I, T>>;
40
40
  /** Log an `"add"` change after writing. */
41
41
  addItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, data: TT): Promise<II>;
42
42
  /** Log a `"set"` change after writing. */
@@ -45,12 +45,18 @@ export declare class ChangesDBProvider<I extends Identifier, T extends Data> ext
45
45
  updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
46
46
  /** Log a `"delete"` change after writing. */
47
47
  deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<void>;
48
- /** Log a `"set"` change after writing. */
49
- setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
50
- /** Log an `"update"` change after writing. */
51
- updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
52
- /** Log a `"delete"` change after writing. */
53
- deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
48
+ /**
49
+ * Replay the `changes` log onto another provider, re-issuing each write in order.
50
+ *
51
+ * - Useful for audit replay or syncing a secondary store.
52
+ * - `"add"` changes replay as `DBProvider.setItem()` with the logged id, so the target keeps the same generated ids.
53
+ * - The changes replay as a sequence of awaited writes the replay itself is not atomic.
54
+ *
55
+ * @param provider Provider to replay the changes onto.
56
+ * @example await changes.replay(mirror);
57
+ * @see https://shelving.cc/db/ChangesDBProvider/replay
58
+ */
59
+ replay(provider: DBProvider<I, T>): Promise<void>;
54
60
  cloneWith(source: DBProvider<I, T>): this;
55
61
  transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
56
62
  }
@@ -3,6 +3,8 @@ import { ThroughDBProvider } from "./ThroughDBProvider.js";
3
3
  * Database provider that records every write it performs to its `changes` log.
4
4
  *
5
5
  * - Wraps a `source` provider, delegates each write, then appends a `DBChange` entry describing what happened.
6
+ * - Every change is an explicit per-item write with an `id` — query writes are inherited two-step from `ThroughDBProvider`, so they arrive here as the individual item writes they resolved to.
7
+ * - Replay the log onto another provider with `ChangesDBProvider.replay()`.
6
8
  * - Useful for building audit logging, change feeds, or assertions in tests; reads are passed straight through and not logged.
7
9
  *
8
10
  * @see https://shelving.cc/db/ChangesDBProvider
@@ -20,38 +22,45 @@ export class ChangesDBProvider extends ThroughDBProvider {
20
22
  /** Log an `"add"` change after writing. */
21
23
  async addItem(collection, data) {
22
24
  const id = await super.addItem(collection, data);
23
- this._changes.push({ action: "add", collection: collection.name, id, data });
25
+ this._changes.push({ action: "add", collection, id, data });
24
26
  return id;
25
27
  }
26
28
  /** Log a `"set"` change after writing. */
27
29
  async setItem(collection, id, data) {
28
30
  await super.setItem(collection, id, data);
29
- this._changes.push({ action: "set", collection: collection.name, id, data });
31
+ this._changes.push({ action: "set", collection, id, data });
30
32
  }
31
33
  /** Log an `"update"` change after writing. */
32
34
  async updateItem(collection, id, updates) {
33
35
  await super.updateItem(collection, id, updates);
34
- this._changes.push({ action: "update", collection: collection.name, id, updates });
36
+ this._changes.push({ action: "update", collection, id, updates });
35
37
  }
36
38
  /** Log a `"delete"` change after writing. */
37
39
  async deleteItem(collection, id) {
38
40
  await super.deleteItem(collection, id);
39
- this._changes.push({ action: "delete", collection: collection.name, id });
41
+ this._changes.push({ action: "delete", collection, id });
40
42
  }
41
- /** Log a `"set"` change after writing. */
42
- async setQuery(collection, query, data) {
43
- await super.setQuery(collection, query, data);
44
- this._changes.push({ action: "set", collection: collection.name, query, data });
45
- }
46
- /** Log an `"update"` change after writing. */
47
- async updateQuery(collection, query, updates) {
48
- await super.updateQuery(collection, query, updates);
49
- this._changes.push({ action: "update", collection: collection.name, query, updates });
50
- }
51
- /** Log a `"delete"` change after writing. */
52
- async deleteQuery(collection, query) {
53
- await super.deleteQuery(collection, query);
54
- this._changes.push({ action: "delete", collection: collection.name, query });
43
+ /**
44
+ * Replay the `changes` log onto another provider, re-issuing each write in order.
45
+ *
46
+ * - Useful for audit replay or syncing a secondary store.
47
+ * - `"add"` changes replay as `DBProvider.setItem()` with the logged id, so the target keeps the same generated ids.
48
+ * - The changes replay as a sequence of awaited writes — the replay itself is not atomic.
49
+ *
50
+ * @param provider Provider to replay the changes onto.
51
+ * @example await changes.replay(mirror);
52
+ * @see https://shelving.cc/db/ChangesDBProvider/replay
53
+ */
54
+ async replay(provider) {
55
+ // `as` casts needed: the log stores `data` and `updates` loosely as `unknown`.
56
+ for (const { action, collection, id, data, updates } of this._changes) {
57
+ if (action === "delete")
58
+ await provider.deleteItem(collection, id);
59
+ else if (action === "update")
60
+ await provider.updateItem(collection, id, updates);
61
+ else
62
+ await provider.setItem(collection, id, data);
63
+ }
55
64
  }
56
65
  // Override so that transaction copies get their own log.
57
66
  cloneWith(source) {
@@ -119,6 +119,7 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
119
119
  abstract getQuerySequence<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): ItemsSequence<II, TT>;
120
120
  /**
121
121
  * Set (overwrite) the data for every item matching a query.
122
+ * - Not guaranteed atomic: an implementation may resolve the matching items first and then write per item (two-step — see `ThroughDBProvider`), so wrap the call in `transact()` when atomicity matters.
122
123
  *
123
124
  * @param collection Collection to write to.
124
125
  * @param query Query selecting the items to set.
@@ -129,6 +130,7 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
129
130
  abstract setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
130
131
  /**
131
132
  * Apply partial updates to every item matching a query.
133
+ * - Not guaranteed atomic: an implementation may resolve the matching items first and then write per item (two-step — see `ThroughDBProvider`), so wrap the call in `transact()` when atomicity matters.
132
134
  *
133
135
  * @param collection Collection to write to.
134
136
  * @param query Query selecting the items to update.
@@ -139,6 +141,7 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
139
141
  abstract updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
140
142
  /**
141
143
  * Delete every item matching a query.
144
+ * - Not guaranteed atomic: an implementation may resolve the matching items first and then delete per item (two-step — see `ThroughDBProvider`), so wrap the call in `transact()` when atomicity matters.
142
145
  *
143
146
  * @param collection Collection to delete from.
144
147
  * @param query Query selecting the items to delete.
@@ -27,8 +27,11 @@ export declare class DebugDBProvider<I extends Identifier, T extends Data> exten
27
27
  countQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<number>;
28
28
  getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
29
29
  getQuerySequence<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): ItemsSequence<II, TT>;
30
+ /** Passthrough, not two-step: log the query write the caller made, not the per-item writes it implies. */
30
31
  setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
32
+ /** Passthrough, not two-step: log the query write the caller made, not the per-item writes it implies. */
31
33
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
34
+ /** Passthrough, not two-step: log the query write the caller made, not the per-item writes it implies. */
32
35
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
33
36
  /** Adds TRANSACT logging around the base behaviour, which re-wraps so operations inside the transaction are logged too. */
34
37
  transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
@@ -120,10 +120,11 @@ export class DebugDBProvider extends ThroughDBProvider {
120
120
  console.error(`${ANSI_FAILURE} SEQUENCE QUERY`, collection.name, query, thrown);
121
121
  }
122
122
  }
123
+ /** Passthrough, not two-step: log the query write the caller made, not the per-item writes it implies. */
123
124
  async setQuery(collection, query, data) {
124
125
  try {
125
126
  console.debug(`${ANSI_RIGHT} SET QUERY`, collection.name, query, data);
126
- await super.setQuery(collection, query, data);
127
+ await this.source.setQuery(collection, query, data);
127
128
  console.debug(`${ANSI_SUCCESS} SET QUERY`, collection.name, query, data);
128
129
  }
129
130
  catch (reason) {
@@ -131,10 +132,11 @@ export class DebugDBProvider extends ThroughDBProvider {
131
132
  throw reason;
132
133
  }
133
134
  }
135
+ /** Passthrough, not two-step: log the query write the caller made, not the per-item writes it implies. */
134
136
  async updateQuery(collection, query, updates) {
135
137
  try {
136
138
  console.debug(`${ANSI_RIGHT} UPDATE QUERY`, collection.name, query, updates);
137
- await super.updateQuery(collection, query, updates);
139
+ await this.source.updateQuery(collection, query, updates);
138
140
  console.debug(`${ANSI_SUCCESS} UPDATE QUERY`, collection.name, query, updates);
139
141
  }
140
142
  catch (reason) {
@@ -142,10 +144,11 @@ export class DebugDBProvider extends ThroughDBProvider {
142
144
  throw reason;
143
145
  }
144
146
  }
147
+ /** Passthrough, not two-step: log the query write the caller made, not the per-item writes it implies. */
145
148
  async deleteQuery(collection, query) {
146
149
  try {
147
150
  console.debug(`${ANSI_RIGHT} DELETE QUERY`, collection.name, query);
148
- await super.deleteQuery(collection, query);
151
+ await this.source.deleteQuery(collection, query);
149
152
  console.debug(`${ANSI_SUCCESS} DELETE QUERY`, collection.name, query);
150
153
  }
151
154
  catch (reason) {
@@ -9,6 +9,7 @@ import type { DBProvider } from "./DBProvider.js";
9
9
  * Database provider that passes every operation straight through to a wrapped `source` provider.
10
10
  *
11
11
  * - Base for the layered `Through*Provider` family (validation, caching, logging, change tracking); subclasses override individual methods to add behaviour and call `super` to delegate.
12
+ * - Query writes (`setQuery()`, `updateQuery()`, `deleteQuery()`) are two-step by default — resolved to their matching items with `getQuery()`, then written per item through this provider's own item methods — so wrapper behaviour applies to every implied write. Subclasses that don't need per-item behaviour override them to pass through to `source` directly.
12
13
  * - Exposes `source` and implements `Sourceable`, so wrapped providers can be discovered with `getSource()` / `requireSource()`.
13
14
  *
14
15
  * @see https://shelving.cc/db/ThroughDBProvider
@@ -31,8 +32,16 @@ export declare class ThroughDBProvider<I extends Identifier, T extends Data> imp
31
32
  countQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<number>;
32
33
  getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
33
34
  getQuerySequence<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): ItemsSequence<II, TT>;
35
+ /**
36
+ * Two-step: resolve the query to its matching items with `getQuery()`, then set each one with `setItem()`.
37
+ * - Routes every implied write through this provider's own item methods, so wrapper behaviour applies to each item — the same theory as `transact()` re-wrapping the transaction provider.
38
+ * - The per-item writes run concurrently (`awaitValues()`), so a batch over a remote source costs one round-trip of latency, not one per item.
39
+ * - The resolve and the writes are separate steps, so this is only atomic inside `transact()`.
40
+ */
34
41
  setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
42
+ /** Two-step: resolve the query to its matching items with `getQuery()`, then update each one concurrently with `updateItem()` — see `ThroughDBProvider.setQuery()`. */
35
43
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
44
+ /** Two-step: resolve the query to its matching items with `getQuery()`, then delete each one concurrently with `deleteItem()` — see `ThroughDBProvider.setQuery()`. */
36
45
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
37
46
  getFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<OptionalItem<II, TT>>;
38
47
  requireFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<Item<II, TT>>;
@@ -1,8 +1,10 @@
1
+ import { awaitValues } from "../../util/async.js";
1
2
  import { awaitDispose } from "../../util/dispose.js";
2
3
  /**
3
4
  * Database provider that passes every operation straight through to a wrapped `source` provider.
4
5
  *
5
6
  * - Base for the layered `Through*Provider` family (validation, caching, logging, change tracking); subclasses override individual methods to add behaviour and call `super` to delegate.
7
+ * - Query writes (`setQuery()`, `updateQuery()`, `deleteQuery()`) are two-step by default — resolved to their matching items with `getQuery()`, then written per item through this provider's own item methods — so wrapper behaviour applies to every implied write. Subclasses that don't need per-item behaviour override them to pass through to `source` directly.
6
8
  * - Exposes `source` and implements `Sourceable`, so wrapped providers can be discovered with `getSource()` / `requireSource()`.
7
9
  *
8
10
  * @see https://shelving.cc/db/ThroughDBProvider
@@ -47,14 +49,25 @@ export class ThroughDBProvider {
47
49
  getQuerySequence(collection, query) {
48
50
  return this.source.getQuerySequence(collection, query);
49
51
  }
50
- setQuery(collection, query, data) {
51
- return this.source.setQuery(collection, query, data);
52
- }
53
- updateQuery(collection, query, updates) {
54
- return this.source.updateQuery(collection, query, updates);
55
- }
56
- deleteQuery(collection, query) {
57
- return this.source.deleteQuery(collection, query);
52
+ /**
53
+ * Two-step: resolve the query to its matching items with `getQuery()`, then set each one with `setItem()`.
54
+ * - Routes every implied write through this provider's own item methods, so wrapper behaviour applies to each item — the same theory as `transact()` re-wrapping the transaction provider.
55
+ * - The per-item writes run concurrently (`awaitValues()`), so a batch over a remote source costs one round-trip of latency, not one per item.
56
+ * - The resolve and the writes are separate steps, so this is only atomic inside `transact()`.
57
+ */
58
+ async setQuery(collection, query, data) {
59
+ const items = await this.getQuery(collection, query);
60
+ await awaitValues(...items.map(({ id }) => this.setItem(collection, id, data)));
61
+ }
62
+ /** Two-step: resolve the query to its matching items with `getQuery()`, then update each one concurrently with `updateItem()` — see `ThroughDBProvider.setQuery()`. */
63
+ async updateQuery(collection, query, updates) {
64
+ const items = await this.getQuery(collection, query);
65
+ await awaitValues(...items.map(({ id }) => this.updateItem(collection, id, updates)));
66
+ }
67
+ /** Two-step: resolve the query to its matching items with `getQuery()`, then delete each one concurrently with `deleteItem()` — see `ThroughDBProvider.setQuery()`. */
68
+ async deleteQuery(collection, query) {
69
+ const items = await this.getQuery(collection, query);
70
+ await awaitValues(...items.map(({ id }) => this.deleteItem(collection, id)));
58
71
  }
59
72
  getFirst(collection, query) {
60
73
  return this.source.getFirst(collection, query);
@@ -30,9 +30,16 @@ export declare class ValidationDBProvider<I extends Identifier, T extends Data>
30
30
  getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
31
31
  /** Validate each emitted result; throws `ValueError` if any items fail the collection schema. */
32
32
  getQuerySequence<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): ItemsSequence<II, TT>;
33
- /** Validate the data before writing; throws `ValueError` if it fails the collection schema. */
33
+ /**
34
+ * Validate the data before writing; throws `ValueError` if it fails the collection schema.
35
+ * - Passthrough, not two-step: validation applies to the query write's own inputs up front, so per-item routing adds nothing — pass through to keep the source's native query write.
36
+ */
34
37
  setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
35
- /** Validate the updates before writing; throws `ValueError` if they fail the collection schema. */
38
+ /**
39
+ * Validate the updates before writing; throws `ValueError` if they fail the collection schema.
40
+ * - Passthrough, not two-step: validation applies to the query write's own inputs up front, so per-item routing adds nothing — pass through to keep the source's native query write.
41
+ */
36
42
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
43
+ /** Passthrough, not two-step: there are no inputs to validate per item, so keep the source's native query write. */
37
44
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
38
45
  }
@@ -46,16 +46,23 @@ export class ValidationDBProvider extends ThroughDBProvider {
46
46
  for await (const items of super.getQuerySequence(collection, query))
47
47
  yield _validateItems(collection, items, this.getQuerySequence);
48
48
  }
49
- /** Validate the data before writing; throws `ValueError` if it fails the collection schema. */
49
+ /**
50
+ * Validate the data before writing; throws `ValueError` if it fails the collection schema.
51
+ * - Passthrough, not two-step: validation applies to the query write's own inputs up front, so per-item routing adds nothing — pass through to keep the source's native query write.
52
+ */
50
53
  setQuery(collection, query, data) {
51
- return super.setQuery(collection, query, collection.validate(data));
54
+ return this.source.setQuery(collection, query, collection.validate(data));
52
55
  }
53
- /** Validate the updates before writing; throws `ValueError` if they fail the collection schema. */
56
+ /**
57
+ * Validate the updates before writing; throws `ValueError` if they fail the collection schema.
58
+ * - Passthrough, not two-step: validation applies to the query write's own inputs up front, so per-item routing adds nothing — pass through to keep the source's native query write.
59
+ */
54
60
  updateQuery(collection, query, updates) {
55
- return super.updateQuery(collection, query, _validateUpdates(collection, updates, this.updateQuery));
61
+ return this.source.updateQuery(collection, query, _validateUpdates(collection, updates, this.updateQuery));
56
62
  }
63
+ /** Passthrough, not two-step: there are no inputs to validate per item, so keep the source's native query write. */
57
64
  deleteQuery(collection, query) {
58
- return super.deleteQuery(collection, query);
65
+ return this.source.deleteQuery(collection, query);
59
66
  }
60
67
  }
61
68
  /**
@@ -108,7 +108,7 @@ export declare class FirestoreProvider<I extends string = string, T extends Data
108
108
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
109
109
  /**
110
110
  * Runs the callback in a Firestore transaction: begin → reads with the transaction id → buffered writes committed atomically.
111
- * - Retries the whole callback (up to 5 attempts) when the commit is aborted by contention, so the callback must have no side effects other than through its provider.
111
+ * - Retries the whole callback (up to 5 attempts, with jittered exponential backoff) when the commit is aborted by contention, so the callback must have no side effects other than through its provider.
112
112
  * - Rolls back and rethrows if the callback throws.
113
113
  * - Reads see a consistent snapshot and never the transaction's own buffered writes.
114
114
  */
@@ -1,12 +1,13 @@
1
1
  import { DBProvider } from "../db/provider/DBProvider.js";
2
2
  import { ResponseError } from "../error/ResponseError.js";
3
3
  import { UnsupportedError } from "../error/UnsupportedError.js";
4
+ import { getDelay } from "../util/async.js";
4
5
  import { joinDataPath } from "../util/data.js";
5
6
  import { BLACKHOLE } from "../util/function.js";
6
7
  import { getItem } from "../util/item.js";
7
8
  import { isPlainObject } from "../util/object.js";
8
9
  import { getQueryFilters, getQueryLimit, getQueryOrders } from "../util/query.js";
9
- import { getRandomKey } from "../util/random.js";
10
+ import { getRandom, getRandomKey } from "../util/random.js";
10
11
  import { getUpdates } from "../util/update.js";
11
12
  import { toData, toFirestoreFields, toFirestoreValue } from "./value.js";
12
13
  // Constants.
@@ -260,7 +261,7 @@ export class FirestoreProvider extends DBProvider {
260
261
  }
261
262
  /**
262
263
  * Runs the callback in a Firestore transaction: begin → reads with the transaction id → buffered writes committed atomically.
263
- * - Retries the whole callback (up to 5 attempts) when the commit is aborted by contention, so the callback must have no side effects other than through its provider.
264
+ * - Retries the whole callback (up to 5 attempts, with jittered exponential backoff) when the commit is aborted by contention, so the callback must have no side effects other than through its provider.
264
265
  * - Rolls back and rethrows if the callback throws.
265
266
  * - Reads see a consistent snapshot and never the transaction's own buffered writes.
266
267
  */
@@ -268,6 +269,9 @@ export class FirestoreProvider extends DBProvider {
268
269
  let retryTransaction;
269
270
  let aborted;
270
271
  for (let attempt = 0; attempt < TRANSACTION_ATTEMPTS; attempt++) {
272
+ // Back off with jitter before each retry so contending transactions de-synchronise instead of re-aborting each other in lockstep.
273
+ if (attempt)
274
+ await getDelay(getRandom(0, 100 * 2 ** attempt));
271
275
  const { transaction } = (await this._request("beginTransaction", {
272
276
  options: { readWrite: retryTransaction ? { retryTransaction } : {} },
273
277
  }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.276.0",
3
+ "version": "1.277.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -9,14 +9,14 @@
9
9
  "main": "./index.js",
10
10
  "module": "./index.js",
11
11
  "devDependencies": {
12
- "@biomejs/biome": "^2.5.4",
12
+ "@biomejs/biome": "^2.5.7",
13
13
  "@heroicons/react": "^2.2.0",
14
14
  "@types/bun": "^1.3.14",
15
- "@types/react": "^19.2.17",
16
- "@types/react-dom": "^19.2.3",
15
+ "@types/react": "^19.2.18",
16
+ "@types/react-dom": "^19.2.4",
17
17
  "react": "^19.3.0-canary-fef12a01-20260413",
18
18
  "react-dom": "^19.3.0-canary-fef12a01-20260413",
19
- "stylelint": "^17.14.0",
19
+ "stylelint": "^17.14.1",
20
20
  "stylelint-config-standard": "^40.0.0",
21
21
  "typescript": "^7.0.2"
22
22
  },