neoorm 0.8.4 → 0.8.6
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 +2 -0
- package/dist/bin/neoorm.js +52 -9
- package/dist/bin/neoorm.js.map +1 -1
- package/dist/codegen/emit-models.d.ts.map +1 -1
- package/dist/codegen/emit-models.js +93 -40
- package/dist/codegen/emit-models.js.map +1 -1
- package/dist/codegen/emit-query-types.d.ts.map +1 -1
- package/dist/codegen/emit-query-types.js +36 -67
- package/dist/codegen/emit-query-types.js.map +1 -1
- package/dist/docs/pages.d.ts.map +1 -1
- package/dist/docs/pages.js +1 -0
- package/dist/docs/pages.js.map +1 -1
- package/dist/docs/server.d.ts.map +1 -1
- package/dist/docs/server.js +1 -11
- package/dist/docs/server.js.map +1 -1
- package/dist/migrate/dev-lock.d.ts +6 -0
- package/dist/migrate/dev-lock.d.ts.map +1 -1
- package/dist/migrate/dev-lock.js +71 -13
- package/dist/migrate/dev-lock.js.map +1 -1
- package/dist/runtime/client.d.ts +2 -0
- package/dist/runtime/client.d.ts.map +1 -1
- package/dist/runtime/client.js.map +1 -1
- package/dist/runtime/query/compile-write.d.ts.map +1 -1
- package/dist/runtime/query/compile-write.js +5 -5
- package/dist/runtime/query/compile-write.js.map +1 -1
- package/dist/runtime/query/delete.d.ts.map +1 -1
- package/dist/runtime/query/delete.js +7 -6
- package/dist/runtime/query/delete.js.map +1 -1
- package/dist/runtime/query/table-index.d.ts +2 -0
- package/dist/runtime/query/table-index.d.ts.map +1 -1
- package/dist/runtime/query/table-index.js +10 -2
- package/dist/runtime/query/table-index.js.map +1 -1
- package/dist/runtime/query/unique.d.ts +16 -0
- package/dist/runtime/query/unique.d.ts.map +1 -1
- package/dist/runtime/query/unique.js +124 -5
- package/dist/runtime/query/unique.js.map +1 -1
- package/dist/runtime/query/unique.test.js +144 -1
- package/dist/runtime/query/unique.test.js.map +1 -1
- package/dist/runtime/query/update.d.ts.map +1 -1
- package/dist/runtime/query/update.js +8 -7
- package/dist/runtime/query/update.js.map +1 -1
- package/dist/schema/types.d.ts +2 -2
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/studio/codec.d.ts +32 -0
- package/dist/studio/codec.d.ts.map +1 -0
- package/dist/studio/codec.js +151 -0
- package/dist/studio/codec.js.map +1 -0
- package/dist/studio/csv.d.ts +12 -0
- package/dist/studio/csv.d.ts.map +1 -0
- package/dist/studio/csv.js +144 -0
- package/dist/studio/csv.js.map +1 -0
- package/dist/studio/errors.d.ts +17 -0
- package/dist/studio/errors.d.ts.map +1 -0
- package/dist/studio/errors.js +77 -0
- package/dist/studio/errors.js.map +1 -0
- package/dist/studio/graph.d.ts +27 -0
- package/dist/studio/graph.d.ts.map +1 -0
- package/dist/studio/graph.js +47 -0
- package/dist/studio/graph.js.map +1 -0
- package/dist/studio/meta.d.ts +76 -0
- package/dist/studio/meta.d.ts.map +1 -0
- package/dist/studio/meta.js +99 -0
- package/dist/studio/meta.js.map +1 -0
- package/dist/studio/open-browser.d.ts +3 -0
- package/dist/studio/open-browser.d.ts.map +1 -0
- package/dist/studio/open-browser.js +13 -0
- package/dist/studio/open-browser.js.map +1 -0
- package/dist/studio/server.d.ts +66 -0
- package/dist/studio/server.d.ts.map +1 -0
- package/dist/studio/server.js +1045 -0
- package/dist/studio/server.js.map +1 -0
- package/dist/studio-ui/assets/index-Leicgvwr.js +36 -0
- package/dist/studio-ui/assets/index-wQS4sFQ_.css +1 -0
- package/dist/studio-ui/index.html +13 -0
- package/docs/cli.md +18 -0
- package/docs/getting-started.md +8 -0
- package/docs/queries.md +11 -3
- package/docs/studio.md +52 -0
- package/package.json +25 -4
|
@@ -0,0 +1,1045 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, stat } from "node:fs/promises";
|
|
4
|
+
import { createServer, } from "node:http";
|
|
5
|
+
import { dirname, extname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { compileSchemaToManifest, readSnapshot } from "../codegen/generate.js";
|
|
8
|
+
import { loadConfig } from "../config.js";
|
|
9
|
+
import { dialectForProvider } from "../dialect/resolve.js";
|
|
10
|
+
import { computeMigrationStatus, listMigrationsOnDisk, } from "../migrate/runner.js";
|
|
11
|
+
import { createNeoOrmClient } from "../runtime/client.js";
|
|
12
|
+
import { decodeStudioData, decodeStudioValue, decodeStudioWhere, encodeStudioRows, } from "./codec.js";
|
|
13
|
+
import { parseCsv, toCsv, toMarkdown, toSqlInserts } from "./csv.js";
|
|
14
|
+
import { toStudioError } from "./errors.js";
|
|
15
|
+
import { toStudioGraph } from "./graph.js";
|
|
16
|
+
import { toStudioMeta } from "./meta.js";
|
|
17
|
+
import { openBrowser } from "./open-browser.js";
|
|
18
|
+
const MAX_BODY_BYTES = 10 * 1024 * 1024;
|
|
19
|
+
const DEFAULT_TAKE = 50;
|
|
20
|
+
const MAX_TAKE = 500;
|
|
21
|
+
const MAX_EXPORT_TAKE = 5000;
|
|
22
|
+
const TEXT_SEARCH_KINDS = new Set([
|
|
23
|
+
"text",
|
|
24
|
+
"citext",
|
|
25
|
+
"id",
|
|
26
|
+
"uuid",
|
|
27
|
+
"enum",
|
|
28
|
+
"xml",
|
|
29
|
+
"inet",
|
|
30
|
+
"cidr",
|
|
31
|
+
"money",
|
|
32
|
+
"date",
|
|
33
|
+
"time",
|
|
34
|
+
]);
|
|
35
|
+
const MIME_TYPES = {
|
|
36
|
+
".html": "text/html; charset=utf-8",
|
|
37
|
+
".js": "text/javascript; charset=utf-8",
|
|
38
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
39
|
+
".css": "text/css; charset=utf-8",
|
|
40
|
+
".json": "application/json; charset=utf-8",
|
|
41
|
+
".svg": "image/svg+xml",
|
|
42
|
+
".png": "image/png",
|
|
43
|
+
".ico": "image/x-icon",
|
|
44
|
+
".woff": "font/woff",
|
|
45
|
+
".woff2": "font/woff2",
|
|
46
|
+
".ttf": "font/ttf",
|
|
47
|
+
".map": "application/json; charset=utf-8",
|
|
48
|
+
".txt": "text/plain; charset=utf-8",
|
|
49
|
+
};
|
|
50
|
+
function isLoopbackHost(host) {
|
|
51
|
+
return (host === "127.0.0.1" ||
|
|
52
|
+
host === "localhost" ||
|
|
53
|
+
host === "::1" ||
|
|
54
|
+
host === "::ffff:127.0.0.1");
|
|
55
|
+
}
|
|
56
|
+
export function resolveStudioUiDir(cwd, override) {
|
|
57
|
+
const candidates = [
|
|
58
|
+
override,
|
|
59
|
+
process.env.STUDIO_UI_DIR,
|
|
60
|
+
join(cwd, "dist", "studio-ui"),
|
|
61
|
+
join(dirname(fileURLToPath(import.meta.url)), "..", "studio-ui"),
|
|
62
|
+
join(cwd, "studio-web", "dist"),
|
|
63
|
+
].filter((c) => c !== undefined && c !== "");
|
|
64
|
+
for (const dir of candidates) {
|
|
65
|
+
try {
|
|
66
|
+
if (existsSync(join(dir, "index.html")))
|
|
67
|
+
return dir;
|
|
68
|
+
}
|
|
69
|
+
catch { }
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function sendJson(res, status, body) {
|
|
74
|
+
res.writeHead(status, {
|
|
75
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
76
|
+
"Cache-Control": "no-cache",
|
|
77
|
+
});
|
|
78
|
+
res.end(JSON.stringify(body));
|
|
79
|
+
}
|
|
80
|
+
function sendText(res, status, body, contentType = "text/plain; charset=utf-8") {
|
|
81
|
+
res.writeHead(status, { "Content-Type": contentType });
|
|
82
|
+
res.end(body);
|
|
83
|
+
}
|
|
84
|
+
function sendFile(res, filePath, contentType, body) {
|
|
85
|
+
res.writeHead(200, {
|
|
86
|
+
"Content-Type": contentType,
|
|
87
|
+
"Cache-Control": "no-cache",
|
|
88
|
+
});
|
|
89
|
+
res.end(body);
|
|
90
|
+
void filePath;
|
|
91
|
+
}
|
|
92
|
+
async function readBody(req, limit = MAX_BODY_BYTES) {
|
|
93
|
+
const chunks = [];
|
|
94
|
+
let size = 0;
|
|
95
|
+
for await (const chunk of req) {
|
|
96
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
97
|
+
size += buf.length;
|
|
98
|
+
if (size > limit)
|
|
99
|
+
throw new Error(`Request body exceeds ${limit} bytes`);
|
|
100
|
+
chunks.push(buf);
|
|
101
|
+
}
|
|
102
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
103
|
+
}
|
|
104
|
+
function parseJsonBody(text) {
|
|
105
|
+
if (text.trim() === "")
|
|
106
|
+
return {};
|
|
107
|
+
return JSON.parse(text);
|
|
108
|
+
}
|
|
109
|
+
function getQueryParams(url) {
|
|
110
|
+
return url.searchParams;
|
|
111
|
+
}
|
|
112
|
+
function parseTake(raw, max) {
|
|
113
|
+
if (raw === null)
|
|
114
|
+
return DEFAULT_TAKE;
|
|
115
|
+
const n = Number.parseInt(raw, 10);
|
|
116
|
+
if (!Number.isFinite(n) || n < 1)
|
|
117
|
+
throw new Error("`take` must be a positive integer");
|
|
118
|
+
return Math.min(n, max);
|
|
119
|
+
}
|
|
120
|
+
function parseSkip(raw) {
|
|
121
|
+
if (raw === null)
|
|
122
|
+
return 0;
|
|
123
|
+
const n = Number.parseInt(raw, 10);
|
|
124
|
+
if (!Number.isFinite(n) || n < 0)
|
|
125
|
+
throw new Error("`skip` must be a non-negative integer");
|
|
126
|
+
return n;
|
|
127
|
+
}
|
|
128
|
+
function parseJsonParam(raw, name) {
|
|
129
|
+
if (raw === null || raw === "")
|
|
130
|
+
return undefined;
|
|
131
|
+
try {
|
|
132
|
+
return JSON.parse(raw);
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
throw new Error(`\`${name}\` must be JSON`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
function tableOr404(ctx, accessor) {
|
|
139
|
+
const table = ctx.manifest.tables[accessor];
|
|
140
|
+
if (!table) {
|
|
141
|
+
throw Object.assign(new Error(`Unknown table "${accessor}"`), {
|
|
142
|
+
statusCode: 404,
|
|
143
|
+
code: "unknown_table",
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return table;
|
|
147
|
+
}
|
|
148
|
+
function repoFor(ctx, accessor) {
|
|
149
|
+
tableOr404(ctx, accessor);
|
|
150
|
+
const repo = ctx.client[accessor];
|
|
151
|
+
if (!repo || typeof repo.findMany !== "function") {
|
|
152
|
+
throw Object.assign(new Error(`Unknown table "${accessor}"`), {
|
|
153
|
+
statusCode: 404,
|
|
154
|
+
code: "unknown_table",
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
return repo;
|
|
158
|
+
}
|
|
159
|
+
function requireWritable(ctx) {
|
|
160
|
+
if (ctx.readOnly) {
|
|
161
|
+
throw Object.assign(new Error("Studio is running with --read-only"), {
|
|
162
|
+
statusCode: 403,
|
|
163
|
+
code: "read_only",
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
/** Build a table-wide search predicate: OR of `contains` over text-like columns. */
|
|
168
|
+
function buildSearchWhere(ctx, accessor, q) {
|
|
169
|
+
const table = tableOr404(ctx, accessor);
|
|
170
|
+
const needle = q.trim();
|
|
171
|
+
if (needle === "")
|
|
172
|
+
return undefined;
|
|
173
|
+
const ors = [];
|
|
174
|
+
for (const column of table.columns) {
|
|
175
|
+
if (TEXT_SEARCH_KINDS.has(column.kind)) {
|
|
176
|
+
ors.push({
|
|
177
|
+
[column.tsName]: { contains: needle, mode: "insensitive" },
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
if (ors.length === 0)
|
|
182
|
+
return undefined;
|
|
183
|
+
return { OR: ors };
|
|
184
|
+
}
|
|
185
|
+
function mergeWhere(base, extra) {
|
|
186
|
+
if (!base)
|
|
187
|
+
return extra;
|
|
188
|
+
if (!extra)
|
|
189
|
+
return base;
|
|
190
|
+
return { AND: [base, extra] };
|
|
191
|
+
}
|
|
192
|
+
const READ_SQL = /^\s*(SELECT|WITH|VALUES|TABLE|EXPLAIN|PRAGMA|SHOW|DESCRIBE|DESC)\b/i;
|
|
193
|
+
function isReadSql(text) {
|
|
194
|
+
return READ_SQL.test(text);
|
|
195
|
+
}
|
|
196
|
+
function errorStatus(err) {
|
|
197
|
+
const statusCode = err.statusCode;
|
|
198
|
+
if (typeof statusCode === "number")
|
|
199
|
+
return statusCode;
|
|
200
|
+
return toStudioError(err).status;
|
|
201
|
+
}
|
|
202
|
+
function errorBody(err) {
|
|
203
|
+
const code = err.code;
|
|
204
|
+
if (typeof code === "string") {
|
|
205
|
+
return {
|
|
206
|
+
error: {
|
|
207
|
+
code,
|
|
208
|
+
message: err instanceof Error ? err.message : String(err),
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
return toStudioError(err).body;
|
|
213
|
+
}
|
|
214
|
+
async function handleApi(ctx, req, res, url) {
|
|
215
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
216
|
+
const segments = url.pathname.split("/").filter((s) => s.length > 0);
|
|
217
|
+
if (ctx.token !== undefined) {
|
|
218
|
+
const header = req.headers["x-studio-token"];
|
|
219
|
+
const auth = req.headers.authorization;
|
|
220
|
+
const queryToken = url.searchParams.get("token");
|
|
221
|
+
const provided = (typeof header === "string" && header) ||
|
|
222
|
+
(typeof auth === "string" && auth.startsWith("Bearer ")
|
|
223
|
+
? auth.slice(7)
|
|
224
|
+
: "") ||
|
|
225
|
+
(queryToken ?? "");
|
|
226
|
+
if (provided !== ctx.token) {
|
|
227
|
+
sendJson(res, 401, {
|
|
228
|
+
error: {
|
|
229
|
+
code: "unauthorized",
|
|
230
|
+
message: "Invalid or missing Studio token",
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
try {
|
|
237
|
+
// GET /api/meta
|
|
238
|
+
if (method === "GET" &&
|
|
239
|
+
segments.length === 2 &&
|
|
240
|
+
segments[0] === "api" &&
|
|
241
|
+
segments[1] === "meta") {
|
|
242
|
+
sendJson(res, 200, {
|
|
243
|
+
...ctx.meta,
|
|
244
|
+
readOnly: ctx.readOnly,
|
|
245
|
+
dialect: ctx.dialect.name,
|
|
246
|
+
});
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// GET /api/graph
|
|
250
|
+
if (method === "GET" &&
|
|
251
|
+
segments.length === 2 &&
|
|
252
|
+
segments[0] === "api" &&
|
|
253
|
+
segments[1] === "graph") {
|
|
254
|
+
sendJson(res, 200, toStudioGraph(ctx.manifest));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
// GET /api/migrate/status
|
|
258
|
+
if (method === "GET" &&
|
|
259
|
+
segments.length === 3 &&
|
|
260
|
+
segments[0] === "api" &&
|
|
261
|
+
segments[1] === "migrate" &&
|
|
262
|
+
segments[2] === "status") {
|
|
263
|
+
sendJson(res, 200, await readMigrateStatus(ctx));
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
// /api/tables/:accessor/rows
|
|
267
|
+
if (segments.length === 4 &&
|
|
268
|
+
segments[0] === "api" &&
|
|
269
|
+
segments[1] === "tables" &&
|
|
270
|
+
segments[3] === "rows") {
|
|
271
|
+
const accessor = segments[2] ?? "";
|
|
272
|
+
if (method === "GET") {
|
|
273
|
+
await handleListRows(ctx, res, url, accessor);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
if (method === "POST") {
|
|
277
|
+
requireWritable(ctx);
|
|
278
|
+
await handleCreateRows(ctx, req, res, accessor);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (method === "PATCH") {
|
|
282
|
+
requireWritable(ctx);
|
|
283
|
+
await handleUpdateRows(ctx, req, res, accessor);
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (method === "DELETE") {
|
|
287
|
+
requireWritable(ctx);
|
|
288
|
+
await handleDeleteRows(ctx, req, res, url, accessor);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
sendJson(res, 405, {
|
|
292
|
+
error: {
|
|
293
|
+
code: "method_not_allowed",
|
|
294
|
+
message: `Method ${method} not allowed`,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
// POST /api/query
|
|
300
|
+
if (method === "POST" &&
|
|
301
|
+
segments.length === 2 &&
|
|
302
|
+
segments[0] === "api" &&
|
|
303
|
+
segments[1] === "query") {
|
|
304
|
+
await handleQueryPlayground(ctx, req, res);
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// POST /api/sql
|
|
308
|
+
if (method === "POST" &&
|
|
309
|
+
segments.length === 2 &&
|
|
310
|
+
segments[0] === "api" &&
|
|
311
|
+
segments[1] === "sql") {
|
|
312
|
+
await handleSql(ctx, req, res);
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// POST /api/import
|
|
316
|
+
if (method === "POST" &&
|
|
317
|
+
segments.length === 2 &&
|
|
318
|
+
segments[0] === "api" &&
|
|
319
|
+
segments[1] === "import") {
|
|
320
|
+
requireWritable(ctx);
|
|
321
|
+
await handleImport(ctx, req, res);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
// GET /api/export
|
|
325
|
+
if (method === "GET" &&
|
|
326
|
+
segments.length === 2 &&
|
|
327
|
+
segments[0] === "api" &&
|
|
328
|
+
segments[1] === "export") {
|
|
329
|
+
await handleExport(ctx, res, url);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
sendJson(res, 404, {
|
|
333
|
+
error: {
|
|
334
|
+
code: "not_found",
|
|
335
|
+
message: `Unknown API route ${url.pathname}`,
|
|
336
|
+
},
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
catch (err) {
|
|
340
|
+
sendJson(res, errorStatus(err), errorBody(err));
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
async function handleListRows(ctx, res, url, accessor) {
|
|
344
|
+
const repo = repoFor(ctx, accessor);
|
|
345
|
+
const params = getQueryParams(url);
|
|
346
|
+
const take = parseTake(params.get("take"), MAX_TAKE);
|
|
347
|
+
const skip = parseSkip(params.get("skip"));
|
|
348
|
+
const where = mergeWhere(decodeStudioWhere(parseJsonParam(params.get("where"), "where")), buildSearchWhere(ctx, accessor, params.get("q") ?? ""));
|
|
349
|
+
const orderBy = parseJsonParam(params.get("orderBy"), "orderBy");
|
|
350
|
+
const withParam = parseJsonParam(params.get("with"), "with");
|
|
351
|
+
const args = {
|
|
352
|
+
...(where ? { where } : {}),
|
|
353
|
+
...(orderBy ? { orderBy: orderBy } : {}),
|
|
354
|
+
take,
|
|
355
|
+
skip,
|
|
356
|
+
...(withParam ? { with: decodeStudioValue(withParam) } : {}),
|
|
357
|
+
includeHidden: true,
|
|
358
|
+
};
|
|
359
|
+
const [rows, total] = await Promise.all([
|
|
360
|
+
repo.findMany(args),
|
|
361
|
+
repo.count(where ? { where } : undefined),
|
|
362
|
+
]);
|
|
363
|
+
sendJson(res, 200, {
|
|
364
|
+
rows: encodeStudioRows(rows),
|
|
365
|
+
total: typeof total === "number" ? total : 0,
|
|
366
|
+
take,
|
|
367
|
+
skip,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
async function handleCreateRows(ctx, req, res, accessor) {
|
|
371
|
+
const repo = repoFor(ctx, accessor);
|
|
372
|
+
const table = tableOr404(ctx, accessor);
|
|
373
|
+
const body = parseJsonBody(await readBody(req));
|
|
374
|
+
if (Array.isArray(body.rows)) {
|
|
375
|
+
if (body.rows.length === 0) {
|
|
376
|
+
sendJson(res, 400, {
|
|
377
|
+
error: {
|
|
378
|
+
code: "invalid_args",
|
|
379
|
+
message: "`rows` must not be empty",
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
if (body.rows.length > MAX_EXPORT_TAKE) {
|
|
385
|
+
sendJson(res, 400, {
|
|
386
|
+
error: {
|
|
387
|
+
code: "invalid_args",
|
|
388
|
+
message: "`rows` exceeds the 5000-row import limit",
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const data = body.rows.map((row) => decodeStudioData(table, row));
|
|
394
|
+
const count = await repo.createMany({
|
|
395
|
+
data,
|
|
396
|
+
...(body.skipDuplicates === true ? { skipDuplicates: true } : {}),
|
|
397
|
+
});
|
|
398
|
+
sendJson(res, 201, { count });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (typeof body.data !== "object" || body.data === null) {
|
|
402
|
+
sendJson(res, 400, {
|
|
403
|
+
error: {
|
|
404
|
+
code: "invalid_args",
|
|
405
|
+
message: "Expected `{data}` or `{rows}`",
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const created = await repo.create({
|
|
411
|
+
data: decodeStudioData(table, body.data),
|
|
412
|
+
returnCreated: true,
|
|
413
|
+
});
|
|
414
|
+
sendJson(res, 201, { row: encodeStudioRows([created])[0] });
|
|
415
|
+
}
|
|
416
|
+
async function handleUpdateRows(ctx, req, res, accessor) {
|
|
417
|
+
const repo = repoFor(ctx, accessor);
|
|
418
|
+
const table = tableOr404(ctx, accessor);
|
|
419
|
+
const body = parseJsonBody(await readBody(req));
|
|
420
|
+
const data = body.data;
|
|
421
|
+
if (typeof data !== "object" || data === null) {
|
|
422
|
+
sendJson(res, 400, {
|
|
423
|
+
error: {
|
|
424
|
+
code: "invalid_args",
|
|
425
|
+
message: "Expected `{where, data}`",
|
|
426
|
+
},
|
|
427
|
+
});
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
430
|
+
if (body.many === true) {
|
|
431
|
+
const where = decodeStudioWhere(body.where);
|
|
432
|
+
const count = await repo.updateMany({
|
|
433
|
+
...(where ? { where } : {}),
|
|
434
|
+
data: decodeStudioData(table, data),
|
|
435
|
+
});
|
|
436
|
+
sendJson(res, 200, { count });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
const where = decodeStudioWhere(body.where);
|
|
440
|
+
if (!where) {
|
|
441
|
+
sendJson(res, 400, {
|
|
442
|
+
error: {
|
|
443
|
+
code: "where_required",
|
|
444
|
+
message: "Single-row update requires a unique `where`",
|
|
445
|
+
},
|
|
446
|
+
});
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const updated = await repo.update({
|
|
450
|
+
where,
|
|
451
|
+
data: decodeStudioData(table, data),
|
|
452
|
+
returnUpdated: true,
|
|
453
|
+
});
|
|
454
|
+
if (updated === null) {
|
|
455
|
+
sendJson(res, 404, {
|
|
456
|
+
error: { code: "not_found", message: "No row matched `where`" },
|
|
457
|
+
});
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
sendJson(res, 200, { row: encodeStudioRows([updated])[0] });
|
|
461
|
+
}
|
|
462
|
+
async function handleDeleteRows(ctx, req, res, url, accessor) {
|
|
463
|
+
const repo = repoFor(ctx, accessor);
|
|
464
|
+
let body = {};
|
|
465
|
+
try {
|
|
466
|
+
body = parseJsonBody(await readBody(req));
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
body = {};
|
|
470
|
+
}
|
|
471
|
+
const whereParam = url.searchParams.get("where");
|
|
472
|
+
const where = decodeStudioWhere(body.where !== undefined
|
|
473
|
+
? body.where
|
|
474
|
+
: parseJsonParam(whereParam, "where"));
|
|
475
|
+
if (body.many === true || url.searchParams.get("many") === "true") {
|
|
476
|
+
const count = await repo.deleteMany(where ? { where } : undefined);
|
|
477
|
+
sendJson(res, 200, { count });
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (!where) {
|
|
481
|
+
sendJson(res, 400, {
|
|
482
|
+
error: {
|
|
483
|
+
code: "where_required",
|
|
484
|
+
message: "Single-row delete requires a unique `where`",
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const deleted = await repo.delete({ where });
|
|
490
|
+
if (deleted === null) {
|
|
491
|
+
sendJson(res, 404, {
|
|
492
|
+
error: { code: "not_found", message: "No row matched `where`" },
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
sendJson(res, 200, { row: encodeStudioRows([deleted])[0] });
|
|
497
|
+
}
|
|
498
|
+
const QUERY_METHODS = new Set([
|
|
499
|
+
"findMany",
|
|
500
|
+
"findFirst",
|
|
501
|
+
"findUnique",
|
|
502
|
+
"findById",
|
|
503
|
+
"count",
|
|
504
|
+
"exists",
|
|
505
|
+
"aggregate",
|
|
506
|
+
"groupBy",
|
|
507
|
+
"paginate",
|
|
508
|
+
]);
|
|
509
|
+
async function handleQueryPlayground(ctx, req, res) {
|
|
510
|
+
const body = parseJsonBody(await readBody(req));
|
|
511
|
+
const accessor = body.accessor;
|
|
512
|
+
const method = body.method;
|
|
513
|
+
if (typeof accessor !== "string" ||
|
|
514
|
+
typeof method !== "string" ||
|
|
515
|
+
!QUERY_METHODS.has(method)) {
|
|
516
|
+
sendJson(res, 400, {
|
|
517
|
+
error: {
|
|
518
|
+
code: "invalid_args",
|
|
519
|
+
message: "Expected `{accessor, method, args}` with a valid method",
|
|
520
|
+
},
|
|
521
|
+
});
|
|
522
|
+
return;
|
|
523
|
+
}
|
|
524
|
+
const repo = repoFor(ctx, accessor);
|
|
525
|
+
const rawArgs = (body.args ?? {});
|
|
526
|
+
const args = decodeStudioValue(rawArgs);
|
|
527
|
+
const withHidden = { includeHidden: true };
|
|
528
|
+
switch (method) {
|
|
529
|
+
case "findMany": {
|
|
530
|
+
const rows = await repo.findMany({
|
|
531
|
+
...args,
|
|
532
|
+
...withHidden,
|
|
533
|
+
});
|
|
534
|
+
sendJson(res, 200, { rows: encodeStudioRows(rows) });
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
case "findFirst": {
|
|
538
|
+
const row = await repo.findFirst({
|
|
539
|
+
...args,
|
|
540
|
+
...withHidden,
|
|
541
|
+
});
|
|
542
|
+
sendJson(res, 200, {
|
|
543
|
+
row: row ? encodeStudioRows([row])[0] : null,
|
|
544
|
+
});
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
case "findUnique": {
|
|
548
|
+
if (typeof args.where !== "object" || args.where === null) {
|
|
549
|
+
sendJson(res, 400, {
|
|
550
|
+
error: {
|
|
551
|
+
code: "where_required",
|
|
552
|
+
message: "`findUnique` requires a unique `where`",
|
|
553
|
+
},
|
|
554
|
+
});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
const row = await repo.findUnique({
|
|
558
|
+
where: args.where,
|
|
559
|
+
...(args.select !== undefined
|
|
560
|
+
? { select: args.select }
|
|
561
|
+
: {}),
|
|
562
|
+
...(args.omit !== undefined
|
|
563
|
+
? { omit: args.omit }
|
|
564
|
+
: {}),
|
|
565
|
+
...(args.with !== undefined
|
|
566
|
+
? { with: args.with }
|
|
567
|
+
: {}),
|
|
568
|
+
...withHidden,
|
|
569
|
+
});
|
|
570
|
+
sendJson(res, 200, {
|
|
571
|
+
row: row ? encodeStudioRows([row])[0] : null,
|
|
572
|
+
});
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
case "findById": {
|
|
576
|
+
const id = args.id;
|
|
577
|
+
if (typeof id !== "string" &&
|
|
578
|
+
(typeof id !== "object" || id === null)) {
|
|
579
|
+
sendJson(res, 400, {
|
|
580
|
+
error: {
|
|
581
|
+
code: "invalid_args",
|
|
582
|
+
message: "`findById` requires `args.id`",
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const row = await repo.findById(id);
|
|
588
|
+
sendJson(res, 200, {
|
|
589
|
+
row: row ? encodeStudioRows([row])[0] : null,
|
|
590
|
+
});
|
|
591
|
+
return;
|
|
592
|
+
}
|
|
593
|
+
case "count": {
|
|
594
|
+
const total = await repo.count(args);
|
|
595
|
+
sendJson(res, 200, { result: total });
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
case "exists": {
|
|
599
|
+
const found = await repo.exists(args);
|
|
600
|
+
sendJson(res, 200, { result: found });
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
case "aggregate": {
|
|
604
|
+
const result = await repo.aggregate(args);
|
|
605
|
+
sendJson(res, 200, { result: encodeStudioRows([result])[0] });
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
case "groupBy": {
|
|
609
|
+
if (args.by === undefined) {
|
|
610
|
+
sendJson(res, 400, {
|
|
611
|
+
error: {
|
|
612
|
+
code: "invalid_args",
|
|
613
|
+
message: "`groupBy` requires `by`",
|
|
614
|
+
},
|
|
615
|
+
});
|
|
616
|
+
return;
|
|
617
|
+
}
|
|
618
|
+
const result = await repo.groupBy(args);
|
|
619
|
+
sendJson(res, 200, { rows: encodeStudioRows(result) });
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
case "paginate": {
|
|
623
|
+
if (typeof args.orderBy !== "object" ||
|
|
624
|
+
args.orderBy === null ||
|
|
625
|
+
typeof args.take !== "number") {
|
|
626
|
+
sendJson(res, 400, {
|
|
627
|
+
error: {
|
|
628
|
+
code: "invalid_args",
|
|
629
|
+
message: "`paginate` requires `{orderBy, take}`",
|
|
630
|
+
},
|
|
631
|
+
});
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
const page = await repo.paginate({
|
|
635
|
+
...args,
|
|
636
|
+
...withHidden,
|
|
637
|
+
});
|
|
638
|
+
sendJson(res, 200, {
|
|
639
|
+
...page,
|
|
640
|
+
items: encodeStudioRows(page.items),
|
|
641
|
+
});
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
default: {
|
|
645
|
+
const _never = method;
|
|
646
|
+
sendJson(res, 400, {
|
|
647
|
+
error: {
|
|
648
|
+
code: "invalid_args",
|
|
649
|
+
message: `Unsupported method ${_never}`,
|
|
650
|
+
},
|
|
651
|
+
});
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
async function handleSql(ctx, req, res) {
|
|
657
|
+
const body = parseJsonBody(await readBody(req));
|
|
658
|
+
if (typeof body.text !== "string" || body.text.trim() === "") {
|
|
659
|
+
sendJson(res, 400, {
|
|
660
|
+
error: {
|
|
661
|
+
code: "invalid_args",
|
|
662
|
+
message: "Expected `{text}` with SQL",
|
|
663
|
+
},
|
|
664
|
+
});
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
const paramsRaw = body.params;
|
|
668
|
+
const params = Array.isArray(paramsRaw)
|
|
669
|
+
? decodeStudioValue(paramsRaw)
|
|
670
|
+
: [];
|
|
671
|
+
let text = body.text;
|
|
672
|
+
if (body.explain === "analyze")
|
|
673
|
+
text = `EXPLAIN ANALYZE ${text}`;
|
|
674
|
+
else if (body.explain === true)
|
|
675
|
+
text = `EXPLAIN ${text}`;
|
|
676
|
+
if (ctx.readOnly && !isReadSql(text)) {
|
|
677
|
+
sendJson(res, 403, {
|
|
678
|
+
error: {
|
|
679
|
+
code: "read_only",
|
|
680
|
+
message: "Only read queries are allowed with --read-only",
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const rows = await ctx.client.execute({ text, params });
|
|
686
|
+
sendJson(res, 200, { rows: encodeStudioRows(rows), rowCount: rows.length });
|
|
687
|
+
}
|
|
688
|
+
function coerceImportValue(raw) {
|
|
689
|
+
if (raw === "")
|
|
690
|
+
return null;
|
|
691
|
+
const lowered = raw.toLowerCase();
|
|
692
|
+
if (lowered === "true")
|
|
693
|
+
return true;
|
|
694
|
+
if (lowered === "false")
|
|
695
|
+
return false;
|
|
696
|
+
if (lowered === "null")
|
|
697
|
+
return null;
|
|
698
|
+
if (/^-?\d+$/.test(raw)) {
|
|
699
|
+
const n = Number(raw);
|
|
700
|
+
if (Number.isSafeInteger(n))
|
|
701
|
+
return n;
|
|
702
|
+
return { __neoorm: "bigint", value: raw };
|
|
703
|
+
}
|
|
704
|
+
if (/^-?\d+\.\d+$/.test(raw))
|
|
705
|
+
return Number(raw);
|
|
706
|
+
return raw;
|
|
707
|
+
}
|
|
708
|
+
async function handleImport(ctx, req, res) {
|
|
709
|
+
const body = parseJsonBody(await readBody(req));
|
|
710
|
+
const accessor = body.accessor;
|
|
711
|
+
if (typeof accessor !== "string") {
|
|
712
|
+
sendJson(res, 400, {
|
|
713
|
+
error: {
|
|
714
|
+
code: "invalid_args",
|
|
715
|
+
message: 'Expected `{accessor, rows}` or `{accessor, format: "csv", csv}`',
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
const repo = repoFor(ctx, accessor);
|
|
721
|
+
const table = tableOr404(ctx, accessor);
|
|
722
|
+
let rows;
|
|
723
|
+
if (typeof body.csv === "string") {
|
|
724
|
+
const parsed = parseCsv(body.csv);
|
|
725
|
+
rows = parsed.rows.map((row) => {
|
|
726
|
+
const out = {};
|
|
727
|
+
for (const [k, v] of Object.entries(row))
|
|
728
|
+
out[k] = coerceImportValue(v);
|
|
729
|
+
return out;
|
|
730
|
+
});
|
|
731
|
+
}
|
|
732
|
+
else if (Array.isArray(body.rows)) {
|
|
733
|
+
rows = body.rows;
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
sendJson(res, 400, {
|
|
737
|
+
error: {
|
|
738
|
+
code: "invalid_args",
|
|
739
|
+
message: 'Expected `{accessor, rows}` or `{accessor, format: "csv", csv}`',
|
|
740
|
+
},
|
|
741
|
+
});
|
|
742
|
+
return;
|
|
743
|
+
}
|
|
744
|
+
if (rows.length === 0) {
|
|
745
|
+
sendJson(res, 400, {
|
|
746
|
+
error: { code: "invalid_args", message: "No rows to import" },
|
|
747
|
+
});
|
|
748
|
+
return;
|
|
749
|
+
}
|
|
750
|
+
if (rows.length > MAX_EXPORT_TAKE) {
|
|
751
|
+
sendJson(res, 400, {
|
|
752
|
+
error: {
|
|
753
|
+
code: "invalid_args",
|
|
754
|
+
message: "Import exceeds the 5000-row limit",
|
|
755
|
+
},
|
|
756
|
+
});
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const data = rows.map((row) => decodeStudioData(table, row));
|
|
760
|
+
const count = await repo.createMany({
|
|
761
|
+
data,
|
|
762
|
+
...(body.skipDuplicates === true ? { skipDuplicates: true } : {}),
|
|
763
|
+
});
|
|
764
|
+
sendJson(res, 201, { count });
|
|
765
|
+
}
|
|
766
|
+
async function handleExport(ctx, res, url) {
|
|
767
|
+
const params = getQueryParams(url);
|
|
768
|
+
const accessor = params.get("accessor");
|
|
769
|
+
if (!accessor) {
|
|
770
|
+
sendJson(res, 400, {
|
|
771
|
+
error: { code: "invalid_args", message: "Missing `?accessor=`" },
|
|
772
|
+
});
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
const repo = repoFor(ctx, accessor);
|
|
776
|
+
const format = params.get("format") ?? "json";
|
|
777
|
+
if (format !== "json" &&
|
|
778
|
+
format !== "csv" &&
|
|
779
|
+
format !== "md" &&
|
|
780
|
+
format !== "sql") {
|
|
781
|
+
sendJson(res, 400, {
|
|
782
|
+
error: {
|
|
783
|
+
code: "invalid_args",
|
|
784
|
+
message: "Unsupported format (json, csv, md, sql)",
|
|
785
|
+
},
|
|
786
|
+
});
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const take = parseTake(params.get("take"), MAX_EXPORT_TAKE);
|
|
790
|
+
const skip = parseSkip(params.get("skip"));
|
|
791
|
+
const where = mergeWhere(decodeStudioWhere(parseJsonParam(params.get("where"), "where")), buildSearchWhere(ctx, accessor, params.get("q") ?? ""));
|
|
792
|
+
const orderBy = parseJsonParam(params.get("orderBy"), "orderBy");
|
|
793
|
+
const rows = encodeStudioRows(await repo.findMany({
|
|
794
|
+
...(where ? { where } : {}),
|
|
795
|
+
...(orderBy ? { orderBy: orderBy } : {}),
|
|
796
|
+
take,
|
|
797
|
+
skip,
|
|
798
|
+
includeHidden: true,
|
|
799
|
+
}));
|
|
800
|
+
const table = tableOr404(ctx, accessor);
|
|
801
|
+
const tsColumns = table.columns.map((c) => c.tsName);
|
|
802
|
+
if (format === "json") {
|
|
803
|
+
sendJson(res, 200, { rows, take, skip });
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
if (format === "csv") {
|
|
807
|
+
sendText(res, 200, toCsv(tsColumns, rows), "text/csv; charset=utf-8");
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
if (format === "md") {
|
|
811
|
+
sendText(res, 200, toMarkdown(tsColumns, rows), "text/markdown; charset=utf-8");
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
const qualified = ctx.pgSchema && ctx.dialect.name === "postgresql"
|
|
815
|
+
? `${ctx.dialect.quoteIdentifier(ctx.pgSchema)}.${ctx.dialect.quoteIdentifier(table.sqlName)}`
|
|
816
|
+
: ctx.dialect.quoteIdentifier(table.sqlName);
|
|
817
|
+
sendText(res, 200, `${toSqlInserts(qualified, (name) => ctx.dialect.quoteIdentifier(name), table.columns.map((c) => c.sqlName), tsColumns, rows)}\n`, "text/plain; charset=utf-8");
|
|
818
|
+
}
|
|
819
|
+
async function readMigrateStatus(ctx) {
|
|
820
|
+
if (!ctx.migrationsDir) {
|
|
821
|
+
return {
|
|
822
|
+
available: false,
|
|
823
|
+
reason: "No migrations directory configured",
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
const disk = await listMigrationsOnDisk(ctx.migrationsDir);
|
|
827
|
+
let applied = [];
|
|
828
|
+
try {
|
|
829
|
+
const rows = await ctx.client.execute({
|
|
830
|
+
text: `SELECT ${ctx.dialect.quoteIdentifier("name")}, ${ctx.dialect.quoteIdentifier("checksum")}, ${ctx.dialect.quoteIdentifier("applied_at")} FROM ${ctx.dialect.quoteIdentifier("_neoorm_migrations")} ORDER BY ${ctx.dialect.quoteIdentifier("name")} ASC`,
|
|
831
|
+
params: [],
|
|
832
|
+
});
|
|
833
|
+
applied = rows.map((row) => ({
|
|
834
|
+
name: String(row.name),
|
|
835
|
+
appliedAt: row.applied_at instanceof Date
|
|
836
|
+
? row.applied_at.toISOString()
|
|
837
|
+
: String(row.applied_at ?? ""),
|
|
838
|
+
checksum: row.checksum === null || row.checksum === undefined
|
|
839
|
+
? null
|
|
840
|
+
: String(row.checksum),
|
|
841
|
+
}));
|
|
842
|
+
}
|
|
843
|
+
catch {
|
|
844
|
+
applied = [];
|
|
845
|
+
}
|
|
846
|
+
const status = computeMigrationStatus(disk, applied.map((a) => ({
|
|
847
|
+
name: a.name,
|
|
848
|
+
appliedAt: new Date(a.appliedAt),
|
|
849
|
+
checksum: a.checksum,
|
|
850
|
+
})));
|
|
851
|
+
return {
|
|
852
|
+
available: true,
|
|
853
|
+
migrationsDir: ctx.migrationsDir,
|
|
854
|
+
applied,
|
|
855
|
+
pending: status.pending,
|
|
856
|
+
orphanApplied: status.orphanApplied,
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
export function createStudioRequestHandler(ctx, uiDir) {
|
|
860
|
+
return async (req, res) => {
|
|
861
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
862
|
+
const { pathname } = url;
|
|
863
|
+
try {
|
|
864
|
+
if (pathname === "/api" || pathname.startsWith("/api/")) {
|
|
865
|
+
await handleApi(ctx, req, res, url);
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
if (!uiDir) {
|
|
869
|
+
sendText(res, 200, "NeoOrm Studio UI is not built. Run `bun run build` (or `bun run studio:dev`) and restart `neoorm studio`.");
|
|
870
|
+
return;
|
|
871
|
+
}
|
|
872
|
+
const relative = pathname === "/"
|
|
873
|
+
? "index.html"
|
|
874
|
+
: (pathname.slice(1).split("?")[0] ?? "index.html");
|
|
875
|
+
const normalized = relative.includes("..")
|
|
876
|
+
? "index.html"
|
|
877
|
+
: relative;
|
|
878
|
+
let filePath = join(uiDir, normalized);
|
|
879
|
+
try {
|
|
880
|
+
const fileStat = await stat(filePath);
|
|
881
|
+
if (fileStat.isDirectory())
|
|
882
|
+
filePath = join(uiDir, "index.html");
|
|
883
|
+
}
|
|
884
|
+
catch {
|
|
885
|
+
filePath = join(uiDir, "index.html");
|
|
886
|
+
}
|
|
887
|
+
const contentType = MIME_TYPES[extname(filePath)] ?? "application/octet-stream";
|
|
888
|
+
const body = await readFile(filePath);
|
|
889
|
+
sendFile(res, filePath, contentType, body);
|
|
890
|
+
}
|
|
891
|
+
catch (err) {
|
|
892
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
893
|
+
sendText(res, 500, message);
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
async function loadStudioManifest(cwd, config) {
|
|
898
|
+
const schemaPath = resolve(cwd, config.schema);
|
|
899
|
+
const pgSchema = config.datasource.provider === "postgresql"
|
|
900
|
+
? config.datasource.schema
|
|
901
|
+
: undefined;
|
|
902
|
+
try {
|
|
903
|
+
const { manifest } = await compileSchemaToManifest(schemaPath, {
|
|
904
|
+
...(config.datasource.provider
|
|
905
|
+
? { provider: config.datasource.provider }
|
|
906
|
+
: {}),
|
|
907
|
+
...(config.datasource.enum
|
|
908
|
+
? { enumMode: config.datasource.enum }
|
|
909
|
+
: {}),
|
|
910
|
+
...(pgSchema ? { schema: pgSchema } : {}),
|
|
911
|
+
});
|
|
912
|
+
return { manifest, metaSource: "schema" };
|
|
913
|
+
}
|
|
914
|
+
catch (schemaErr) {
|
|
915
|
+
const outDir = resolve(cwd, config.out);
|
|
916
|
+
const snapshot = await readSnapshot(outDir);
|
|
917
|
+
if (snapshot)
|
|
918
|
+
return { manifest: snapshot, metaSource: "snapshot" };
|
|
919
|
+
const detail = schemaErr instanceof Error ? schemaErr.message : String(schemaErr);
|
|
920
|
+
throw new Error(`Could not compile schema.ts and no snapshot.json was found. Run \`neoorm generate\` or fix schema.ts.\n${detail}`);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
export function buildStudioClient(manifest, options) {
|
|
924
|
+
const client = createNeoOrmClient(manifest, {
|
|
925
|
+
...(options.connectionString !== undefined
|
|
926
|
+
? { connectionString: options.connectionString }
|
|
927
|
+
: {}),
|
|
928
|
+
...(options.provider !== undefined
|
|
929
|
+
? { provider: options.provider }
|
|
930
|
+
: {}),
|
|
931
|
+
...(options.pgSchema !== undefined ? { schema: options.pgSchema } : {}),
|
|
932
|
+
...(options.migrationsDir !== undefined
|
|
933
|
+
? { migrationsDir: options.migrationsDir }
|
|
934
|
+
: {}),
|
|
935
|
+
...(options.sqliteDb !== undefined ? { db: options.sqliteDb } : {}),
|
|
936
|
+
...(options.verbose
|
|
937
|
+
? {
|
|
938
|
+
beforeQuery: (event) => {
|
|
939
|
+
console.log(`[studio] ${event.method} ${event.sql}`);
|
|
940
|
+
},
|
|
941
|
+
afterQuery: (event) => {
|
|
942
|
+
if (event.error)
|
|
943
|
+
console.log(`[studio] error after ${event.durationMs}ms: ${String(event.error)}`);
|
|
944
|
+
else
|
|
945
|
+
console.log(`[studio] done in ${event.durationMs}ms`);
|
|
946
|
+
},
|
|
947
|
+
}
|
|
948
|
+
: {}),
|
|
949
|
+
});
|
|
950
|
+
return client;
|
|
951
|
+
}
|
|
952
|
+
export async function startStudioServer(options) {
|
|
953
|
+
const cwd = options.cwd ?? process.cwd();
|
|
954
|
+
const host = options.host ?? "127.0.0.1";
|
|
955
|
+
const port = options.port ?? 7584;
|
|
956
|
+
const readOnly = options.readOnly ?? false;
|
|
957
|
+
const verbose = options.verbose ?? false;
|
|
958
|
+
let token = options.token;
|
|
959
|
+
if (!isLoopbackHost(host) && token === undefined) {
|
|
960
|
+
token = randomUUID();
|
|
961
|
+
}
|
|
962
|
+
let manifest = options.manifest;
|
|
963
|
+
let metaSource = "schema";
|
|
964
|
+
let provider = options.provider;
|
|
965
|
+
let pgSchema = options.pgSchema;
|
|
966
|
+
let migrationsDir = options.migrationsDir;
|
|
967
|
+
let connectionString = options.connectionString;
|
|
968
|
+
if (!manifest) {
|
|
969
|
+
const config = await loadConfig(cwd);
|
|
970
|
+
const loaded = await loadStudioManifest(cwd, config);
|
|
971
|
+
manifest = loaded.manifest;
|
|
972
|
+
metaSource = loaded.metaSource;
|
|
973
|
+
provider = config.datasource.provider;
|
|
974
|
+
pgSchema =
|
|
975
|
+
config.datasource.provider === "postgresql"
|
|
976
|
+
? config.datasource.schema
|
|
977
|
+
: undefined;
|
|
978
|
+
connectionString = config.datasource.url;
|
|
979
|
+
migrationsDir = resolve(cwd, config.out, "migrations");
|
|
980
|
+
if (metaSource === "snapshot") {
|
|
981
|
+
console.warn("Warning: schema.ts did not compile; Studio is showing snapshot.json and may be stale");
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const client = buildStudioClient(manifest, {
|
|
985
|
+
...(connectionString !== undefined ? { connectionString } : {}),
|
|
986
|
+
...(provider !== undefined ? { provider } : {}),
|
|
987
|
+
...(pgSchema !== undefined ? { pgSchema } : {}),
|
|
988
|
+
...(migrationsDir !== undefined ? { migrationsDir } : {}),
|
|
989
|
+
...(verbose ? { verbose: true } : {}),
|
|
990
|
+
...(options.sqliteDb !== undefined
|
|
991
|
+
? { sqliteDb: options.sqliteDb }
|
|
992
|
+
: {}),
|
|
993
|
+
});
|
|
994
|
+
const dialect = dialectForProvider(provider ?? manifest.provider ?? "postgresql");
|
|
995
|
+
const meta = toStudioMeta(manifest, {
|
|
996
|
+
metaSource,
|
|
997
|
+
...(pgSchema !== undefined ? { pgSchema } : {}),
|
|
998
|
+
});
|
|
999
|
+
const ctx = {
|
|
1000
|
+
manifest,
|
|
1001
|
+
meta,
|
|
1002
|
+
client,
|
|
1003
|
+
dialect,
|
|
1004
|
+
...(provider !== undefined ? { provider } : {}),
|
|
1005
|
+
...(pgSchema !== undefined ? { pgSchema } : {}),
|
|
1006
|
+
...(migrationsDir !== undefined ? { migrationsDir } : {}),
|
|
1007
|
+
readOnly,
|
|
1008
|
+
verbose,
|
|
1009
|
+
...(token !== undefined ? { token } : {}),
|
|
1010
|
+
metaSource,
|
|
1011
|
+
};
|
|
1012
|
+
const uiDir = resolveStudioUiDir(cwd, options.uiDir);
|
|
1013
|
+
const handler = createStudioRequestHandler(ctx, uiDir);
|
|
1014
|
+
const server = createServer((req, res) => {
|
|
1015
|
+
void handler(req, res);
|
|
1016
|
+
});
|
|
1017
|
+
await new Promise((resolvePromise, reject) => {
|
|
1018
|
+
server.once("error", reject);
|
|
1019
|
+
server.listen(port, host, () => resolvePromise());
|
|
1020
|
+
});
|
|
1021
|
+
const address = server.address();
|
|
1022
|
+
const actualPort = typeof address === "object" && address !== null ? address.port : port;
|
|
1023
|
+
const url = `http://${host}:${actualPort}`;
|
|
1024
|
+
const openUrl = token !== undefined
|
|
1025
|
+
? `${url}/?token=${encodeURIComponent(token)}`
|
|
1026
|
+
: url;
|
|
1027
|
+
if (options.open)
|
|
1028
|
+
openBrowser(openUrl);
|
|
1029
|
+
return {
|
|
1030
|
+
url,
|
|
1031
|
+
...(token !== undefined ? { token } : {}),
|
|
1032
|
+
close: async () => {
|
|
1033
|
+
await client.$disconnect().catch(() => undefined);
|
|
1034
|
+
await new Promise((resolvePromise, reject) => {
|
|
1035
|
+
server.close((err) => {
|
|
1036
|
+
if (err)
|
|
1037
|
+
reject(err);
|
|
1038
|
+
else
|
|
1039
|
+
resolvePromise();
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
},
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
//# sourceMappingURL=server.js.map
|