shelving 1.279.0 → 1.281.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/db/index.d.ts +2 -1
- package/db/index.js +2 -1
- package/db/provider/DBProvider.d.ts +9 -6
- package/db/provider/DBProvider.js +45 -0
- package/db/provider/MemoryDBProvider.d.ts +4 -4
- package/db/provider/MemoryDBProvider.js +7 -7
- package/db/provider/RecordingDBProvider.d.ts +115 -0
- package/db/provider/RecordingDBProvider.js +146 -0
- package/db/provider/ThroughDBProvider.d.ts +5 -18
- package/db/provider/ThroughDBProvider.js +8 -34
- package/db/provider/UndoDBProvider.d.ts +44 -0
- package/db/provider/UndoDBProvider.js +62 -0
- package/package.json +1 -1
- package/db/provider/ChangesDBProvider.d.ts +0 -62
- package/db/provider/ChangesDBProvider.js +0 -80
package/db/index.d.ts
CHANGED
|
@@ -6,16 +6,17 @@ export * from "./migrate/PostgresMigrator.js";
|
|
|
6
6
|
export * from "./migrate/SQLiteMigrator.js";
|
|
7
7
|
export * from "./migrate/SQLMigrator.js";
|
|
8
8
|
export * from "./provider/CacheDBProvider.js";
|
|
9
|
-
export * from "./provider/ChangesDBProvider.js";
|
|
10
9
|
export * from "./provider/DBProvider.js";
|
|
11
10
|
export * from "./provider/DebugDBProvider.js";
|
|
12
11
|
export * from "./provider/MemoryDBProvider.js";
|
|
13
12
|
export * from "./provider/MockDBProvider.js";
|
|
14
13
|
export * from "./provider/PostgresProvider.js";
|
|
14
|
+
export * from "./provider/RecordingDBProvider.js";
|
|
15
15
|
export * from "./provider/SQLiteProvider.js";
|
|
16
16
|
export * from "./provider/SQLProvider.js";
|
|
17
17
|
export * from "./provider/StorageDBProvider.js";
|
|
18
18
|
export * from "./provider/ThroughDBProvider.js";
|
|
19
|
+
export * from "./provider/UndoDBProvider.js";
|
|
19
20
|
export * from "./provider/ValidationDBProvider.js";
|
|
20
21
|
export * from "./store/ItemStore.js";
|
|
21
22
|
export * from "./store/QueryStore.js";
|
package/db/index.js
CHANGED
|
@@ -6,16 +6,17 @@ export * from "./migrate/PostgresMigrator.js";
|
|
|
6
6
|
export * from "./migrate/SQLiteMigrator.js";
|
|
7
7
|
export * from "./migrate/SQLMigrator.js";
|
|
8
8
|
export * from "./provider/CacheDBProvider.js";
|
|
9
|
-
export * from "./provider/ChangesDBProvider.js";
|
|
10
9
|
export * from "./provider/DBProvider.js";
|
|
11
10
|
export * from "./provider/DebugDBProvider.js";
|
|
12
11
|
export * from "./provider/MemoryDBProvider.js";
|
|
13
12
|
export * from "./provider/MockDBProvider.js";
|
|
14
13
|
export * from "./provider/PostgresProvider.js";
|
|
14
|
+
export * from "./provider/RecordingDBProvider.js";
|
|
15
15
|
export * from "./provider/SQLiteProvider.js";
|
|
16
16
|
export * from "./provider/SQLProvider.js";
|
|
17
17
|
export * from "./provider/StorageDBProvider.js";
|
|
18
18
|
export * from "./provider/ThroughDBProvider.js";
|
|
19
|
+
export * from "./provider/UndoDBProvider.js";
|
|
19
20
|
export * from "./provider/ValidationDBProvider.js";
|
|
20
21
|
export * from "./store/ItemStore.js";
|
|
21
22
|
export * from "./store/QueryStore.js";
|
|
@@ -119,7 +119,8 @@ 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
|
-
* -
|
|
122
|
+
* - Two-step by default: resolves the query to its matching items with `getQuery()`, then sets each one concurrently with `setItem()`. Engine providers override this with a native query write where one exists (e.g. SQL `UPDATE … WHERE`).
|
|
123
|
+
* - Not guaranteed atomic: the resolve and the writes are separate steps, so wrap the call in `transact()` when atomicity matters.
|
|
123
124
|
*
|
|
124
125
|
* @param collection Collection to write to.
|
|
125
126
|
* @param query Query selecting the items to set.
|
|
@@ -127,10 +128,11 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
|
|
|
127
128
|
* @example await provider.setQuery(users, { age: 40 }, { active: true });
|
|
128
129
|
* @see https://shelving.cc/db/DBProvider/setQuery
|
|
129
130
|
*/
|
|
130
|
-
|
|
131
|
+
setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
|
|
131
132
|
/**
|
|
132
133
|
* Apply partial updates to every item matching a query.
|
|
133
|
-
* -
|
|
134
|
+
* - Two-step by default: resolves the query to its matching items with `getQuery()`, then updates each one concurrently with `updateItem()`. Engine providers override this with a native query write where one exists (e.g. SQL `UPDATE … WHERE`).
|
|
135
|
+
* - Not guaranteed atomic: the resolve and the writes are separate steps, so wrap the call in `transact()` when atomicity matters.
|
|
134
136
|
*
|
|
135
137
|
* @param collection Collection to write to.
|
|
136
138
|
* @param query Query selecting the items to update.
|
|
@@ -138,17 +140,18 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
|
|
|
138
140
|
* @example await provider.updateQuery(users, { age: 40 }, { active: true });
|
|
139
141
|
* @see https://shelving.cc/db/DBProvider/updateQuery
|
|
140
142
|
*/
|
|
141
|
-
|
|
143
|
+
updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
|
|
142
144
|
/**
|
|
143
145
|
* Delete every item matching a query.
|
|
144
|
-
* -
|
|
146
|
+
* - Two-step by default: resolves the query to its matching items with `getQuery()`, then deletes each one concurrently with `deleteItem()`. Engine providers override this with a native query write where one exists (e.g. SQL `DELETE … WHERE`).
|
|
147
|
+
* - Not guaranteed atomic: the resolve and the writes are separate steps, so wrap the call in `transact()` when atomicity matters.
|
|
145
148
|
*
|
|
146
149
|
* @param collection Collection to delete from.
|
|
147
150
|
* @param query Query selecting the items to delete.
|
|
148
151
|
* @example await provider.deleteQuery(users, { active: false });
|
|
149
152
|
* @see https://shelving.cc/db/DBProvider/deleteQuery
|
|
150
153
|
*/
|
|
151
|
-
|
|
154
|
+
deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
|
|
152
155
|
/**
|
|
153
156
|
* Get the first item matching a query, or `undefined` if there are none.
|
|
154
157
|
*
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { RequiredError } from "../../error/RequiredError.js";
|
|
2
2
|
import { UnsupportedError } from "../../error/UnsupportedError.js";
|
|
3
3
|
import { countArray, getFirst } from "../../util/array.js";
|
|
4
|
+
import { awaitValues } from "../../util/async.js";
|
|
4
5
|
import { awaitDispose } from "../../util/dispose.js";
|
|
5
6
|
/**
|
|
6
7
|
* Provider with a fully asynchronous interface for database access.
|
|
@@ -49,6 +50,50 @@ export class DBProvider {
|
|
|
49
50
|
async countQuery(collection, query) {
|
|
50
51
|
return countArray(await this.getQuery(collection, query));
|
|
51
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Set (overwrite) the data for every item matching a query.
|
|
55
|
+
* - Two-step by default: resolves the query to its matching items with `getQuery()`, then sets each one concurrently with `setItem()`. Engine providers override this with a native query write where one exists (e.g. SQL `UPDATE … WHERE`).
|
|
56
|
+
* - Not guaranteed atomic: the resolve and the writes are separate steps, so wrap the call in `transact()` when atomicity matters.
|
|
57
|
+
*
|
|
58
|
+
* @param collection Collection to write to.
|
|
59
|
+
* @param query Query selecting the items to set.
|
|
60
|
+
* @param data Full data to store for each matching item.
|
|
61
|
+
* @example await provider.setQuery(users, { age: 40 }, { active: true });
|
|
62
|
+
* @see https://shelving.cc/db/DBProvider/setQuery
|
|
63
|
+
*/
|
|
64
|
+
async setQuery(collection, query, data) {
|
|
65
|
+
const items = await this.getQuery(collection, query);
|
|
66
|
+
await awaitValues(...items.map(({ id }) => this.setItem(collection, id, data)));
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Apply partial updates to every item matching a query.
|
|
70
|
+
* - Two-step by default: resolves the query to its matching items with `getQuery()`, then updates each one concurrently with `updateItem()`. Engine providers override this with a native query write where one exists (e.g. SQL `UPDATE … WHERE`).
|
|
71
|
+
* - Not guaranteed atomic: the resolve and the writes are separate steps, so wrap the call in `transact()` when atomicity matters.
|
|
72
|
+
*
|
|
73
|
+
* @param collection Collection to write to.
|
|
74
|
+
* @param query Query selecting the items to update.
|
|
75
|
+
* @param updates Updates to apply to each matching item.
|
|
76
|
+
* @example await provider.updateQuery(users, { age: 40 }, { active: true });
|
|
77
|
+
* @see https://shelving.cc/db/DBProvider/updateQuery
|
|
78
|
+
*/
|
|
79
|
+
async updateQuery(collection, query, updates) {
|
|
80
|
+
const items = await this.getQuery(collection, query);
|
|
81
|
+
await awaitValues(...items.map(({ id }) => this.updateItem(collection, id, updates)));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Delete every item matching a query.
|
|
85
|
+
* - Two-step by default: resolves the query to its matching items with `getQuery()`, then deletes each one concurrently with `deleteItem()`. Engine providers override this with a native query write where one exists (e.g. SQL `DELETE … WHERE`).
|
|
86
|
+
* - Not guaranteed atomic: the resolve and the writes are separate steps, so wrap the call in `transact()` when atomicity matters.
|
|
87
|
+
*
|
|
88
|
+
* @param collection Collection to delete from.
|
|
89
|
+
* @param query Query selecting the items to delete.
|
|
90
|
+
* @example await provider.deleteQuery(users, { active: false });
|
|
91
|
+
* @see https://shelving.cc/db/DBProvider/deleteQuery
|
|
92
|
+
*/
|
|
93
|
+
async deleteQuery(collection, query) {
|
|
94
|
+
const items = await this.getQuery(collection, query);
|
|
95
|
+
await awaitValues(...items.map(({ id }) => this.deleteItem(collection, id)));
|
|
96
|
+
}
|
|
52
97
|
/**
|
|
53
98
|
* Get the first item matching a query, or `undefined` if there are none.
|
|
54
99
|
*
|
|
@@ -11,7 +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,
|
|
14
|
+
* - Supports transactions: `transact()` runs the callback against a snapshot clone, records its operations with `RecordingDBProvider`, and replays the recorded writes onto this provider on success — sequences and nested transactions work inside the callback, scoped to the transaction.
|
|
15
15
|
*
|
|
16
16
|
* @see https://shelving.cc/db/MemoryDBProvider
|
|
17
17
|
*/
|
|
@@ -69,11 +69,11 @@ export declare class MemoryDBProvider<I extends Identifier = Identifier, T exten
|
|
|
69
69
|
*/
|
|
70
70
|
clone(): MemoryDBProvider<I, T>;
|
|
71
71
|
/**
|
|
72
|
-
* Runs the callback against a shallow clone of this provider,
|
|
73
|
-
* - If the callback throws, the clone and its
|
|
72
|
+
* Runs the callback against a shallow clone of this provider, recording its operations with `RecordingDBProvider`, then replays the recorded writes onto this provider when the callback resolves.
|
|
73
|
+
* - If the callback throws, the clone and its recorded operations are discarded and nothing is committed.
|
|
74
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
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
|
|
76
|
+
* - Writes made to this provider while the callback is running are kept — the recorded writes 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
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
78
|
*/
|
|
79
79
|
transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
|
|
@@ -8,15 +8,15 @@ 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";
|
|
12
11
|
import { DBProvider } from "./DBProvider.js";
|
|
12
|
+
import { RecordingDBProvider } from "./RecordingDBProvider.js";
|
|
13
13
|
/**
|
|
14
14
|
* Synchronous in-memory database provider, storing each collection in a `MemoryTable`.
|
|
15
15
|
*
|
|
16
16
|
* - Extremely fast (ideal as the cache behind `CacheDBProvider`!), but does not persist data after the process or browser window closes.
|
|
17
17
|
* - Identity-preserving: `getItem()` etc. return the exact same object instance that was passed into `setItem()`.
|
|
18
18
|
* - Supports live subscriptions, so it can back `ItemStore` / `QueryStore` reads.
|
|
19
|
-
* - Supports transactions: `transact()` runs the callback against a snapshot clone,
|
|
19
|
+
* - Supports transactions: `transact()` runs the callback against a snapshot clone, records its operations with `RecordingDBProvider`, and replays the recorded writes onto this provider on success — sequences and nested transactions work inside the callback, scoped to the transaction.
|
|
20
20
|
*
|
|
21
21
|
* @see https://shelving.cc/db/MemoryDBProvider
|
|
22
22
|
*/
|
|
@@ -108,19 +108,19 @@ export class MemoryDBProvider extends DBProvider {
|
|
|
108
108
|
return clone;
|
|
109
109
|
}
|
|
110
110
|
/**
|
|
111
|
-
* Runs the callback against a shallow clone of this provider,
|
|
112
|
-
* - If the callback throws, the clone and its
|
|
111
|
+
* Runs the callback against a shallow clone of this provider, recording its operations with `RecordingDBProvider`, then replays the recorded writes onto this provider when the callback resolves.
|
|
112
|
+
* - If the callback throws, the clone and its recorded operations are discarded and nothing is committed.
|
|
113
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
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
|
|
115
|
+
* - Writes made to this provider while the callback is running are kept — the recorded writes 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
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
117
|
*/
|
|
118
118
|
async transact(callback) {
|
|
119
119
|
const clone = this.clone();
|
|
120
120
|
try {
|
|
121
|
-
const transaction = new
|
|
121
|
+
const transaction = new RecordingDBProvider(clone);
|
|
122
122
|
const result = await callback(transaction);
|
|
123
|
-
await transaction.
|
|
123
|
+
await transaction.replayWrites(this); // Commit the recorded writes.
|
|
124
124
|
return result;
|
|
125
125
|
}
|
|
126
126
|
finally {
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ImmutableArray, MutableArray } from "../../util/array.js";
|
|
2
|
+
import type { Data } from "../../util/data.js";
|
|
3
|
+
import type { Identifier, Item, Items, OptionalItem } from "../../util/item.js";
|
|
4
|
+
import type { Query } from "../../util/query.js";
|
|
5
|
+
import type { Updates } from "../../util/update.js";
|
|
6
|
+
import type { Collection } from "../collection/Collection.js";
|
|
7
|
+
import type { DBProvider } from "./DBProvider.js";
|
|
8
|
+
import { ThroughDBProvider } from "./ThroughDBProvider.js";
|
|
9
|
+
/**
|
|
10
|
+
* Structured log entry recording a single database operation performed through a `RecordingDBProvider`.
|
|
11
|
+
*
|
|
12
|
+
* - `action` is the kind of operation; `collection` is the `Collection` it applies to; `id` is the item involved; `data` and `updates` carry whichever fields apply to that operation.
|
|
13
|
+
* - A `"get"` operation records a read — `data` is the item that was observed, or `undefined` if the read confirmed the item absent.
|
|
14
|
+
*
|
|
15
|
+
* @see https://shelving.cc/db/DBOperation
|
|
16
|
+
*/
|
|
17
|
+
export type DBOperation<I extends Identifier = Identifier, T extends Data = Data> = {
|
|
18
|
+
readonly action: "get" | "add" | "set" | "update" | "delete";
|
|
19
|
+
readonly collection: Collection<string, I, T>;
|
|
20
|
+
readonly id: I;
|
|
21
|
+
readonly data?: unknown;
|
|
22
|
+
readonly updates?: unknown;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Readonly array of `DBOperation` entries, e.g. the log recorded by a `RecordingDBProvider`.
|
|
26
|
+
*
|
|
27
|
+
* @see https://shelving.cc/db/DBOperations
|
|
28
|
+
*/
|
|
29
|
+
export type DBOperations<I extends Identifier = Identifier, T extends Data = Data> = ImmutableArray<DBOperation<I, T>>;
|
|
30
|
+
/**
|
|
31
|
+
* Replay a list of database operations onto a provider, re-issuing each one in order.
|
|
32
|
+
*
|
|
33
|
+
* - Writes re-issue as the corresponding item write — `"add"` replays as `DBProvider.setItem()` with the logged id, so the target keeps the same generated ids.
|
|
34
|
+
* - `"get"` reads apply what they observed — the item is set, or deleted when the read confirmed it absent. That's right for refreshing a mirror or cache, and wrong for an authoritative target, where an observed snapshot would overwrite newer data — replay only the writes there (see `RecordingDBProvider.replayWrites()`).
|
|
35
|
+
* - Operations re-issue as a sequence of awaited writes — the replay itself is not atomic.
|
|
36
|
+
*
|
|
37
|
+
* @param provider Provider to replay the operations onto.
|
|
38
|
+
* @param operations Operations to replay, in order.
|
|
39
|
+
* @example await replayOperations(mirror, recording.operations);
|
|
40
|
+
* @see https://shelving.cc/db/replayOperations
|
|
41
|
+
*/
|
|
42
|
+
export declare function replayOperations<I extends Identifier, T extends Data>(provider: DBProvider<I, T>, operations: DBOperations<I, T>): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Database provider that records every operation it performs to its `operations` log.
|
|
45
|
+
*
|
|
46
|
+
* - Wraps a `source` provider, delegates each operation, then appends a `DBOperation` entry describing what happened.
|
|
47
|
+
* - Records reads as well as writes: `getItem()` logs a `"get"` with the item it observed (or its confirmed absence), and `getQuery()` logs a `"get"` per item returned. Derived reads and two-step query writes are inherited, so everything they do is recorded per item too. Realtime sequences are not recorded.
|
|
48
|
+
* - Replay the log onto another provider with `replay()`, `replayWrites()`, or `replayReads()`.
|
|
49
|
+
* - Useful for building audit logging, change feeds, optimistic updates (see `UndoDBProvider`), or assertions in tests.
|
|
50
|
+
*
|
|
51
|
+
* @see https://shelving.cc/db/RecordingDBProvider
|
|
52
|
+
*/
|
|
53
|
+
export declare class RecordingDBProvider<I extends Identifier, T extends Data> extends ThroughDBProvider<I, T> {
|
|
54
|
+
/**
|
|
55
|
+
* The log of operations performed through this provider, in the order they happened.
|
|
56
|
+
*
|
|
57
|
+
* @see https://shelving.cc/db/RecordingDBProvider/operations
|
|
58
|
+
*/
|
|
59
|
+
get operations(): DBOperations<I, T>;
|
|
60
|
+
readonly _operations: MutableArray<DBOperation<I, T>>;
|
|
61
|
+
/**
|
|
62
|
+
* The write operations from the `operations` log, in the order they happened.
|
|
63
|
+
*
|
|
64
|
+
* @see https://shelving.cc/db/RecordingDBProvider/writes
|
|
65
|
+
*/
|
|
66
|
+
get writes(): DBOperations<I, T>;
|
|
67
|
+
/**
|
|
68
|
+
* The `"get"` read operations from the `operations` log, in the order they happened.
|
|
69
|
+
*
|
|
70
|
+
* @see https://shelving.cc/db/RecordingDBProvider/reads
|
|
71
|
+
*/
|
|
72
|
+
get reads(): DBOperations<I, T>;
|
|
73
|
+
/** Log a `"get"` operation recording the item that was observed (or its confirmed absence). */
|
|
74
|
+
getItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<OptionalItem<II, TT>>;
|
|
75
|
+
/** Log a `"get"` operation for each item the query observed. */
|
|
76
|
+
getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
|
|
77
|
+
/** Log an `"add"` operation after writing. */
|
|
78
|
+
addItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, data: TT): Promise<II>;
|
|
79
|
+
/** Log a `"set"` operation after writing. */
|
|
80
|
+
setItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, data: TT): Promise<void>;
|
|
81
|
+
/** Log an `"update"` operation after writing. */
|
|
82
|
+
updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
|
|
83
|
+
/** Log a `"delete"` operation after writing. */
|
|
84
|
+
deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Replay every recorded operation onto another provider, in order.
|
|
87
|
+
* - Reads apply what they observed and writes re-issue, so this refreshes a mirror or cache exactly — see `replayOperations()`.
|
|
88
|
+
* - Use `replayWrites()` for an authoritative target, where applying observed reads would overwrite newer data.
|
|
89
|
+
*
|
|
90
|
+
* @param provider Provider to replay the operations onto.
|
|
91
|
+
* @example await recording.replay(mirror);
|
|
92
|
+
* @see https://shelving.cc/db/RecordingDBProvider/replay
|
|
93
|
+
*/
|
|
94
|
+
replay(provider: DBProvider<I, T>): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* Replay only the recorded write operations onto another provider, in order.
|
|
97
|
+
* - The right call for authoritative targets — applying a log to a real database, audit replay, syncing a second source of truth — where updates should compose onto current state and observed reads must never overwrite newer data.
|
|
98
|
+
*
|
|
99
|
+
* @param provider Provider to replay the writes onto.
|
|
100
|
+
* @example await recording.replayWrites(db);
|
|
101
|
+
* @see https://shelving.cc/db/RecordingDBProvider/replayWrites
|
|
102
|
+
*/
|
|
103
|
+
replayWrites(provider: DBProvider<I, T>): Promise<void>;
|
|
104
|
+
/**
|
|
105
|
+
* Replay only the recorded `"get"` read operations onto another provider, in order.
|
|
106
|
+
* - Applies what each read observed (setting the item, or deleting it when the read confirmed absence) — e.g. warming a cache from a recorded session.
|
|
107
|
+
*
|
|
108
|
+
* @param provider Provider to replay the reads onto.
|
|
109
|
+
* @example await recording.replayReads(cache.memory);
|
|
110
|
+
* @see https://shelving.cc/db/RecordingDBProvider/replayReads
|
|
111
|
+
*/
|
|
112
|
+
replayReads(provider: DBProvider<I, T>): Promise<void>;
|
|
113
|
+
cloneWith(source: DBProvider<I, T>): this;
|
|
114
|
+
transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
|
|
115
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { ThroughDBProvider } from "./ThroughDBProvider.js";
|
|
2
|
+
/**
|
|
3
|
+
* Replay a list of database operations onto a provider, re-issuing each one in order.
|
|
4
|
+
*
|
|
5
|
+
* - Writes re-issue as the corresponding item write — `"add"` replays as `DBProvider.setItem()` with the logged id, so the target keeps the same generated ids.
|
|
6
|
+
* - `"get"` reads apply what they observed — the item is set, or deleted when the read confirmed it absent. That's right for refreshing a mirror or cache, and wrong for an authoritative target, where an observed snapshot would overwrite newer data — replay only the writes there (see `RecordingDBProvider.replayWrites()`).
|
|
7
|
+
* - Operations re-issue as a sequence of awaited writes — the replay itself is not atomic.
|
|
8
|
+
*
|
|
9
|
+
* @param provider Provider to replay the operations onto.
|
|
10
|
+
* @param operations Operations to replay, in order.
|
|
11
|
+
* @example await replayOperations(mirror, recording.operations);
|
|
12
|
+
* @see https://shelving.cc/db/replayOperations
|
|
13
|
+
*/
|
|
14
|
+
export async function replayOperations(provider, operations) {
|
|
15
|
+
// `as` casts needed: the log stores `data` and `updates` loosely as `unknown`.
|
|
16
|
+
for (const { action, collection, id, data, updates } of operations) {
|
|
17
|
+
if (action === "update")
|
|
18
|
+
await provider.updateItem(collection, id, updates);
|
|
19
|
+
else if (action === "delete")
|
|
20
|
+
await provider.deleteItem(collection, id);
|
|
21
|
+
else if (action === "get" && !data)
|
|
22
|
+
await provider.deleteItem(collection, id);
|
|
23
|
+
else
|
|
24
|
+
await provider.setItem(collection, id, data); // Observed items, adds, and sets all replay as the item's full data.
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Database provider that records every operation it performs to its `operations` log.
|
|
29
|
+
*
|
|
30
|
+
* - Wraps a `source` provider, delegates each operation, then appends a `DBOperation` entry describing what happened.
|
|
31
|
+
* - Records reads as well as writes: `getItem()` logs a `"get"` with the item it observed (or its confirmed absence), and `getQuery()` logs a `"get"` per item returned. Derived reads and two-step query writes are inherited, so everything they do is recorded per item too. Realtime sequences are not recorded.
|
|
32
|
+
* - Replay the log onto another provider with `replay()`, `replayWrites()`, or `replayReads()`.
|
|
33
|
+
* - Useful for building audit logging, change feeds, optimistic updates (see `UndoDBProvider`), or assertions in tests.
|
|
34
|
+
*
|
|
35
|
+
* @see https://shelving.cc/db/RecordingDBProvider
|
|
36
|
+
*/
|
|
37
|
+
export class RecordingDBProvider extends ThroughDBProvider {
|
|
38
|
+
/**
|
|
39
|
+
* The log of operations performed through this provider, in the order they happened.
|
|
40
|
+
*
|
|
41
|
+
* @see https://shelving.cc/db/RecordingDBProvider/operations
|
|
42
|
+
*/
|
|
43
|
+
get operations() {
|
|
44
|
+
return this._operations;
|
|
45
|
+
}
|
|
46
|
+
_operations = [];
|
|
47
|
+
/**
|
|
48
|
+
* The write operations from the `operations` log, in the order they happened.
|
|
49
|
+
*
|
|
50
|
+
* @see https://shelving.cc/db/RecordingDBProvider/writes
|
|
51
|
+
*/
|
|
52
|
+
get writes() {
|
|
53
|
+
return this._operations.filter(({ action }) => action !== "get");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The `"get"` read operations from the `operations` log, in the order they happened.
|
|
57
|
+
*
|
|
58
|
+
* @see https://shelving.cc/db/RecordingDBProvider/reads
|
|
59
|
+
*/
|
|
60
|
+
get reads() {
|
|
61
|
+
return this._operations.filter(({ action }) => action === "get");
|
|
62
|
+
}
|
|
63
|
+
/** Log a `"get"` operation recording the item that was observed (or its confirmed absence). */
|
|
64
|
+
async getItem(collection, id) {
|
|
65
|
+
const item = await super.getItem(collection, id);
|
|
66
|
+
this._operations.push({ action: "get", collection, id, data: item });
|
|
67
|
+
return item;
|
|
68
|
+
}
|
|
69
|
+
/** Log a `"get"` operation for each item the query observed. */
|
|
70
|
+
async getQuery(collection, query) {
|
|
71
|
+
const items = await super.getQuery(collection, query);
|
|
72
|
+
for (const item of items)
|
|
73
|
+
this._operations.push({ action: "get", collection, id: item.id, data: item });
|
|
74
|
+
return items;
|
|
75
|
+
}
|
|
76
|
+
/** Log an `"add"` operation after writing. */
|
|
77
|
+
async addItem(collection, data) {
|
|
78
|
+
const id = await super.addItem(collection, data);
|
|
79
|
+
this._operations.push({ action: "add", collection, id, data });
|
|
80
|
+
return id;
|
|
81
|
+
}
|
|
82
|
+
/** Log a `"set"` operation after writing. */
|
|
83
|
+
async setItem(collection, id, data) {
|
|
84
|
+
await super.setItem(collection, id, data);
|
|
85
|
+
this._operations.push({ action: "set", collection, id, data });
|
|
86
|
+
}
|
|
87
|
+
/** Log an `"update"` operation after writing. */
|
|
88
|
+
async updateItem(collection, id, updates) {
|
|
89
|
+
await super.updateItem(collection, id, updates);
|
|
90
|
+
this._operations.push({ action: "update", collection, id, updates });
|
|
91
|
+
}
|
|
92
|
+
/** Log a `"delete"` operation after writing. */
|
|
93
|
+
async deleteItem(collection, id) {
|
|
94
|
+
await super.deleteItem(collection, id);
|
|
95
|
+
this._operations.push({ action: "delete", collection, id });
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Replay every recorded operation onto another provider, in order.
|
|
99
|
+
* - Reads apply what they observed and writes re-issue, so this refreshes a mirror or cache exactly — see `replayOperations()`.
|
|
100
|
+
* - Use `replayWrites()` for an authoritative target, where applying observed reads would overwrite newer data.
|
|
101
|
+
*
|
|
102
|
+
* @param provider Provider to replay the operations onto.
|
|
103
|
+
* @example await recording.replay(mirror);
|
|
104
|
+
* @see https://shelving.cc/db/RecordingDBProvider/replay
|
|
105
|
+
*/
|
|
106
|
+
replay(provider) {
|
|
107
|
+
return replayOperations(provider, this._operations);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Replay only the recorded write operations onto another provider, in order.
|
|
111
|
+
* - The right call for authoritative targets — applying a log to a real database, audit replay, syncing a second source of truth — where updates should compose onto current state and observed reads must never overwrite newer data.
|
|
112
|
+
*
|
|
113
|
+
* @param provider Provider to replay the writes onto.
|
|
114
|
+
* @example await recording.replayWrites(db);
|
|
115
|
+
* @see https://shelving.cc/db/RecordingDBProvider/replayWrites
|
|
116
|
+
*/
|
|
117
|
+
replayWrites(provider) {
|
|
118
|
+
return replayOperations(provider, this.writes);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Replay only the recorded `"get"` read operations onto another provider, in order.
|
|
122
|
+
* - Applies what each read observed (setting the item, or deleting it when the read confirmed absence) — e.g. warming a cache from a recorded session.
|
|
123
|
+
*
|
|
124
|
+
* @param provider Provider to replay the reads onto.
|
|
125
|
+
* @example await recording.replayReads(cache.memory);
|
|
126
|
+
* @see https://shelving.cc/db/RecordingDBProvider/replayReads
|
|
127
|
+
*/
|
|
128
|
+
replayReads(provider) {
|
|
129
|
+
return replayOperations(provider, this.reads);
|
|
130
|
+
}
|
|
131
|
+
// Override so that transaction copies get their own log.
|
|
132
|
+
cloneWith(source) {
|
|
133
|
+
const clone = super.cloneWith(source);
|
|
134
|
+
Object.defineProperty(clone, "_operations", { value: [], enumerable: false });
|
|
135
|
+
return clone;
|
|
136
|
+
}
|
|
137
|
+
// Override to log the transaction's operations after it commits — a failed transaction logs nothing.
|
|
138
|
+
// The merge must happen after `source.transact()` resolves: backends may retry the callback or fail the commit itself, and only the committed attempt's operations belong in the log.
|
|
139
|
+
async transact(callback) {
|
|
140
|
+
let transaction;
|
|
141
|
+
const result = await this.source.transact(provider => callback((transaction = this.cloneWith(provider))));
|
|
142
|
+
if (transaction)
|
|
143
|
+
this._operations.push(...transaction.operations);
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -4,17 +4,17 @@ import type { Query } from "../../util/query.js";
|
|
|
4
4
|
import type { Sourceable } from "../../util/source.js";
|
|
5
5
|
import type { Updates } from "../../util/update.js";
|
|
6
6
|
import type { Collection } from "../collection/Collection.js";
|
|
7
|
-
import
|
|
7
|
+
import { DBProvider } from "./DBProvider.js";
|
|
8
8
|
/**
|
|
9
|
-
* Database provider that passes every operation straight through to a wrapped `source` provider.
|
|
9
|
+
* Database provider that passes every core 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
|
-
* -
|
|
12
|
+
* - Only the core operations delegate to `source` — derived reads (`DBProvider.requireItem()`, `DBProvider.getFirst()`, `DBProvider.requireFirst()`) and two-step query writes are inherited from `DBProvider`, so they route through this provider's own overridden methods and wrapper behaviour applies to everything they do. Wrappers that don't need per-item behaviour override the query writes to pass through to `source` directly.
|
|
13
13
|
* - Exposes `source` and implements `Sourceable`, so wrapped providers can be discovered with `getSource()` / `requireSource()`.
|
|
14
14
|
*
|
|
15
15
|
* @see https://shelving.cc/db/ThroughDBProvider
|
|
16
16
|
*/
|
|
17
|
-
export declare class ThroughDBProvider<I extends Identifier, T extends Data>
|
|
17
|
+
export declare class ThroughDBProvider<I extends Identifier, T extends Data> extends DBProvider<I, T> implements Sourceable<DBProvider<I, T>> {
|
|
18
18
|
/**
|
|
19
19
|
* The wrapped source provider that every operation is delegated to.
|
|
20
20
|
*
|
|
@@ -23,28 +23,15 @@ export declare class ThroughDBProvider<I extends Identifier, T extends Data> imp
|
|
|
23
23
|
readonly source: DBProvider<I, T>;
|
|
24
24
|
constructor(source: DBProvider<I, T>);
|
|
25
25
|
getItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<OptionalItem<II, TT>>;
|
|
26
|
-
requireItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<Item<II, TT>>;
|
|
27
26
|
getItemSequence<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): OptionalItemSequence<II, TT>;
|
|
28
27
|
addItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, data: TT): Promise<II>;
|
|
29
28
|
setItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, data: TT): Promise<void>;
|
|
30
29
|
updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
|
|
31
30
|
deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<void>;
|
|
31
|
+
/** Delegates to `source` so its native counting is kept (the base implementation would fetch the items and count them). */
|
|
32
32
|
countQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<number>;
|
|
33
33
|
getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
|
|
34
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
|
-
*/
|
|
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()`. */
|
|
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()`. */
|
|
45
|
-
deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
|
|
46
|
-
getFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<OptionalItem<II, TT>>;
|
|
47
|
-
requireFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<Item<II, TT>>;
|
|
48
35
|
transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
|
|
49
36
|
/** Clone this provider with different `source`. */
|
|
50
37
|
cloneWith(source: DBProvider<I, T>): this;
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { awaitValues } from "../../util/async.js";
|
|
2
1
|
import { awaitDispose } from "../../util/dispose.js";
|
|
2
|
+
import { DBProvider } from "./DBProvider.js";
|
|
3
3
|
/**
|
|
4
|
-
* Database provider that passes every operation straight through to a wrapped `source` provider.
|
|
4
|
+
* Database provider that passes every core operation straight through to a wrapped `source` provider.
|
|
5
5
|
*
|
|
6
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
|
-
* -
|
|
7
|
+
* - Only the core operations delegate to `source` — derived reads (`DBProvider.requireItem()`, `DBProvider.getFirst()`, `DBProvider.requireFirst()`) and two-step query writes are inherited from `DBProvider`, so they route through this provider's own overridden methods and wrapper behaviour applies to everything they do. Wrappers that don't need per-item behaviour override the query writes to pass through to `source` directly.
|
|
8
8
|
* - Exposes `source` and implements `Sourceable`, so wrapped providers can be discovered with `getSource()` / `requireSource()`.
|
|
9
9
|
*
|
|
10
10
|
* @see https://shelving.cc/db/ThroughDBProvider
|
|
11
11
|
*/
|
|
12
|
-
export class ThroughDBProvider {
|
|
12
|
+
export class ThroughDBProvider extends DBProvider {
|
|
13
13
|
/**
|
|
14
14
|
* The wrapped source provider that every operation is delegated to.
|
|
15
15
|
*
|
|
@@ -17,14 +17,12 @@ export class ThroughDBProvider {
|
|
|
17
17
|
*/
|
|
18
18
|
source;
|
|
19
19
|
constructor(source) {
|
|
20
|
+
super();
|
|
20
21
|
this.source = source;
|
|
21
22
|
}
|
|
22
23
|
getItem(collection, id) {
|
|
23
24
|
return this.source.getItem(collection, id);
|
|
24
25
|
}
|
|
25
|
-
requireItem(collection, id) {
|
|
26
|
-
return this.source.requireItem(collection, id);
|
|
27
|
-
}
|
|
28
26
|
getItemSequence(collection, id) {
|
|
29
27
|
return this.source.getItemSequence(collection, id);
|
|
30
28
|
}
|
|
@@ -40,6 +38,7 @@ export class ThroughDBProvider {
|
|
|
40
38
|
deleteItem(collection, id) {
|
|
41
39
|
return this.source.deleteItem(collection, id);
|
|
42
40
|
}
|
|
41
|
+
/** Delegates to `source` so its native counting is kept (the base implementation would fetch the items and count them). */
|
|
43
42
|
countQuery(collection, query) {
|
|
44
43
|
return this.source.countQuery(collection, query);
|
|
45
44
|
}
|
|
@@ -49,32 +48,6 @@ export class ThroughDBProvider {
|
|
|
49
48
|
getQuerySequence(collection, query) {
|
|
50
49
|
return this.source.getQuerySequence(collection, query);
|
|
51
50
|
}
|
|
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)));
|
|
71
|
-
}
|
|
72
|
-
getFirst(collection, query) {
|
|
73
|
-
return this.source.getFirst(collection, query);
|
|
74
|
-
}
|
|
75
|
-
requireFirst(collection, query) {
|
|
76
|
-
return this.source.requireFirst(collection, query);
|
|
77
|
-
}
|
|
78
51
|
// Run the transaction against the wrapped `source` provider, keeping this provider's behaviour inside the transaction.
|
|
79
52
|
transact(callback) {
|
|
80
53
|
return this.source.transact(transaction => callback(this.cloneWith(transaction)));
|
|
@@ -85,6 +58,7 @@ export class ThroughDBProvider {
|
|
|
85
58
|
}
|
|
86
59
|
// Implement `AsyncDisposable`
|
|
87
60
|
async [Symbol.asyncDispose]() {
|
|
88
|
-
await awaitDispose(this.source
|
|
61
|
+
await awaitDispose(this.source, // Dispose the source API provider.
|
|
62
|
+
super[Symbol.asyncDispose]());
|
|
89
63
|
}
|
|
90
64
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { Data } from "../../util/data.js";
|
|
2
|
+
import type { Identifier, Item } from "../../util/item.js";
|
|
3
|
+
import type { Updates } from "../../util/update.js";
|
|
4
|
+
import type { Collection } from "../collection/Collection.js";
|
|
5
|
+
import { RecordingDBProvider } from "./RecordingDBProvider.js";
|
|
6
|
+
/**
|
|
7
|
+
* Database provider that records every operation and can undo its own writes.
|
|
8
|
+
*
|
|
9
|
+
* - Extends `RecordingDBProvider`, additionally reading each item before the first write that touches it — so the log always contains every touched item's original state.
|
|
10
|
+
* - Call `undo()` to restore the wrapped provider to the state the log first observed, e.g. rolling back optimistic local updates after a failed server call.
|
|
11
|
+
* - The extra read is skipped when the log already establishes the item's state (an earlier read observed it, or an earlier add created it) — over a local `MemoryDBProvider` the reads are effectively free anyway.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* const local = new UndoDBProvider(memory);
|
|
15
|
+
* await runServiceLogic(local); // Applies locally right away.
|
|
16
|
+
* try {
|
|
17
|
+
* await api.push(local.writes); // Send the writes to the server.
|
|
18
|
+
* } catch {
|
|
19
|
+
* await local.undo(); // Server failed — restore the local copy.
|
|
20
|
+
* }
|
|
21
|
+
*
|
|
22
|
+
* @see https://shelving.cc/db/UndoDBProvider
|
|
23
|
+
*/
|
|
24
|
+
export declare class UndoDBProvider<I extends Identifier, T extends Data> extends RecordingDBProvider<I, T> {
|
|
25
|
+
/** Whether the log already establishes the original state of an item (an earlier read observed it, or an earlier add created it). */
|
|
26
|
+
protected _isEstablished(collection: Collection<string, I, T>, id: I): boolean;
|
|
27
|
+
/** Read the item first (recording its original state) if the log doesn't already establish it. */
|
|
28
|
+
setItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, data: TT): Promise<void>;
|
|
29
|
+
/** Read the item first (recording its original state) if the log doesn't already establish it. */
|
|
30
|
+
updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
|
|
31
|
+
/** Read the item first (recording its original state) if the log doesn't already establish it. */
|
|
32
|
+
deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* Restore the wrapped provider to the state the log first observed, undoing this provider's writes.
|
|
35
|
+
*
|
|
36
|
+
* - Applies the state-establishing operations in reverse order, so the earliest observed state of each item wins: reads restore the item that was observed (or delete it when the read confirmed it absent), and adds delete (the item did not exist before).
|
|
37
|
+
* - Restores touched items unconditionally, so concurrent writes made to those items since the recording are overwritten.
|
|
38
|
+
* - Writes directly to `source` without recording, so the log still describes the original operations afterwards.
|
|
39
|
+
*
|
|
40
|
+
* @example await provider.undo();
|
|
41
|
+
* @see https://shelving.cc/db/UndoDBProvider/undo
|
|
42
|
+
*/
|
|
43
|
+
undo(): Promise<void>;
|
|
44
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { RecordingDBProvider } from "./RecordingDBProvider.js";
|
|
2
|
+
/**
|
|
3
|
+
* Database provider that records every operation and can undo its own writes.
|
|
4
|
+
*
|
|
5
|
+
* - Extends `RecordingDBProvider`, additionally reading each item before the first write that touches it — so the log always contains every touched item's original state.
|
|
6
|
+
* - Call `undo()` to restore the wrapped provider to the state the log first observed, e.g. rolling back optimistic local updates after a failed server call.
|
|
7
|
+
* - The extra read is skipped when the log already establishes the item's state (an earlier read observed it, or an earlier add created it) — over a local `MemoryDBProvider` the reads are effectively free anyway.
|
|
8
|
+
*
|
|
9
|
+
* @example
|
|
10
|
+
* const local = new UndoDBProvider(memory);
|
|
11
|
+
* await runServiceLogic(local); // Applies locally right away.
|
|
12
|
+
* try {
|
|
13
|
+
* await api.push(local.writes); // Send the writes to the server.
|
|
14
|
+
* } catch {
|
|
15
|
+
* await local.undo(); // Server failed — restore the local copy.
|
|
16
|
+
* }
|
|
17
|
+
*
|
|
18
|
+
* @see https://shelving.cc/db/UndoDBProvider
|
|
19
|
+
*/
|
|
20
|
+
export class UndoDBProvider extends RecordingDBProvider {
|
|
21
|
+
/** Whether the log already establishes the original state of an item (an earlier read observed it, or an earlier add created it). */
|
|
22
|
+
_isEstablished(collection, id) {
|
|
23
|
+
return this._operations.some(operation => operation.collection === collection && operation.id === id && (operation.action === "get" || operation.action === "add"));
|
|
24
|
+
}
|
|
25
|
+
/** Read the item first (recording its original state) if the log doesn't already establish it. */
|
|
26
|
+
async setItem(collection, id, data) {
|
|
27
|
+
if (!this._isEstablished(collection, id))
|
|
28
|
+
await this.getItem(collection, id);
|
|
29
|
+
await super.setItem(collection, id, data);
|
|
30
|
+
}
|
|
31
|
+
/** Read the item first (recording its original state) if the log doesn't already establish it. */
|
|
32
|
+
async updateItem(collection, id, updates) {
|
|
33
|
+
if (!this._isEstablished(collection, id))
|
|
34
|
+
await this.getItem(collection, id);
|
|
35
|
+
await super.updateItem(collection, id, updates);
|
|
36
|
+
}
|
|
37
|
+
/** Read the item first (recording its original state) if the log doesn't already establish it. */
|
|
38
|
+
async deleteItem(collection, id) {
|
|
39
|
+
if (!this._isEstablished(collection, id))
|
|
40
|
+
await this.getItem(collection, id);
|
|
41
|
+
await super.deleteItem(collection, id);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Restore the wrapped provider to the state the log first observed, undoing this provider's writes.
|
|
45
|
+
*
|
|
46
|
+
* - Applies the state-establishing operations in reverse order, so the earliest observed state of each item wins: reads restore the item that was observed (or delete it when the read confirmed it absent), and adds delete (the item did not exist before).
|
|
47
|
+
* - Restores touched items unconditionally, so concurrent writes made to those items since the recording are overwritten.
|
|
48
|
+
* - Writes directly to `source` without recording, so the log still describes the original operations afterwards.
|
|
49
|
+
*
|
|
50
|
+
* @example await provider.undo();
|
|
51
|
+
* @see https://shelving.cc/db/UndoDBProvider/undo
|
|
52
|
+
*/
|
|
53
|
+
async undo() {
|
|
54
|
+
// `as` cast needed: the log stores `data` loosely as `unknown`.
|
|
55
|
+
for (const { action, collection, id, data } of this._operations.toReversed()) {
|
|
56
|
+
if (action === "get" && data)
|
|
57
|
+
await this.source.setItem(collection, id, data);
|
|
58
|
+
else if (action === "get" || action === "add")
|
|
59
|
+
await this.source.deleteItem(collection, id);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
package/package.json
CHANGED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
import type { MutableArray } from "../../util/array.js";
|
|
2
|
-
import type { Data } from "../../util/data.js";
|
|
3
|
-
import type { Identifier, Item } from "../../util/item.js";
|
|
4
|
-
import type { Updates } from "../../util/update.js";
|
|
5
|
-
import type { Collection } from "../collection/Collection.js";
|
|
6
|
-
import type { DBProvider } from "./DBProvider.js";
|
|
7
|
-
import { ThroughDBProvider } from "./ThroughDBProvider.js";
|
|
8
|
-
/**
|
|
9
|
-
* Structured log entry recording a single database write performed through a `ChangesDBProvider`.
|
|
10
|
-
*
|
|
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.
|
|
12
|
-
*
|
|
13
|
-
* @see https://shelving.cc/db/DBChange
|
|
14
|
-
*/
|
|
15
|
-
export type DBChange<I extends Identifier, T extends Data = Data> = {
|
|
16
|
-
readonly action: "add" | "set" | "update" | "delete";
|
|
17
|
-
readonly collection: Collection<string, I, T>;
|
|
18
|
-
readonly id: I;
|
|
19
|
-
readonly data?: unknown;
|
|
20
|
-
readonly updates?: unknown;
|
|
21
|
-
};
|
|
22
|
-
/**
|
|
23
|
-
* Database provider that records every write it performs to its `changes` log.
|
|
24
|
-
*
|
|
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
|
-
* - Useful for building audit logging, change feeds, or assertions in tests; reads are passed straight through and not logged.
|
|
29
|
-
*
|
|
30
|
-
* @see https://shelving.cc/db/ChangesDBProvider
|
|
31
|
-
*/
|
|
32
|
-
export declare class ChangesDBProvider<I extends Identifier, T extends Data> extends ThroughDBProvider<I, T> {
|
|
33
|
-
/**
|
|
34
|
-
* The log of writes performed through this provider, in the order they happened.
|
|
35
|
-
*
|
|
36
|
-
* @see https://shelving.cc/db/ChangesDBProvider/changes
|
|
37
|
-
*/
|
|
38
|
-
get changes(): ReadonlyArray<DBChange<I, T>>;
|
|
39
|
-
readonly _changes: MutableArray<DBChange<I, T>>;
|
|
40
|
-
/** Log an `"add"` change after writing. */
|
|
41
|
-
addItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, data: TT): Promise<II>;
|
|
42
|
-
/** Log a `"set"` change after writing. */
|
|
43
|
-
setItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, data: TT): Promise<void>;
|
|
44
|
-
/** Log an `"update"` change after writing. */
|
|
45
|
-
updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
|
|
46
|
-
/** Log a `"delete"` change after writing. */
|
|
47
|
-
deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): 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 — and the commit mechanism for `MemoryDBProvider.transact()`.
|
|
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>;
|
|
60
|
-
cloneWith(source: DBProvider<I, T>): this;
|
|
61
|
-
transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
|
|
62
|
-
}
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { ThroughDBProvider } from "./ThroughDBProvider.js";
|
|
2
|
-
/**
|
|
3
|
-
* Database provider that records every write it performs to its `changes` log.
|
|
4
|
-
*
|
|
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()`.
|
|
8
|
-
* - Useful for building audit logging, change feeds, or assertions in tests; reads are passed straight through and not logged.
|
|
9
|
-
*
|
|
10
|
-
* @see https://shelving.cc/db/ChangesDBProvider
|
|
11
|
-
*/
|
|
12
|
-
export class ChangesDBProvider extends ThroughDBProvider {
|
|
13
|
-
/**
|
|
14
|
-
* The log of writes performed through this provider, in the order they happened.
|
|
15
|
-
*
|
|
16
|
-
* @see https://shelving.cc/db/ChangesDBProvider/changes
|
|
17
|
-
*/
|
|
18
|
-
get changes() {
|
|
19
|
-
return this._changes;
|
|
20
|
-
}
|
|
21
|
-
_changes = [];
|
|
22
|
-
/** Log an `"add"` change after writing. */
|
|
23
|
-
async addItem(collection, data) {
|
|
24
|
-
const id = await super.addItem(collection, data);
|
|
25
|
-
this._changes.push({ action: "add", collection, id, data });
|
|
26
|
-
return id;
|
|
27
|
-
}
|
|
28
|
-
/** Log a `"set"` change after writing. */
|
|
29
|
-
async setItem(collection, id, data) {
|
|
30
|
-
await super.setItem(collection, id, data);
|
|
31
|
-
this._changes.push({ action: "set", collection, id, data });
|
|
32
|
-
}
|
|
33
|
-
/** Log an `"update"` change after writing. */
|
|
34
|
-
async updateItem(collection, id, updates) {
|
|
35
|
-
await super.updateItem(collection, id, updates);
|
|
36
|
-
this._changes.push({ action: "update", collection, id, updates });
|
|
37
|
-
}
|
|
38
|
-
/** Log a `"delete"` change after writing. */
|
|
39
|
-
async deleteItem(collection, id) {
|
|
40
|
-
await super.deleteItem(collection, id);
|
|
41
|
-
this._changes.push({ action: "delete", collection, id });
|
|
42
|
-
}
|
|
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 — and the commit mechanism for `MemoryDBProvider.transact()`.
|
|
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
|
-
}
|
|
64
|
-
}
|
|
65
|
-
// Override so that transaction copies get their own log.
|
|
66
|
-
cloneWith(source) {
|
|
67
|
-
const clone = super.cloneWith(source);
|
|
68
|
-
Object.defineProperty(clone, "_changes", { value: [], enumerable: false });
|
|
69
|
-
return clone;
|
|
70
|
-
}
|
|
71
|
-
// Override to log the transaction's writes after it commits — a failed transaction logs nothing.
|
|
72
|
-
// The merge must happen after `source.transact()` resolves: backends may retry the callback or fail the commit itself, and only the committed attempt's writes belong in the log.
|
|
73
|
-
async transact(callback) {
|
|
74
|
-
let transaction;
|
|
75
|
-
const result = await this.source.transact(provider => callback((transaction = this.cloneWith(provider))));
|
|
76
|
-
if (transaction)
|
|
77
|
-
this._changes.push(...transaction.changes);
|
|
78
|
-
return result;
|
|
79
|
-
}
|
|
80
|
-
}
|