@cloud-cli/d0 1.7.4 → 1.8.1

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,86 @@ 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
+ },
140
+ components: {
141
+ schemas: {
142
+ QueryRequest: {
143
+ type: 'object',
144
+ properties: {
145
+ s: { type: 'string', description: 'SQL statement.' },
146
+ d: { description: 'Positional or named statement bindings.' },
147
+ m: { type: 'string', enum: ['all', 'get', 'run', 'exec'], default: 'run' },
148
+ p: { type: 'array', items: { type: 'string' }, description: 'Pragmas applied before execution.' },
149
+ t: { type: 'array', items: { $ref: '#/components/schemas/TransactionStatement' } },
150
+ },
151
+ description: 'Use t instead of s to execute an atomic transaction.',
152
+ },
153
+ TransactionStatement: {
154
+ type: 'object',
155
+ required: ['s'],
156
+ properties: {
157
+ s: { type: 'string' },
158
+ d: {},
159
+ m: { type: 'string', enum: ['all', 'get', 'run', 'exec'], default: 'run' },
160
+ },
161
+ },
162
+ },
163
+ },
164
+ };
165
+ sendJson(response, 200, document);
166
+ }
94
167
  async function onSchema(response, db, includeInternal) {
95
168
  try {
96
169
  const sqlite = getDatabase(db);
97
170
  const schema = getSchema(sqlite, includeInternal);
98
- response.writeHead(200, { 'content-type': 'application/json' });
99
- response.end(JSON.stringify(schema));
171
+ sendJson(response, 200, schema);
100
172
  }
101
173
  catch (error) {
102
174
  DEBUG && console.error(error);
103
- response.writeHead(400).end(String(error));
175
+ sendError(response, 400, error);
104
176
  }
105
177
  }
106
178
  function getSchema(sqlite, includeInternal) {
@@ -142,6 +214,7 @@ function quotePragmaValue(value) {
142
214
  }
143
215
  export async function onQuery(request, response, db) {
144
216
  const query = await readBody(request);
217
+ let isTransaction = false;
145
218
  if (!query) {
146
219
  response.writeHead(413).end('Request body too large.');
147
220
  return;
@@ -153,15 +226,15 @@ export async function onQuery(request, response, db) {
153
226
  try {
154
227
  const { s = '', d, m = 'run', p, t } = JSON.parse(query.toString('utf-8'));
155
228
  if (t !== undefined) {
229
+ isTransaction = true;
156
230
  if (!Array.isArray(t) || !t.length)
157
231
  throw new Error('Invalid transaction.');
158
232
  const sqlite = getDatabase(db);
159
233
  applyPragmas(sqlite, p);
160
234
  const started = performance.now();
161
- const result = sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
235
+ sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
162
236
  logSlowQuery(started, `transaction (${t.length} statements)`);
163
- response.writeHead(200, { 'content-type': 'application/json' });
164
- response.end(JSON.stringify(result));
237
+ sendJson(response, 200, { success: true });
165
238
  return;
166
239
  }
167
240
  if (!s.trim()) {
@@ -175,16 +248,25 @@ export async function onQuery(request, response, db) {
175
248
  const started = performance.now();
176
249
  const result = executeStatement(sqlite, { s, d, m });
177
250
  logSlowQuery(started, s.trim());
178
- response.writeHead(200, { 'content-type': 'application/json' });
179
- response.end(JSON.stringify(result ?? null));
251
+ sendJson(response, 200, result ?? null);
180
252
  DEBUG && console.log(s.trim(), d, result);
181
253
  }
182
254
  catch (error) {
183
255
  DEBUG && console.error(error);
184
256
  const status = error.code === 'SQLITE_BUSY' ? 503 : 400;
185
- response.writeHead(status).end(String(error));
257
+ sendError(response, status, error, isTransaction ? 'Transaction failed: ' : '');
186
258
  }
187
259
  }
260
+ function redactDataPath(value) {
261
+ return value.replaceAll(dataPath, '***');
262
+ }
263
+ function sendJson(response, status, value) {
264
+ response.writeHead(status, { 'content-type': 'application/json' });
265
+ response.end(redactDataPath(JSON.stringify(value)));
266
+ }
267
+ function sendError(response, status, error, prefix = '') {
268
+ response.writeHead(status).end(redactDataPath(prefix + String(error)));
269
+ }
188
270
  async function readBody(request) {
189
271
  const chunks = [];
190
272
  let size = 0;
@@ -211,8 +293,10 @@ function executeStatement(sqlite, statement) {
211
293
  if (typeof sql !== 'string' || !sql.trim() || !methods.includes(String(method))) {
212
294
  throw new Error('Invalid transaction statement.');
213
295
  }
214
- if (method === 'exec')
215
- return sqlite.exec(sql.trim());
296
+ if (method === 'exec') {
297
+ sqlite.exec(sql.trim());
298
+ return null;
299
+ }
216
300
  const runner = sqlite.prepare(sql.trim());
217
301
  const execute = runner[method];
218
302
  return statement.d === undefined ? execute.call(runner) : execute.call(runner, statement.d);
@@ -231,5 +315,5 @@ async function onEsModule(request, response) {
231
315
  'Content-Type': 'text/javascript',
232
316
  'Access-Control-Allow-Origin': '*',
233
317
  })
234
- .end(code.replace('__API_URL__', hostname));
318
+ .end(redactDataPath(code.replace('__API_URL__', hostname)));
235
319
  }
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.1",
4
4
  "packageManager": "pnpm@12.3.4",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",