@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.
- package/CHANGELOG.md +8 -0
- package/LICENSE +20 -0
- package/README.md +102 -0
- package/dist/devtools/assets/panel-B3paLkwG.js +82 -0
- package/dist/devtools/assets/panel-CIU0JBOs.css +1 -0
- package/dist/devtools/panel.html +31 -0
- package/dist/react-native/chunks/bridge-values.cjs +5 -0
- package/dist/react-native/chunks/bridge-values.js +258 -0
- package/dist/react-native/chunks/index.require.cjs +1 -0
- package/dist/react-native/chunks/index.require.js +118 -0
- package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.cjs +1 -0
- package/dist/react-native/chunks/useRozeniteSqlitePlugin.require.js +189 -0
- package/dist/react-native/index.cjs +1 -0
- package/dist/react-native/index.d.ts +178 -0
- package/dist/react-native/index.js +16 -0
- package/dist/rozenite.json +1 -0
- package/package.json +83 -0
- package/postcss.config.js +6 -0
- package/react-native.ts +55 -0
- package/rozenite.config.ts +8 -0
- package/src/react-native/adapters/__tests__/expo-sqlite.test.ts +94 -0
- package/src/react-native/adapters/expo-sqlite.ts +230 -0
- package/src/react-native/adapters/generic.ts +88 -0
- package/src/react-native/adapters/index.ts +9 -0
- package/src/react-native/sqlite-view.ts +24 -0
- package/src/react-native/useRozeniteSqlitePlugin.ts +262 -0
- package/src/shared/__tests__/bridge-values.test.ts +34 -0
- package/src/shared/__tests__/sql.test.ts +55 -0
- package/src/shared/bridge-values.ts +170 -0
- package/src/shared/protocol.ts +41 -0
- package/src/shared/sql.ts +420 -0
- package/src/shared/types.ts +81 -0
- package/src/ui/__tests__/sql-editor-utils.test.ts +135 -0
- package/src/ui/__tests__/sqlite-row-edit-value.test.ts +22 -0
- package/src/ui/__tests__/sqlite-row-mutations.test.ts +310 -0
- package/src/ui/__tests__/sqlite-table-column-order.test.ts +83 -0
- package/src/ui/__tests__/value-utils.test.tsx +12 -0
- package/src/ui/cell-detail-drawer.tsx +65 -0
- package/src/ui/globals.css +1415 -0
- package/src/ui/panel.tsx +2815 -0
- package/src/ui/query-result-table.tsx +199 -0
- package/src/ui/sql-editor-utils.ts +352 -0
- package/src/ui/sql-editor.tsx +509 -0
- package/src/ui/sqlite-data-table.tsx +296 -0
- package/src/ui/sqlite-introspection.ts +189 -0
- package/src/ui/sqlite-modal-controls.tsx +32 -0
- package/src/ui/sqlite-row-delete-modal.tsx +130 -0
- package/src/ui/sqlite-row-edit-modal.tsx +487 -0
- package/src/ui/sqlite-row-edit-value.ts +53 -0
- package/src/ui/sqlite-row-mutations.ts +246 -0
- package/src/ui/sqlite-table-column-order.ts +154 -0
- package/src/ui/use-sqlite-requests.ts +205 -0
- package/src/ui/utils.ts +107 -0
- package/src/ui/value-utils.tsx +162 -0
- package/tsconfig.json +36 -0
- package/vite.config.ts +20 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SqliteAdapter,
|
|
3
|
+
SqliteDatabaseNode,
|
|
4
|
+
SqliteExecuteStatementsRunner,
|
|
5
|
+
} from '../../shared/types';
|
|
6
|
+
|
|
7
|
+
type SqliteDatabaseConfig = {
|
|
8
|
+
name?: string;
|
|
9
|
+
executeStatements: SqliteExecuteStatementsRunner;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
type SingleDatabaseOptions = {
|
|
13
|
+
database: SqliteExecuteStatementsRunner | SqliteDatabaseConfig;
|
|
14
|
+
adapterId?: string;
|
|
15
|
+
adapterName?: string;
|
|
16
|
+
databaseName?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type MultiDatabaseOptions = {
|
|
20
|
+
databases: Record<
|
|
21
|
+
string,
|
|
22
|
+
SqliteExecuteStatementsRunner | SqliteDatabaseConfig
|
|
23
|
+
>;
|
|
24
|
+
adapterId?: string;
|
|
25
|
+
adapterName?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type CreateSqliteAdapterOptions =
|
|
29
|
+
| SingleDatabaseOptions
|
|
30
|
+
| MultiDatabaseOptions;
|
|
31
|
+
|
|
32
|
+
const slugify = (value: string) =>
|
|
33
|
+
value
|
|
34
|
+
.trim()
|
|
35
|
+
.toLowerCase()
|
|
36
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
37
|
+
.replace(/^-+|-+$/g, '') || 'database';
|
|
38
|
+
|
|
39
|
+
const createDatabaseId = (adapterId: string, seed: string, index: number) =>
|
|
40
|
+
`${adapterId}__${slugify(seed)}__${index.toString(36)}`;
|
|
41
|
+
|
|
42
|
+
const resolveDatabaseConfig = (
|
|
43
|
+
config: SqliteExecuteStatementsRunner | SqliteDatabaseConfig,
|
|
44
|
+
) => (typeof config === 'function' ? { executeStatements: config } : config);
|
|
45
|
+
|
|
46
|
+
const toDatabaseNode = (
|
|
47
|
+
adapterId: string,
|
|
48
|
+
databaseKey: string,
|
|
49
|
+
config: SqliteExecuteStatementsRunner | SqliteDatabaseConfig,
|
|
50
|
+
index: number,
|
|
51
|
+
fallbackName?: string,
|
|
52
|
+
): SqliteDatabaseNode => {
|
|
53
|
+
const resolved = resolveDatabaseConfig(config);
|
|
54
|
+
const name = resolved.name ?? fallbackName ?? databaseKey;
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: createDatabaseId(adapterId, `${databaseKey}-${name}`, index),
|
|
58
|
+
name,
|
|
59
|
+
executeStatements: resolved.executeStatements,
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const createSqliteAdapter = (
|
|
64
|
+
options: CreateSqliteAdapterOptions,
|
|
65
|
+
): SqliteAdapter => {
|
|
66
|
+
const { adapterId = 'sqlite', adapterName = 'SQLite' } = options;
|
|
67
|
+
|
|
68
|
+
const databases =
|
|
69
|
+
'databases' in options
|
|
70
|
+
? Object.entries(options.databases).map(([key, config], index) =>
|
|
71
|
+
toDatabaseNode(adapterId, key, config, index),
|
|
72
|
+
)
|
|
73
|
+
: [
|
|
74
|
+
toDatabaseNode(
|
|
75
|
+
adapterId,
|
|
76
|
+
options.databaseName ?? 'default',
|
|
77
|
+
options.database,
|
|
78
|
+
0,
|
|
79
|
+
options.databaseName ?? 'Default Database',
|
|
80
|
+
),
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
id: adapterId,
|
|
85
|
+
name: adapterName,
|
|
86
|
+
databases,
|
|
87
|
+
};
|
|
88
|
+
};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SqliteAdapter,
|
|
3
|
+
SqliteDatabaseInfo,
|
|
4
|
+
SqliteStatementInput,
|
|
5
|
+
} from '../shared/types';
|
|
6
|
+
|
|
7
|
+
export type SqliteDatabaseView = SqliteDatabaseInfo & {
|
|
8
|
+
executeStatements: (
|
|
9
|
+
statements: SqliteStatementInput[],
|
|
10
|
+
) => ReturnType<SqliteAdapter['databases'][number]['executeStatements']>;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const createSqliteDatabaseViews = (
|
|
14
|
+
adapters: SqliteAdapter[],
|
|
15
|
+
): SqliteDatabaseView[] =>
|
|
16
|
+
adapters.flatMap((adapter) =>
|
|
17
|
+
adapter.databases.map((database) => ({
|
|
18
|
+
id: database.id,
|
|
19
|
+
name: database.name,
|
|
20
|
+
adapterId: adapter.id,
|
|
21
|
+
adapterName: adapter.name,
|
|
22
|
+
executeStatements: database.executeStatements,
|
|
23
|
+
})),
|
|
24
|
+
);
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { useRozeniteDevToolsClient } from '@rozenite/plugin-bridge';
|
|
2
|
+
import { useEffect, useMemo, useRef } from 'react';
|
|
3
|
+
import { formatSqliteError } from '../shared/bridge-values';
|
|
4
|
+
import { PLUGIN_ID, type SqliteEventMap } from '../shared/protocol';
|
|
5
|
+
import { normalizeSingleStatementSql, splitSqlStatements } from '../shared/sql';
|
|
6
|
+
import type {
|
|
7
|
+
SqliteAdapter,
|
|
8
|
+
SqliteExecuteStatementsError,
|
|
9
|
+
SqliteQueryParams,
|
|
10
|
+
SqliteStatementInput,
|
|
11
|
+
} from '../shared/types';
|
|
12
|
+
import { createSqliteDatabaseViews } from './sqlite-view';
|
|
13
|
+
|
|
14
|
+
export type RozeniteSqlitePluginOptions = {
|
|
15
|
+
adapters: SqliteAdapter[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const safeError = (error: unknown) => formatSqliteError(error);
|
|
19
|
+
|
|
20
|
+
const isExecuteStatementsError = (
|
|
21
|
+
error: unknown,
|
|
22
|
+
): error is SqliteExecuteStatementsError =>
|
|
23
|
+
error instanceof Error &&
|
|
24
|
+
('completedResults' in error || 'failedStatementIndex' in error);
|
|
25
|
+
|
|
26
|
+
export const useRozeniteSqlitePlugin = ({
|
|
27
|
+
adapters,
|
|
28
|
+
}: RozeniteSqlitePluginOptions) => {
|
|
29
|
+
const views = useMemo(() => createSqliteDatabaseViews(adapters), [adapters]);
|
|
30
|
+
const client = useRozeniteDevToolsClient<SqliteEventMap>({
|
|
31
|
+
pluginId: PLUGIN_ID,
|
|
32
|
+
});
|
|
33
|
+
const subscriptionsRef = useRef<Array<{ remove: () => void }>>([]);
|
|
34
|
+
const databaseQueuesRef = useRef(new Map<string, Promise<void>>());
|
|
35
|
+
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (!client) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const enqueueDatabaseTask = async <T>(
|
|
42
|
+
databaseId: string,
|
|
43
|
+
task: () => Promise<T>,
|
|
44
|
+
): Promise<T> => {
|
|
45
|
+
const queue =
|
|
46
|
+
databaseQueuesRef.current.get(databaseId) ?? Promise.resolve();
|
|
47
|
+
const next = queue.catch(() => undefined).then(task);
|
|
48
|
+
|
|
49
|
+
databaseQueuesRef.current.set(
|
|
50
|
+
databaseId,
|
|
51
|
+
next.then(
|
|
52
|
+
() => undefined,
|
|
53
|
+
() => undefined,
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return next;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const resolveDatabase = (databaseId: string) => {
|
|
61
|
+
const database = views.find((view) => view.id === databaseId);
|
|
62
|
+
|
|
63
|
+
if (!database) {
|
|
64
|
+
throw new Error(`Unknown database "${databaseId}".`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return database;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const executeStatements = async (
|
|
71
|
+
databaseId: string,
|
|
72
|
+
statements: SqliteStatementInput[],
|
|
73
|
+
) => {
|
|
74
|
+
const database = resolveDatabase(databaseId);
|
|
75
|
+
const normalizedStatements = statements.map(({ sql, params }) => ({
|
|
76
|
+
sql: normalizeSingleStatementSql(sql),
|
|
77
|
+
params,
|
|
78
|
+
}));
|
|
79
|
+
const results = await database.executeStatements(normalizedStatements);
|
|
80
|
+
|
|
81
|
+
if (results.length !== normalizedStatements.length) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`Expected ${normalizedStatements.length} statement result(s), received ${results.length}.`,
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
inputs: normalizedStatements,
|
|
89
|
+
results,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const executeSingleQuery = async (
|
|
94
|
+
databaseId: string,
|
|
95
|
+
sql: string,
|
|
96
|
+
params?: SqliteQueryParams,
|
|
97
|
+
) => {
|
|
98
|
+
const execution = await executeStatements(databaseId, [
|
|
99
|
+
{
|
|
100
|
+
sql,
|
|
101
|
+
params,
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
const result = execution.results[0];
|
|
105
|
+
|
|
106
|
+
if (!result) {
|
|
107
|
+
throw new Error('The query completed without a result payload.');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return result;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
client.send('sqlite:ready', { timestamp: Date.now() });
|
|
114
|
+
|
|
115
|
+
subscriptionsRef.current.push(
|
|
116
|
+
client.onMessage('sqlite:list-databases', ({ requestId }) => {
|
|
117
|
+
client.send('sqlite:list-databases:result', {
|
|
118
|
+
requestId,
|
|
119
|
+
databases: views.map(({ id, name, adapterId, adapterName }) => ({
|
|
120
|
+
id,
|
|
121
|
+
name,
|
|
122
|
+
adapterId,
|
|
123
|
+
adapterName,
|
|
124
|
+
})),
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
subscriptionsRef.current.push(
|
|
130
|
+
client.onMessage(
|
|
131
|
+
'sqlite:query',
|
|
132
|
+
async ({ requestId, databaseId, sql, params }) => {
|
|
133
|
+
try {
|
|
134
|
+
const result = await enqueueDatabaseTask(databaseId, () =>
|
|
135
|
+
executeSingleQuery(databaseId, sql, params),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
client.send('sqlite:query:result', {
|
|
139
|
+
requestId,
|
|
140
|
+
databaseId,
|
|
141
|
+
result,
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
client.send('sqlite:query:result', {
|
|
145
|
+
requestId,
|
|
146
|
+
databaseId,
|
|
147
|
+
error: safeError(error),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
subscriptionsRef.current.push(
|
|
155
|
+
client.onMessage(
|
|
156
|
+
'sqlite:execute-script',
|
|
157
|
+
async ({ requestId, databaseId, sql }) => {
|
|
158
|
+
try {
|
|
159
|
+
const result = await enqueueDatabaseTask(databaseId, async () => {
|
|
160
|
+
const statementSegments = splitSqlStatements(sql);
|
|
161
|
+
|
|
162
|
+
if (statementSegments.length === 0) {
|
|
163
|
+
throw new Error('Query cannot be empty.');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const statementInputs = statementSegments.map((statement) => ({
|
|
167
|
+
sql: statement.text,
|
|
168
|
+
}));
|
|
169
|
+
|
|
170
|
+
try {
|
|
171
|
+
const execution = await executeStatements(
|
|
172
|
+
databaseId,
|
|
173
|
+
statementInputs,
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
statements: statementSegments.map((statement, index) => ({
|
|
178
|
+
index,
|
|
179
|
+
start: statement.start,
|
|
180
|
+
end: statement.end,
|
|
181
|
+
input: execution.inputs[index],
|
|
182
|
+
execution: {
|
|
183
|
+
input: execution.inputs[index],
|
|
184
|
+
result: execution.results[index],
|
|
185
|
+
},
|
|
186
|
+
})),
|
|
187
|
+
totalStatementCount: statementSegments.length,
|
|
188
|
+
failedStatementIndex: null,
|
|
189
|
+
};
|
|
190
|
+
} catch (error) {
|
|
191
|
+
if (!isExecuteStatementsError(error)) {
|
|
192
|
+
throw error;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const failedStatementIndex = Math.max(
|
|
196
|
+
0,
|
|
197
|
+
Math.min(
|
|
198
|
+
typeof error.failedStatementIndex === 'number'
|
|
199
|
+
? error.failedStatementIndex
|
|
200
|
+
: (error.completedResults?.length ?? 0),
|
|
201
|
+
statementSegments.length - 1,
|
|
202
|
+
),
|
|
203
|
+
);
|
|
204
|
+
const completedResults = (error.completedResults ?? []).slice(
|
|
205
|
+
0,
|
|
206
|
+
failedStatementIndex,
|
|
207
|
+
);
|
|
208
|
+
const completedStatements = completedResults.map(
|
|
209
|
+
(queryResult, index) => ({
|
|
210
|
+
index,
|
|
211
|
+
start: statementSegments[index].start,
|
|
212
|
+
end: statementSegments[index].end,
|
|
213
|
+
input: statementInputs[index],
|
|
214
|
+
execution: {
|
|
215
|
+
input: statementInputs[index],
|
|
216
|
+
result: queryResult,
|
|
217
|
+
},
|
|
218
|
+
}),
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
statements: [
|
|
223
|
+
...completedStatements,
|
|
224
|
+
{
|
|
225
|
+
index: failedStatementIndex,
|
|
226
|
+
start: statementSegments[failedStatementIndex].start,
|
|
227
|
+
end: statementSegments[failedStatementIndex].end,
|
|
228
|
+
input: statementInputs[failedStatementIndex],
|
|
229
|
+
error: safeError(error),
|
|
230
|
+
},
|
|
231
|
+
],
|
|
232
|
+
totalStatementCount: statementSegments.length,
|
|
233
|
+
failedStatementIndex,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
client.send('sqlite:execute-script:result', {
|
|
239
|
+
requestId,
|
|
240
|
+
databaseId,
|
|
241
|
+
result,
|
|
242
|
+
});
|
|
243
|
+
} catch (error) {
|
|
244
|
+
client.send('sqlite:execute-script:result', {
|
|
245
|
+
requestId,
|
|
246
|
+
databaseId,
|
|
247
|
+
error: safeError(error),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
),
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
return () => {
|
|
255
|
+
subscriptionsRef.current.forEach((subscription) => subscription.remove());
|
|
256
|
+
subscriptionsRef.current = [];
|
|
257
|
+
databaseQueuesRef.current.clear();
|
|
258
|
+
};
|
|
259
|
+
}, [client, views]);
|
|
260
|
+
|
|
261
|
+
return client;
|
|
262
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
decodeSqliteBridgeValue,
|
|
4
|
+
encodeSqliteBridgeValue,
|
|
5
|
+
formatSqliteError,
|
|
6
|
+
} from '../bridge-values';
|
|
7
|
+
|
|
8
|
+
describe('sqlite bridge values', () => {
|
|
9
|
+
it('round-trips Uint8Array values through a bridge-safe payload', () => {
|
|
10
|
+
const original = {
|
|
11
|
+
params: [new Uint8Array([1, 2, 255]), { nested: new Uint8Array([9, 8]) }],
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
expect(decodeSqliteBridgeValue(encodeSqliteBridgeValue(original))).toEqual(
|
|
15
|
+
original,
|
|
16
|
+
);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('formats nested error details with code and cause information', () => {
|
|
20
|
+
const error = Object.assign(
|
|
21
|
+
new Error("Calling the 'runAsync' function has failed"),
|
|
22
|
+
{
|
|
23
|
+
cause: {
|
|
24
|
+
code: 'ERR_INTERNAL_SQLITE_ERROR',
|
|
25
|
+
reason: 'Invalid bind parameter',
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
expect(formatSqliteError(error)).toBe(
|
|
31
|
+
"Calling the 'runAsync' function has failed\nCaused by: [ERR_INTERNAL_SQLITE_ERROR] Invalid bind parameter",
|
|
32
|
+
);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
getStatementAtCursor,
|
|
4
|
+
normalizeSingleStatementSql,
|
|
5
|
+
splitSqlStatements,
|
|
6
|
+
} from '../sql';
|
|
7
|
+
|
|
8
|
+
describe('SQL statement helpers', () => {
|
|
9
|
+
it('finds the active statement when earlier statements contain comments and quoted semicolons', () => {
|
|
10
|
+
const sql = [
|
|
11
|
+
'-- setup comment;',
|
|
12
|
+
"SELECT 'semi;colon' AS value;",
|
|
13
|
+
"INSERT INTO logs(message) VALUES('done');",
|
|
14
|
+
].join('\n');
|
|
15
|
+
const cursor = sql.indexOf('logs');
|
|
16
|
+
|
|
17
|
+
expect(getStatementAtCursor(sql, cursor)?.text).toBe(
|
|
18
|
+
"INSERT INTO logs(message) VALUES('done')",
|
|
19
|
+
);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('normalizes a single statement and removes the trailing semicolon', () => {
|
|
23
|
+
expect(normalizeSingleStatementSql(' SELECT * FROM projects; ')).toBe(
|
|
24
|
+
'SELECT * FROM projects',
|
|
25
|
+
);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('rejects multiple statements', () => {
|
|
29
|
+
expect(() =>
|
|
30
|
+
normalizeSingleStatementSql('SELECT * FROM projects; DELETE FROM logs;'),
|
|
31
|
+
).toThrow('Only a single SQL statement is supported in v1.');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('splits statements while preserving source offsets', () => {
|
|
35
|
+
const sql = [
|
|
36
|
+
'-- comment before first statement',
|
|
37
|
+
'SELECT 1;',
|
|
38
|
+
'',
|
|
39
|
+
"INSERT INTO logs(message) VALUES('done');",
|
|
40
|
+
].join('\n');
|
|
41
|
+
|
|
42
|
+
expect(splitSqlStatements(sql)).toEqual([
|
|
43
|
+
{
|
|
44
|
+
text: '-- comment before first statement\nSELECT 1',
|
|
45
|
+
start: 0,
|
|
46
|
+
end: sql.indexOf(';'),
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
text: "INSERT INTO logs(message) VALUES('done')",
|
|
50
|
+
start: 43,
|
|
51
|
+
end: sql.lastIndexOf(';'),
|
|
52
|
+
},
|
|
53
|
+
]);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
const SQLITE_BRIDGE_BINARY_TYPE = '__rozeniteSqliteBinary';
|
|
2
|
+
|
|
3
|
+
type SqliteEncodedBinaryValue = {
|
|
4
|
+
[SQLITE_BRIDGE_BINARY_TYPE]: true;
|
|
5
|
+
data: number[];
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
9
|
+
!!value && typeof value === 'object';
|
|
10
|
+
|
|
11
|
+
const isEncodedBinaryValue = (
|
|
12
|
+
value: unknown,
|
|
13
|
+
): value is SqliteEncodedBinaryValue =>
|
|
14
|
+
isRecord(value) &&
|
|
15
|
+
value[SQLITE_BRIDGE_BINARY_TYPE] === true &&
|
|
16
|
+
Array.isArray(value.data) &&
|
|
17
|
+
value.data.every((item) => typeof item === 'number');
|
|
18
|
+
|
|
19
|
+
export const encodeSqliteBridgeValue = (value: unknown): unknown => {
|
|
20
|
+
if (
|
|
21
|
+
value == null ||
|
|
22
|
+
typeof value === 'string' ||
|
|
23
|
+
typeof value === 'number' ||
|
|
24
|
+
typeof value === 'boolean'
|
|
25
|
+
) {
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (value instanceof Uint8Array) {
|
|
30
|
+
return {
|
|
31
|
+
[SQLITE_BRIDGE_BINARY_TYPE]: true,
|
|
32
|
+
data: Array.from(value),
|
|
33
|
+
} satisfies SqliteEncodedBinaryValue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (value instanceof ArrayBuffer) {
|
|
37
|
+
return {
|
|
38
|
+
[SQLITE_BRIDGE_BINARY_TYPE]: true,
|
|
39
|
+
data: Array.from(new Uint8Array(value)),
|
|
40
|
+
} satisfies SqliteEncodedBinaryValue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (Array.isArray(value)) {
|
|
44
|
+
return value.map(encodeSqliteBridgeValue);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (isRecord(value)) {
|
|
48
|
+
return Object.fromEntries(
|
|
49
|
+
Object.entries(value).map(([key, nestedValue]) => [
|
|
50
|
+
key,
|
|
51
|
+
encodeSqliteBridgeValue(nestedValue),
|
|
52
|
+
]),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return String(value);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export const decodeSqliteBridgeValue = (value: unknown): unknown => {
|
|
60
|
+
if (
|
|
61
|
+
value == null ||
|
|
62
|
+
typeof value === 'string' ||
|
|
63
|
+
typeof value === 'number' ||
|
|
64
|
+
typeof value === 'boolean'
|
|
65
|
+
) {
|
|
66
|
+
return value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (isEncodedBinaryValue(value)) {
|
|
70
|
+
return new Uint8Array(value.data);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (Array.isArray(value)) {
|
|
74
|
+
return value.map(decodeSqliteBridgeValue);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (isRecord(value)) {
|
|
78
|
+
return Object.fromEntries(
|
|
79
|
+
Object.entries(value).map(([key, nestedValue]) => [
|
|
80
|
+
key,
|
|
81
|
+
decodeSqliteBridgeValue(nestedValue),
|
|
82
|
+
]),
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return value;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const getStringField = (value: unknown, key: string): string | null => {
|
|
90
|
+
if (!isRecord(value)) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const field = value[key];
|
|
95
|
+
if (typeof field !== 'string') {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const trimmed = field.trim();
|
|
100
|
+
return trimmed ? trimmed : null;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const stringifyFallback = (value: unknown): string => {
|
|
104
|
+
try {
|
|
105
|
+
return JSON.stringify(value);
|
|
106
|
+
} catch {
|
|
107
|
+
return String(value);
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const describeError = (error: unknown): string | null => {
|
|
112
|
+
if (error instanceof Error) {
|
|
113
|
+
const code = getStringField(error, 'code');
|
|
114
|
+
const reason = getStringField(error, 'reason');
|
|
115
|
+
const message = error.message.trim();
|
|
116
|
+
const detailParts = [message || null, reason].filter(
|
|
117
|
+
(part, index, parts): part is string =>
|
|
118
|
+
!!part && parts.indexOf(part) === index,
|
|
119
|
+
);
|
|
120
|
+
const detail = detailParts.join(' | ') || error.name;
|
|
121
|
+
|
|
122
|
+
return code ? `[${code}] ${detail}` : detail;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (isRecord(error)) {
|
|
126
|
+
const code = getStringField(error, 'code');
|
|
127
|
+
const message = getStringField(error, 'message');
|
|
128
|
+
const reason = getStringField(error, 'reason');
|
|
129
|
+
const detail = message ?? reason ?? stringifyFallback(error);
|
|
130
|
+
|
|
131
|
+
return code ? `[${code}] ${detail}` : detail;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (typeof error === 'string') {
|
|
135
|
+
return error.trim() || null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (error == null) {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return stringifyFallback(error);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const getCause = (error: unknown): unknown => {
|
|
146
|
+
if (!isRecord(error)) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return error.cause;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
export const formatSqliteError = (error: unknown): string => {
|
|
154
|
+
const visited = new Set<unknown>();
|
|
155
|
+
const parts: string[] = [];
|
|
156
|
+
let current: unknown = error;
|
|
157
|
+
|
|
158
|
+
while (current !== undefined && current !== null && !visited.has(current)) {
|
|
159
|
+
visited.add(current);
|
|
160
|
+
|
|
161
|
+
const description = describeError(current);
|
|
162
|
+
if (description && !parts.includes(description)) {
|
|
163
|
+
parts.push(description);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
current = getCause(current);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return parts.join('\nCaused by: ') || 'Unknown SQLite error.';
|
|
170
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SqliteDatabaseInfo,
|
|
3
|
+
SqliteQueryParams,
|
|
4
|
+
SqliteQueryResult,
|
|
5
|
+
SqliteScriptResult,
|
|
6
|
+
} from './types';
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_ID = '@rozenite/sqlite-plugin';
|
|
9
|
+
|
|
10
|
+
export type SqliteEventMap = {
|
|
11
|
+
'sqlite:ready': { timestamp: number };
|
|
12
|
+
'sqlite:list-databases': { requestId: string };
|
|
13
|
+
'sqlite:list-databases:result': {
|
|
14
|
+
requestId: string;
|
|
15
|
+
databases: SqliteDatabaseInfo[];
|
|
16
|
+
error?: string;
|
|
17
|
+
};
|
|
18
|
+
'sqlite:query': {
|
|
19
|
+
requestId: string;
|
|
20
|
+
databaseId: string;
|
|
21
|
+
sql: string;
|
|
22
|
+
params?: SqliteQueryParams;
|
|
23
|
+
};
|
|
24
|
+
'sqlite:query:result': {
|
|
25
|
+
requestId: string;
|
|
26
|
+
databaseId: string;
|
|
27
|
+
result?: SqliteQueryResult;
|
|
28
|
+
error?: string;
|
|
29
|
+
};
|
|
30
|
+
'sqlite:execute-script': {
|
|
31
|
+
requestId: string;
|
|
32
|
+
databaseId: string;
|
|
33
|
+
sql: string;
|
|
34
|
+
};
|
|
35
|
+
'sqlite:execute-script:result': {
|
|
36
|
+
requestId: string;
|
|
37
|
+
databaseId: string;
|
|
38
|
+
result?: SqliteScriptResult;
|
|
39
|
+
error?: string;
|
|
40
|
+
};
|
|
41
|
+
};
|