@firtoz/drizzle-sqlite-wasm 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,271 @@
1
+ import type {
2
+ CollectionConfig,
3
+ SyncConfig,
4
+ InsertMutationFnParams,
5
+ UpdateMutationFnParams,
6
+ DeleteMutationFnParams,
7
+ UtilsRecord,
8
+ BaseCollectionConfig,
9
+ InferSchemaOutput,
10
+ InferSchemaInput,
11
+ } from "@tanstack/db";
12
+ import type { StandardSchemaV1 } from "@standard-schema/spec";
13
+
14
+ interface WebSocketMessage<T> {
15
+ type: "insert" | "update" | "delete" | "sync" | "transaction" | "ack";
16
+ data?: T | T[];
17
+ mutations?: Array<{
18
+ type: "insert" | "update" | "delete";
19
+ data: T;
20
+ id?: string;
21
+ }>;
22
+ transactionId?: string;
23
+ id?: string;
24
+ }
25
+
26
+ interface WebSocketCollectionConfig<TSchema extends StandardSchemaV1>
27
+ extends Omit<
28
+ BaseCollectionConfig<InferSchemaOutput<TSchema>, string | number, TSchema>,
29
+ "onInsert" | "onUpdate" | "onDelete" | "sync" | "schema"
30
+ > {
31
+ url: string;
32
+ reconnectInterval?: number;
33
+ schema: TSchema;
34
+
35
+ // Note: onInsert/onUpdate/onDelete are handled by the WebSocket connection
36
+ // Users don't provide these handlers
37
+ }
38
+
39
+ interface WebSocketUtils extends UtilsRecord {
40
+ reconnect: () => void;
41
+ getConnectionState: () => "connected" | "disconnected" | "connecting";
42
+ }
43
+
44
+ export function webSocketCollectionOptions<TSchema extends StandardSchemaV1>(
45
+ config: WebSocketCollectionConfig<TSchema>,
46
+ ): CollectionConfig<InferSchemaOutput<TSchema>, string | number, TSchema> & {
47
+ utils: WebSocketUtils;
48
+ schema: TSchema;
49
+ } {
50
+ type TItem = InferSchemaOutput<TSchema>;
51
+
52
+ let ws: WebSocket | null = null;
53
+ let reconnectTimer: NodeJS.Timeout | null = null;
54
+ let connectionState: "connected" | "disconnected" | "connecting" =
55
+ "disconnected";
56
+
57
+ // Track pending transactions awaiting acknowledgment
58
+ const pendingTransactions = new Map<
59
+ string,
60
+ {
61
+ resolve: () => void;
62
+ reject: (error: Error) => void;
63
+ timeout: NodeJS.Timeout;
64
+ }
65
+ >();
66
+
67
+ const handlers: {
68
+ onOpen?: () => void;
69
+ onMessage?: (event: MessageEvent) => void;
70
+ onError?: (error: Event) => void;
71
+ onClose?: () => void;
72
+ } = {};
73
+
74
+ const connect = () => {
75
+ connectionState = "connecting";
76
+ ws = new WebSocket(config.url);
77
+
78
+ ws.onopen = () => {
79
+ handlers.onOpen?.();
80
+ };
81
+ ws.onmessage = (event) => {
82
+ handlers.onMessage?.(event);
83
+ };
84
+ ws.onerror = (error) => {
85
+ handlers.onError?.(error);
86
+ };
87
+ ws.onclose = () => {
88
+ handlers.onClose?.();
89
+ };
90
+ };
91
+
92
+ const sync: SyncConfig<TItem>["sync"] = (params) => {
93
+ const { begin, write, commit, markReady } = params;
94
+
95
+ handlers.onOpen = () => {
96
+ if (!ws) return;
97
+
98
+ connectionState = "connected";
99
+ // Request initial sync
100
+ ws.send(JSON.stringify({ type: "sync" }));
101
+ };
102
+
103
+ handlers.onMessage = (event) => {
104
+ if (!ws) return;
105
+
106
+ const message: WebSocketMessage<TItem> = JSON.parse(event.data);
107
+
108
+ switch (message.type) {
109
+ case "sync":
110
+ // Initial sync with array of items
111
+ begin();
112
+ if (Array.isArray(message.data)) {
113
+ for (const item of message.data) {
114
+ write({ type: "insert", value: item });
115
+ }
116
+ }
117
+ commit();
118
+ markReady();
119
+ break;
120
+
121
+ case "insert":
122
+ case "update":
123
+ case "delete":
124
+ // Real-time updates from other clients
125
+ begin();
126
+ write({
127
+ type: message.type,
128
+ value: message.data as InferSchemaInput<TSchema>,
129
+ });
130
+ commit();
131
+ break;
132
+
133
+ case "ack":
134
+ // Server acknowledged our transaction
135
+ if (message.transactionId) {
136
+ const pending = pendingTransactions.get(message.transactionId);
137
+ if (pending) {
138
+ clearTimeout(pending.timeout);
139
+ pendingTransactions.delete(message.transactionId);
140
+ pending.resolve();
141
+ }
142
+ }
143
+ break;
144
+
145
+ case "transaction":
146
+ // Server sending back the actual data after processing our transaction
147
+ if (message.mutations) {
148
+ begin();
149
+ for (const mutation of message.mutations) {
150
+ write({
151
+ type: mutation.type,
152
+ value: mutation.data,
153
+ });
154
+ }
155
+ commit();
156
+ }
157
+ break;
158
+ }
159
+ };
160
+
161
+ handlers.onError = (error) => {
162
+ console.error("WebSocket error:", error);
163
+ connectionState = "disconnected";
164
+ };
165
+
166
+ handlers.onClose = () => {
167
+ connectionState = "disconnected";
168
+ // Auto-reconnect
169
+ if (!reconnectTimer) {
170
+ reconnectTimer = setTimeout(() => {
171
+ reconnectTimer = null;
172
+ connect();
173
+ }, config.reconnectInterval || 5000);
174
+ }
175
+ };
176
+
177
+ // Start connection
178
+ connect();
179
+
180
+ // Return cleanup function
181
+ return () => {
182
+ if (reconnectTimer) {
183
+ clearTimeout(reconnectTimer);
184
+ reconnectTimer = null;
185
+ }
186
+ if (ws) {
187
+ ws.close();
188
+ ws = null;
189
+ }
190
+ };
191
+ };
192
+
193
+ // Helper function to send transaction and wait for server acknowledgment
194
+ const sendTransaction = async (
195
+ params:
196
+ | InsertMutationFnParams<TItem>
197
+ | UpdateMutationFnParams<TItem>
198
+ | DeleteMutationFnParams<TItem>,
199
+ ): Promise<void> => {
200
+ if (ws?.readyState !== WebSocket.OPEN) {
201
+ throw new Error("WebSocket not connected");
202
+ }
203
+
204
+ const transactionId = crypto.randomUUID();
205
+
206
+ // Convert all mutations in the transaction to the wire format
207
+ const mutations = params.transaction.mutations.map((mutation) => ({
208
+ type: mutation.type,
209
+ id: mutation.key,
210
+ data:
211
+ mutation.type === "delete"
212
+ ? undefined
213
+ : mutation.type === "update"
214
+ ? mutation.changes
215
+ : mutation.modified,
216
+ }));
217
+
218
+ // Send the entire transaction at once
219
+ ws.send(
220
+ JSON.stringify({
221
+ type: "transaction",
222
+ transactionId,
223
+ mutations,
224
+ }),
225
+ );
226
+
227
+ // Wait for server acknowledgment
228
+ return new Promise<void>((resolve, reject) => {
229
+ const timeout = setTimeout(() => {
230
+ pendingTransactions.delete(transactionId);
231
+ reject(new Error(`Transaction ${transactionId} timed out`));
232
+ }, 10000); // 10 second timeout
233
+
234
+ pendingTransactions.set(transactionId, {
235
+ resolve,
236
+ reject,
237
+ timeout,
238
+ });
239
+ });
240
+ };
241
+
242
+ // All mutation handlers use the same transaction sender
243
+ const onInsert = async (params: InsertMutationFnParams<TItem>) => {
244
+ await sendTransaction(params);
245
+ };
246
+
247
+ const onUpdate = async (params: UpdateMutationFnParams<TItem>) => {
248
+ await sendTransaction(params);
249
+ };
250
+
251
+ const onDelete = async (params: DeleteMutationFnParams<TItem>) => {
252
+ await sendTransaction(params);
253
+ };
254
+
255
+ return {
256
+ id: config.id,
257
+ schema: config.schema,
258
+ getKey: config.getKey,
259
+ sync: { sync },
260
+ onInsert,
261
+ onUpdate,
262
+ onDelete,
263
+ utils: {
264
+ reconnect: () => {
265
+ if (ws) ws.close();
266
+ connect();
267
+ },
268
+ getConnectionState: () => connectionState,
269
+ },
270
+ };
271
+ }
@@ -0,0 +1,35 @@
1
+ import { useContext } from "react";
2
+ import type { DrizzleSqliteContextValue } from "./DrizzleSqliteProvider";
3
+ import {
4
+ DrizzleSqliteContext,
5
+ useSqliteCollection,
6
+ } from "./DrizzleSqliteProvider";
7
+ import type { ValidTableNames } from "../collections/sqlite-collection";
8
+
9
+ export type UseDrizzleSqliteReturn<TSchema extends Record<string, unknown>> = {
10
+ drizzle: DrizzleSqliteContextValue<TSchema>["drizzle"];
11
+ useCollection: <TTableName extends string & ValidTableNames<TSchema>>(
12
+ tableName: TTableName,
13
+ ) => ReturnType<typeof useSqliteCollection<TSchema, TTableName>>;
14
+ };
15
+
16
+ export function useDrizzleSqlite<
17
+ TSchema extends Record<string, unknown>,
18
+ >(): UseDrizzleSqliteReturn<TSchema> {
19
+ const context = useContext(
20
+ DrizzleSqliteContext,
21
+ ) as DrizzleSqliteContextValue<TSchema> | null;
22
+
23
+ if (!context) {
24
+ throw new Error(
25
+ "useDrizzleSqlite must be used within a DrizzleSqliteProvider",
26
+ );
27
+ }
28
+
29
+ return {
30
+ drizzle: context.drizzle,
31
+ useCollection: <TTableName extends string & ValidTableNames<TSchema>>(
32
+ tableName: TTableName,
33
+ ) => useSqliteCollection(context, tableName),
34
+ };
35
+ }
@@ -0,0 +1,27 @@
1
+ import type { Database } from "@sqlite.org/sqlite-wasm";
2
+ import type { DrizzleConfig } from "drizzle-orm";
3
+ import { drizzle as drizzleSqliteProxy } from "drizzle-orm/sqlite-proxy";
4
+ import { handleRemoteCallback } from "./handle-callback";
5
+
6
+ export const drizzleSqliteWasm = <
7
+ TSchema extends Record<string, unknown> = Record<string, never>,
8
+ >(
9
+ sqliteDb: Database,
10
+ config: DrizzleConfig<TSchema> = {},
11
+ debug?: boolean,
12
+ ) => {
13
+ return drizzleSqliteProxy<TSchema>(async (sql, params, method) => {
14
+ const result = await handleRemoteCallback({
15
+ sqliteDb,
16
+ sql,
17
+ params,
18
+ method,
19
+ debug,
20
+ });
21
+ if (result.success) {
22
+ return result.result;
23
+ }
24
+ // If the callback failed, throw an error for drizzle to handle
25
+ throw new Error(result.error);
26
+ }, config);
27
+ };
@@ -0,0 +1,113 @@
1
+ import {
2
+ exhaustiveGuard,
3
+ fail,
4
+ success,
5
+ type MaybeError,
6
+ } from "@firtoz/maybe-error";
7
+ import type { Database } from "@sqlite.org/sqlite-wasm";
8
+
9
+ export const handleRemoteCallback = async ({
10
+ sqliteDb,
11
+ sql,
12
+ params,
13
+ method,
14
+ debug: _debug = false,
15
+ }: {
16
+ sqliteDb: Database;
17
+ sql: string;
18
+ // biome-ignore lint/suspicious/noExplicitAny: This is what drizzle-orm expects.
19
+ params: any[];
20
+ method: "run" | "all" | "values" | "get";
21
+ debug?: boolean;
22
+ }): Promise<MaybeError<{ rows: unknown[] }, string>> => {
23
+ switch (method) {
24
+ case "run": {
25
+ // For INSERT, UPDATE, DELETE operations
26
+ try {
27
+ sqliteDb.exec({
28
+ sql,
29
+ bind: params,
30
+ callback: () => {},
31
+ });
32
+
33
+ return success({ rows: [] });
34
+ } catch (e: unknown) {
35
+ const errorMsg = e instanceof Error ? e.message : String(e);
36
+ console.error("Error executing run query:", errorMsg);
37
+ return fail(errorMsg);
38
+ }
39
+ }
40
+ case "get": {
41
+ // For getting a single row
42
+ const columnNames: string[] = [];
43
+ let rowData: unknown[] = [];
44
+ let callbackReceived = false;
45
+
46
+ try {
47
+ // Get column names and data in one go
48
+ sqliteDb.exec({
49
+ sql,
50
+ bind: params,
51
+ columnNames,
52
+ callback: (row) => {
53
+ callbackReceived = true;
54
+ if (Array.isArray(row)) {
55
+ // Store the first row's values
56
+ rowData = row;
57
+ } else {
58
+ // Convert object to array if needed
59
+ rowData = columnNames.map((col) => row[col]);
60
+ }
61
+ },
62
+ });
63
+ } catch (e: unknown) {
64
+ const errorMsg = e instanceof Error ? e.message : String(e);
65
+ console.error("Error getting row data:", errorMsg);
66
+ return fail(errorMsg);
67
+ }
68
+
69
+ if (!callbackReceived) {
70
+ const errorMsg = "No callback received for get method";
71
+ console.error(errorMsg);
72
+ return fail(errorMsg);
73
+ }
74
+
75
+ // For get method, return a single array of values
76
+ return success({ rows: rowData });
77
+ }
78
+
79
+ case "all":
80
+ case "values": {
81
+ // For getting multiple rows
82
+ const columnNames: string[] = [];
83
+ const rowsData: unknown[][] = [];
84
+
85
+ try {
86
+ // Get column names and data in one go
87
+ sqliteDb.exec({
88
+ sql,
89
+ bind: params,
90
+ columnNames,
91
+ callback: (row) => {
92
+ if (Array.isArray(row)) {
93
+ // Convert all values to strings
94
+ rowsData.push(row);
95
+ } else {
96
+ // Convert object to array if needed
97
+ rowsData.push(columnNames.map((col) => row[col]));
98
+ }
99
+ },
100
+ });
101
+ } catch (e: unknown) {
102
+ const errorMsg = e instanceof Error ? e.message : String(e);
103
+ console.error("Error getting all/values data:", errorMsg);
104
+ return fail(errorMsg);
105
+ }
106
+
107
+ // For all/values methods, return an array of arrays
108
+ return success({ rows: rowsData });
109
+ }
110
+ }
111
+
112
+ return exhaustiveGuard(method);
113
+ };
@@ -0,0 +1,24 @@
1
+ import type { DrizzleConfig } from "drizzle-orm";
2
+ import { drizzle as drizzleSqliteProxy } from "drizzle-orm/sqlite-proxy";
3
+ import type { ISqliteWorkerClient } from "../worker/client";
4
+
5
+ export const drizzleSqliteWasmWorker = <
6
+ TSchema extends Record<string, unknown> = Record<string, never>,
7
+ >(
8
+ client: ISqliteWorkerClient,
9
+ config: DrizzleConfig<TSchema> = {},
10
+ ) => {
11
+ return drizzleSqliteProxy<TSchema>(async (sql, params, method) => {
12
+ return new Promise<{ rows: unknown[] }>((resolve, reject) => {
13
+ client.performRemoteCallback(
14
+ {
15
+ sql,
16
+ params,
17
+ method,
18
+ },
19
+ resolve,
20
+ reject,
21
+ );
22
+ });
23
+ }, config);
24
+ };
@@ -0,0 +1,139 @@
1
+ import { useEffect, useMemo, useRef, useState } from "react";
2
+ import {
3
+ customSqliteMigrate,
4
+ type DurableSqliteMigrationConfig,
5
+ } from "@firtoz/drizzle-sqlite-wasm/sqlite-wasm-migrator";
6
+ import { drizzleSqliteWasmWorker } from "@firtoz/drizzle-sqlite-wasm/drizzle-sqlite-wasm-worker";
7
+ import type { ISqliteWorkerClient } from "../worker/manager";
8
+ import {
9
+ initializeSqliteWorker,
10
+ isSqliteWorkerInitialized,
11
+ } from "../worker/global-manager";
12
+
13
+ export const useDrizzleSqliteDb = <TSchema extends Record<string, unknown>>(
14
+ WorkerConstructor: new () => Worker,
15
+ dbName: string,
16
+ schema: TSchema,
17
+ migrations: DurableSqliteMigrationConfig,
18
+ debug?: boolean,
19
+ ) => {
20
+ const resolveRef = useRef<null | (() => void)>(null);
21
+ const rejectRef = useRef<null | ((error: unknown) => void)>(null);
22
+ const [sqliteClient, setSqliteClient] = useState<ISqliteWorkerClient | null>(
23
+ null,
24
+ );
25
+ const sqliteClientRef = useRef<ISqliteWorkerClient | null>(null);
26
+
27
+ const readyPromise = useMemo(() => {
28
+ return new Promise<void>((resolve, reject) => {
29
+ resolveRef.current = resolve;
30
+ rejectRef.current = reject;
31
+ });
32
+ }, []);
33
+
34
+ // Initialize the global manager and get db instance
35
+ useEffect(() => {
36
+ if (typeof window === "undefined") {
37
+ // SSR stub
38
+ setSqliteClient({
39
+ performRemoteCallback: () => {},
40
+ checkpoint: () => Promise.resolve(),
41
+ onStarted: () => {},
42
+ terminate: () => {},
43
+ });
44
+ return;
45
+ }
46
+
47
+ let mounted = true;
48
+
49
+ const init = async () => {
50
+ // Initialize manager if not already initialized
51
+ if (!isSqliteWorkerInitialized()) {
52
+ await initializeSqliteWorker(WorkerConstructor);
53
+ }
54
+
55
+ // Get manager and create db instance
56
+ const { getSqliteWorkerManager } = await import(
57
+ "../worker/global-manager"
58
+ );
59
+ const manager = getSqliteWorkerManager();
60
+ const instance = await manager.getDbInstance(dbName);
61
+
62
+ if (mounted) {
63
+ sqliteClientRef.current = instance;
64
+ setSqliteClient(instance);
65
+ }
66
+ };
67
+
68
+ init();
69
+
70
+ return () => {
71
+ mounted = false;
72
+ };
73
+ }, [dbName, WorkerConstructor]);
74
+
75
+ // Create drizzle instance with a callback-based approach that waits for the client
76
+ const drizzle = useMemo(() => {
77
+ if (debug) {
78
+ console.log(`[DEBUG] ${dbName} - creating drizzle proxy wrapper`);
79
+ }
80
+ return drizzleSqliteWasmWorker<TSchema>(
81
+ {
82
+ performRemoteCallback: (data, resolve, reject) => {
83
+ const client = sqliteClientRef.current;
84
+ if (!client) {
85
+ console.error(
86
+ `[DEBUG] ${dbName} - performRemoteCallback called but no sqliteClient yet`,
87
+ );
88
+ reject(
89
+ new Error(
90
+ `Database ${dbName} not ready yet - still initializing`,
91
+ ),
92
+ );
93
+ return;
94
+ }
95
+ client.performRemoteCallback(data, resolve, reject);
96
+ },
97
+ onStarted: (callback) => {
98
+ const client = sqliteClientRef.current;
99
+ if (!client) {
100
+ console.warn(
101
+ `[DEBUG] ${dbName} - onStarted called but no sqliteClient yet`,
102
+ );
103
+ return;
104
+ }
105
+ client.onStarted(callback);
106
+ },
107
+ terminate: () => {
108
+ sqliteClientRef.current?.terminate();
109
+ },
110
+ },
111
+ { schema },
112
+ );
113
+ }, [schema, dbName]); // Using ref for sqliteClient to avoid recreating drizzle
114
+
115
+ useEffect(() => {
116
+ if (!sqliteClient) {
117
+ if (debug) {
118
+ console.log(`[DEBUG] ${dbName} - waiting for sqliteClient...`);
119
+ }
120
+ return;
121
+ }
122
+
123
+ sqliteClient.onStarted(async () => {
124
+ try {
125
+ await customSqliteMigrate(drizzle, migrations);
126
+ resolveRef.current?.();
127
+ } catch (error) {
128
+ console.error(`Migration error for ${dbName}:`, error);
129
+ rejectRef.current?.(error);
130
+ }
131
+ });
132
+
133
+ return () => {
134
+ sqliteClient.terminate();
135
+ };
136
+ }, [sqliteClient, drizzle, migrations, dbName]);
137
+
138
+ return { drizzle, readyPromise, sqliteClient };
139
+ };
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ export { drizzleSqliteWasm } from "./drizzle/direct";
2
+ export { sqliteCollectionOptions as drizzleCollectionOptions } from "./collections/sqlite-collection";
3
+ export { syncableTable } from "@firtoz/drizzle-utils";
4
+ export { makeId } from "@firtoz/drizzle-utils";
5
+ export type {
6
+ IdOf,
7
+ TableId,
8
+ Branded,
9
+ SelectSchema,
10
+ InsertSchema,
11
+ } from "@firtoz/drizzle-utils";
12
+ export { useDrizzleSqliteDb } from "./hooks/useDrizzleSqliteDb";
13
+ // SQLite WASM Provider
14
+ export {
15
+ DrizzleSqliteProvider,
16
+ DrizzleSqliteContext,
17
+ useSqliteCollection,
18
+ } from "./context/DrizzleSqliteProvider";
19
+ export type { DrizzleSqliteContextValue } from "./context/DrizzleSqliteProvider";
20
+ export { useDrizzleSqlite } from "./context/useDrizzleSqlite";
21
+ export type { UseDrizzleSqliteReturn } from "./context/useDrizzleSqlite";
22
+
23
+ export {
24
+ initializeSqliteWorker,
25
+ getSqliteWorkerManager,
26
+ isSqliteWorkerInitialized,
27
+ resetSqliteWorkerManager,
28
+ } from "./worker/global-manager";
29
+ export { SqliteWorkerManager, DbInstance } from "./worker/manager";
30
+ export type { ISqliteWorkerClient } from "./worker/manager";
31
+ export { customSqliteMigrate } from "./migration/migrator";
32
+ export type { DurableSqliteMigrationConfig } from "./migration/migrator";