@cloud-cli/d0 1.3.2 → 1.6.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/.dockerignore +4 -1
- package/Dockerfile +9 -3
- package/README.md +2 -0
- package/client.mjs +13 -12
- package/console.html +178 -0
- package/dist/index.d.ts +4 -11
- package/dist/index.js +62 -40
- package/package.json +7 -7
- package/pnpm-workspace.yaml +2 -0
package/.dockerignore
CHANGED
package/Dockerfile
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
FROM ghcr.io/cloud-cli/node:latest
|
|
1
|
+
FROM ghcr.io/cloud-cli/node:latest AS builder
|
|
2
2
|
|
|
3
3
|
USER root
|
|
4
4
|
WORKDIR /home/app
|
|
5
5
|
COPY . /home/app
|
|
6
|
-
|
|
6
|
+
ENV CI=true
|
|
7
|
+
RUN pnpm i && pnpm build && rm -rf node_modules/ src/ && pnpm store prune
|
|
8
|
+
|
|
9
|
+
FROM ghcr.io/cloud-cli/node:latest
|
|
10
|
+
|
|
7
11
|
ENV NODE_ENV=production
|
|
8
|
-
|
|
12
|
+
WORKDIR /home/app
|
|
13
|
+
COPY --from=builder /home/app/ ./
|
|
14
|
+
RUN pnpm install --prod
|
package/README.md
CHANGED
|
@@ -14,6 +14,7 @@ Accepts a JSON with these properties:
|
|
|
14
14
|
|`s` | string with the statement | **yes** |
|
|
15
15
|
|`d` | data to bind on a statement | no |
|
|
16
16
|
|`m` | method to execute: `all`, `run` or `get`. Run is the default | no |
|
|
17
|
+
|`p` | pragma statements as an array of strings. They run before the query | no |
|
|
17
18
|
|
|
18
19
|
```js
|
|
19
20
|
// select all items using fetch
|
|
@@ -23,6 +24,7 @@ fetch('https://db.example.com/query', {
|
|
|
23
24
|
s: 'SELECT * FROM user WHERE id = ?',
|
|
24
25
|
d: [123],
|
|
25
26
|
m: 'all',
|
|
27
|
+
p: ['foreign_keys = ON']
|
|
26
28
|
});
|
|
27
29
|
});
|
|
28
30
|
|
package/client.mjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
const baseURL = "https://__API_URL__";
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
let pragmas = [];
|
|
4
|
+
|
|
5
|
+
async function query(method, statement, data, pragma = pragmas) {
|
|
4
6
|
const req = await fetch(new URL("/query", baseURL), {
|
|
5
7
|
method: "POST",
|
|
6
8
|
body: JSON.stringify({
|
|
7
9
|
s: statement,
|
|
8
|
-
d: data
|
|
10
|
+
d: data,
|
|
9
11
|
m: method,
|
|
12
|
+
p: pragma
|
|
10
13
|
}),
|
|
11
14
|
});
|
|
12
15
|
|
|
@@ -17,16 +20,14 @@ export async function query(statement, data, method = "run") {
|
|
|
17
20
|
throw new Error(await req.text());
|
|
18
21
|
}
|
|
19
22
|
|
|
20
|
-
export
|
|
21
|
-
|
|
22
|
-
|
|
23
|
+
export const get = query.bind(null, 'get');
|
|
24
|
+
export const run = query.bind(null, 'run');
|
|
25
|
+
export const all = query.bind(null, 'all');
|
|
23
26
|
|
|
24
|
-
export function
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
export function all(statement, data) {
|
|
29
|
-
return query(statement, data, "all");
|
|
27
|
+
export function pragma(p) {
|
|
28
|
+
if (Array.isArray(p) && p.every(s => typeof s === 'string')) {
|
|
29
|
+
pragmas = p;
|
|
30
|
+
}
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
export default { query, get, run, all };
|
|
33
|
+
export default { query, get, run, all, pragma };
|
package/console.html
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<link rel="component" href="https://sodium.static.apphor.de/code-editor.html" />
|
|
7
|
+
<link rel="component" href="https://sodium.static.apphor.de/code-block.html" />
|
|
8
|
+
<link rel="component" href="https://sodium.static.apphor.de/mermaid-graph.html" />
|
|
9
|
+
<title></title>
|
|
10
|
+
<script type="importmap">
|
|
11
|
+
{
|
|
12
|
+
"imports": {
|
|
13
|
+
"@li3/": "https://at-li3.static.apphor.de/",
|
|
14
|
+
"@sodium/": "https://at-sodium.static.apphor.de/",
|
|
15
|
+
"@app/": "./"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
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>
|
|
23
|
+
|
|
24
|
+
<script type="module">
|
|
25
|
+
import '@li3/web';
|
|
26
|
+
</script>
|
|
27
|
+
|
|
28
|
+
<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
|
+
</div>
|
|
69
|
+
</form>
|
|
70
|
+
</div>
|
|
71
|
+
<mermaid-graph class="flex-1 h-64 overflow-auto" bind-input="diagram"></mermaid-graph>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<script setup>
|
|
75
|
+
import { hook, onInit } from '@li3/web';
|
|
76
|
+
|
|
77
|
+
export default function () {
|
|
78
|
+
const [diagram, setDiagram] = hook(null);
|
|
79
|
+
const url = new URL(location.href);
|
|
80
|
+
const [name, setName] = hook(url.hostname.replace('.db.apphor.de', ''));
|
|
81
|
+
const [query, setQuery] = hook('');
|
|
82
|
+
const [responses] = hook('');
|
|
83
|
+
const [error, setError] = hook('');
|
|
84
|
+
|
|
85
|
+
function append(r) {
|
|
86
|
+
responses.value = [...responses.value, r];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onKeyUp(event) {
|
|
90
|
+
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
|
|
91
|
+
event.preventDefault();
|
|
92
|
+
onRun();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function runQuery(q) {
|
|
97
|
+
const db = await import(`https://${name}.db.apphor.de/index.mjs`);
|
|
98
|
+
if (!q.endsWith(';')) {
|
|
99
|
+
q += ';';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const s = await (q.toLowerCase().includes('select ') ? db.all(q) : db.run(q));
|
|
103
|
+
return s;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function onRun() {
|
|
107
|
+
if (!name.value) return;
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
setError('');
|
|
111
|
+
const q = query.value.trim();
|
|
112
|
+
const s = await runQuery(q);
|
|
113
|
+
append(JSON.stringify(s, null, 2));
|
|
114
|
+
} catch (e) {
|
|
115
|
+
setError(e);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
onInit(async () => {
|
|
120
|
+
const schema = await runQuery('select * from sqlite_schema;');
|
|
121
|
+
setDiagram(schemaToMermaid(schema));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
return { name, diagram, query, responses, setName, setQuery, error, onRun, onKeyUp };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function schemaToMermaid(schema) {
|
|
128
|
+
let diagram = 'erDiagram\n';
|
|
129
|
+
|
|
130
|
+
for (const row of schema) {
|
|
131
|
+
// 1. Ignore indexes, views, and system tables
|
|
132
|
+
if (row.type !== 'table' || row.name.startsWith('sqlite_') || !row.sql) continue;
|
|
133
|
+
|
|
134
|
+
const tableName = row.name;
|
|
135
|
+
const sql = row.sql;
|
|
136
|
+
|
|
137
|
+
// 2. Extract relationships (matches both inline 'REFERENCES tbl' and 'FOREIGN KEY (...) REFERENCES tbl')
|
|
138
|
+
const fkRegex = /REFERENCES\s+([^\s(;]+)/gi;
|
|
139
|
+
let match;
|
|
140
|
+
const referencedTables = new Set();
|
|
141
|
+
|
|
142
|
+
while ((match = fkRegex.exec(sql)) !== null) {
|
|
143
|
+
// Clean up target table name (strip quotes/brackets)
|
|
144
|
+
const parentTable = match[1].replace(/[`"'[\]]/g, '').split('(')[0];
|
|
145
|
+
referencedTables.add(parentTable);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
referencedTables.forEach((parentTable) => {
|
|
149
|
+
diagram += ` ${parentTable} ||--o{ ${tableName} : "references"\n`;
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// 3. Extract columns
|
|
153
|
+
diagram += ` ${tableName} {\n`;
|
|
154
|
+
const bodyMatch = sql.match(/\(([\s\S]+)\)/);
|
|
155
|
+
|
|
156
|
+
if (bodyMatch) {
|
|
157
|
+
const definitions = bodyMatch[1].split(/,\s*(?![^()]*\))/);
|
|
158
|
+
definitions.forEach((def) => {
|
|
159
|
+
const parts = def.trim().split(/\s+/);
|
|
160
|
+
const keyword = parts[0] ? parts[0].toUpperCase() : '';
|
|
161
|
+
|
|
162
|
+
// Skip table-level constraints
|
|
163
|
+
if (parts[0] && !['CONSTRAINT', 'PRIMARY', 'FOREIGN', 'UNIQUE', 'CHECK'].includes(keyword)) {
|
|
164
|
+
const colName = parts[0].replace(/[`"'[\]]/g, '');
|
|
165
|
+
const colType = parts[1] ? parts[1].replace(/,/g, '') : 'TEXT';
|
|
166
|
+
diagram += ` ${colType} ${colName}\n`;
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
diagram += ` }\n`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return diagram;
|
|
174
|
+
}
|
|
175
|
+
</script>
|
|
176
|
+
</template>
|
|
177
|
+
</body>
|
|
178
|
+
</html>
|
package/dist/index.d.ts
CHANGED
|
@@ -1,13 +1,6 @@
|
|
|
1
|
-
import { IncomingMessage, ServerResponse } from
|
|
2
|
-
import { Database } from
|
|
3
|
-
type Query = {
|
|
4
|
-
db: string;
|
|
5
|
-
request: IncomingMessage;
|
|
6
|
-
response: ServerResponse;
|
|
7
|
-
args: Record<string, string>;
|
|
8
|
-
};
|
|
1
|
+
import { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { Database } from 'better-sqlite3';
|
|
9
3
|
export declare function getDatabase(file: string): Database;
|
|
10
|
-
export declare function serve():
|
|
4
|
+
export declare function serve(): any;
|
|
11
5
|
export declare function handleRequest(request: IncomingMessage, response: ServerResponse, db: string): Promise<void>;
|
|
12
|
-
export declare function onQuery(
|
|
13
|
-
export {};
|
|
6
|
+
export declare function onQuery(request: IncomingMessage, response: ServerResponse, db: string): Promise<void>;
|
package/dist/index.js
CHANGED
|
@@ -1,81 +1,103 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
import
|
|
4
|
-
import
|
|
5
|
-
const
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import SQLite from 'better-sqlite3';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
const DEBUG = !!process.env.DEBUG;
|
|
6
|
+
const methods = ['all', 'run', 'get', 'exec'];
|
|
6
7
|
const baseDomain = process.env.BASE_DOMAIN;
|
|
7
|
-
const dataPath = process.env.DATA_PATH || join(import.meta.dirname,
|
|
8
|
+
const dataPath = process.env.DATA_PATH || join(import.meta.dirname, 'data');
|
|
8
9
|
export function getDatabase(file) {
|
|
9
10
|
const fullPath = join(dataPath, file);
|
|
10
11
|
const db = new SQLite(fullPath);
|
|
11
|
-
db.pragma(
|
|
12
|
+
db.pragma('journal_mode = WAL');
|
|
12
13
|
return db;
|
|
13
14
|
}
|
|
14
15
|
export function serve() {
|
|
16
|
+
let server;
|
|
15
17
|
if (baseDomain) {
|
|
16
|
-
|
|
17
|
-
const hostname = String(req.headers[
|
|
18
|
-
const subdomain = hostname
|
|
18
|
+
server = createServer((req, res) => {
|
|
19
|
+
const hostname = String(req.headers['x-forwarded-host'] || '');
|
|
20
|
+
const subdomain = hostname
|
|
21
|
+
.replace(baseDomain, '')
|
|
22
|
+
.replace('.', '')
|
|
23
|
+
.replace(/[^a-z0-9-]+/g, '');
|
|
19
24
|
if (subdomain) {
|
|
20
|
-
return handleRequest(req, res, subdomain +
|
|
25
|
+
return handleRequest(req, res, subdomain + '.sqlite3');
|
|
21
26
|
}
|
|
22
27
|
res.writeHead(400).end();
|
|
23
28
|
});
|
|
24
29
|
}
|
|
25
|
-
|
|
30
|
+
else {
|
|
31
|
+
server = createServer((req, res) => handleRequest(req, res, 'db.sqlite3'));
|
|
32
|
+
}
|
|
33
|
+
server.listen(+process.env.PORT, () => {
|
|
34
|
+
console.log(`Started on ${process.env.PORT}`);
|
|
35
|
+
});
|
|
36
|
+
return server;
|
|
26
37
|
}
|
|
27
|
-
export function handleRequest(request, response, db) {
|
|
28
|
-
|
|
38
|
+
export async function handleRequest(request, response, db) {
|
|
39
|
+
DEBUG &&
|
|
40
|
+
response.on('finish', () => {
|
|
41
|
+
console.log(`[${new Date().toISOString().slice(0, 19)}] [${response.statusCode} ${String(request.headers['x-forwarded-host'] || '')}] ${request.method} ${request.url}`);
|
|
42
|
+
});
|
|
43
|
+
const url = new URL(request.url, 'http://localhost');
|
|
29
44
|
const route = `${request.method} ${url.pathname}`.trim();
|
|
30
|
-
const args = Object.fromEntries(url.searchParams.entries());
|
|
31
|
-
const q = { db, request, response, args };
|
|
32
45
|
switch (route) {
|
|
33
|
-
case
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return
|
|
46
|
+
case 'GET /console.html':
|
|
47
|
+
const consolePage = await readFile('./console.html', 'utf8');
|
|
48
|
+
response.writeHead(200, { 'content-type': 'text/html' }).end(consolePage);
|
|
49
|
+
return;
|
|
50
|
+
case 'GET /index.mjs':
|
|
51
|
+
return onEsModule(request, response);
|
|
52
|
+
case 'POST /query':
|
|
53
|
+
return onQuery(request, response, db);
|
|
37
54
|
default:
|
|
38
55
|
response.writeHead(404).end();
|
|
39
56
|
}
|
|
40
57
|
}
|
|
41
|
-
export async function onQuery(
|
|
58
|
+
export async function onQuery(request, response, db) {
|
|
42
59
|
const query = Buffer.concat(await request.toArray());
|
|
43
60
|
if (!query.length) {
|
|
44
61
|
response.writeHead(400).end();
|
|
45
62
|
return;
|
|
46
63
|
}
|
|
47
64
|
try {
|
|
48
|
-
const { s =
|
|
65
|
+
const { s = '', d, m = 'run', p } = JSON.parse(query.toString('utf-8'));
|
|
49
66
|
if (!s.trim()) {
|
|
50
|
-
throw new Error(
|
|
67
|
+
throw new Error('Invalid statement.');
|
|
51
68
|
}
|
|
52
69
|
if (!methods.includes(m)) {
|
|
53
|
-
throw new Error(
|
|
70
|
+
throw new Error('Invalid method. Must be one of ' + methods.join(', '));
|
|
54
71
|
}
|
|
55
72
|
const sqlite = getDatabase(db);
|
|
56
|
-
|
|
57
|
-
|
|
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
|
+
}
|
|
58
86
|
response.end(JSON.stringify(result || null));
|
|
87
|
+
DEBUG && console.log(s.trim(), d, result);
|
|
59
88
|
}
|
|
60
89
|
catch (error) {
|
|
61
|
-
|
|
90
|
+
DEBUG && console.error(error);
|
|
62
91
|
response.writeHead(400).end(String(error));
|
|
63
92
|
}
|
|
64
93
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
file = await readFile("./client.mjs", "utf8");
|
|
69
|
-
}
|
|
70
|
-
return file;
|
|
71
|
-
}
|
|
72
|
-
async function onEsModule({ request, response }) {
|
|
73
|
-
const hostname = String(request.headers["x-forwarded-for"]);
|
|
74
|
-
const code = await getFile();
|
|
94
|
+
async function onEsModule(request, response) {
|
|
95
|
+
const hostname = String(request.headers['x-forwarded-host']);
|
|
96
|
+
const code = await readFile('./client.mjs', 'utf8');
|
|
75
97
|
response
|
|
76
98
|
.writeHead(200, {
|
|
77
|
-
|
|
78
|
-
|
|
99
|
+
'Content-Type': 'text/javascript',
|
|
100
|
+
'Access-Control-Allow-Origin': '*',
|
|
79
101
|
})
|
|
80
|
-
.end(code.replace(
|
|
102
|
+
.end(code.replace('__API_URL__', hostname));
|
|
81
103
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cloud-cli/d0",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"main": "./dist/index.js",
|
|
5
5
|
"types": "./dist/index.d.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -18,14 +18,14 @@
|
|
|
18
18
|
"access": "public"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
-
"
|
|
22
|
-
"
|
|
21
|
+
"@cloud-cli/http": "^1.1.0",
|
|
22
|
+
"better-sqlite3": "^12.11.1"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"@cloud-cli/prettier-config": "^1.0.0",
|
|
26
|
-
"@cloud-cli/typescript-config": "^1.0.
|
|
27
|
-
"@types/better-sqlite3": "^7.6.
|
|
28
|
-
"@types/node": "^20.
|
|
29
|
-
"typescript": "^5.
|
|
26
|
+
"@cloud-cli/typescript-config": "^1.0.1",
|
|
27
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
28
|
+
"@types/node": "^20.19.43",
|
|
29
|
+
"typescript": "^5.9.3"
|
|
30
30
|
}
|
|
31
31
|
}
|