shelving 1.277.0 → 1.279.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.
@@ -0,0 +1,33 @@
1
+ import { SQL } from "bun";
2
+ import type { Collection } from "../db/collection/Collection.js";
3
+ import { PostgresProvider, SQLFragment } from "../db/index.js";
4
+ import type { DBProvider } from "../db/provider/DBProvider.js";
5
+ import type { ImmutableArray } from "../util/array.js";
6
+ import type { Data } from "../util/data.js";
7
+ import type { Identifier, Item } from "../util/item.js";
8
+ import type { Query } from "../util/query.js";
9
+ /**
10
+ * PostgreSQL database provider backed by Bun's built-in `Bun.SQL` driver.
11
+ *
12
+ * Implements the `PostgresProvider` SQL abstraction by executing tagged-template queries against a `Bun.SQL` connection.
13
+ * - Identifiers are escaped through `Bun.SQL`'s own `sql()` helper rather than naive string quoting, which is more secure.
14
+ * - Supports transactions via `transact()` — the callback runs in a `SERIALIZABLE` Postgres transaction, and contention aborts are retried automatically.
15
+ * - Requires the `bun` peer dependency and a running Bun environment.
16
+ *
17
+ * @see https://shelving.cc/bun/BunPostgresProvider
18
+ */
19
+ export declare class BunPostgresProvider<I extends Identifier = Identifier, T extends Data = Data> extends PostgresProvider<I, T> {
20
+ private _sql;
21
+ constructor(sql: SQL);
22
+ /** Composes via `SQLFragment` (which flattens embedded fragments at construction), since `Bun.SQL` would otherwise bind them as `$n` parameters. */
23
+ exec<X extends Data>(strings: TemplateStringsArray, ...values: ImmutableArray<unknown>): Promise<ImmutableArray<X>>;
24
+ /** Escapes the identifier via `Bun.SQL`'s first-class `sql()` wrapping rather than manual quoting, which is more secure. */
25
+ sqlIdentifier(name: string): SQLFragment;
26
+ /** Coerces the count to a number, since `Bun.SQL` returns Postgres's 64-bit `COUNT(*)` as a string. */
27
+ countQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<number>;
28
+ /**
29
+ * Runs the callback in a `SERIALIZABLE` Postgres transaction via `Bun.SQL`'s `begin()` — resolving commits, throwing rolls back and rethrows.
30
+ * - Retries the whole callback (up to 5 attempts, with jittered exponential backoff) when Postgres aborts it for contention (serialization failure or deadlock), so the callback must have no side effects other than through its provider.
31
+ */
32
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
33
+ }
@@ -0,0 +1,79 @@
1
+ import { SQL } from "bun";
2
+ import { PostgresProvider, SQLFragment } from "../db/index.js";
3
+ import { UnsupportedError } from "../error/UnsupportedError.js";
4
+ import { getDelay } from "../util/async.js";
5
+ import { getRandom } from "../util/random.js";
6
+ // Constants.
7
+ const TRANSACTION_ATTEMPTS = 5;
8
+ const RETRYABLE_SQLSTATES = ["40001", "40P01"]; // Serialization failure and deadlock.
9
+ /**
10
+ * PostgreSQL database provider backed by Bun's built-in `Bun.SQL` driver.
11
+ *
12
+ * Implements the `PostgresProvider` SQL abstraction by executing tagged-template queries against a `Bun.SQL` connection.
13
+ * - Identifiers are escaped through `Bun.SQL`'s own `sql()` helper rather than naive string quoting, which is more secure.
14
+ * - Supports transactions via `transact()` — the callback runs in a `SERIALIZABLE` Postgres transaction, and contention aborts are retried automatically.
15
+ * - Requires the `bun` peer dependency and a running Bun environment.
16
+ *
17
+ * @see https://shelving.cc/bun/BunPostgresProvider
18
+ */
19
+ export class BunPostgresProvider extends PostgresProvider {
20
+ _sql;
21
+ constructor(sql) {
22
+ super();
23
+ this._sql = sql;
24
+ }
25
+ /** Composes via `SQLFragment` (which flattens embedded fragments at construction), since `Bun.SQL` would otherwise bind them as `$n` parameters. */
26
+ exec(strings, ...values) {
27
+ const flat = new SQLFragment(strings, values);
28
+ return this._sql(_getTemplateStrings(flat.strings), ...flat.values);
29
+ }
30
+ /** Escapes the identifier via `Bun.SQL`'s first-class `sql()` wrapping rather than manual quoting, which is more secure. */
31
+ sqlIdentifier(name) {
32
+ return this.sql `${this._sql(name)}`;
33
+ }
34
+ /** Coerces the count to a number, since `Bun.SQL` returns Postgres's 64-bit `COUNT(*)` as a string. */
35
+ async countQuery(collection, query) {
36
+ return Number.parseInt((await super.countQuery(collection, query)).toString(), 10);
37
+ }
38
+ /**
39
+ * Runs the callback in a `SERIALIZABLE` Postgres transaction via `Bun.SQL`'s `begin()` — resolving commits, throwing rolls back and rethrows.
40
+ * - Retries the whole callback (up to 5 attempts, with jittered exponential backoff) when Postgres aborts it for contention (serialization failure or deadlock), so the callback must have no side effects other than through its provider.
41
+ */
42
+ async transact(callback) {
43
+ let aborted;
44
+ for (let attempt = 0; attempt < TRANSACTION_ATTEMPTS; attempt++) {
45
+ // Back off with jitter before each retry so contending transactions de-synchronise instead of re-aborting each other in lockstep.
46
+ if (attempt)
47
+ await getDelay(getRandom(0, 100 * 2 ** attempt));
48
+ try {
49
+ return await this._sql.begin("isolation level serializable", tx => callback(new _BunPostgresTransaction(tx)));
50
+ }
51
+ catch (thrown) {
52
+ if (!_isRetryableError(thrown))
53
+ throw thrown;
54
+ aborted = thrown; // Retry the transaction after contention.
55
+ }
56
+ }
57
+ throw aborted;
58
+ }
59
+ }
60
+ /** Transaction-scoped provider for `BunPostgresProvider.transact()` — every query runs on the transaction's reserved connection. */
61
+ class _BunPostgresTransaction extends BunPostgresProvider {
62
+ /** Not supported inside a transaction — always throws `UnsupportedError`. */
63
+ transact(callback) {
64
+ throw new UnsupportedError("BunPostgresProvider does not support nested transactions", {
65
+ provider: this,
66
+ received: callback,
67
+ caller: this.transact,
68
+ });
69
+ }
70
+ }
71
+ /** Is a thrown value a Postgres contention abort that a fresh transaction attempt may resolve? */
72
+ function _isRetryableError(thrown) {
73
+ return thrown instanceof SQL.PostgresError && RETRYABLE_SQLSTATES.includes(thrown.errno ?? thrown.code);
74
+ }
75
+ /** Convert a strings array into the `TemplateStringsArray` shape `Bun.SQL` expects. */
76
+ function _getTemplateStrings(strings) {
77
+ const raw = [...strings];
78
+ return Object.assign(raw, { raw });
79
+ }
package/bun/index.d.ts CHANGED
@@ -1 +1 @@
1
- export * from "./BunPostgreSQLProvider.js";
1
+ export * from "./BunPostgresProvider.js";
package/bun/index.js CHANGED
@@ -1 +1 @@
1
- export * from "./BunPostgreSQLProvider.js";
1
+ export * from "./BunPostgresProvider.js";
package/db/index.d.ts CHANGED
@@ -2,7 +2,7 @@ export * from "./cache/CollectionCache.js";
2
2
  export * from "./cache/DBCache.js";
