shelving 1.273.1 → 1.275.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.
@@ -4,6 +4,7 @@ import type { Identifier, Item } from "../../util/item.js";
4
4
  import type { Query } from "../../util/query.js";
5
5
  import type { Updates } from "../../util/update.js";
6
6
  import type { Collection } from "../collection/Collection.js";
7
+ import type { DBProvider } from "./DBProvider.js";
7
8
  import { ThroughDBProvider } from "./ThroughDBProvider.js";
8
9
  /**
9
10
  * Structured log entry recording a single database write performed through a `ChangesDBProvider`.
@@ -50,4 +51,6 @@ export declare class ChangesDBProvider<I extends Identifier, T extends Data> ext
50
51
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
51
52
  /** Log a `"delete"` change after writing. */
52
53
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
54
+ cloneWith(source: DBProvider<I, T>): this;
55
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
53
56
  }
@@ -53,4 +53,19 @@ export class ChangesDBProvider extends ThroughDBProvider {
53
53
  await super.deleteQuery(collection, query);
54
54
  this._changes.push({ action: "delete", collection: collection.name, query });
55
55
  }
56
+ // Override so that transaction copies get their own log.
57
+ cloneWith(source) {
58
+ const clone = super.cloneWith(source);
59
+ Object.defineProperty(clone, "_changes", { value: [], enumerable: false });
60
+ return clone;
61
+ }
62
+ // Override to log the transaction's writes after it commits — a failed transaction logs nothing.
63
+ // 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.
64
+ async transact(callback) {
65
+ let transaction;
66
+ const result = await this.source.transact(provider => callback((transaction = this.cloneWith(provider))));
67
+ if (transaction)
68
+ this._changes.push(...transaction.changes);
69
+ return result;
70
+ }
56
71
  }
@@ -167,5 +167,22 @@ export declare abstract class DBProvider<I extends Identifier = Identifier, T ex
167
167
  * @see https://shelving.cc/db/DBProvider/requireFirst
168
168
  */
169
169
  requireFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<Item<II, TT>>;
170
+ /**
171
+ * Run a callback as a single atomic transaction — every write made through the callback's provider is committed together, or not at all.
172
+ *
173
+ * - The callback receives a transaction-scoped `DBProvider`; reads and writes made through it belong to the transaction.
174
+ * - Reads see a consistent snapshot of the data from before the transaction, and do not see the transaction's own uncommitted writes.
175
+ * - If the callback throws, nothing is committed and the error is rethrown.
176
+ * - 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.
177
+ * - Inside a transaction, realtime sequences and nested `transact()` calls throw `UnsupportedError`.
178
+ * - Not every provider supports transactions — the base implementation throws `UnsupportedError`.
179
+ *
180
+ * @param callback Function that performs the transaction's reads and writes through the provider it receives.
181
+ * @returns The value returned by the callback.
182
+ * @throws `UnsupportedError` if this provider does not support transactions.
183
+ * @example await provider.transact(async db => void (await db.updateItem(users, 123, { logins: { sum: 1 } })));
184
+ * @see https://shelving.cc/db/DBProvider/transact
185
+ */
186
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
170
187
  [Symbol.asyncDispose](): Promise<void>;
171
188
  }
@@ -1,4 +1,5 @@
1
1
  import { RequiredError } from "../../error/RequiredError.js";
2
+ import { UnsupportedError } from "../../error/UnsupportedError.js";
2
3
  import { countArray, getFirst } from "../../util/array.js";
3
4
  import { awaitDispose } from "../../util/dispose.js";
4
5
  /**
@@ -81,6 +82,29 @@ export class DBProvider {
81
82
  });
82
83
  return first;
83
84
  }
85
+ /**
86
+ * Run a callback as a single atomic transaction — every write made through the callback's provider is committed together, or not at all.
87
+ *
88
+ * - The callback receives a transaction-scoped `DBProvider`; reads and writes made through it belong to the transaction.
89
+ * - Reads see a consistent snapshot of the data from before the transaction, and do not see the transaction's own uncommitted writes.
90
+ * - If the callback throws, nothing is committed and the error is rethrown.
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`.
93
+ * - Not every provider supports transactions — the base implementation throws `UnsupportedError`.
94
+ *
95
+ * @param callback Function that performs the transaction's reads and writes through the provider it receives.
96
+ * @returns The value returned by the callback.
97
+ * @throws `UnsupportedError` if this provider does not support transactions.
98
+ * @example await provider.transact(async db => void (await db.updateItem(users, 123, { logins: { sum: 1 } })));
99
+ * @see https://shelving.cc/db/DBProvider/transact
100
+ */
101
+ transact(callback) {
102
+ throw new UnsupportedError(`${this.constructor.name} does not support transactions`, {
103
+ provider: this,
104
+ received: callback,
105
+ caller: this.transact,
106
+ });
107
+ }
84
108
  // Implement `AsyncDisposable`
