preact-missing-hooks 1.2.0 → 2.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,257 @@
1
+ /**
2
+ * Table controller: insert, update, delete, exists, query, upsert, bulkInsert, clear, count.
3
+ * Works in standalone mode (opens its own transaction per op) or bound to a transaction.
4
+ * @module indexedDB/tableController
5
+ */
6
+
7
+ import type { OperationCallbacks } from './types';
8
+ import { requestToPromise } from './requestToPromise';
9
+
10
+ /** Runs optional callbacks and returns the result. */
11
+ function withCallbacks<T>(
12
+ promise: Promise<T>,
13
+ options?: OperationCallbacks<T>
14
+ ): Promise<T> {
15
+ if (!options) return promise;
16
+ return promise
17
+ .then((result) => {
18
+ options.onSuccess?.(result);
19
+ return result;
20
+ })
21
+ .catch((err: DOMException) => {
22
+ options.onError?.(err);
23
+ throw err;
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Standalone table controller: opens a new transaction for each operation.
29
+ */
30
+ function createStandaloneController(db: IDBDatabase, tableName: string): ITableController {
31
+ function getStore(mode: IDBTransactionMode): IDBObjectStore {
32
+ const tx = db.transaction([tableName], mode);
33
+ return tx.objectStore(tableName);
34
+ }
35
+
36
+ return {
37
+ insert<T>(data: T, options?: OperationCallbacks<IDBValidKey>): Promise<IDBValidKey> {
38
+ const store = getStore('readwrite');
39
+ return withCallbacks(requestToPromise(store.add(data)), options);
40
+ },
41
+
42
+ update<T>(key: IDBValidKey, updates: Partial<T>, options?: OperationCallbacks<void>): Promise<void> {
43
+ const store = getStore('readwrite');
44
+ const getReq = store.get(key);
45
+ return withCallbacks(
46
+ requestToPromise(getReq).then((existing) => {
47
+ if (existing === undefined) {
48
+ throw new DOMException('Key not found', 'NotFoundError');
49
+ }
50
+ const merged = { ...existing, ...updates } as T;
51
+ return requestToPromise(store.put(merged));
52
+ }).then(() => undefined),
53
+ options
54
+ );
55
+ },
56
+
57
+ delete(key: IDBValidKey, options?: OperationCallbacks<void>): Promise<void> {
58
+ const store = getStore('readwrite');
59
+ return withCallbacks(
60
+ requestToPromise(store.delete(key)).then(() => undefined),
61
+ options
62
+ );
63
+ },
64
+
65
+ exists(key: IDBValidKey): Promise<boolean> {
66
+ const store = getStore('readonly');
67
+ return requestToPromise(store.getKey(key)).then((k) => k !== undefined);
68
+ },
69
+
70
+ query<T>(filterFn: (item: T) => boolean, options?: OperationCallbacks<T[]>): Promise<T[]> {
71
+ const store = getStore('readonly');
72
+ const request = store.openCursor();
73
+ const results: T[] = [];
74
+ return withCallbacks(
75
+ new Promise<T[]>((resolve, reject) => {
76
+ request.onsuccess = () => {
77
+ const cursor = request.result;
78
+ if (cursor) {
79
+ if (filterFn(cursor.value as T)) results.push(cursor.value as T);
80
+ cursor.continue();
81
+ } else {
82
+ resolve(results);
83
+ }
84
+ };
85
+ request.onerror = () => reject(request.error ?? new DOMException('Unknown error'));
86
+ }),
87
+ options
88
+ );
89
+ },
90
+
91
+ upsert<T>(data: T, options?: OperationCallbacks<IDBValidKey>): Promise<IDBValidKey> {
92
+ const store = getStore('readwrite');
93
+ return withCallbacks(requestToPromise(store.put(data)), options);
94
+ },
95
+
96
+ bulkInsert<T>(items: T[], options?: OperationCallbacks<IDBValidKey[]>): Promise<IDBValidKey[]> {
97
+ const store = getStore('readwrite');
98
+ const keys: IDBValidKey[] = [];
99
+ if (items.length === 0) {
100
+ return withCallbacks(Promise.resolve(keys), options);
101
+ }
102
+ let completed = 0;
103
+ const promise = new Promise<IDBValidKey[]>((resolve, reject) => {
104
+ const onDone = () => {
105
+ completed++;
106
+ if (completed === items.length) resolve(keys);
107
+ };
108
+ items.forEach((item, i) => {
109
+ const req = store.add(item);
110
+ req.onsuccess = () => {
111
+ keys[i] = req.result;
112
+ onDone();
113
+ };
114
+ req.onerror = () => reject(req.error ?? new DOMException('Unknown error'));
115
+ });
116
+ });
117
+ return withCallbacks(promise, options);
118
+ },
119
+
120
+ clear(options?: OperationCallbacks<void>): Promise<void> {
121
+ const store = getStore('readwrite');
122
+ return withCallbacks(
123
+ requestToPromise(store.clear()).then(() => undefined),
124
+ options
125
+ );
126
+ },
127
+
128
+ count(options?: OperationCallbacks<number>): Promise<number> {
129
+ const store = getStore('readonly');
130
+ return withCallbacks(requestToPromise(store.count()), options ?? {});
131
+ },
132
+ };
133
+ }
134
+
135
+ /**
136
+ * Transaction-scoped table controller: uses the given transaction (no new transaction).
137
+ */
138
+ function createTransactionController(tx: IDBTransaction, tableName: string): ITableController {
139
+ function getStore(): IDBObjectStore {
140
+ return tx.objectStore(tableName);
141
+ }
142
+
143
+ return {
144
+ insert<T>(data: T, options?: OperationCallbacks<IDBValidKey>): Promise<IDBValidKey> {
145
+ const store = getStore();
146
+ return withCallbacks(requestToPromise(store.add(data)), options);
147
+ },
148
+
149
+ update<T>(key: IDBValidKey, updates: Partial<T>, options?: OperationCallbacks<void>): Promise<void> {
150
+ const store = getStore();
151
+ return withCallbacks(
152
+ requestToPromise(store.get(key)).then((existing) => {
153
+ if (existing === undefined) {
154
+ throw new DOMException('Key not found', 'NotFoundError');
155
+ }
156
+ const merged = { ...existing, ...updates } as T;
157
+ return requestToPromise(store.put(merged));
158
+ }).then(() => undefined),
159
+ options
160
+ );
161
+ },
162
+
163
+ delete(key: IDBValidKey, options?: OperationCallbacks<void>): Promise<void> {
164
+ const store = getStore();
165
+ return withCallbacks(
166
+ requestToPromise(store.delete(key)).then(() => undefined),
167
+ options
168
+ );
169
+ },
170
+
171
+ exists(key: IDBValidKey): Promise<boolean> {
172
+ const store = getStore();
173
+ return requestToPromise(store.getKey(key)).then((k) => k !== undefined);
174
+ },
175
+
176
+ query<T>(filterFn: (item: T) => boolean, options?: OperationCallbacks<T[]>): Promise<T[]> {
177
+ const store = getStore();
178
+ const request = store.openCursor();
179
+ const results: T[] = [];
180
+ return withCallbacks(
181
+ new Promise<T[]>((resolve, reject) => {
182
+ request.onsuccess = () => {
183
+ const cursor = request.result;
184
+ if (cursor) {
185
+ if (filterFn(cursor.value as T)) results.push(cursor.value as T);
186
+ cursor.continue();
187
+ } else {
188
+ resolve(results);
189
+ }
190
+ };
191
+ request.onerror = () => reject(request.error ?? new DOMException('Unknown error'));
192
+ }),
193
+ options
194
+ );
195
+ },
196
+
197
+ upsert<T>(data: T, options?: OperationCallbacks<IDBValidKey>): Promise<IDBValidKey> {
198
+ const store = getStore();
199
+ return withCallbacks(requestToPromise(store.put(data)), options);
200
+ },
201
+
202
+ bulkInsert<T>(items: T[], options?: OperationCallbacks<IDBValidKey[]>): Promise<IDBValidKey[]> {
203
+ const store = getStore();
204
+ const keys: IDBValidKey[] = [];
205
+ if (items.length === 0) {
206
+ return withCallbacks(Promise.resolve(keys), options);
207
+ }
208
+ let completed = 0;
209
+ const promise = new Promise<IDBValidKey[]>((resolve, reject) => {
210
+ items.forEach((item, i) => {
211
+ const req = store.add(item);
212
+ req.onsuccess = () => {
213
+ keys[i] = req.result;
214
+ completed++;
215
+ if (completed === items.length) resolve(keys);
216
+ };
217
+ req.onerror = () => reject(req.error ?? new DOMException('Unknown error'));
218
+ });
219
+ });
220
+ return withCallbacks(promise, options);
221
+ },
222
+
223
+ clear(options?: OperationCallbacks<void>): Promise<void> {
224
+ const store = getStore();
225
+ return withCallbacks(
226
+ requestToPromise(store.clear()).then(() => undefined),
227
+ options
228
+ );
229
+ },
230
+
231
+ count(options?: OperationCallbacks<number>): Promise<number> {
232
+ const store = getStore();
233
+ return withCallbacks(requestToPromise(store.count()), options ?? {});
234
+ },
235
+ };
236
+ }
237
+
238
+ /** Public interface for a table controller (standalone or transaction-scoped). */
239
+ export interface ITableController {
240
+ insert<T>(data: T, options?: OperationCallbacks<IDBValidKey>): Promise<IDBValidKey>;
241
+ update<T>(key: IDBValidKey, updates: Partial<T>, options?: OperationCallbacks<void>): Promise<void>;
242
+ delete(key: IDBValidKey, options?: OperationCallbacks<void>): Promise<void>;
243
+ exists(key: IDBValidKey): Promise<boolean>;
244
+ query<T>(filterFn: (item: T) => boolean, options?: OperationCallbacks<T[]>): Promise<T[]>;
245
+ upsert<T>(data: T, options?: OperationCallbacks<IDBValidKey>): Promise<IDBValidKey>;
246
+ bulkInsert<T>(items: T[], options?: OperationCallbacks<IDBValidKey[]>): Promise<IDBValidKey[]>;
247
+ clear(options?: OperationCallbacks<void>): Promise<void>;
248
+ count(options?: OperationCallbacks<number>): Promise<number>;
249
+ }
250
+
251
+ export function createTableController(db: IDBDatabase, tableName: string): ITableController {
252
+ return createStandaloneController(db, tableName);
253
+ }
254
+
255
+ export function createTransactionTableController(tx: IDBTransaction, tableName: string): ITableController {
256
+ return createTransactionController(tx, tableName);
257
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * IndexedDB hook system – shared types.
3
+ * @module indexedDB/types
4
+ */
5
+
6
+ /** Table schema for a single object store. */
7
+ export interface TableSchema {
8
+ /** Key path (e.g. `"id"` or `["a", "b"]`). */
9
+ keyPath: string | string[];
10
+ /** Use auto-increment primary key. */
11
+ autoIncrement?: boolean;
12
+ /** Optional index names to create (each indexes the keyPath by default). */
13
+ indexes?: string[];
14
+ }
15
+
16
+ /** Configuration passed to useIndexedDB. */
17
+ export interface IndexedDBConfig {
18
+ /** Database name. */
19
+ name: string;
20
+ /** Schema version (increment to trigger onupgradeneeded). */
21
+ version: number;
22
+ /** Map of table name → store schema (keyPath, autoIncrement, indexes). */
23
+ tables: {
24
+ [tableName: string]: TableSchema;
25
+ };
26
+ }
27
+
28
+ /** Optional callbacks for any operation. */
29
+ export interface OperationCallbacks<T = unknown> {
30
+ onSuccess?: (result: T) => void;
31
+ onError?: (error: DOMException) => void;
32
+ }
33
+
34
+ /** Options for transaction. */
35
+ export interface TransactionOptions extends OperationCallbacks<void> { }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Preact hook for IndexedDB: open database, create stores/indexes, return a database controller.
3
+ * Uses a singleton connection per (name, version).
4
+ * @module useIndexedDB
5
+ */
6
+
7
+ import { useState, useEffect, useRef } from 'preact/hooks';
8
+ import type { IndexedDBConfig } from './indexedDB/types';
9
+ import { openDB } from './indexedDB/openDB';
10
+ import { createDBController } from './indexedDB/dbController';
11
+ import type { IDBController } from './indexedDB/dbController';
12
+
13
+ export type { IndexedDBConfig, IDBController } from './indexedDB';
14
+
15
+ export interface UseIndexedDBReturn {
16
+ /** Database controller (table, transaction). Null until the database is open. */
17
+ db: IDBController | null;
18
+ /** True once the database is open and ready. */
19
+ isReady: boolean;
20
+ /** Error from opening the database, if any. */
21
+ error: DOMException | null;
22
+ }
23
+
24
+ /**
25
+ * Opens an IndexedDB database and returns a controller for tables and transactions.
26
+ * Handles onupgradeneeded: creates object stores and indexes from config.
27
+ * Connection is a singleton per (config.name, config.version).
28
+ *
29
+ * @param config - Database name, version, and table schemas (keyPath, autoIncrement, indexes).
30
+ * @returns { db, isReady, error }. Use db.table(name) and db.transaction(...) when isReady is true.
31
+ *
32
+ * @example
33
+ * const { db, isReady, error } = useIndexedDB({
34
+ * name: 'my-db',
35
+ * version: 1,
36
+ * tables: {
37
+ * users: { keyPath: 'id', autoIncrement: true, indexes: ['email'] },
38
+ * },
39
+ * })
40
+ * if (isReady && db) {
41
+ * const users = db.table('users')
42
+ * await users.insert({ email: 'a@b.com' })
43
+ * await db.transaction(['users'], 'readwrite', (tx) => tx.table('users').insert({ email: 'b@b.com' }))
44
+ * }
45
+ */
46
+ export function useIndexedDB(config: IndexedDBConfig): UseIndexedDBReturn {
47
+ const [db, setDb] = useState<IDBController | null>(null);
48
+ const [error, setError] = useState<DOMException | null>(null);
49
+ const [isReady, setIsReady] = useState(false);
50
+ const configRef = useRef(config);
51
+ configRef.current = config;
52
+
53
+ useEffect(() => {
54
+ let cancelled = false;
55
+ setError(null);
56
+ setIsReady(false);
57
+ setDb(null);
58
+
59
+ const { name, version, tables } = configRef.current;
60
+ openDB({ name, version, tables })
61
+ .then((database) => {
62
+ if (cancelled) {
63
+ database.close();
64
+ return;
65
+ }
66
+ const controller = createDBController(database, configRef.current);
67
+ setDb(controller);
68
+ setIsReady(true);
69
+ })
70
+ .catch((err: DOMException) => {
71
+ if (!cancelled) setError(err);
72
+ });
73
+
74
+ return () => {
75
+ cancelled = true;
76
+ };
77
+ }, [config.name, config.version]);
78
+
79
+ return { db, isReady, error };
80
+ }
81
+
82
+ /*
83
+ * Usage example:
84
+ *
85
+ * const { db, isReady, error } = useIndexedDB({
86
+ * name: 'my-app-db',
87
+ * version: 1,
88
+ * tables: {
89
+ * users: { keyPath: 'id', autoIncrement: true, indexes: ['email'] },
90
+ * settings: { keyPath: 'key' },
91
+ * },
92
+ * })
93
+ *
94
+ * if (error) return <div>Failed to open database</div>
95
+ * if (!isReady || !db) return <div>Loading...</div>
96
+ *
97
+ * const users = db.table('users')
98
+ * await users.insert({ email: 'a@b.com', name: 'Alice' })
99
+ * await users.update(1, { name: 'Alice Smith' })
100
+ * const found = await users.query((u) => u.email.startsWith('a@'))
101
+ * const n = await users.count()
102
+ * await users.delete(1)
103
+ * await users.upsert({ id: 2, email: 'b@b.com' })
104
+ * await users.bulkInsert([{ email: 'c@b.com' }, { email: 'd@b.com' }])
105
+ * await users.clear({ onSuccess: () => console.log('cleared') })
106
+ *
107
+ * await db.transaction(['users', 'settings'], 'readwrite', async (tx) => {
108
+ * await tx.table('users').insert({ email: 'e@b.com' })
109
+ * await tx.table('settings').upsert({ key: 'theme', value: 'dark' })
110
+ * }, { onSuccess: () => console.log('transaction done') })
111
+ */
@@ -0,0 +1,165 @@
1
+ import { useState, useCallback, useRef, useEffect } from 'preact/hooks';
2
+
3
+ /** Lower number = higher priority. Default priority when not specified. */
4
+ const DEFAULT_PRIORITY = 1;
5
+
6
+ export type ThreadedWorkerMode = 'sequential' | 'parallel';
7
+
8
+ export interface UseThreadedWorkerOptions {
9
+ /** Sequential: single worker, priority-ordered. Parallel: worker pool. */
10
+ mode: ThreadedWorkerMode;
11
+ /** Max concurrent workers. Only used when mode is "parallel". Default 4. */
12
+ concurrency?: number;
13
+ }
14
+
15
+ export interface RunOptions {
16
+ /** 1 = highest priority. Lower number runs first. FIFO within same priority. */
17
+ priority?: number;
18
+ }
19
+
20
+ interface QueuedTask<TData, TResult> {
21
+ data: TData;
22
+ priority: number;
23
+ sequence: number;
24
+ resolve: (value: TResult) => void;
25
+ reject: (reason: unknown) => void;
26
+ }
27
+
28
+ export interface UseThreadedWorkerReturn<TData, TResult> {
29
+ /** Enqueue work. Returns a Promise that resolves with the worker result. */
30
+ run: (data: TData, options?: RunOptions) => Promise<TResult>;
31
+ /** True while any task is queued or running. */
32
+ loading: boolean;
33
+ /** Result of the most recently completed successful task. */
34
+ result: TResult | undefined;
35
+ /** Error from the most recently failed task. */
36
+ error: unknown;
37
+ /** Number of tasks currently queued + running. */
38
+ queueSize: number;
39
+ /** Clear all pending (not yet started) tasks. Running tasks continue. */
40
+ clearQueue: () => void;
41
+ /** Stop accepting new work and clear pending queue. Running tasks finish. */
42
+ terminate: () => void;
43
+ }
44
+
45
+ /**
46
+ * Production-grade hook to run async work in a queue with optional priority
47
+ * and either sequential or parallel execution.
48
+ *
49
+ * @param workerFn - Async function to run for each task (e.g. API call, heavy compute).
50
+ * @param options - mode: "sequential" | "parallel", concurrency (parallel only).
51
+ * @returns run, loading, result, error, queueSize, clearQueue, terminate.
52
+ */
53
+ export function useThreadedWorker<TData, TResult>(
54
+ workerFn: (data: TData) => Promise<TResult>,
55
+ options: UseThreadedWorkerOptions
56
+ ): UseThreadedWorkerReturn<TData, TResult> {
57
+ const { mode, concurrency = 4 } = options;
58
+ const maxConcurrent = mode === 'sequential' ? 1 : Math.max(1, concurrency);
59
+
60
+ const [loading, setLoading] = useState(false);
61
+ const [result, setResult] = useState<TResult | undefined>(undefined);
62
+ const [error, setError] = useState<unknown>(undefined);
63
+ const [queueSize, setQueueSize] = useState(0);
64
+
65
+ const queueRef = useRef<QueuedTask<TData, TResult>[]>([]);
66
+ const sequenceRef = useRef(0);
67
+ const activeCountRef = useRef(0);
68
+ const terminatedRef = useRef(false);
69
+ const workerFnRef = useRef(workerFn);
70
+ workerFnRef.current = workerFn;
71
+
72
+ const updateQueueSize = useCallback(() => {
73
+ setQueueSize(queueRef.current.length + activeCountRef.current);
74
+ }, []);
75
+
76
+ const processNext = useCallback(() => {
77
+ if (terminatedRef.current) return;
78
+ if (activeCountRef.current >= maxConcurrent) return;
79
+ if (queueRef.current.length === 0) {
80
+ if (activeCountRef.current === 0) setLoading(false);
81
+ updateQueueSize();
82
+ return;
83
+ }
84
+
85
+ // Sort by priority (asc), then by sequence (FIFO within same priority).
86
+ queueRef.current.sort((a, b) => {
87
+ if (a.priority !== b.priority) return a.priority - b.priority;
88
+ return a.sequence - b.sequence;
89
+ });
90
+ const task = queueRef.current.shift()!;
91
+ activeCountRef.current += 1;
92
+ setLoading(true);
93
+ updateQueueSize();
94
+
95
+ const fn = workerFnRef.current;
96
+ fn(task.data)
97
+ .then((value) => {
98
+ setResult(value);
99
+ setError(undefined);
100
+ task.resolve(value);
101
+ })
102
+ .catch((err) => {
103
+ setError(err);
104
+ task.reject(err);
105
+ })
106
+ .finally(() => {
107
+ activeCountRef.current -= 1;
108
+ updateQueueSize();
109
+ processNext();
110
+ });
111
+
112
+ // Fill remaining slots (parallel mode).
113
+ if (queueRef.current.length > 0 && activeCountRef.current < maxConcurrent) {
114
+ processNext();
115
+ }
116
+ }, [maxConcurrent, updateQueueSize]);
117
+
118
+ const run = useCallback(
119
+ (data: TData, runOptions?: RunOptions): Promise<TResult> => {
120
+ if (terminatedRef.current) {
121
+ return Promise.reject(new Error('Worker is terminated'));
122
+ }
123
+ const priority = runOptions?.priority ?? DEFAULT_PRIORITY;
124
+ const sequence = ++sequenceRef.current;
125
+ const promise = new Promise<TResult>((resolve, reject) => {
126
+ queueRef.current.push({ data, priority, sequence, resolve, reject });
127
+ });
128
+ updateQueueSize();
129
+ setLoading(true);
130
+ queueMicrotask(processNext);
131
+ return promise;
132
+ },
133
+ [processNext, updateQueueSize]
134
+ );
135
+
136
+ const clearQueue = useCallback(() => {
137
+ const pending = queueRef.current;
138
+ queueRef.current = [];
139
+ pending.forEach((t) => t.reject(new Error('Task cleared from queue')));
140
+ updateQueueSize();
141
+ if (activeCountRef.current === 0) setLoading(false);
142
+ }, [updateQueueSize]);
143
+
144
+ const terminate = useCallback(() => {
145
+ terminatedRef.current = true;
146
+ clearQueue();
147
+ }, [clearQueue]);
148
+
149
+ // Reset terminated on unmount so the same hook instance can't be "revived" without options change.
150
+ useEffect(() => {
151
+ return () => {
152
+ terminatedRef.current = true;
153
+ };
154
+ }, []);
155
+
156
+ return {
157
+ run,
158
+ loading,
159
+ result,
160
+ error,
161
+ queueSize,
162
+ clearQueue,
163
+ terminate,
164
+ };
165
+ }