3
3
  export * from "./collection/Collection.js";
4
4
  export * from "./migrate/DBMigrator.js";
5
- export * from "./migrate/PostgreSQLMigrator.js";
5
+ 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";
@@ -11,7 +11,7 @@ export * from "./provider/DBProvider.js";
11
11
  export * from "./provider/DebugDBProvider.js";
12
12
  export * from "./provider/MemoryDBProvider.js";
13
13
  export * from "./provider/MockDBProvider.js";
14
- export * from "./provider/PostgreSQLProvider.js";
14
+ export * from "./provider/PostgresProvider.js";
15
15
  export * from "./provider/SQLiteProvider.js";
16
16
  export * from "./provider/SQLProvider.js";
17
17
  export * from "./provider/StorageDBProvider.js";
package/db/index.js CHANGED
@@ -2,7 +2,7 @@ export * from "./cache/CollectionCache.js";
2
2
  export * from "./cache/DBCache.js";
3
3
  export * from "./collection/Collection.js";
4
4
  export * from "./migrate/DBMigrator.js";
5
- export * from "./migrate/PostgreSQLMigrator.js";
5
+ 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";
@@ -11,7 +11,7 @@ export * from "./provider/DBProvider.js";
11
11
  export * from "./provider/DebugDBProvider.js";
