rozenite-sqlite 0.0.9 → 0.0.12

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,17 @@
1
+ export const PLUGIN_ID = 'rozenite-sqlite' as const;
2
+
3
+ export const EVENTS = {
4
+ GET_DB_LIST: 'get-db-list',
5
+ SEND_DB_LIST: 'send-db-list',
6
+ SQL_EXECUTE: 'sql-execute',
7
+ SQL_EXEC_RESULT: 'sql-exec-result',
8
+ SAVE_ROW: 'save-row',
9
+ DELETE_ROW: 'delete-row',
10
+ CLEAR_TABLE: 'clear-table',
11
+ MUTATION_RESULT: 'mutation-result',
12
+ } as const;
13
+
14
+ export const QUERIES = {
15
+ LIST_TABLES:
16
+ "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name",
17
+ } as const;
@@ -0,0 +1,217 @@
1
+ import { useEffect, useRef, useCallback } from 'react';
2
+ import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
3
+ import { EVENTS, PLUGIN_ID, QUERIES } from '../constants';
4
+ import type { RowData } from '../theme';
5
+ import type { Action } from './useExplorerState';
6
+
7
+ type BridgeClient = ReturnType<typeof useRozeniteDevToolsClient>;
8
+
9
+ function fetchTables(client: NonNullable<BridgeClient>, dbName: string) {
10
+ client.send(EVENTS.SQL_EXECUTE, { dbName, query: QUERIES.LIST_TABLES });
11
+ }
12
+
13
+ function fetchData(client: NonNullable<BridgeClient>, dbName: string, tableName: string) {
14
+ client.send(EVENTS.SQL_EXECUTE, { dbName, query: `SELECT * FROM "${tableName}"` });
15
+ }
16
+
17
+ export interface MutationResult {
18
+ success: boolean;
19
+ error?: string;
20
+ }
21
+
22
+ export function useBridgeSync(
23
+ dispatch: React.Dispatch<Action>,
24
+ selectedDB: string | null,
25
+ selectedTable: string | null,
26
+ ) {
27
+ const client = useRozeniteDevToolsClient({ pluginId: PLUGIN_ID });
28
+ const pendingRef = useRef<'tables' | 'data' | 'query' | null>(null);
29
+ const selectedDBRef = useRef(selectedDB);
30
+ const selectedTableRef = useRef(selectedTable);
31
+ const customQueryResolverRef = useRef<((r: { rows: RowData[]; columns: string[]; error?: string }) => void) | null>(null);
32
+ const mutationResolverRef = useRef<((r: MutationResult) => void) | null>(null);
33
+ useEffect(() => { selectedDBRef.current = selectedDB; }, [selectedDB]);
34
+ useEffect(() => { selectedTableRef.current = selectedTable; }, [selectedTable]);
35
+
36
+ useEffect(() => {
37
+ if (!client) return;
38
+ // Reset state fully so the DB→tables→data cascade re-runs cleanly.
39
+ // This prevents the race where effects 2 & 3 both fire on reconnect/hot-reload.
40
+ dispatch({ type: 'RESET' });
41
+ client.send(EVENTS.GET_DB_LIST, true);
42
+
43
+ const sub1 = client.onMessage(EVENTS.SEND_DB_LIST, (payload: unknown) => {
44
+ const databases = Array.isArray(payload) ? (payload as string[]) : [];
45
+ dispatch({ type: 'SET_DATABASES', databases });
46
+ });
47
+
48
+ const sub2 = client.onMessage(EVENTS.SQL_EXEC_RESULT, (payload: unknown) => {
49
+ const mode = pendingRef.current;
50
+ const isError = payload !== null && typeof payload === 'object' && 'error' in payload;
51
+ const errorMsg = isError ? String((payload as { error: unknown }).error) : null;
52
+
53
+ if (mode === 'query') {
54
+ const resolver = customQueryResolverRef.current;
55
+ customQueryResolverRef.current = null;
56
+ pendingRef.current = null;
57
+ resolver?.(
58
+ isError
59
+ ? { rows: [], columns: [], error: errorMsg! }
60
+ : (() => {
61
+ const rows: RowData[] = Array.isArray(payload) ? (payload as RowData[]) : [];
62
+ return { rows, columns: rows.length > 0 ? Object.keys(rows[0]) : [] };
63
+ })(),
64
+ );
65
+ return;
66
+ }
67
+
68
+ if (isError) {
69
+ dispatch({ type: 'SET_ERROR', error: errorMsg! });
70
+ return;
71
+ }
72
+
73
+ if (mode === 'tables') {
74
+ const tables = Array.isArray(payload)
75
+ ? (payload as Array<{ name: string }>).map((r) => r.name)
76
+ : [];
77
+ dispatch({ type: 'SET_TABLES', tables });
78
+ } else if (mode === 'data') {
79
+ const rows: RowData[] = Array.isArray(payload) ? (payload as RowData[]) : [];
80
+ dispatch({ type: 'SET_DATA', rows, columns: rows.length > 0 ? Object.keys(rows[0]) : [] });
81
+ }
82
+ });
83
+
84
+ const sub3 = client.onMessage(EVENTS.MUTATION_RESULT, (payload: unknown) => {
85
+ const resolver = mutationResolverRef.current;
86
+ mutationResolverRef.current = null;
87
+ const result = payload as MutationResult;
88
+ resolver?.(result);
89
+ });
90
+
91
+ return () => {
92
+ sub1.remove();
93
+ sub2.remove();
94
+ sub3.remove();
95
+ };
96
+ }, [client, dispatch]);
97
+
98
+ // No `client` in deps — driven purely by state changes from the cascade above.
99
+ // This prevents effects 2 & 3 from firing simultaneously when client reconnects.
100
+ useEffect(() => {
101
+ if (!client || !selectedDB) return;
102
+ pendingRef.current = 'tables';
103
+ dispatch({ type: 'LOAD_TABLES_START' });
104
+ fetchTables(client, selectedDB);
105
+ // eslint-disable-next-line react-hooks/exhaustive-deps
106
+ }, [selectedDB, dispatch]);
107
+
108
+ useEffect(() => {
109
+ if (!client || !selectedDB || !selectedTable) return;
110
+ pendingRef.current = 'data';
111
+ dispatch({ type: 'LOAD_DATA_START' });
112
+ fetchData(client, selectedDB, selectedTable);
113
+ // eslint-disable-next-line react-hooks/exhaustive-deps
114
+ }, [selectedDB, selectedTable, dispatch]);
115
+
116
+ const refresh = useCallback(() => {
117
+ if (!client) return;
118
+ if (selectedDB && selectedTable) {
119
+ pendingRef.current = 'data';
120
+ dispatch({ type: 'LOAD_DATA_START' });
121
+ fetchData(client, selectedDB, selectedTable);
122
+ } else if (selectedDB) {
123
+ pendingRef.current = 'tables';
124
+ dispatch({ type: 'LOAD_TABLES_START' });
125
+ fetchTables(client, selectedDB);
126
+ }
127
+ }, [client, selectedDB, selectedTable, dispatch]);
128
+
129
+ const clearTable = useCallback(
130
+ (): Promise<MutationResult> =>
131
+ new Promise((resolve) => {
132
+ const db = selectedDBRef.current;
133
+ const table = selectedTableRef.current;
134
+ if (!client || !db || !table) {
135
+ resolve({ success: false, error: 'No database or table selected' });
136
+ return;
137
+ }
138
+ mutationResolverRef.current = (result) => {
139
+ resolve(result);
140
+ // Refresh data after successful clear
141
+ if (result.success && client) {
142
+ pendingRef.current = 'data';
143
+ dispatch({ type: 'LOAD_DATA_START' });
144
+ fetchData(client, db, table);
145
+ }
146
+ };
147
+ client.send(EVENTS.CLEAR_TABLE, { dbName: db, table });
148
+ }),
149
+ [client, dispatch],
150
+ );
151
+
152
+ const runCustomQuery = useCallback(
153
+ (sql: string): Promise<{ rows: RowData[]; columns: string[]; error?: string }> =>
154
+ new Promise((resolve) => {
155
+ const db = selectedDBRef.current;
156
+ if (!client || !db) {
157
+ resolve({ rows: [], columns: [], error: 'No database selected' });
158
+ return;
159
+ }
160
+ pendingRef.current = 'query';
161
+ customQueryResolverRef.current = resolve;
162
+ client.send(EVENTS.SQL_EXECUTE, { dbName: db, query: sql });
163
+ }),
164
+ [client],
165
+ );
166
+
167
+ const saveRowToDB = useCallback(
168
+ (row: RowData, columns: string[]): Promise<MutationResult> =>
169
+ new Promise((resolve) => {
170
+ const db = selectedDBRef.current;
171
+ const table = selectedTableRef.current;
172
+ if (!client || !db || !table) {
173
+ resolve({ success: false, error: 'No database or table selected' });
174
+ return;
175
+ }
176
+ // Detect primary key: use 'id' if it exists, otherwise use first column
177
+ const primaryKey = columns.includes('id') ? 'id' : columns[0];
178
+ if (!primaryKey) {
179
+ resolve({ success: false, error: 'Cannot determine primary key' });
180
+ return;
181
+ }
182
+ mutationResolverRef.current = resolve;
183
+ client.send(EVENTS.SAVE_ROW, { dbName: db, table, row, primaryKey });
184
+ }),
185
+ [client],
186
+ );
187
+
188
+ const deleteRowFromDB = useCallback(
189
+ (row: RowData, columns: string[]): Promise<MutationResult> =>
190
+ new Promise((resolve) => {
191
+ const db = selectedDBRef.current;
192
+ const table = selectedTableRef.current;
193
+ if (!client || !db || !table) {
194
+ resolve({ success: false, error: 'No database or table selected' });
195
+ return;
196
+ }
197
+ // Detect primary key: use 'id' if it exists, otherwise use first column
198
+ const primaryKey = columns.includes('id') ? 'id' : columns[0];
199
+ if (!primaryKey) {
200
+ resolve({ success: false, error: 'Cannot determine primary key' });
201
+ return;
202
+ }
203
+ const primaryKeyValue = row[primaryKey];
204
+ mutationResolverRef.current = resolve;
205
+ client.send(EVENTS.DELETE_ROW, { dbName: db, table, primaryKey, primaryKeyValue });
206
+ }),
207
+ [client],
208
+ );
209
+
210
+ const reconnect = useCallback(() => {
211
+ if (!client) return;
212
+ dispatch({ type: 'RESET' });
213
+ client.send(EVENTS.GET_DB_LIST, true);
214
+ }, [client, dispatch]);
215
+
216
+ return { refresh, clearTable, runCustomQuery, saveRowToDB, deleteRowFromDB, reconnect };
217
+ }
@@ -0,0 +1,216 @@
1
+ import { useReducer, useCallback, useState } from 'react';
2
+ import type { RowData } from '../theme';
3
+ import { useBridgeSync, type MutationResult } from './useBridgeSync';
4
+
5
+ export type ExplorerStatus = 'connecting' | 'idle' | 'loadingTables' | 'loadingData' | 'error';
6
+
7
+ export interface ExplorerState {
8
+ databases: string[];
9
+ selectedDB: string | null;
10
+ tables: string[];
11
+ selectedTable: string | null;
12
+ rows: RowData[];
13
+ columns: string[];
14
+ selectedRowIndex: number | null;
15
+ status: ExplorerStatus;
16
+ error: string | null;
17
+ }
18
+
19
+ export type Action =
20
+ | { type: 'RESET' }
21
+ | { type: 'SELECT_DB'; db: string }
22
+ | { type: 'SELECT_TABLE'; table: string }
23
+ | { type: 'SET_DATABASES'; databases: string[] }
24
+ | { type: 'LOAD_TABLES_START' }
25
+ | { type: 'SET_TABLES'; tables: string[] }
26
+ | { type: 'LOAD_DATA_START' }
27
+ | { type: 'SET_DATA'; rows: RowData[]; columns: string[] }
28
+ | { type: 'SET_ERROR'; error: string }
29
+ | { type: 'SELECT_ROW'; index: number }
30
+ | { type: 'CLOSE_ROW' }
31
+ | { type: 'SAVE_ROW'; updated: RowData }
32
+ | { type: 'DELETE_ROW' };
33
+
34
+ const initial: ExplorerState = {
35
+ databases: [],
36
+ selectedDB: null,
37
+ tables: [],
38
+ selectedTable: null,
39
+ rows: [],
40
+ columns: [],
41
+ selectedRowIndex: null,
42
+ status: 'connecting',
43
+ error: null,
44
+ };
45
+
46
+ function reducer(state: ExplorerState, action: Action): ExplorerState {
47
+ switch (action.type) {
48
+ case 'RESET':
49
+ return { ...initial };
50
+
51
+ case 'SELECT_DB':
52
+ return {
53
+ ...state,
54
+ selectedDB: action.db,
55
+ tables: [],
56
+ selectedTable: null,
57
+ rows: [],
58
+ columns: [],
59
+ selectedRowIndex: null,
60
+ error: null,
61
+ };
62
+
63
+ case 'SELECT_TABLE':
64
+ return { ...state, selectedTable: action.table, selectedRowIndex: null, error: null };
65
+
66
+ case 'SET_DATABASES':
67
+ return {
68
+ ...state,
69
+ status: 'idle',
70
+ databases: action.databases,
71
+ selectedDB: action.databases[0] ?? null,
72
+ error: null,
73
+ };
74
+
75
+ case 'LOAD_TABLES_START':
76
+ return {
77
+ ...state,
78
+ status: 'loadingTables',
79
+ tables: [],
80
+ selectedTable: null,
81
+ rows: [],
82
+ columns: [],
83
+ error: null,
84
+ };
85
+
86
+ case 'SET_TABLES': {
87
+ const { tables } = action;
88
+ const selectedTable = tables.includes(state.selectedTable ?? '')
89
+ ? state.selectedTable
90
+ : (tables[0] ?? null);
91
+ return { ...state, status: 'idle', tables, selectedTable };
92
+ }
93
+
94
+ case 'LOAD_DATA_START':
95
+ return {
96
+ ...state,
97
+ status: 'loadingData',
98
+ rows: [],
99
+ columns: [],
100
+ selectedRowIndex: null,
101
+ error: null,
102
+ };
103
+
104
+ case 'SET_DATA':
105
+ return {
106
+ ...state,
107
+ status: 'idle',
108
+ rows: action.rows,
109
+ columns: action.columns,
110
+ selectedRowIndex: null,
111
+ };
112
+
113
+ case 'SET_ERROR':
114
+ return { ...state, status: 'error', error: action.error };
115
+
116
+ case 'SELECT_ROW':
117
+ return {
118
+ ...state,
119
+ selectedRowIndex: state.selectedRowIndex === action.index ? null : action.index,
120
+ };
121
+
122
+ case 'CLOSE_ROW':
123
+ return { ...state, selectedRowIndex: null };
124
+
125
+ case 'SAVE_ROW':
126
+ if (state.selectedRowIndex === null) return state;
127
+ return {
128
+ ...state,
129
+ rows: state.rows.map((r, i) => (i === state.selectedRowIndex ? action.updated : r)),
130
+ };
131
+
132
+ case 'DELETE_ROW':
133
+ if (state.selectedRowIndex === null) return state;
134
+ return {
135
+ ...state,
136
+ rows: state.rows.filter((_, i) => i !== state.selectedRowIndex),
137
+ selectedRowIndex: null,
138
+ };
139
+
140
+ default:
141
+ return state;
142
+ }
143
+ }
144
+
145
+ export function useExplorerState() {
146
+ const [state, dispatch] = useReducer(reducer, initial);
147
+ const { selectedDB, selectedTable, columns, selectedRowIndex, rows } = state;
148
+ const [mutating, setMutating] = useState(false);
149
+ const [mutationError, setMutationError] = useState<string | null>(null);
150
+
151
+ const { refresh, clearTable, runCustomQuery, saveRowToDB, deleteRowFromDB, reconnect } = useBridgeSync(dispatch, selectedDB, selectedTable);
152
+
153
+ const selectDB = useCallback((db: string) => dispatch({ type: 'SELECT_DB', db }), []);
154
+ const selectTable = useCallback((table: string) => dispatch({ type: 'SELECT_TABLE', table }), []);
155
+ const selectRow = useCallback((index: number) => dispatch({ type: 'SELECT_ROW', index }), []);
156
+ const closeRow = useCallback(() => dispatch({ type: 'CLOSE_ROW' }), []);
157
+
158
+ const saveRow = useCallback(
159
+ async (updated: RowData): Promise<MutationResult> => {
160
+ setMutating(true);
161
+ setMutationError(null);
162
+ const result = await saveRowToDB(updated, columns);
163
+ setMutating(false);
164
+ if (result.success) {
165
+ dispatch({ type: 'SAVE_ROW', updated });
166
+ } else {
167
+ setMutationError(result.error ?? 'Failed to save row');
168
+ }
169
+ return result;
170
+ },
171
+ [saveRowToDB, columns],
172
+ );
173
+
174
+ const deleteRow = useCallback(
175
+ async (): Promise<MutationResult> => {
176
+ if (selectedRowIndex === null) {
177
+ return { success: false, error: 'No row selected' };
178
+ }
179
+ const row = rows[selectedRowIndex];
180
+ if (!row) {
181
+ return { success: false, error: 'Row not found' };
182
+ }
183
+ setMutating(true);
184
+ setMutationError(null);
185
+ const result = await deleteRowFromDB(row, columns);
186
+ setMutating(false);
187
+ if (result.success) {
188
+ dispatch({ type: 'DELETE_ROW' });
189
+ } else {
190
+ setMutationError(result.error ?? 'Failed to delete row');
191
+ }
192
+ return result;
193
+ },
194
+ [deleteRowFromDB, columns, selectedRowIndex, rows],
195
+ );
196
+
197
+ const clearMutationError = useCallback(() => setMutationError(null), []);
198
+
199
+ return {
200
+ state,
201
+ selectDB,
202
+ selectTable,
203
+ selectRow,
204
+ closeRow,
205
+ saveRow,
206
+ deleteRow,
207
+ refresh,
208
+ clearTable,
209
+ runCustomQuery,
210
+ mutating,
211
+ mutationError,
212
+ clearMutationError,
213
+ reconnect,
214
+ };
215
+ }
216
+
@@ -0,0 +1,127 @@
1
+ import { useEffect, useRef } from 'react';
2
+ import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
3
+ import { EVENTS, PLUGIN_ID } from '../constants';
4
+
5
+ export type SQLExecutor = (
6
+ dbName: string,
7
+ query: string,
8
+ ) => Promise<Record<string, unknown>[]>;
9
+
10
+ export interface RozeniteSQLiteConfig {
11
+ /** List of database names exposed to the devtools panel, e.g. ["app.db", "cache.db"] */
12
+ databases: string[];
13
+ /** Library-agnostic SQL runner — receives the db name and raw query, returns rows */
14
+ sqlExecutor: SQLExecutor;
15
+ }
16
+
17
+ /** Escape a value for safe SQL string interpolation */
18
+ function escapeValue(val: unknown): string {
19
+ if (val === null || val === undefined) return 'NULL';
20
+ if (typeof val === 'number') return String(val);
21
+ if (typeof val === 'boolean') return val ? '1' : '0';
22
+ // Escape single quotes by doubling them
23
+ const str = String(val).replace(/'/g, "''");
24
+ return `'${str}'`;
25
+ }
26
+
27
+ /**
28
+ * Connects your React Native app to the SQLighter devtools panel.
29
+ *
30
+ * Call this once somewhere near the root of your app (or in the component
31
+ * that holds the database instances). It handles all devtools communication —
32
+ * you don't need to touch the plugin-bridge directly.
33
+ *
34
+ * @example
35
+ * useRozeniteSQLite({
36
+ * databases: ['app.db', 'cache.db'],
37
+ * sqlExecutor: async (dbName, query) => {
38
+ * const db = myDatabases[dbName];
39
+ * return db.getAllAsync(query);
40
+ * },
41
+ * });
42
+ */
43
+ export function useRozeniteSQLite(config: RozeniteSQLiteConfig): void {
44
+ const client = useRozeniteDevToolsClient({ pluginId: PLUGIN_ID });
45
+
46
+ const configRef = useRef(config);
47
+ useEffect(() => {
48
+ configRef.current = config;
49
+ });
50
+
51
+ useEffect(() => {
52
+ if (!client) return;
53
+
54
+ const subs = [
55
+ client.onMessage(EVENTS.GET_DB_LIST, () => {
56
+ client.send(EVENTS.SEND_DB_LIST, configRef.current.databases);
57
+ }),
58
+
59
+ client.onMessage(EVENTS.SQL_EXECUTE, (payload: unknown) => {
60
+ const { dbName, query } = payload as { dbName: string; query: string };
61
+ configRef.current.sqlExecutor(dbName, query).then(
62
+ (rows) => {
63
+ client.send(EVENTS.SQL_EXEC_RESULT, rows);
64
+ },
65
+ (error: unknown) => {
66
+ client.send(EVENTS.SQL_EXEC_RESULT, {
67
+ error: error instanceof Error ? error.message : String(error),
68
+ });
69
+ },
70
+ );
71
+ }),
72
+
73
+ client.onMessage(EVENTS.SAVE_ROW, (payload: unknown) => {
74
+ const { dbName, table, row, primaryKey } = payload as {
75
+ dbName: string;
76
+ table: string;
77
+ row: Record<string, unknown>;
78
+ primaryKey: string;
79
+ };
80
+ const pkValue = row[primaryKey];
81
+ const columns = Object.keys(row).filter((k) => k !== primaryKey);
82
+ const setClause = columns.map((col) => `"${col}" = ${escapeValue(row[col])}`).join(', ');
83
+ const query = `UPDATE "${table}" SET ${setClause} WHERE "${primaryKey}" = ${escapeValue(pkValue)}`;
84
+ configRef.current.sqlExecutor(dbName, query).then(
85
+ () => client.send(EVENTS.MUTATION_RESULT, { success: true }),
86
+ (error: unknown) => client.send(EVENTS.MUTATION_RESULT, {
87
+ success: false,
88
+ error: error instanceof Error ? error.message : String(error),
89
+ }),
90
+ );
91
+ }),
92
+
93
+ client.onMessage(EVENTS.DELETE_ROW, (payload: unknown) => {
94
+ const { dbName, table, primaryKey, primaryKeyValue } = payload as {
95
+ dbName: string;
96
+ table: string;
97
+ primaryKey: string;
98
+ primaryKeyValue: unknown;
99
+ };
100
+ const query = `DELETE FROM "${table}" WHERE "${primaryKey}" = ${escapeValue(primaryKeyValue)}`;
101
+ configRef.current.sqlExecutor(dbName, query).then(
102
+ () => client.send(EVENTS.MUTATION_RESULT, { success: true }),
103
+ (error: unknown) => client.send(EVENTS.MUTATION_RESULT, {
104
+ success: false,
105
+ error: error instanceof Error ? error.message : String(error),
106
+ }),
107
+ );
108
+ }),
109
+
110
+ client.onMessage(EVENTS.CLEAR_TABLE, (payload: unknown) => {
111
+ const { dbName, table } = payload as { dbName: string; table: string };
112
+ const query = `DELETE FROM "${table}"`;
113
+ configRef.current.sqlExecutor(dbName, query).then(
114
+ () => client.send(EVENTS.MUTATION_RESULT, { success: true }),
115
+ (error: unknown) => client.send(EVENTS.MUTATION_RESULT, {
116
+ success: false,
117
+ error: error instanceof Error ? error.message : String(error),
118
+ }),
119
+ );
120
+ }),
121
+ ];
122
+
123
+ return () => {
124
+ subs.forEach((sub) => sub.remove());
125
+ };
126
+ }, [client]);
127
+ }
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export * from './hooks/useRozeniteSQLite';