@sun-yryr/queot 0.1.0 → 0.1.1

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.
@@ -0,0 +1,4 @@
1
+ import{Hono as sn}from"hono";import{Hono as on}from"hono";import{Effect as c,Schema as ce}from"effect";import*as j from"effect/Either";import{Effect as Se,Context as ge,Schema as h}from"effect";var Ne=h.Struct({name:h.String,type:h.String}),xe=h.Record({key:h.String,value:h.Unknown}),ln=h.Struct({rows:h.Array(xe),fields:h.Array(Ne)}),v=class extends ge.Tag("Queryable")(){},_=e=>Se.gen(function*(){return yield*(yield*v).execute(e)});import k from"daff";function $(e){if(e===null)return"null";if(e===void 0)return"";switch(typeof e){case"string":return e;case"number":case"bigint":case"boolean":return String(e);case"symbol":return e.toString();case"function":return"[function]";case"object":try{return JSON.stringify(e)}catch{return Object.prototype.toString.call(e)}default:return""}}function q(e){return(e??[]).some(t=>{let r=$(t?.[0]);return r==="!"||r==="+++"||r==="---"||r==="->"})}function K(e){let o=e.fields.map(r=>r.name),t=e.rows.map(r=>{let i=r;return e.fields.map(s=>$(i[s.name]))});return[o,...t]}function V(e,o){let t=K(e),r=K(o),i=new k.TableView(t),s=new k.TableView(r),a=k.compareTables(i,s).align(),u=new k.CompareFlags,m=new k.TableDiff(a,u),d=new k.TableView([]);return m.hilite(d),k.jsonify(d)?.h?.sheet??[]}import{Effect as Be,Schema as Me}from"effect";import{Effect as Ee,Schema as n}from"effect";var U=n.Struct({"Node Type":n.String,Plans:n.optional(n.Array(n.Unknown))}),yn=n.Struct({"Node Type":n.Literal("UnknownNode"),originalNodeType:n.String,Plans:n.optional(n.Array(n.Unknown))}),S={"Parallel Aware":n.optional(n.Boolean),"Async Capable":n.optional(n.Boolean),"Parent Relationship":n.optional(n.String),Disabled:n.optional(n.Boolean),"Startup Cost":n.optional(n.Number),"Total Cost":n.optional(n.Number),"Plan Rows":n.optional(n.Number),"Plan Width":n.optional(n.Number),"Actual Startup Time":n.optional(n.Number),"Actual Total Time":n.optional(n.Number),"Actual Rows":n.optional(n.Number),"Actual Loops":n.optional(n.Number),"Shared Hit Blocks":n.optional(n.Number),"Shared Read Blocks":n.optional(n.Number),"Shared Dirtied Blocks":n.optional(n.Number),"Shared Written Blocks":n.optional(n.Number)},ke=n.Struct({...S,"Node Type":n.Literal("Limit"),Plans:n.optional(n.Array(n.Unknown))}),we=n.Struct({...S,"Node Type":n.Literal("Unique"),Plans:n.optional(n.Array(n.Unknown))}),Pe=n.Struct({...S,"Node Type":n.Literal("Incremental Sort"),"Sort Key":n.optional(n.Array(n.String)),"Presorted Key":n.optional(n.Array(n.String)),Plans:n.optional(n.Array(n.Unknown))}),Re=n.Struct({...S,"Node Type":n.Literal("Nested Loop"),"Join Type":n.optional(n.String),"Inner Unique":n.optional(n.Boolean),"Join Filter":n.optional(n.String),"Rows Removed by Join Filter":n.optional(n.Number),Plans:n.optional(n.Array(n.Unknown))}),Te=n.Struct({...S,"Node Type":n.Literal("Index Scan"),"Scan Direction":n.optional(n.String),"Index Name":n.optional(n.String),"Relation Name":n.optional(n.String),Alias:n.optional(n.String),Filter:n.optional(n.String),"Index Cond":n.optional(n.String),"Index Searches":n.optional(n.Number),Plans:n.optional(n.Array(n.Unknown))}),ve=n.Struct({...S,"Node Type":n.Literal("Index Only Scan"),"Scan Direction":n.optional(n.String),"Index Name":n.optional(n.String),"Relation Name":n.optional(n.String),Alias:n.optional(n.String),"Index Cond":n.optional(n.String),"Heap Fetches":n.optional(n.Number),"Index Searches":n.optional(n.Number),Plans:n.optional(n.Array(n.Unknown))}),be=n.Struct({...S,"Node Type":n.Literal("Seq Scan"),"Relation Name":n.optional(n.String),Alias:n.optional(n.String),Filter:n.optional(n.String),Plans:n.optional(n.Array(n.Unknown))}),Ae=n.Struct({...S,"Node Type":n.Literal("Materialize"),Storage:n.optional(n.String),"Maximum Storage":n.optional(n.Number),Plans:n.optional(n.Array(n.Unknown))}),qe=n.Struct({...S,"Node Type":n.Literal("Memoize"),"Cache Key":n.optional(n.String),"Cache Mode":n.optional(n.String),Plans:n.optional(n.Array(n.Unknown))});function y(e,o){try{return{ok:!0,value:Ee.runSync(n.decodeUnknown(e)(o))}}catch(t){return{ok:!1,error:t}}}function Ue(e){if(!y(U,e).ok)throw new TypeError('Invalid Plan node: expected an object with string "Node Type" (and optional Plans array)')}function I(e){Ue(e);let o=e["Node Type"];switch(o){case"Limit":{let t=y(ke,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Limit",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Unique":{let t=y(we,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Unique",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Incremental Sort":{let t=y(Pe,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Incremental Sort",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Nested Loop":{let t=y(Re,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Nested Loop",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Index Scan":{let t=y(Te,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Index Scan",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Index Only Scan":{let t=y(ve,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Index Only Scan",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Seq Scan":{let t=y(be,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Seq Scan",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Materialize":{let t=y(Ae,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Materialize",Plans:e.Plans},raw:e,knownSchemaError:t.error}}case"Memoize":{let t=y(qe,e);return t.ok?{kind:"KnownNode",value:t.value,raw:e}:{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:"Memoize",Plans:e.Plans},raw:e,knownSchemaError:t.error}}default:return{kind:"UnknownNode",value:{"Node Type":"UnknownNode",originalNodeType:o,Plans:e.Plans},raw:e}}}import{Schema as f}from"effect";var Y=f.Struct({Plan:U,"Planning Time":f.optional(f.Number),"Execution Time":f.optional(f.Number),Planning:f.optional(f.Unknown),Triggers:f.optional(f.Unknown),JIT:f.optional(f.Unknown)}),X=f.Union(f.Array(Y),Y);function Ce(e){return typeof e=="object"&&e!==null}function He(e){return Ce(e)&&typeof e["Node Type"]=="string"}function Ie(e){return Be.runSync(Me.decodeUnknown(X)(e)),Array.isArray(e)?e:[e]}function Qe(e,o){let t=0,r=s=>o.idStrategy==="preorder"?`n${t++}`:s.join("."),i=(s,a,u)=>{let m=r(a),d=I(s),R={...d.value,kind:d.kind,raw:d.raw,knownSchemaError:d.kind==="UnknownNode"?d.knownSchemaError:void 0,id:m,depth:Math.max(0,a.length-1),path:a,parentId:u,children:[]},N=Array.isArray(s.Plans)?s.Plans:[];return R.children=N.map((x,E)=>{let T=He(x)?x:{"Node Type":"InvalidPlanItem",Value:x};return i(T,[...a,E],m)}),R};return i(e,[0],void 0)}function B(e,o={}){let t=typeof e=="string"?JSON.parse(e):e,r=Ie(t),i=o.idStrategy??"path";return{statements:r.map(a=>({root:Qe(a.Plan,{idStrategy:i}),statement:a,meta:{planningTimeMs:typeof a["Planning Time"]=="number"?a["Planning Time"]:void 0,executionTimeMs:typeof a["Execution Time"]=="number"?a["Execution Time"]:void 0}}))}}function Q(e){return e.toLowerCase().replace(/[_\s]+/g," ").trim()}function Le(e,o){if(o in e)return e[o];let t=Q(o);for(let r of Object.keys(e))if(Q(r)===t)return e[r]}function G(e){if(!e.fields?.length||!e.rows?.length)return B([]);let o=e.fields.find(i=>Q(i.name)==="query plan")??e.fields.find(i=>["json","jsonb"].includes(i.type?.toLowerCase?.()??""))??e.fields[0],t=e.rows[0],r=Le(t,o.name);if(r===void 0)throw new Error(`EXPLAIN JSON column not found (field: ${o.name})`);return B(r)}import w from"node:fs/promises";import $e from"node:path";import{createReadStream as Ve}from"node:fs";import Ye from"node:readline";import{randomUUID as ie}from"node:crypto";import{Context as Xe,Effect as g,Layer as Ge}from"effect";import je from"node:os";import L from"node:path";import Oe from"node:fs/promises";import{Context as Fe,Effect as b,Layer as ze,Schema as p}from"effect";import{Context as De,Layer as Bn}from"effect";var M=class extends De.Tag("Env")(){};var A=class extends Fe.Tag("RuntimeConfig")(){},W=p.Number.pipe(p.int(),p.nonNegative()),Je=p.Struct({historyPath:p.optional(p.Trim.pipe(p.nonEmptyString())),historyMaxEntries:p.optional(W),historyMaxBytes:p.optional(W)});function Z(e,o,t){return Number.isFinite(e)?Math.max(o,Math.min(t,Math.trunc(e))):o}function ne(e){let o=e.get("XDG_CONFIG_HOME")?.trim();return o&&o.length>0?o:L.join(je.homedir(),".config")}function _e(e){return L.join(ne(e),"queot","config.json")}function te(e){let o=_e(e);return b.tryPromise({try:async()=>{let t=await Oe.readFile(o,"utf8"),r=JSON.parse(t),i=p.decodeUnknownEither(Je)(r);return i._tag==="Left"?{}:i.right},catch:t=>t instanceof Error?t:new Error(String(t))}).pipe(b.catchAll(()=>b.succeed({})))}function ee(e,o){let t=e.get(o);if(t)try{return p.decodeUnknownSync(p.NumberFromString.pipe(p.int(),p.nonNegative()))(t)}catch{return}}function Ke(e,o){let t=e.get(o);if(t)try{return p.decodeUnknownSync(p.Trim.pipe(p.nonEmptyString()))(t)}catch{return}}function oe(e,o){let t=Ke(e,"QUEOT_HISTORY_PATH")??o.historyPath??L.join(ne(e),"queot","history.jsonl"),r=ee(e,"QUEOT_HISTORY_MAX_ENTRIES")??o.historyMaxEntries??500,i=ee(e,"QUEOT_HISTORY_MAX_BYTES")??o.historyMaxBytes??5*1024*1024;return{historyPath:t,historyMaxEntries:Z(r,1,5e4),historyMaxBytes:Z(i,1,1024*1024*1024)}}var Dn=ze.effect(A,b.gen(function*(){let e=yield*M,o=yield*te(e);return{history:oe(e,o),nodeEnv:e.get("NODE_ENV")}}));async function jn(e){let o={get:r=>e[r]},t=await b.runPromise(te(o));return{history:oe(o,t),nodeEnv:o.get("NODE_ENV")}}var P=class extends Xe.Tag("HistoryStore")(){};function We(e,o,t){return Number.isFinite(e)?Math.max(o,Math.min(t,Math.trunc(e))):o}function Ze(e){let o=e.request.queryA??e.response.queryA??"",t=e.request.queryB??e.response.queryB??"",r=!!(e.response.resultA&&e.response.resultB),i=typeof e.response.hasDiffChanges=="boolean"?e.response.hasDiffChanges:r?q(e.response.diff??e.response.planDiff):o.trim().length>0||t.trim().length>0?!0:void 0;return{id:e.id,timestamp:e.timestamp,queryA:o,queryB:t,planMode:e.request.planMode,hasError:!!(e.response.errorA||e.response.errorB),hasDiffChanges:i}}function se(e){return{id:ie(),timestamp:new Date().toISOString(),request:{queryA:e.queryA,queryB:e.queryB,planMode:e.planMode},response:e.response}}async function en(e,o){let{maxBytes:t,maxEntries:r}=o,i=await w.stat(e).catch(()=>{});if(!i||i.size<=t)return;let s=[];for await(let u of D(e)){let m=u.trim();m&&(s.push(m),s.length>r&&s.shift())}let a=`${e}.tmp-${ie()}`;await w.writeFile(a,`${s.join(`
2
+ `)}
3
+ `,"utf8"),await w.rename(a,e)}function nn(e){let o={maxEntries:e.history.historyMaxEntries,maxBytes:e.history.historyMaxBytes},t=e.history.historyPath;return{getHistoryPath:g.succeed(t),appendHistory:r=>g.tryPromise({try:async()=>{await w.mkdir($e.dirname(t),{recursive:!0}),await w.appendFile(t,`${JSON.stringify(r)}
4
+ `,"utf8");try{await en(t,o)}catch{}},catch:i=>i instanceof Error?i:new Error(String(i))}).pipe(g.orDie),readHistorySummaries:r=>g.tryPromise({try:async()=>{let i=We(r?.limit??50,1,200),s=r?.id?.trim()||void 0,a=r?.timestamp?.trim()||void 0;await w.stat(t).catch(()=>{});let u=[];try{for await(let m of D(t)){let d=re(m);d&&(s&&d.id!==s||a&&d.timestamp!==a||(u.push(Ze(d)),!s&&!a&&u.length>i&&u.shift()))}}catch{return[]}return u.reverse().slice(0,i)},catch:i=>i instanceof Error?i:new Error(String(i))}).pipe(g.orDie),readHistoryById:r=>g.tryPromise({try:async()=>{let i=r.trim();if(!i)return;await w.stat(t).catch(()=>{});let s;try{for await(let a of D(t)){let u=re(a);u&&u.id===i&&(s=u)}}catch{return}return s},catch:i=>i instanceof Error?i:new Error(String(i))}).pipe(g.orDie)}}var Xn=Ge.effect(P,g.gen(function*(){let e=yield*A;return nn(e)}));async function*D(e){let o=Ve(e,{encoding:"utf8"}),t=Ye.createInterface({input:o,crlfDelay:1/0});try{for await(let r of t)yield r}finally{t.close(),o.destroy()}}function re(e){let o=e.trim();if(o)try{let t=JSON.parse(o);if(!t||typeof t!="object"||typeof t.id!="string"||typeof t.timestamp!="string")return;let r=t.request??{},i=r.planMode==="analyze"?"analyze":"explain";return{id:t.id,timestamp:t.timestamp,request:{queryA:typeof r.queryA=="string"?r.queryA:"",queryB:typeof r.queryB=="string"?r.queryB:"",planMode:i},response:t.response??{}}}catch{return}}import{Schema as l}from"effect";var tn=l.Literal("explain","analyze"),ae=l.Struct({queryA:l.optional(l.String),queryB:l.optional(l.String),planMode:l.optional(tn)}),ue=l.Struct({limit:l.optional(l.NumberFromString.pipe(l.int(),l.nonNegative(),l.clamp(1,200))),id:l.optional(l.Trim.pipe(l.nonEmptyString())),timestamp:l.optional(l.Trim.pipe(l.nonEmptyString()))});function C(e){let o=e.trim();return o.length===0?c.succeed({result:void 0,error:"EMPTY_QUERY"}):_(o).pipe(c.map(t=>({result:t,error:void 0})),c.catchAll(t=>c.succeed({result:void 0,error:t instanceof Error?t.message:String(t)})))}function le(e){if(!e)return{plan:void 0,error:void 0};try{return{plan:G(e),error:void 0}}catch(o){return{plan:void 0,error:o instanceof Error?o.message:String(o)}}}function rn(e){return e.resultA&&e.resultB?q(e.diff):e.queryA.trim().length>0||e.queryB.trim().length>0?!0:void 0}function de(e){let o=new on;return o.get("/histories",async t=>{let r=ce.decodeUnknownEither(ue)({limit:t.req.query("limit"),id:t.req.query("id"),timestamp:t.req.query("timestamp")});if(j.isLeft(r))return t.json({error:"BAD_REQUEST"},400);let s={items:await c.runPromise(c.gen(function*(){return yield*(yield*P).readHistorySummaries(r.right)}).pipe(c.provide(e.context)))};return t.json(s)}),o.get("/histories/:id",async t=>{let r=t.req.param("id"),i=await c.runPromise(c.gen(function*(){return yield*(yield*P).readHistoryById(r)}).pipe(c.provide(e.context)));return i?t.json({item:i}):t.json({item:void 0},404)}),o.post("/run",async t=>{let r=await t.req.json().catch(()=>({})),i=ce.decodeUnknownEither(ae)(r);if(j.isLeft(i))return t.json({error:"BAD_REQUEST"},400);let s=i.right,a=s.queryA??"",u=s.queryB??"",m=s.planMode??"explain",d="EXPLAIN (FORMAT JSON";m==="analyze"&&(d+=", ANALYZE true"),d+=")";let R=c.gen(function*(){let H=yield*v;return yield*c.acquireUseRelease(H.execute("BEGIN"),()=>c.gen(function*(){let me=yield*C(a),fe=yield*C(`${d} ${a}`),ye=yield*C(u),he=yield*C(`${d} ${u}`);return{a:me,aPlan:fe,b:ye,bPlan:he}}),()=>H.execute("ROLLBACK").pipe(c.catchAll(()=>c.void)))}),{a:N,aPlan:x,b:E,bPlan:T}=await c.runPromise(R.pipe(c.provide(e.context))),O=N.result&&E.result?V(N.result,E.result):void 0,F=le(x.result),z=le(T.result),J={queryA:a,queryB:u,planMode:m,resultA:N.result,resultB:E.result,errorA:N.error,errorB:E.error,diff:O,hasDiffChanges:rn({queryA:a,queryB:u,resultA:N.result,resultB:E.result,diff:O}),planQueryResultA:x.result,planQueryResultB:T.result,planResultA:F.plan,planResultB:z.plan,planErrorA:F.error??x.error,planErrorB:z.error??T.error,planDiff:void 0};try{await c.runPromise(c.gen(function*(){yield*(yield*P).appendHistory(se({queryA:a,queryB:u,planMode:m,response:J}))}).pipe(c.provide(e.context)))}catch{}return t.json(J)}),o}import{serveStatic as pe}from"@hono/node-server/serve-static";import an from"node:path";import{fileURLToPath as un}from"node:url";function ft(e){let o=new sn;if(o.route("/api",de(e)),e.isProduction){let t=un(new URL("../dist/client",import.meta.url)),r=an.relative(process.cwd(),t)||".";o.use("/*",pe({root:r,precompressed:!0})),o.get("*",pe({root:r,path:"index.html"}))}return o}export{ln as a,v as b,A as c,jn as d,P as e,nn as f,ft as g};
@@ -1,109 +1,2 @@
1
- import { Command, Flags, settings } from "@oclif/core";
2
- import { createApp } from "../server.js";
3
- import { serve } from "@hono/node-server";
4
- import { Context } from "effect";
5
- import { RuntimeConfig, loadRuntimeConfigFromEnv } from "../infra/config.js";
6
- import { HistoryStore, makeHistoryStore } from "../infra/history.js";
7
- import { Client } from "pg";
8
- import { makePgQueryable } from "../infra/postgres/queryable.js";
9
- import { Queryable } from "../services/query.js";
10
- import open from "open";
11
- import { password } from "@inquirer/prompts";
12
- function getPortFromServerAddress(addr) {
13
- if (!addr)
14
- return null;
15
- if (typeof addr === "string")
16
- return null;
17
- return addr.port;
18
- }
19
- export default class Serve extends Command {
20
- static args = {};
21
- static summary = "PostgreSQL に接続し、queot のWeb UIサーバーを起動します。";
22
- static description = `パスワードは起動時にプロンプトから入力します。 DBPASSWORD 環境変数で渡すことも可能です。`;
23
- static examples = [
24
- "$ <%= config.bin %> <%= command.id %>",
25
- "$ <%= config.bin %> <%= command.id %> --db-name demo",
26
- "$ DBPASSWORD=**** <%= config.bin %> <%= command.id %> --db-name demo",
27
- "$ <%= config.bin %> <%= command.id %> --db-name demo --listen-port 3000",
28
- ];
29
- static flags = {
30
- "db-host": Flags.string({
31
- description: "host to connect to Database",
32
- char: "h",
33
- default: "localhost",
34
- helpValue: "<hostname/IP address>",
35
- }),
36
- "db-port": Flags.integer({
37
- char: "p",
38
- description: "port to connect to Database",
39
- default: 5432,
40
- helpValue: "<port number>",
41
- }),
42
- "db-username": Flags.string({
43
- char: "u",
44
- description: "user to connect to Database",
45
- default: "postgres",
46
- helpValue: "<username>",
47
- }),
48
- "db-name": Flags.string({
49
- char: "d",
50
- description: "database to connect to Database",
51
- default: "postgres",
52
- helpValue: "<database name>",
53
- }),
54
- "listen-port": Flags.integer({
55
- char: "l",
56
- description: "port to listen on (default: auto select an available port)",
57
- helpValue: "<port number>",
58
- }),
59
- };
60
- async run() {
61
- const { flags } = await this.parse(Serve);
62
- // env は起動時に1回だけ読む(各モジュールで process.env を参照しない)
63
- const cfg = await loadRuntimeConfigFromEnv(process.env);
64
- const listenPort = flags["listen-port"];
65
- const dbHost = flags["db-host"];
66
- const dbPort = flags["db-port"];
67
- const dbUser = flags["db-username"];
68
- const dbDatabase = flags["db-name"];
69
- let dbPassword = process.env.DBPASSWORD;
70
- if (!dbPassword) {
71
- dbPassword = await password({
72
- message: "password > ",
73
- mask: true,
74
- });
75
- }
76
- const client = new Client({
77
- host: dbHost,
78
- port: dbPort,
79
- user: dbUser,
80
- password: dbPassword,
81
- database: dbDatabase,
82
- });
83
- await client.connect();
84
- const queryable = makePgQueryable(client);
85
- const historyStore = makeHistoryStore(cfg);
86
- const context = Context.empty().pipe(Context.add(RuntimeConfig, cfg), Context.add(Queryable, queryable), Context.add(HistoryStore, historyStore));
87
- const app = createApp({
88
- context,
89
- isProduction: cfg.nodeEnv !== "development",
90
- });
91
- const server = serve({
92
- fetch: app.fetch,
93
- port: listenPort ?? 0,
94
- });
95
- const actualPort = getPortFromServerAddress(server.address()) ?? listenPort ?? 0;
96
- process.on("SIGINT", () => {
97
- server.close();
98
- void client.end();
99
- });
100
- process.on("SIGTERM", () => {
101
- server.close();
102
- void client.end();
103
- });
104
- this.log(`サーバーはポート ${actualPort} で起動しています\n終了するには Ctrl+C を押してください`);
105
- if (!settings.debug) {
106
- await open(`http://localhost:${actualPort}`);
107
- }
108
- }
109
- }
1
+ import{a as m,b as p,c as u,d as f,e as b,f as g,g as y}from"../chunk-SID7L2SW.js";import{Command as F,Flags as r,settings as $}from"@oclif/core";import{serve as k}from"@hono/node-server";import{Context as s}from"effect";import{Client as V}from"pg";import{Effect as x,Schema as I}from"effect";function A(e){return e.map(t=>({name:t.name,type:String(t.dataTypeID)}))}function E(e){return I.decodeUnknownSync(m)({rows:e.rows,fields:A(e.fields)})}function h(e){return{execute:t=>x.tryPromise({try:async()=>E(await e.query(t)),catch:o=>o instanceof Error?o:new Error(String(o))})}}import T from"open";import{password as W}from"@inquirer/prompts";function q(e){return!e||typeof e=="string"?null:e.port}var d=class e extends F{static args={};static summary="PostgreSQL \u306B\u63A5\u7D9A\u3057\u3001queot \u306EWeb UI\u30B5\u30FC\u30D0\u30FC\u3092\u8D77\u52D5\u3057\u307E\u3059\u3002";static description="\u30D1\u30B9\u30EF\u30FC\u30C9\u306F\u8D77\u52D5\u6642\u306B\u30D7\u30ED\u30F3\u30D7\u30C8\u304B\u3089\u5165\u529B\u3057\u307E\u3059\u3002 DBPASSWORD \u74B0\u5883\u5909\u6570\u3067\u6E21\u3059\u3053\u3068\u3082\u53EF\u80FD\u3067\u3059\u3002";static examples=["$ <%= config.bin %> <%= command.id %>","$ <%= config.bin %> <%= command.id %> --db-name demo","$ DBPASSWORD=**** <%= config.bin %> <%= command.id %> --db-name demo","$ <%= config.bin %> <%= command.id %> --db-name demo --listen-port 3000"];static flags={"db-host":r.string({description:"host to connect to Database",char:"h",default:"localhost",helpValue:"<hostname/IP address>"}),"db-port":r.integer({char:"p",description:"port to connect to Database",default:5432,helpValue:"<port number>"}),"db-username":r.string({char:"u",description:"user to connect to Database",default:"postgres",helpValue:"<username>"}),"db-name":r.string({char:"d",description:"database to connect to Database",default:"postgres",helpValue:"<database name>"}),"listen-port":r.integer({char:"l",description:"port to listen on (default: auto select an available port)",helpValue:"<port number>"})};async run(){let{flags:t}=await this.parse(e),o=await f(process.env),c=t["listen-port"],P=t["db-host"],S=t["db-port"],v=t["db-username"],w=t["db-name"],a=process.env.DBPASSWORD;a||(a=await W({message:"password > ",mask:!0}));let n=new V({host:P,port:S,user:v,password:a,database:w});await n.connect();let D=h(n),R=g(o),C=s.empty().pipe(s.add(u,o),s.add(p,D),s.add(b,R)),Q=y({context:C,isProduction:o.nodeEnv!=="development"}),i=k({fetch:Q.fetch,port:c??0}),l=q(i.address())??c??0;process.on("SIGINT",()=>{i.close(),n.end()}),process.on("SIGTERM",()=>{i.close(),n.end()}),this.log(`\u30B5\u30FC\u30D0\u30FC\u306F\u30DD\u30FC\u30C8 ${l} \u3067\u8D77\u52D5\u3057\u3066\u3044\u307E\u3059
2
+ \u7D42\u4E86\u3059\u308B\u306B\u306F Ctrl+C \u3092\u62BC\u3057\u3066\u304F\u3060\u3055\u3044`),$.debug||await T(`http://localhost:${l}`)}};export{d as default};
package/dist/server.js CHANGED
@@ -1,19 +1 @@
1
- import { Hono } from "hono";
2
- import { createApiRoute } from "./routes/api.js";
3
- import { serveStatic } from "@hono/node-server/serve-static";
4
- import path from "node:path";
5
- import { fileURLToPath } from "node:url";
6
- export function createApp(deps) {
7
- const app = new Hono();
8
- app.route("/api", createApiRoute(deps));
9
- if (deps.isProduction) {
10
- const clientDistDir = fileURLToPath(new URL("../dist/client", import.meta.url));
11
- // serveStatic の root は「起動時の cwd からの相対パス」前提なので、cwd依存を吸収する
12
- const clientRoot = path.relative(process.cwd(), clientDistDir) || ".";
13
- // まず実ファイル(/assets/* や /favicon.ico など)を配信
14
- app.use("/*", serveStatic({ root: clientRoot, precompressed: true }));
15
- // SPA fallback(存在しないパスは index.html)
16
- app.get("*", serveStatic({ root: clientRoot, path: "index.html" }));
17
- }
18
- return app;
19
- }
1
+ import{g as a}from"./chunk-SID7L2SW.js";export{a as createApp};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-yryr/queot",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Query Optimization Tool for SQL",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,14 +17,14 @@
17
17
  "hono": "^4.10.8",
18
18
  "open": "^11.0.0",
19
19
  "pg": "^8.16.3",
20
- "tailwindcss": "^4.1.18",
21
- "@sun-yryr/queot-planparser": "0.1.0"
20
+ "tailwindcss": "^4.1.18"
22
21
  },
23
22
  "devDependencies": {
24
23
  "@eslint/js": "^9.39.2",
25
24
  "@tsconfig/node24": "^24.0.3",
26
25
  "@types/node": "^24.10.3",
27
26
  "@types/pg": "^8.16.0",
27
+ "esbuild": "^0.27.2",
28
28
  "eslint": "^9.39.2",
29
29
  "eslint-config-prettier": "^10.1.8",
30
30
  "oclif": "^4.22.55",
@@ -33,7 +33,8 @@
33
33
  "typescript": "^5.9.3",
34
34
  "typescript-eslint": "^8.49.0",
35
35
  "vite": "^7.3.0",
36
- "vitest": "^4.0.16"
36
+ "vitest": "^4.0.16",
37
+ "@sun-yryr/queot-planparser": "0.1.0"
37
38
  },
38
39
  "files": [
39
40
  "dist",
@@ -59,16 +60,18 @@
59
60
  "scripts": {
60
61
  "dev": "pnpm -s dev:web & pnpm -s dev:api",
61
62
  "dev:web": "vite --open",
62
- "dev:api": "OCLIF_DEBUG=1 ./bin/dev.js serve -p 3000",
63
- "build:server": "tsc --project tsconfig.build.json",
63
+ "dev:api": "OCLIF_DEBUG=1 ./bin/dev.js serve -l 3000",
64
+ "build:server": "node ./scripts/bundle-server.mjs",
64
65
  "build:client": "vite build",
65
- "build": "pnpm build:server && pnpm build:client",
66
- "start": "NODE_ENV=production node ./bin/run.js serve -p 3000",
66
+ "build": "pnpm clean && pnpm build:server && pnpm build:client",
67
+ "start": "NODE_ENV=production node ./bin/run.js serve",
68
+ "clean": "rm -rf dist",
67
69
  "queot": "./bin/run.js",
68
70
  "queot:dev": "./bin/dev.js",
69
71
  "check": "pnpm \"/^check:.*/\"",
70
72
  "check:prettier": "prettier . --check",
71
73
  "check:eslint": "eslint",
74
+ "check:types": "tsc --noEmit",
72
75
  "fix": "pnpm \"/^fix:.+$/\"",
73
76
  "fix:prettier": "prettier . --write",
74
77
  "fix:eslint": "eslint --fix",
@@ -1,101 +0,0 @@
1
- import os from "node:os";
2
- import path from "node:path";
3
- import fs from "node:fs/promises";
4
- import { Context, Effect, Layer, Schema } from "effect";
5
- import { Env } from "./env.js";
6
- export class RuntimeConfig extends Context.Tag("RuntimeConfig")() {
7
- }
8
- const NonNegativeInt = Schema.Number.pipe(Schema.int(), Schema.nonNegative());
9
- const QueotConfigFileSchema = Schema.Struct({
10
- historyPath: Schema.optional(Schema.Trim.pipe(Schema.nonEmptyString())),
11
- historyMaxEntries: Schema.optional(NonNegativeInt),
12
- historyMaxBytes: Schema.optional(NonNegativeInt),
13
- });
14
- function clampInt(v, min, max) {
15
- if (!Number.isFinite(v))
16
- return min;
17
- return Math.max(min, Math.min(max, Math.trunc(v)));
18
- }
19
- function getConfigHome(env) {
20
- const xdg = env.get("XDG_CONFIG_HOME")?.trim();
21
- if (xdg && xdg.length > 0)
22
- return xdg;
23
- return path.join(os.homedir(), ".config");
24
- }
25
- export function getQueotConfigPath(env) {
26
- return path.join(getConfigHome(env), "queot", "config.json");
27
- }
28
- function loadQueotConfig(env) {
29
- const p = getQueotConfigPath(env);
30
- return Effect.tryPromise({
31
- try: async () => {
32
- const raw = await fs.readFile(p, "utf8");
33
- const json = JSON.parse(raw);
34
- const decoded = Schema.decodeUnknownEither(QueotConfigFileSchema)(json);
35
- if (decoded._tag === "Left")
36
- return {};
37
- return decoded.right;
38
- },
39
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
40
- }).pipe(Effect.catchAll(() => Effect.succeed({})));
41
- }
42
- function parseEnvNonNegativeInt(env, name) {
43
- const raw = env.get(name);
44
- if (!raw)
45
- return undefined;
46
- try {
47
- return Schema.decodeUnknownSync(Schema.NumberFromString.pipe(Schema.int(), Schema.nonNegative()))(raw);
48
- }
49
- catch {
50
- return undefined;
51
- }
52
- }
53
- function parseEnvNonEmptyString(env, name) {
54
- const raw = env.get(name);
55
- if (!raw)
56
- return undefined;
57
- try {
58
- return Schema.decodeUnknownSync(Schema.Trim.pipe(Schema.nonEmptyString()))(raw);
59
- }
60
- catch {
61
- return undefined;
62
- }
63
- }
64
- function resolveHistoryConfig(env, cfg) {
65
- const historyPath = parseEnvNonEmptyString(env, "QUEOT_HISTORY_PATH") ??
66
- cfg.historyPath ??
67
- path.join(getConfigHome(env), "queot", "history.jsonl");
68
- const historyMaxEntriesRaw = parseEnvNonNegativeInt(env, "QUEOT_HISTORY_MAX_ENTRIES") ??
69
- cfg.historyMaxEntries ??
70
- 500;
71
- const historyMaxBytesRaw = parseEnvNonNegativeInt(env, "QUEOT_HISTORY_MAX_BYTES") ??
72
- cfg.historyMaxBytes ??
73
- 5 * 1024 * 1024;
74
- return {
75
- historyPath,
76
- historyMaxEntries: clampInt(historyMaxEntriesRaw, 1, 50_000),
77
- historyMaxBytes: clampInt(historyMaxBytesRaw, 1, 1024 * 1024 * 1024),
78
- };
79
- }
80
- export const RuntimeConfigLive = Layer.effect(RuntimeConfig, Effect.gen(function* () {
81
- const env = yield* Env;
82
- const cfg = yield* loadQueotConfig(env);
83
- return {
84
- history: resolveHistoryConfig(env, cfg),
85
- nodeEnv: env.get("NODE_ENV"),
86
- };
87
- }));
88
- /**
89
- * 起動時に1回だけ `process.env` を読む用途。
90
- * - Effect/Layer を使わずに `RuntimeConfig` のサービス値だけ欲しいケース(CLI起動など)向け
91
- */
92
- export async function loadRuntimeConfigFromEnv(env) {
93
- const envSvc = {
94
- get: (name) => env[name],
95
- };
96
- const cfg = await Effect.runPromise(loadQueotConfig(envSvc));
97
- return {
98
- history: resolveHistoryConfig(envSvc, cfg),
99
- nodeEnv: envSvc.get("NODE_ENV"),
100
- };
101
- }
package/dist/infra/env.js DELETED
@@ -1,8 +0,0 @@
1
- import { Context, Layer } from "effect";
2
- export class Env extends Context.Tag("Env")() {
3
- }
4
- export function EnvLive(env) {
5
- return Layer.succeed(Env, {
6
- get: (name) => env[name],
7
- });
8
- }
@@ -1,201 +0,0 @@
1
- import fs from "node:fs/promises";
2
- import path from "node:path";
3
- import { createReadStream } from "node:fs";
4
- import readline from "node:readline";
5
- import { randomUUID } from "node:crypto";
6
- import { Context, Effect, Layer } from "effect";
7
- import { diffHasChanges } from "../services/diff.js";
8
- import { RuntimeConfig } from "./config.js";
9
- export class HistoryStore extends Context.Tag("HistoryStore")() {
10
- }
11
- function clampInt(v, min, max) {
12
- if (!Number.isFinite(v))
13
- return min;
14
- return Math.max(min, Math.min(max, Math.trunc(v)));
15
- }
16
- function toSummary(entry) {
17
- const qA = entry.request.queryA ?? entry.response.queryA ?? "";
18
- const qB = entry.request.queryB ?? entry.response.queryB ?? "";
19
- const hasBothResults = Boolean(entry.response.resultA && entry.response.resultB);
20
- // 期待仕様:
21
- // - A/B片側しかなく diff が取れない場合は true(= 要確認)
22
- // - diff がある場合は true
23
- // - diff がない場合は false
24
- const hasDiffChanges = typeof entry.response.hasDiffChanges === "boolean"
25
- ? entry.response.hasDiffChanges
26
- : hasBothResults
27
- ? diffHasChanges(entry.response.diff ?? entry.response.planDiff)
28
- : qA.trim().length > 0 || qB.trim().length > 0
29
- ? true
30
- : undefined;
31
- return {
32
- id: entry.id,
33
- timestamp: entry.timestamp,
34
- queryA: qA,
35
- queryB: qB,
36
- planMode: entry.request.planMode,
37
- hasError: Boolean(entry.response.errorA || entry.response.errorB),
38
- hasDiffChanges,
39
- };
40
- }
41
- export function newHistoryEntry(args) {
42
- return {
43
- id: randomUUID(),
44
- timestamp: new Date().toISOString(),
45
- request: {
46
- queryA: args.queryA,
47
- queryB: args.queryB,
48
- planMode: args.planMode,
49
- },
50
- response: args.response,
51
- };
52
- }
53
- async function trimHistoryIfNeeded(p, retention) {
54
- const { maxBytes, maxEntries } = retention;
55
- const st = await fs.stat(p).catch(() => undefined);
56
- if (!st)
57
- return;
58
- if (st.size <= maxBytes)
59
- return;
60
- // 最新maxEntries行だけ残す
61
- const buf = [];
62
- for await (const line of readLines(p)) {
63
- const trimmed = line.trim();
64
- if (!trimmed)
65
- continue;
66
- buf.push(trimmed);
67
- if (buf.length > maxEntries)
68
- buf.shift();
69
- }
70
- const tmp = `${p}.tmp-${randomUUID()}`;
71
- await fs.writeFile(tmp, `${buf.join("\n")}\n`, "utf8");
72
- await fs.rename(tmp, p);
73
- }
74
- export function makeHistoryStore(cfg) {
75
- const retention = {
76
- // 大きくなりすぎた時だけ compact して「最新N件」を残す(通常時は append のみで高速)
77
- maxEntries: cfg.history.historyMaxEntries,
78
- maxBytes: cfg.history.historyMaxBytes,
79
- };
80
- const historyPath = cfg.history.historyPath;
81
- return {
82
- getHistoryPath: Effect.succeed(historyPath),
83
- appendHistory: (entry) => Effect.tryPromise({
84
- try: async () => {
85
- await fs.mkdir(path.dirname(historyPath), { recursive: true });
86
- await fs.appendFile(historyPath, `${JSON.stringify(entry)}\n`, "utf8");
87
- // best-effort: 履歴肥大化時に古いものから削除(失敗しても本処理は壊さない)
88
- try {
89
- await trimHistoryIfNeeded(historyPath, retention);
90
- }
91
- catch {
92
- // ignore
93
- }
94
- },
95
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
96
- }).pipe(Effect.orDie),
97
- readHistorySummaries: (opts) => Effect.tryPromise({
98
- try: async () => {
99
- const limit = clampInt(opts?.limit ?? 50, 1, 200);
100
- const id = opts?.id?.trim() || undefined;
101
- const timestamp = opts?.timestamp?.trim() || undefined;
102
- await fs.stat(historyPath).catch(() => undefined);
103
- // 最新N件だけ欲しいケースが多いので、リングバッファで保持する
104
- const buf = [];
105
- try {
106
- for await (const line of readLines(historyPath)) {
107
- const entry = parseEntry(line);
108
- if (!entry)
109
- continue;
110
- if (id && entry.id !== id)
111
- continue;
112
- if (timestamp && entry.timestamp !== timestamp)
113
- continue;
114
- buf.push(toSummary(entry));
115
- if (!id && !timestamp && buf.length > limit)
116
- buf.shift();
117
- }
118
- }
119
- catch {
120
- // file not found / read error → empty
121
- return [];
122
- }
123
- // 新しい順で返す
124
- return buf.reverse().slice(0, limit);
125
- },
126
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
127
- }).pipe(Effect.orDie),
128
- readHistoryById: (id) => Effect.tryPromise({
129
- try: async () => {
130
- const needle = id.trim();
131
- if (!needle)
132
- return undefined;
133
- await fs.stat(historyPath).catch(() => undefined);
134
- let found = undefined;
135
- try {
136
- for await (const line of readLines(historyPath)) {
137
- const entry = parseEntry(line);
138
- if (!entry)
139
- continue;
140
- if (entry.id === needle)
141
- found = entry;
142
- }
143
- }
144
- catch {
145
- return undefined;
146
- }
147
- return found;
148
- },
149
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
150
- }).pipe(Effect.orDie),
151
- };
152
- }
153
- export const HistoryStoreLive = Layer.effect(HistoryStore, Effect.gen(function* () {
154
- const cfg = yield* RuntimeConfig;
155
- return makeHistoryStore(cfg);
156
- }));
157
- async function* readLines(p) {
158
- const stream = createReadStream(p, { encoding: "utf8" });
159
- const rl = readline.createInterface({
160
- input: stream,
161
- crlfDelay: Infinity,
162
- });
163
- try {
164
- for await (const line of rl) {
165
- yield line;
166
- }
167
- }
168
- finally {
169
- rl.close();
170
- stream.destroy();
171
- }
172
- }
173
- function parseEntry(line) {
174
- const trimmed = line.trim();
175
- if (!trimmed)
176
- return undefined;
177
- try {
178
- const v = JSON.parse(trimmed);
179
- if (!v || typeof v !== "object")
180
- return undefined;
181
- if (typeof v.id !== "string")
182
- return undefined;
183
- if (typeof v.timestamp !== "string")
184
- return undefined;
185
- const req = (v.request ?? {});
186
- const planMode = req.planMode === "analyze" ? "analyze" : "explain";
187
- return {
188
- id: v.id,
189
- timestamp: v.timestamp,
190
- request: {
191
- queryA: typeof req.queryA === "string" ? req.queryA : "",
192
- queryB: typeof req.queryB === "string" ? req.queryB : "",
193
- planMode,
194
- },
195
- response: (v.response ?? {}),
196
- };
197
- }
198
- catch {
199
- return undefined;
200
- }
201
- }
@@ -1,27 +0,0 @@
1
- import { Effect, Schema } from "effect";
2
- import { QueryResultSchema, } from "../../services/query.js";
3
- function mapPgFields(fields) {
4
- return fields.map((f) => ({
5
- name: f.name,
6
- // pgの型情報は dataTypeID(number) 等なので、ここでは文字列化して返す
7
- type: String(f.dataTypeID),
8
- }));
9
- }
10
- function mapPgResult(result) {
11
- return Schema.decodeUnknownSync(QueryResultSchema)({
12
- rows: result.rows,
13
- fields: mapPgFields(result.fields),
14
- });
15
- }
16
- /**
17
- * `pg` の `Client` / `PoolClient` を、Effectの `Queryable` サービスに変換する。
18
- * `createApp` へ注入して `provideService(Queryable, ...)` する想定。
19
- */
20
- export function makePgQueryable(pg) {
21
- return {
22
- execute: (query) => Effect.tryPromise({
23
- try: async () => mapPgResult(await pg.query(query)),
24
- catch: (e) => (e instanceof Error ? e : new Error(String(e))),
25
- }),
26
- };
27
- }
@@ -1,157 +0,0 @@
1
- import { Hono } from "hono";
2
- import { Effect, Schema } from "effect";
3
- import * as Either from "effect/Either";
4
- import { executeQuery, Queryable, } from "../services/query.js";
5
- import { createDiffSheet, diffHasChanges, } from "../services/diff.js";
6
- import { parseExplainFromQueryResult } from "../services/plan.js";
7
- import { newHistoryEntry, HistoryStore } from "../infra/history.js";
8
- import { HistoriesQuerySchema, RunRequestSchema } from "./schemas.js";
9
- /**
10
- * 1つのクエリを実行する。
11
- * @param query - 実行するクエリ
12
- * @returns 実行結果のEffect
13
- */
14
- function runOne(query) {
15
- const trimmed = query.trim();
16
- if (trimmed.length === 0)
17
- return Effect.succeed({
18
- result: undefined,
19
- error: "EMPTY_QUERY",
20
- });
21
- return executeQuery(trimmed).pipe(Effect.map((result) => ({
22
- result,
23
- error: undefined,
24
- })), Effect.catchAll((e) => Effect.succeed({
25
- result: undefined,
26
- error: e instanceof Error ? e.message : String(e),
27
- })));
28
- }
29
- function parsePlanSafe(planQueryResult) {
30
- if (!planQueryResult)
31
- return { plan: undefined, error: undefined };
32
- try {
33
- return {
34
- plan: parseExplainFromQueryResult(planQueryResult),
35
- error: undefined,
36
- };
37
- }
38
- catch (e) {
39
- return {
40
- plan: undefined,
41
- error: e instanceof Error ? e.message : String(e),
42
- };
43
- }
44
- }
45
- function computeHasDiffChanges(args) {
46
- const hasBoth = Boolean(args.resultA && args.resultB);
47
- if (hasBoth)
48
- return diffHasChanges(args.diff);
49
- // 片側しか結果が無く diff が取れない場合は「差分あり(= 要確認)」扱いにする
50
- const hasAnyQuery = args.queryA.trim().length > 0 || args.queryB.trim().length > 0;
51
- return hasAnyQuery ? true : undefined;
52
- }
53
- export function createApiRoute(deps) {
54
- const api = new Hono();
55
- api.get("/histories", async (c) => {
56
- const decoded = Schema.decodeUnknownEither(HistoriesQuerySchema)({
57
- limit: c.req.query("limit"),
58
- id: c.req.query("id"),
59
- timestamp: c.req.query("timestamp"),
60
- });
61
- if (Either.isLeft(decoded)) {
62
- return c.json({ error: "BAD_REQUEST" }, 400);
63
- }
64
- const items = await Effect.runPromise(Effect.gen(function* () {
65
- const history = yield* HistoryStore;
66
- return yield* history.readHistorySummaries(decoded.right);
67
- }).pipe(Effect.provide(deps.context)));
68
- const res = { items };
69
- return c.json(res);
70
- });
71
- api.get("/histories/:id", async (c) => {
72
- const id = c.req.param("id");
73
- const item = await Effect.runPromise(Effect.gen(function* () {
74
- const history = yield* HistoryStore;
75
- return yield* history.readHistoryById(id);
76
- }).pipe(Effect.provide(deps.context)));
77
- if (!item)
78
- return c.json({ item: undefined }, 404);
79
- return c.json({ item });
80
- });
81
- api.post("/run", async (c) => {
82
- const rawBody = (await c.req.json().catch(() => ({})));
83
- const decoded = Schema.decodeUnknownEither(RunRequestSchema)(rawBody);
84
- if (Either.isLeft(decoded)) {
85
- return c.json({ error: "BAD_REQUEST" }, 400);
86
- }
87
- const body = decoded.right;
88
- const queryA = body.queryA ?? "";
89
- const queryB = body.queryB ?? "";
90
- const planMode = body.planMode ?? "explain";
91
- let planPrefix = "EXPLAIN (FORMAT JSON";
92
- if (planMode === "analyze") {
93
- planPrefix += ", ANALYZE true";
94
- }
95
- planPrefix += ")";
96
- // 1つのトランザクションで2つのクエリを実行する。
97
- const program = Effect.gen(function* () {
98
- const connection = yield* Queryable;
99
- return yield* Effect.acquireUseRelease(connection.execute("BEGIN"), () => {
100
- return Effect.gen(function* () {
101
- const a = yield* runOne(queryA);
102
- const aPlan = yield* runOne(`${planPrefix} ${queryA}`);
103
- const b = yield* runOne(queryB);
104
- const bPlan = yield* runOne(`${planPrefix} ${queryB}`);
105
- return { a, aPlan, b, bPlan };
106
- });
107
- }, () => connection
108
- .execute("ROLLBACK")
109
- .pipe(Effect.catchAll(() => Effect.void)));
110
- });
111
- const { a, aPlan, b, bPlan } = await Effect.runPromise(program.pipe(Effect.provide(deps.context)));
112
- const diff = a.result && b.result ? createDiffSheet(a.result, b.result) : undefined;
113
- const aPlanParsed = parsePlanSafe(aPlan.result);
114
- const bPlanParsed = parsePlanSafe(bPlan.result);
115
- const res = {
116
- queryA,
117
- queryB,
118
- planMode,
119
- resultA: a.result,
120
- resultB: b.result,
121
- errorA: a.error,
122
- errorB: b.error,
123
- diff,
124
- hasDiffChanges: computeHasDiffChanges({
125
- queryA,
126
- queryB,
127
- resultA: a.result,
128
- resultB: b.result,
129
- diff,
130
- }),
131
- planQueryResultA: aPlan.result,
132
- planQueryResultB: bPlan.result,
133
- planResultA: aPlanParsed.plan,
134
- planResultB: bPlanParsed.plan,
135
- planErrorA: aPlanParsed.error ?? aPlan.error,
136
- planErrorB: bPlanParsed.error ?? bPlan.error,
137
- planDiff: undefined,
138
- };
139
- // 履歴は「失敗してもレスポンスを壊さない」方針で、best-effort で追記する
140
- try {
141
- await Effect.runPromise(Effect.gen(function* () {
142
- const history = yield* HistoryStore;
143
- yield* history.appendHistory(newHistoryEntry({
144
- queryA,
145
- queryB,
146
- planMode,
147
- response: res,
148
- }));
149
- }).pipe(Effect.provide(deps.context)));
150
- }
151
- catch {
152
- // ignore
153
- }
154
- return c.json(res);
155
- });
156
- return api;
157
- }
@@ -1,12 +0,0 @@
1
- import { Schema } from "effect";
2
- export const PlanModeSchema = Schema.Literal("explain", "analyze");
3
- export const RunRequestSchema = Schema.Struct({
4
- queryA: Schema.optional(Schema.String),
5
- queryB: Schema.optional(Schema.String),
6
- planMode: Schema.optional(PlanModeSchema),
7
- });
8
- export const HistoriesQuerySchema = Schema.Struct({
9
- limit: Schema.optional(Schema.NumberFromString.pipe(Schema.int(), Schema.nonNegative(), Schema.clamp(1, 200))),
10
- id: Schema.optional(Schema.Trim.pipe(Schema.nonEmptyString())),
11
- timestamp: Schema.optional(Schema.Trim.pipe(Schema.nonEmptyString())),
12
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,60 +0,0 @@
1
- import daff from "daff";
2
- function cellTextForDiff(v) {
3
- if (v === null)
4
- return "null";
5
- if (v === undefined)
6
- return "";
7
- switch (typeof v) {
8
- case "string":
9
- return v;
10
- case "number":
11
- case "bigint":
12
- case "boolean":
13
- return String(v);
14
- case "symbol":
15
- return v.toString();
16
- case "function":
17
- return "[function]";
18
- case "object": {
19
- try {
20
- return JSON.stringify(v);
21
- }
22
- catch {
23
- return Object.prototype.toString.call(v);
24
- }
25
- }
26
- default:
27
- return "";
28
- }
29
- }
30
- export function diffHasChanges(diff) {
31
- const rows = diff ?? [];
32
- // DiffView と同じ判定: row差分だけでなく、カラム差分("!" 行)も「差分あり」と扱う
33
- return rows.some((row) => {
34
- const marker = cellTextForDiff(row?.[0]);
35
- return (marker === "!" || marker === "+++" || marker === "---" || marker === "->");
36
- });
37
- }
38
- function resultToSheet(result) {
39
- const headers = result.fields.map((f) => f.name);
40
- const rows = result.rows.map((r) => {
41
- const row = r;
42
- return result.fields.map((f) => cellTextForDiff(row[f.name]));
43
- });
44
- return [headers, ...rows];
45
- }
46
- export function createDiffSheet(a, b) {
47
- const sheetA = resultToSheet(a);
48
- const sheetB = resultToSheet(b);
49
- const viewA = new daff.TableView(sheetA);
50
- const viewB = new daff.TableView(sheetB);
51
- const alignment = daff.compareTables(viewA, viewB).align();
52
- const flags = new daff.CompareFlags();
53
- const highlighter = new daff.TableDiff(alignment, flags);
54
- const out = new daff.TableView([]);
55
- highlighter.hilite(out);
56
- const json = daff.jsonify(out);
57
- // daff.jsonify(out).h.sheet は (string|number|boolean|null|undefined)[][] になりうるが、
58
- // 本アプリでは文字列として表示するため、必要に応じて JSX 側で string 化する。
59
- return (json?.h?.sheet ?? []);
60
- }
@@ -1,35 +0,0 @@
1
- import { parseExplainAnalyzeJson, } from "@sun-yryr/queot-planparser";
2
- function normKey(k) {
3
- return k
4
- .toLowerCase()
5
- .replace(/[_\s]+/g, " ")
6
- .trim();
7
- }
8
- function pickRowValue(row, fieldName) {
9
- if (fieldName in row)
10
- return row[fieldName];
11
- const want = normKey(fieldName);
12
- for (const k of Object.keys(row)) {
13
- if (normKey(k) === want)
14
- return row[k];
15
- }
16
- return undefined;
17
- }
18
- /**
19
- * Postgres の EXPLAIN (FORMAT JSON ...) のクエリ結果(QueryResult) から、
20
- * planparser 用の入力(JSON)を取り出して ExplainParseResult に変換する。
21
- */
22
- export function parseExplainFromQueryResult(result) {
23
- if (!result.fields?.length || !result.rows?.length) {
24
- return parseExplainAnalyzeJson([]);
25
- }
26
- const field = result.fields.find((f) => normKey(f.name) === "query plan") ??
27
- result.fields.find((f) => ["json", "jsonb"].includes(f.type?.toLowerCase?.() ?? "")) ??
28
- result.fields[0];
29
- const row0 = result.rows[0];
30
- const value = pickRowValue(row0, field.name);
31
- if (value === undefined) {
32
- throw new Error(`EXPLAIN JSON column not found (field: ${field.name})`);
33
- }
34
- return parseExplainAnalyzeJson(value);
35
- }
@@ -1,19 +0,0 @@
1
- import { Effect, Context, Schema } from "effect";
2
- const FieldSchema = Schema.Struct({
3
- name: Schema.String,
4
- type: Schema.String,
5
- });
6
- const RowSchema = Schema.Record({
7
- key: Schema.String,
8
- value: Schema.Unknown,
9
- });
10
- export const QueryResultSchema = Schema.Struct({
11
- rows: Schema.Array(RowSchema),
12
- fields: Schema.Array(FieldSchema),
13
- });
14
- export class Queryable extends Context.Tag("Queryable")() {
15
- }
16
- export const executeQuery = (query) => Effect.gen(function* () {
17
- const connection = yield* Queryable;
18
- return yield* connection.execute(query);
19
- });