12
12
  export * from "./provider/MemoryDBProvider.js";
13
13
  export * from "./provider/MockDBProvider.js";
14
- export * from "./provider/PostgreSQLProvider.js";
14
+ export * from "./provider/PostgresProvider.js";
15
15
  export * from "./provider/SQLiteProvider.js";
16
16
  export * from "./provider/SQLProvider.js";
17
17
  export * from "./provider/StorageDBProvider.js";
@@ -16,9 +16,9 @@ type PostgreSQLColumnRow = {
16
16
  /**
17
17
  * PostgreSQL migrator that inspects the live schema via `pg_catalog` tables to diff and migrate columns.
18
18
  *
19
- * @see https://shelving.cc/db/PostgreSQLMigrator
19
+ * @see https://shelving.cc/db/PostgresMigrator
20
20
  */
21
- export declare class PostgreSQLMigrator<T extends SQLProvider = SQLProvider> extends SQLMigrator<T> {
21
+ export declare class PostgresMigrator<T extends SQLProvider = SQLProvider> extends SQLMigrator<T> {
22
22
  protected getTables(): Promise<readonly string[]>;
23
23
  protected getTable(name: string): Promise<SQLTable | undefined>;
24
24
  protected getCreateTableSuffix<TData extends Data>(_collection: Collection<string, Identifier, TData>): string;
@@ -18,9 +18,9 @@ const COMPATIBLE_STRING_TYPES = ["character varying", "varchar", "text", "char",
18
18
  /**
19
19
  * PostgreSQL migrator that inspects the live schema via `pg_catalog` tables to diff and migrate columns.
20
20
  *
21
- * @see https://shelving.cc/db/PostgreSQLMigrator
21
+ * @see https://shelving.cc/db/PostgresMigrator
22
22
  */
23
- export class PostgreSQLMigrator extends SQLMigrator {
23
+ export class PostgresMigrator extends SQLMigrator {
24
24
  async getTables() {
25
25
  const rows = await this.provider.exec `
26
26
  SELECT c.relname AS ${this.provider.sqlIdentifier("name")}
@@ -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
  }
@@ -7,15 +7,15 @@ import { type SQLFragment, SQLProvider } from "./SQLProvider.js";
7
7
  /**
8
8
  * Abstract PostgreSQL provider with JSONB function support for nested keys, array containment, and array mutations.
9
9
  *
10
- * @see https://shelving.cc/db/PostgreSQLProvider
10
+ * @see https://shelving.cc/db/PostgresProvider
11
11
  */
12
- export declare abstract class PostgreSQLProvider<I extends Identifier = Identifier, T extends Data = Data> extends SQLProvider<I, T> {
12
+ export declare abstract class PostgresProvider<I extends Identifier = Identifier, T extends Data = Data> extends SQLProvider<I, T> {
13
13
  /** Get the Postgres JSONB path for the nested segments of a key, e.g. `{"b","c"}`. */
14
14
  private sqlPath;
15
15
  /** Extract via the Postgres `#>>` JSONB operator for nested keys, e.g. `"a" #>> {"b"}`. */
16
16
  sqlExtract(key: Segments): SQLFragment;
17
17
  /** Add Postgres JSONB support for nested keys and `with` / `omit` array mutations. */
18
18
  sqlUpdate(update: Update): SQLFragment;
19
- /** Add Postgres JSONB support for `contains` filters and deeply-nested queries. */
19
+ /** Add Postgres JSONB support for `contains` filters, and boolean literals for empty `in` / `out` filters. */
20
20
  sqlFilter(filter: QueryFilter): SQLFragment;
21
21
  }
@@ -2,9 +2,9 @@ import { SQLProvider } from "./SQLProvider.js";
2
2
  /**
3
3
  * Abstract PostgreSQL provider with JSONB function support for nested keys, array containment, and array mutations.
4
4
  *
5
- * @see https://shelving.cc/db/PostgreSQLProvider
5
+ * @see https://shelving.cc/db/PostgresProvider
6
6
  */
7
- export class PostgreSQLProvider extends SQLProvider {
7
+ export class PostgresProvider extends SQLProvider {
8
8
  /** Get the Postgres JSONB path for the nested segments of a key, e.g. `{"b","c"}`. */
9
9
  sqlPath(key) {
10
10
  return this.sqlConcat(key.slice(1).map(k => this.sqlIdentifier(k)), ",", "{", "}");
@@ -65,12 +65,17 @@ export class PostgreSQLProvider extends SQLProvider {
65
65
  }
66
66
  return super.sqlUpdate(update);
67
67
  }
68
- /** Add Postgres JSONB support for `contains` filters and deeply-nested queries. */
68
+ /** Add Postgres JSONB support for `contains` filters, and boolean literals for empty `in` / `out` filters. */
69
69
  sqlFilter(filter) {
70
70
  const { key, operator, value } = filter;
71
71
  // Implement `contains` filters.
72
72
  if (operator === "contains")
73
73
  return this.sql `${this.sqlExtract(key)} @> ${[value]}`;
74
+ // Postgres `WHERE` requires a boolean, so empty `in` / `out` filters can't use the base class's `0` / `1` integer literals.
75
+ if (operator === "in" && !value.length)
76
+ return this.sql `FALSE`;
77
+ if (operator === "out" && !value.length)
78
+ return this.sql `TRUE`;
74
79
  return super.sqlFilter(filter);
75
80
  }
76
81
  }
@@ -8,11 +8,16 @@ import type { Collection } from "../collection/Collection.js";
8
8
  import { DBProvider } from "./DBProvider.js";
9
9
  /**
10
10
  * SQL fragment made from template strings plus embedded expressions, ready to be composed into a query.
11
+ *
12
+ * - Flattens eagerly: `SQLFragment` values are spliced inline at construction, so `values` only ever contains bindable parameters (or driver-specific identifier tokens) — never other fragments.
13
+ * - Concrete providers can therefore pass `strings` / `values` straight to their driver, and detect fragments with a plain `instanceof` check.
14
+ *
11
15
  * @see https://shelving.cc/db/SQLFragment
12
16
  */
13
- export interface SQLFragment {
17
+ export declare class SQLFragment {
14
18
  readonly strings: ImmutableArray<string>;
15
19
  readonly values: ImmutableArray<unknown>;
20
+ constructor(strings: ImmutableArray<string>, values: ImmutableArray<unknown>);
16
21
  }
17
22
  /**
18
23
  * Abstract database provider that implements CRUD and query operations by generating and executing SQL.
@@ -41,6 +46,7 @@ export declare abstract class SQLProvider<I extends Identifier = Identifier, T e
41
46
  setItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, data: TT): Promise<void>;
42
47
  updateItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II, updates: Updates<Item<II, TT>>): Promise<void>;
43
48
  deleteItem<II extends I, TT extends T>(collection: Collection<string, II, TT>, id: II): Promise<void>;
49
+ /** Counts via a subquery so a `$limit` in the query caps the counted rows rather than the (single) result row. */
44
50
  countQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<number>;
45
51
  getQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query?: Query<Item<II, TT>>): Promise<Items<II, TT>>;
46
52
  /** Unsupported by SQL providers — always throws `UnsupportedError`. */
@@ -3,6 +3,39 @@ import { UnsupportedError } from "../../error/UnsupportedError.js";
3
3
  import { getQueryFilters, getQueryLimit, getQueryOrders } from "../../util/query.js";
4
4
  import { getUpdates } from "../../util/update.js";
5
5
  import { DBProvider } from "./DBProvider.js";
6
+ /**
7
+ * SQL fragment made from template strings plus embedded expressions, ready to be composed into a query.
8
+ *
9
+ * - Flattens eagerly: `SQLFragment` values are spliced inline at construction, so `values` only ever contains bindable parameters (or driver-specific identifier tokens) — never other fragments.
10
+ * - Concrete providers can therefore pass `strings` / `values` straight to their driver, and detect fragments with a plain `instanceof` check.
11
+ *
12
+ * @see https://shelving.cc/db/SQLFragment
13
+ */
14
+ export class SQLFragment {
15
+ strings;
16
+ values;
17
+ constructor(strings, values) {
18
+ const outStrings = [strings[0] ?? ""];
19
+ const outValues = [];
20
+ for (const [i, value] of values.entries()) {
21
+ if (value instanceof SQLFragment) {
22
+ // Splice the (already flat) fragment inline.
23
+ outStrings[outStrings.length - 1] += value.strings[0] ?? "";
24
+ for (let n = 0; n < value.values.length; n++) {
25
+ outValues.push(value.values[n]);
26
+ outStrings.push(value.strings[n + 1] ?? "");
27
+ }
28
+ }
29
+ else {
30
+ outValues.push(value);
31
+ outStrings.push("");
32
+ }
33
+ outStrings[outStrings.length - 1] += strings[i + 1] ?? "";
34
+ }
35
+ this.strings = outStrings;
36
+ this.values = outValues;
37
+ }
38
+ }
6
39
  /**
7
40
  * Abstract database provider that implements CRUD and query operations by generating and executing SQL.
8
41
  *
@@ -52,10 +85,13 @@ export class SQLProvider extends DBProvider {
52
85
  async deleteItem(collection, id) {
53
86
  await this.exec `DELETE FROM ${this.sqlIdentifier(collection.name)} WHERE ${this.sqlIdentifier("id")} = ${id}`;
54
87
  }
88
+ /** Counts via a subquery so a `$limit` in the query caps the counted rows rather than the (single) result row. */
55
89
  async countQuery(collection, query) {
56
90
  const rows = await this.exec `
57
- SELECT COUNT(*) AS "count" FROM ${this.sqlIdentifier(collection.name)}
58
- ${query ? this.sqlClauses(query) : this.sql ``}
91
+ SELECT COUNT(*) AS "count" FROM (
92
+ SELECT 1 FROM ${this.sqlIdentifier(collection.name)}
93
+ ${query ? this.sqlClauses(query) : this.sql ``}
94
+ ) AS ${this.sqlIdentifier("items")}
59
95
  `;
60
96
  return rows[0]?.count ?? 0;
61
97
  }
@@ -87,7 +123,7 @@ export class SQLProvider extends DBProvider {
87
123
  * @see https://shelving.cc/db/SQLProvider/sql
88
124
  */
89
125
  sql(strings, ...values) {
90
- return { strings, values };
126
+ return new SQLFragment(strings, values);
91
127
  }
92
128
  /**
93
129
  * Define an SQL fragment for an escaped identifier, e.g. `"myTable"`.
@@ -97,7 +133,7 @@ export class SQLProvider extends DBProvider {
97
133
  * @see https://shelving.cc/db/SQLProvider/sqlIdentifier
98
134
  */
99
135
  sqlIdentifier(name) {
100
- return { strings: [_escapeIdentifier(name)], values: [] };
136
+ return new SQLFragment([_escapeIdentifier(name)], []);
101
137
  }
102
138
  /**
103
139
  * Define an SQL fragment that extracts a value at a key for comparison, e.g. `"a" #>> {"b","c"}` in Postgres.
@@ -125,7 +161,7 @@ export class SQLProvider extends DBProvider {
125
161
  */
126
162
  sqlConcat(values, separator = ", ", before = "", after = "") {
127
163
  const strings = [before, ...new Array(Math.max(0, values.length - 1)).fill(separator), after];
128
- return { strings, values };
164
+ return new SQLFragment(strings, values);
129
165
  }
130
166
  /**
131
167
  * Define an SQL fragment for setting a list of values, e.g. `"a" = 1, "b" = 2`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.277.0",
3
+ "version": "1.279.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -68,7 +68,8 @@
68
68
  "fix": "bun run --sequential fix:*",
69
69
  "fix:0:lint": "biome check --write .",
70
70
  "fix:1:style": "stylelint \"modules/**/*.css\" --fix",
71
- "test:firebase": "bunx firebase-tools emulators:exec --only firestore --project shelving-test \"bun test ./modules/firebase\"",
71
+ "firebase": "bunx firebase-tools emulators:exec --only firestore --project shelving-test \"bun test ./modules/firebase\"",
72
+ "postgres": "bun ./scripts/postgres.ts",
72
73
  "docs:build": "bun ./docs/build.tsx",
73
74
  "docs:start": "bun ./docs/start.tsx",
74
75
  "build": "bun run --sequential build:*",
@@ -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 () => {
@@ -1,21 +0,0 @@
1
- import type { SQL } from "bun";
2
- import { PostgreSQLProvider, type SQLFragment } from "../db/index.js";
3
- import type { ImmutableArray } from "../util/array.js";
4
- import type { Data } from "../util/data.js";
5
- import type { Identifier } from "../util/item.js";
6
- /**
7
- * PostgreSQL database provider backed by Bun's built-in `Bun.SQL` driver.
8
- *
9
- * Implements the `PostgreSQLProvider` SQL abstraction by executing tagged-template queries against a `Bun.SQL` connection.
10
- * - Identifiers are escaped through `Bun.SQL`'s own `sql()` helper rather than naive string quoting, which is more secure.
11
- * - Requires the `bun` peer dependency and a running Bun environment.
12
- *
13
- * @see https://shelving.cc/bun/BunPostgreSQLProvider
14
- */
15
- export declare class BunPostgreSQLProvider<I extends Identifier = Identifier, T extends Data = Data> extends PostgreSQLProvider<I, T> {
16
- private _sql;
17
- constructor(sql: SQL);
18
- exec<X extends Data>(strings: TemplateStringsArray, ...values: ImmutableArray<unknown>): Promise<ImmutableArray<X>>;
19
- /** Escapes the identifier via `Bun.SQL`'s first-class `sql()` wrapping rather than manual quoting, which is more secure. */
20
- sqlIdentifier(name: string): SQLFragment;
21
- }
@@ -1,24 +0,0 @@
1
- import { PostgreSQLProvider } from "../db/index.js";
2
- /**
3
- * PostgreSQL database provider backed by Bun's built-in `Bun.SQL` driver.
4
- *
5
- * Implements the `PostgreSQLProvider` SQL abstraction by executing tagged-template queries against a `Bun.SQL` connection.
6
- * - Identifiers are escaped through `Bun.SQL`'s own `sql()` helper rather than naive string quoting, which is more secure.
7
- * - Requires the `bun` peer dependency and a running Bun environment.
8
- *
9
- * @see https://shelving.cc/bun/BunPostgreSQLProvider
10
- */
11
- export class BunPostgreSQLProvider extends PostgreSQLProvider {
12
- _sql;
13
- constructor(sql) {
14
- super();
15
- this._sql = sql;
16
- }
17
- exec(strings, ...values) {
18
- return this._sql(strings, ...values);
19
- }
20
- /** Escapes the identifier via `Bun.SQL`'s first-class `sql()` wrapping rather than manual quoting, which is more secure. */
21
- sqlIdentifier(name) {
22
- return this.sql `${this._sql(name)}`;
23
- }
24
- }