@cloud-cli/d0 1.6.1 → 1.7.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 +5 -2
- package/README.md +23 -2
- package/client.mjs +18 -3
- package/console.html +240 -149
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -17
- package/logo.svg +8 -0
- package/package.json +1 -1
package/Dockerfile
CHANGED
|
@@ -9,6 +9,9 @@ RUN pnpm i && pnpm build && rm -rf node_modules/ src/ && pnpm store prune
|
|
|
9
9
|
FROM ghcr.io/cloud-cli/node:latest
|
|
10
10
|
|
|
11
11
|
ENV NODE_ENV=production
|
|
12
|
+
ENV DATA_PATH=/home/app/data
|
|
12
13
|
WORKDIR /home/app
|
|
13
|
-
COPY --from=builder /home/app/ ./
|
|
14
|
-
RUN pnpm install --prod
|
|
14
|
+
COPY --from=builder --chown=node:node /home/app/ ./
|
|
15
|
+
RUN pnpm install --prod --frozen-lockfile && pnpm rebuild better-sqlite3
|
|
16
|
+
RUN mkdir -p /home/app/data && chown node:node /home/app/data
|
|
17
|
+
VOLUME ["/home/app/data"]
|
package/README.md
CHANGED
|
@@ -4,6 +4,15 @@ SQLite server over HTTP
|
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
7
|
+
**GET /schema**
|
|
8
|
+
|
|
9
|
+
Inspect the database schema without issuing SQL. The response includes table and view columns, indexes, foreign keys, original `sqlite_schema` objects, and an ordered `statements` array suitable for a basic schema dump or recreation script. SQLite internal objects are excluded by default; request `/schema?internal=1` to include them.
|
|
10
|
+
|
|
11
|
+
```js
|
|
12
|
+
const schema = await db.schema();
|
|
13
|
+
console.log(schema.tables, schema.statements);
|
|
14
|
+
```
|
|
15
|
+
|
|
7
16
|
**POST /query**
|
|
8
17
|
|
|
9
18
|
Run a prepared SQLite statement.
|
|
@@ -13,7 +22,7 @@ Accepts a JSON with these properties:
|
|
|
13
22
|
|-|:-:|-|
|
|
14
23
|
|`s` | string with the statement | **yes** |
|
|
15
24
|
|`d` | data to bind on a statement | no |
|
|
16
|
-
|`m` | method to execute: `all`, `run` or `
|
|
25
|
+
|`m` | method to execute: `all`, `run`, `get` or `exec`. Run is the default | no |
|
|
17
26
|
|`p` | pragma statements as an array of strings. They run before the query | no |
|
|
18
27
|
|
|
19
28
|
```js
|
|
@@ -32,6 +41,15 @@ fetch('https://db.example.com/query', {
|
|
|
32
41
|
import db from 'https://db.example.com/index.mjs';
|
|
33
42
|
|
|
34
43
|
const user = await db.query('SELECT * FROM user WHERE id = ?', [123]);
|
|
44
|
+
|
|
45
|
+
// Execute one or more SQL statements without preparing them
|
|
46
|
+
await db.exec('CREATE TABLE IF NOT EXISTS user (id INTEGER PRIMARY KEY)');
|
|
47
|
+
|
|
48
|
+
// Run several statements atomically; the server owns the transaction lifecycle
|
|
49
|
+
await db.transaction([
|
|
50
|
+
{ s: 'INSERT INTO user (id) VALUES (?)', d: [123] },
|
|
51
|
+
{ s: 'UPDATE user SET id = ? WHERE id = ?', d: [456, 123] }
|
|
52
|
+
]);
|
|
35
53
|
```
|
|
36
54
|
|
|
37
55
|
## Server address
|
|
@@ -45,5 +63,8 @@ Otherwise, it will be available at `http://localhost:PORT/` and serve a single d
|
|
|
45
63
|
| Variable | Description |
|
|
46
64
|
|-|-|
|
|
47
65
|
| PORT | HTTP port |
|
|
48
|
-
| DATA_PATH | Path to a folder where the database files are stored
|
|
66
|
+
| DATA_PATH | Path to a folder where the database files are stored (default: `/home/app/data` in Docker) |
|
|
49
67
|
| BASE_DOMAIN | Root domain to use in a multi-db server, e.g. `.example.com` |
|
|
68
|
+
| MAX_DATABASES | Maximum number of database connections kept open (default: `32`) |
|
|
69
|
+
| MAX_BODY_BYTES | Maximum JSON request size (default: `1048576`) |
|
|
70
|
+
| SLOW_QUERY_MS | Log slow queries in `DEBUG` mode (default: `1000`) |
|
package/client.mjs
CHANGED
|
@@ -2,14 +2,15 @@ const baseURL = "https://__API_URL__";
|
|
|
2
2
|
|
|
3
3
|
let pragmas = [];
|
|
4
4
|
|
|
5
|
-
async function query(method, statement, data, pragma = pragmas) {
|
|
5
|
+
async function query(method, statement, data, pragma = pragmas, transaction) {
|
|
6
6
|
const req = await fetch(new URL("/query", baseURL), {
|
|
7
7
|
method: "POST",
|
|
8
8
|
body: JSON.stringify({
|
|
9
9
|
s: statement,
|
|
10
10
|
d: data,
|
|
11
11
|
m: method,
|
|
12
|
-
p: pragma
|
|
12
|
+
p: pragma,
|
|
13
|
+
t: transaction
|
|
13
14
|
}),
|
|
14
15
|
});
|
|
15
16
|
|
|
@@ -20,9 +21,23 @@ async function query(method, statement, data, pragma = pragmas) {
|
|
|
20
21
|
throw new Error(await req.text());
|
|
21
22
|
}
|
|
22
23
|
|
|
24
|
+
export async function schema({ internal = false } = {}) {
|
|
25
|
+
const url = new URL('/schema', baseURL);
|
|
26
|
+
if (internal) url.searchParams.set('internal', '1');
|
|
27
|
+
|
|
28
|
+
const req = await fetch(url);
|
|
29
|
+
if (req.ok) return req.json();
|
|
30
|
+
throw new Error(await req.text());
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function transaction(statements, pragma = pragmas) {
|
|
34
|
+
return query('transaction', undefined, undefined, pragma, statements);
|
|
35
|
+
}
|
|
36
|
+
|
|
23
37
|
export const get = query.bind(null, 'get');
|
|
24
38
|
export const run = query.bind(null, 'run');
|
|
25
39
|
export const all = query.bind(null, 'all');
|
|
40
|
+
export const exec = query.bind(null, 'exec');
|
|
26
41
|
|
|
27
42
|
export function pragma(p) {
|
|
28
43
|
if (Array.isArray(p) && p.every(s => typeof s === 'string')) {
|
|
@@ -30,4 +45,4 @@ export function pragma(p) {
|
|
|
30
45
|
}
|
|
31
46
|
}
|
|
32
47
|
|
|
33
|
-
export default { query, get, run, all, pragma };
|
|
48
|
+
export default { query, get, run, all, exec, transaction, schema, pragma };
|
package/console.html
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
<html lang="en">
|
|
3
3
|
<head>
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<link rel="component" href="https://sodium.static.apphor.de/code-editor.html" />
|
|
7
7
|
<link rel="component" href="https://sodium.static.apphor.de/code-block.html" />
|
|
8
8
|
<link rel="component" href="https://sodium.static.apphor.de/mermaid-graph.html" />
|
|
9
|
-
<title
|
|
9
|
+
<title>D0 Console</title>
|
|
10
10
|
<script type="importmap">
|
|
11
11
|
{
|
|
12
12
|
"imports": {
|
|
@@ -16,175 +16,266 @@
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
</script>
|
|
19
|
-
<
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
19
|
+
<style>
|
|
20
|
+
:root {
|
|
21
|
+
color-scheme: dark;
|
|
22
|
+
--background: #101316;
|
|
23
|
+
--panel: #171b20;
|
|
24
|
+
--panel-raised: #1d232a;
|
|
25
|
+
--border: #303943;
|
|
26
|
+
--muted: #8d99a8;
|
|
27
|
+
--text: #edf2f7;
|
|
28
|
+
--accent: #8bd5ca;
|
|
29
|
+
--danger: #ff8f8f;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
* { box-sizing: border-box; }
|
|
33
|
+
html, body { height: 100%; margin: 0; }
|
|
34
|
+
body {
|
|
35
|
+
background: var(--background);
|
|
36
|
+
color: var(--text);
|
|
37
|
+
font: 14px/1.4 ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
38
|
+
}
|
|
39
|
+
button, input { font: inherit; }
|
|
40
|
+
button {
|
|
41
|
+
border: 1px solid var(--border);
|
|
42
|
+
border-radius: 6px;
|
|
43
|
+
background: var(--panel-raised);
|
|
44
|
+
color: var(--text);
|
|
45
|
+
cursor: pointer;
|
|
46
|
+
padding: 7px 11px;
|
|
47
|
+
}
|
|
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; }
|
|
50
|
+
.topbar {
|
|
51
|
+
align-items: center;
|
|
52
|
+
border-bottom: 1px solid var(--border);
|
|
53
|
+
display: flex;
|
|
54
|
+
gap: 10px;
|
|
55
|
+
padding: 10px 14px;
|
|
56
|
+
}
|
|
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; }
|
|
61
|
+
.workspace {
|
|
62
|
+
display: grid;
|
|
63
|
+
grid-template-columns: 245px minmax(360px, 1fr) minmax(360px, 1.15fr);
|
|
64
|
+
min-height: 0;
|
|
65
|
+
}
|
|
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; }
|
|
23
90
|
|
|
91
|
+
@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; }
|
|
95
|
+
}
|
|
96
|
+
@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; }
|
|
103
|
+
}
|
|
104
|
+
</style>
|
|
105
|
+
</head>
|
|
106
|
+
<body>
|
|
24
107
|
<script type="module">
|
|
25
108
|
import '@li3/web';
|
|
26
109
|
</script>
|
|
110
|
+
<app-main class="contents"></app-main>
|
|
27
111
|
|
|
28
112
|
<template component="app-main">
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<template for="r of responses">
|
|
32
|
-
<code-block
|
|
33
|
-
bind-source="r || ''"
|
|
34
|
-
language="json"
|
|
35
|
-
class="py-1 border-b block w-full whitespace-pre-wrap overflow-auto"
|
|
36
|
-
style="max-height: 10rem"
|
|
37
|
-
></code-block>
|
|
38
|
-
</template>
|
|
39
|
-
<div class="text-red-400 text-sm">{{ error || '' }}</div>
|
|
40
|
-
</div>
|
|
41
|
-
<div class="flex-1">
|
|
42
|
-
<input
|
|
43
|
-
bind-value="name"
|
|
44
|
-
placeholder="Name"
|
|
45
|
-
class="block w-full p-2 rounded-md bg-gray-300 border text-black text-xs font-mono"
|
|
46
|
-
on-change="setName($event.target.value)"
|
|
47
|
-
/>
|
|
48
|
-
</div>
|
|
49
|
-
<div class="flex-1">
|
|
50
|
-
<form on-submit.prevent="onRun()">
|
|
51
|
-
<code-editor
|
|
52
|
-
nolines="1"
|
|
53
|
-
nostatus="1"
|
|
54
|
-
language="sql"
|
|
55
|
-
bind-value="query"
|
|
56
|
-
on-change="setQuery($event.target.value)"
|
|
57
|
-
on-keyup="onKeyUp($event)"
|
|
58
|
-
class="font-mono text-xs border rounded-md w-full block h-32"
|
|
59
|
-
></code-editor>
|
|
60
|
-
<div class="flex items-center justify-end mt-2">
|
|
61
|
-
<button
|
|
62
|
-
type="submit"
|
|
63
|
-
bind-disabled="!name || !query"
|
|
64
|
-
class="bg-gray-400 text-black px-4 py-2 rounded focus:bg-white hover:bg-white"
|
|
65
|
-
>
|
|
66
|
-
Run
|
|
67
|
-
</button>
|
|
68
|
-
|
|
69
|
-
<button
|
|
70
|
-
type="button"
|
|
71
|
-
on-click="onUpdateGraph()"
|
|
72
|
-
bind-disabled="!name"
|
|
73
|
-
class="bg-gray-400 text-black px-4 py-2 rounded focus:bg-white hover:bg-white"
|
|
74
|
-
>
|
|
75
|
-
Graph
|
|
76
|
-
</button>
|
|
77
|
-
</div>
|
|
78
|
-
</form>
|
|
79
|
-
</div>
|
|
80
|
-
<mermaid-graph class="flex-1 h-64 overflow-auto" bind-input="diagram"></mermaid-graph>
|
|
81
|
-
</div>
|
|
82
|
-
|
|
83
|
-
<script setup>
|
|
84
|
-
import { hook, onInit } from '@li3/web';
|
|
113
|
+
<script setup>
|
|
114
|
+
import { hook, onInit } from '@li3/web';
|
|
85
115
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const [query, setQuery] = hook('');
|
|
91
|
-
const [responses] = hook('');
|
|
92
|
-
const [error, setError] = hook('');
|
|
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';
|
|
93
120
|
|
|
94
|
-
|
|
95
|
-
|
|
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`;
|
|
96
128
|
}
|
|
129
|
+
diagram += ' }\n';
|
|
130
|
+
}
|
|
97
131
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
}
|
|
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`;
|
|
103
137
|
}
|
|
138
|
+
}
|
|
139
|
+
return diagram;
|
|
140
|
+
}
|
|
104
141
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
q += ';';
|
|
109
|
-
}
|
|
142
|
+
function mermaidName(value) {
|
|
143
|
+
return String(value).replace(/[^a-zA-Z0-9_]/g, '_');
|
|
144
|
+
}
|
|
110
145
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
146
|
+
function mermaidType(value) {
|
|
147
|
+
return String(value || 'value').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
|
|
148
|
+
}
|
|
114
149
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
try {
|
|
119
|
-
setError('');
|
|
120
|
-
const q = query.value.trim();
|
|
121
|
-
const s = await runQuery(q);
|
|
122
|
-
append(JSON.stringify(s, null, 2));
|
|
123
|
-
} catch (e) {
|
|
124
|
-
setError(e);
|
|
125
|
-
}
|
|
126
|
-
}
|
|
150
|
+
function pretty(value) {
|
|
151
|
+
return JSON.stringify(value, null, 2);
|
|
152
|
+
}
|
|
127
153
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
154
|
+
function escapeQueryIdentifier(value) {
|
|
155
|
+
return String(value).replaceAll('"', '""');
|
|
156
|
+
}
|
|
157
|
+
|
|
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]);
|
|
133
167
|
|
|
134
|
-
|
|
168
|
+
async function database() {
|
|
169
|
+
return import(`https://${name.value}.db.apphor.de/index.mjs`);
|
|
170
|
+
}
|
|
135
171
|
|
|
136
|
-
|
|
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);
|
|
185
|
+
}
|
|
137
186
|
}
|
|
138
187
|
|
|
139
|
-
function
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
// 2. Extract relationships (matches both inline 'REFERENCES tbl' and 'FOREIGN KEY (...) REFERENCES tbl')
|
|
150
|
-
const fkRegex = /REFERENCES\s+([^\s(;]+)/gi;
|
|
151
|
-
let match;
|
|
152
|
-
const referencedTables = new Set();
|
|
153
|
-
|
|
154
|
-
while ((match = fkRegex.exec(sql)) !== null) {
|
|
155
|
-
// Clean up target table name (strip quotes/brackets)
|
|
156
|
-
const parentTable = match[1].replace(/[`"'[\]]/g, '').split('(')[0];
|
|
157
|
-
referencedTables.add(parentTable);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
referencedTables.forEach((parentTable) => {
|
|
161
|
-
diagram += ` ${parentTable} ||--o{ ${tableName} : "references"\n`;
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
// 3. Extract columns
|
|
165
|
-
diagram += ` ${tableName} {\n`;
|
|
166
|
-
const bodyMatch = sql.match(/\(([\s\S]+)\)/);
|
|
167
|
-
|
|
168
|
-
if (bodyMatch) {
|
|
169
|
-
const definitions = bodyMatch[1].split(/,\s*(?![^()]*\))/);
|
|
170
|
-
definitions.forEach((def) => {
|
|
171
|
-
const parts = def.trim().split(/\s+/);
|
|
172
|
-
const keyword = parts[0] ? parts[0].toUpperCase() : '';
|
|
173
|
-
|
|
174
|
-
// Skip table-level constraints
|
|
175
|
-
if (parts[0] && !['CONSTRAINT', 'PRIMARY', 'FOREIGN', 'UNIQUE', 'CHECK'].includes(keyword)) {
|
|
176
|
-
const colName = parts[0].replace(/[`"'[\]]/g, '');
|
|
177
|
-
const colType = parts[1] ? parts[1].replace(/,/g, '') : 'TEXT';
|
|
178
|
-
diagram += ` ${colType} ${colName}\n`;
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
}
|
|
182
|
-
diagram += ` }\n`;
|
|
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));
|
|
183
198
|
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function selectTable(tableName) {
|
|
202
|
+
setQuery(`SELECT * FROM "${escapeQueryIdentifier(tableName)}" LIMIT 100;`);
|
|
203
|
+
}
|
|
184
204
|
|
|
185
|
-
|
|
205
|
+
function onKeyUp(event) {
|
|
206
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
runQuery();
|
|
209
|
+
}
|
|
186
210
|
}
|
|
187
|
-
|
|
211
|
+
|
|
212
|
+
onInit(loadSchema);
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
name, schemaData, schemaTables, diagram, query, responses, error, loading,
|
|
216
|
+
setQuery, loadSchema, runQuery, selectTable, onKeyUp,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default appMain;
|
|
221
|
+
|
|
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>
|
|
230
|
+
</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>
|
|
236
|
+
</div>
|
|
237
|
+
<div class="schema-list">
|
|
238
|
+
<template if="!schemaTables.length">
|
|
239
|
+
<div class="hint">No tables discovered.</div>
|
|
240
|
+
</template>
|
|
241
|
+
<template for="table of schemaTables">
|
|
242
|
+
<button class="schema-item" type="button" on-click="selectTable(table.name)">
|
|
243
|
+
{{ table.name }} <span class="schema-kind">{{ table.type }}</span>
|
|
244
|
+
<span class="schema-columns">{{ table.columns.length }} columns</span>
|
|
245
|
+
</button>
|
|
246
|
+
</template>
|
|
247
|
+
</div>
|
|
248
|
+
</aside>
|
|
249
|
+
|
|
250
|
+
<section class="panel editor-panel">
|
|
251
|
+
<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>
|
|
254
|
+
<div class="editor-actions">
|
|
255
|
+
<button class="run" type="button" on-click="runQuery()">Run query</button>
|
|
256
|
+
</div>
|
|
257
|
+
</div>
|
|
258
|
+
<div class="results">
|
|
259
|
+
<div class="panel-title">Results</div>
|
|
260
|
+
<template for="result of responses">
|
|
261
|
+
<code-block class="result" bind-source="result" language="json"></code-block>
|
|
262
|
+
</template>
|
|
263
|
+
<div class="error">{{ error || '' }}</div>
|
|
264
|
+
</div>
|
|
265
|
+
</section>
|
|
266
|
+
|
|
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>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="graph-wrap">
|
|
274
|
+
<mermaid-graph class="h-full w-full" bind-input="diagram"></mermaid-graph>
|
|
275
|
+
</div>
|
|
276
|
+
</section>
|
|
277
|
+
</main>
|
|
278
|
+
</div>
|
|
188
279
|
</template>
|
|
189
280
|
</body>
|
|
190
281
|
</html>
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
2
|
import { Database } from 'better-sqlite3';
|
|
3
3
|
export declare function getDatabase(file: string): Database;
|
|
4
|
+
export declare function closeDatabases(): void;
|
|
4
5
|
export declare function serve(): any;
|
|
5
6
|
export declare function handleRequest(request: IncomingMessage, response: ServerResponse, db: string): Promise<void>;
|
|
6
7
|
export declare function onQuery(request: IncomingMessage, response: ServerResponse, db: string): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,17 +1,46 @@
|
|
|
1
1
|
import { createServer } from 'node:http';
|
|
2
|
+
import { mkdirSync } from 'node:fs';
|
|
2
3
|
import { readFile } from 'node:fs/promises';
|
|
3
4
|
import SQLite from 'better-sqlite3';
|
|
4
5
|
import { join } from 'node:path';
|
|
6
|
+
import { performance } from 'node:perf_hooks';
|
|
5
7
|
const DEBUG = !!process.env.DEBUG;
|
|
6
8
|
const methods = ['all', 'run', 'get', 'exec'];
|
|
7
9
|
const baseDomain = process.env.BASE_DOMAIN;
|
|
8
10
|
const dataPath = process.env.DATA_PATH || join(import.meta.dirname, 'data');
|
|
11
|
+
const maxDatabases = Math.max(1, Number.parseInt(process.env.MAX_DATABASES || '32', 10) || 32);
|
|
12
|
+
const maxBodyBytes = Math.max(1, Number.parseInt(process.env.MAX_BODY_BYTES || '1048576', 10) || 1048576);
|
|
13
|
+
const slowQueryMs = Math.max(0, Number.parseInt(process.env.SLOW_QUERY_MS || '1000', 10) || 1000);
|
|
14
|
+
const databases = new Map();
|
|
15
|
+
mkdirSync(dataPath, { recursive: true });
|
|
9
16
|
export function getDatabase(file) {
|
|
10
17
|
const fullPath = join(dataPath, file);
|
|
18
|
+
const cached = databases.get(fullPath);
|
|
19
|
+
if (cached) {
|
|
20
|
+
// Map insertion order provides a small LRU without another dependency.
|
|
21
|
+
databases.delete(fullPath);
|
|
22
|
+
databases.set(fullPath, cached);
|
|
23
|
+
return cached;
|
|
24
|
+
}
|
|
11
25
|
const db = new SQLite(fullPath);
|
|
12
26
|
db.pragma('journal_mode = WAL');
|
|
27
|
+
db.pragma('busy_timeout = 5000');
|
|
28
|
+
db.pragma('foreign_keys = ON');
|
|
29
|
+
databases.set(fullPath, db);
|
|
30
|
+
while (databases.size > maxDatabases) {
|
|
31
|
+
const oldest = databases.keys().next().value;
|
|
32
|
+
if (!oldest)
|
|
33
|
+
break;
|
|
34
|
+
databases.get(oldest)?.close();
|
|
35
|
+
databases.delete(oldest);
|
|
36
|
+
}
|
|
13
37
|
return db;
|
|
14
38
|
}
|
|
39
|
+
export function closeDatabases() {
|
|
40
|
+
for (const db of databases.values())
|
|
41
|
+
db.close();
|
|
42
|
+
databases.clear();
|
|
43
|
+
}
|
|
15
44
|
export function serve() {
|
|
16
45
|
let server;
|
|
17
46
|
if (baseDomain) {
|
|
@@ -33,6 +62,7 @@ export function serve() {
|
|
|
33
62
|
server.listen(+process.env.PORT, () => {
|
|
34
63
|
console.log(`Started on ${process.env.PORT}`);
|
|
35
64
|
});
|
|
65
|
+
server.once('close', closeDatabases);
|
|
36
66
|
return server;
|
|
37
67
|
}
|
|
38
68
|
export async function handleRequest(request, response, db) {
|
|
@@ -47,22 +77,93 @@ export async function handleRequest(request, response, db) {
|
|
|
47
77
|
const consolePage = await readFile('./console.html', 'utf8');
|
|
48
78
|
response.writeHead(200, { 'content-type': 'text/html' }).end(consolePage);
|
|
49
79
|
return;
|
|
80
|
+
case 'GET /logo.svg':
|
|
81
|
+
const logo = await readFile('./logo.svg', 'utf8');
|
|
82
|
+
response.writeHead(200, { 'content-type': 'image/svg+xml' }).end(logo);
|
|
83
|
+
return;
|
|
50
84
|
case 'GET /index.mjs':
|
|
51
85
|
return onEsModule(request, response);
|
|
86
|
+
case 'GET /schema':
|
|
87
|
+
return onSchema(response, db, url.searchParams.get('internal') === '1');
|
|
52
88
|
case 'POST /query':
|
|
53
89
|
return onQuery(request, response, db);
|
|
54
90
|
default:
|
|
55
91
|
response.writeHead(404).end();
|
|
56
92
|
}
|
|
57
93
|
}
|
|
94
|
+
async function onSchema(response, db, includeInternal) {
|
|
95
|
+
try {
|
|
96
|
+
const sqlite = getDatabase(db);
|
|
97
|
+
const schema = getSchema(sqlite, includeInternal);
|
|
98
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
99
|
+
response.end(JSON.stringify(schema));
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
DEBUG && console.error(error);
|
|
103
|
+
response.writeHead(400).end(String(error));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function getSchema(sqlite, includeInternal) {
|
|
107
|
+
const objects = sqlite
|
|
108
|
+
.prepare(`SELECT type, name, tbl_name, sql
|
|
109
|
+
FROM sqlite_schema
|
|
110
|
+
WHERE sql IS NOT NULL
|
|
111
|
+
ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'trigger' THEN 2 ELSE 3 END, name`)
|
|
112
|
+
.all();
|
|
113
|
+
const tables = sqlite.pragma('table_list');
|
|
114
|
+
const visibleTables = tables.filter((table) => includeInternal || !table.name.startsWith('sqlite_'));
|
|
115
|
+
const details = visibleTables.map((table) => {
|
|
116
|
+
const columns = sqlite.pragma(`table_xinfo(${quotePragmaValue(table.name)})`);
|
|
117
|
+
const tableObjects = objects.filter((object) => object.tbl_name === table.name);
|
|
118
|
+
const indexes = table.type === 'table'
|
|
119
|
+
? sqlite.pragma(`index_list(${quotePragmaValue(table.name)})`).map((index) => ({
|
|
120
|
+
...index,
|
|
121
|
+
columns: sqlite.pragma(`index_info(${quotePragmaValue(index.name)})`),
|
|
122
|
+
sql: objects.find((object) => object.type === 'index' && object.name === index.name)?.sql || null,
|
|
123
|
+
}))
|
|
124
|
+
: [];
|
|
125
|
+
return {
|
|
126
|
+
...table,
|
|
127
|
+
sql: objects.find((object) => object.type === 'table' && object.name === table.name)?.sql || null,
|
|
128
|
+
columns,
|
|
129
|
+
indexes,
|
|
130
|
+
foreignKeys: table.type === 'table' ? sqlite.pragma(`foreign_key_list(${quotePragmaValue(table.name)})`) : [],
|
|
131
|
+
objects: tableObjects,
|
|
132
|
+
};
|
|
133
|
+
});
|
|
134
|
+
return {
|
|
135
|
+
tables: details,
|
|
136
|
+
objects: includeInternal ? objects : objects.filter((object) => !object.name.startsWith('sqlite_')),
|
|
137
|
+
statements: (includeInternal ? objects : objects.filter((object) => !object.name.startsWith('sqlite_'))).map((object) => object.sql),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
function quotePragmaValue(value) {
|
|
141
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
142
|
+
}
|
|
58
143
|
export async function onQuery(request, response, db) {
|
|
59
|
-
const query =
|
|
144
|
+
const query = await readBody(request);
|
|
145
|
+
if (!query) {
|
|
146
|
+
response.writeHead(413).end('Request body too large.');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
60
149
|
if (!query.length) {
|
|
61
150
|
response.writeHead(400).end();
|
|
62
151
|
return;
|
|
63
152
|
}
|
|
64
153
|
try {
|
|
65
|
-
const { s = '', d, m = 'run', p } = JSON.parse(query.toString('utf-8'));
|
|
154
|
+
const { s = '', d, m = 'run', p, t } = JSON.parse(query.toString('utf-8'));
|
|
155
|
+
if (t !== undefined) {
|
|
156
|
+
if (!Array.isArray(t) || !t.length)
|
|
157
|
+
throw new Error('Invalid transaction.');
|
|
158
|
+
const sqlite = getDatabase(db);
|
|
159
|
+
applyPragmas(sqlite, p);
|
|
160
|
+
const started = performance.now();
|
|
161
|
+
const result = sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
|
|
162
|
+
logSlowQuery(started, `transaction (${t.length} statements)`);
|
|
163
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
164
|
+
response.end(JSON.stringify(result));
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
66
167
|
if (!s.trim()) {
|
|
67
168
|
throw new Error('Invalid statement.');
|
|
68
169
|
}
|
|
@@ -70,25 +171,56 @@ export async function onQuery(request, response, db) {
|
|
|
70
171
|
throw new Error('Invalid method. Must be one of ' + methods.join(', '));
|
|
71
172
|
}
|
|
72
173
|
const sqlite = getDatabase(db);
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (m === 'exec') {
|
|
80
|
-
result = sqlite.exec(s.trim());
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
const runner = sqlite.prepare(s.trim());
|
|
84
|
-
result = d ? runner[m](d) : runner[m]();
|
|
85
|
-
}
|
|
86
|
-
response.end(JSON.stringify(result || null));
|
|
174
|
+
applyPragmas(sqlite, p);
|
|
175
|
+
const started = performance.now();
|
|
176
|
+
const result = executeStatement(sqlite, { s, d, m });
|
|
177
|
+
logSlowQuery(started, s.trim());
|
|
178
|
+
response.writeHead(200, { 'content-type': 'application/json' });
|
|
179
|
+
response.end(JSON.stringify(result ?? null));
|
|
87
180
|
DEBUG && console.log(s.trim(), d, result);
|
|
88
181
|
}
|
|
89
182
|
catch (error) {
|
|
90
183
|
DEBUG && console.error(error);
|
|
91
|
-
|
|
184
|
+
const status = error.code === 'SQLITE_BUSY' ? 503 : 400;
|
|
185
|
+
response.writeHead(status).end(String(error));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
async function readBody(request) {
|
|
189
|
+
const chunks = [];
|
|
190
|
+
let size = 0;
|
|
191
|
+
for await (const chunk of request) {
|
|
192
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
193
|
+
size += buffer.length;
|
|
194
|
+
if (size > maxBodyBytes) {
|
|
195
|
+
request.resume();
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
chunks.push(buffer);
|
|
199
|
+
}
|
|
200
|
+
return Buffer.concat(chunks);
|
|
201
|
+
}
|
|
202
|
+
function applyPragmas(sqlite, pragmas) {
|
|
203
|
+
if (Array.isArray(pragmas) && pragmas.every((value) => typeof value === 'string')) {
|
|
204
|
+
for (const pragma of pragmas)
|
|
205
|
+
sqlite.pragma(pragma);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function executeStatement(sqlite, statement) {
|
|
209
|
+
const sql = statement.s;
|
|
210
|
+
const method = String(statement.m || 'run');
|
|
211
|
+
if (typeof sql !== 'string' || !sql.trim() || !methods.includes(String(method))) {
|
|
212
|
+
throw new Error('Invalid transaction statement.');
|
|
213
|
+
}
|
|
214
|
+
if (method === 'exec')
|
|
215
|
+
return sqlite.exec(sql.trim());
|
|
216
|
+
const runner = sqlite.prepare(sql.trim());
|
|
217
|
+
const execute = runner[method];
|
|
218
|
+
return statement.d === undefined ? execute.call(runner) : execute.call(runner, statement.d);
|
|
219
|
+
}
|
|
220
|
+
function logSlowQuery(started, statement) {
|
|
221
|
+
const duration = performance.now() - started;
|
|
222
|
+
if (DEBUG && duration >= slowQueryMs) {
|
|
223
|
+
console.log(`Slow query (${Math.round(duration)}ms):`, statement);
|
|
92
224
|
}
|
|
93
225
|
}
|
|
94
226
|
async function onEsModule(request, response) {
|
package/logo.svg
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-labelledby="title desc">
|
|
2
|
+
<title id="title">d0</title>
|
|
3
|
+
<desc id="desc">A simple stacked database mark with a zero-shaped center.</desc>
|
|
4
|
+
<rect width="256" height="256" rx="56" fill="#101316" />
|
|
5
|
+
<path fill="#8bd5ca" d="M48 72c0-24 36-40 80-40s80 16 80 40v112c0 24-36 40-80 40s-80-16-80-40V72Zm24 0v112c0 8 22 16 56 16s56-8 56-16V72c-16 10-38 16-56 16S88 82 72 72Zm56-16c-34 0-56 10-56 16s22 16 56 16 56-10 56-16-22-16-56-16Z" />
|
|
6
|
+
<path fill="#101316" d="M104 120h24c22 0 36 12 36 32s-14 32-36 32h-24v-16h24c11 0 18-5 18-16s-7-16-18-16h-24v-16Z" />
|
|
7
|
+
<path fill="#101316" d="M88 120h16v64H88z" />
|
|
8
|
+
</svg>
|