@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 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 `get`. Run is the default | no |
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.0" />
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></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
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" />
20
- </head>
21
- <body class="bg-gray-900 text-white font-sans h-screen flex flex-col overflow-hidden">
22
- <app-main class="contents"></app-main>
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
- <div class="flex flex-col items-stretch justify-between space-y-2 p-4 h-full">
30
- <div class="p-2 overflow-auto font-mono text-xs h-full">
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
- export default function () {
87
- const [diagram, setDiagram] = hook(null);
88
- const url = new URL(location.href);
89
- const [name, setName] = hook(url.hostname.replace('.db.apphor.de', ''));
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
- function append(r) {
95
- responses.value = [...responses.value, r];
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
- function onKeyUp(event) {
99
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
100
- event.preventDefault();
101
- onRun();
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
- async function runQuery(q) {
106
- const db = await import(`https://${name}.db.apphor.de/index.mjs`);
107
- if (!q.endsWith(';')) {
108
- q += ';';
109
- }
142
+ function mermaidName(value) {
143
+ return String(value).replace(/[^a-zA-Z0-9_]/g, '_');
144
+ }
110
145
 
111
- const s = await (q.toLowerCase().includes('select ') ? db.all(q) : db.run(q));
112
- return s;
113
- }
146
+ function mermaidType(value) {
147
+ return String(value || 'value').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
148
+ }
114
149
 
115
- async function onRun() {
116
- if (!name.value) return;
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
- async function onUpdateGraph() {
129
- if (!name.value) return;
130
- const schema = await runQuery('select * from sqlite_schema;');
131
- setDiagram(schemaToMermaid(schema));
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
- onInit(onUpdateGraph);
168
+ async function database() {
169
+ return import(`https://${name.value}.db.apphor.de/index.mjs`);
170
+ }
135
171
 
136
- return { name, diagram, query, responses, setName, setQuery, error, onRun, onKeyUp, onUpdateGraph };
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 schemaToMermaid(schema) {
140
- let diagram = 'erDiagram\n';
141
-
142
- for (const row of schema) {
143
- // 1. Ignore indexes, views, and system tables
144
- if (row.type !== 'table' || row.name.startsWith('sqlite_') || !row.sql) continue;
145
-
146
- const tableName = row.name;
147
- const sql = row.sql;
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
- return diagram;
205
+ function onKeyUp(event) {
206
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
207
+ event.preventDefault();
208
+ runQuery();
209
+ }
186
210
  }
187
- </script>
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 = Buffer.concat(await request.toArray());
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
- if (p && Array.isArray(p) && p.every((s) => typeof s === 'string')) {
74
- for (const s of p) {
75
- sqlite.pragma(s);
76
- }
77
- }
78
- let result;
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
- response.writeHead(400).end(String(error));
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>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/d0",
3
- "version": "1.6.1",
3
+ "version": "1.7.0",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "type": "module",