@bodil/bdb 0.1.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,200 @@
1
+ import "temporal-polyfill/global";
2
+ import "fake-indexeddb/auto";
3
+
4
+ import { time } from "@bodil/core";
5
+ import { sleep } from "@bodil/core/async";
6
+ import { Signal } from "@bodil/signal";
7
+ import { expect, expectTypeOf, test } from "vitest";
8
+
9
+ import { arrayIndex, compoundIndex, createTable, index, timeIndex } from ".";
10
+ import { IndexedDBBackend } from "./backend";
11
+ import type { IndexablesOf } from "./types";
12
+
13
+ test("indexable", () => {
14
+ type IndexableTest = {
15
+ key: string;
16
+ value: number;
17
+ stamp: Temporal.Instant;
18
+ object: { foo: string; bar: number };
19
+ valueOfObject: { foo: number; valueOf(): number };
20
+ goodArray: Array<string>;
21
+ tuple: [string, number];
22
+ badArray: Array<Element>;
23
+ };
24
+ expectTypeOf<IndexablesOf<IndexableTest>>().toEqualTypeOf<
25
+ "key" | "value" | "goodArray" | "tuple"
26
+ >();
27
+ });
28
+
29
+ test("memdb basics", () => {
30
+ type TestItem = {
31
+ id: string;
32
+ created: Temporal.Instant;
33
+ uri: string;
34
+ tags: Array<string>;
35
+ };
36
+
37
+ const table = createTable<TestItem>()(index<TestItem>()("id"))
38
+ .withIndex(timeIndex<TestItem>()("created"))
39
+ .withIndex(index<TestItem>()("uri"))
40
+ .withIndex(arrayIndex<TestItem>()("tags"))
41
+ .withIndex(compoundIndex<TestItem>()("id", "uri"));
42
+
43
+ const now = time.now();
44
+ const [welp, wolp, wulp, wilp] = [
45
+ {
46
+ id: "welp",
47
+ created: now.add(time.seconds(1)),
48
+ uri: "http://lol.com",
49
+ tags: ["bar", "foo"],
50
+ stamp: time.now(),
51
+ },
52
+ {
53
+ id: "wolp",
54
+ created: now,
55
+ uri: "https://rofl.com",
56
+ tags: ["foo"],
57
+ stamp: time.now(),
58
+ },
59
+ {
60
+ id: "wulp",
61
+ created: now.add(time.seconds(2)),
62
+ uri: "ftp://ftp.lol.gov",
63
+ tags: ["bar"],
64
+ stamp: time.now(),
65
+ },
66
+ {
67
+ id: "wilp",
68
+ created: now.add(time.seconds(1)),
69
+ uri: "http://altavista.com",
70
+ tags: [],
71
+ stamp: time.now(),
72
+ },
73
+ ];
74
+
75
+ table.add(welp, wolp, wulp, wilp);
76
+
77
+ // Lookup should return the exact same objects.
78
+ expect(table.get("welp")).equal(welp);
79
+ expect(table.get("wolp")).equal(wolp);
80
+ expect(table.get("wulp")).equal(wulp);
81
+ expect(table.get("wilp")).equal(wilp);
82
+
83
+ // The objects should be frozen when added to the database.
84
+ expect(() => {
85
+ welp.id = "walp";
86
+ }).throw(TypeError);
87
+
88
+ // Index equal query should return all relevant matches.
89
+ let query = table.where("created").equals(now.add(time.seconds(1)));
90
+ expect(Array.from(query)).deep.equal([welp, wilp]);
91
+
92
+ query = table.where("uri").equals("ftp://ftp.lol.gov");
93
+ expect(Array.from(query)).deep.equal([wulp]);
94
+
95
+ query = table.where("*tags").equals("foo");
96
+ expect(Array.from(query)).deep.equal([welp, wolp]);
97
+ query = table.where("*tags").equals("bar");
98
+ expect(Array.from(query)).deep.equal([welp, wulp]);
99
+
100
+ query = table.where("id&uri").equals(["welp", "http://lol.com"]);
101
+ expect(Array.from(query)).deep.equal([welp]);
102
+ query = table.where("id&uri").equals(["welp", "http://lol.org"]);
103
+ expect(Array.from(query)).empty;
104
+ query = table.where("id&uri").equals(["wolp", "https://rofl.com"]);
105
+ expect(Array.from(query)).deep.equal([wolp]);
106
+
107
+ // Query should correctly infer type of key.
108
+ const _checkCreatedQueryType = table.where("created").equals;
109
+ expectTypeOf<Parameters<typeof _checkCreatedQueryType>>().toEqualTypeOf<[Temporal.Instant]>();
110
+ const _checkTagsQueryType = table.where("*tags").equals;
111
+ expectTypeOf<Parameters<typeof _checkTagsQueryType>>().toEqualTypeOf<[string]>();
112
+ const _checkCompoundQueryType = table.where("id&uri").equals;
113
+ expectTypeOf<Parameters<typeof _checkCompoundQueryType>>().toEqualTypeOf<[[string, string]]>();
114
+ });
115
+
116
+ test("memdb index dupes", () => {
117
+ type Account = { id: string; order: number };
118
+ const accounts = createTable<Account>()(index<Account>()("id")).withIndex(
119
+ index<Account>()("order")
120
+ );
121
+
122
+ const account = { id: "test@test.com", order: 1 };
123
+ accounts.add({ ...account });
124
+ expect(accounts.orderBy("order").toArray()).length(1);
125
+ accounts.add({ ...account });
126
+ expect(accounts.orderBy("order").toArray()).length(1);
127
+ });
128
+
129
+ test("memdb signals", () => {
130
+ type Thing = { name: string; counter: number };
131
+ const things = createTable<Thing>()(index<Thing>()("name"));
132
+
133
+ type ThingMap = { id: number; name: string };
134
+ const thingMaps = createTable<ThingMap>()(index<ThingMap>()("id"));
135
+ thingMaps.add({ id: 1, name: "Mike" }, { id: 2, name: "Robert" });
136
+
137
+ const joe = things.signal("Joe");
138
+ expect(joe.value).toBeUndefined();
139
+
140
+ things.add({ name: "Joe", counter: 1 });
141
+ expect(joe.value).not.toBeUndefined();
142
+
143
+ const joeCounter = Signal.computed(() => joe.value?.counter);
144
+ expect(joeCounter.value).toEqual(1);
145
+
146
+ const mappedCounter = Signal.computed(() => {
147
+ const c = joe.value?.counter;
148
+ return c === undefined ? undefined : thingMaps.signal(c)?.value?.name;
149
+ });
150
+ expect(mappedCounter.value).toEqual("Mike");
151
+
152
+ things.add({ name: "Joe", counter: 2 });
153
+ expect(joeCounter.value).toEqual(2);
154
+ expect(mappedCounter.value).toEqual("Robert");
155
+
156
+ thingMaps.add({ id: 2, name: "Bjarne" });
157
+ expect(mappedCounter.value).toEqual("Bjarne");
158
+
159
+ things.delete("Joe");
160
+ expect(joeCounter.value).toBeUndefined();
161
+ expect(mappedCounter.value).toBeUndefined();
162
+ });
163
+
164
+ test("failed update shouldn't change anything", () => {
165
+ type Thing = { name: string; counter: number };
166
+ const things = createTable<Thing>()(index<Thing>()("name"));
167
+
168
+ things.add({ name: "Joe", counter: 321 });
169
+ expect(() =>
170
+ things.update("Joe", (item) => {
171
+ item.counter = 123;
172
+ throw new TypeError("welp!");
173
+ })
174
+ ).toThrowError(new TypeError("welp!"));
175
+ expect(things.get("Joe")?.counter).toEqual(321);
176
+ });
177
+
178
+ test("IndexedDB", async () => {
179
+ type Thing = { key: string; value: string };
180
+ {
181
+ const store = await IndexedDBBackend.open({ name: "test", version: 1 });
182
+ const things = createTable<Thing>()(index<Thing>()("key")).withStorage(store, "things");
183
+ await things.ready;
184
+ things.add(
185
+ { key: "Joe", value: "Armstrong" },
186
+ { key: "Robert", value: "Virding" },
187
+ { key: "Mike", value: "Williams" }
188
+ );
189
+ expect(things.get("Joe")).deep.equal({ key: "Joe", value: "Armstrong" });
190
+ expect(things.get("Bjarne")).toBeUndefined();
191
+ await sleep(1);
192
+ }
193
+ {
194
+ const store = await IndexedDBBackend.open({ name: "test", version: 1 });
195
+ const things = createTable<Thing>()(index<Thing>()("key")).withStorage(store, "things");
196
+ await things.ready;
197
+ expect(things.get("Joe")).deep.equal({ key: "Joe", value: "Armstrong" });
198
+ expect(things.get("Bjarne")).toBeUndefined();
199
+ }
200
+ });
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ import type { OrderFn } from "@bodil/core/order";
2
+ import BTree from "sorted-btree";
3
+
4
+ import { ArrayIndex, CompoundIndex, CustomIndex, PrimitiveIndex, type UnitIndex } from "./indices";
5
+ import { Table } from "./table";
6
+ import type { ArrayIndexablesOf, CustomIndexablesOf, PrimitiveIndexablesOf } from "./types";
7
+
8
+ export type {
9
+ Index,
10
+ ArrayIndex,
11
+ CompoundIndex,
12
+ CustomIndex,
13
+ PrimitiveIndex,
14
+ UnitIndex,
15
+ } from "./indices";
16
+ export type {
17
+ IndexablePrimitive,
18
+ ArrayIndexablesOf,
19
+ CustomIndexablesOf,
20
+ PrimitiveIndexablesOf,
21
+ } from "./types";
22
+ export type { Table, TableEvent } from "./table";
23
+ export type { IndexQuery, ArrayQuery, ChainQuery } from "./query";
24
+ export type { Broadcaster } from "./broadcast";
25
+ export type { IndexedDBBackendConfig, DatabaseBroadcast } from "./backend";
26
+
27
+ export { StorageBackend, IndexedDBBackend } from "./backend";
28
+
29
+ export function createTable<A extends object>(): <PI extends UnitIndex<A>>(
30
+ primaryIndex: PI,
31
+ ) => Table<A, PI, PI["record"]> {
32
+ return (primaryIndex) => new Table(primaryIndex);
33
+ }
34
+
35
+ export function index<A extends object>(): <I extends PrimitiveIndexablesOf<A>>(
36
+ key: I,
37
+ ) => PrimitiveIndex<A, I> {
38
+ return (key) => new PrimitiveIndex(key);
39
+ }
40
+
41
+ export function timeIndex<A extends object>(): <I extends CustomIndexablesOf<A, Temporal.Instant>>(
42
+ key: I,
43
+ ) => CustomIndex<A, Temporal.Instant, I> {
44
+ return (key) =>
45
+ new CustomIndex(
46
+ key,
47
+ () => new BTree(undefined, Temporal.Instant.compare as OrderFn<A[typeof key]>),
48
+ );
49
+ }
50
+
51
+ export function arrayIndex<A extends object>(): <
52
+ I extends ArrayIndexablesOf<A>,
53
+ L extends A[I] & Array<unknown>,
54
+ >(
55
+ key: I,
56
+ ) => ArrayIndex<A, I, L> {
57
+ return (key) => new ArrayIndex(key);
58
+ }
59
+
60
+ export function compoundIndex<A extends object>(): <
61
+ I extends PrimitiveIndexablesOf<A>,
62
+ J extends Exclude<PrimitiveIndexablesOf<A>, I>,
63
+ >(
64
+ leftKey: I,
65
+ rightKey: J,
66
+ ) => CompoundIndex<A, I, J> {
67
+ return (leftKey, rightKey) => new CompoundIndex(leftKey, rightKey);
68
+ }
69
+
70
+ export function customIndex<A extends object, T>(): <I extends CustomIndexablesOf<A, T>>(
71
+ key: I,
72
+ orderFn: OrderFn<T>,
73
+ ) => CustomIndex<A, T, I> {
74
+ return (key, orderFn: OrderFn<T>) =>
75
+ new CustomIndex(key, () => new BTree(undefined, orderFn as OrderFn<A[typeof key]>));
76
+ }
package/src/indices.ts ADDED
@@ -0,0 +1,108 @@
1
+ import type BTree from "sorted-btree";
2
+
3
+ import type { ArrayIndexablesOf, CustomIndexablesOf, PrimitiveIndexablesOf } from "./types";
4
+
5
+ export abstract class Index<A extends object> {
6
+ readonly name!: string;
7
+ readonly keyType!: unknown;
8
+ readonly record!: object;
9
+ abstract extractKeys(value: A): Array<typeof this.keyType>;
10
+ }
11
+
12
+ export abstract class UnitIndex<A extends object> extends Index<A> {
13
+ abstract extractKey(value: A): typeof this.keyType;
14
+ }
15
+
16
+ export class CustomIndex<A extends object, T, I extends CustomIndexablesOf<A, T>>
17
+ implements Index<A>, UnitIndex<A>
18
+ {
19
+ readonly keyType!: A[I];
20
+ readonly record!: { [K in I]: this };
21
+
22
+ constructor(
23
+ public readonly index: I,
24
+ public readonly makeIndex: () => BTree<A[I], Array<A>>,
25
+ ) {}
26
+
27
+ get name(): I {
28
+ return this.index;
29
+ }
30
+
31
+ extractKey(value: A): A[I] {
32
+ return value[this.index];
33
+ }
34
+
35
+ extractKeys(value: A): Array<A[I]> {
36
+ return [value[this.index]];
37
+ }
38
+ }
39
+
40
+ export class PrimitiveIndex<A extends object, I extends PrimitiveIndexablesOf<A>>
41
+ implements Index<A>, UnitIndex<A>
42
+ {
43
+ readonly keyType!: A[I];
44
+ readonly record!: { [K in I]: this };
45
+
46
+ constructor(public readonly index: I) {}
47
+
48
+ get name(): I {
49
+ return this.index;
50
+ }
51
+
52
+ extractKey(value: A): A[I] {
53
+ return value[this.index];
54
+ }
55
+
56
+ extractKeys(value: A): Array<A[I]> {
57
+ return [value[this.index]];
58
+ }
59
+ }
60
+
61
+ export class ArrayIndex<
62
+ A extends object,
63
+ I extends ArrayIndexablesOf<A>,
64
+ L extends A[I] & Array<unknown>,
65
+ > implements Index<A>
66
+ {
67
+ readonly index: I;
68
+ readonly name: `*${I}`;
69
+ readonly keyType!: L[number];
70
+ readonly record!: { [K in `*${I}`]: this };
71
+
72
+ constructor(index: I) {
73
+ this.index = index;
74
+ this.name = `*${index}`;
75
+ }
76
+
77
+ extractKeys(value: A): L {
78
+ return value[this.index] as L;
79
+ }
80
+ }
81
+
82
+ export class CompoundIndex<
83
+ A extends object,
84
+ I extends PrimitiveIndexablesOf<A>,
85
+ J extends Exclude<PrimitiveIndexablesOf<A>, I>,
86
+ >
87
+ implements Index<A>, UnitIndex<A>
88
+ {
89
+ readonly leftIndex: I;
90
+ readonly rightIndex: J;
91
+ readonly name: `${I}&${J}`;
92
+ readonly keyType!: [A[I], A[J]];
93
+ readonly record!: { [K in `${I}&${J}`]: this };
94
+
95
+ constructor(leftIndex: I, rightIndex: J) {
96
+ this.leftIndex = leftIndex;
97
+ this.rightIndex = rightIndex;
98
+ this.name = `${leftIndex}&${rightIndex}`;
99
+ }
100
+
101
+ extractKey(value: A): [A[I], A[J]] {
102
+ return [value[this.leftIndex], value[this.rightIndex]];
103
+ }
104
+
105
+ extractKeys(value: A): Array<[A[I], A[J]]> {
106
+ return [[value[this.leftIndex], value[this.rightIndex]]];
107
+ }
108
+ }
package/src/query.ts ADDED
@@ -0,0 +1,233 @@
1
+ import { assert } from "@bodil/core/assert";
2
+ import { Option } from "@bodil/opt";
3
+ import { Signal } from "@bodil/signal";
4
+ import type BTree from "sorted-btree";
5
+
6
+ import type { Index, UnitIndex } from "./indices";
7
+ import type { Table } from "./table";
8
+
9
+ export enum IteratorDirection {
10
+ Ascending = 0,
11
+ Descending = 1,
12
+ DescendingExclusive = 2,
13
+ }
14
+
15
+ function* indexIterator<A extends object, I extends Index<A>>(
16
+ table: BTree<I["keyType"], Array<A>>,
17
+ direction: IteratorDirection,
18
+ start?: I["keyType"],
19
+ ): Generator<Readonly<A>> {
20
+ if (table.isEmpty) {
21
+ return;
22
+ }
23
+ if (direction === IteratorDirection.Ascending) {
24
+ for (const items of table.values(start)) {
25
+ for (const item of items) {
26
+ yield item;
27
+ }
28
+ }
29
+ } else {
30
+ for (const entry of table.entriesReversed(
31
+ start,
32
+ undefined,
33
+ direction === IteratorDirection.DescendingExclusive,
34
+ )) {
35
+ for (const item of entry[1]) {
36
+ yield item;
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ function* primaryIndexIterator<A extends object, I extends UnitIndex<A>>(
43
+ table: BTree<I["keyType"], Readonly<A>>,
44
+ direction: IteratorDirection,
45
+ start?: I["keyType"],
46
+ ): Generator<Readonly<A>> {
47
+ if (table.isEmpty) {
48
+ return;
49
+ }
50
+ if (direction === IteratorDirection.Ascending) {
51
+ for (const item of table.values(start)) {
52
+ yield item;
53
+ }
54
+ } else {
55
+ for (const entry of table.entriesReversed(
56
+ start,
57
+ undefined,
58
+ direction === IteratorDirection.DescendingExclusive,
59
+ )) {
60
+ yield entry[1];
61
+ }
62
+ }
63
+ }
64
+
65
+ export abstract class Query<A extends object> implements Iterable<Readonly<A>> {
66
+ abstract [Symbol.iterator](): IterableIterator<Readonly<A>>;
67
+ abstract signal(): Signal.Computed<Array<Readonly<A>>>;
68
+
69
+ map<B>(mapFn: (item: Readonly<A>) => B): IterableIterator<B> {
70
+ return Iterator.from(this).map(mapFn);
71
+ }
72
+
73
+ forEach(action: (item: A) => void): void {
74
+ for (const item of this) {
75
+ action(item);
76
+ }
77
+ }
78
+
79
+ toArray(): Array<Readonly<A>> {
80
+ return Array.from(this);
81
+ }
82
+ }
83
+
84
+ export abstract class TableQuery<
85
+ A extends object,
86
+ PI extends UnitIndex<A>,
87
+ Ix extends object,
88
+ > extends Query<A> {
89
+ table: Table<A, PI, Ix>;
90
+
91
+ constructor(table: Table<A, PI, Ix>) {
92
+ super();
93
+ this.table = table;
94
+ }
95
+
96
+ signal(): Signal.Computed<Array<Readonly<A>>> {
97
+ return Signal.computed(() => {
98
+ this.table.changed.get();
99
+ return Array.from(this);
100
+ });
101
+ }
102
+
103
+ delete(): number {
104
+ return this.table.delete(...Array.from(this));
105
+ }
106
+
107
+ filter(predicate: (item: Readonly<A>) => boolean): ChainQuery<A, PI, Ix> {
108
+ return new ChainQuery(this.table, this, (iter) => Iterator.from(iter).filter(predicate));
109
+ }
110
+
111
+ limit(count: number): ChainQuery<A, PI, Ix> {
112
+ return new ChainQuery(this.table, this, (iter) => Iterator.from(iter).take(count));
113
+ }
114
+ }
115
+
116
+ export class IndexQuery<
117
+ A extends object,
118
+ PI extends UnitIndex<A>,
119
+ I extends Index<A>,
120
+ Ix extends object,
121
+ > extends TableQuery<A, PI, Ix> {
122
+ private readonly index: I;
123
+ private readonly indexTable?: BTree<I["keyType"], Array<A>>;
124
+ private readonly isPrimary: boolean;
125
+ private start?: I["keyType"];
126
+ private direction: IteratorDirection;
127
+
128
+ constructor(
129
+ table: Table<A, PI, Ix>,
130
+ indexKey: keyof Ix & string,
131
+ direction: IteratorDirection,
132
+ start?: I["keyType"],
133
+ ) {
134
+ super(table);
135
+ this.isPrimary = table.primaryIndex.name === indexKey;
136
+ if (this.isPrimary) {
137
+ this.index = table.primaryIndex as unknown as I;
138
+ } else {
139
+ this.index = (table.indices as any)[indexKey];
140
+ assert(this.index !== undefined, `Undefined index "${indexKey}"`);
141
+ this.indexTable = (table.indexTables as any)[indexKey];
142
+ }
143
+ this.direction = direction;
144
+ this.start = start;
145
+ }
146
+
147
+ [Symbol.iterator](): IterableIterator<Readonly<A>> {
148
+ return this.isPrimary
149
+ ? primaryIndexIterator(this.table.primary, this.direction, this.start)
150
+ : indexIterator(this.indexTable!, this.direction, this.start);
151
+ }
152
+
153
+ reverse(): this {
154
+ switch (this.direction) {
155
+ case IteratorDirection.Ascending:
156
+ this.direction = IteratorDirection.Descending;
157
+ return this;
158
+ case IteratorDirection.Descending:
159
+ this.direction = IteratorDirection.Ascending;
160
+ return this;
161
+ case IteratorDirection.DescendingExclusive:
162
+ throw new Error("not sure how to reverse IteratorDirection.DescendingExclusive");
163
+ }
164
+ }
165
+
166
+ equals(value: I["keyType"]): ArrayQuery<A, PI, Ix> {
167
+ if (this.isPrimary) {
168
+ const item = this.table.get(value);
169
+ return new ArrayQuery(this.table, item === undefined ? [] : [item]);
170
+ }
171
+ return new ArrayQuery(this.table, this.indexTable!.get(value) ?? []);
172
+ }
173
+
174
+ below(value: I["keyType"]): this {
175
+ this.start = value;
176
+ this.direction = IteratorDirection.DescendingExclusive;
177
+ return this;
178
+ }
179
+
180
+ max(): Option<I["keyType"]> {
181
+ return Option.from(
182
+ this.isPrimary ? this.table.primary.maxKey() : this.indexTable!.maxKey(),
183
+ );
184
+ }
185
+
186
+ min(): Option<I["keyType"]> {
187
+ return Option.from(
188
+ this.isPrimary ? this.table.primary.minKey() : this.indexTable!.minKey(),
189
+ );
190
+ }
191
+ }
192
+
193
+ export class ChainQuery<
194
+ A extends object,
195
+ PI extends UnitIndex<A>,
196
+ Ix extends object,
197
+ > extends TableQuery<A, PI, Ix> {
198
+ private readonly parent: Query<A>;
199
+ private readonly iterator: (
200
+ parentIterator: IterableIterator<Readonly<A>>,
201
+ ) => IterableIterator<Readonly<A>>;
202
+
203
+ constructor(
204
+ table: Table<A, PI, Ix>,
205
+ parent: Query<A>,
206
+ iterator: (parentIterator: IterableIterator<Readonly<A>>) => IterableIterator<Readonly<A>>,
207
+ ) {
208
+ super(table);
209
+ this.parent = parent;
210
+ this.iterator = iterator;
211
+ }
212
+
213
+ [Symbol.iterator](): IterableIterator<Readonly<A>> {
214
+ return this.iterator(this.parent[Symbol.iterator]());
215
+ }
216
+ }
217
+
218
+ export class ArrayQuery<
219
+ A extends object,
220
+ PI extends UnitIndex<A>,
221
+ Ix extends object,
222
+ > extends TableQuery<A, PI, Ix> {
223
+ private readonly values: Array<Readonly<A>>;
224
+
225
+ constructor(table: Table<A, PI, Ix>, values: Array<Readonly<A>>) {
226
+ super(table);
227
+ this.values = values;
228
+ }
229
+
230
+ [Symbol.iterator](): IterableIterator<Readonly<A>> {
231
+ return this.values[Symbol.iterator]();
232
+ }
233
+ }