@rozenite/sqlite-plugin 1.7.0-rc.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/LICENSE +20 -0
  3. package/README.md +102 -0
  4. package/dist/devtools/assets/panel-B3paLkwG.js +82 -0
  5. package/dist/devtools/assets/panel-CIU0JBOs.css +1 -0
  6. package/dist/devtools/panel.html +31 -0
  7. package/dist/react-native/chunks/bridge-values.cjs +5 -0
  8. package/dist/react-native/chunks/bridge-values.js +258 -0
  9. package/dist/react-native/chunks/index.require.cjs +1 -0
  10. package/dist/react-native/chunks/index.require.js +118 -0
  11. package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.cjs +1 -0
  12. package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.js +189 -0
  13. package/dist/react-native/index.cjs +1 -0
  14. package/dist/react-native/index.d.ts +178 -0
  15. package/dist/react-native/index.js +16 -0
  16. package/dist/rozenite.json +1 -0
  17. package/package.json +83 -0
  18. package/postcss.config.js +6 -0
  19. package/react-native.ts +55 -0
  20. package/rozenite.config.ts +8 -0
  21. package/src/react-native/adapters/__tests__/expo-sqlite.test.ts +94 -0
  22. package/src/react-native/adapters/expo-sqlite.ts +230 -0
  23. package/src/react-native/adapters/generic.ts +88 -0
  24. package/src/react-native/adapters/index.ts +9 -0
  25. package/src/react-native/sqlite-view.ts +24 -0
  26. package/src/react-native/useRozeniteSqlitePlugin.ts +262 -0
  27. package/src/shared/__tests__/bridge-values.test.ts +34 -0
  28. package/src/shared/__tests__/sql.test.ts +55 -0
  29. package/src/shared/bridge-values.ts +170 -0
  30. package/src/shared/protocol.ts +41 -0
  31. package/src/shared/sql.ts +420 -0
  32. package/src/shared/types.ts +81 -0
  33. package/src/ui/__tests__/sql-editor-utils.test.ts +135 -0
  34. package/src/ui/__tests__/sqlite-row-edit-value.test.ts +22 -0
  35. package/src/ui/__tests__/sqlite-row-mutations.test.ts +310 -0
  36. package/src/ui/__tests__/sqlite-table-column-order.test.ts +83 -0
  37. package/src/ui/__tests__/value-utils.test.tsx +12 -0
  38. package/src/ui/cell-detail-drawer.tsx +65 -0
  39. package/src/ui/globals.css +1415 -0
  40. package/src/ui/panel.tsx +2815 -0
  41. package/src/ui/query-result-table.tsx +199 -0
  42. package/src/ui/sql-editor-utils.ts +352 -0
  43. package/src/ui/sql-editor.tsx +509 -0
  44. package/src/ui/sqlite-data-table.tsx +296 -0
  45. package/src/ui/sqlite-introspection.ts +189 -0
  46. package/src/ui/sqlite-modal-controls.tsx +32 -0
  47. package/src/ui/sqlite-row-delete-modal.tsx +130 -0
  48. package/src/ui/sqlite-row-edit-modal.tsx +487 -0
  49. package/src/ui/sqlite-row-edit-value.ts +53 -0
  50. package/src/ui/sqlite-row-mutations.ts +246 -0
  51. package/src/ui/sqlite-table-column-order.ts +154 -0
  52. package/src/ui/use-sqlite-requests.ts +205 -0
  53. package/src/ui/utils.ts +107 -0
  54. package/src/ui/value-utils.tsx +162 -0
  55. package/tsconfig.json +36 -0
  56. package/vite.config.ts +20 -0
