@firtoz/idb-collections 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,65 @@
1
+ {
2
+ "name": "@firtoz/idb-collections",
3
+ "version": "0.2.0",
4
+ "description": "IndexedDB collection utilities for TanStack DB — key-value adapter, query optimization",
5
+ "main": "./src/index.ts",
6
+ "module": "./src/index.ts",
7
+ "types": "./src/index.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./src/index.ts",
11
+ "import": "./src/index.ts",
12
+ "require": "./src/index.ts"
13
+ }
14
+ },
15
+ "files": [
16
+ "src/**/*.ts",
17
+ "!src/**/*.test.ts",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "typecheck": "tsc --noEmit -p ./tsconfig.json",
22
+ "test": "bun test",
23
+ "lint": "biome check --write src",
24
+ "lint:ci": "biome ci src",
25
+ "format": "biome format src --write"
26
+ },
27
+ "keywords": [
28
+ "typescript",
29
+ "tanstack",
30
+ "tanstack-db",
31
+ "indexeddb",
32
+ "idb",
33
+ "collections"
34
+ ],
35
+ "author": "Firtina Ozbalikchi <firtoz@github.com>",
36
+ "license": "MIT",
37
+ "homepage": "https://github.com/firtoz/fullstack-toolkit#readme",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "https://github.com/firtoz/fullstack-toolkit.git",
41
+ "directory": "packages/idb-collections"
42
+ },
43
+ "bugs": {
44
+ "url": "https://github.com/firtoz/fullstack-toolkit/issues"
45
+ },
46
+ "engines": {
47
+ "node": ">=18.0.0"
48
+ },
49
+ "publishConfig": {
50
+ "access": "public"
51
+ },
52
+ "peerDependencies": {
53
+ "@standard-schema/spec": ">=1.1.0",
54
+ "@tanstack/db": ">=0.5.33"
55
+ },
56
+ "devDependencies": {
57
+ "@standard-schema/spec": "^1.1.0",
58
+ "@tanstack/db": "^0.5.33",
59
+ "bun-types": "^1.3.10",
60
+ "zod": "^4.3.6"
61
+ },
62
+ "dependencies": {
63
+ "@firtoz/db-helpers": "^2.0.0"
64
+ }
65
+ }
@@ -0,0 +1,102 @@
1
+ import type { IR } from "@tanstack/db";
2
+ import { extractSimpleComparisons } from "@tanstack/db";
3
+
4
+ /**
5
+ * Key range specification for index queries.
6
+ * Used by IndexedDB implementations to build IDBKeyRange objects.
7
+ */
8
+ export interface KeyRangeSpec {
9
+ type: "only" | "lowerBound" | "upperBound" | "bound";
10
+ value?: unknown;
11
+ lower?: unknown;
12
+ upper?: unknown;
13
+ lowerOpen?: boolean;
14
+ upperOpen?: boolean;
15
+ }
16
+
17
+ /**
18
+ * Attempts to extract a simple indexed query from an IR expression.
19
+ * Returns the field name and key range if the query can be optimized.
20
+ *
21
+ * IndexedDB indexes are much more limited than SQL WHERE clauses:
22
+ * - Only supports simple comparisons on a SINGLE indexed field
23
+ * - Supported operators: eq, gt, gte, lt, lte
24
+ * - Complex queries (AND, OR, NOT, multiple fields) fall back to in-memory filtering
25
+ */
26
+ export function tryExtractIndexedQuery(
27
+ expression: IR.BasicExpression,
28
+ indexes?: Record<string, string>,
29
+ debug?: boolean,
30
+ ): { fieldName: string; indexName: string; keyRange: KeyRangeSpec } | null {
31
+ if (!indexes) {
32
+ return null;
33
+ }
34
+
35
+ try {
36
+ const comparisons = extractSimpleComparisons(expression);
37
+
38
+ if (comparisons.length !== 1) {
39
+ return null;
40
+ }
41
+
42
+ const comparison = comparisons[0];
43
+ const fieldName = comparison.field.join(".");
44
+ const indexName = indexes[fieldName];
45
+
46
+ if (!indexName) {
47
+ return null;
48
+ }
49
+
50
+ let keyRange: KeyRangeSpec | null = null;
51
+
52
+ switch (comparison.operator) {
53
+ case "eq":
54
+ keyRange = { type: "only", value: comparison.value };
55
+ break;
56
+ case "gt":
57
+ keyRange = {
58
+ type: "lowerBound",
59
+ lower: comparison.value,
60
+ lowerOpen: true,
61
+ };
62
+ break;
63
+ case "gte":
64
+ keyRange = {
65
+ type: "lowerBound",
66
+ lower: comparison.value,
67
+ lowerOpen: false,
68
+ };
69
+ break;
70
+ case "lt":
71
+ keyRange = {
72
+ type: "upperBound",
73
+ upper: comparison.value,
74
+ upperOpen: true,
75
+ };
76
+ break;
77
+ case "lte":
78
+ keyRange = {
79
+ type: "upperBound",
80
+ upper: comparison.value,
81
+ upperOpen: false,
82
+ };
83
+ break;
84
+ default:
85
+ if (debug) {
86
+ console.warn(
87
+ `Skipping indexed query extraction for unsupported operator: ${comparison.operator}`,
88
+ );
89
+ }
90
+ return null;
91
+ }
92
+
93
+ if (!keyRange) {
94
+ return null;
95
+ }
96
+
97
+ return { fieldName, indexName, keyRange };
98
+ } catch (error) {
99
+ console.error("Error extracting indexed query", error, expression);
100
+ return null;
101
+ }
102
+ }
package/src/index.ts ADDED
@@ -0,0 +1,12 @@
1
+ export {
2
+ keyvalCollectionOptions,
3
+ createKeyValCollection,
4
+ type KeyValAdapter,
5
+ type KeyValCollectionConfig,
6
+ type KeyValCollection,
7
+ } from "./keyvalCollection";
8
+
9
+ export {
10
+ tryExtractIndexedQuery,
11
+ type KeyRangeSpec,
12
+ } from "./idb-query-utils";
@@ -0,0 +1,243 @@
1
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
2
+ import {
3
+ type Collection,
4
+ type CollectionConfig,
5
+ createCollection,
6
+ type InferSchemaInput,
7
+ type InferSchemaOutput,
8
+ type IR,
9
+ type SyncMode,
10
+ parseOrderByExpression,
11
+ } from "@tanstack/db";
12
+ import type { SyncMessage, CollectionUtils } from "@firtoz/db-helpers";
13
+ import { evaluateExpression } from "@firtoz/db-helpers";
14
+ import {
15
+ type GenericBaseSyncConfig,
16
+ type GenericSyncBackend,
17
+ type GenericSyncFunctionResult,
18
+ createGenericSyncFunction,
19
+ createGenericCollectionConfig,
20
+ } from "@firtoz/db-helpers";
21
+
22
+ /**
23
+ * Minimal key-value storage adapter.
24
+ * Compatible with idb-keyval (almost directly) and localforage (via thin wrapper).
25
+ */
26
+ export interface KeyValAdapter<T = unknown> {
27
+ get(key: string): Promise<T | null | undefined>;
28
+ set(key: string, value: T): Promise<void>;
29
+ del(key: string): Promise<void>;
30
+ entries(): Promise<[string, T][]>;
31
+ clear(): Promise<void>;
32
+ /** Optional batch set for performance. Falls back to sequential set() calls. */
33
+ setMany?(entries: [string, T][]): Promise<void>;
34
+ /** Optional batch delete for performance. Falls back to sequential del() calls. */
35
+ delMany?(keys: string[]): Promise<void>;
36
+ }
37
+
38
+ export interface KeyValCollectionConfig<TSchema extends StandardSchemaV1> {
39
+ schema: TSchema;
40
+ adapter: KeyValAdapter<InferSchemaOutput<TSchema>>;
41
+ /** Extracts the key from an item. Defaults to `(item) => item.id`. */
42
+ getKey?: (item: InferSchemaOutput<TSchema>) => string;
43
+ /** Promise that resolves when the adapter is ready. Defaults to resolved. */
44
+ readyPromise?: Promise<void>;
45
+ syncMode?: SyncMode;
46
+ debug?: boolean;
47
+ /** Called when a local mutation is persisted; use to broadcast to other peers/tabs. */
48
+ onBroadcast?: (
49
+ changes: SyncMessage<InferSchemaOutput<TSchema>, string | number>[],
50
+ ) => void;
51
+ }
52
+
53
+ type KeyValUtils<TItem> = CollectionUtils<TItem>;
54
+
55
+ function defaultGetKey<TSchema extends StandardSchemaV1>(
56
+ item: InferSchemaOutput<TSchema>,
57
+ ): string {
58
+ return (item as { id: string }).id;
59
+ }
60
+
61
+ export function keyvalCollectionOptions<TSchema extends StandardSchemaV1>(
62
+ config: KeyValCollectionConfig<TSchema>,
63
+ ): CollectionConfig<InferSchemaOutput<TSchema>, string, TSchema> & {
64
+ utils: KeyValUtils<InferSchemaOutput<TSchema>>;
65
+ schema: TSchema;
66
+ } {
67
+ type TItem = InferSchemaOutput<TSchema>;
68
+
69
+ const adapter = config.adapter;
70
+ const getKey = config.getKey ?? defaultGetKey<TSchema>;
71
+ const readyPromise = config.readyPromise ?? Promise.resolve();
72
+
73
+ const adapterSetMany = async (entries: [string, TItem][]) => {
74
+ if (adapter.setMany) {
75
+ await adapter.setMany(entries);
76
+ } else {
77
+ for (const [key, value] of entries) {
78
+ await adapter.set(key, value);
79
+ }
80
+ }
81
+ };
82
+
83
+ const adapterDelMany = async (keys: string[]) => {
84
+ if (adapter.delMany) {
85
+ await adapter.delMany(keys);
86
+ } else {
87
+ for (const key of keys) {
88
+ await adapter.del(key);
89
+ }
90
+ }
91
+ };
92
+
93
+ const backend: GenericSyncBackend<TItem> = {
94
+ initialLoad: async () => {
95
+ const allEntries = await adapter.entries();
96
+ return allEntries.map(([, value]) => value);
97
+ },
98
+
99
+ loadSubset: async (options) => {
100
+ const allEntries = await adapter.entries();
101
+ let items = allEntries.map(([, value]) => value);
102
+
103
+ let combinedWhere = options.where;
104
+ if (options.cursor?.whereFrom) {
105
+ if (combinedWhere) {
106
+ combinedWhere = {
107
+ type: "func",
108
+ name: "and",
109
+ args: [combinedWhere, options.cursor.whereFrom],
110
+ } as IR.Func;
111
+ } else {
112
+ combinedWhere = options.cursor.whereFrom;
113
+ }
114
+ }
115
+
116
+ if (combinedWhere) {
117
+ const whereExpression = combinedWhere;
118
+ items = items.filter((item) =>
119
+ evaluateExpression(whereExpression, item as Record<string, unknown>),
120
+ );
121
+ }
122
+
123
+ if (options.orderBy) {
124
+ const sorts = parseOrderByExpression(options.orderBy);
125
+ items.sort((a, b) => {
126
+ for (const sort of sorts) {
127
+ // biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
128
+ let aValue: any = a;
129
+ // biome-ignore lint/suspicious/noExplicitAny: Need any for dynamic field access
130
+ let bValue: any = b;
131
+ for (const fieldName of sort.field) {
132
+ aValue = aValue?.[fieldName];
133
+ bValue = bValue?.[fieldName];
134
+ }
135
+
136
+ if (aValue < bValue) {
137
+ return sort.direction === "asc" ? -1 : 1;
138
+ }
139
+ if (aValue > bValue) {
140
+ return sort.direction === "asc" ? 1 : -1;
141
+ }
142
+ }
143
+ return 0;
144
+ });
145
+ }
146
+
147
+ if (options.offset !== undefined && options.offset > 0) {
148
+ items = items.slice(options.offset);
149
+ }
150
+
151
+ if (options.limit !== undefined) {
152
+ items = items.slice(0, options.limit);
153
+ }
154
+
155
+ return items;
156
+ },
157
+
158
+ handleInsert: async (itemsToInsert) => {
159
+ const entries: [string, TItem][] = itemsToInsert.map((item) => [
160
+ getKey(item),
161
+ item,
162
+ ]);
163
+ await adapterSetMany(entries);
164
+ return itemsToInsert;
165
+ },
166
+
167
+ handleUpdate: async (mutations) => {
168
+ const results: TItem[] = [];
169
+ const entriesToSet: [string, TItem][] = [];
170
+
171
+ for (const mutation of mutations) {
172
+ const existing = await adapter.get(mutation.key);
173
+ if (existing) {
174
+ const updatedItem = {
175
+ ...existing,
176
+ ...mutation.changes,
177
+ } as TItem;
178
+ entriesToSet.push([mutation.key, updatedItem]);
179
+ results.push(updatedItem);
180
+ } else {
181
+ results.push(mutation.original);
182
+ }
183
+ }
184
+
185
+ if (entriesToSet.length > 0) {
186
+ await adapterSetMany(entriesToSet);
187
+ }
188
+
189
+ return results;
190
+ },
191
+
192
+ handleDelete: async (mutations) => {
193
+ const keysToDelete = mutations.map((m) => m.key);
194
+ await adapterDelMany(keysToDelete);
195
+ },
196
+
197
+ handleTruncate: async () => {
198
+ await adapter.clear();
199
+ },
200
+ };
201
+
202
+ const wrappedBackend: GenericSyncBackend<TItem> = {
203
+ ...backend,
204
+ initialLoad: async () => {
205
+ if (config.syncMode === "eager" || !config.syncMode) {
206
+ return await backend.initialLoad();
207
+ }
208
+ return [];
209
+ },
210
+ };
211
+
212
+ const baseSyncConfig: GenericBaseSyncConfig = {
213
+ readyPromise,
214
+ syncMode: config.syncMode,
215
+ debug: config.debug,
216
+ };
217
+
218
+ const syncResult: GenericSyncFunctionResult<TItem> =
219
+ createGenericSyncFunction(baseSyncConfig, wrappedBackend);
220
+
221
+ return createGenericCollectionConfig<TItem, TSchema>({
222
+ schema: config.schema,
223
+ getKey,
224
+ syncResult,
225
+ syncMode: config.syncMode,
226
+ });
227
+ }
228
+
229
+ export type KeyValCollection<TSchema extends StandardSchemaV1> = Collection<
230
+ InferSchemaOutput<TSchema>,
231
+ string,
232
+ KeyValUtils<InferSchemaOutput<TSchema>>,
233
+ TSchema,
234
+ InferSchemaInput<TSchema>
235
+ >;
236
+
237
+ export function createKeyValCollection<TSchema extends StandardSchemaV1>(
238
+ config: KeyValCollectionConfig<TSchema>,
239
+ ): KeyValCollection<TSchema> {
240
+ return createCollection(
241
+ keyvalCollectionOptions(config),
242
+ ) as KeyValCollection<TSchema>;
243
+ }