@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 CHANGED
@@ -1,21 +1,10 @@
1
- FROM ghcr.io/cloud-cli/node:latest AS builder
2
-
3
- USER root
4
- WORKDIR /home/app
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
- * { box-sizing: border-box; }
33
- html, body { height: 100%; margin: 0; }
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: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
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, button:focus-visible { border-color: var(--accent); color: var(--accent); }
49
- .shell { display: grid; grid-template-rows: auto 1fr; height: 100%; min-height: 0; }
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 { color: var(--accent); font-weight: 700; letter-spacing: .08em; }
58
- .brand-mark { border-radius: 5px; height: 24px; width: 24px; }
59
- .db-name { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
60
- .spacer { flex: 1; }
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 { min-width: 0; min-height: 0; overflow: hidden; }
67
- .schema-panel { border-right: 1px solid var(--border); display: flex; flex-direction: column; }
68
- .editor-panel { border-right: 1px solid var(--border); display: grid; grid-template-rows: minmax(190px, 38%) minmax(0, 1fr); }
69
- .graph-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); padding: 12px; gap: 10px; }
70
- .panel-header { align-items: center; display: flex; gap: 8px; padding: 12px; }
71
- .panel-title { color: var(--muted); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }
72
- .schema-count { color: var(--accent); margin-left: auto; }
73
- .schema-list { border-top: 1px solid var(--border); flex: 1; overflow: auto; padding: 7px; }
74
- .schema-item { background: transparent; border: 0; display: block; padding: 9px; text-align: left; width: 100%; }
75
- .schema-item:hover { background: var(--panel-raised); }
76
- .schema-kind { color: var(--muted); font-size: 11px; margin-left: 5px; }
77
- .schema-columns { color: var(--muted); display: block; font-size: 11px; margin-top: 3px; }
78
- .editor, .results { min-height: 0; padding: 12px; }
79
- .editor { border-bottom: 1px solid var(--border); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; }
80
- .editor-actions { display: flex; gap: 8px; justify-content: flex-end; }
81
- .run { background: var(--accent); border-color: var(--accent); color: #0d1918; font-weight: 700; }
82
- .run:hover, .run:focus-visible { color: #0d1918; opacity: .85; }
83
- code-editor { display: block; min-height: 0; }
84
- .results { overflow: auto; }
85
- .result { border-bottom: 1px solid var(--border); display: block; padding: 8px 0; white-space: pre-wrap; }
86
- .error { color: var(--danger); padding: 8px 0; white-space: pre-wrap; }
87
- .graph-wrap { background: #f8fafc; border-radius: 8px; min-height: 0; overflow: auto; }
88
- mermaid-graph { display: block; min-height: 100%; min-width: 100%; }
89
- .hint { color: var(--muted); font-size: 12px; padding: 12px; }
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 { grid-template-columns: 210px minmax(330px, 1fr); }
93
- .graph-panel { border-top: 1px solid var(--border); grid-column: 1 / -1; min-height: 420px; }
94
- .editor-panel { border-right: 0; }
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 { display: block; overflow: auto; }
98
- .schema-panel, .editor-panel { border-right: 0; border-bottom: 1px solid var(--border); }
99
- .schema-panel { max-height: 220px; }
100
- .editor-panel { height: 560px; }
101
- .graph-panel { min-height: 420px; }
102
- .db-name { display: none; }
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
- <script setup>
114
- import { hook, onInit } from '@li3/web';
276
+ <script setup>
277
+ import { hook, onInit } from '@li3/web';
278
+ import db from '@app/index.mjs';
115
279
 
116
- function schemaToMermaid(schema) {
117
- const tables = schema.tables.filter((table) => table.type === 'table' && table.sql);
118
- const names = new Map(tables.map((table) => [table.name, mermaidName(table.name)]));
119
- let diagram = 'erDiagram\n';
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
- for (const table of tables) {
122
- const name = names.get(table.name);
123
- diagram += ` ${name} {\n`;
124
- for (const column of table.columns.filter((column) => !column.hidden)) {
125
- const type = mermaidType(column.type);
126
- const key = column.pk ? ' PK' : '';
127
- diagram += ` ${type} ${mermaidName(column.name)}${key}\n`;
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
- for (const table of tables) {
133
- for (const foreignKey of table.foreignKeys) {
134
- const parent = names.get(foreignKey.table);
135
- const child = names.get(table.name);
136
- if (parent && child) diagram += ` ${parent} ||--o{ ${child} : references\n`;
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
- function mermaidName(value) {
143
- return String(value).replace(/[^a-zA-Z0-9_]/g, '_');
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
- function escapeQueryIdentifier(value) {
155
- return String(value).replaceAll('"', '""');
156
- }
310
+ function mermaidType(value) {
311
+ return String(value || 'value')
312
+ .replace(/[^a-zA-Z0-9_]/g, '_')
313
+ .toLowerCase();
314
+ }
157
315
 
158
- function appMain() {
159
- const [schemaData, setSchemaData] = hook(null);
160
- const [schemaTables, setSchemaTables] = hook([]);
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
- async function database() {
169
- return import(`https://${name.value}.db.apphor.de/index.mjs`);
320
+ function escapeQueryIdentifier(value) {
321
+ return String(value).replaceAll('"', '""');
170
322
  }
171
323
 
172
- async function loadSchema() {
173
- try {
174
- setError('');
175
- setLoading(true);
176
- const db = await database();
177
- const value = await db.schema();
178
- setSchemaData(value);
179
- setSchemaTables(value.tables.filter((table) => !table.name.startsWith('sqlite_')));
180
- setDiagram(schemaToMermaid(value));
181
- } catch (e) {
182
- setError(String(e));
183
- } finally {
184
- setLoading(false);
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
- async function runQuery() {
189
- if (!query.value.trim()) return;
190
- try {
191
- setError('');
192
- const db = await database();
193
- const method = /^\s*(select|pragma|with)\b/i.test(query.value) ? 'all' : 'run';
194
- const result = await db.query(method, query.value);
195
- setResponses([...responses.value, pretty(result)]);
196
- } catch (e) {
197
- setError(String(e));
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
- function selectTable(tableName) {
202
- setQuery(`SELECT * FROM "${escapeQueryIdentifier(tableName)}" LIMIT 100;`);
203
- }
363
+ function selectTable(tableName) {
364
+ setMethod('all');
365
+ setQuery(`SELECT * FROM "${escapeQueryIdentifier(tableName)}" LIMIT 100;`);
366
+ }
204
367
 
205
- function onKeyUp(event) {
206
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
207
- event.preventDefault();
208
- runQuery();
368
+ function clearResponses() {
369
+ setResponses([]);
209
370
  }
210
- }
211
371
 
212
- onInit(loadSchema);
372
+ function onKeyUp(event) {
373
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
374
+ event.preventDefault();
375
+ runQuery();
376
+ }
377
+ }
213
378
 
214
- return {
215
- name, schemaData, schemaTables, diagram, query, responses, error, loading,
216
- setQuery, loadSchema, runQuery, selectTable, onKeyUp,
217
- };
218
- }
379
+ onInit(loadSchema);
219
380
 
220
- export default appMain;
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
- </script>
223
- <div class="shell">
224
- <header class="topbar">
225
- <img class="brand-mark" src="./logo.svg" alt="d0" />
226
- <span class="brand">D0</span>
227
- <span class="db-name">{{ name || 'database' }}</span>
228
- <span class="spacer"></span>
229
- <button type="button" on-click="loadSchema()">{{ loading ? 'Loading...' : 'Refresh schema' }}</button>
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="panel-title">Query</div>
253
- <code-editor nolines="1" nostatus="1" theme="a11y-dark" language="sql" bind-value="query" on-change="setQuery($event.target.value)" on-keyup="onKeyUp($event)"></code-editor>
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="panel-title">Results</div>
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" style="padding: 0">
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.writeHead(200, { 'content-type': 'application/json' });
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.writeHead(400).end(String(error));
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
- const result = sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
237
+ sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
162
238
  logSlowQuery(started, `transaction (${t.length} statements)`);
163
- response.writeHead(200, { 'content-type': 'application/json' });
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.writeHead(200, { 'content-type': 'application/json' });
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.writeHead(status).end(String(error));
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
- return sqlite.exec(sql.trim());
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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/d0",
3
- "version": "1.7.4",
3
+ "version": "1.8.0",
4
4
  "packageManager": "pnpm@12.3.4",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",