@cloud-cli/d0 1.6.1 → 1.6.2

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/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
@@ -47,3 +65,6 @@ Otherwise, it will be available at `http://localhost:PORT/` and serve a single d
47
65
  | PORT | HTTP port |
48
66
  | DATA_PATH | Path to a folder where the database files are stored |
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,264 @@
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
+ .db-name { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
59
+ .spacer { flex: 1; }
60
+ .workspace {
61
+ display: grid;
62
+ grid-template-columns: 245px minmax(360px, 1fr) minmax(360px, 1.15fr);
63
+ min-height: 0;
64
+ }
65
+ .panel { min-width: 0; min-height: 0; overflow: hidden; }
66
+ .schema-panel { border-right: 1px solid var(--border); display: flex; flex-direction: column; }
67
+ .editor-panel { border-right: 1px solid var(--border); display: grid; grid-template-rows: minmax(190px, 38%) minmax(0, 1fr); }
68
+ .graph-panel { display: grid; grid-template-rows: auto minmax(0, 1fr); padding: 12px; gap: 10px; }
69
+ .panel-header { align-items: center; display: flex; gap: 8px; padding: 12px; }
70
+ .panel-title { color: var(--muted); font-size: 11px; letter-spacing: .1em; text-transform: uppercase; }
71
+ .schema-count { color: var(--accent); margin-left: auto; }
72
+ .schema-list { border-top: 1px solid var(--border); flex: 1; overflow: auto; padding: 7px; }
73
+ .schema-item { background: transparent; border: 0; display: block; padding: 9px; text-align: left; width: 100%; }
74
+ .schema-item:hover { background: var(--panel-raised); }
75
+ .schema-kind { color: var(--muted); font-size: 11px; margin-left: 5px; }
76
+ .schema-columns { color: var(--muted); display: block; font-size: 11px; margin-top: 3px; }
77
+ .editor, .results { min-height: 0; padding: 12px; }
78
+ .editor { border-bottom: 1px solid var(--border); display: grid; grid-template-rows: auto minmax(0, 1fr) auto; gap: 8px; }
79
+ .editor-actions { display: flex; gap: 8px; justify-content: flex-end; }
80
+ .run { background: var(--accent); border-color: var(--accent); color: #0d1918; font-weight: 700; }
81
+ .run:hover, .run:focus-visible { color: #0d1918; opacity: .85; }
82
+ code-editor { display: block; min-height: 0; }
83
+ .results { overflow: auto; }
84
+ .result { border-bottom: 1px solid var(--border); display: block; padding: 8px 0; white-space: pre-wrap; }
85
+ .error { color: var(--danger); padding: 8px 0; white-space: pre-wrap; }
86
+ .graph-wrap { background: #f8fafc; border-radius: 8px; min-height: 0; overflow: auto; }
87
+ mermaid-graph { display: block; min-height: 100%; min-width: 100%; }
88
+ .hint { color: var(--muted); font-size: 12px; padding: 12px; }
23
89
 
90
+ @media (max-width: 1050px) {
91
+ .workspace { grid-template-columns: 210px minmax(330px, 1fr); }
92
+ .graph-panel { border-top: 1px solid var(--border); grid-column: 1 / -1; min-height: 420px; }
93
+ .editor-panel { border-right: 0; }
94
+ }
95
+ @media (max-width: 680px) {
96
+ .workspace { display: block; overflow: auto; }
97
+ .schema-panel, .editor-panel { border-right: 0; border-bottom: 1px solid var(--border); }
98
+ .schema-panel { max-height: 220px; }
99
+ .editor-panel { height: 560px; }
100
+ .graph-panel { min-height: 420px; }
101
+ .db-name { display: none; }
102
+ }
103
+ </style>
104
+ </head>
105
+ <body>
24
106
  <script type="module">
25
107
  import '@li3/web';
26
108
  </script>
109
+ <app-main class="contents"></app-main>
27
110
 
28
111
  <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';
112
+ <script setup>
113
+ import { hook, onInit } from '@li3/web';
85
114
 
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('');
115
+ function schemaToMermaid(schema) {
116
+ const tables = schema.tables.filter((table) => table.type === 'table' && table.sql);
117
+ const names = new Map(tables.map((table) => [table.name, mermaidName(table.name)]));
118
+ let diagram = 'erDiagram\n';
93
119
 
94
- function append(r) {
95
- responses.value = [...responses.value, r];
120
+ for (const table of tables) {
121
+ const name = names.get(table.name);
122
+ diagram += ` ${name} {\n`;
123
+ for (const column of table.columns.filter((column) => !column.hidden)) {
124
+ const type = mermaidType(column.type);
125
+ const key = column.pk ? ' PK' : '';
126
+ diagram += ` ${type} ${mermaidName(column.name)}${key}\n`;
96
127
  }
128
+ diagram += ' }\n';
129
+ }
97
130
 
98
- function onKeyUp(event) {
99
- if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
100
- event.preventDefault();
101
- onRun();
102
- }
131
+ for (const table of tables) {
132
+ for (const foreignKey of table.foreignKeys) {
133
+ const parent = names.get(foreignKey.table);
134
+ const child = names.get(table.name);
135
+ if (parent && child) diagram += ` ${parent} ||--o{ ${child} : references\n`;
103
136
  }
137
+ }
138
+ return diagram;
139
+ }
104
140
 
105
- async function runQuery(q) {
106
- const db = await import(`https://${name}.db.apphor.de/index.mjs`);
107
- if (!q.endsWith(';')) {
108
- q += ';';
109
- }
141
+ function mermaidName(value) {
142
+ return String(value).replace(/[^a-zA-Z0-9_]/g, '_');
143
+ }
110
144
 
111
- const s = await (q.toLowerCase().includes('select ') ? db.all(q) : db.run(q));
112
- return s;
113
- }
145
+ function mermaidType(value) {
146
+ return String(value || 'value').replace(/[^a-zA-Z0-9_]/g, '_').toLowerCase();
147
+ }
114
148
 
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
- }
149
+ function pretty(value) {
150
+ return JSON.stringify(value, null, 2);
151
+ }
127
152
 
128
- async function onUpdateGraph() {
129
- if (!name.value) return;
130
- const schema = await runQuery('select * from sqlite_schema;');
131
- setDiagram(schemaToMermaid(schema));
132
- }
153
+ function escapeQueryIdentifier(value) {
154
+ return String(value).replaceAll('"', '""');
155
+ }
156
+
157
+ function appMain() {
158
+ const [schemaData, setSchemaData] = hook(null);
159
+ const [schemaTables, setSchemaTables] = hook([]);
160
+ const [diagram, setDiagram] = hook('erDiagram\n');
161
+ const [query, setQuery] = hook('');
162
+ const [responses, setResponses] = hook([]);
163
+ const [error, setError] = hook('');
164
+ const [loading, setLoading] = hook(false);
165
+ const [name] = hook(new URL(location.href).hostname.split('.')[0]);
133
166
 
134
- onInit(onUpdateGraph);
167
+ async function database() {
168
+ return import(`https://${name.value}.db.apphor.de/index.mjs`);
169
+ }
135
170
 
136
- return { name, diagram, query, responses, setName, setQuery, error, onRun, onKeyUp, onUpdateGraph };
171
+ async function loadSchema() {
172
+ try {
173
+ setError('');
174
+ setLoading(true);
175
+ const db = await database();
176
+ const value = await db.schema();
177
+ setSchemaData(value);
178
+ setSchemaTables(value.tables.filter((table) => !table.name.startsWith('sqlite_')));
179
+ setDiagram(schemaToMermaid(value));
180
+ } catch (e) {
181
+ setError(String(e));
182
+ } finally {
183
+ setLoading(false);
184
+ }
137
185
  }
138
186
 
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`;
187
+ async function runQuery() {
188
+ if (!query.value.trim()) return;
189
+ try {
190
+ setError('');
191
+ const db = await database();
192
+ const method = /^\s*(select|pragma|with)\b/i.test(query.value) ? 'all' : 'run';
193
+ const result = await db.query(method, query.value);
194
+ setResponses([...responses.value, pretty(result)]);
195
+ } catch (e) {
196
+ setError(String(e));
183
197
  }
198
+ }
199
+
200
+ function selectTable(tableName) {
201
+ setQuery(`SELECT * FROM "${escapeQueryIdentifier(tableName)}" LIMIT 100;`);
202
+ }
184
203
 
185
- return diagram;
204
+ function onKeyUp(event) {
205
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
206
+ event.preventDefault();
207
+ runQuery();
208
+ }
186
209
  }
187
- </script>
210
+
211
+ onInit(loadSchema);
212
+
213
+ return {
214
+ name, schemaData, schemaTables, diagram, query, responses, error, loading,
215
+ setQuery, loadSchema, runQuery, selectTable, onKeyUp,
216
+ };
217
+ }
218
+
219
+ export default appMain;
220
+
221
+ </script>
222
+ <div class="shell">
223
+ <header class="topbar">
224
+ <span class="brand">D0</span>
225
+ <span class="db-name">{{ name || 'database' }}</span>
226
+ <span class="spacer"></span>
227
+ <button type="button" on-click="loadSchema()">{{ loading ? 'Loading...' : 'Refresh schema' }}</button>
228
+ </header>
229
+ <main class="workspace">
230
+ <aside class="panel schema-panel">
231
+ <div class="panel-header">
232
+ <span class="panel-title">Schema</span>
233
+ <span class="schema-count">{{ schemaTables.length }}</span>
234
+ </div>
235
+ <div class="schema-list">
236
+ <template if="!schemaTables.length">
237
+ <div class="hint">No tables discovered.</div>
238
+ </template>
239
+ <template for="table of schemaTables">
240
+ <button class="schema-item" type="button" on-click="selectTable(table.name)">
241
+ {{ table.name }} <span class="schema-kind">{{ table.type }}</span>
242
+ <span class="schema-columns">{{ table.columns.length }} columns</span>
243
+ </button>
244
+ </template>
245
+ </div>
246
+ </aside>
247
+
248
+ <section class="panel editor-panel">
249
+ <div class="editor">
250
+ <div class="panel-title">Query</div>
251
+ <code-editor nolines="1" language="sql" bind-value="query" on-change="setQuery($event.target.value)" on-keyup="onKeyUp($event)"></code-editor>
252
+ <div class="editor-actions">
253
+ <button class="run" type="button" on-click="runQuery()">Run query</button>
254
+ </div>
255
+ </div>
256
+ <div class="results">
257
+ <div class="panel-title">Results</div>
258
+ <template for="result of responses">
259
+ <code-block class="result" bind-source="result" language="json"></code-block>
260
+ </template>
261
+ <div class="error">{{ error || '' }}</div>
262
+ </div>
263
+ </section>
264
+
265
+ <section class="panel graph-panel">
266
+ <div class="panel-header" style="padding: 0">
267
+ <span class="panel-title">Relationships</span>
268
+ <span class="spacer"></span>
269
+ <button type="button" on-click="loadSchema()">Redraw</button>
270
+ </div>
271
+ <div class="graph-wrap">
272
+ <mermaid-graph class="h-full w-full" bind-input="diagram"></mermaid-graph>
273
+ </div>
274
+ </section>
275
+ </main>
276
+ </div>
188
277
  </template>
189
278
  </body>
190
279
  </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
@@ -2,16 +2,43 @@ import { createServer } from 'node:http';
2
2
  import { readFile } from 'node:fs/promises';
3
3
  import SQLite from 'better-sqlite3';
4
4
  import { join } from 'node:path';
5
+ import { performance } from 'node:perf_hooks';
5
6
  const DEBUG = !!process.env.DEBUG;
6
7
  const methods = ['all', 'run', 'get', 'exec'];
7
8
  const baseDomain = process.env.BASE_DOMAIN;
8
9
  const dataPath = process.env.DATA_PATH || join(import.meta.dirname, 'data');
10
+ const maxDatabases = Math.max(1, Number.parseInt(process.env.MAX_DATABASES || '32', 10) || 32);
11
+ const maxBodyBytes = Math.max(1, Number.parseInt(process.env.MAX_BODY_BYTES || '1048576', 10) || 1048576);
12
+ const slowQueryMs = Math.max(0, Number.parseInt(process.env.SLOW_QUERY_MS || '1000', 10) || 1000);
13
+ const databases = new Map();
9
14
  export function getDatabase(file) {
10
15
  const fullPath = join(dataPath, file);
16
+ const cached = databases.get(fullPath);
17
+ if (cached) {
18
+ // Map insertion order provides a small LRU without another dependency.
19
+ databases.delete(fullPath);
20
+ databases.set(fullPath, cached);
21
+ return cached;
22
+ }
11
23
  const db = new SQLite(fullPath);
12
24
  db.pragma('journal_mode = WAL');
25
+ db.pragma('busy_timeout = 5000');
26
+ db.pragma('foreign_keys = ON');
27
+ databases.set(fullPath, db);
28
+ while (databases.size > maxDatabases) {
29
+ const oldest = databases.keys().next().value;
30
+ if (!oldest)
31
+ break;
32
+ databases.get(oldest)?.close();
33
+ databases.delete(oldest);
34
+ }
13
35
  return db;
14
36
  }
37
+ export function closeDatabases() {
38
+ for (const db of databases.values())
39
+ db.close();
40
+ databases.clear();
41
+ }
15
42
  export function serve() {
16
43
  let server;
17
44
  if (baseDomain) {
@@ -33,6 +60,7 @@ export function serve() {
33
60
  server.listen(+process.env.PORT, () => {
34
61
  console.log(`Started on ${process.env.PORT}`);
35
62
  });
63
+ server.once('close', closeDatabases);
36
64
  return server;
37
65
  }
38
66
  export async function handleRequest(request, response, db) {
@@ -49,20 +77,87 @@ export async function handleRequest(request, response, db) {
49
77
  return;
50
78
  case 'GET /index.mjs':
51
79
  return onEsModule(request, response);
80
+ case 'GET /schema':
81
+ return onSchema(response, db, url.searchParams.get('internal') === '1');
52
82
  case 'POST /query':
53
83
  return onQuery(request, response, db);
54
84
  default:
55
85
  response.writeHead(404).end();
56
86
  }
57
87
  }
88
+ async function onSchema(response, db, includeInternal) {
89
+ try {
90
+ const sqlite = getDatabase(db);
91
+ const schema = getSchema(sqlite, includeInternal);
92
+ response.writeHead(200, { 'content-type': 'application/json' });
93
+ response.end(JSON.stringify(schema));
94
+ }
95
+ catch (error) {
96
+ DEBUG && console.error(error);
97
+ response.writeHead(400).end(String(error));
98
+ }
99
+ }
100
+ function getSchema(sqlite, includeInternal) {
101
+ const objects = sqlite
102
+ .prepare(`SELECT type, name, tbl_name, sql
103
+ FROM sqlite_schema
104
+ WHERE sql IS NOT NULL
105
+ ORDER BY CASE type WHEN 'table' THEN 0 WHEN 'index' THEN 1 WHEN 'trigger' THEN 2 ELSE 3 END, name`)
106
+ .all();
107
+ const tables = sqlite.pragma('table_list');
108
+ const visibleTables = tables.filter((table) => includeInternal || !table.name.startsWith('sqlite_'));
109
+ const details = visibleTables.map((table) => {
110
+ const columns = sqlite.pragma(`table_xinfo(${quotePragmaValue(table.name)})`);
111
+ const tableObjects = objects.filter((object) => object.tbl_name === table.name);
112
+ const indexes = table.type === 'table'
113
+ ? sqlite.pragma(`index_list(${quotePragmaValue(table.name)})`).map((index) => ({
114
+ ...index,
115
+ columns: sqlite.pragma(`index_info(${quotePragmaValue(index.name)})`),
116
+ sql: objects.find((object) => object.type === 'index' && object.name === index.name)?.sql || null,
117
+ }))
118
+ : [];
119
+ return {
120
+ ...table,
121
+ sql: objects.find((object) => object.type === 'table' && object.name === table.name)?.sql || null,
122
+ columns,
123
+ indexes,
124
+ foreignKeys: table.type === 'table' ? sqlite.pragma(`foreign_key_list(${quotePragmaValue(table.name)})`) : [],
125
+ objects: tableObjects,
126
+ };
127
+ });
128
+ return {
129
+ tables: details,
130
+ objects: includeInternal ? objects : objects.filter((object) => !object.name.startsWith('sqlite_')),
131
+ statements: (includeInternal ? objects : objects.filter((object) => !object.name.startsWith('sqlite_'))).map((object) => object.sql),
132
+ };
133
+ }
134
+ function quotePragmaValue(value) {
135
+ return `'${value.replaceAll("'", "''")}'`;
136
+ }
58
137
  export async function onQuery(request, response, db) {
59
- const query = Buffer.concat(await request.toArray());
138
+ const query = await readBody(request);
139
+ if (!query) {
140
+ response.writeHead(413).end('Request body too large.');
141
+ return;
142
+ }
60
143
  if (!query.length) {
61
144
  response.writeHead(400).end();
62
145
  return;
63
146
  }
64
147
  try {
65
- const { s = '', d, m = 'run', p } = JSON.parse(query.toString('utf-8'));
148
+ const { s = '', d, m = 'run', p, t } = JSON.parse(query.toString('utf-8'));
149
+ if (t !== undefined) {
150
+ if (!Array.isArray(t) || !t.length)
151
+ throw new Error('Invalid transaction.');
152
+ const sqlite = getDatabase(db);
153
+ applyPragmas(sqlite, p);
154
+ const started = performance.now();
155
+ const result = sqlite.transaction(() => t.map((statement) => executeStatement(sqlite, statement)))();
156
+ logSlowQuery(started, `transaction (${t.length} statements)`);
157
+ response.writeHead(200, { 'content-type': 'application/json' });
158
+ response.end(JSON.stringify(result));
159
+ return;
160
+ }
66
161
  if (!s.trim()) {
67
162
  throw new Error('Invalid statement.');
68
163
  }
@@ -70,25 +165,56 @@ export async function onQuery(request, response, db) {
70
165
  throw new Error('Invalid method. Must be one of ' + methods.join(', '));
71
166
  }
72
167
  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));
168
+ applyPragmas(sqlite, p);
169
+ const started = performance.now();
170
+ const result = executeStatement(sqlite, { s, d, m });
171
+ logSlowQuery(started, s.trim());
172
+ response.writeHead(200, { 'content-type': 'application/json' });
173
+ response.end(JSON.stringify(result ?? null));
87
174
  DEBUG && console.log(s.trim(), d, result);
88
175
  }
89
176
  catch (error) {
90
177
  DEBUG && console.error(error);
91
- response.writeHead(400).end(String(error));
178
+ const status = error.code === 'SQLITE_BUSY' ? 503 : 400;
179
+ response.writeHead(status).end(String(error));
180
+ }
181
+ }
182
+ async function readBody(request) {
183
+ const chunks = [];
184
+ let size = 0;
185
+ for await (const chunk of request) {
186
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
187
+ size += buffer.length;
188
+ if (size > maxBodyBytes) {
189
+ request.resume();
190
+ return null;
191
+ }
192
+ chunks.push(buffer);
193
+ }
194
+ return Buffer.concat(chunks);
195
+ }
196
+ function applyPragmas(sqlite, pragmas) {
197
+ if (Array.isArray(pragmas) && pragmas.every((value) => typeof value === 'string')) {
198
+ for (const pragma of pragmas)
199
+ sqlite.pragma(pragma);
200
+ }
201
+ }
202
+ function executeStatement(sqlite, statement) {
203
+ const sql = statement.s;
204
+ const method = String(statement.m || 'run');
205
+ if (typeof sql !== 'string' || !sql.trim() || !methods.includes(String(method))) {
206
+ throw new Error('Invalid transaction statement.');
207
+ }
208
+ if (method === 'exec')
209
+ return sqlite.exec(sql.trim());
210
+ const runner = sqlite.prepare(sql.trim());
211
+ const execute = runner[method];
212
+ return statement.d === undefined ? execute.call(runner) : execute.call(runner, statement.d);
213
+ }
214
+ function logSlowQuery(started, statement) {
215
+ const duration = performance.now() - started;
216
+ if (DEBUG && duration >= slowQueryMs) {
217
+ console.log(`Slow query (${Math.round(duration)}ms):`, statement);
92
218
  }
93
219
  }
94
220
  async function onEsModule(request, response) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloud-cli/d0",
3
- "version": "1.6.1",
3
+ "version": "1.6.2",
4
4
  "main": "./dist/index.js",
5
5
  "types": "./dist/index.d.ts",
6
6
  "type": "module",