@cloud-cli/d0 1.7.4 → 1.8.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/Dockerfile +6 -17
- package/README.md +4 -0
- package/console.html +356 -156
- package/dist/index.js +98 -12
- package/package.json +1 -1
package/Dockerfile
CHANGED
|
@@ -1,21 +1,10 @@
|
|
|
1
|
-
FROM ghcr.io/cloud-cli/node:latest AS builder
|
|
2
|
-
|
|
3
|
-
USER
|
|
4
|
-
|
|
5
|
-
COPY . /home/app
|
|
6
|
-
ENV CI=true
|
|
7
|
-
RUN npm install --global pnpm@12.3.4
|
|
8
|
-
RUN pnpm i && pnpm build && rm -rf node_modules/ src/
|
|
9
|
-
|
|
10
|
-
FROM ghcr.io/cloud-cli/node:latest
|
|
11
|
-
|
|
12
|
-
USER root
|
|
1
|
+
FROM ghcr.io/cloud-cli/image-node:latest AS builder
|
|
2
|
+
COPY . .
|
|
3
|
+
USER 0
|
|
4
|
+
RUN pnpm i && pnpm build && rm -rf src/ && pnpm clean
|
|
13
5
|
ENV NODE_ENV=production
|
|
6
|
+
RUN pnpm i --prod --frozen-lockfile && pnpm rebuild better-sqlite3
|
|
14
7
|
ENV DATA_PATH=/home/app/data
|
|
15
|
-
WORKDIR /home/app
|
|
16
|
-
COPY --from=builder --chown=node:node /home/app/ ./
|
|
17
|
-
RUN npm install --global pnpm@12.3.4
|
|
18
|
-
RUN pnpm install --prod --frozen-lockfile && pnpm rebuild better-sqlite3
|
|
19
8
|
RUN mkdir -p /home/app/data && chown node:node /home/app/data
|
|
9
|
+
USER 1000
|
|
20
10
|
VOLUME ["/home/app/data"]
|
|
21
|
-
USER node
|
package/README.md
CHANGED
|
@@ -13,6 +13,10 @@ const schema = await db.schema();
|
|
|
13
13
|
console.log(schema.tables, schema.statements);
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
+
**GET /api** returns an OpenAPI 3.1 description of the HTTP API.
|
|
17
|
+
|
|
18
|
+
The web console uses an explicit method selector instead of guessing from SQL text. Use `all` or `get` for reads, `run` for one prepared statement, `exec` for DDL or multiple statements, and `transaction` to run the entered SQL atomically.
|
|
19
|
+
|
|
16
20
|
**POST /query**
|
|
17
21
|
|
|
18
22
|
Run a prepared SQLite statement.
|
package/console.html
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
</script>
|
|
19
|
+
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>
|
|
19
20
|
<style>
|
|
20
21
|
:root {
|
|
21
22
|
color-scheme: dark;
|
|
@@ -29,14 +30,27 @@
|
|
|
29
30
|
--danger: #ff8f8f;
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
* {
|
|
33
|
-
|
|
33
|
+
* {
|
|
34
|
+
box-sizing: border-box;
|
|
35
|
+
}
|
|
36
|
+
html,
|
|
37
|
+
body {
|
|
38
|
+
height: 100%;
|
|
39
|
+
margin: 0;
|
|
40
|
+
}
|
|
34
41
|
body {
|
|
35
42
|
background: var(--background);
|
|
36
43
|
color: var(--text);
|
|
37
|
-
font:
|
|
44
|
+
font:
|
|
45
|
+
14px/1.4 ui-monospace,
|
|
46
|
+
SFMono-Regular,
|
|
47
|
+
Menlo,
|
|
48
|
+
monospace;
|
|
49
|
+
}
|
|
50
|
+
button,
|
|
51
|
+
input {
|
|
52
|
+
font: inherit;
|
|
38
53
|
}
|
|
39
|
-
button, input { font: inherit; }
|
|
40
54
|
button {
|
|
41
55
|
border: 1px solid var(--border);
|
|
42
56
|
border-radius: 6px;
|
|
@@ -45,8 +59,17 @@
|
|
|
45
59
|
cursor: pointer;
|
|
46
60
|
padding: 7px 11px;
|
|
47
61
|
}
|
|
48
|
-
button:hover,
|
|
49
|
-
|
|
62
|
+
button:hover,
|
|
63
|
+
button:focus-visible {
|
|
64
|
+
border-color: var(--accent);
|
|
65
|
+
color: var(--accent);
|
|
66
|
+
}
|
|
67
|
+
.shell {
|
|
68
|
+
display: grid;
|
|
69
|
+
grid-template-rows: auto 1fr;
|
|
70
|
+
height: 100%;
|
|
71
|
+
min-height: 0;
|
|
72
|
+
}
|
|
50
73
|
.topbar {
|
|
51
74
|
align-items: center;
|
|
52
75
|
border-bottom: 1px solid var(--border);
|
|
@@ -54,192 +77,349 @@
|
|
|
54
77
|
gap: 10px;
|
|
55
78
|
padding: 10px 14px;
|
|
56
79
|
}
|
|
57
|
-
.brand {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
80
|
+
.brand {
|
|
81
|
+
color: var(--accent);
|
|
82
|
+
font-weight: 700;
|
|
83
|
+
letter-spacing: 0.08em;
|
|
84
|
+
}
|
|
85
|
+
.brand-mark {
|
|
86
|
+
border-radius: 5px;
|
|
87
|
+
height: 24px;
|
|
88
|
+
width: 24px;
|
|
89
|
+
}
|
|
90
|
+
.db-name {
|
|
91
|
+
color: var(--muted);
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
text-overflow: ellipsis;
|
|
94
|
+
white-space: nowrap;
|
|
95
|
+
}
|
|
96
|
+
.spacer {
|
|
97
|
+
flex: 1;
|
|
98
|
+
}
|
|
61
99
|
.workspace {
|
|
62
100
|
display: grid;
|
|
63
101
|
grid-template-columns: 245px minmax(360px, 1fr) minmax(360px, 1.15fr);
|
|
64
102
|
min-height: 0;
|
|
65
103
|
}
|
|
66
|
-
.panel {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
.panel
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
.
|
|
88
|
-
|
|
89
|
-
|
|
104
|
+
.panel {
|
|
105
|
+
min-width: 0;
|
|
106
|
+
min-height: 0;
|
|
107
|
+
overflow: hidden;
|
|
108
|
+
}
|
|
109
|
+
.schema-panel {
|
|
110
|
+
border-right: 1px solid var(--border);
|
|
111
|
+
display: flex;
|
|
112
|
+
flex-direction: column;
|
|
113
|
+
}
|
|
114
|
+
.editor-panel {
|
|
115
|
+
border-right: 1px solid var(--border);
|
|
116
|
+
display: grid;
|
|
117
|
+
grid-template-rows: minmax(190px, 38%) minmax(0, 1fr);
|
|
118
|
+
}
|
|
119
|
+
.graph-panel {
|
|
120
|
+
display: grid;
|
|
121
|
+
grid-template-rows: auto minmax(0, 1fr);
|
|
122
|
+
padding: 12px;
|
|
123
|
+
gap: 10px;
|
|
124
|
+
}
|
|
125
|
+
.panel-header {
|
|
126
|
+
align-items: center;
|
|
127
|
+
display: flex;
|
|
128
|
+
gap: 8px;
|
|
129
|
+
padding: 12px;
|
|
130
|
+
}
|
|
131
|
+
.panel-title {
|
|
132
|
+
color: var(--muted);
|
|
133
|
+
font-size: 11px;
|
|
134
|
+
letter-spacing: 0.1em;
|
|
135
|
+
text-transform: uppercase;
|
|
136
|
+
}
|
|
137
|
+
.schema-count {
|
|
138
|
+
color: var(--accent);
|
|
139
|
+
margin-left: auto;
|
|
140
|
+
}
|
|
141
|
+
.schema-list {
|
|
142
|
+
border-top: 1px solid var(--border);
|
|
143
|
+
flex: 1;
|
|
144
|
+
overflow: auto;
|
|
145
|
+
padding: 7px;
|
|
146
|
+
}
|
|
147
|
+
.schema-item {
|
|
148
|
+
background: transparent;
|
|
149
|
+
border: 0;
|
|
150
|
+
display: block;
|
|
151
|
+
padding: 9px;
|
|
152
|
+
text-align: left;
|
|
153
|
+
width: 100%;
|
|
154
|
+
}
|
|
155
|
+
.schema-item:hover {
|
|
156
|
+
background: var(--panel-raised);
|
|
157
|
+
}
|
|
158
|
+
.schema-kind {
|
|
159
|
+
color: var(--muted);
|
|
160
|
+
font-size: 11px;
|
|
161
|
+
margin-left: 5px;
|
|
162
|
+
}
|
|
163
|
+
.schema-columns {
|
|
164
|
+
color: var(--muted);
|
|
165
|
+
display: block;
|
|
166
|
+
font-size: 11px;
|
|
167
|
+
margin-top: 3px;
|
|
168
|
+
}
|
|
169
|
+
.editor,
|
|
170
|
+
.results {
|
|
171
|
+
min-height: 0;
|
|
172
|
+
padding: 12px;
|
|
173
|
+
}
|
|
174
|
+
.editor {
|
|
175
|
+
border-bottom: 1px solid var(--border);
|
|
176
|
+
display: grid;
|
|
177
|
+
grid-template-rows: auto minmax(0, 1fr) auto;
|
|
178
|
+
gap: 8px;
|
|
179
|
+
}
|
|
180
|
+
.editor-actions {
|
|
181
|
+
display: flex;
|
|
182
|
+
gap: 8px;
|
|
183
|
+
justify-content: flex-end;
|
|
184
|
+
}
|
|
185
|
+
.run {
|
|
186
|
+
background: var(--accent);
|
|
187
|
+
border-color: var(--accent);
|
|
188
|
+
color: #0d1918;
|
|
189
|
+
font-weight: 700;
|
|
190
|
+
}
|
|
191
|
+
.run:hover,
|
|
192
|
+
.run:focus-visible {
|
|
193
|
+
color: #0d1918;
|
|
194
|
+
opacity: 0.85;
|
|
195
|
+
}
|
|
196
|
+
code-editor {
|
|
197
|
+
display: block;
|
|
198
|
+
min-height: 0;
|
|
199
|
+
}
|
|
200
|
+
.results {
|
|
201
|
+
overflow: auto;
|
|
202
|
+
}
|
|
203
|
+
.result {
|
|
204
|
+
border-bottom: 1px solid var(--border);
|
|
205
|
+
display: block;
|
|
206
|
+
padding: 8px 0;
|
|
207
|
+
white-space: pre-wrap;
|
|
208
|
+
}
|
|
209
|
+
.error {
|
|
210
|
+
color: var(--danger);
|
|
211
|
+
padding: 8px 0;
|
|
212
|
+
white-space: pre-wrap;
|
|
213
|
+
}
|
|
214
|
+
.graph-wrap {
|
|
215
|
+
background: #f8fafc;
|
|
216
|
+
border-radius: 8px;
|
|
217
|
+
min-height: 0;
|
|
218
|
+
overflow: auto;
|
|
219
|
+
}
|
|
220
|
+
mermaid-graph {
|
|
221
|
+
display: block;
|
|
222
|
+
min-height: 100%;
|
|
223
|
+
min-width: 100%;
|
|
224
|
+
}
|
|
225
|
+
.hint {
|
|
226
|
+
color: var(--muted);
|
|
227
|
+
font-size: 12px;
|
|
228
|
+
padding: 12px;
|
|
229
|
+
}
|
|
90
230
|
|
|
91
231
|
@media (max-width: 1050px) {
|
|
92
|
-
.workspace {
|
|
93
|
-
|
|
94
|
-
|
|
232
|
+
.workspace {
|
|
233
|
+
grid-template-columns: 210px minmax(330px, 1fr);
|
|
234
|
+
}
|
|
235
|
+
.graph-panel {
|
|
236
|
+
border-top: 1px solid var(--border);
|
|
237
|
+
grid-column: 1 / -1;
|
|
238
|
+
min-height: 420px;
|
|
239
|
+
}
|
|
240
|
+
.editor-panel {
|
|
241
|
+
border-right: 0;
|
|
242
|
+
}
|
|
95
243
|
}
|
|
96
244
|
@media (max-width: 680px) {
|
|
97
|
-
.workspace {
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
.
|
|
102
|
-
.
|
|
245
|
+
.workspace {
|
|
246
|
+
display: block;
|
|
247
|
+
overflow: auto;
|
|
248
|
+
}
|
|
249
|
+
.schema-panel,
|
|
250
|
+
.editor-panel {
|
|
251
|
+
border-right: 0;
|
|
252
|
+
border-bottom: 1px solid var(--border);
|
|
253
|
+
}
|
|
254
|
+
.schema-panel {
|
|
255
|
+
max-height: 220px;
|
|
256
|
+
}
|
|
257
|
+
.editor-panel {
|
|
258
|
+
height: 560px;
|
|
259
|
+
}
|
|
260
|
+
.graph-panel {
|
|
261
|
+
min-height: 420px;
|
|
262
|
+
}
|
|
263
|
+
.db-name {
|
|
264
|
+
display: none;
|
|
265
|
+
}
|
|
103
266
|
}
|
|
104
267
|
</style>
|
|
105
268
|
</head>
|
|
106
|
-
<body>
|
|
269
|
+
<body class="h-full min-h-full bg-[#101316] font-mono text-sm leading-relaxed text-[#edf2f7]">
|
|
107
270
|
<script type="module">
|
|
108
271
|
import '@li3/web';
|
|
109
272
|
</script>
|
|
110
273
|
<app-main class="contents"></app-main>
|
|
111
274
|
|
|
112
275
|
<template component="app-main">
|
|
113
|
-
|
|
114
|
-
|
|
276
|
+
<script setup>
|
|
277
|
+
import { hook, onInit } from '@li3/web';
|
|
278
|
+
import db from '@app/index.mjs';
|
|
115
279
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
280
|
+
function schemaToMermaid(schema) {
|
|
281
|
+
const tables = schema.tables.filter((table) => table.type === 'table' && table.sql);
|
|
282
|
+
const names = new Map(tables.map((table) => [table.name, mermaidName(table.name)]));
|
|
283
|
+
let diagram = 'erDiagram\n';
|
|
120
284
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
285
|
+
for (const table of tables) {
|
|
286
|
+
const name = names.get(table.name);
|
|
287
|
+
diagram += ` ${name} {\n`;
|
|
288
|
+
for (const column of table.columns.filter((column) => !column.hidden)) {
|
|
289
|
+
const type = mermaidType(column.type);
|
|
290
|
+
const key = column.pk ? ' PK' : '';
|
|
291
|
+
diagram += ` ${type} ${mermaidName(column.name)}${key}\n`;
|
|
292
|
+
}
|
|
293
|
+
diagram += ' }\n';
|
|
128
294
|
}
|
|
129
|
-
diagram += ' }\n';
|
|
130
|
-
}
|
|
131
295
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
296
|
+
for (const table of tables) {
|
|
297
|
+
for (const foreignKey of table.foreignKeys) {
|
|
298
|
+
const parent = names.get(foreignKey.table);
|
|
299
|
+
const child = names.get(table.name);
|
|
300
|
+
if (parent && child) diagram += ` ${parent} ||--o{ ${child} : references\n`;
|
|
301
|
+
}
|
|
137
302
|
}
|
|
303
|
+
return diagram;
|
|
138
304
|
}
|
|
139
|
-
return diagram;
|
|
140
|
-
}
|
|
141
305
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
function mermaidType(value) {
|
|
147
|
-
return String(value || 'value').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
function pretty(value) {
|
|
151
|
-
return JSON.stringify(value, null, 2);
|
|
152
|
-
}
|
|
306
|
+
function mermaidName(value) {
|
|
307
|
+
return String(value).replace(/[^a-zA-Z0-9_]/g, '_');
|
|
308
|
+
}
|
|
153
309
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
310
|
+
function mermaidType(value) {
|
|
311
|
+
return String(value || 'value')
|
|
312
|
+
.replace(/[^a-zA-Z0-9_]/g, '_')
|
|
313
|
+
.toLowerCase();
|
|
314
|
+
}
|
|
157
315
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const [diagram, setDiagram] = hook('erDiagram\n');
|
|
162
|
-
const [query, setQuery] = hook('');
|
|
163
|
-
const [responses, setResponses] = hook([]);
|
|
164
|
-
const [error, setError] = hook('');
|
|
165
|
-
const [loading, setLoading] = hook(false);
|
|
166
|
-
const [name] = hook(new URL(location.href).hostname.split('.')[0]);
|
|
316
|
+
function pretty(value) {
|
|
317
|
+
return JSON.stringify(value, null, 2);
|
|
318
|
+
}
|
|
167
319
|
|
|
168
|
-
|
|
169
|
-
return
|
|
320
|
+
function escapeQueryIdentifier(value) {
|
|
321
|
+
return String(value).replaceAll('"', '""');
|
|
170
322
|
}
|
|
171
323
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
324
|
+
function appMain() {
|
|
325
|
+
const [schemaData, setSchemaData] = hook(null);
|
|
326
|
+
const [schemaTables, setSchemaTables] = hook([]);
|
|
327
|
+
const [diagram, setDiagram] = hook('erDiagram\n');
|
|
328
|
+
const [query, setQuery] = hook('');
|
|
329
|
+
const [method, setMethod] = hook('all');
|
|
330
|
+
const [responses, setResponses] = hook([]);
|
|
331
|
+
const [error, setError] = hook('');
|
|
332
|
+
const [loading, setLoading] = hook(false);
|
|
333
|
+
const name = new URL(location.href).hostname.split('.')[0];
|
|
334
|
+
|
|
335
|
+
async function loadSchema() {
|
|
336
|
+
try {
|
|
337
|
+
setError('');
|
|
338
|
+
setLoading(true);
|
|
339
|
+
const value = await db.schema();
|
|
340
|
+
setSchemaData(value);
|
|
341
|
+
setSchemaTables(value.tables.filter((table) => !table.name.startsWith('sqlite_')));
|
|
342
|
+
setDiagram(schemaToMermaid(value));
|
|
343
|
+
} catch (e) {
|
|
344
|
+
setError(String(e));
|
|
345
|
+
} finally {
|
|
346
|
+
setLoading(false);
|
|
347
|
+
}
|
|
185
348
|
}
|
|
186
|
-
}
|
|
187
349
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
350
|
+
async function runQuery() {
|
|
351
|
+
if (!query.value.trim()) return;
|
|
352
|
+
try {
|
|
353
|
+
setError('');
|
|
354
|
+
const result = method.value === 'transaction'
|
|
355
|
+
? await db.transaction([{ s: query.value, m: 'exec' }])
|
|
356
|
+
: await db.query(method.value, query.value);
|
|
357
|
+
setResponses([...responses.value, pretty(result)]);
|
|
358
|
+
} catch (e) {
|
|
359
|
+
setError(String(e));
|
|
360
|
+
}
|
|
198
361
|
}
|
|
199
|
-
}
|
|
200
362
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
363
|
+
function selectTable(tableName) {
|
|
364
|
+
setMethod('all');
|
|
365
|
+
setQuery(`SELECT * FROM "${escapeQueryIdentifier(tableName)}" LIMIT 100;`);
|
|
366
|
+
}
|
|
204
367
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
event.preventDefault();
|
|
208
|
-
runQuery();
|
|
368
|
+
function clearResponses() {
|
|
369
|
+
setResponses([]);
|
|
209
370
|
}
|
|
210
|
-
}
|
|
211
371
|
|
|
212
|
-
|
|
372
|
+
function onKeyUp(event) {
|
|
373
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
374
|
+
event.preventDefault();
|
|
375
|
+
runQuery();
|
|
376
|
+
}
|
|
377
|
+
}
|
|
213
378
|
|
|
214
|
-
|
|
215
|
-
name, schemaData, schemaTables, diagram, query, responses, error, loading,
|
|
216
|
-
setQuery, loadSchema, runQuery, selectTable, onKeyUp,
|
|
217
|
-
};
|
|
218
|
-
}
|
|
379
|
+
onInit(loadSchema);
|
|
219
380
|
|
|
220
|
-
|
|
381
|
+
return {
|
|
382
|
+
name,
|
|
383
|
+
schemaData,
|
|
384
|
+
schemaTables,
|
|
385
|
+
diagram,
|
|
386
|
+
query,
|
|
387
|
+
method,
|
|
388
|
+
responses,
|
|
389
|
+
error,
|
|
390
|
+
loading,
|
|
391
|
+
setQuery,
|
|
392
|
+
setMethod,
|
|
393
|
+
loadSchema,
|
|
394
|
+
runQuery,
|
|
395
|
+
selectTable,
|
|
396
|
+
clearResponses,
|
|
397
|
+
onKeyUp,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
221
400
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
<
|
|
227
|
-
<span class="
|
|
228
|
-
<span class="
|
|
229
|
-
<
|
|
401
|
+
export default appMain;
|
|
402
|
+
</script>
|
|
403
|
+
<div class="shell h-full min-h-0 grid-rows-[auto_1fr]">
|
|
404
|
+
<header class="topbar flex items-center gap-2.5 border-b border-[#303943] px-3.5 py-2.5">
|
|
405
|
+
<img class="brand-mark h-6 w-6 rounded-[5px]" src="./logo.svg" alt="d0" />
|
|
406
|
+
<span class="brand font-bold tracking-[.08em] text-[#8bd5ca]">D0</span>
|
|
407
|
+
<span class="db-name max-w-[40vw] overflow-hidden text-ellipsis whitespace-nowrap text-[#8d99a8]">{{ name || 'database' }}</span>
|
|
408
|
+
<span class="spacer flex-1"></span>
|
|
409
|
+
<button class="rounded-md border border-[#303943] bg-[#1d232a] px-2.5 py-1.5 text-[#edf2f7] hover:border-[#8bd5ca] hover:text-[#8bd5ca]" type="button" on-click="loadSchema()">{{ loading ? 'Loading...' : 'Refresh schema' }}</button>
|
|
230
410
|
</header>
|
|
231
|
-
<main class="workspace">
|
|
232
|
-
<aside class="panel schema-panel">
|
|
233
|
-
<div class="panel-header">
|
|
234
|
-
<span class="panel-title">Schema</span>
|
|
235
|
-
<span class="schema-count">{{ schemaTables.length }}</span>
|
|
411
|
+
<main class="workspace grid min-h-0 grid-cols-[245px_minmax(360px,1fr)_minmax(360px,1.15fr)] max-[1050px]:grid-cols-[210px_minmax(330px,1fr)] max-[1050px]:grid-rows-[minmax(0,1fr)_minmax(420px,auto)] max-[680px]:block max-[680px]:overflow-auto">
|
|
412
|
+
<aside class="panel schema-panel flex min-h-0 min-w-0 flex-col overflow-hidden border-r border-[#303943] max-[1050px]:row-span-1 max-[680px]:max-h-[220px] max-[680px]:border-b max-[680px]:border-r-0">
|
|
413
|
+
<div class="panel-header flex items-center gap-2 px-3 py-3">
|
|
414
|
+
<span class="panel-title text-[11px] uppercase tracking-[.1em] text-[#8d99a8]">Schema</span>
|
|
415
|
+
<span class="schema-count ml-auto text-[#8bd5ca]">{{ schemaTables.length }}</span>
|
|
236
416
|
</div>
|
|
237
|
-
<div class="schema-list">
|
|
417
|
+
<div class="schema-list flex-1 overflow-auto border-t border-[#303943] p-1.5">
|
|
238
418
|
<template if="!schemaTables.length">
|
|
239
419
|
<div class="hint">No tables discovered.</div>
|
|
240
420
|
</template>
|
|
241
421
|
<template for="table of schemaTables">
|
|
242
|
-
<button class="schema-item" type="button" on-click="selectTable(table.name)">
|
|
422
|
+
<button class="schema-item block w-full rounded-md border-0 bg-transparent p-2.5 text-left hover:bg-[#1d232a]" type="button" on-click="selectTable(table.name)">
|
|
243
423
|
{{ table.name }} <span class="schema-kind">{{ table.type }}</span>
|
|
244
424
|
<span class="schema-columns">{{ table.columns.length }} columns</span>
|
|
245
425
|
</button>
|
|
@@ -247,16 +427,36 @@
|
|
|
247
427
|
</div>
|
|
248
428
|
</aside>
|
|
249
429
|
|
|
250
|
-
<section class="panel editor-panel">
|
|
430
|
+
<section class="panel editor-panel grid min-h-0 min-w-0 grid-rows-[minmax(190px,38%)_minmax(0,1fr)] overflow-hidden border-r border-[#303943] max-[1050px]:border-r-0 max-[680px]:h-[560px] max-[680px]:border-b">
|
|
251
431
|
<div class="editor">
|
|
252
|
-
<div class="
|
|
253
|
-
|
|
432
|
+
<div class="flex items-center justify-between gap-2">
|
|
433
|
+
<div class="panel-title">Query</div>
|
|
434
|
+
<select class="rounded-md border border-[#303943] bg-[#1d232a] px-2 py-1 text-xs text-[#edf2f7] outline-none focus:border-[#8bd5ca]" bind-value="method" on-change="setMethod($event.target.value)" aria-label="Query method">
|
|
435
|
+
<option value="all">all rows</option>
|
|
436
|
+
<option value="get">one row</option>
|
|
437
|
+
<option value="run">run statement</option>
|
|
438
|
+
<option value="exec">execute SQL</option>
|
|
439
|
+
<option value="transaction">transaction</option>
|
|
440
|
+
</select>
|
|
441
|
+
</div>
|
|
442
|
+
<code-editor
|
|
443
|
+
nolines="1"
|
|
444
|
+
nostatus="1"
|
|
445
|
+
theme="a11y-dark"
|
|
446
|
+
language="sql"
|
|
447
|
+
bind-value="query"
|
|
448
|
+
on-change="setQuery($event.target.value)"
|
|
449
|
+
on-keyup="onKeyUp($event)"
|
|
450
|
+
></code-editor>
|
|
254
451
|
<div class="editor-actions">
|
|
255
452
|
<button class="run" type="button" on-click="runQuery()">Run query</button>
|
|
256
453
|
</div>
|
|
257
454
|
</div>
|
|
258
|
-
<div class="results">
|
|
259
|
-
<div class="
|
|
455
|
+
<div class="results min-h-0 overflow-auto p-3">
|
|
456
|
+
<div class="flex items-center justify-between">
|
|
457
|
+
<div class="panel-title text-[11px] uppercase tracking-[.1em] text-[#8d99a8]">Results</div>
|
|
458
|
+
<button class="rounded border border-[#303943] bg-transparent px-1.5 py-0.5 text-xs text-[#8d99a8] hover:border-[#ff8f8f] hover:text-[#ff8f8f]" type="button" title="Clear results" aria-label="Clear results" on-click="clearResponses()">X</button>
|
|
459
|
+
</div>
|
|
260
460
|
<template for="result of responses">
|
|
261
461
|
<code-block class="result" bind-source="result" language="json"></code-block>
|
|
262
462
|
</template>
|
|
@@ -264,11 +464,11 @@
|
|
|
264
464
|
</div>
|
|
265
465
|
</section>
|
|
266
466
|
|
|
267
|
-
<section class="panel graph-panel">
|
|
268
|
-
<div class="panel-header
|
|
269
|
-
<span class="panel-title">Relationships</span>
|
|
270
|
-
<span class="spacer"></span>
|
|
271
|
-
<button type="button" on-click="loadSchema()">Redraw</button>
|
|
467
|
+
<section class="panel graph-panel grid min-h-0 min-w-0 grid-rows-[auto_minmax(0,1fr)] gap-2.5 overflow-hidden p-3 max-[1050px]:col-span-2 max-[1050px]:border-t max-[1050px]:border-[#303943] max-[680px]:min-h-[420px]">
|
|
468
|
+
<div class="panel-header flex items-center gap-2 p-0">
|
|
469
|
+
<span class="panel-title text-[11px] uppercase tracking-[.1em] text-[#8d99a8]">Relationships</span>
|
|
470
|
+
<span class="spacer flex-1"></span>
|
|
471
|
+
<button class="rounded-md border border-[#303943] bg-[#1d232a] px-2.5 py-1.5 hover:border-[#8bd5ca] hover:text-[#8bd5ca]" type="button" on-click="loadSchema()">Redraw</button>
|
|
272
472
|
</div>
|
|
273
473
|
<div class="graph-wrap">
|
|
274
474
|
<mermaid-graph class="h-full w-full" bind-input="diagram"></mermaid-graph>
|
package/dist/index.js
CHANGED
|
@@ -81,6 +81,8 @@ export async function handleRequest(request, response, db) {
|
|
|
81
81
|
const logo = await readFile('./logo.svg', 'utf8');
|
|
82
82
|
response.writeHead(200, { 'content-type': 'image/svg+xml' }).end(logo);
|
|
83
83
|
return;
|
|
84
|
+
case 'GET /api':
|
|
85
|
+
return onApi(request, response);
|
|
84
86
|
case 'GET /index.mjs':
|
|
85
87
|
return onEsModule(request, response);
|
|
86
88
|
case 'GET /schema':
|
|
@@ -91,16 +93,88 @@ export async function handleRequest(request, response, db) {
|
|
|
91
93
|
response.writeHead(404).end();
|
|
92
94
|
}
|
|
93
95
|
}
|
|
96
|
+
function onApi(request, response) {
|
|
97
|
+
const host = request.headers['x-forwarded-host'] || request.headers.host;
|
|
98
|
+
const protocol = request.headers['x-forwarded-proto'] || 'http';
|
|
99
|
+
const document = {
|
|
100
|
+
openapi: '3.1.0',
|
|
101
|
+
info: {
|
|
102
|
+
title: 'd0 SQLite API',
|
|
103
|
+
version: '1.0.0',
|
|
104
|
+
description: 'SQLite over HTTPS with prepared statements and schema introspection.',
|
|
105
|
+
},
|
|
106
|
+
...(host ? { servers: [{ url: `${protocol}://${host}` }] } : {}),
|
|
107
|
+
paths: {
|
|
108
|
+
'/query': {
|
|
109
|
+
post: {
|
|
110
|
+
summary: 'Execute SQL',
|
|
111
|
+
requestBody: {
|
|
112
|
+
required: true,
|
|
113
|
+
content: { 'application/json': { schema: { $ref: '#/components/schemas/QueryRequest' } } },
|
|
114
|
+
},
|
|
115
|
+
responses: {
|
|
116
|
+
'200': { description: 'SQLite result.' },
|
|
117
|
+
'400': { description: 'Invalid SQL or request.' },
|
|
118
|
+
'413': { description: 'Request body too large.' },
|
|
119
|
+
'503': { description: 'SQLite is busy.' },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
'/schema': {
|
|
124
|
+
get: {
|
|
125
|
+
summary: 'Inspect database schema',
|
|
126
|
+
parameters: [
|
|
127
|
+
{
|
|
128
|
+
name: 'internal',
|
|
129
|
+
in: 'query',
|
|
130
|
+
required: false,
|
|
131
|
+
schema: { type: 'string', enum: ['1'] },
|
|
132
|
+
description: 'Include SQLite internal objects.',
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
responses: { '200': { description: 'Schema metadata.' } },
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
'/index.mjs': { get: { summary: 'Get the consumer ES module', responses: { '200': { description: 'JavaScript module.' } } } },
|
|
139
|
+
'/console.html': { get: { summary: 'Get the web console', responses: { '200': { description: 'HTML console.' } } } },
|
|
140
|
+
'/logo.svg': { get: { summary: 'Get the d0 logo', responses: { '200': { description: 'SVG image.' } } } },
|
|
141
|
+
},
|
|
142
|
+
components: {
|
|
143
|
+
schemas: {
|
|
144
|
+
QueryRequest: {
|
|
145
|
+
type: 'object',
|
|
146
|
+
properties: {
|
|
147
|
+
s: { type: 'string', description: 'SQL statement.' },
|
|
148
|
+
d: { description: 'Positional or named statement bindings.' },
|
|
149
|
+
m: { type: 'string', enum: ['all', 'get', 'run', 'exec'], default: 'run' },
|
|
150
|
+
p: { type: 'array', items: { type: 'string' }, description: 'Pragmas applied before execution.' },
|
|
151
|
+
t: { type: 'array', items: { $ref: '#/components/schemas/TransactionStatement' } },
|
|
152
|
+
},
|
|
153
|
+
description: 'Use t instead of s to execute an atomic transaction.',
|
|
154
|
+
},
|
|
155
|
+
TransactionStatement: {
|
|
156
|
+
type: 'object',
|
|
157
|
+
required: ['s'],
|
|
158
|
+
properties: {
|
|
159
|
+
s: { type: 'string' },
|
|
160
|
+
d: {},
|
|
161
|
+
m: { type: 'string', enum: ['all', 'get', 'run', 'exec'], default: 'run' },
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
sendJson(response, 200, document);
|
|
168
|
+
}
|
|
94
169
|
async function onSchema(response, db, includeInternal) {
|
|
95
170
|
try {
|
|
96
171
|
const sqlite = getDatabase(db);
|
|
97
172
|
const schema = getSchema(sqlite, includeInternal);
|
|
98
|
-
response
|
|
99
|
-
response.end(JSON.stringify(schema));
|
|
173
|
+
sendJson(response, 200, schema);
|
|
100
174
|
}
|
|
101
175
|
catch (error) {
|
|
102
176
|
DEBUG && console.error(error);
|
|
103
|
-
response
|
|
177
|
+
sendError(response, 400, error);
|
|
104
178
|
}
|
|
105
179
|
}
|
|
106
180
|
function getSchema(sqlite, includeInternal) {
|
|
@@ -142,6 +216,7 @@ function quotePragmaValue(value) {
|
|
|
142
216
|
}
|
|
143
217
|
export async function onQuery(request, response, db) {
|
|
144
218
|
const query = await readBody(request);
|
|
219
|
+
let isTransaction = false;
|
|
145
220
|
if (!query) {
|
|
146
221
|
response.writeHead(413).end('Request body too large.');
|
|
147
222
|
return;
|
|
@@ -153,15 +228,15 @@ export async function onQuery(request, response, db) {
|
|
|
153
228
|
try {
|
|
154
229
|
const { s = '', d, m = 'run', p, t } = JSON.parse(query.toString('utf-8'));
|
|
155
230
|
if (t !== undefined) {
|
|
231
|
+
isTransaction = true;
|
|
156
232
|
if (!Array.isArray(t) || !t.length)
|
|
157
233
|
throw new Error('Invalid transaction.');
|
|
158
234
|
const sqlite = getDatabase(db);
|
|
159
235
|
applyPragmas(sqlite, p);
|
|
160
236
|
const started = performance.now();
|
|
161
|
-
|
|
237
|
+
sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
|
|
162
238
|
logSlowQuery(started, `transaction (${t.length} statements)`);
|
|
163
|
-
response
|
|
164
|
-
response.end(JSON.stringify(result));
|
|
239
|
+
sendJson(response, 200, { success: true });
|
|
165
240
|
return;
|
|
166
241
|
}
|
|
167
242
|
if (!s.trim()) {
|
|
@@ -175,16 +250,25 @@ export async function onQuery(request, response, db) {
|
|
|
175
250
|
const started = performance.now();
|
|
176
251
|
const result = executeStatement(sqlite, { s, d, m });
|
|
177
252
|
logSlowQuery(started, s.trim());
|
|
178
|
-
response
|
|
179
|
-
response.end(JSON.stringify(result ?? null));
|
|
253
|
+
sendJson(response, 200, result ?? null);
|
|
180
254
|
DEBUG && console.log(s.trim(), d, result);
|
|
181
255
|
}
|
|
182
256
|
catch (error) {
|
|
183
257
|
DEBUG && console.error(error);
|
|
184
258
|
const status = error.code === 'SQLITE_BUSY' ? 503 : 400;
|
|
185
|
-
response
|
|
259
|
+
sendError(response, status, error, isTransaction ? 'Transaction failed: ' : '');
|
|
186
260
|
}
|
|
187
261
|
}
|
|
262
|
+
function redactDataPath(value) {
|
|
263
|
+
return value.replaceAll(dataPath, '***');
|
|
264
|
+
}
|
|
265
|
+
function sendJson(response, status, value) {
|
|
266
|
+
response.writeHead(status, { 'content-type': 'application/json' });
|
|
267
|
+
response.end(redactDataPath(JSON.stringify(value)));
|
|
268
|
+
}
|
|
269
|
+
function sendError(response, status, error, prefix = '') {
|
|
270
|
+
response.writeHead(status).end(redactDataPath(prefix + String(error)));
|
|
271
|
+
}
|
|
188
272
|
async function readBody(request) {
|
|
189
273
|
const chunks = [];
|
|
190
274
|
let size = 0;
|
|
@@ -211,8 +295,10 @@ function executeStatement(sqlite, statement) {
|
|
|
211
295
|
if (typeof sql !== 'string' || !sql.trim() || !methods.includes(String(method))) {
|
|
212
296
|
throw new Error('Invalid transaction statement.');
|
|
213
297
|
}
|
|
214
|
-
if (method === 'exec')
|
|
215
|
-
|
|
298
|
+
if (method === 'exec') {
|
|
299
|
+
sqlite.exec(sql.trim());
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
216
302
|
const runner = sqlite.prepare(sql.trim());
|
|
217
303
|
const execute = runner[method];
|
|
218
304
|
return statement.d === undefined ? execute.call(runner) : execute.call(runner, statement.d);
|
|
@@ -231,5 +317,5 @@ async function onEsModule(request, response) {
|
|
|
231
317
|
'Content-Type': 'text/javascript',
|
|
232
318
|
'Access-Control-Allow-Origin': '*',
|
|
233
319
|
})
|
|
234
|
-
.end(code.replace('__API_URL__', hostname));
|
|
320
|
+
.end(redactDataPath(code.replace('__API_URL__', hostname)));
|
|
235
321
|
}
|