@@ -0,0 +1,246 @@
1
+ import { quoteSqlIdentifier } from '../shared/sql';
2
+ import type { SqliteQueryParams } from '../shared/types';
3
+ import type { SqliteColumnInfo, SqliteEntity } from './sqlite-introspection';
4
+
5
+ export const SQLITE_HIDDEN_ROWID_COLUMN_ID = '__sqlite-hidden-rowid__';
6
+ export const SQLITE_ROW_ACTIONS_COLUMN_ID = '__sqlite-row-actions__';
7
+
8
+ const SQLITE_ROWID_IDENTIFIERS = ['rowid', '_rowid_', 'oid'] as const;
9
+
10
+ export type SqliteRowIdIdentifier = (typeof SQLITE_ROWID_IDENTIFIERS)[number];
11
+
12
+ export type SqliteEditableValueKind =
13
+ | 'text'
14
+ | 'number'
15
+ | 'boolean'
16
+ | 'blob-ish'
17
+ | 'json';
18
+
19
+ export type SqliteRowMutationDescriptor =
20
+ | {
21
+ mode: 'primary-key';
22
+ primaryKeyColumns: SqliteColumnInfo[];
23
+ }
24
+ | {
25
+ mode: 'rowid';
26
+ rowIdIdentifier: SqliteRowIdIdentifier;
27
+ };
28
+
29
+ type BuildRowUpdateMutationInput = {
30
+ entity: SqliteEntity;
31
+ columns: SqliteColumnInfo[];
32
+ row: Record<string, unknown>;
33
+ descriptor: SqliteRowMutationDescriptor;
34
+ nextValues: Record<string, unknown>;
35
+ };
36
+
37
+ type BuildRowDeleteMutationInput = {
38
+ entity: SqliteEntity;
39
+ row: Record<string, unknown>;
40
+ descriptor: SqliteRowMutationDescriptor;
41
+ };
42
+
43
+ type SqliteMutationResult = {
44
+ sql: string;
45
+ params: SqliteQueryParams;
46
+ };
47
+
48
+ const buildQualifiedEntityName = (schemaName: string, entityName: string) =>
49
+ `${quoteSqlIdentifier(schemaName)}.${quoteSqlIdentifier(entityName)}`;
50
+
51
+ const isWithoutRowIdEntity = (entity: SqliteEntity) =>
52
+ /\bwithout\s+rowid\b/i.test(entity.sql ?? '');
53
+
54
+ export const getPrimaryKeyColumns = (columns: SqliteColumnInfo[]) =>
55
+ columns
56
+ .filter((column) => column.primaryKeyOrder > 0)
57
+ .sort((left, right) => left.primaryKeyOrder - right.primaryKeyOrder);
58
+
59
+ export const getEditableColumns = (columns: SqliteColumnInfo[]) =>
60
+ columns.filter(
61
+ (column) => column.primaryKeyOrder === 0 && column.hidden === 0,
62
+ );
63
+
64
+ const getNormalizedColumnType = (column: SqliteColumnInfo) =>
65
+ column.type.trim().toLowerCase();
66
+
67
+ const inferValueKindFromRuntimeValue = (
68
+ value: unknown,
69
+ ): SqliteEditableValueKind => {
70
+ if (typeof value === 'number') {
71
+ return 'number';
72
+ }
73
+
74
+ if (typeof value === 'boolean') {
75
+ return 'boolean';
76
+ }
77
+
78
+ if (Array.isArray(value)) {
79
+ return value.every((item) => typeof item === 'number')
80
+ ? 'blob-ish'
81
+ : 'json';
82
+ }
83
+
84
+ if (value && typeof value === 'object') {
85
+ return 'json';
86
+ }
87
+
88
+ return 'text';
89
+ };
90
+
91
+ export const getCompatibleValueKinds = (
92
+ column: SqliteColumnInfo,
93
+ value: unknown,
94
+ ): SqliteEditableValueKind[] => {
95
+ const normalizedType = getNormalizedColumnType(column);
96
+
97
+ if (normalizedType.includes('json')) {
98
+ return ['json'];
99
+ }
100
+
101
+ if (normalizedType.includes('blob')) {
102
+ return ['blob-ish'];
103
+ }
104
+
105
+ if (normalizedType.includes('bool')) {
106
+ return ['boolean'];
107
+ }
108
+
109
+ if (
110
+ normalizedType.includes('int') ||
111
+ normalizedType.includes('real') ||
112
+ normalizedType.includes('floa') ||
113
+ normalizedType.includes('doub') ||
114
+ normalizedType.includes('num') ||
115
+ normalizedType.includes('dec')
116
+ ) {
117
+ return ['number'];
118
+ }
119
+
120
+ if (
121
+ normalizedType.includes('char') ||
122
+ normalizedType.includes('clob') ||
123
+ normalizedType.includes('text') ||
124
+ normalizedType.includes('varchar') ||
125
+ normalizedType.includes('string')
126
+ ) {
127
+ return ['text'];
128
+ }
129
+
130
+ return [inferValueKindFromRuntimeValue(value)];
131
+ };
132
+
133
+ export const canColumnBeNull = (column: SqliteColumnInfo) => !column.notNull;
134
+
135
+ export const getAvailableRowIdIdentifier = (
136
+ columns: SqliteColumnInfo[],
137
+ ): SqliteRowIdIdentifier | null => {
138
+ const lowerCaseColumnNames = new Set(
139
+ columns.map((column) => column.name.toLowerCase()),
140
+ );
141
+
142
+ return (
143
+ SQLITE_ROWID_IDENTIFIERS.find(
144
+ (identifier) => !lowerCaseColumnNames.has(identifier),
145
+ ) ?? null
146
+ );
147
+ };
148
+
149
+ export const getRowMutationDescriptor = (
150
+ entity: SqliteEntity | null,
151
+ columns: SqliteColumnInfo[],
152
+ ): SqliteRowMutationDescriptor | null => {
153
+ if (!entity || entity.type !== 'table' || columns.length === 0) {
154
+ return null;
155
+ }
156
+
157
+ const primaryKeyColumns = getPrimaryKeyColumns(columns);
158
+ if (primaryKeyColumns.length > 0) {
159
+ return {
160
+ mode: 'primary-key',
161
+ primaryKeyColumns,
162
+ };
163
+ }
164
+
165
+ if (isWithoutRowIdEntity(entity)) {
166
+ return null;
167
+ }
168
+
169
+ const rowIdIdentifier = getAvailableRowIdIdentifier(columns);
170
+ if (!rowIdIdentifier) {
171
+ return null;
172
+ }
173
+
174
+ return {
175
+ mode: 'rowid',
176
+ rowIdIdentifier,
177
+ };
178
+ };
179
+
180
+ const buildWhereClause = (
181
+ row: Record<string, unknown>,
182
+ descriptor: SqliteRowMutationDescriptor,
183
+ ) => {
184
+ if (descriptor.mode === 'primary-key') {
185
+ const params = descriptor.primaryKeyColumns.map(
186
+ (column) => row[column.name],
187
+ );
188
+ const clause = descriptor.primaryKeyColumns
189
+ .map((column) => `${quoteSqlIdentifier(column.name)} = ?`)
190
+ .join(' AND ');
191
+
192
+ return {
193
+ clause,
194
+ params,
195
+ };
196
+ }
197
+
198
+ return {
199
+ clause: `${descriptor.rowIdIdentifier} = ?`,
200
+ params: [row[SQLITE_HIDDEN_ROWID_COLUMN_ID]],
201
+ };
202
+ };
203
+
204
+ export const buildRowUpdateMutation = ({
205
+ entity,
206
+ columns,
207
+ row,
208
+ descriptor,
209
+ nextValues,
210
+ }: BuildRowUpdateMutationInput): SqliteMutationResult => {
211
+ const updateColumnNames = getEditableColumns(columns)
212
+ .map((column) => column.name)
213
+ .filter((columnName) =>
214
+ Object.prototype.hasOwnProperty.call(nextValues, columnName),
215
+ );
216
+
217
+ if (updateColumnNames.length === 0) {
218
+ throw new Error('No editable columns are available for this row.');
219
+ }
220
+
221
+ const assignments = updateColumnNames.map(
222
+ (columnName) => `${quoteSqlIdentifier(columnName)} = ?`,
223
+ );
224
+ const assignmentParams = updateColumnNames.map(
225
+ (columnName) => nextValues[columnName],
226
+ );
227
+ const where = buildWhereClause(row, descriptor);
228
+
229
+ return {
230
+ sql: `UPDATE ${buildQualifiedEntityName(entity.schemaName, entity.name)} SET ${assignments.join(', ')} WHERE ${where.clause}`,
231
+ params: [...assignmentParams, ...where.params],
232
+ };
233
+ };
234
+
235
+ export const buildRowDeleteMutation = ({
236
+ entity,
237
+ row,
238
+ descriptor,
239
+ }: BuildRowDeleteMutationInput): SqliteMutationResult => {
240
+ const where = buildWhereClause(row, descriptor);
241
+
242
+ return {
243
+ sql: `DELETE FROM ${buildQualifiedEntityName(entity.schemaName, entity.name)} WHERE ${where.clause}`,
244
+ params: where.params,
245
+ };
246
+ };
@@ -0,0 +1,154 @@
1
+ import type { Updater } from '@tanstack/react-table';
2
+
3
+ export const SQLITE_ROW_NUMBER_COLUMN_ID = '__sqlite-row-number__';
4
+
5
+ type NormalizeTableColumnOrderInput = {
6
+ columnIds: string[];
7
+ fixedLeadingColumnIds?: string[];
8
+ storedColumnOrder?: string[] | null;
9
+ };
10
+
11
+ type ResolveTableColumnOrderUpdateInput = NormalizeTableColumnOrderInput & {
12
+ nextColumnOrder: Updater<string[]>;
13
+ };
14
+
15
+ type ReorderTableColumnOrderInput = NormalizeTableColumnOrderInput & {
16
+ activeColumnId: string;
17
+ overColumnId: string;
18
+ };
19
+
20
+ const applyUpdater = <TValue>(
21
+ updater: Updater<TValue>,
22
+ currentValue: TValue,
23
+ ): TValue =>
24
+ typeof updater === 'function'
25
+ ? (updater as (old: TValue) => TValue)(currentValue)
26
+ : updater;
27
+
28
+ export const getDefaultTableColumnOrder = (
29
+ columnIds: string[],
30
+ fixedLeadingColumnIds: string[] = [],
31
+ ) =>
32
+ normalizeTableColumnOrder({
33
+ columnIds,
34
+ fixedLeadingColumnIds,
35
+ });
36
+
37
+ export const normalizeTableColumnOrder = ({
38
+ columnIds,
39
+ fixedLeadingColumnIds = [],
40
+ storedColumnOrder,
41
+ }: NormalizeTableColumnOrderInput) => {
42
+ const availableColumnIds = new Set(columnIds);
43
+ const seenColumnIds = new Set<string>();
44
+ const normalizedOrder: string[] = [];
45
+
46
+ for (const columnId of fixedLeadingColumnIds) {
47
+ if (!availableColumnIds.has(columnId) || seenColumnIds.has(columnId)) {
48
+ continue;
49
+ }
50
+
51
+ normalizedOrder.push(columnId);
52
+ seenColumnIds.add(columnId);
53
+ }
54
+
55
+ for (const columnId of storedColumnOrder ?? []) {
56
+ if (!availableColumnIds.has(columnId) || seenColumnIds.has(columnId)) {
57
+ continue;
58
+ }
59
+
60
+ normalizedOrder.push(columnId);
61
+ seenColumnIds.add(columnId);
62
+ }
63
+
64
+ for (const columnId of columnIds) {
65
+ if (seenColumnIds.has(columnId)) {
66
+ continue;
67
+ }
68
+
69
+ normalizedOrder.push(columnId);
70
+ seenColumnIds.add(columnId);
71
+ }
72
+
73
+ return normalizedOrder;
74
+ };
75
+
76
+ export const reorderTableColumnOrder = ({
77
+ columnIds,
78
+ fixedLeadingColumnIds = [],
79
+ storedColumnOrder = [],
80
+ activeColumnId,
81
+ overColumnId,
82
+ }: ReorderTableColumnOrderInput) => {
83
+ const normalizedOrder = normalizeTableColumnOrder({
84
+ columnIds,
85
+ fixedLeadingColumnIds,
86
+ storedColumnOrder,
87
+ });
88
+ const fixedColumnIds = new Set(fixedLeadingColumnIds);
89
+
90
+ if (
91
+ activeColumnId === overColumnId ||
92
+ fixedColumnIds.has(activeColumnId) ||
93
+ fixedColumnIds.has(overColumnId)
94
+ ) {
95
+ return normalizedOrder;
96
+ }
97
+
98
+ const movableColumnOrder = normalizedOrder.filter(
99
+ (columnId) => !fixedColumnIds.has(columnId),
100
+ );
101
+ const activeIndex = movableColumnOrder.indexOf(activeColumnId);
102
+ const overIndex = movableColumnOrder.indexOf(overColumnId);
103
+
104
+ if (activeIndex === -1 || overIndex === -1) {
105
+ return normalizedOrder;
106
+ }
107
+
108
+ const nextMovableOrder = [...movableColumnOrder];
109
+ const [movedColumnId] = nextMovableOrder.splice(activeIndex, 1);
110
+ nextMovableOrder.splice(overIndex, 0, movedColumnId);
111
+
112
+ return [...fixedLeadingColumnIds, ...nextMovableOrder];
113
+ };
114
+
115
+ export const resolveTableColumnOrderUpdate = ({
116
+ columnIds,
117
+ fixedLeadingColumnIds = [],
118
+ storedColumnOrder = [],
119
+ nextColumnOrder,
120
+ }: ResolveTableColumnOrderUpdateInput) => {
121
+ const normalizedCurrentOrder = normalizeTableColumnOrder({
122
+ columnIds,
123
+ fixedLeadingColumnIds,
124
+ storedColumnOrder,
125
+ });
126
+
127
+ return normalizeTableColumnOrder({
128
+ columnIds,
129
+ fixedLeadingColumnIds,
130
+ storedColumnOrder: applyUpdater(nextColumnOrder, normalizedCurrentOrder),
131
+ });
132
+ };
133
+
134
+ export const areColumnOrdersEqual = (
135
+ leftColumnOrder: string[],
136
+ rightColumnOrder: string[],
137
+ ) =>
138
+ leftColumnOrder.length === rightColumnOrder.length &&
139
+ leftColumnOrder.every(
140
+ (columnId, columnIndex) => columnId === rightColumnOrder[columnIndex],
141
+ );
142
+
143
+ export const buildEntityTableId = (
144
+ scope: 'data' | 'structure-columns' | 'structure-indexes',
145
+ databaseId: string | null,
146
+ schemaName: string | null,
147
+ entityName: string | null,
148
+ ) =>
149
+ `${scope}:${databaseId ?? 'unknown'}:${schemaName ?? 'unknown'}:${entityName ?? 'unknown'}`;
150
+
151
+ export const buildQueryTableId = (
152
+ databaseId: string | null,
153
+ columnIds: string[],
154
+ ) => `query:${databaseId ?? 'unknown'}:${JSON.stringify(columnIds)}`;
@@ -0,0 +1,205 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ import type { RozeniteDevToolsClient } from '@rozenite/plugin-bridge';
3
+ import { encodeSqliteBridgeValue } from '../shared/bridge-values';
4
+ import type { SqliteEventMap } from '../shared/protocol';
5
+ import type {
6
+ SqliteDatabaseInfo,
7
+ SqliteQueryParams,
8
+ SqliteQueryResult,
9
+ SqliteScriptResult,
10
+ } from '../shared/types';
11
+ import { newRequestId, withTimeout } from './utils';
12
+
13
+ type PendingListResolver = (
14
+ payload: SqliteEventMap['sqlite:list-databases:result'],
15
+ ) => void;
16
+ type PendingQueryResolver = (
17
+ payload: SqliteEventMap['sqlite:query:result'],
18
+ ) => void;
19
+ type PendingScriptResolver = (
20
+ payload: SqliteEventMap['sqlite:execute-script:result'],
21
+ ) => void;
22
+
23
+ export const useSqliteRequests = (
24
+ client: RozeniteDevToolsClient<SqliteEventMap> | null,
25
+ ) => {
26
+ const listResolversRef = useRef(new Map<string, PendingListResolver>());
27
+ const queryResolversRef = useRef(new Map<string, PendingQueryResolver>());
28
+ const scriptResolversRef = useRef(new Map<string, PendingScriptResolver>());
29
+
30
+ useEffect(() => {
31
+ if (!client) {
32
+ return;
33
+ }
34
+
35
+ const listSubscription = client.onMessage(
36
+ 'sqlite:list-databases:result',
37
+ (payload) => {
38
+ const resolve = listResolversRef.current.get(payload.requestId);
39
+ if (!resolve) {
40
+ return;
41
+ }
42
+
43
+ listResolversRef.current.delete(payload.requestId);
44
+ resolve(payload);
45
+ },
46
+ );
47
+
48
+ const querySubscription = client.onMessage(
49
+ 'sqlite:query:result',
50
+ (payload) => {
51
+ const resolve = queryResolversRef.current.get(payload.requestId);
52
+ if (!resolve) {
53
+ return;
54
+ }
55
+
56
+ queryResolversRef.current.delete(payload.requestId);
57
+ resolve(payload);
58
+ },
59
+ );
60
+
61
+ const scriptSubscription = client.onMessage(
62
+ 'sqlite:execute-script:result',
63
+ (payload) => {
64
+ const resolve = scriptResolversRef.current.get(payload.requestId);
65
+ if (!resolve) {
66
+ return;
67
+ }
68
+
69
+ scriptResolversRef.current.delete(payload.requestId);
70
+ resolve(payload);
71
+ },
72
+ );
73
+
74
+ return () => {
75
+ listSubscription.remove();
76
+ querySubscription.remove();
77
+ scriptSubscription.remove();
78
+ listResolversRef.current.clear();
79
+ queryResolversRef.current.clear();
80
+ scriptResolversRef.current.clear();
81
+ };
82
+ }, [client]);
83
+
84
+ const requestDatabases = useCallback(async (): Promise<
85
+ SqliteDatabaseInfo[]
86
+ > => {
87
+ if (!client) {
88
+ return [];
89
+ }
90
+
91
+ const requestId = newRequestId();
92
+ const pending = new Promise<SqliteEventMap['sqlite:list-databases:result']>(
93
+ (resolve) => {
94
+ listResolversRef.current.set(requestId, resolve);
95
+ },
96
+ );
97
+
98
+ client.send('sqlite:list-databases', { requestId });
99
+
100
+ const response = await withTimeout(
101
+ pending,
102
+ 8000,
103
+ 'Timeout fetching databases.',
104
+ );
105
+
106
+ if (response.error) {
107
+ throw new Error(response.error);
108
+ }
109
+
110
+ return response.databases;
111
+ }, [client]);
112
+
113
+ const requestQuery = useCallback(
114
+ async (input: {
115
+ databaseId: string;
116
+ sql: string;
117
+ params?: SqliteQueryParams;
118
+ }): Promise<SqliteQueryResult> => {
119
+ if (!client) {
120
+ throw new Error('Rozenite client is not connected.');
121
+ }
122
+
123
+ const requestId = newRequestId();
124
+ const pending = new Promise<SqliteEventMap['sqlite:query:result']>(
125
+ (resolve) => {
126
+ queryResolversRef.current.set(requestId, resolve);
127
+ },
128
+ );
129
+
130
+ client.send('sqlite:query', {
131
+ requestId,
132
+ databaseId: input.databaseId,
133
+ sql: input.sql,
134
+ params:
135
+ input.params === undefined
136
+ ? undefined
137
+ : (encodeSqliteBridgeValue(input.params) as SqliteQueryParams),
138
+ });
139
+
140
+ const response = await withTimeout(
141
+ pending,
142
+ 15000,
143
+ 'Timeout executing SQL query.',
144
+ );
145
+
146
+ if (response.error) {
147
+ throw new Error(response.error);
148
+ }
149
+
150
+ if (!response.result) {
151
+ throw new Error('The query completed without a result payload.');
152
+ }
153
+
154
+ return response.result;
155
+ },
156
+ [client],
157
+ );
158
+
159
+ const requestScriptExecution = useCallback(
160
+ async (input: {
161
+ databaseId: string;
162
+ sql: string;
163
+ }): Promise<SqliteScriptResult> => {
164
+ if (!client) {
165
+ throw new Error('Rozenite client is not connected.');
166
+ }
167
+
168
+ const requestId = newRequestId();
169
+ const pending = new Promise<
170
+ SqliteEventMap['sqlite:execute-script:result']
171
+ >((resolve) => {
172
+ scriptResolversRef.current.set(requestId, resolve);
173
+ });
174
+
175
+ client.send('sqlite:execute-script', {
176
+ requestId,
177
+ databaseId: input.databaseId,
178
+ sql: input.sql,
179
+ });
180
+
181
+ const response = await withTimeout(
182
+ pending,
183
+ 30000,
184
+ 'Timeout executing SQL script.',
185
+ );
186
+
187
+ if (response.error) {
188
+ throw new Error(response.error);
189
+ }
190
+
191
+ if (!response.result) {
192
+ throw new Error('The script completed without a result payload.');
193
+ }
194
+
195
+ return response.result;
196
+ },
197
+ [client],
198
+ );
199
+
200
+ return {
201
+ requestDatabases,
202
+ requestQuery,
203
+ requestScriptExecution,
204
+ };
205
+ };
@@ -0,0 +1,107 @@
1
+ export function newRequestId(): string {
2
+ return `${Date.now().toString(16)}-${Math.random().toString(16).slice(2)}`;
3
+ }
4
+
5
+ export async function withTimeout<T>(
6
+ promise: Promise<T>,
7
+ ms: number,
8
+ message: string,
9
+ ): Promise<T> {
10
+ let timeout: ReturnType<typeof setTimeout> | null = null;
11
+
12
+ try {
13
+ return await Promise.race([
14
+ promise,
15
+ new Promise<T>((_, reject) => {
16
+ timeout = setTimeout(() => reject(new Error(message)), ms);
17
+ }),
18
+ ]);
19
+ } finally {
20
+ if (timeout) {
21
+ clearTimeout(timeout);
22
+ }
23
+ }
24
+ }
25
+
26
+ export const formatDuration = (durationMs?: number | null) => {
27
+ if (durationMs == null || Number.isNaN(durationMs)) {
28
+ return '—';
29
+ }
30
+
31
+ if (durationMs < 1) {
32
+ return `${durationMs.toFixed(2)} ms`;
33
+ }
34
+
35
+ if (durationMs < 100) {
36
+ return `${durationMs.toFixed(1)} ms`;
37
+ }
38
+
39
+ return `${Math.round(durationMs)} ms`;
40
+ };
41
+
42
+ export const formatNumber = (value?: number | null) => {
43
+ if (value == null || Number.isNaN(value)) {
44
+ return '—';
45
+ }
46
+
47
+ return new Intl.NumberFormat().format(value);
48
+ };
49
+
50
+ export const truncateText = (value: string, maxLength = 180) => {
51
+ if (value.length <= maxLength) {
52
+ return value;
53
+ }
54
+
55
+ return `${value.slice(0, maxLength - 1)}…`;
56
+ };
57
+
58
+ export const copyToClipboard = async (text: string) => {
59
+ if (typeof navigator !== 'undefined' && navigator.clipboard) {
60
+ await navigator.clipboard.writeText(text);
61
+ return true;
62
+ }
63
+
64
+ if (typeof document === 'undefined') {
65
+ return false;
66
+ }
67
+
68
+ try {
69
+ const textArea = document.createElement('textarea');
70
+ textArea.value = text;
71
+ textArea.style.position = 'fixed';
72
+ textArea.style.top = '0';
73
+ textArea.style.left = '-9999px';
74
+ textArea.style.opacity = '0';
75
+ document.body.appendChild(textArea);
76
+ textArea.focus();
77
+ textArea.select();
78
+ const success = document.execCommand('copy');
79
+ document.body.removeChild(textArea);
80
+ return success;
81
+ } catch {
82
+ return false;
83
+ }
84
+ };
85
+
86
+ export const downloadTextFile = (fileName: string, content: string) => {
87
+ if (typeof document === 'undefined') {
88
+ return false;
89
+ }
90
+
91
+ const blob = new Blob([content], { type: 'text/plain;charset=utf-8' });
92
+ const objectUrl = URL.createObjectURL(blob);
93
+ const anchor = document.createElement('a');
94
+ anchor.href = objectUrl;
95
+ anchor.download = fileName;
96
+ document.body.appendChild(anchor);
97
+ anchor.click();
98
+ document.body.removeChild(anchor);
99
+ URL.revokeObjectURL(objectUrl);
100
+ return true;
101
+ };
102
+
103
+ export const slugifyFileName = (value: string) =>
104
+ value
105
+ .toLowerCase()
106
+ .replace(/[^a-z0-9]+/g, '-')
107
+ .replace(/^-+|-+$/g, '') || 'sqlite-export';