85
109
  async [Symbol.asyncDispose]() {
86
110
  await awaitDispose(
@@ -3,6 +3,7 @@ import type { Identifier, Item, Items, ItemsSequence, OptionalItem, OptionalItem
3
3
  import type { Query } from "../../util/query.js";
4
4
  import type { Updates } from "../../util/update.js";
5
5
  import type { Collection } from "../collection/Collection.js";
6
+ import type { DBProvider } from "./DBProvider.js";
6
7
  import { ThroughDBProvider } from "./ThroughDBProvider.js";
7
8
  /**
8
9
  * Database provider that logs every operation it performs to the console for debugging.
@@ -29,4 +30,6 @@ export declare class DebugDBProvider<I extends Identifier, T extends Data> exten
29
30
  setQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, data: TT): Promise<void>;
30
31
  updateQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>, updates: Updates<TT>): Promise<void>;
31
32
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
33
+ /** Adds TRANSACT logging around the base behaviour, which re-wraps so operations inside the transaction are logged too. */
34
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
32
35
  }
@@ -153,4 +153,17 @@ export class DebugDBProvider extends ThroughDBProvider {
153
153
  throw reason;
154
154
  }
155
155
  }
156
+ /** Adds TRANSACT logging around the base behaviour, which re-wraps so operations inside the transaction are logged too. */
157
+ async transact(callback) {
158
+ try {
159
+ console.debug(`${ANSI_RIGHT} TRANSACT`);
160
+ const result = await super.transact(callback);
161
+ console.debug(`${ANSI_SUCCESS} TRANSACT`);
162
+ return result;
163
+ }
164
+ catch (reason) {
165
+ console.error(`${ANSI_FAILURE} TRANSACT`, reason);
166
+ throw reason;
167
+ }
168
+ }
156
169
  }
@@ -36,5 +36,8 @@ export declare class ThroughDBProvider<I extends Identifier, T extends Data> imp
36
36
  deleteQuery<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<void>;
37
37
  getFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<OptionalItem<II, TT>>;
38
38
  requireFirst<II extends I, TT extends T>(collection: Collection<string, II, TT>, query: Query<Item<II, TT>>): Promise<Item<II, TT>>;
39
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
40
+ /** Clone this provider with different `source`. */
41
+ cloneWith(source: DBProvider<I, T>): this;
39
42
  [Symbol.asyncDispose](): Promise<void>;
40
43
  }
@@ -62,6 +62,14 @@ export class ThroughDBProvider {
62
62
  requireFirst(collection, query) {
63
63
  return this.source.requireFirst(collection, query);
64
64
  }
65
+ // Run the transaction against the wrapped `source` provider, keeping this provider's behaviour inside the transaction.
66
+ transact(callback) {
67
+ return this.source.transact(transaction => callback(this.cloneWith(transaction)));
68
+ }
69
+ /** Clone this provider with different `source`. */
70
+ cloneWith(source) {
71
+ return Object.create(this, { source: { value: source, enumerable: true } });
72
+ }
65
73
  // Implement `AsyncDisposable`
66
74
  async [Symbol.asyncDispose]() {
67
75
  await awaitDispose(this.source);
@@ -10,6 +10,7 @@ import { ThroughDBProvider } from "./ThroughDBProvider.js";
10
10
  * - Wraps a `source` provider (which may have any type, because validation guarantees the type) and runs every value through the relevant `Collection` schema before writing and after reading.
11
11
  * - Written data is validated against the collection's data schema; read data is validated against the item schema, so trusted, correctly-typed values reach the rest of the app.
12
12
  * - Validation failures here are program-state errors, so they throw a typed `ValueError` rather than a raw validation `string`.
13
+ * - Applies inside `transact()` too — the transaction provider the callback receives validates its reads and writes the same way.
13
14
  *
14
15
  * @see https://shelving.cc/db/ValidationDBProvider
15
16
  */
@@ -8,6 +8,7 @@ import { ThroughDBProvider } from "./ThroughDBProvider.js";
8
8
  * - Wraps a `source` provider (which may have any type, because validation guarantees the type) and runs every value through the relevant `Collection` schema before writing and after reading.
9
9
  * - Written data is validated against the collection's data schema; read data is validated against the item schema, so trusted, correctly-typed values reach the rest of the app.
10
10
  * - Validation failures here are program-state errors, so they throw a typed `ValueError` rather than a raw validation `string`.
11
+ * - Applies inside `transact()` too — the transaction provider the callback receives validates its reads and writes the same way.
11
12
  *
12
13
  * @see https://shelving.cc/db/ValidationDBProvider
13
14
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shelving",
3
- "version": "1.273.1",
3
+ "version": "1.275.0",
4
4
  "author": "Dave Houlbrooke <dave@shax.com>",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,15 @@
1
+ import type { DBProvider } from "../db/provider/DBProvider.js";
2
+ import { MemoryDBProvider } from "../db/provider/MemoryDBProvider.js";
3
+ import type { Data } from "../util/data.js";
4
+ import type { Identifier } from "../util/item.js";
5
+ /**
6
+ * In-memory provider for testing wrapping providers' `transact()` — runs the callback directly against itself.
7
+ *
8
+ * - No atomicity or rollback: writes apply immediately and are kept even if the callback throws.
9
+ * - Use it as the `source` of a wrapping provider to test how the wrapper behaves inside transactions, not to test transaction semantics themselves.
10
+ *
11
+ * @see https://shelving.cc/test/TransactionTestDBProvider
12
+ */
13
+ export declare class TransactionTestDBProvider<I extends Identifier = Identifier, T extends Data = Data> extends MemoryDBProvider<I, T> {
14
+ transact<X>(callback: (provider: DBProvider<I, T>) => Promise<X>): Promise<X>;
15
+ }
@@ -0,0 +1,14 @@
1
+ import { MemoryDBProvider } from "../db/provider/MemoryDBProvider.js";
2
+ /**
3
+ * In-memory provider for testing wrapping providers' `transact()` — runs the callback directly against itself.
4
+ *
5
+ * - No atomicity or rollback: writes apply immediately and are kept even if the callback throws.
6
+ * - Use it as the `source` of a wrapping provider to test how the wrapper behaves inside transactions, not to test transaction semantics themselves.
7
+ *
8
+ * @see https://shelving.cc/test/TransactionTestDBProvider
9
+ */
10
+ export class TransactionTestDBProvider extends MemoryDBProvider {
11
+ async transact(callback) {
12
+ return await callback(this);
13
+ }
14
+ }
package/test/basics.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Collection } from "../db/collection/Collection.js";
1
2
  import type { Item } from "../util/item.js";
2
3
  import type { ValidatorType } from "../util/validate.js";
3
4
  /**
@@ -97,3 +98,22 @@ export declare const basics: ReadonlyArray<BasicItem>;
97
98
  * @see https://shelving.cc/test/basic999
98
99
  */
99
100
  export declare const basic999: BasicData;
101
+ /**
102
+ * Collection of `BasicData` items keyed by string id, for testing database providers.
103
+ *
104
+ * @see https://shelving.cc/test/BASICS_COLLECTION
105
+ */
106
+ export declare const BASICS_COLLECTION: Collection<"basics", string, {
107
+ str: string;
108
+ num: number;
109
+ group: "a" | "b" | "c";
110
+ tags: import("../index.js").ImmutableArray<string>;
111
+ odd: boolean;
112
+ even: boolean;
113
+ sub: {
114
+ str: /*elided*/ any;
115
+ num: /*elided*/ any;
116
+ odd: /*elided*/ any;
117
+ even: /*elided*/ any;
118
+ };
119
+ }>;
package/test/basics.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { Collection } from "../db/collection/Collection.js";
1
2
  import { ARRAY } from "../schema/ArraySchema.js";
2
3
  import { BOOLEAN } from "../schema/BooleanSchema.js";
3
4
  import { CHOICE } from "../schema/ChoiceSchema.js";
@@ -173,3 +174,9 @@ export const basic999 = {
173
174
  tags: ["odd", "prime"],
174
175
  sub: { str: "zzz", num: 999, even: false, odd: true },
175
176
  };
177
+ /**
178
+ * Collection of `BasicData` items keyed by string id, for testing database providers.
179
+ *
180
+ * @see https://shelving.cc/test/BASICS_COLLECTION
181
+ */
182
+ export const BASICS_COLLECTION = new Collection("basics", STRING, BASIC_SCHEMA);
package/test/index.d.ts CHANGED
@@ -1,25 +1,5 @@
1
- import { Collection } from "../db/collection/Collection.js";
2
1
  export * from "./basics.js";
3
2
  export * from "./people.js";
3
+ export * from "./TransactionTestDBProvider.js";
4
+ export * from "./testDBProvider.js";
4
5
  export * from "./util.js";
5
- export declare const BASICS_COLLECTION: Collection<"basics", string, {
6
- str: string;
7
- num: number;
8
- group: "a" | "b" | "c";
9
- tags: import("../index.js").ImmutableArray<string>;
10
- odd: boolean;
11
- even: boolean;
12
- sub: {
13
- str: /*elided*/ any;
14
- num: /*elided*/ any;
15
- odd: /*elided*/ any;
16
- even: /*elided*/ any;
17
- };
18
- }>;
19
- export declare const PEOPLE_COLLECTION: Collection<"people", string, {
20
- name: {
21
- first: /*elided*/ any;
22
- last: /*elided*/ any;
23
- };
24
- birthday: string | null;
25
- }>;
package/test/index.js CHANGED
@@ -1,9 +1,5 @@
1
- import { Collection } from "../db/collection/Collection.js";
2
- import { STRING } from "../schema/StringSchema.js";
3
- import { BASIC_SCHEMA } from "./basics.js";
4
- import { PERSON_SCHEMA } from "./people.js";
5
1
  export * from "./basics.js";
6
2
  export * from "./people.js";
3
+ export * from "./TransactionTestDBProvider.js";
4
+ export * from "./testDBProvider.js";
7
5
  export * from "./util.js";
8
- export const BASICS_COLLECTION = new Collection("basics", STRING, BASIC_SCHEMA);
9
- export const PEOPLE_COLLECTION = new Collection("people", STRING, PERSON_SCHEMA);
package/test/people.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { Collection } from "../db/collection/Collection.js";
1
2
  import type { Item } from "../util/item.js";
2
3
  import type { ValidatorType } from "../util/validate.js";
3
4
  /**
@@ -60,3 +61,15 @@ export declare const person5: PersonItem;
60
61
  * @see https://shelving.cc/test/people
61
62
  */
62
63
  export declare const people: ReadonlyArray<PersonItem>;
64
+ /**
65
+ * Collection of `PersonData` items keyed by string id, for testing database providers.
66
+ *
67
+ * @see https://shelving.cc/test/PEOPLE_COLLECTION
68
+ */
69
+ export declare const PEOPLE_COLLECTION: Collection<"people", string, {
70
+ name: {
71
+ first: /*elided*/ any;
72
+ last: /*elided*/ any;
73
+ };
74
+ birthday: string | null;
75
+ }>;
package/test/people.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { Collection } from "../db/collection/Collection.js";
1
2
  import { DATA } from "../schema/DataSchema.js";
2
3
  import { NULLABLE_DATE } from "../schema/DateSchema.js";
3
- import { REQUIRED_STRING } from "../schema/StringSchema.js";
4
+ import { REQUIRED_STRING, STRING } from "../schema/StringSchema.js";
4
5
  /**
5
6
  * Schema for a test "person" fixture, with a nested `name` and a nullable `birthday`.
6
7
  *
@@ -46,3 +47,9 @@ export const person5 = { id: "person5", name: { first: "Terry", last: "Times" },
46
47
  * @see https://shelving.cc/test/people
47
48
  */
48
49
  export const people = [person1, person2, person3, person4, person5];
50
+ /**
51
+ * Collection of `PersonData` items keyed by string id, for testing database providers.
52
+ *
53
+ * @see https://shelving.cc/test/PEOPLE_COLLECTION
54
+ */
55
+ export const PEOPLE_COLLECTION = new Collection("people", STRING, PERSON_SCHEMA);
@@ -0,0 +1,22 @@
1
+ import type { DBProvider } from "../db/provider/DBProvider.js";
2
+ import type { Data } from "../util/data.js";
3
+ /** Options for `testDBProvider()`, declaring the capabilities of the provider under test. */
4
+ export interface TestDBProviderOptions {
5
+ /** Whether the provider supports realtime sequences — when `false`, sequences are asserted to throw `UnsupportedError`. @default true */
6
+ readonly realtime?: boolean;
7
+ /** Whether the provider supports `transact()` — when `false`, it is asserted to throw `UnsupportedError`. @default false */
8
+ readonly transactions?: boolean;
9
+ }
10
+ /**
11
+ * Register the universal `DBProvider` contract test suite against a provider, so every backend proves the same behaviour.
12
+ *
13
+ * - Calls `createProvider()` fresh for every test and wipes `BASICS_COLLECTION` and `PEOPLE_COLLECTION` first, so persistent backends (e.g. an emulator) start each test clean.
14
+ * - Declare the provider's capabilities via options — unsupported capabilities are asserted to throw `UnsupportedError` rather than skipped.
15
+ *
16
+ * @param name Name for the provider used in the `describe` block.
17
+ * @param createProvider Create (or return) the provider instance to test.
18
+ * @param options Capability flags for the provider under test.
19
+ * @example testDBProvider("MemoryDBProvider", () => new MemoryDBProvider<string>());
20
+ * @see https://shelving.cc/test/testDBProvider
21
+ */
22
+ export declare function testDBProvider(name: string, createProvider: () => DBProvider<string, Data> | PromiseLike<DBProvider<string, Data>>, { realtime, transactions }?: TestDBProviderOptions): void;
@@ -0,0 +1,272 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { RequiredError } from "../error/RequiredError.js";
3
+ import { UnsupportedError } from "../error/UnsupportedError.js";
4
+ import { runMicrotasks } from "../util/async.js";
5
+ import { runSequence } from "../util/sequence.js";
6
+ import { BASICS_COLLECTION, basic1, basic2, basic3, basic999, basics } from "./basics.js";
7
+ import { PEOPLE_COLLECTION, person1 } from "./people.js";
8
+ import { expectOrderedItems, expectUnorderedItems } from "./util.js";
9
+ /**
10
+ * Register the universal `DBProvider` contract test suite against a provider, so every backend proves the same behaviour.
11
+ *
12
+ * - Calls `createProvider()` fresh for every test and wipes `BASICS_COLLECTION` and `PEOPLE_COLLECTION` first, so persistent backends (e.g. an emulator) start each test clean.
13
+ * - Declare the provider's capabilities via options — unsupported capabilities are asserted to throw `UnsupportedError` rather than skipped.
14
+ *
15
+ * @param name Name for the provider used in the `describe` block.
16
+ * @param createProvider Create (or return) the provider instance to test.
17
+ * @param options Capability flags for the provider under test.
18
+ * @example testDBProvider("MemoryDBProvider", () => new MemoryDBProvider<string>());
19
+ * @see https://shelving.cc/test/testDBProvider
20
+ */
21
+ export function testDBProvider(name, createProvider, { realtime = true, transactions = false } = {}) {
22
+ // Create the provider and wipe both fixture collections so each test starts clean.
23
+ async function init() {
24
+ const provider = await createProvider();
25
+ await provider.deleteQuery(BASICS_COLLECTION, {});
26
+ await provider.deleteQuery(PEOPLE_COLLECTION, {});
27
+ return provider;
28
+ }
29
+ describe(`DBProvider contract: ${name}`, () => {
30
+ test("sets, gets, and deletes items", async () => {
31
+ const db = await init();
32
+ // Set and get.
33
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
34
+ await db.setItem(BASICS_COLLECTION, "basic2", basic2);
35
+ await db.setItem(PEOPLE_COLLECTION, "person1", person1);
36
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject(basic1);
37
+ expect(await db.getItem(BASICS_COLLECTION, "basic2")).toMatchObject(basic2);
38
+ expect(await db.getItem(BASICS_COLLECTION, "basicNone")).toBe(undefined);
39
+ expect(await db.getItem(PEOPLE_COLLECTION, "person1")).toMatchObject(person1);
40
+ // Overwrite.
41
+ await db.setItem(BASICS_COLLECTION, "basic1", { ...basic1, str: "NEW" });
42
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject({ ...basic1, str: "NEW" });
43
+ // Require.
44
+ expect(await db.requireItem(BASICS_COLLECTION, "basic2")).toMatchObject(basic2);
45
+ try {
46
+ await db.requireItem(BASICS_COLLECTION, "basicNone");
47
+ expect.unreachable();
48
+ }
49
+ catch (thrown) {
50
+ expect(thrown).toBeInstanceOf(RequiredError);
51
+ }
52
+ // Delete (including a missing item, which is a no-op).
53
+ await db.deleteItem(BASICS_COLLECTION, "basic1");
54
+ await db.deleteItem(BASICS_COLLECTION, "basicNone");
55
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toBe(undefined);
56
+ expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(1);
57
+ expect(await db.countQuery(PEOPLE_COLLECTION, {})).toBe(1);
58
+ });
59
+ test("adds items with generated ids", async () => {
60
+ const db = await init();
61
+ const id1 = await db.addItem(BASICS_COLLECTION, basic999);
62
+ const id2 = await db.addItem(BASICS_COLLECTION, basic999);
63
+ expect(typeof id1).toBe("string");
64
+ expect(typeof id2).toBe("string");
65
+ expect(id1).not.toBe(id2);
66
+ expect(await db.getItem(BASICS_COLLECTION, id1)).toMatchObject(basic999);
67
+ expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(2);
68
+ });
69
+ test("updates items with set, sum, and array updates", async () => {
70
+ const db = await init();
71
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
72
+ // Set and sum updates.
73
+ await db.updateItem(BASICS_COLLECTION, "basic1", { str: "NEW", "+=num": 100 });
74
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject({ ...basic1, str: "NEW", num: basic1.num + 100 });
75
+ // Array with/omit updates.
76
+ await db.updateItem(BASICS_COLLECTION, "basic1", { "+[]tags": "extra" });
77
+ expect((await db.requireItem(BASICS_COLLECTION, "basic1")).tags).toEqual([...basic1.tags, "extra"]);
78
+ await db.updateItem(BASICS_COLLECTION, "basic1", { "-[]tags": "extra" });
79
+ expect((await db.requireItem(BASICS_COLLECTION, "basic1")).tags).toEqual(basic1.tags);
80
+ });
81
+ test("gets queries with filters", async () => {
82
+ const db = await init();
83
+ for (const { id, ...data } of basics)
84
+ await db.setItem(BASICS_COLLECTION, id, data);
85
+ // Equality filters.
86
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { str: "aaa" }), ["basic1"]);
87
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { str: "NOPE" }), []);
88
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { num: 300 }), ["basic3"]);
89
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { group: "a" }), ["basic1", "basic2", "basic3"]);
90
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { group: "b" }), ["basic4", "basic5", "basic6"]);
91
+ // ArrayContains filters.
92
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "tags[]": "odd" }), ["basic1", "basic3", "basic5", "basic7", "basic9"]);
93
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "tags[]": "NOPE" }), []);
94
+ // In filters.
95
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { num: [200, 600, 900, 999999] }), ["basic2", "basic6", "basic9"]);
96
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { id: ["basic1", "basic5", "basicNone"] }), ["basic1", "basic5"]);
97
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { num: [] }), []);
98
+ // Range filters.
99
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "num<": 300 }), ["basic1", "basic2"]);
100
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "num<=": 300 }), ["basic1", "basic2", "basic3"]);
101
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "num>": 700 }), ["basic8", "basic9"]);
102
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "num>=": 700 }), ["basic7", "basic8", "basic9"]);
103
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { "str<": "ccc" }), ["basic1", "basic2"]);
104
+ });
105
+ test("gets queries with sorts and limits", async () => {
106
+ const db = await init();
107
+ for (const { id, ...data } of basics)
108
+ await db.setItem(BASICS_COLLECTION, id, data);
109
+ const keysAsc = ["basic1", "basic2", "basic3", "basic4", "basic5", "basic6", "basic7", "basic8", "basic9"];
110
+ const keysDesc = [...keysAsc].reverse();
111
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "id" }), keysAsc);
112
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "!id" }), keysDesc);
113
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "str" }), keysAsc);
114
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "!str" }), keysDesc);
115
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "num" }), keysAsc);
116
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "!num" }), keysDesc);
117
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "id", $limit: 2 }), ["basic1", "basic2"]);
118
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { $order: "!id", $limit: 1 }), ["basic9"]);
119
+ expectOrderedItems(await db.getQuery(BASICS_COLLECTION, { "tags[]": "prime", $order: "!id", $limit: 2 }), ["basic7", "basic5"]);
120
+ });
121
+ test("counts queries", async () => {
122
+ const db = await init();
123
+ for (const { id, ...data } of basics)
124
+ await db.setItem(BASICS_COLLECTION, id, data);
125
+ expect(await db.countQuery(BASICS_COLLECTION)).toBe(9);
126
+ expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(9);
127
+ expect(await db.countQuery(BASICS_COLLECTION, { group: "a" })).toBe(3);
128
+ expect(await db.countQuery(BASICS_COLLECTION, { str: "NOPE" })).toBe(0);
129
+ expect(await db.countQuery(BASICS_COLLECTION, { $limit: 4 })).toBe(4);
130
+ });
131
+ test("gets first items", async () => {
132
+ const db = await init();
133
+ for (const { id, ...data } of basics)
134
+ await db.setItem(BASICS_COLLECTION, id, data);
135
+ expect(await db.getFirst(BASICS_COLLECTION, { $order: "num" })).toMatchObject(basic1);
136
+ expect(await db.getFirst(BASICS_COLLECTION, { str: "NOPE", $order: "num" })).toBe(undefined);
137
+ expect(await db.requireFirst(BASICS_COLLECTION, { $order: "num" })).toMatchObject(basic1);
138
+ try {
139
+ await db.requireFirst(BASICS_COLLECTION, { str: "NOPE", $order: "num" });
140
+ expect.unreachable();
141
+ }
142
+ catch (thrown) {
143
+ expect(thrown).toBeInstanceOf(RequiredError);
144
+ }
145
+ });
146
+ test("sets, updates, and deletes queries", async () => {
147
+ const db = await init();
148
+ for (const { id, ...data } of basics)
149
+ await db.setItem(BASICS_COLLECTION, id, data);
150
+ // Set every matching item to the same data.
151
+ await db.setQuery(BASICS_COLLECTION, { group: "a" }, basic999);
152
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject(basic999);
153
+ expect(await db.getItem(BASICS_COLLECTION, "basic2")).toMatchObject(basic999);
154
+ expect(await db.getItem(BASICS_COLLECTION, "basic4")).toMatchObject({ id: "basic4", group: "b" }); // Non-matching items unchanged.
155
+ // Update every matching item.
156
+ await db.updateQuery(BASICS_COLLECTION, { group: "b" }, { str: "UPDATED" });
157
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { str: "UPDATED" }), ["basic4", "basic5", "basic6"]);
158
+ // Delete every matching item.
159
+ await db.deleteQuery(BASICS_COLLECTION, { group: "c" });
160
+ expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(6);
161
+ await db.deleteQuery(BASICS_COLLECTION, {});
162
+ expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(0);
163
+ });
164
+ if (realtime) {
165
+ test("subscribes to an item", async () => {
166
+ const db = await init();
167
+ const calls = [];
168
+ const stop = runSequence(db.getItemSequence(BASICS_COLLECTION, "basic1"), v => void calls.push(v));
169
+ await runMicrotasks();
170
+ expect(calls.length).toBe(1);
171
+ expect(calls[0]).toBe(undefined);
172
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
173
+ await runMicrotasks();
174
+ expect(calls.length).toBe(2);
175
+ expect(calls[1]).toMatchObject(basic1);
176
+ await db.deleteItem(BASICS_COLLECTION, "basic1");
177
+ await runMicrotasks();
178
+ expect(calls.length).toBe(3);
179
+ expect(calls[2]).toBe(undefined);
180
+ stop();
181
+ });
182
+ test("subscribes to a query", async () => {
183
+ const db = await init();
184
+ const calls = [];
185
+ const stop = runSequence(db.getQuerySequence(BASICS_COLLECTION, { $order: "id" }), v => void calls.push(v));
186
+ await runMicrotasks();
187
+ expectOrderedItems(calls[0] ?? [], []);
188
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
189
+ await runMicrotasks();
190
+ expectOrderedItems(calls[1] ?? [], ["basic1"]);
191
+ await db.setItem(BASICS_COLLECTION, "basic2", basic2);
192
+ await runMicrotasks();
193
+ expectOrderedItems(calls[2] ?? [], ["basic1", "basic2"]);
194
+ stop();
195
+ });
196
+ }
197
+ else {
198
+ test("sequences are not supported", async () => {
199
+ const db = await init();
200
+ expect(() => db.getItemSequence(BASICS_COLLECTION, "basic1")).toThrow(UnsupportedError);
201
+ expect(() => db.getQuerySequence(BASICS_COLLECTION, {})).toThrow(UnsupportedError);
202
+ });
203
+ }
204
+ if (transactions) {
205
+ test("transact(): commits reads and writes atomically", async () => {
206
+ const db = await init();
207
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
208
+ await db.setItem(BASICS_COLLECTION, "basic2", basic2);
209
+ const id = await db.transact(async (tx) => {
210
+ // Reads inside the transaction see committed items.
211
+ expect(await tx.getItem(BASICS_COLLECTION, "basic1")).toMatchObject(basic1);
212
+ expect(await tx.countQuery(BASICS_COLLECTION, {})).toBe(2);
213
+ expectUnorderedItems(await tx.getQuery(BASICS_COLLECTION, { group: "a" }), ["basic1", "basic2"]);
214
+ // Writes commit together when the callback resolves.
215
+ await tx.setItem(BASICS_COLLECTION, "basic3", basic3);
216
+ await tx.updateItem(BASICS_COLLECTION, "basic1", { str: "TX", "+=num": 1 });
217
+ await tx.deleteItem(BASICS_COLLECTION, "basic2");
218
+ return await tx.addItem(BASICS_COLLECTION, basic999);
219
+ });
220
+ expect(typeof id).toBe("string");
221
+ expect(await db.getItem(BASICS_COLLECTION, "basic3")).toMatchObject(basic3);
222
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject({ ...basic1, str: "TX", num: basic1.num + 1 });
223
+ expect(await db.getItem(BASICS_COLLECTION, "basic2")).toBe(undefined);
224
+ expect(await db.getItem(BASICS_COLLECTION, id)).toMatchObject(basic999);
225
+ });
226
+ test("transact(): commits nothing when the callback throws", async () => {
227
+ const db = await init();
228
+ await db.setItem(BASICS_COLLECTION, "basic1", basic1);
229
+ try {
230
+ await db.transact(async (tx) => {
231
+ await tx.setItem(BASICS_COLLECTION, "basic2", basic2);
232
+ await tx.deleteItem(BASICS_COLLECTION, "basic1");
233
+ throw new Error("nope");
234
+ });
235
+ expect.unreachable();
236
+ }
237
+ catch (thrown) {
238
+ expect(thrown).toBeInstanceOf(Error);
239
+ expect(thrown.message).toBe("nope");
240
+ }
241
+ expect(await db.getItem(BASICS_COLLECTION, "basic1")).toMatchObject(basic1);
242
+ expect(await db.getItem(BASICS_COLLECTION, "basic2")).toBe(undefined);
243
+ });
244
+ test("transact(): supports query writes", async () => {
245
+ const db = await init();
246
+ for (const { id, ...data } of basics)
247
+ await db.setItem(BASICS_COLLECTION, id, data);
248
+ await db.transact(async (tx) => {
249
+ await tx.updateQuery(BASICS_COLLECTION, { group: "a" }, { str: "TX" });
250
+ await tx.deleteQuery(BASICS_COLLECTION, { group: "c" });
251
+ });
252
+ expectUnorderedItems(await db.getQuery(BASICS_COLLECTION, { str: "TX" }), ["basic1", "basic2", "basic3"]);
253
+ expect(await db.countQuery(BASICS_COLLECTION, {})).toBe(6);
254
+ });
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);
262
+ });
263
+ });
264
+ }
265
+ else {
266
+ test("transactions are not supported", async () => {
267
+ const db = await init();
268
+ expect(() => db.transact(async () => undefined)).toThrow(UnsupportedError);
269
+ });
270
+ }
271
+ });
272
+ }